Skip to content

Commit 7115b7a

Browse files
olwangclaude
andcommitted
rsscript: module glob import, qualified variant patterns; verify value access
Finishes the three docs/requests module items: 1. module-value-access (R2) — was a false alarm: qualified `module.CONST` / `module.Variant` value access already works through the *merged* semantic checker (isolation runs before body checks). The reopened RS0026 came from single-file `rss check`, which can't resolve ANY cross-module reference (even `use ops.Ops` fails the same way). Added a checker-level regression test (analyze_sources_with_interfaces) and corrected the doc. 2. module-glob-import — `use module.*` now parses (no RS0015) and imports every bare-referenceable module symbol (functions, consts, types, sums, aliases) into the file scope; variants resolve globally via their sum type. Expanded in a second pass once the module symbol tables are complete. 3. module-qualified-variant-pattern — the pattern parser accepts a dotted head (`ops.ADD`, `a.b.Variant`); isolation rewrites it to the bare variant (and `module.Type { .. }` payload patterns to the mangled type). Each verified through the semantic checker on a merged multi-file program (three new tests in checker_frontend). Still deferred: cross-module same-named-variant disambiguation (needs variant namespacing). lib 111 / frontend 197 / lowering 225 / vm_eval 17 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a7c4268 commit 7115b7a

8 files changed

Lines changed: 251 additions & 45 deletions

File tree

