Skip to content

Commit b3b5f57

Browse files
olwangclaude
andcommitted
rsscript: module name disambiguation (use-as aliasing + qualified calls)
Modules could not de-prefix because a file referencing a same-leaf name owned by two modules had no way to disambiguate. Add both mechanisms the user opted into: - `use module.name as alias`: UseDecl.alias (AST) + parser `as <ident>`; the resolver keys imports by local name -> (module prefix, real name); the formatter round-trips the alias; the cast check exempts `as` in a `use` header. - Qualified `module.fn()` calls: a lowercase `module.fn()` parses as a receiver call, so the resolver collapses a ReceiverCall whose receiver names a module (not an in-scope local) and whose method is that module's free function to the mangled symbol. Supports multi-segment module paths. - Two `use` declarations binding the same local name are now a hard RS0015 diagnostic instead of a silent last-one-wins shadow. Spec §14.8 corrected: the resolution order no longer claims a fully-qualified `module.name` form that did not exist, and now documents aliasing, qualified calls, and the duplicate-import diagnostic. Tests: module_isolation unit tests (alias, qualified, multi-segment), checker_lowering integration tests (alias + qualified end-to-end), formatter alias round-trip, and a duplicate-import fail fixture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18e3c44 commit b3b5f57

9 files changed

Lines changed: 370 additions & 32 deletions

File tree

crates/rsscript/src/analyzer.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,9 @@ impl Analyzer<'_> {
11871187
let mut seen_module: HashSet<String> = HashSet::new();
11881188
let mut seen_use: HashSet<String> = HashSet::new();
11891189
let mut seen_non_organization_item: HashSet<String> = HashSet::new();
1190+
// Per file, the local import names already bound, so a second import that
1191+
// would silently shadow the first is rejected instead of overwritten.
1192+
let mut seen_import_local: HashMap<String, HashSet<String>> = HashMap::new();
11901193
let items = self.syntax_program.items.clone();
11911194
for item in &items {
11921195
let file = item_span_file(item);
@@ -1223,6 +1226,18 @@ impl Analyzer<'_> {
12231226
"`use` is source-organization metadata and must appear before declarations.",
12241227
);
12251228
}
1229+
if let Some(local) = use_decl.local_name()
1230+
&& !seen_import_local
1231+
.entry(file.clone())
1232+
.or_default()
1233+
.insert(local.to_string())
1234+
{
1235+
self.unsupported_syntax(
1236+
use_decl.span.clone(),
1237+
"duplicate import name",
1238+
"Two `use` declarations bind the same local name in this file. Rename one with `use module.name as other_name` so each import is unambiguous.",
1239+
);
1240+
}
12261241
seen_use.insert(file);
12271242
}
12281243
Item::Type(_)

