From becaeadca5311296868e4229b4d6ff4daac1dc1f Mon Sep 17 00:00:00 2001 From: ryuichi1208 Date: Mon, 22 Jun 2026 12:44:49 +0900 Subject: [PATCH 1/3] feat(parser): parse `from x import y [as w]` Add KW_FROM keyword and parse from-imports into Import.names as a list of (name, alias) pairs. Sema resolution lands in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- rwc/lexer.py | 2 ++ rwc/parser.py | 26 +++++++++++++++++++++++--- tests/test_parser.py | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/rwc/lexer.py b/rwc/lexer.py index b1c9189..0be1e23 100644 --- a/rwc/lexer.py +++ b/rwc/lexer.py @@ -73,6 +73,7 @@ class TokenKind(Enum): KW_FOR = auto() KW_IN = auto() KW_AS = auto() + KW_FROM = auto() KW_TYPE = auto() # Punctuation / operators @@ -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, } diff --git a/rwc/parser.py b/rwc/parser.py index 002b733..dde983c 100644 --- a/rwc/parser.py +++ b/rwc/parser.py @@ -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'", @@ -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: @@ -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'") diff --git a/tests/test_parser.py b/tests/test_parser.py index 0bfb4db..c3bc45b 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -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) From e501462eda4235443589137200c6790946cbbf19 Mon Sep 17 00:00:00 2001 From: ryuichi1208 Date: Mon, 22 Jun 2026 12:45:46 +0900 Subject: [PATCH 2/3] feat(sema): resolve `from x import y` bindings Build a per-module from_env (local name -> (module, name)) and resolve unqualified calls against it. Detect collisions with local functions, builtins, and duplicate imported names. irgen is unchanged: resolution flows through call_resolution as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- rwc/sema.py | 44 ++++++++++++++++++++++++++++++++++++++- tests/test_sema.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/rwc/sema.py b/rwc/sema.py index 22ccc0b..cad1103 100644 --- a/rwc/sema.py +++ b/rwc/sema.py @@ -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) @@ -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)] @@ -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) diff --git a/tests/test_sema.py b/tests/test_sema.py index 123a260..6f723aa 100644 --- a/tests/test_sema.py +++ b/tests/test_sema.py @@ -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 From 749873b0655754cf035a2f14b7aea0e072b803c2 Mon Sep 17 00:00:00 2001 From: ryuichi1208 Date: Mon, 22 Jun 2026 12:46:43 +0900 Subject: [PATCH 3/3] test(e2e): from-import example and coverage examples/import_from.rw exercises `from x import a, b as c` against the existing import_basic_lib. Add it to the e2e parametrize list plus a lexer test for the `from` keyword. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/import_from.rw | 6 ++++++ examples/import_from.rw.expected | 2 ++ tests/test_e2e.py | 2 +- tests/test_lexer.py | 8 ++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 examples/import_from.rw create mode 100644 examples/import_from.rw.expected diff --git a/examples/import_from.rw b/examples/import_from.rw new file mode 100644 index 0000000..74e84c3 --- /dev/null +++ b/examples/import_from.rw @@ -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 diff --git a/examples/import_from.rw.expected b/examples/import_from.rw.expected new file mode 100644 index 0000000..6016cbb --- /dev/null +++ b/examples/import_from.rw.expected @@ -0,0 +1,2 @@ +5 +20 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1803b70..6fa838b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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" diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 44b3ef9..dae183f 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -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