Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/import_from.rw
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from import_basic_lib import add, mul as times

def main() -> int:
print(add(2, 3))
print(times(4, 5))
return 0
2 changes: 2 additions & 0 deletions examples/import_from.rw.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
5
20
2 changes: 2 additions & 0 deletions rwc/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class TokenKind(Enum):
KW_FOR = auto()
KW_IN = auto()
KW_AS = auto()
KW_FROM = auto()
KW_TYPE = auto()

# Punctuation / operators
Expand Down Expand Up @@ -143,6 +144,7 @@ class TokenKind(Enum):
"for": TokenKind.KW_FOR,
"in": TokenKind.KW_IN,
"as": TokenKind.KW_AS,
"from": TokenKind.KW_FROM,
"type": TokenKind.KW_TYPE,
}

Expand Down
26 changes: 23 additions & 3 deletions rwc/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def parse_module(self) -> A.Module:
mod = A.Module()
self.skip_newlines()
while self.cur.kind != TokenKind.EOF:
if self.cur.kind == TokenKind.KW_IMPORT:
if self.cur.kind in (TokenKind.KW_IMPORT, TokenKind.KW_FROM):
if mod.functions or mod.type_aliases:
raise ParserError(
"import must appear before any 'def' or 'type'",
Expand All @@ -156,8 +156,9 @@ def parse_module(self) -> A.Module:

# ------- imports -------
def parse_import(self) -> A.Import:
# PR1: plain `import IDENT NEWLINE` only. `import x as m` (PR3) and
# `from x import y` (PR2) are not yet supported.
if self.cur.kind == TokenKind.KW_FROM:
return self._parse_from_import()
# Plain `import IDENT NEWLINE`. `import x as m` (PR3) not yet supported.
kw = self.eat(TokenKind.KW_IMPORT, "'import'")
name_tok = self.eat(TokenKind.IDENT, "module name after 'import'")
if self.cur.kind == TokenKind.KW_AS:
Expand All @@ -170,6 +171,25 @@ def parse_import(self) -> A.Import:
self.eat(TokenKind.NEWLINE)
return A.Import(name_tok.value, None, None, kw.line, kw.col)

def _parse_from_import(self) -> A.Import:
# `from IDENT import IDENT [as IDENT] { ',' IDENT [as IDENT] }`
kw = self.eat(TokenKind.KW_FROM, "'from'")
mod_tok = self.eat(TokenKind.IDENT, "module name after 'from'")
self.eat(TokenKind.KW_IMPORT, "'import' after module name")
names: List[tuple] = [self._parse_import_name()]
while self.match(TokenKind.COMMA):
names.append(self._parse_import_name())
self.eat(TokenKind.NEWLINE)
return A.Import(mod_tok.value, None, names, kw.line, kw.col)

def _parse_import_name(self) -> tuple:
"""Parse `IDENT [as IDENT]`, returning (name, alias|None)."""
name_tok = self.eat(TokenKind.IDENT, "imported name")
alias = None
if self.match(TokenKind.KW_AS):
alias = self.eat(TokenKind.IDENT, "alias after 'as'").value
return (name_tok.value, alias)

# ------- type aliases -------
def parse_type_alias(self) -> A.TypeAlias:
kw = self.eat(TokenKind.KW_TYPE, "'type'")
Expand Down
44 changes: 43 additions & 1 deletion rwc/sema.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ def __init__(self, program: LoadedProgram, filename: str) -> None:
# Module names visible to the current module via `import`.
# PR1: a module can call `m.f()` only for an `m` it imported.
self._visible_imports: set[str] = set()
# Names brought into the current module via `from x import y [as w]`:
# local name -> resolved (module, name). PR2.
self._from_env: Dict[str, FuncKey] = {}
# All (module, A.Module) pairs in load order: entry first, then imports.
self._all_modules: List[Tuple[Optional[str], A.Module]] = [
(None, program.root)
Expand Down Expand Up @@ -176,15 +179,51 @@ def analyze(self) -> SemaResult:
))

# Second pass: check each function body, module by module, so that
# `current_module` / visible imports are scoped correctly.
# `current_module` / visible imports / from-bindings are scoped correctly.
for mod_name, mod in self._all_modules:
self.current_module = mod_name
self._visible_imports = {imp.module for imp in mod.imports}
self._from_env = self._build_from_env(mod_name, mod)
for fn in mod.functions:
self._check_func(fn)

return self.result

def _build_from_env(self, mod_name: Optional[str], mod: A.Module) -> Dict[str, FuncKey]:
"""Resolve `from x import y [as w]` bindings for one module: local name
-> (source_module, source_name). Detects collisions with local
functions, builtins, and other from-bindings."""
env: Dict[str, FuncKey] = {}
local_names = {fn.name for fn in mod.functions}
for imp in mod.imports:
if imp.names is None:
continue # plain `import x` / `import x as m`
for src_name, alias in imp.names:
local = alias or src_name
src_key: FuncKey = (imp.module, src_name)
if src_key not in self.result.functions:
raise CompileError(Diagnostic(
self.filename, imp.line, imp.col, max(1, len(src_name)),
f"module '{imp.module}' has no function `{src_name}`",
))
if local in _BUILTIN_FUNCS:
raise CompileError(Diagnostic(
self.filename, imp.line, imp.col, max(1, len(local)),
f"imported name `{local}` collides with a builtin",
))
if local in local_names:
raise CompileError(Diagnostic(
self.filename, imp.line, imp.col, max(1, len(local)),
f"imported name `{local}` collides with a local function",
))
if local in env:
raise CompileError(Diagnostic(
self.filename, imp.line, imp.col, max(1, len(local)),
f"duplicate imported name `{local}`",
))
env[local] = src_key
return env