crates/rsscript/src/checks/forbidden.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ fn token_can_start_expr(token: &crate::lexer::Token) -> bool {
111111

112112
fn check_implicit_conversion_attempts(analyzer: &mut Analyzer<'_>) {
113113
for index in 0..analyzer.tokens.len() {
114-
if !analyzer.tokens[index].is_ident_text("as") || as_belongs_to_with(analyzer, index) {
114+
if !analyzer.tokens[index].is_ident_text("as")
115+
|| as_belongs_to_with(analyzer, index)
116+
|| as_belongs_to_use(analyzer, index)
117+
{
115118
continue;
116119
}
117120
analyzer.diagnostics.push(
@@ -131,6 +134,25 @@ fn check_implicit_conversion_attempts(analyzer: &mut Analyzer<'_>) {
131134
}
132135
}
133136

137+
/// `use module.name as alias` — the `as` renames an import and is not a cast. A
138+
/// use header is only identifiers and dots before the `as`, so scanning back to a
139+
/// `use` keyword without crossing any other token confirms the import context.
140+
fn as_belongs_to_use(analyzer: &Analyzer<'_>, as_index: usize) -> bool {
141+
for token in analyzer.tokens[..as_index].iter().rev() {
142+
if token.is_ident_text("use") {
143+
return true;
144+
}
145+
let is_path_token = matches!(
146+
token.kind,
147+
crate::lexer::TokenKind::Ident(_) | crate::lexer::TokenKind::Keyword(_)
148+
) || token.symbol(".");
149+
if !is_path_token {
150+
return false;
151+
}
152+
}
153+
false
154+
}
155+
134156
fn as_belongs_to_with(analyzer: &Analyzer<'_>, as_index: usize) -> bool {
135157
for token in analyzer.tokens[..as_index].iter().rev() {
136158
if token.is_ident_text("with") {

crates/rsscript/src/formatter.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ 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 {
128+
self.out.push_str(" as ");
129+
self.out.push_str(alias);
130+
}
127131
self.out.push('\n');
128132
}
129133
}
@@ -1907,6 +1911,17 @@ impl Writer for BufferWriter {
19071911
assert_eq!(format_source("pin.rss", &formatted), formatted);
19081912
}
19091913

1914+
#[test]
1915+
fn round_trips_use_alias() {
1916+
let source = "module app\n\nuse helpers.count as helpers_count\n\nfn run() -> Int {\n return helpers_count()\n}\n";
1917+
let formatted = format_source("alias.rss", source);
1918+
assert!(
1919+
formatted.contains("use helpers.count as helpers_count"),
1920+
"formatter dropped the use alias:\n{formatted}"
1921+
);
1922+
assert_eq!(format_source("alias.rss", &formatted), formatted);
1923+
}
1924+
19101925
#[test]
19111926
fn renders_list_slice_patterns() {
19121927
let source = r#"features: local

crates/rsscript/src/syntax/ast.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,22 @@ pub struct ModuleDecl {
142142
#[derive(Debug, Clone, PartialEq, Eq)]
143143
pub struct UseDecl {
144144
pub path: Vec<String>,
145+
/// Optional `as <alias>` local name. When present, the import is referenced
146+
/// in this file by the alias instead of the path's last segment.
147+
pub alias: Option<String>,
145148
pub span: Span,
146149
}
147150

151+
impl UseDecl {
152+
/// The local name this import is referenced by in its file: the alias when
153+
/// present, otherwise the path's last segment.
154+
pub fn local_name(&self) -> Option<&str> {
155+
self.alias
156+
.as_deref()
157+
.or_else(|| self.path.last().map(String::as_str))
158+
}
159+
}
160+
148161
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149162
pub enum TypeKind {
150163
Class,

crates/rsscript/src/syntax/module_isolation.rs

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,18 @@ struct Resolver {
8282
/// `SCREAMING_SNAKE_CASE`, so their mangled symbol is upper-cased to stay
8383
/// consistent between declaration and reference.
8484
module_consts: HashMap<String, HashSet<String>>,
85-
/// file -> (imported bare name -> module prefix it was imported from).
86-
file_imports: HashMap<String, HashMap<String, String>>,
85+
/// file -> (local import name -> (module prefix, real symbol name)). The
86+
/// local name is the `as` alias when present, otherwise the path's last
87+
/// segment; the real name is always the path's last segment.
88+
file_imports: HashMap<String, HashMap<String, (String, String)>>,
8789
}
8890

8991
impl Resolver {
9092
fn build(program: &Program, file_module: &HashMap<String, String>) -> Self {
9193
let mut module_defs: HashMap<String, HashSet<String>> = HashMap::new();
9294
let mut module_types: HashMap<String, HashSet<String>> = HashMap::new();
9395
let mut module_consts: HashMap<String, HashSet<String>> = HashMap::new();
94-
let mut file_imports: HashMap<String, HashMap<String, String>> = HashMap::new();
96+
let mut file_imports: HashMap<String, HashMap<String, (String, String)>> = HashMap::new();
9597

9698
for item in &program.items {
9799
let Some(file) = item_file(item) else {
@@ -156,12 +158,16 @@ impl Resolver {
156158
}
157159
Item::Use(decl) => {
158160
if decl.path.len() >= 2 {
159-
let name = decl.path[decl.path.len() - 1].clone();
161+
let real_name = decl.path[decl.path.len() - 1].clone();
160162
let import_prefix = module_prefix(&decl.path[..decl.path.len() - 1]);
163+
let local = decl
164+
.local_name()
165+
.map(str::to_string)
166+
.unwrap_or_else(|| real_name.clone());
161167
file_imports
162168
.entry(decl.span.file.clone())
163169
.or_default()
164-
.insert(name, import_prefix);
170+
.insert(local, (import_prefix, real_name));
165171
}
166172
}
167173
Item::Module(_) => {}
@@ -206,42 +212,46 @@ impl Resolver {
206212
{
207213
return Some(self.mangle_value(prefix, name));
208214
}
209-
// Otherwise a `use module.name` import that names a known module symbol.
210-
if let Some(import_prefix) = self
215+
// Otherwise a `use module.name [as local]` import that names a known
216+
// module symbol. The reference uses the local name; the mangled symbol
217+
// uses the imported module's real name.
218+
if let Some((import_prefix, real)) = self
211219
.file_imports
212220
.get(file)
213221
.and_then(|imports| imports.get(name))
214222
&& self
215223
.module_defs
216224
.get(import_prefix)
217-
.is_some_and(|names| names.contains(name))
225+
.is_some_and(|names| names.contains(real))
218226
{
219-
return Some(self.mangle_value(import_prefix, name));
227+
return Some(self.mangle_value(import_prefix, real));
220228
}
221229
None
222230
}
223231

224-
/// The module prefix that owns type `name` as seen from `file` (own module,
225-
/// then `use` import), or `None` if it is not a module-scoped type.
226-
fn resolve_type_module(&self, file: &str, name: &str) -> Option<String> {
232+
/// The `(module prefix, real type name)` that owns type `name` as seen from
233+
/// `file` (own module, then `use` import), or `None` if it is not a
234+
/// module-scoped type. The real name differs from `name` when `name` is a
235+
/// `use … as` alias.
236+
fn resolve_type_module(&self, file: &str, name: &str) -> Option<(String, String)> {
227237
if let Some(prefix) = self.file_module.get(file)
228238
&& self
229239
.module_types
230240
.get(prefix)
231241
.is_some_and(|types| types.contains(name))
232242
{
233-
return Some(prefix.clone());
243+
return Some((prefix.clone(), name.to_string()));
234244
}
235-
if let Some(import_prefix) = self
245+
if let Some((import_prefix, real)) = self
236246
.file_imports
237247
.get(file)
238248
.and_then(|imports| imports.get(name))
239249
&& self
240250
.module_types
241251
.get(import_prefix)
242-
.is_some_and(|types| types.contains(name))
252+
.is_some_and(|types| types.contains(real))
243253
{
244-
return Some(import_prefix.clone());
254+
return Some((import_prefix.clone(), real.clone()));
245255
}
246256
None
247257
}
@@ -250,7 +260,7 @@ impl Resolver {
250260
/// namespace names a module-scoped type, return its mangled type name.
251261
fn resolve_type_namespace(&self, file: &str, namespace: &str) -> Option<String> {
252262
self.resolve_type_module(file, namespace)
253-
.map(|prefix| format!("{prefix}{MODULE_SEP}{namespace}"))
263+
.map(|(prefix, real)| format!("{prefix}{MODULE_SEP}{real}"))
254264
}
255265

256266
fn rewrite(&self, program: &mut Program) {
@@ -539,9 +549,9 @@ impl Resolver {
539549
// where the access lives in a different file than the declaration.
540550
if let Expr::Ident(type_name, _) = base.as_ref()
541551
&& !scope.contains(type_name)
542-
&& let Some(type_module) = self.resolve_type_module(file, type_name)
552+
&& let Some((type_module, real_type)) = self.resolve_type_module(file, type_name)
543553
{
544-
let flat = flatten_associated_const(type_name, name);
554+
let flat = flatten_associated_const(&real_type, name);
545555
if self
546556
.module_defs
547557
.get(&type_module)
@@ -634,7 +644,23 @@ impl Resolver {
634644
*callee = Callee::Name(format!("{prefix}{MODULE_SEP}{name}"));
635645
}
636646
}
637-
Callee::ReceiverCall { receiver, .. } => {
647+
Callee::ReceiverCall {
648+
receiver, method, ..
649+
} => {
650+
// A lowercase `module.fn()` parses as a receiver call, but if the
651+
// receiver names a module (not a local in scope) whose free
652+
// function is `method`, it is a module-qualified call: collapse it
653+
// to the module's mangled symbol. Otherwise it is a real value
654+
// receiver call and only the receiver is rewritten.
655+
if let Some(prefix) = module_path_of_receiver(receiver, scope)
656+
&& self
657+
.module_defs
658+
.get(&prefix)
659+
.is_some_and(|names| names.contains(method))
660+
{
661+
*callee = Callee::Name(format!("{prefix}{MODULE_SEP}{method}"));
662+
return;
663+
}
638664
self.rewrite_expr(receiver, file, scope);
639665
}
640666
}
@@ -814,6 +840,80 @@ mod tests {
814840
isolate_module_namespaces(&mut program);
815841
assert!(function_names(&program).contains(&"main".to_string()));
816842
}
843+
844+
fn body_of(program: &Program, name: &str) -> String {
845+
let function = program
846+
.items
847+
.iter()
848+
.find_map(|item| match item {
849+
Item::Function(function) if function.name == name => Some(function),
850+
_ => None,
851+
})
852+
.unwrap_or_else(|| panic!("{name} present"));
853+
format!("{:?}", function.body)
854+
}
855+
856+
#[test]
857+
fn aliased_import_resolves_to_the_imported_module_symbol() {
858+
// `use lib.count as lib_count` lets `app` reference `lib`'s `count` under
859+
// a distinct local name; the reference resolves to the real module symbol.
860+
let lib = parse_source(
861+
"lib.rss",
862+
"module lib\n\nfn count() -> Int {\n return 1\n}\n",
863+
);
864+
let app = parse_source(
865+
"app.rss",
866+
"module app\n\nuse lib.count as lib_count\n\nfn run() -> Int {\n return lib_count()\n}\n",
867+
);
868+
let mut program = merge_programs([lib, app]);
869+
isolate_module_namespaces(&mut program);
870+
let body = body_of(&program, "app__run");
871+
assert!(
872+
body.contains("lib__count"),
873+
"alias should resolve to lib__count: {body}"
874+
);
875+
}
876+
877+
#[test]
878+
fn qualified_module_call_resolves_to_the_module_symbol() {
879+
// `helpers.count()` names the owner at the call site, with no `use`.
880+
let helpers = parse_source(
881+
"helpers.rss",
882+
"module helpers\n\nfn count() -> Int {\n return 1\n}\n",
883+
);
884+
let app = parse_source(
885+
"app.rss",
886+
"module app\n\nfn run() -> Int {\n return helpers.count()\n}\n",
887+
);
888+
let mut program = merge_programs([helpers, app]);
889+
isolate_module_namespaces(&mut program);
890+
let body = body_of(&program, "app__run");
891+
assert!(
892+
body.contains("helpers__count"),
893+
"qualified call should resolve to helpers__count: {body}"
894+
);
895+
}
896+
897+
#[test]
898+
fn multi_segment_qualified_module_call_resolves() {
899+
// A dotted module path (`a.b`) is mangled to `a_b` and the call collapses
900+
// to its module-qualified symbol.
901+
let inner = parse_source(
902+
"inner.rss",
903+
"module a.b\n\nfn count() -> Int {\n return 1\n}\n",
904+
);
905+
let app = parse_source(
906+
"app.rss",
907+
"module app\n\nfn run() -> Int {\n return a.b.count()\n}\n",
908+
);
909+
let mut program = merge_programs([inner, app]);
910+
isolate_module_namespaces(&mut program);
911+
let body = body_of(&program, "app__run");
912+
assert!(
913+
body.contains("a_b__count"),
914+
"multi-segment qualified call should resolve to a_b__count: {body}"
915+
);
916+
}
817917
}
818918

819919
/// The flattened symbol for an associated constant `Type.MEMBER`, matching the
@@ -823,6 +923,37 @@ fn flatten_associated_const(type_name: &str, member: &str) -> String {
823923
format!("{type_name}_{member}").to_uppercase()
824924
}
825925

926+
/// If `receiver` is a chain of plain identifiers (`a` or `a.b.c`) whose root is
927+
/// not shadowed by a local binding, return the candidate module prefix (encoded
928+
/// with the same injective scheme as declarations). Anything else (a method call,
929+
/// an indexed value, a literal, or a root that names an in-scope local) is a
930+
/// value receiver, not a module path.
931+
fn module_path_of_receiver(receiver: &Expr, scope: &HashSet<String>) -> Option<String> {
932+
fn collect(expr: &Expr, segments: &mut Vec<String>) -> bool {
933+
match expr {
934+
Expr::Ident(name, _) => {
935+
segments.push(name.clone());
936+
true
937+
}
938+
Expr::Field { base, name, .. } => {
939+
collect(base, segments) && {
940+
segments.push(name.clone());
941+
true
942+
}
943+
}
944+
_ => false,
945+
}
946+
}
947+
let mut segments = Vec::new();
948+
if !collect(receiver, &mut segments) || segments.is_empty() {
949+
return None;
950+
}
951+
if scope.contains(&segments[0]) {
952+
return None;
953+
}
954+
Some(module_prefix(&segments))
955+
}
956+
826957
fn item_file(item: &Item) -> Option<&str> {
827958
Some(match item {
828959
Item::Function(function) => function.span.file.as_str(),

crates/rsscript/src/syntax/parser.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,15 @@ impl Parser<'_> {
853853
}
854854
self.index += 1;
855855
let path = self.parse_dotted_path()?;
856-
Some(UseDecl { path, span })
856+
// 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") {
859+
self.index += 1;
860+
self.take_ident_name()
861+
} else {
862+
None
863+
};
864+
Some(UseDecl { path, alias, span })
857865
}
858866

859867
/// Parse a dot-separated path like `package.contract.PackageContract`.

0 commit comments

Comments
 (0)