crates/rsscript/src/formatter.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ impl Formatter {
124124
Item::Use(u) => {
125125
self.out.push_str("use ");
126126
self.out.push_str(&u.path.join("."));
127-
if let Some(alias) = &u.alias {
127+
if u.glob {
128+
self.out.push_str(".*");
129+
} else if let Some(alias) = &u.alias {
128130
self.out.push_str(" as ");
129131
self.out.push_str(alias);
130132
}

crates/rsscript/src/syntax/ast.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,20 @@ pub struct UseDecl {
145145
/// Optional `as <alias>` local name. When present, the import is referenced
146146
/// in this file by the alias instead of the path's last segment.
147147
pub alias: Option<String>,
148+
/// `use module.*` — import every public symbol of the module named by `path`.
149+
/// A glob has no single local name and cannot carry an alias.
150+
pub glob: bool,
148151
pub span: Span,
149152
}
150153

151154
impl UseDecl {
152155
/// The local name this import is referenced by in its file: the alias when
153-
/// present, otherwise the path's last segment.
156+
/// present, otherwise the path's last segment. A glob import has no single
157+
/// local name.
154158
pub fn local_name(&self) -> Option<&str> {
159+
if self.glob {
160+
return None;
161+
}
155162
self.alias
156163
.as_deref()
157164
.or_else(|| self.path.last().map(String::as_str))

crates/rsscript/src/syntax/module_isolation.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ impl Resolver {
100100
let mut module_consts: HashMap<String, HashSet<String>> = HashMap::new();
101101
let mut module_variants: HashMap<String, HashSet<String>> = HashMap::new();
102102
let mut file_imports: HashMap<String, HashMap<String, (String, String)>> = HashMap::new();
103+
// (file, module prefix) for each `use module.*`, expanded after the loop.
104+
let mut glob_imports: Vec<(String, String)> = Vec::new();
103105

104106
for item in &program.items {
105107
let Some(file) = item_file(item) else {
@@ -168,6 +170,12 @@ impl Resolver {
168170
.or_default()
169171
.insert(decl.name.clone());
170172
}
173+
Item::Use(decl) if decl.glob => {
174+
// `use module.*` imports every symbol of `module`. The module's
175+
// symbol table isn't fully populated until the loop finishes,
176+
// so record the glob and expand it in a second pass below.
177+
glob_imports.push((decl.span.file.clone(), module_prefix(&decl.path)));
178+
}
171179
Item::Use(decl) => {
172180
if decl.path.len() >= 2 {
173181
let real_name = decl.path[decl.path.len() - 1].clone();
@@ -186,6 +194,23 @@ impl Resolver {
186194
}
187195
}
188196

197+
// Expand `use module.*` globs now that every module's symbol table is
198+
// complete: import each of the module's bare-referenceable names (free
199+
// functions, constants, types, sums, and aliases — everything in
200+
// `module_defs`) under its own name. Sum variants resolve globally through
201+
// their sum type and need no import.
202+
for (file, prefix) in glob_imports {
203+
let Some(names) = module_defs.get(&prefix) else {
204+
continue;
205+
};
206+
let entry = file_imports.entry(file).or_default();
207+
for name in names {
208+
entry
209+
.entry(name.clone())
210+
.or_insert_with(|| (prefix.clone(), name.clone()));
211+
}
212+
}
213+
189214
Self {
190215
file_module: file_module.clone(),
191216
module_defs,
@@ -507,6 +532,18 @@ impl Resolver {
507532
MatchPattern::Variant { name, binding, .. } => {
508533
if let Some(mangled) = self.resolve_bare(file, name) {
509534
*name = mangled;
535+
} else if let Some((namespace, variant)) = name.rsplit_once('.') {
536+
// `module.Variant` qualified pattern resolves to the bare
537+
// variant (variant names resolve through their sum type),
538+
// mirroring qualified value access in expression position.
539+
let prefix = module_prefix_from_dotted(namespace);
540+
if self
541+
.module_variants
542+
.get(&prefix)
543+
.is_some_and(|variants| variants.contains(variant))
544+
{
545+
*name = variant.to_string();
546+
}
510547
}
511548
if let Some(binding) = binding {
512549
self.rewrite_pattern(binding, file);
@@ -515,6 +552,22 @@ impl Resolver {
515552
MatchPattern::Struct { name, fields, .. } => {
516553
if let Some(mangled) = self.resolve_bare(file, name) {
517554
*name = mangled;
555+
} else if let Some((namespace, type_name)) = name.rsplit_once('.') {
556+
// `module.Type { .. }` qualified struct/variant-payload pattern.
557+
let prefix = module_prefix_from_dotted(namespace);
558+
if self
559+
.module_types
560+
.get(&prefix)
561+
.is_some_and(|types| types.contains(type_name))
562+
{
563+
*name = format!("{prefix}{MODULE_SEP}{type_name}");
564+
} else if self
565+
.module_variants
566+
.get(&prefix)
567+
.is_some_and(|variants| variants.contains(type_name))
568+
{
569+
*name = type_name.to_string();
570+
}
518571
}
519572
for field in fields {
520573
self.rewrite_field_pattern(field, file);

crates/rsscript/src/syntax/parser.rs

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -853,15 +853,27 @@ impl Parser<'_> {
853853
}
854854
self.index += 1;
855855
let path = self.parse_dotted_path()?;
856+
// `use module.*` glob: `parse_dotted_path` consumes the trailing `.` and
857+
// stops at `*` (not an identifier), leaving the cursor on it.
858+
let glob = self.at_symbol("*");
859+
if glob {
860+
self.index += 1;
861+
}
856862
// Optional `as <alias>` renames the import locally so a file can pull two
857-
// same-leaf symbols from different modules without collision.
858-
let alias = if self.at_ident("as") {
863+
// same-leaf symbols from different modules without collision. A glob has
864+
// no single local name, so it takes no alias.
865+
let alias = if !glob && self.at_ident("as") {
859866
self.index += 1;
860867
self.take_ident_name()
861868
} else {
862869
None
863870
};
864-
Some(UseDecl { path, alias, span })
871+
Some(UseDecl {
872+
path,
873+
alias,
874+
glob,
875+
span,
876+
})
865877
}
866878

867879
/// Parse a dot-separated path like `package.contract.PackageContract`.
@@ -2311,6 +2323,23 @@ fn split_match_pattern_guard(tokens: &[Token], start: usize, end: usize) -> Opti
23112323
None
23122324
}
23132325

2326+
/// Parse a pattern head name: a bare identifier (`ADD`) or a module-qualified
2327+
/// dotted name (`ops.ADD`, `a.b.Variant`). Returns the joined name and the token
2328+
/// index just past it.
2329+
fn parse_dotted_pattern_name(tokens: &[Token], start: usize, end: usize) -> Option<(String, usize)> {
2330+
let mut name = ident_name(tokens.get(start)?)?.to_string();
2331+
let mut index = start + 1;
2332+
while index + 1 < end
2333+
&& tokens[index].symbol(".")
2334+
&& let Some(segment) = ident_name(&tokens[index + 1])
2335+
{
2336+
name.push('.');
2337+
name.push_str(segment);
2338+
index += 2;
2339+
}
2340+
Some((name, index))
2341+
}
2342+
23142343
fn parse_match_pattern(tokens: &[Token], start: usize, end: usize) -> Option<MatchPattern> {
23152344
// A tuple pattern `(p0, p1, ..)` desugars to the synthetic `__TupleN` struct
23162345
// pattern `__TupleN { item0: p0, item1: p1, .. }`. Checked before `trim_outer`
@@ -2354,40 +2383,43 @@ fn parse_match_pattern(tokens: &[Token], start: usize, end: usize) -> Option<Mat
23542383
_ => {}
23552384
}
23562385
}
2357-
let name = ident_name(&tokens[start])?.to_string();
2386+
// The pattern head may be a bare name (`ADD`, `Some`) or a module-qualified
2387+
// variant/type (`ops.ADD`, `ops.Pair`). `head_end` is the token index just
2388+
// past the (possibly dotted) name.
2389+
let (name, head_end) = parse_dotted_pattern_name(tokens, start, end)?;
23582390
if name == "_" {
23592391
return Some(MatchPattern::Wildcard(tokens[start].span.clone()));
23602392
}
2361-
if tokens.get(start + 1).is_some_and(|token| token.symbol("{")) {
2362-
let close = find_matching(tokens, start + 1, "{", "}")?;
2393+
if tokens.get(head_end).is_some_and(|token| token.symbol("{")) {
2394+
let close = find_matching(tokens, head_end, "{", "}")?;
23632395
if close + 1 != end {
23642396
return None;
23652397
}
2366-
let (fields, has_rest) = parse_match_field_patterns(tokens, start + 2, close)?;
2398+
let (fields, has_rest) = parse_match_field_patterns(tokens, head_end + 1, close)?;
23672399
return Some(MatchPattern::Struct {
23682400
name,
23692401
fields,
23702402
has_rest,
23712403
span: tokens[start].span.clone(),
23722404
});
23732405
}
2374-
let binding = if tokens.get(start + 1).is_some_and(|token| token.symbol("(")) {
2375-
let close = find_matching(tokens, start + 1, "(", ")")?;
2406+
let binding = if tokens.get(head_end).is_some_and(|token| token.symbol("(")) {
2407+
let close = find_matching(tokens, head_end, "(", ")")?;
23762408
if close + 1 != end {
23772409
return None;
23782410
}
2379-
if start + 2 == close {
2411+
if head_end + 1 == close {
23802412
None
2381-
} else if start + 3 == close && tokens[start + 2].is_ident_text("_") {
2413+
} else if head_end + 2 == close && tokens[head_end + 1].is_ident_text("_") {
23822414
Some(Box::new(MatchPattern::Wildcard(
2383-
tokens[start + 2].span.clone(),
2415+
tokens[head_end + 1].span.clone(),
23842416
)))
2385-
} else if start + 3 == close {
2386-
parse_single_payload_pattern(tokens, start + 2)
2417+
} else if head_end + 2 == close {
2418+
parse_single_payload_pattern(tokens, head_end + 1)
23872419
} else {
2388-
parse_match_pattern(tokens, start + 2, close).map(Box::new)
2420+
parse_match_pattern(tokens, head_end + 1, close).map(Box::new)
23892421
}
2390-
} else if start + 1 == end {
2422+
} else if head_end == end {
23912423
None
23922424
} else {
23932425
return None;

crates/rsscript/tests/checker_frontend/misc.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,103 @@
22
#![allow(unused_imports, dead_code)]
33
use super::*;
44

5+
#[test]
6+
fn qualified_module_value_access_checks_clean_through_the_checker() {
7+
// Regression guard: qualified `module.CONST` / `module.Variant` in value
8+
// position must resolve through the *semantic checker* on a merged multi-file
9+
// program — not only through the lowering helper. (`module-value-access.md`.)
10+
let diagnostics = analyze_sources_with_interfaces(
11+
&[
12+
(
13+
"ops.rss",
14+
"module ops\n\nsum Ops { ADD MUL OTHER }\n\nconst MAX_OPS: Int = 64\n",
15+
),
16+
(
17+
"user.rss",
18+
concat!(
19+
"module user\n\n",
20+
"use ops.Ops\n\n",
21+
"fn c() -> fresh Ops { return ops.MUL }\n\n",
22+
"fn e() -> Int { return ops.MAX_OPS }\n",
23+
),
24+
),
25+
],
26+
&[],
27+
);
28+
assert_eq!(
29+
diagnostics,
30+
Vec::new(),
31+
"qualified module value access should check clean: {diagnostics:?}"
32+
);
33+
}
34+
35+
#[test]
36+
fn qualified_variant_in_match_pattern_checks_clean() {
37+
// `ops.ADD` / `ops.MUL` as match patterns resolve through the merged checker
38+
// (parser accepts the dotted pattern; isolation rewrites it to the bare
39+
// variant). (`module-qualified-variant-pattern.md`.)
40+
let diagnostics = analyze_sources_with_interfaces(
41+
&[
42+
("ops.rss", "module ops\n\nsum Ops { ADD MUL OTHER }\n"),
43+
(
44+
"app.rss",
45+
concat!(
46+
"module app\n\n",
47+
"use ops.Ops\n\n",
48+
"fn classify(o: read Ops) -> Int {\n",
49+
" match read o {\n",
50+
" ops.ADD => { return 1 }\n",
51+
" ops.MUL => { return 2 }\n",
52+
" _ => { return 0 }\n",
53+
" }\n",
54+
"}\n",
55+
),
56+
),
57+
],
58+
&[],
59+
);
60+
assert_eq!(
61+
diagnostics,
62+
Vec::new(),
63+
"qualified variant in match pattern should check clean: {diagnostics:?}"
64+
);
65+
}
66+
67+
#[test]
68+
fn glob_import_brings_module_symbols_into_scope() {
69+
// `use ops.*` imports the module's type, const, and functions; bare variants
70+
// resolve globally. The snippet checks clean. (`module-glob-import.md`.)
71+
let diagnostics = analyze_sources_with_interfaces(
72+
&[
73+
(
74+
"ops.rss",
75+
"module ops\n\nsum Ops { ADD MUL OTHER }\n\nconst MAX_OPS: Int = 64\n\nfn helper() -> Int { return 1 }\n",
76+
),
77+
(
78+
"app.rss",
79+
concat!(
80+
"module app\n\n",
81+
"use ops.*\n\n",
82+
"fn pick() -> fresh Ops { return ADD }\n\n",
83+
"fn lim() -> Int { return MAX_OPS }\n\n",
84+
"fn classify(o: read Ops) -> Int {\n",
85+
" match read o {\n",
86+
" ADD => { return 1 }\n",
87+
" _ => { return helper() }\n",
88+
" }\n",
89+
"}\n",
90+
),
91+
),
92+
],
93+
&[],
94+
);
95+
assert_eq!(
96+
diagnostics,
97+
Vec::new(),
98+
"glob import should bring module symbols into scope: {diagnostics:?}"
99+
);
100+
}
101+
5102
#[test]
6103
fn pass_fixtures_have_no_diagnostics() {
7104
for path in common::fixture_paths("tests/fixtures/pass") {

docs/requests/module-glob-import.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
# Feature request: glob import `use module.*`
22

3-
**Status:** requested · **Driver:** tinygrad-rsmc module de-prefixing
3+
**Status:** IMPLEMENTED · **Driver:** tinygrad-rsmc module de-prefixing
44
**Repro built on:** `rss` @ `6c6c57a`
55

6+
> **Resolution.** `use module.*` is supported. The parser accepts the `.*` glob
7+
> form (no more RS0015), and `module_isolation` imports every bare-referenceable
8+
> symbol of the module — functions, constants, types, sums, and type aliases —
9+
> into the file's scope (sum variants resolve globally through their sum type, so
10+
> they need no import). Verified through the semantic checker on a merged
11+
> multi-file program: `glob_import_brings_module_symbols_into_scope`
12+
> (`tests/checker_frontend/misc.rs`) — `Ops`, `MAX_OPS`, a function, and bare
13+
> variants all resolve in value, type, and pattern position. Cross-module
14+
> same-named-variant collisions are still out of scope (needs variant
15+
> namespacing), as noted below.
16+
617
## Summary
718

819
Support `use module.*` to bring every public symbol of a module (its types, sum

docs/requests/module-qualified-variant-pattern.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
# Feature request: qualified sum variant in `match` pattern position
22

3-
**Status:** requested · **Driver:** tinygrad-rsmc module de-prefixing
3+
**Status:** IMPLEMENTED · **Driver:** tinygrad-rsmc module de-prefixing
44
**Repro built on:** `rss` @ `6c6c57a`
55

6+
> **Resolution.** A module-qualified variant is accepted as a match pattern. The
7+
> pattern parser now reads a dotted head (`ops.ADD`, `a.b.Variant`) — no more
8+
> RS0015 — and `module_isolation` rewrites it to the bare variant (resolved
9+
> through its sum type), identical to qualified value access in expression
10+
> position. Exhaustiveness/typeck treat `ops.ADD` as the bare `ADD` it resolves
11+
> to. Verified through the semantic checker on a merged program:
12+
> `qualified_variant_in_match_pattern_checks_clean`
13+
> (`tests/checker_frontend/misc.rs`). Qualified `module.Type { .. }` payload
14+
> patterns resolve too.
15+
616
## Summary
717

818
Allow a module-qualified sum variant (`module.Variant`) as a pattern in a `match`

0 commit comments

Comments
 (0)