# ---------- function body ----------
def _check_func(self, fn: A.FuncDef) -> None:
sig = self.result.functions[(self.current_module, fn.name)]
Expand Down Expand Up @@ -579,6 +618,9 @@ def _resolve_user_func(self, call: A.Call) -> FuncKey:
self.filename, call.line, call.col, len(call.callee),
f"module '{call.module}' has no function `{call.callee}`",
))
elif call.callee in self._from_env:
# Unqualified call to a `from x import y` name.
key = self._from_env[call.callee]
else:
# Unqualified call: a function in the current module.
key = (self.current_module, call.callee)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _build_and_run(rw_path: Path) -> tuple[int, str]:

@pytest.mark.parametrize(
"name",
["hello", "arith", "fib", "while_count", "spawn_basic", "spawn_many", "spawn_string", "string_ops", "bytes_basic", "list_basic", "option_basic", "result_basic", "for_count", "ternary", "file_io", "file_par", "num_literals", "bitops", "break_continue", "assert_ok", "type_alias", "math_basic", "import_basic"],
["hello", "arith", "fib", "while_count", "spawn_basic", "spawn_many", "spawn_string", "string_ops", "bytes_basic", "list_basic", "option_basic", "result_basic", "for_count", "ternary", "file_io", "file_par", "num_literals", "bitops", "break_continue", "assert_ok", "type_alias", "math_basic", "import_basic", "import_from"],
)
def test_synchronous_example(name: str):
rw_path = EXAMPLES / f"{name}.rw"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,11 @@ def test_dot_does_not_break_float_literals():
assert toks[0].kind == TokenKind.FLOAT and toks[0].value == "1.5"
assert toks[1].kind == TokenKind.FLOAT and toks[1].value == "3.14e2"
assert TokenKind.DOT not in [t.kind for t in toks]


def test_from_and_as_keywords():
toks = tokenize("from lib import add as a\n")
kinds_list = [t.kind for t in toks]
assert TokenKind.KW_FROM in kinds_list
assert TokenKind.KW_IMPORT in kinds_list
assert TokenKind.KW_AS in kinds_list
23 changes: 23 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,26 @@ def test_import_as_not_yet_supported():
src = "import math_lib as m\n\ndef main() -> int:\n return 0\n"
with pytest.raises(ParserError):
parse_src(src)


def test_parse_from_import_single():
src = "from math_lib import add\n\ndef main() -> int:\n return add(1, 2)\n"
mod = parse_src(src)
assert len(mod.imports) == 1
imp = mod.imports[0]
assert imp.module == "math_lib"
assert imp.alias is None
assert imp.names == [("add", None)]


def test_parse_from_import_multiple_with_alias():
src = "from math_lib import add, mul as m\n\ndef main() -> int:\n return add(1, m(2, 3))\n"
mod = parse_src(src)
imp = mod.imports[0]
assert imp.names == [("add", None), ("mul", "m")]


def test_from_import_after_def_is_error():
src = "def main() -> int:\n return 0\n\nfrom lib import add\n"
with pytest.raises(ParserError):
parse_src(src)
51 changes: 51 additions & 0 deletions tests/test_sema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1434,3 +1434,54 @@ def test_qualified_arg_type_mismatch_errors(tmp_path):
lib="def add(a: int, b: int) -> int:\n return a + b\n",
)
assert "argument 1" in ei.value.diagnostic.message


# ----- from-imports (spec 17, PR2) -----

def test_from_import_resolves_unqualified(tmp_path):
res = _check_program(
tmp_path,
"from lib import add\n\ndef main() -> int:\n return add(1, 2)\n",
lib="def add(a: int, b: int) -> int:\n return a + b\n",
)
assert (None, "main") in res.functions
assert ("lib", "add") in res.functions


def test_from_import_alias(tmp_path):
res = _check_program(
tmp_path,
"from lib import add as plus\n\ndef main() -> int:\n return plus(1, 2)\n",
lib="def add(a: int, b: int) -> int:\n return a + b\n",
)
assert ("lib", "add") in res.functions


def test_from_import_unknown_name_errors(tmp_path):
with pytest.raises(CompileError) as ei:
_check_program(
tmp_path,
"from lib import nope\n\ndef main() -> int:\n return nope()\n",
lib="def add(a: int, b: int) -> int:\n return a + b\n",
)
assert "no function" in ei.value.diagnostic.message


def test_from_import_collides_with_local(tmp_path):
with pytest.raises(CompileError) as ei:
_check_program(
tmp_path,
"from lib import add\n\ndef add() -> int:\n return 9\n\ndef main() -> int:\n return add(1, 2)\n",
lib="def add(a: int, b: int) -> int:\n return a + b\n",
)
assert "collides with a local function" in ei.value.diagnostic.message


def test_from_import_collides_with_builtin(tmp_path):
with pytest.raises(CompileError) as ei:
_check_program(
tmp_path,
"from lib import print\n\ndef main() -> int:\n return 0\n",
lib="def print(a: int) -> int:\n return a\n",
)
assert "collides with a builtin" in ei.value.diagnostic.message
Loading