diff --git a/pd-vm-wasm/src/analyzer.rs b/pd-vm-wasm/src/analyzer.rs index cb77f169..4c671efc 100644 --- a/pd-vm-wasm/src/analyzer.rs +++ b/pd-vm-wasm/src/analyzer.rs @@ -98,6 +98,15 @@ fn lint_compile_result( LintReport { diagnostics } } } + // Milestone 5+ load/merge errors carry the compilation-wide source + // map; unwrap it so the branch below renders from the owning source. + Err(SourcePathError::SourceWithMap { error, .. }) => lint_compile_result( + source, + flavor, + path, + options, + Err(SourcePathError::Source(error)), + ), Err(SourcePathError::Source(SourceError::Parse(err))) => { let mut diagnostics = lint_trailing_function_return_semicolon_diagnostics(source, flavor); @@ -115,7 +124,11 @@ fn lint_compile_result( source, flavor, path, options, )); let mut source_map = SourceMap::new(); - let source_id = source_map.add_source("", source.to_string()); + // Milestone 5+ compile errors name their owning source; register + // the root text under that name so the rendered snippet resolves + // against the right file. + let source_id = + source_map.add_source(err.source_name().unwrap_or(""), source.to_string()); let line = err.line().unwrap_or(0); let span = err.line().and_then(|value| { let span = source_map.line_span(source_id, value)?; diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 9ccc0896..48b31cb6 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -1169,8 +1169,7 @@ mod runtime_tests { diagnostic.message ); assert!( - diagnostic.rendered.contains(":") - && diagnostic.rendered.contains("let value = if true => {"), + diagnostic.rendered.contains("let value = if true => {"), "expected rendered diagnostic snippet, got {:?}", diagnostic.rendered ); diff --git a/plans/2026-08-09_semantic-module-system.md b/plans/2026-08-09_semantic-module-system.md index 121bd5f6..96fb4040 100644 --- a/plans/2026-08-09_semantic-module-system.md +++ b/plans/2026-08-09_semantic-module-system.md @@ -1,5 +1,14 @@ # Semantic Module System Implementation Plan +**Status (2026-08-09):** Milestones 1-7 complete, committed in b3ef8a7. +The semantic module graph is the sole file-module path: `rewrite.rs` and +`line_map.rs` are deleted, the synthetic imported-function prelude is gone, +and module sources are parsed verbatim with implicit-extern fallback and +resolved by `SymbolId` in the source loader (see +`src/compiler/source_loader.rs` module docs). Verification: `compiler_tests` +(215 tests incl. `semantic_module_m6_tests`), workspace all-features tests, +fmt, clippy (no new warnings), and `git diff --check` are green. + **Goal:** Replace textual import rewriting and synthetic declarations with a semantic module graph and symbol resolution model. **Architecture:** Parse imports as syntax, assign every source a `ModuleId` and `SourceId`, resolve declarations to `SymbolId`, and link by resolved identity. Module namespaces, visibility, private helpers, and diagnostics become first-class compiler data instead of rewritten text and parallel metadata arrays. diff --git a/src/cli.rs b/src/cli.rs index a02f99c1..45684d7f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -332,16 +332,51 @@ fn run_vm_loop( fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String { match err { - SourcePathError::Source(vm::SourceError::Parse(parse)) => { - let source = std::fs::read_to_string(source_path).unwrap_or_default(); + SourcePathError::SourceWithMap { .. } => { + vm::render_source_path_error(source_path, err, true) + } + SourcePathError::Source(error) => render_source_error_at_path(source_path, None, error), + SourcePathError::InvalidImportSyntax { + path, + line, + message, + } => { + let source = std::fs::read_to_string(path).unwrap_or_default(); let mut source_map = SourceMap::new(); - let source_id = source_map.add_source(source_path.display().to_string(), source); + let source_id = source_map.add_source(path.display().to_string(), source); + let parse = vm::ParseError::at_line(*line, message.clone()) + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, true) + } + _ => err.to_string(), + } +} + +fn render_source_error_at_path( + source_path: &Path, + source_override: Option<&str>, + error: &vm::SourceError, +) -> String { + match error { + vm::SourceError::Parse(parse) => { + let render_path = parse + .message + .split_once(": ") + .map(|(path, _)| Path::new(path)) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = source_override + .filter(|_| render_path == source_path) + .map(str::to_owned) + .unwrap_or_else(|| std::fs::read_to_string(render_path).unwrap_or_default()); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(render_path.display().to_string(), source); let parse = parse .clone() .with_line_span_from_source(&source_map, source_id); render_source_error(&source_map, &parse, true) } - SourcePathError::Source(vm::SourceError::Compile(compile)) => { + vm::SourceError::Compile(compile) => { let render_path = compile .source_name() .map(Path::new) @@ -352,19 +387,6 @@ fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String source_map.add_source(render_path.display().to_string(), source); vm::render_compile_error(&source_map, compile, true) } - SourcePathError::InvalidImportSyntax { - path, - line, - message, - } => { - let source = std::fs::read_to_string(path).unwrap_or_default(); - let mut source_map = SourceMap::new(); - let source_id = source_map.add_source(path.display().to_string(), source); - let parse = vm::ParseError::at_line(*line, message.clone()) - .with_line_span_from_source(&source_map, source_id); - render_source_error(&source_map, &parse, true) - } - _ => err.to_string(), } } @@ -2372,4 +2394,117 @@ mod tests { fn repl_input_incomplete_for_trailing_operator() { assert!(!super::is_repl_input_complete("let a = 1 +")); } + + fn cli_diagnostic_root(prefix: &str) -> std::path::PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("cli diagnostic root should be created"); + root.canonicalize().unwrap_or(root) + } + + #[test] + fn cli_nested_module_parse_error_renders_nested_source_frame() { + let root = cli_diagnostic_root("pd-vm-cli-nested-parse-diag"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n") + .expect("main fixture should write"); + std::fs::write(&nested_path, "pub fn run( {\n").expect("nested fixture should write"); + + let error = match vm::compile_source_file_with_options( + &main_path, + vm::CompileSourceFileOptions::default(), + ) { + Ok(_) => panic!("nested parse error fixture should fail"), + Err(error) => error, + }; + let rendered = super::render_source_path_error(&main_path, &error); + + // The rendered frame must belong to the nested source: its path, its + // line text, and an underline, not the root file. + assert!( + rendered.contains(&nested_path.display().to_string()), + "rendered diagnostic should name the nested path: {rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "rendered diagnostic should show the nested source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the nested source: {rendered}" + ); + assert!( + !rendered.contains("use self::nested as nested;"), + "rendered diagnostic should not show the root source frame: {rendered}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn cli_nested_strict_type_error_renders_nested_source_frame() { + let root = cli_diagnostic_root("pd-vm-cli-nested-strict-diag"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n") + .expect("main fixture should write"); + std::fs::write(&nested_path, "pub fn run() -> unknown { 1 }\n") + .expect("nested fixture should write"); + + let error = match vm::compile_source_file_with_options( + &main_path, + vm::CompileSourceFileOptions::default(), + ) { + Ok(_) => panic!("strict nested fixture should fail"), + Err(error) => error, + }; + let rendered = super::render_source_path_error(&main_path, &error); + + assert!( + rendered.contains(&nested_path.display().to_string()), + "rendered diagnostic should name the nested path: {rendered}" + ); + assert!( + rendered.contains("pub fn run() -> unknown { 1 }"), + "rendered diagnostic should show the nested source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the nested source: {rendered}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn render_source_error_uses_source_override_for_virtual_paths() { + let virtual_path = std::path::Path::new("__pd_vm_inmemory__/main.rss"); + let error = vm::SourceError::Parse(vm::ParseError::at_line(2, "boom")); + let rendered = super::render_source_error_at_path( + virtual_path, + Some("line one\nline two target\nline three"), + &error, + ); + + assert!( + rendered.contains("__pd_vm_inmemory__/main.rss"), + "rendered diagnostic should name the virtual path: {rendered}" + ); + assert!( + rendered.contains("line two target"), + "rendered diagnostic should show the override source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the override source: {rendered}" + ); + } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 5b9b43f3..3b3fe564 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -668,6 +668,14 @@ impl Compiler { let slot = self.ensure_function_value_slot(*index, type_args)?; self.emit_copy_ldloc(slot)?; } + // Resolved module targets are lowered into plain flat-index calls + // by `linker::merge_units`; reaching codegen means the merge + // missed a site. + Expr::ModuleFunctionRef(..) + | Expr::ModuleCall(..) + | Expr::UnresolvedFunctionRef { .. } => { + return Err(CompileError::UnresolvedModuleCall); + } Expr::Call(index, _, args) => { self.compile_function_call(*index, args)?; } diff --git a/src/compiler/diagnostics.rs b/src/compiler/diagnostics.rs index f7a27517..832b0bb1 100644 --- a/src/compiler/diagnostics.rs +++ b/src/compiler/diagnostics.rs @@ -1,5 +1,5 @@ use super::source_map::{SourceMap, Span}; -use super::{CompileError, ParseError}; +use super::{CompileError, ParseError, SourceError, SourcePathError}; pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bool) -> String { let code_prefix = err @@ -19,12 +19,21 @@ pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bo pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: bool) -> String { let message = err.diagnostic_message(); - let source_id = err - .source_name() - .and_then(|name| source_map.source_id_by_name(name)) - .unwrap_or(0); + let source_name = err.source_name(); + + // Resolve the owning source id: by name when the error names its source, + // or as the single-file fallback used by inline/REPL compiles (a map with + // exactly one file at id 0). A named error is never rendered against + // another file: when its source is missing from the map it renders as a + // plain path/line message instead of misattributing the span. + let source_id = match source_name { + Some(name) => source_map.source_id_by_name(name), + None if source_map.file(0).is_some() && source_map.file(1).is_none() => Some(0), + None => None, + }; if let Some(line) = err.line() + && let Some(source_id) = source_id && let Some(span) = source_map.line_span(source_id, line) && let Some(rendered) = render_span_snippet(source_map, span, &message) { @@ -32,7 +41,7 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: } if let Some(line) = err.line() { - if let Some(source_name) = err.source_name() { + if let Some(source_name) = source_name { return format!("compile error: {source_name}:{line}: {message}"); } return format!("compile error: line {line}: {message}"); @@ -41,6 +50,64 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: format!("compile error: {message}") } +/// Render a source error (parse or compile) against the compilation-wide +/// source map carried by a [`SourcePathError`] when present, falling back to +/// a map-less render otherwise. Parse errors whose span references a source +/// id outside the map keep their path-prefixed message. +pub fn render_source_path_error( + source_path: &std::path::Path, + err: &SourcePathError, + _styled: bool, +) -> String { + match err { + SourcePathError::SourceWithMap { error, sources } => match error { + SourceError::Parse(parse) => render_source_error(sources, parse, _styled), + SourceError::Compile(compile) => render_compile_error(sources, compile, _styled), + }, + SourcePathError::Source(error) => match error { + SourceError::Parse(parse) => { + let render_path = parse + .message + .split_once(": ") + .map(|(path, _)| std::path::Path::new(path)) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = std::fs::read_to_string(render_path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(render_path.display().to_string(), source); + let parse = parse + .clone() + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, _styled) + } + SourceError::Compile(compile) => { + let render_path = compile + .source_name() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = std::fs::read_to_string(render_path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + source_map.add_source(render_path.display().to_string(), source); + render_compile_error(&source_map, compile, _styled) + } + }, + SourcePathError::InvalidImportSyntax { + path, + line, + message, + } => { + let source = std::fs::read_to_string(path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(path.display().to_string(), source); + let parse = ParseError::at_line(*line, message.clone()) + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, _styled) + } + _ => err.to_string(), + } +} + fn render_span_snippet(source_map: &SourceMap, span: Span, message: &str) -> Option { let file = source_map.file(span.source_id)?; let (line, col) = file.line_col_for_offset(span.lo)?; diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index 45a0380b..af0ed9c8 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -20,11 +20,62 @@ pub(super) fn parse_source( source: &str, flavor: SourceFlavor, options: &CompileSourceFileOptions, +) -> Result { + parse_source_with_source_id(source, flavor, options, 0) +} + +/// Parse `source` and attribute every produced span to `original_source_id`. +/// +/// The id belongs to the compilation-wide [`SourceMap`] built by the source +/// loader, whose ids are the semantic module graph's +/// [`SourceId`](crate::compiler::modules::SourceId) space. Spans produced by +/// this parse (including the error span on failure) therefore stay owned by +/// the module's source after unit merge. The default id `0` preserves the +/// legacy single-source behavior for entry points that build their own map. +pub(super) fn parse_source_with_source_id( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, +) -> Result { + parse_source_with_source_id_and_externs(source, flavor, options, original_source_id, false) +} + +/// Parse one module's source for the source loader (module mode). +/// +/// Module-mode parses enable the parser's implicit-extern fallback so that +/// calls to imported module functions and module namespace members parse +/// before the loader resolves them by [`SymbolId`](crate::compiler::modules::SymbolId). +/// The produced IR carries the implicit-extern names on +/// [`FrontendIr::implicit_extern_names`] so the loader keeps those synthetic +/// declarations out of module declaration/export tables. +pub(super) fn parse_module_source_with_source_id( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, +) -> Result { + parse_source_with_source_id_and_externs(source, flavor, options, original_source_id, true) +} + +fn parse_source_with_source_id_and_externs( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, + allow_implicit_externs: bool, ) -> Result { match flavor { SourceFlavor::RustScript => { let lowered = rustscript::lower(source)?; - parse_lowered_with_mapping(source, lowered, false, false, true) + parse_lowered_with_mapping( + source, + lowered, + allow_implicit_externs, + false, + true, + original_source_id, + ) } SourceFlavor::JavaScript | SourceFlavor::Lua => { let Some(plugin) = options.source_plugin_for_flavor(flavor) else { @@ -60,6 +111,7 @@ pub fn parse_source_with_dialect( options.allow_implicit_externs, options.allow_implicit_semicolons, options.enforce_mutable_bindings, + options.import_scan_mode, dialect, ) } @@ -86,6 +138,7 @@ fn parse_with_parser( allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + import_scan_mode: bool, dialect: &'static dyn ParserDialect, ) -> Result { let mut parser = Parser::new( @@ -94,6 +147,7 @@ fn parse_with_parser( allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + import_scan_mode, dialect, )?; let stmts = parser.parse_program()?; @@ -107,6 +161,8 @@ fn parse_with_parser( function_impls: parser.function_impls(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: parser.use_declarations(), + implicit_extern_names: parser.implicit_extern_names(), }) } @@ -142,6 +198,8 @@ fn parse_repl_with_parser( function_impls: parser.function_impls(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: parser.use_declarations(), + implicit_extern_names: parser.implicit_extern_names(), }, bindings, }) @@ -153,9 +211,10 @@ fn parse_lowered_with_mapping( allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + original_source_id: u32, ) -> Result { let mut source_map = SourceMap::new(); - let original_source_id = source_map.add_source("", original_source.to_string()); + source_map.add_source_at(original_source_id, "", original_source.to_string()); let lowered_source_id = source_map.add_source("", lowered.text.clone()); match parse_with_parser( @@ -164,6 +223,7 @@ fn parse_lowered_with_mapping( allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + false, rustscript::parser_dialect(), ) { Ok(mut ir) => { diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index e17494a5..a0f8388b 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -4,6 +4,7 @@ use crate::ValueType; use crate::builtins::default_host_callable; use super::ParseError; +use super::modules::SymbolId; pub type LocalSlot = u16; @@ -186,6 +187,25 @@ pub enum Expr { String(String), Bytes(Vec), FunctionRef(u16, Vec), + /// A function value whose target was resolved to a compiler-owned module + /// symbol before unit merge (milestone 4). + /// + /// Produced by the source loader's resolution pass for imported function + /// values and lowered by `linker::merge_units` into a plain + /// [`Expr::FunctionRef`] against the merged flat function table. + ModuleFunctionRef(SymbolId, Vec), + /// A function value reference whose target is not yet resolved (module + /// mode only). + /// + /// Produced by the parser in module mode when a function value refers to + /// a name the parser cannot resolve locally (an imported function binding + /// whose export table only the source loader knows). The loader's + /// resolution pass maps it to [`Expr::ModuleFunctionRef`] before unit + /// merge, so downstream passes never observe it. + UnresolvedFunctionRef { + name: String, + type_args: Vec, + }, OptionalGet { container: Box, key: Box, @@ -198,6 +218,17 @@ pub enum Expr { fallback: Box, }, Call(u16, Vec, Vec), + /// A call whose target was resolved to a compiler-owned module symbol + /// before unit merge (milestone 4). + /// + /// The source loader's resolution pass rewrites calls to imported + /// functions into this form, carrying the [`SymbolId`] of the source + /// module's declaration; `linker::merge_units` lowers it back into a + /// plain [`Expr::Call`] against the merged flat function table. Unlike + /// [`Expr::Call`]'s flat index, the symbol identity never depends on + /// unit-local index assignment or on the source name, so same-named + /// declarations in independent modules resolve to distinct targets. + ModuleCall(SymbolId, Vec, Vec), LocalCall(LocalSlot, Vec, Vec), Closure(ClosureExpr), ClosureCall(ClosureExpr, Vec), @@ -342,6 +373,10 @@ pub struct FunctionDecl { pub type_params: Vec, pub exported: bool, pub return_type: ValueType, + /// Semantic symbol owned by the declaring module, assigned by the source + /// loader after parse (milestone 3). `None` for IR that has not been + /// attached to a module yet (parser output, REPL snippets). + pub symbol: Option, } #[derive(Clone, Debug)] @@ -364,6 +399,18 @@ pub struct FrontendIr { pub function_impls: HashMap, pub stmt_sources: Vec>, pub function_sources: HashMap, + /// Structured `use` directives parsed from RustScript source, with spans + /// and clauses. Consumed by the source loader for import discovery. + pub use_declarations: Vec, + /// Names created by the parser's implicit-extern fallback (module mode). + /// + /// Module-mode parses tolerate calls whose target only the source loader + /// can resolve (imported module functions, module namespace members). + /// These synthetic declarations must never receive a module symbol or a + /// flat entry; the loader resolves their call sites or rejects them. + /// Plain (non-module) parses leave this empty because implicit externs are + /// disabled there. + pub implicit_extern_names: Vec, } pub struct LocalIrBuilder { @@ -475,6 +522,7 @@ impl LocalIrBuilder { type_params: Vec::new(), exported: false, return_type: ValueType::Unknown, + symbol: None, }); self.function_meta.insert(name.to_string(), (index, arity)); Ok(()) @@ -521,6 +569,8 @@ impl LocalIrBuilder { function_impls: HashMap::new(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), } } diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index e9f977bf..10c5562c 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -665,7 +665,9 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => Ok(state.clone()), + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => Ok(state.clone()), Expr::Var(index) => { self.require_available(*index, state, line)?; self.require_local_not_moved(*index, state, line)?; @@ -720,6 +722,9 @@ impl AvailabilityAnalyzer { let then_state = self.analyze_expr(fallback, &value_state, line)?; Ok(self.merge_states(then_state, value_state)) } + // Resolved module calls (pre-merge only) analyze their arguments; + // interprocedural effects apply to the post-merge flat call. + Expr::ModuleCall(_, _, args) => self.analyze_args(args, state, line), Expr::Call(index, _, args) => { if !self.enable_local_move_semantics { if let Some(root_slot) = self.extract_collection_mutation_root(*index, args) { diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index f234fc17..82cb1774 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -329,7 +329,9 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) => { if *index == captured_slot { *seen = true; @@ -358,7 +360,7 @@ impl AvailabilityAnalyzer { self.capture_mode_for_expr(value, captured_slot, context, mode, seen); self.capture_mode_for_expr(fallback, captured_slot, context, mode, seen); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); } diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index f410291b..c37e1226 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -171,7 +171,9 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => false, + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => false, Expr::Var(index) | Expr::MoveVar(index) => *index == slot, Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => *root == slot, Expr::OptionalGet { @@ -190,7 +192,7 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { value_slot, fallback, } => *value_slot == slot || expr_uses_slot(value, slot) || expr_uses_slot(fallback, slot), - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { args.iter().any(|arg| expr_uses_slot(arg, slot)) } Expr::Closure(closure) => { @@ -376,6 +378,8 @@ pub(super) fn collect_consumed_positions_from_expr( | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) => {} Expr::MoveVar(slot) => { if let Some(position) = function_impl @@ -459,6 +463,18 @@ pub(super) fn collect_consumed_positions_from_expr( } } } + // Resolved module calls (pre-merge only) have no per-unit consumed + // position table; their arguments are still scanned. + Expr::ModuleCall(_, _, args) => { + for arg in args { + collect_consumed_positions_from_expr( + arg, + function_impl, + known_consumed_positions, + out, + ); + } + } Expr::LocalCall(_, _, args) => { for arg in args { collect_consumed_positions_from_expr( diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 175437bc..42f5fed8 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -437,7 +437,9 @@ impl LivenessRewriter { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => self.mark_live(live, *index), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { self.mark_live(live, *root) @@ -472,6 +474,14 @@ impl LivenessRewriter { self.union_inplace(live, &footprint); } } + // Resolved module calls (pre-merge only) contribute their + // arguments' uses; the callee lives in another unit and its + // footprint is folded in by the post-merge call lowering. + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.add_expr_uses(arg, live); + } + } Expr::LocalCall(index, _, args) => { self.mark_live(live, *index); for arg in args { @@ -736,7 +746,9 @@ impl LivenessRewriter { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { self.mark_live(footprint, *index); } @@ -770,6 +782,14 @@ impl LivenessRewriter { self.collect_expr_footprint(arg, footprint, stack); } } + // Resolved module calls (pre-merge only) contribute their + // arguments' footprint; the callee lives in another unit and is + // folded in by the post-merge call lowering. + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.collect_expr_footprint(arg, footprint, stack); + } + } Expr::Closure(closure) => { for slot in &closure.param_slots { self.mark_live(footprint, *slot); @@ -898,6 +918,8 @@ fn expr_contains_local_call(expr: &Expr) -> bool { | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -908,7 +930,9 @@ fn expr_contains_local_call(expr: &Expr) -> bool { Expr::OptionUnwrapOr { value, fallback, .. } => expr_contains_local_call(value) || expr_contains_local_call(fallback), - Expr::Call(_, _, args) => args.iter().any(expr_contains_local_call), + Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { + args.iter().any(expr_contains_local_call) + } Expr::Closure(closure) => expr_contains_local_call(&closure.body), Expr::ClosureCall(closure, args) => { args.iter().any(expr_contains_local_call) || expr_contains_local_call(&closure.body) @@ -1133,7 +1157,9 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => { self.add_slot_live_edges(*index, &live_during); } @@ -1170,6 +1196,13 @@ impl LocalSlotAllocator { self.add_cross_live_with_set(&live_during, &footprint); } } + // Resolved module calls (pre-merge only) constrain their + // arguments; the callee's footprint is folded in post-merge. + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.collect_expr_constraints(arg, &live_during)?; + } + } Expr::LocalCall(index, _, args) => { self.add_slot_live_edges(*index, &live_during); for arg in args { @@ -1357,7 +1390,9 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { self.mark_set_slot(set, *index) } @@ -1397,6 +1432,11 @@ impl LocalSlotAllocator { self.collect_expr_footprint(arg, set, stack); } } + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.collect_expr_footprint(arg, set, stack); + } + } Expr::Closure(closure) => { for (source_slot, captured_slot) in &closure.capture_copies { self.mark_set_slot(set, *source_slot); @@ -1745,6 +1785,8 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe | Expr::String(_) | Expr::Bytes(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1759,7 +1801,7 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe collect_persistent_closure_sources_from_expr(value, slots); collect_persistent_closure_sources_from_expr(fallback, slots); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { collect_persistent_closure_sources_from_expr(arg, slots); } @@ -1897,8 +1939,10 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) => {} - Expr::FunctionRef(..) => {} - Expr::Call(_, _, args) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} + Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { remap_expr_slots(arg, mapping)?; } diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index 677840b6..cdef2c29 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use crate::builtins::BuiltinFunction; @@ -6,18 +6,42 @@ use crate::builtins::BuiltinFunction; use super::{ ParseError, SourceError, SourcePathError, ir::{Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, StructDecl}, + modules::{ModuleId, SymbolId}, }; pub(super) struct ParsedUnit { pub(super) parsed: FrontendIr, - pub(super) scope_prefix: Option, + /// Deterministic scope identity for the unit's local bindings at the flat + /// bytecode boundary. `None` for the root unit (which keeps bare names); + /// otherwise a mangled full-module identity computed by the loader, never + /// a bare file stem (milestone 4). + pub(super) scope_identity: Option, pub(super) source_name: String, + /// Semantic module identity assigned by the module graph during discovery. + /// Consumed by milestone 4+ symbol resolution; carried on the unit so the + /// link between parsed IR and graph node survives the merge pipeline. + /// (The loader resolves call sites with it before merge; the flat merge + /// itself keys on [`SymbolId`].) + #[allow(dead_code)] + pub(super) module: ModuleId, + /// Graph `SourceId` of the unit's module (milestone 5). Every span the + /// unit's IR carries references this id in the compilation-wide + /// [`SourceMap`](crate::compiler::source_map::SourceMap), so merged + /// diagnostics always render from the owning source. + #[allow(dead_code)] + pub(super) source_id: u32, } -pub(super) fn sanitize_scope_prefix(path: &Path) -> String { - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("module") +/// Deterministic flat-boundary scope identity for a non-root module's local +/// bindings. +/// +/// Encodes the full canonical module identity (never a bare file stem) plus +/// the compiler-owned [`ModuleId`], so same-stem modules in different +/// directories and same-named locals across independent modules never collide +/// at the flat boundary. +pub(super) fn module_scope_prefix(identity: &Path, module: ModuleId) -> String { + let sanitized: String = identity + .to_string_lossy() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '_' { @@ -26,7 +50,16 @@ pub(super) fn sanitize_scope_prefix(path: &Path) -> String { '_' } }) - .collect() + .collect(); + format!("{sanitized}__m{}", module.0) +} + +/// Deterministic flat name for a module function whose source name is already +/// claimed by another flat entry. The mangling encodes the compiler-owned +/// module identity, so it is stable across compilations and never depends on +/// discovery-order-dependent string synthesis. +fn deterministic_flat_name(name: &str, symbol: SymbolId) -> String { + format!("{}__m{}", name, symbol.module.0) } pub(super) fn merge_units(units: Vec) -> Result { @@ -38,22 +71,39 @@ pub(super) fn merge_units(units: Vec) -> Result::new(); let mut merged_function_sources = HashMap::::new(); - let mut function_index_by_name = HashMap::::new(); + + // Milestone 4 flat identity maps. + // + // Module functions (declarations with implementations) are merged by + // compiler-owned `SymbolId`, so same-named declarations in independent + // modules each get their own flat entry. Host imports (declarations + // without implementations) keep name-keyed deduplication: their names are + // the runtime binding surface (`program.imports`, `Vm::bind_function`), + // so the legacy merge semantics apply verbatim. + let mut flat_index_by_symbol = HashMap::::new(); + let mut host_index_by_name = HashMap::::new(); + // Every flat name claimed so far. Module functions that collide are + // deterministically mangled with their module identity; host imports are + // deduplicated by name before ever reaching this set. + let mut claimed_flat_names = HashSet::::new(); + let mut local_base = 0usize; for unit in units { let source_name = unit.source_name.clone(); - let function_map = remap_functions( - &unit.parsed.functions, + let function_map = register_unit_functions( + &unit, &mut merged_functions, - &mut function_index_by_name, + &mut flat_index_by_symbol, + &mut host_index_by_name, + &mut claimed_flat_names, )?; let unit_local_base = local_base; let unit_local_count = unit.parsed.locals; let mut remapped_stmts = unit.parsed.stmts; for stmt in &mut remapped_stmts { - remap_stmt_indices(stmt, unit_local_base, &function_map)?; + remap_stmt_indices(stmt, unit_local_base, &function_map, &flat_index_by_symbol)?; } merged_stmt_sources.extend(std::iter::repeat_n( Some(source_name.clone()), @@ -63,8 +113,8 @@ pub(super) fn merge_units(units: Vec) -> Result) -> Result) -> Result Result { + u16::try_from(merged_functions.len()).map_err(|_| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "too many functions across imported modules".to_string(), + })) + }) +} + +/// Register one unit's declarations in the flat function table and return the +/// unit-index → flat-index map. +/// +/// Synthetic prelude declarations (symbol-less, mirroring imported bindings) +/// never become flat entries: the loader already resolved their call sites to +/// [`Expr::ModuleCall`] with the target's [`SymbolId`]. +fn register_unit_functions( + unit: &ParsedUnit, merged_functions: &mut Vec, - function_index_by_name: &mut HashMap, + flat_index_by_symbol: &mut HashMap, + host_index_by_name: &mut HashMap, + claimed_flat_names: &mut HashSet, ) -> Result, SourcePathError> { let mut map = HashMap::new(); - for func in unit_functions { - let merged_index = if let Some(existing_index) = function_index_by_name.get(&func.name) { - let existing = &mut merged_functions[*existing_index as usize]; - if existing.arity != func.arity { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting arity {} vs {}", - func.name, existing.arity, func.arity - ), - }))); - } - if existing.return_type != func.return_type { - match (existing.return_type, func.return_type) { - (crate::ValueType::Unknown, known) => existing.return_type = known, - (known, crate::ValueType::Unknown) => existing.return_type = known, - (lhs, rhs) => { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting return type {} vs {}", - func.name, - value_type_name(lhs), - value_type_name(rhs) - ), - }))); - } - } - } - if existing.return_schema != func.return_schema { - match (&existing.return_schema, &func.return_schema) { - (None, Some(schema)) => existing.return_schema = Some(schema.clone()), - (Some(_), None) => {} - (Some(lhs), Some(rhs)) if lhs == rhs => {} - _ => { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting return schemas across imported modules", - func.name - ), - }))); - } - } - } - if existing.type_params != func.type_params { - if existing.type_params.is_empty() { - existing.type_params = func.type_params.clone(); - } else if !func.type_params.is_empty() { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting type parameters across imported modules", - func.name - ), - }))); - } - } - if existing.arg_schemas != func.arg_schemas { - if existing.arg_schemas.iter().all(Option::is_none) { - existing.arg_schemas = func.arg_schemas.clone(); - } else if !func.arg_schemas.iter().all(Option::is_none) { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting parameter schemas across imported modules", - func.name - ), - }))); - } - } - if function_args_are_placeholders(&existing.args) - && !function_args_are_placeholders(&func.args) - { - existing.args = func.args.clone(); + for func in &unit.parsed.functions { + let Some(symbol) = func.symbol else { + // Synthetic prelude/stub declaration; resolved by the loader. + continue; + }; + if let Some(&existing) = flat_index_by_symbol.get(&symbol) { + map.insert(func.index, existing); + continue; + } + let has_impl = unit.parsed.function_impls.contains_key(&func.index); + let flat = if !has_impl { + // Host import: name-keyed deduplication preserves the legacy + // merge semantics and the runtime name-binding surface. + if let Some(&existing) = host_index_by_name.get(&func.name) { + merge_host_import_metadata(&mut merged_functions[existing as usize], func)?; + flat_index_by_symbol.insert(symbol, existing); + map.insert(func.index, existing); + continue; } - existing.exported = existing.exported || func.exported; - *existing_index - } else { - let next_index = u16::try_from(merged_functions.len()).map_err(|_| { - SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: "too many functions across imported modules".to_string(), - })) - })?; + let flat = next_flat_index(merged_functions)?; merged_functions.push(FunctionDecl { name: func.name.clone(), arity: func.arity, - index: next_index, + index: flat, + args: func.args.clone(), + arg_schemas: func.arg_schemas.clone(), + return_schema: func.return_schema.clone(), + type_params: func.type_params.clone(), + exported: func.exported, + return_type: func.return_type, + symbol: Some(symbol), + }); + host_index_by_name.insert(func.name.clone(), flat); + claimed_flat_names.insert(func.name.clone()); + flat + } else { + // Module function: one flat entry per symbol; the source name is + // kept unless another flat entry already claimed it, in which + // case it is deterministically mangled with the module identity. + let flat = next_flat_index(merged_functions)?; + let flat_name = if claimed_flat_names.insert(func.name.clone()) { + func.name.clone() + } else { + deterministic_flat_name(&func.name, symbol) + }; + merged_functions.push(FunctionDecl { + name: flat_name, + arity: func.arity, + index: flat, args: func.args.clone(), arg_schemas: func.arg_schemas.clone(), return_schema: func.return_schema.clone(), type_params: func.type_params.clone(), exported: func.exported, return_type: func.return_type, + symbol: Some(symbol), }); - function_index_by_name.insert(func.name.clone(), next_index); - next_index + flat }; - map.insert(func.index, merged_index); + flat_index_by_symbol.insert(symbol, flat); + map.insert(func.index, flat); } Ok(map) } +/// Replicate the legacy name-merge metadata rules for host imports that are +/// declared by more than one unit: arity conflicts are errors, `Unknown` +/// return types are refined, and schemas/type parameters merge. +fn merge_host_import_metadata( + existing: &mut FunctionDecl, + func: &FunctionDecl, +) -> Result<(), SourcePathError> { + if existing.arity != func.arity { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting arity {} vs {}", + func.name, existing.arity, func.arity + ), + }))); + } + if existing.return_type != func.return_type { + match (existing.return_type, func.return_type) { + (crate::ValueType::Unknown, known) => existing.return_type = known, + (known, crate::ValueType::Unknown) => existing.return_type = known, + (lhs, rhs) => { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting return type {} vs {}", + func.name, + value_type_name(lhs), + value_type_name(rhs) + ), + }))); + } + } + } + if existing.return_schema != func.return_schema { + match (&existing.return_schema, &func.return_schema) { + (None, Some(schema)) => existing.return_schema = Some(schema.clone()), + (Some(_), None) => {} + (Some(lhs), Some(rhs)) if lhs == rhs => {} + _ => { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting return schemas across imported modules", + func.name + ), + }))); + } + } + } + if existing.type_params != func.type_params { + if existing.type_params.is_empty() { + existing.type_params = func.type_params.clone(); + } else if !func.type_params.is_empty() { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting type parameters across imported modules", + func.name + ), + }))); + } + } + if existing.arg_schemas != func.arg_schemas { + if existing.arg_schemas.iter().all(Option::is_none) { + existing.arg_schemas = func.arg_schemas.clone(); + } else if !func.arg_schemas.iter().all(Option::is_none) { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting parameter schemas across imported modules", + func.name + ), + }))); + } + } + if function_args_are_placeholders(&existing.args) && !function_args_are_placeholders(&func.args) + { + existing.args = func.args.clone(); + } + existing.exported = existing.exported || func.exported; + Ok(()) +} + fn function_args_are_placeholders(args: &[String]) -> bool { args.iter() .enumerate() @@ -325,37 +446,51 @@ fn remap_stmt_indices( stmt: &mut Stmt, local_base: usize, function_map: &HashMap, + flat_index_by_symbol: &HashMap, ) -> Result<(), SourcePathError> { match stmt { Stmt::Noop { .. } => {} Stmt::Let { index, expr, .. } => { *index = remap_local_index(*index, local_base)?; - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::Assign { index, expr, .. } => { *index = remap_local_index(*index, local_base)?; - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::ClosureLet { closure, .. } => { for (source_index, captured_slot) in &mut closure.capture_copies { *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; } - Stmt::FuncDecl { index, .. } => { - *index = function_map.get(index).copied().ok_or_else(|| { - SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: "function index remap failed while merging imported modules" - .to_string(), - })) - })?; + Stmt::FuncDecl { + index, has_impl, .. + } => { + // Implementation-less declarations (import prelude stubs, extern + // prototypes) never enter the flat table and codegen ignores + // their index; only declarations with implementations are + // remapped to their symbol-owned flat entry. + if *has_impl { + *index = function_map.get(index).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "function index remap failed while merging imported modules" + .to_string(), + })) + })?; + } } Stmt::Expr { expr, .. } => { - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::IfElse { condition, @@ -363,12 +498,12 @@ fn remap_stmt_indices( else_branch, .. } => { - remap_expr_indices(condition, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; for stmt in then_branch { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } for stmt in else_branch { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::For { @@ -378,19 +513,19 @@ fn remap_stmt_indices( body, .. } => { - remap_stmt_indices(init, local_base, function_map)?; - remap_expr_indices(condition, local_base, function_map)?; - remap_stmt_indices(post, local_base, function_map)?; + remap_stmt_indices(init, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; + remap_stmt_indices(post, local_base, function_map, flat_index_by_symbol)?; for stmt in body { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::While { condition, body, .. } => { - remap_expr_indices(condition, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; for stmt in body { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::Break { .. } | Stmt::Continue { .. } => {} @@ -405,6 +540,7 @@ fn remap_expr_indices( expr: &mut Expr, local_base: usize, function_map: &HashMap, + flat_index_by_symbol: &HashMap, ) -> Result<(), SourcePathError> { match expr { Expr::Null @@ -426,6 +562,29 @@ fn remap_expr_indices( }))); } } + Expr::ModuleFunctionRef(symbol, _) => { + let flat = flat_index_by_symbol.get(symbol).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: + "resolved module function value target is missing from the merged function table" + .to_string(), + })) + })?; + *expr = Expr::FunctionRef(flat, std::mem::take(&mut expr_type_args(expr))); + } + Expr::UnresolvedFunctionRef { .. } => { + // The loader resolves every function-value reference before + // merge; reaching the merge means resolution missed a site. + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "unresolved function value reference reached the module merge".to_string(), + }))); + } Expr::Call(index, _, args) => { if let Some(remapped_index) = function_map.get(index).copied() { *index = remapped_index; @@ -439,8 +598,24 @@ fn remap_expr_indices( }))); } for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; + } + } + Expr::ModuleCall(symbol, type_args, args) => { + for arg in args.iter_mut() { + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } + let flat = flat_index_by_symbol.get(symbol).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: + "resolved module call target is missing from the merged function table" + .to_string(), + })) + })?; + *expr = Expr::Call(flat, std::mem::take(type_args), std::mem::take(args)); } Expr::OptionalGet { container, @@ -450,8 +625,8 @@ fn remap_expr_indices( } => { *container_slot = remap_local_index(*container_slot, local_base)?; *key_slot = remap_local_index(*key_slot, local_base)?; - remap_expr_indices(container, local_base, function_map)?; - remap_expr_indices(key, local_base, function_map)?; + remap_expr_indices(container, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(key, local_base, function_map, flat_index_by_symbol)?; } Expr::OptionUnwrapOr { value, @@ -459,13 +634,13 @@ fn remap_expr_indices( fallback, } => { *value_slot = remap_local_index(*value_slot, local_base)?; - remap_expr_indices(value, local_base, function_map)?; - remap_expr_indices(fallback, local_base, function_map)?; + remap_expr_indices(value, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(fallback, local_base, function_map, flat_index_by_symbol)?; } Expr::LocalCall(index, _, args) => { *index = remap_local_index(*index, local_base)?; for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } } Expr::Closure(closure) => { @@ -476,7 +651,12 @@ fn remap_expr_indices( *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; } Expr::ClosureCall(closure, args) => { for param_slot in &mut closure.param_slots { @@ -486,9 +666,14 @@ fn remap_expr_indices( *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } } Expr::Add(lhs, rhs) @@ -501,15 +686,15 @@ fn remap_expr_indices( | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - remap_expr_indices(lhs, local_base, function_map)?; - remap_expr_indices(rhs, local_base, function_map)?; + remap_expr_indices(lhs, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(rhs, local_base, function_map, flat_index_by_symbol)?; } Expr::Neg(inner) | Expr::Not(inner) | Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { - remap_expr_indices(inner, local_base, function_map)?; + remap_expr_indices(inner, local_base, function_map, flat_index_by_symbol)?; } Expr::Var(index) | Expr::MoveVar(index) => { *index = remap_local_index(*index, local_base)?; @@ -522,9 +707,9 @@ fn remap_expr_indices( then_expr, else_expr, } => { - remap_expr_indices(condition, local_base, function_map)?; - remap_expr_indices(then_expr, local_base, function_map)?; - remap_expr_indices(else_expr, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(then_expr, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(else_expr, local_base, function_map, flat_index_by_symbol)?; } Expr::Match { value_slot, @@ -535,21 +720,32 @@ fn remap_expr_indices( } => { *value_slot = remap_local_index(*value_slot, local_base)?; *result_slot = remap_local_index(*result_slot, local_base)?; - remap_expr_indices(value, local_base, function_map)?; + remap_expr_indices(value, local_base, function_map, flat_index_by_symbol)?; for (pattern, arm_expr) in arms { if let crate::compiler::ir::MatchPattern::SomeBinding(binding_slot) = pattern { *binding_slot = remap_local_index(*binding_slot, local_base)?; } - remap_expr_indices(arm_expr, local_base, function_map)?; + remap_expr_indices(arm_expr, local_base, function_map, flat_index_by_symbol)?; } - remap_expr_indices(default, local_base, function_map)?; + remap_expr_indices(default, local_base, function_map, flat_index_by_symbol)?; } Expr::Block { stmts, expr } => { for stmt in stmts { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } } Ok(()) } + +/// Borrow the type arguments of a resolved function-value node. +/// +/// Only used while converting a [`Expr::ModuleFunctionRef`] into a plain +/// [`Expr::FunctionRef`] in place. +fn expr_type_args(expr: &mut Expr) -> Vec { + match expr { + Expr::ModuleFunctionRef(_, type_args) => std::mem::take(type_args), + _ => Vec::new(), + } +} diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 3b52d54b..ff77d89f 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -14,6 +14,7 @@ mod frontends; pub mod ir; mod lifetime; mod linker; +mod modules; mod parser; mod pipeline; mod source_loader; @@ -31,6 +32,10 @@ pub use self::ir::{ AssignmentKind, ClosureExpr, Expr, FrontendIr, FunctionDecl, FunctionImpl, FunctionParam, LocalIrBuilder, LocalSlot, MatchPattern, MatchTypePattern, Stmt, StructDecl, TypeSchema, }; +pub use self::modules::{ + DeclSymbol, ExportEntry, ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ModuleNode, + ResolvedImport, SymbolId, UseDecl, UsePathSegment, +}; pub use self::parser::ParserDialect; pub use self::pipeline::{ InferredLocalTypeHint, UnknownInferredLocal, collect_inferred_local_type_hints, @@ -90,6 +95,10 @@ pub enum CompileError { source_name: Option, detail: String, }, + /// Internal error: a symbol-resolved module call or function value + /// survived unit merge and reached codegen, where flat function indices + /// are the only valid call targets. + UnresolvedModuleCall, } impl CompileError { @@ -160,6 +169,9 @@ impl CompileError { CompileError::InvalidFieldAccess { detail, .. } => detail.clone(), CompileError::FunctionParameterTypeConflict { detail, .. } => detail.clone(), CompileError::StrictTypingRequired { detail, .. } => detail.clone(), + CompileError::UnresolvedModuleCall => { + "internal compiler error: unresolved module call reached codegen".to_string() + } } } } @@ -272,6 +284,25 @@ pub enum SourcePathError { message: String, }, Source(SourceError), + /// A source error plus the compilation-wide [`SourceMap`] that resolves + /// every span it carries (milestone 5). Produced by the module-loading + /// compile entry points; spans reference the semantic module graph's + /// `SourceId` space, so rendering against this map always reads from the + /// owning source. `Display` delegates to the inner error. + SourceWithMap { + error: SourceError, + sources: SourceMap, + }, +} + +impl SourcePathError { + /// The compilation-wide source map carried with this error, if any. + pub fn sources(&self) -> Option<&SourceMap> { + match self { + SourcePathError::SourceWithMap { sources, .. } => Some(sources), + _ => None, + } + } } impl fmt::Display for SourcePathError { @@ -309,6 +340,7 @@ impl fmt::Display for SourcePathError { message ), SourcePathError::Source(err) => write!(f, "{err}"), + SourcePathError::SourceWithMap { error, .. } => write!(f, "{error}"), } } } @@ -366,6 +398,12 @@ pub struct SharedParserOptions { pub allow_implicit_externs: bool, pub allow_implicit_semicolons: bool, pub enforce_mutable_bindings: bool, + /// Import-scan mode: used by the source loader's discovery parse. The + /// parser tolerates calls to not-yet-declared imported functions + /// (`allow_implicit_externs`) and records host aliases for multi-segment + /// file-module paths so namespace calls parse during the scan; the + /// resulting IR is discarded after `use` declarations are extracted. + pub import_scan_mode: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/compiler/modules.rs b/src/compiler/modules.rs new file mode 100644 index 00000000..efe8ea97 --- /dev/null +++ b/src/compiler/modules.rs @@ -0,0 +1,1046 @@ +//! Compiler-owned module identities and the semantic module graph. +//! +//! Milestones 1-6 of the semantic module system: every source that takes +//! part in a compilation is assigned a deterministic [`ModuleId`] and +//! [`SourceId`], `use` directives are parsed into structured [`UseDecl`] +//! nodes with spans and clauses, the source loader records resolved import +//! edges in a [`ModuleGraph`], and every declaration receives a +//! [`SymbolId`] owned by its module alongside an explicit public export +//! table and a separate imported-binding table. Identities never depend on +//! a file stem alone: two modules with the same basename in different +//! directories are distinct nodes, and re-visiting the same module identity +//! reuses the same node. +//! +//! Since milestone 6 the semantic graph is the *sole* file-module path: the +//! textual import rewriting, the synthetic imported-function prelude, and +//! the prelude line-map remapping are removed. Call sites resolve to +//! [`SymbolId`]s in the source loader and the linker merges units by symbol +//! identity, applying deterministic flat-boundary mangling only at the final +//! bytecode boundary. +//! +//! [`SourceId`] here is the module graph's own identity space, distinct from +//! `source_map::SourceId` (which is assigned per-unit by ad hoc `SourceMap` +//! instances). Milestone 5 reconciles the two spaces: every module's raw +//! text is registered in the compilation-wide `SourceMap` at its graph +//! `SourceId`, so spans survive unit merge with their owning source. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::SourcePathError; +use super::frontends::{is_ident_continue, is_ident_start}; +use super::source_loader::ImportClause; +use super::source_map::Span; + +/// Deterministic identity of one module within a single compilation. +/// +/// Assigned in discovery order: the root unit is always `ModuleId(0)`, and +/// every discovered module gets the next unused id the first time its +/// canonical identity is registered. Re-importing the same module yields the +/// same id; two modules that merely share a file stem are distinct. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ModuleId(pub u32); + +/// Deterministic identity of one parsed source text within a compilation. +/// +/// Distinct from `source_map::SourceId`: the module graph hands out its own +/// monotonic ids so that graph edges can reference sources without depending +/// on per-unit `SourceMap` construction order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SourceId(pub u32); + +/// Deterministic identity of one declaration within a compilation. +/// +/// Composed of the owning [`ModuleId`] and a module-local index, so two +/// same-named declarations in independent modules never collide. Milestone 3 +/// assigns symbol ids to declarations; the type is defined here so the whole +/// identity surface lands in one place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SymbolId { + pub module: ModuleId, + pub index: u32, +} + +/// One segment of a structured `use` path. +/// +/// `self`/`super` are only classified as qualifiers while they lead the path, +/// mirroring the legacy line-based resolver: a `self` appearing mid-path is a +/// literal file segment (e.g. `use a::self::b;` resolves `a/self/b.rss`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UsePathSegment { + Self_, + Super, + Ident(String), +} + +/// A structured `use` directive parsed from RustScript source. +/// +/// Carries the full path (including `self`/`super` qualifiers), the import +/// clause, the exact source span of the directive, and the directive line. +/// The source loader consumes these nodes for discovery instead of treating +/// line-prefix stripping as the authoritative import parser. +#[derive(Clone, Debug)] +pub struct UseDecl { + pub path: Vec, + pub clause: ImportClause, + pub span: Span, + pub line: usize, +} + +/// Classification of one resolved import edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImportTargetKind { + /// A RustScript file module loaded from disk or an override. + FileModule, + /// A virtual host namespace resolved on the dedicated host path. + HostNamespace, + /// A builtin namespace such as `io`, `json`, or `re`. + BuiltinNamespace, +} + +/// A resolved import edge inside a [`ModuleGraph`]. +/// +/// `target` is `Some(ModuleId)` once the destination module node is known +/// (`FileModule` edges), and `None` for host/builtin namespaces that stay on +/// their dedicated resolution paths. +#[derive(Clone, Debug)] +pub struct ResolvedImport { + pub kind: ImportTargetKind, + /// Normalized module specifier (e.g. `./nested.rss`). + pub spec: String, + pub clause: ImportClause, + pub span: Span, + pub line: usize, + pub target: Option, +} + +/// One declaration owned by a module, with its deterministic [`SymbolId`]. +/// +/// Milestone 3: every declaration in a module receives a symbol whose +/// `module` is the owning [`ModuleId`] and whose `index` is the declaration's +/// position in the module's declaration table. Two same-named declarations in +/// independent modules therefore never share a symbol. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeclSymbol { + pub symbol: SymbolId, + pub name: String, + /// `true` when the declaration is marked `pub` and appears in the module's + /// public export table. + pub public: bool, +} + +/// One entry of a module's public export table. +/// +/// The table is populated exclusively from local public declarations: +/// imported bindings never appear here, so re-exporting another module's +/// functions requires an explicit mechanism and there is no implicit +/// transitive re-export through the graph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportEntry { + pub name: String, + pub symbol: SymbolId, +} + +/// A binding introduced into a module by a resolved import edge. +/// +/// Imported bindings are tracked separately from local declarations: they are +/// never part of [`ModuleNode::declarations`] and never enter +/// [`ModuleNode::exports`]. `local_name` is the name the importing module +/// binds (`as` alias for named imports, or the source name otherwise); +/// `source_name` is the declaration's name in the source module. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportedBinding { + pub local_name: String, + pub source_module: ModuleId, + pub source_symbol: SymbolId, + pub source_name: String, +} + +/// One node of the [`ModuleGraph`]: a module, its resolved imports, and its +/// milestone-3 declaration/export/imported-binding tables. +/// +/// Milestone 1-2 fills `imports` during source-loader discovery; milestone 3 +/// fills `declarations`, `exports`, and `imported_bindings` once each module's +/// unit is parsed. +#[derive(Clone, Debug)] +pub struct ModuleNode { + pub module: ModuleId, + pub source: SourceId, + /// Canonical disk identity (or normalized virtual identity) of the module. + pub identity: PathBuf, + /// Display name used in diagnostics. + pub source_name: String, + pub imports: Vec, + /// Local declarations in source order, each with its owned symbol. + pub declarations: Vec, + /// Public export table: local `pub` declarations only. + pub exports: Vec, + /// Bindings introduced by import edges, separate from local declarations. + pub imported_bindings: Vec, +} + +/// The module graph for one compilation. +/// +/// Nodes are registered in deterministic discovery order; the first node is +/// always the root unit. `by_identity` guarantees that the same canonical +/// module identity maps to exactly one node, so modules with identical file +/// stems in different directories stay distinct while lexically equivalent +/// paths collapse. +#[derive(Default)] +pub struct ModuleGraph { + nodes: Vec, + by_identity: HashMap, + next_source: u32, +} + +impl ModuleGraph { + pub fn new() -> Self { + Self::default() + } + + /// Register a module node for `identity`, or return the existing node id. + /// + /// The first call for the root unit yields `ModuleId(0)` / `SourceId(0)`; + /// subsequent first-time registrations receive the next unused ids in + /// call order. + pub fn add_node( + &mut self, + identity: PathBuf, + source_name: String, + imports: Vec, + ) -> ModuleId { + if let Some(existing) = self.by_identity.get(&identity) { + return *existing; + } + let module = ModuleId(u32::try_from(self.nodes.len()).unwrap_or(u32::MAX)); + let source = SourceId(self.next_source); + self.next_source = self.next_source.saturating_add(1); + self.by_identity.insert(identity.clone(), module); + self.nodes.push(ModuleNode { + module, + source, + identity, + source_name, + imports, + declarations: Vec::new(), + exports: Vec::new(), + imported_bindings: Vec::new(), + }); + module + } + + pub fn node(&self, module: ModuleId) -> Option<&ModuleNode> { + self.nodes.get(module.0 as usize) + } + + pub fn nodes(&self) -> &[ModuleNode] { + &self.nodes + } + + pub fn len(&self) -> usize { + self.nodes.len() + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + pub fn module_id_for_identity(&self, identity: &Path) -> Option { + self.by_identity.get(identity).copied() + } + + /// Append one resolved import edge to a module node. + pub fn add_import(&mut self, module: ModuleId, import: ResolvedImport) { + if let Some(node) = self.nodes.get_mut(module.0 as usize) { + node.imports.push(import); + } + } + + /// Register one local declaration of `module` and return its symbol. + /// + /// Symbols are deterministic: the first declaration of a module is + /// `SymbolId { module, index: 0 }`, the second `index: 1`, and so on in + /// registration order. Public declarations are appended to the module's + /// export table at the same time; imported bindings never enter it. + /// + /// Errors when the module already declares the same name (the parser + /// normally reports this first with a source line) or when the name is + /// already bound by an import. + pub fn add_declaration( + &mut self, + module: ModuleId, + name: &str, + public: bool, + ) -> Result { + let node = self.node_mut(module)?; + if node.declarations.iter().any(|decl| decl.name == name) { + return Err(format!( + "duplicate local declaration '{name}' in module '{}'", + node.source_name + )); + } + if node + .imported_bindings + .iter() + .any(|binding| binding.local_name == name) + { + return Err(format!( + "local declaration '{name}' conflicts with an imported binding in module '{}'", + node.source_name + )); + } + let index = u32::try_from(node.declarations.len()) + .map_err(|_| format!("too many declarations in module '{}'", node.source_name))?; + let symbol = SymbolId { module, index }; + node.declarations.push(DeclSymbol { + symbol, + name: name.to_string(), + public, + }); + if public { + node.exports.push(ExportEntry { + name: name.to_string(), + symbol, + }); + } + Ok(symbol) + } + + /// Record one binding introduced by an import edge of `module`. + /// + /// Imported bindings stay out of `declarations` and `exports`: nothing in + /// the graph re-exports them implicitly. Binding the same local name from + /// several modules is recorded as several bindings (the legacy pipeline + /// merges such imports by name; milestone 4 resolves them by symbol), but + /// a binding that clashes with a local declaration is rejected. + pub fn add_imported_binding( + &mut self, + module: ModuleId, + binding: ImportedBinding, + ) -> Result<(), String> { + let node = self.node_mut(module)?; + if node + .declarations + .iter() + .any(|decl| decl.name == binding.local_name) + { + return Err(format!( + "imported binding '{}' conflicts with a local declaration in module '{}'", + binding.local_name, node.source_name + )); + } + node.imported_bindings.push(binding); + Ok(()) + } + + pub fn declaration(&self, module: ModuleId, name: &str) -> Option<&DeclSymbol> { + self.node(module)? + .declarations + .iter() + .find(|decl| decl.name == name) + } + + pub fn declaration_symbol(&self, module: ModuleId, name: &str) -> Option { + self.declaration(module, name).map(|decl| decl.symbol) + } + + pub fn export(&self, module: ModuleId, name: &str) -> Option<&ExportEntry> { + self.node(module)? + .exports + .iter() + .find(|entry| entry.name == name) + } + + pub fn symbol_for_export(&self, module: ModuleId, name: &str) -> Option { + self.export(module, name).map(|entry| entry.symbol) + } + + pub fn imported_binding(&self, module: ModuleId, name: &str) -> Option<&ImportedBinding> { + self.node(module)? + .imported_bindings + .iter() + .find(|binding| binding.local_name == name) + } + + fn node_mut(&mut self, module: ModuleId) -> Result<&mut ModuleNode, String> { + self.nodes + .get_mut(module.0 as usize) + .ok_or_else(|| format!("unknown module id {} in module graph", module.0)) + } +} + +fn is_valid_ident_segment(input: &str) -> bool { + let mut chars = input.chars(); + let Some(first) = chars.next() else { + return false; + }; + is_ident_start(first) && chars.all(is_ident_continue) +} + +/// Convert a structured `use` path into a module specifier. +/// +/// Replicates the legacy resolver's semantics from structured segments: +/// leading `self`/`super` qualifiers become `./`/`../` prefixes, a leading +/// `crate` is rejected, remaining segments join into a relative path, and the +/// result is normalized to a `.rss` specifier. +pub(super) fn use_path_to_spec( + path: &Path, + line: usize, + segments: &[UsePathSegment], +) -> Result { + let invalid = |message: &str| SourcePathError::InvalidImportSyntax { + path: path.to_path_buf(), + line, + message: message.to_string(), + }; + if segments.is_empty() { + return Err(invalid("expected module path after 'use'")); + } + + let mut path_prefix = PathBuf::new(); + let mut cursor = 0usize; + let mut explicit_self = false; + while cursor < segments.len() { + match &segments[cursor] { + UsePathSegment::Self_ => { + explicit_self = true; + cursor += 1; + } + UsePathSegment::Super => { + path_prefix.push(".."); + cursor += 1; + } + UsePathSegment::Ident(name) if name == "crate" => { + return Err(invalid( + "crate:: paths are not supported; use relative module paths", + )); + } + UsePathSegment::Ident(_) => break, + } + } + if cursor >= segments.len() { + return Err(invalid("expected module name after path qualifiers")); + } + + for segment in &segments[cursor..] { + match segment { + UsePathSegment::Ident(name) => { + if !is_valid_ident_segment(name) { + return Err(invalid(&format!( + "invalid module path segment '{name}' in use directive" + ))); + } + path_prefix.push(name); + } + // Mid-path qualifier words are literal file segments, mirroring + // the legacy line-based resolver. + UsePathSegment::Self_ => path_prefix.push("self"), + UsePathSegment::Super => path_prefix.push("super"), + } + } + + let mut spec = path_prefix.to_string_lossy().replace('\\', "/"); + if spec.is_empty() { + return Err(invalid("expected module path after 'use'")); + } + if explicit_self && !spec.starts_with("../") { + spec = format!("./{spec}"); + } + if !spec.ends_with(".rss") { + spec.push_str(".rss"); + } + Ok(spec) +} + +#[cfg(test)] +mod tests { + use super::{ + ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SourceId, + SymbolId, UsePathSegment, use_path_to_spec, + }; + use crate::compiler::source_loader::ImportClause; + use crate::compiler::source_map::Span; + use std::path::PathBuf; + + fn ident(name: &str) -> UsePathSegment { + UsePathSegment::Ident(name.to_string()) + } + + #[test] + fn use_path_to_spec_plain_module_path() { + let path = PathBuf::from("/root/main.rss"); + let spec = use_path_to_spec(&path, 1, &[ident("helpers")]).expect("spec should resolve"); + assert_eq!(spec, "helpers.rss"); + let spec = + use_path_to_spec(&path, 1, &[ident("a"), ident("b")]).expect("spec should resolve"); + assert_eq!(spec, "a/b.rss"); + } + + #[test] + fn use_path_to_spec_self_and_super_qualifiers() { + let path = PathBuf::from("/root/pkg/main.rss"); + let spec = use_path_to_spec(&path, 1, &[UsePathSegment::Self_, ident("nested")]) + .expect("spec should resolve"); + assert_eq!(spec, "./nested.rss"); + let spec = use_path_to_spec(&path, 1, &[UsePathSegment::Super, ident("shared")]) + .expect("spec should resolve"); + assert_eq!(spec, "../shared.rss"); + let spec = use_path_to_spec( + &path, + 1, + &[ + UsePathSegment::Self_, + UsePathSegment::Super, + ident("nested"), + ], + ) + .expect("spec should resolve"); + assert_eq!(spec, "../nested.rss"); + let spec = use_path_to_spec( + &path, + 1, + &[UsePathSegment::Self_, UsePathSegment::Self_, ident("x")], + ) + .expect("spec should resolve"); + assert_eq!(spec, "./x.rss"); + } + + #[test] + fn use_path_to_spec_rejects_leading_crate() { + let path = PathBuf::from("/root/main.rss"); + let err = use_path_to_spec(&path, 4, &[ident("crate"), ident("x")]) + .expect_err("crate:: should be rejected"); + let message = err.to_string(); + assert!( + message.contains("crate:: paths are not supported"), + "unexpected error: {message}" + ); + assert!( + message.contains("line 4"), + "line should be preserved: {message}" + ); + } + + #[test] + fn use_path_to_spec_requires_module_name_after_qualifiers() { + let path = PathBuf::from("/root/main.rss"); + let err = use_path_to_spec(&path, 2, &[UsePathSegment::Self_]) + .expect_err("bare self:: should be rejected"); + assert!( + err.to_string() + .contains("expected module name after path qualifiers"), + "unexpected error: {err}" + ); + } + + #[test] + fn use_path_to_spec_mid_path_qualifier_words_are_literal_segments() { + let path = PathBuf::from("/root/main.rss"); + let spec = use_path_to_spec(&path, 1, &[ident("a"), UsePathSegment::Self_, ident("b")]) + .expect("mid-path self should be a literal segment"); + assert_eq!(spec, "a/self/b.rss"); + } + + #[test] + fn module_graph_ids_are_deterministic_and_deduplicated() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + assert_eq!(root, ModuleId(0)); + let again = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + assert_eq!( + again, root, + "re-registering the same identity reuses the node" + ); + + let nested = graph.add_node( + PathBuf::from("/root/nested.rss"), + "/root/nested.rss".to_string(), + Vec::new(), + ); + assert_eq!(nested, ModuleId(1)); + assert_eq!(graph.node(nested).expect("node exists").source, SourceId(1)); + } + + #[test] + fn module_graph_same_stem_modules_are_distinct() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a/common.rss"), + "/root/a/common.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/common.rss"), + "/root/b/common.rss".to_string(), + Vec::new(), + ); + assert_ne!( + first, second, + "modules that share a stem but differ by directory must be distinct" + ); + assert_eq!(graph.len(), 2); + assert_eq!( + graph.module_id_for_identity(PathBuf::from("/root/b/common.rss").as_path()), + Some(second) + ); + } + + #[test] + fn module_graph_records_import_edges_and_targets() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let nested = graph.add_node( + PathBuf::from("/root/nested.rss"), + "/root/nested.rss".to_string(), + Vec::new(), + ); + graph.add_import( + root, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec: "./nested.rss".to_string(), + clause: ImportClause::Namespace("nested".to_string()), + span: Span::new(0, 0, 0), + line: 1, + target: Some(nested), + }, + ); + graph.add_import( + root, + ResolvedImport { + kind: ImportTargetKind::BuiltinNamespace, + spec: "json.rss".to_string(), + clause: ImportClause::AllPublic, + span: Span::new(0, 0, 0), + line: 2, + target: None, + }, + ); + let node = graph.node(root).expect("root node exists"); + assert_eq!(node.imports.len(), 2); + assert_eq!(node.imports[0].target, Some(nested)); + assert_eq!(node.imports[1].kind, ImportTargetKind::BuiltinNamespace); + assert_eq!(node.imports[1].target, None); + } + + #[test] + fn symbol_ids_are_module_scoped() { + let a = SymbolId { + module: ModuleId(0), + index: 3, + }; + let b = SymbolId { + module: ModuleId(1), + index: 3, + }; + assert_ne!(a, b, "same index in different modules must differ"); + assert_eq!( + SymbolId { + module: ModuleId(0), + index: 3 + }, + a + ); + } + + #[test] + fn declaration_symbols_are_deterministic_and_module_owned() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a.rss"), + "/root/a.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b.rss"), + "/root/b.rss".to_string(), + Vec::new(), + ); + let first_symbol = graph + .add_declaration(first, "run", true) + .expect("declaration registers"); + let second_symbol = graph + .add_declaration(second, "run", true) + .expect("declaration registers"); + assert_eq!( + first_symbol, + SymbolId { + module: first, + index: 0 + }, + "first declaration of a module owns symbol index 0" + ); + assert_eq!( + second_symbol, + SymbolId { + module: second, + index: 0 + }, + "same index in a different module is a different symbol" + ); + assert_ne!(first_symbol, second_symbol); + + let next = graph + .add_declaration(first, "helper", false) + .expect("declaration registers"); + assert_eq!( + next, + SymbolId { + module: first, + index: 1 + }, + "symbols are assigned in declaration order" + ); + assert_eq!(graph.declaration_symbol(first, "run"), Some(first_symbol)); + assert_eq!(graph.declaration_symbol(second, "run"), Some(second_symbol)); + } + + #[test] + fn export_table_contains_only_public_declarations() { + let mut graph = ModuleGraph::new(); + let module = graph.add_node( + PathBuf::from("/root/lib.rss"), + "/root/lib.rss".to_string(), + Vec::new(), + ); + let public = graph + .add_declaration(module, "visible", true) + .expect("public declaration registers"); + graph + .add_declaration(module, "hidden", false) + .expect("private declaration registers"); + + let node = graph.node(module).expect("node exists"); + assert_eq!( + node.exports.len(), + 1, + "only the pub declaration is exported" + ); + assert_eq!(node.exports[0].name, "visible"); + assert_eq!(node.exports[0].symbol, public); + assert_eq!(graph.symbol_for_export(module, "visible"), Some(public)); + assert_eq!( + graph.symbol_for_export(module, "hidden"), + None, + "private declarations never enter the export table" + ); + assert!( + !graph + .declaration(module, "hidden") + .expect("declaration exists") + .public + ); + assert_eq!(node.declarations.len(), 2); + } + + #[test] + fn duplicate_local_declaration_is_rejected() { + let mut graph = ModuleGraph::new(); + let module = graph.add_node( + PathBuf::from("/root/lib.rss"), + "/root/lib.rss".to_string(), + Vec::new(), + ); + graph + .add_declaration(module, "run", true) + .expect("first declaration registers"); + let err = graph + .add_declaration(module, "run", false) + .expect_err("second same-named declaration must be rejected"); + assert!( + err.contains("duplicate local declaration 'run'"), + "unexpected error: {err}" + ); + // The same name in another module is not a duplicate. + let other = graph.add_node( + PathBuf::from("/root/other.rss"), + "/root/other.rss".to_string(), + Vec::new(), + ); + graph + .add_declaration(other, "run", true) + .expect("independent module may reuse the name"); + } + + #[test] + fn imported_bindings_stay_separate_from_local_declarations() { + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let source = graph.add_node( + PathBuf::from("/root/util.rss"), + "/root/util.rss".to_string(), + Vec::new(), + ); + let exported = graph + .add_declaration(source, "helper", true) + .expect("source export registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: source, + source_symbol: exported, + source_name: "helper".to_string(), + }, + ) + .expect("imported binding registers"); + + let node = graph.node(importer).expect("importer node exists"); + assert_eq!( + node.declarations.len(), + 0, + "imported symbols are not local declarations" + ); + assert_eq!( + node.exports.len(), + 0, + "imported symbols never enter the export table" + ); + assert_eq!(node.imported_bindings.len(), 1); + let binding = graph + .imported_binding(importer, "helper") + .expect("binding exists"); + assert_eq!(binding.source_module, source); + assert_eq!(binding.source_symbol, exported); + assert_eq!(binding.source_name, "helper"); + } + + #[test] + fn same_named_declarations_across_modules_coexist() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a/util.rss"), + "/root/a/util.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/util.rss"), + "/root/b/util.rss".to_string(), + Vec::new(), + ); + let first_helper = graph + .add_declaration(first, "helper", false) + .expect("private helper registers"); + let second_helper = graph + .add_declaration(second, "helper", false) + .expect("private helper registers"); + assert_ne!( + first_helper, second_helper, + "same-named helpers in independent modules have distinct symbols" + ); + assert_eq!( + graph.declaration_symbol(first, "helper"), + Some(first_helper) + ); + assert_eq!( + graph.declaration_symbol(second, "helper"), + Some(second_helper) + ); + + // Same-named *public* functions in independent modules coexist too, + // each in its own export table. + graph + .add_declaration(first, "run", true) + .expect("public run registers in first module"); + graph + .add_declaration(second, "run", true) + .expect("public run registers in second module"); + let first_run = graph + .symbol_for_export(first, "run") + .expect("first export exists"); + let second_run = graph + .symbol_for_export(second, "run") + .expect("second export exists"); + assert_ne!(first_run, second_run); + } + + #[test] + fn no_implicit_transitive_reexport_through_the_graph() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let middle = graph.add_node( + PathBuf::from("/root/middle.rss"), + "/root/middle.rss".to_string(), + Vec::new(), + ); + let leaf = graph.add_node( + PathBuf::from("/root/leaf.rss"), + "/root/leaf.rss".to_string(), + Vec::new(), + ); + let shared = graph + .add_declaration(leaf, "shared", true) + .expect("leaf export registers"); + let middle_own = graph + .add_declaration(middle, "middle_own", true) + .expect("middle export registers"); + + // middle imports leaf's export; root imports middle's export. + for (importer, source, source_symbol, source_name) in [ + (middle, leaf, shared, "shared"), + (root, middle, middle_own, "middle_own"), + ] { + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: source_name.to_string(), + source_module: source, + source_symbol, + source_name: source_name.to_string(), + }, + ) + .expect("imported binding registers"); + } + + assert_eq!( + graph.symbol_for_export(middle, "shared"), + None, + "middle's export table must not re-export leaf's function" + ); + assert_eq!( + graph.symbol_for_export(root, "shared"), + None, + "root's export table must not see leaf's function through middle" + ); + assert_eq!( + graph.symbol_for_export(middle, "middle_own"), + Some(middle_own), + "middle's own public declaration stays in its export table" + ); + assert_eq!( + graph.symbol_for_export(root, "middle_own"), + None, + "root's export table is empty: direct imports become bindings, not exports" + ); + assert!(graph.imported_binding(root, "middle_own").is_some()); + assert!(graph.imported_binding(middle, "shared").is_some()); + assert!( + graph + .node(root) + .expect("root node exists") + .exports + .is_empty() + ); + } + + #[test] + fn imported_binding_clashing_with_local_declaration_is_rejected() { + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let source = graph.add_node( + PathBuf::from("/root/util.rss"), + "/root/util.rss".to_string(), + Vec::new(), + ); + let exported = graph + .add_declaration(source, "helper", true) + .expect("source export registers"); + graph + .add_declaration(importer, "helper", true) + .expect("local declaration registers"); + + let err = graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: source, + source_symbol: exported, + source_name: "helper".to_string(), + }, + ) + .expect_err("binding a name already declared locally must be rejected"); + assert!( + err.contains("conflicts with a local declaration"), + "unexpected error: {err}" + ); + } + + #[test] + fn duplicate_imported_bindings_are_recorded_like_the_legacy_merge() { + // Two modules exporting the same name, both imported into one module: + // the legacy pipeline merges such imports by name, so the graph keeps + // every binding instead of rejecting the second one. + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let first = graph.add_node( + PathBuf::from("/root/a/util.rss"), + "/root/a/util.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/util.rss"), + "/root/b/util.rss".to_string(), + Vec::new(), + ); + let first_helper = graph + .add_declaration(first, "helper", true) + .expect("first export registers"); + let second_helper = graph + .add_declaration(second, "helper", true) + .expect("second export registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: first, + source_symbol: first_helper, + source_name: "helper".to_string(), + }, + ) + .expect("first binding registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: second, + source_symbol: second_helper, + source_name: "helper".to_string(), + }, + ) + .expect("second binding with the same local name registers"); + + let node = graph.node(importer).expect("importer node exists"); + assert_eq!(node.imported_bindings.len(), 2); + assert_eq!(node.imported_bindings[0].source_module, first); + assert_eq!(node.imported_bindings[1].source_module, second); + assert_eq!( + node.declarations.len(), + 0, + "bindings are never declarations" + ); + assert!(node.exports.is_empty(), "bindings are never exports"); + } +} diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index 451b1d57..e9c6217e 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -398,7 +398,7 @@ impl Parser { .get(1..) .map(|tail| tail.to_vec()) .unwrap_or_default(); - if let Some((builtin_namespace, builtin_member)) = + let expr = if let Some((builtin_namespace, builtin_member)) = self.resolve_builtins_call_path(&name, &member, &subpath) { let builtin_namespace = builtin_namespace.to_string(); @@ -406,23 +406,37 @@ impl Parser { if let Some(builtin) = resolve_builtin_namespace_call(&builtin_namespace, &builtin_member) { - let expr = - self.build_builtin_call_expr_with_type_args(builtin, args, type_args)?; - return Ok(expr); + self.build_builtin_call_expr_with_type_args(builtin, args, type_args)? + } else { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!( + "unknown builtin function '{}::{}'", + builtin_namespace, builtin_member + ), + }); } + } else if let Some(host_name) = + self.resolve_host_namespace_call_target(&name, &member, &subpath) + { + self.build_host_call_expr_with_type_args(&host_name, args, type_args)? + } else if self.allow_implicit_externs + && self.module_namespace_alias(&name).is_some() + { + // File-module namespace call (`alias::member(...)`): the + // parser cannot resolve the member against the target + // module's exports — only the source loader can. Emit an + // implicit extern carrying the qualified name; the loader + // resolves the call to a `ModuleCall` or rejects it. + // Type-argument validation is deferred to the loader, + // which knows the exported type parameters. + let qualified = format!("{}::{}", name, path_segments.join("::")); + let decl = self.resolve_function_for_call(&qualified, args.len())?; + Expr::Call(decl.index, type_args, args) + } else { return Err(ParseError { - span: None, - code: None, - line: self.current_line(), - message: format!( - "unknown builtin function '{}::{}'", - builtin_namespace, builtin_member - ), - }); - } - let host_name = self - .resolve_host_namespace_call_target(&name, &member, &subpath) - .ok_or_else(|| ParseError { span: None, code: None, line: self.current_line(), @@ -432,8 +446,11 @@ impl Parser { path_segments.join("::"), builtin_namespace_hint() ), - })?; - let expr = self.build_host_call_expr_with_type_args(&host_name, args, type_args)?; + }); + }; + // Namespace calls participate in postfix access like any + // other call (`iter::range(n)[0]`, `json::decode::(s).x`). + let expr = self.parse_postfix_access(expr)?; return Ok(expr); } @@ -488,7 +505,11 @@ impl Parser { } } else { let decl = self.resolve_function_for_call(&name, args.len())?; - self.validate_named_call_type_args(&decl, &type_args)?; + // Implicit externs mirror imported calls the loader + // validates against the exported signature. + if !self.is_implicit_extern(&name) { + self.validate_named_call_type_args(&decl, &type_args)?; + } Expr::Call(decl.index, type_args, args) } } else if let Some(expr) = self.try_build_language_builtin_call(&name, &args)? { @@ -507,7 +528,14 @@ impl Parser { self.build_host_call_expr_with_type_args(&host_name, args, type_args)? } else { let decl = self.resolve_function_for_call(&name, args.len())?; - self.validate_named_call_type_args(&decl, &type_args)?; + // Import-scan and module-mode parses tolerate type + // arguments on calls whose target only the source + // loader can type; the loader validates them against + // the exported signature. Plain compile parses + // validate locally declared functions normally. + if !self.import_scan_mode && !self.is_implicit_extern(&name) { + self.validate_named_call_type_args(&decl, &type_args)?; + } Expr::Call(decl.index, type_args, args) } } else { @@ -541,6 +569,11 @@ impl Parser { }); } Expr::FunctionRef(index, Vec::new()) + } else if self.allow_implicit_externs { + // Module mode: the name may be an imported function + // binding the loader resolves to a module symbol + // (`Expr::ModuleFunctionRef`) before unit merge. + Expr::UnresolvedFunctionRef { name, type_args } } else { return Err(ParseError { span: None, @@ -1610,7 +1643,12 @@ impl Parser { args: Vec, type_args: Vec, ) -> Result { - self.validate_host_call_type_args(host_name, &type_args)?; + // Import-scan parses discard their IR; the compile parse validates + // host type arguments unless the namespace may name a file module + // (deferred to the source loader, which knows the exports). + if !self.import_scan_mode && self.host_type_args_validated_at_parse(host_name) { + self.validate_host_call_type_args(host_name, &type_args)?; + } let arity = u8::try_from(args.len()).map_err(|_| ParseError { span: None, code: None, @@ -1621,6 +1659,20 @@ impl Parser { Ok(Expr::Call(decl.index, type_args, args)) } + /// Whether host type arguments are validated at parse time. + /// + /// Single-segment import forms (`use module;`, `use module::{wrap}`) may + /// name a file module; the parser cannot know, so calls through such + /// namespaces defer type-argument validation to the source loader, which + /// validates against the module's exported type parameters. Builtin + /// namespaces keep their parse-time validation. + fn host_type_args_validated_at_parse(&self, host_name: &str) -> bool { + match host_name.split_once("::") { + Some((namespace, _)) => is_builtin_namespace(namespace), + None => true, + } + } + pub(super) fn contextualize_function_value( &self, expr: &mut Expr, @@ -2392,7 +2444,7 @@ fn builtin_generic_type_arg_arity(builtin: BuiltinFunction) -> GenericCallableTy } } -fn host_generic_type_arg_arity(host_name: &str) -> Option { +pub(crate) fn host_generic_type_arg_arity(host_name: &str) -> Option { match host_name { "json::decode" => Some(1), _ => None, diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index f989111e..98f6b40d 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -15,8 +15,10 @@ use crate::builtins::{ BuiltinFunction, builtin_namespace_hint, default_host_callable, is_builtin_namespace, resolve_builtin_namespace_call, }; +use crate::compiler::modules::{UseDecl, UsePathSegment}; use crate::compiler::source_map::{SourceId, Span}; +pub(crate) use self::expressions::host_generic_type_arg_arity; use self::lexer::{Lexer, ParserFormatArg, Token, TokenKind, is_ident_continue, is_ident_start}; use self::symbols::is_virtual_host_namespace_spec; use super::{ @@ -138,6 +140,19 @@ pub(super) struct Parser { host_namespace_aliases: HashMap, direct_host_call_aliases: HashMap, direct_host_wildcard_imports: HashSet, + /// Names created through the implicit-extern fallback (module mode). + /// The source loader uses this marker to keep synthetic externs out of + /// module declaration/export tables and to resolve (or reject) their + /// call sites. + implicit_extern_names: HashSet, + /// Namespace aliases introduced by file-module `use` directives. + /// + /// Unlike [`Parser::host_namespace_aliases`] these are recorded in every + /// parse mode; a namespace call that is neither builtin nor host resolves + /// through this map into a loader-resolved module call placeholder. + module_namespace_aliases: HashMap, + use_declarations: Vec, + import_scan_mode: bool, mutable_locals: Vec, borrowed_map_iter_locals: Vec, local_schemas: HashMap, @@ -155,6 +170,7 @@ impl Parser { allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + import_scan_mode: bool, dialect: &'static dyn ParserDialect, ) -> Result { let mut lexer = Lexer::new(source, source_id, dialect); @@ -193,6 +209,10 @@ impl Parser { host_namespace_aliases: HashMap::new(), direct_host_call_aliases: HashMap::new(), direct_host_wildcard_imports: HashSet::new(), + implicit_extern_names: HashSet::new(), + module_namespace_aliases: HashMap::new(), + use_declarations: Vec::new(), + import_scan_mode, mutable_locals: Vec::new(), borrowed_map_iter_locals: Vec::new(), local_schemas: HashMap::new(), @@ -214,6 +234,7 @@ impl Parser { allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + false, dialect, )?; for binding in predeclared_locals { @@ -222,6 +243,10 @@ impl Parser { Ok(parser) } + pub(super) fn use_declarations(&self) -> Vec { + self.use_declarations.clone() + } + pub(super) fn parse_program(&mut self) -> Result, ParseError> { self.predeclare_functions()?; let mut stmts = Vec::new(); @@ -336,6 +361,7 @@ impl Parser { type_params, exported, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name, decl.clone()); self.function_list.push(decl); @@ -388,6 +414,28 @@ impl Parser { self.unknown_type_spans.clone() } + pub(super) fn implicit_extern_names(&self) -> Vec { + let mut names = self + .implicit_extern_names + .iter() + .cloned() + .collect::>(); + names.sort(); + names + } + + pub(super) fn is_implicit_extern(&self, name: &str) -> bool { + self.implicit_extern_names.contains(name) + } + + /// Look up a file-module namespace alias recorded from a structured + /// `use` directive (both parse modes). + pub(super) fn module_namespace_alias(&self, namespace: &str) -> Option<&str> { + self.module_namespace_aliases + .get(namespace) + .map(String::as_str) + } + fn validate_schema_reference_sites(&self) -> Result<(), ParseError> { for (name, arg_count, line, span) in &self.schema_reference_sites { let Some(decl) = self.struct_schemas.get(name) else { diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 53811c91..92e24cb3 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -1,5 +1,17 @@ use super::*; +use crate::compiler::source_loader::{ImportClause, NamedImport}; + +/// Classify a `use` path segment: leading `self`/`super` words become +/// qualifiers; every other segment is a plain identifier. +fn classify_use_segment(segment: &str) -> UsePathSegment { + match segment { + "self" => UsePathSegment::Self_, + "super" => UsePathSegment::Super, + _ => UsePathSegment::Ident(segment.to_string()), + } +} + impl Parser { pub(super) fn parse_stmt(&mut self) -> Result { if self.match_kind(&TokenKind::Pub) { @@ -94,21 +106,42 @@ impl Parser { pub(super) fn parse_use_stmt(&mut self) -> Result { let line = self.last_line(); - let namespace = self.expect_ident("expected namespace after 'use'")?; - if self.match_kind(&TokenKind::Semicolon) { - self.host_namespace_aliases - .insert(namespace.clone(), namespace); - return Ok(Stmt::Noop { line }); - } + let directive_start = self + .tokens + .get(self.pos.saturating_sub(1)) + .map(|token| token.span.lo) + .unwrap_or(0); - if self.match_kind(&TokenKind::As) { - let alias = self.expect_ident("expected namespace alias after 'as'")?; - self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; - self.host_namespace_aliases.insert(alias, namespace); - return Ok(Stmt::Noop { line }); - } + let namespace = self.expect_ident("expected namespace after 'use'")?; if !self.match_path_separator() { + // Single-segment host-namespace forms: `use io;`, `use io as x;`. + if self.match_kind(&TokenKind::Semicolon) { + self.host_namespace_aliases + .insert(namespace.clone(), namespace.clone()); + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::AllPublic, + line, + directive_start, + ); + return Ok(Stmt::Noop { line }); + } + + if self.match_kind(&TokenKind::As) { + let alias = self.expect_ident("expected namespace alias after 'as'")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; + self.host_namespace_aliases + .insert(alias.clone(), namespace.clone()); + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::Namespace(alias), + line, + directive_start, + ); + return Ok(Stmt::Noop { line }); + } + return Err(ParseError { span: None, code: None, @@ -130,45 +163,210 @@ impl Parser { }); } + if namespace == "crate" { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: "crate:: paths are not supported; use relative module paths".to_string(), + }); + } + if self.match_kind(&TokenKind::Star) { - self.direct_host_wildcard_imports.insert(namespace); + self.direct_host_wildcard_imports.insert(namespace.clone()); self.expect( &TokenKind::Semicolon, "expected ';' after host wildcard import", )?; + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::AllPublic, + line, + directive_start, + ); return Ok(Stmt::Noop { line }); } - self.expect(&TokenKind::LBrace, "expected '{' after host import path")?; - if self.match_kind(&TokenKind::Star) { - self.direct_host_wildcard_imports.insert(namespace); - self.expect(&TokenKind::RBrace, "expected '}' after '*'")?; + if self.match_kind(&TokenKind::LBrace) { + let named = self.parse_use_named_list(&namespace, true)?; + self.expect(&TokenKind::RBrace, "expected '}' after use list")?; self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::Named(named), + line, + directive_start, + ); return Ok(Stmt::Noop { line }); } + // Multi-segment file-module path: `use a::b;`, `use a::b::*;`, + // `use a::b::{x};`, `use a::b as alias;`, with optional leading + // `self`/`super` qualifiers. These directives are consumed as + // structured nodes; the source loader resolves them against the + // module graph, so no host aliases are recorded here. + let mut path = vec![classify_use_segment(&namespace)]; + let mut qualifier_run = matches!(path[0], UsePathSegment::Self_ | UsePathSegment::Super); loop { - let imported = self.expect_ident("expected host function name in use list")?; - let local = if self.match_kind(&TokenKind::As) { - self.expect_ident("expected local alias after 'as'")? - } else { - imported.clone() - }; - let target = format!("{namespace}::{imported}"); - if let Some(existing) = self.direct_host_call_aliases.get(&local) - && existing != &target - { + if self.match_kind(&TokenKind::Star) { + self.expect(&TokenKind::Semicolon, "expected ';' after use wildcard")?; + self.record_use_decl(path, ImportClause::AllPublic, line, directive_start); + return Ok(Stmt::Noop { line }); + } + if self.match_kind(&TokenKind::LBrace) { + let named = self.parse_use_named_list(&namespace, false)?; + self.expect(&TokenKind::RBrace, "expected '}' after use list")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; + self.record_use_decl(path, ImportClause::Named(named), line, directive_start); + return Ok(Stmt::Noop { line }); + } + let segment = self.expect_ident("expected module path segment after '::'")?; + if qualifier_run && segment == "crate" { return Err(ParseError { span: None, code: None, line: self.current_line(), - message: format!( - "host import alias '{local}' already maps to '{existing}', cannot remap to '{target}'" - ), + message: "crate:: paths are not supported; use relative module paths" + .to_string(), }); } - self.direct_host_call_aliases.insert(local, target); + let classified = if qualifier_run { + classify_use_segment(&segment) + } else { + UsePathSegment::Ident(segment) + }; + qualifier_run = matches!(classified, UsePathSegment::Self_ | UsePathSegment::Super); + path.push(classified); + if !self.match_path_separator() { + break; + } + } + + if self.match_kind(&TokenKind::As) { + let alias = self.expect_ident("expected namespace alias after 'as'")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; + self.record_use_decl(path, ImportClause::Namespace(alias), line, directive_start); + } else { + self.expect(&TokenKind::Semicolon, "expected ';' after use directive")?; + self.record_use_decl(path, ImportClause::AllPublic, line, directive_start); + } + Ok(Stmt::Noop { line }) + } + + /// Record a structured `use` declaration with its directive span. + /// + /// In import-scan mode (source-loader discovery) the parser also records + /// host aliases for file-module paths so that namespace calls parse + /// before the loader's resolution pass runs; the compile parse runs with + /// scan mode off and resolves module namespaces through the structured + /// declarations instead. The alias is the clause alias for namespace + /// imports and the default namespace (last path segment) for all-public + /// and named imports. + fn record_use_decl( + &mut self, + path: Vec, + clause: ImportClause, + line: u32, + directive_start: usize, + ) { + let source_id = self.current_span().source_id; + let end = self + .tokens + .get(self.pos.saturating_sub(1)) + .map(|token| token.span.hi) + .unwrap_or(directive_start); + if self.import_scan_mode { + let alias = match &clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic | ImportClause::Named(_) => match path.last() { + Some(UsePathSegment::Ident(name)) => Some(name.clone()), + _ => None, + }, + ImportClause::Prefix(_) => None, + }; + if let Some(alias) = alias { + let joined = path + .iter() + .map(|segment| match segment { + UsePathSegment::Self_ => "self".to_string(), + UsePathSegment::Super => "super".to_string(), + UsePathSegment::Ident(name) => name.clone(), + }) + .collect::>() + .join("::"); + self.host_namespace_aliases.insert(alias, joined); + } + } + // File-module namespace aliases are recorded in every parse mode so + // the compile parse can recognize `alias::member(...)` calls and emit + // a loader-resolved placeholder. The alias mirrors the loader's + // clause-based namespace table: the `as` alias for namespace + // imports, the last path segment for all-public imports, and no + // namespace for named imports (which bind direct names only). + let module_alias = match &clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic => match path.last() { + Some(UsePathSegment::Ident(name)) => Some(name.clone()), + _ => None, + }, + ImportClause::Named(_) | ImportClause::Prefix(_) => None, + }; + if let Some(alias) = module_alias { + let joined = path + .iter() + .map(|segment| match segment { + UsePathSegment::Self_ => "self".to_string(), + UsePathSegment::Super => "super".to_string(), + UsePathSegment::Ident(name) => name.clone(), + }) + .collect::>() + .join("::"); + self.module_namespace_aliases.insert(alias, joined); + } + self.use_declarations.push(UseDecl { + path, + clause, + span: Span::new(source_id, directive_start, end), + line: line as usize, + }); + } + /// Parse the named list of a `use` directive. + /// + /// Single-segment forms keep the legacy host behavior: every binding is + /// recorded as a direct host call alias (`use io::{read};` maps `read` to + /// `io::read`). Multi-segment file-module forms only collect the bindings; + /// the module graph's resolution pass owns their resolution. + fn parse_use_named_list( + &mut self, + namespace: &str, + record_host_aliases: bool, + ) -> Result, ParseError> { + let mut named = Vec::::new(); + loop { + let imported = self.expect_ident("expected host function name in use list")?; + let local = if self.match_kind(&TokenKind::As) { + self.expect_ident("expected local alias after 'as'")? + } else { + imported.clone() + }; + if record_host_aliases { + let target = format!("{namespace}::{imported}"); + if let Some(existing) = self.direct_host_call_aliases.get(&local) + && existing != &target + { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!( + "host import alias '{local}' already maps to '{existing}', cannot remap to '{target}'" + ), + }); + } + self.direct_host_call_aliases.insert(local.clone(), target); + } + named.push(NamedImport { imported, local }); if self.match_kind(&TokenKind::Comma) { if self.check(&TokenKind::RBrace) { break; @@ -177,9 +375,7 @@ impl Parser { } break; } - self.expect(&TokenKind::RBrace, "expected '}' after use list")?; - self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; - Ok(Stmt::Noop { line }) + Ok(named) } pub(super) fn parse_js_import_stmt(&mut self) -> Result { @@ -494,6 +690,7 @@ impl Parser { type_params: type_params.clone(), exported, return_type, + symbol: None, }; self.functions.insert(name.clone(), decl.clone()); let current_line = self.current_line(); diff --git a/src/compiler/parser/symbols.rs b/src/compiler/parser/symbols.rs index 35adcd84..8290e75f 100644 --- a/src/compiler/parser/symbols.rs +++ b/src/compiler/parser/symbols.rs @@ -280,6 +280,7 @@ impl Parser { type_params: Vec::new(), exported: true, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); @@ -310,6 +311,10 @@ impl Parser { message: format!("name '{name}' already used by a local binding"), }); } + // The module loader resolves (or rejects) every implicit extern's + // call sites; the marker keeps synthetic externs out of module + // declaration/export tables. + self.implicit_extern_names.insert(name.to_string()); let index = self.next_function; self.next_function = self.next_function.checked_add(1).ok_or(ParseError { span: None, @@ -328,6 +333,7 @@ impl Parser { type_params: Vec::new(), exported: true, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); @@ -376,6 +382,7 @@ impl Parser { type_params: Vec::new(), exported: false, return_type: known_host_return_type(name), + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index ff6e768a..dda97e63 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -7,7 +7,8 @@ use super::ReplLocalState; use super::codegen::Compiler; use super::frontends; use super::ir::{Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, TypeSchema}; -use super::linker::merge_units; +use super::linker::{ParsedUnit, merge_units}; +use super::modules::ModuleGraph; use super::source_loader::load_units_for_source_file; use super::source_map::SourceMap; use super::{ @@ -173,7 +174,9 @@ fn record_expr_local_debug_ranges( | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => { note_local_use(ranges, *index, line); } @@ -200,7 +203,7 @@ fn record_expr_local_debug_ranges( record_expr_local_debug_ranges(value, line, ranges); record_expr_local_debug_ranges(fallback, line, ranges); } - Expr::Call(_, _, args) => { + Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { record_expr_local_debug_ranges(arg, line, ranges); } @@ -869,8 +872,9 @@ fn lint_unknown_inferred_local_types_at_path_with_options_impl( ) -> Result, SourcePathError> { let mut source_map = SourceMap::new(); let source_id = source_map.add_source(path.display().to_string(), source.to_string()); - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let parsed = units + let loaded = load_units_for_source_file(path, flavor, source, options)?; + let parsed = loaded + .units .into_iter() .last() .map(|unit| unit.parsed) @@ -888,8 +892,9 @@ fn collect_inferred_local_type_hints_at_path_with_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let parsed = units + let loaded = load_units_for_source_file(path, flavor, source, options)?; + let parsed = loaded + .units .into_iter() .last() .map(|unit| unit.parsed) @@ -1321,6 +1326,45 @@ fn compile_source_with_flavor_impl( } } +fn compile_loaded_units( + source: String, + units: Vec, + flavor: SourceFlavor, + // Carried from the loader for Milestone 2+ (structured imports, symbol + // resolution); codegen output is unchanged until then. + _module_graph: ModuleGraph, + // Compilation-wide source map keyed by the module graph's `SourceId` + // space (milestone 5). Every span produced during load/merge references + // this map, so errors are returned with it and render from the owning + // source. + sources: SourceMap, +) -> Result { + let diagnostic_path = units + .iter() + .find(|unit| !unit.parsed.unknown_type_spans.is_empty()) + .map(|unit| PathBuf::from(&unit.source_name)); + let merged = merge_units(units)?; + compile_parsed_output( + source, + merged, + CompileBehavior::DEFAULT, + TypingMode::for_flavor(flavor), + matches!(flavor, SourceFlavor::RustScript), + ) + .map_err(|error| match (error, diagnostic_path) { + (SourceError::Parse(mut parse), Some(path)) + if parse.code.as_deref() == Some("E_STRICT_UNKNOWN_TYPE") => + { + parse.message = format!("{}: {}", path.display(), parse.message); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources, + } + } + (error, _) => SourcePathError::SourceWithMap { error, sources }, + }) +} + fn compile_source_with_flavor_and_options_impl( source: &str, flavor: SourceFlavor, @@ -1332,16 +1376,14 @@ fn compile_source_with_flavor_and_options_impl( } let path = virtual_inmemory_entry_path(flavor); - let (_root_parse_source, units) = load_units_for_source_file(&path, flavor, source, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(&path, flavor, source, options)?; + compile_loaded_units( source.to_string(), - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn compile_source_at_path_with_flavor_and_options_impl( @@ -1350,16 +1392,14 @@ fn compile_source_at_path_with_flavor_and_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(path, flavor, source, options)?; + compile_loaded_units( source.to_string(), - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn virtual_inmemory_entry_path(flavor: SourceFlavor) -> PathBuf { @@ -1389,17 +1429,14 @@ fn compile_source_file_impl( ) -> Result { let flavor = SourceFlavor::from_path_with_options(path, options)?; let source_raw = std::fs::read_to_string(path)?; - let (_root_parse_source, units) = - load_units_for_source_file(path, flavor, &source_raw, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(path, flavor, &source_raw, options)?; + compile_loaded_units( source_raw, - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn run_with_compiler_stack(f: F) -> T diff --git a/src/compiler/source_loader.rs b/src/compiler/source_loader.rs index 5478bae2..1228550b 100644 --- a/src/compiler/source_loader.rs +++ b/src/compiler/source_loader.rs @@ -1,7 +1,49 @@ +//! Semantic file-module loading. +//! +//! This module is the sole file-module path of the compiler (milestone 6): +//! there is no textual import rewriting, no synthetic imported-function +//! prelude, and no prelude line-map remapping anymore. Every module source is +//! parsed verbatim with the real frontend parser; `use` directives become +//! structured [`UseDecl`](crate::compiler::modules::UseDecl) nodes, the +//! [`ModuleGraph`] assigns deterministic [`ModuleId`]s/[`SourceId`]s and +//! records import edges, exports, and imported bindings, and calls to +//! imported functions are resolved to [`SymbolId`]s before unit merge. +//! +//! ## Load pipeline +//! +//! 1. `collect_module_units` discovers the import graph from the root, +//! registering every module (disk or in-memory override) in the +//! [`ModuleGraph`] and its raw text in the compilation-wide +//! [`SourceMap`](crate::compiler::source_map::SourceMap) at its graph +//! `SourceId`. +//! 2. Each module is parsed in module mode (implicit-extern fallback on): +//! calls the parser cannot resolve locally — imported module functions, +//! module namespace members, imported function values — parse into +//! synthetic externs (tracked on `FrontendIr::implicit_extern_names`) or +//! `Expr::UnresolvedFunctionRef`, and are resolved afterwards. +//! 3. `record_module_symbols` assigns every real declaration its owned +//! [`SymbolId`], fills the module's public export table and imported +//! binding table, then resolves every call site to an `Expr::ModuleCall` +//! or `Expr::ModuleFunctionRef` carrying the target symbol, validating +//! arity and type arguments against the exported signature. +//! 4. `linker::merge_units` merges units by symbol identity and applies the +//! deterministic flat-boundary mangling only there. +//! +//! Host namespace imports (`use io;`, `use myhost;`) never enter the textual +//! machinery: the parser keeps their dedicated host resolution path and the +//! loader records them as host/builtin import edges. Single-segment imports +//! that may name a file module (`use module;`) parse as host-form calls and +//! are fixed up by the loader when the spec resolves to a file module. +//! +//! Every span produced here references the owning module's graph `SourceId` +//! in the compilation-wide map, so diagnostics always render from the +//! owning source. + use std::path::Path; use crate::compiler::source_map::SourceMap; +use super::modules::ModuleGraph; use super::{ CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, linker::ParsedUnit, @@ -9,68 +51,547 @@ use super::{ mod graph; mod imports; -mod line_map; mod model; -mod rewrite; -use graph::{build_rustscript_import_prelude, collect_module_units}; -use imports::{parse_module_imports, strip_import_directives}; -use line_map::remap_frontend_ir_line_numbers; +use graph::{collect_module_units, record_module_symbols}; +use imports::{module_identity, parse_module_imports, strip_import_directives}; use model::ModuleCollectState; pub use model::{FrontendImportSyntax, ImportClause, ModuleImport, NamedImport}; -use rewrite::rewrite_imported_call_sites; +pub(super) struct LoadedSourceUnits { + pub(super) units: Vec, + /// Semantic module graph built during discovery (milestones 1-3). + pub(super) module_graph: ModuleGraph, + /// Compilation-wide source map keyed by the module graph's `SourceId` + /// space (milestone 5). Every module's raw text is registered here at + /// its graph source id, so spans carried by the loaded units and by any + /// load-time diagnostic resolve to the owning source. + pub(super) sources: SourceMap, +} pub(super) fn load_units_for_source_file( path: &Path, flavor: SourceFlavor, source_raw: &str, options: &CompileSourceFileOptions, -) -> Result<(String, Vec), SourcePathError> { - let root_imports = parse_module_imports(source_raw, flavor, path, options)?; - let source = strip_import_directives(source_raw, flavor, options)?; +) -> Result { + // The root participates in the same identity scheme as every module: + // canonical disk identity when the file exists, normalized virtual + // identity otherwise. This keeps `seen`/`visiting`/exports/overrides + // keyed uniformly across the whole import graph. + let path = module_identity(path.to_path_buf()); + let path = path.as_path(); let mut collect_state = ModuleCollectState::default(); + // Pre-register the root text at its graph source id. The root node is + // always registered first (SourceId(0)); registering the text here lets + // the root's own scan/parse diagnostics attach spans against the + // compilation-wide map before collection runs. + collect_state + .sources + .add_source_at(0, path.display().to_string(), source_raw.to_string()); collect_state.visiting.push(path.to_path_buf()); - collect_module_units(path, source_raw, flavor, options, &mut collect_state)?; - let rewritten_root = rewrite_imported_call_sites( - &source, + let root_imports = parse_module_imports(source_raw, flavor, path, options).map_err(|err| { + // The root's own scan/parse diagnostics attach their span against + // the pre-registered root source and carry the compilation-wide map, + // so they render from the root's text. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse.span = None; + parse = parse.with_line_span_from_source(&collect_state.sources, 0); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), + } + } + other => other, + } + })?; + + collect_module_units(path, source_raw, flavor, options, &mut collect_state).map_err(|err| { + // Load-time source diagnostics (nested scan/parse errors, symbol + // resolution, imported-call resolution) already carry spans keyed to + // the compilation-wide map; attach the map so they render from the + // owning source. + match err { + SourcePathError::Source(error) => SourcePathError::SourceWithMap { + error, + sources: collect_state.sources.clone(), + }, + other => other, + } + })?; + let root_module = collect_state + .module_graph + .module_id_for_identity(path) + .expect("root module should be registered in the module graph"); + let root_source_id = collect_state + .module_graph + .node(root_module) + .map(|node| node.source.0) + .unwrap_or(0); + let root_parse_source = strip_import_directives(source_raw, flavor, options)?; + + let mut root_parsed = frontends::parse_module_source_with_source_id( + &root_parse_source, flavor, - path, - &root_imports, - &collect_state.module_exports, options, - )?; - let mut prelude = build_rustscript_import_prelude( + root_source_id, + ) + .map_err(|mut err| { + // Module sources are parsed verbatim (no synthetic prelude, no + // textual rewrite), so parse lines already refer to the owning + // source; rebuild the span against the compilation-wide map so the + // diagnostic renders from the root's text. + err.span = None; + let parse = err.with_line_span_from_source(&collect_state.sources, root_source_id); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), + } + })?; + record_module_symbols( + &mut collect_state, + root_module, path, &root_imports, - &collect_state.module_exports, + &mut root_parsed, options, - )?; - let root_prelude_lines = prelude.lines().count(); - prelude.push_str(&rewritten_root.source); - let root_parse_source = prelude; - - let mut root_source_map = SourceMap::new(); - let root_source_id = root_source_map.add_source(path.display().to_string(), source_raw); - let mut root_parsed = frontends::parse_source(&root_parse_source, flavor, options) - .map_err(|mut err| { - if root_prelude_lines > 0 { - err.line = err.line.saturating_sub(root_prelude_lines).max(1); - // Reattach span against original source text for diagnostics. - err.span = None; - } - SourceError::Parse(err.with_line_span_from_source(&root_source_map, root_source_id)) - }) - .map_err(SourcePathError::Source)?; - if root_prelude_lines > 0 { - remap_frontend_ir_line_numbers(&mut root_parsed, root_prelude_lines); - } + ) + .map_err(|err| match err { + // Root resolution diagnostics (unknown/ambiguous imported calls, + // visibility failures) already carry spans keyed to the + // compilation-wide map; attach the map so they render from the + // owning source. + SourcePathError::Source(error) => SourcePathError::SourceWithMap { + error, + sources: collect_state.sources.clone(), + }, + other => other, + })?; collect_state.units.push(ParsedUnit { parsed: root_parsed, - scope_prefix: None, + scope_identity: None, source_name: path.display().to_string(), + module: root_module, + source_id: root_source_id, }); - Ok((root_parse_source, collect_state.units)) + Ok(LoadedSourceUnits { + units: collect_state.units, + module_graph: collect_state.module_graph, + sources: collect_state.sources, + }) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::super::modules::ModuleId; + use super::*; + + fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + // Module identities are canonical for existing files; keep expected + // paths canonical too so assertions match under symlinked temp dirs. + root.canonicalize().unwrap_or(root) + } + + fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source) + .unwrap_or_else(|err| panic!("{description} should write: {err}")); + } + + fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); + } + + /// Two modules named `util.rss` in different directories plus a root that + /// imports both. Returns `(main, a/util, b/util)` paths. + fn write_same_stem_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + let b_module = b_dir.join("util.rss"); + write_source(&a_module, "pub fn helper() { 1; }\n", "a/util source"); + write_source(&b_module, "pub fn helper() { 2; }\n", "b/util source"); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nfn run() { au::helper(); bu::helper(); }\n", + "main source", + ); + (main_path, a_module, b_module) + } + + #[test] + fn loader_graph_records_same_stem_modules_in_different_directories() { + let root = temp_module_root("semantic_m1_same_stem"); + let (main_path, a_module, b_module) = write_same_stem_fixture(&root); + let main_source = std::fs::read_to_string(&main_path).expect("main source readable"); + + let loaded = load_units_for_source_file( + &main_path, + SourceFlavor::RustScript, + &main_source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed"); + let graph = loaded.module_graph; + assert_eq!(graph.len(), 3, "root plus two same-stem modules"); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main module should be registered"); + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + assert_ne!( + a_id, b_id, + "same-stem modules in different dirs must differ" + ); + assert_eq!(main_id, ModuleId(0), "root module is always module 0"); + + // Import edges from the root to both modules, in source order. + let main_node = graph.node(main_id).expect("main node should exist"); + assert_eq!(main_node.imports.len(), 2); + let targets: Vec<_> = main_node + .imports + .iter() + .map(|import| import.target) + .collect(); + assert!(targets.contains(&Some(a_id))); + assert!(targets.contains(&Some(b_id))); + assert!(main_node.imports.iter().all(|import| import.line >= 1)); + assert_eq!(main_node.imports[0].spec, "a/util.rss"); + assert_eq!(main_node.imports[1].spec, "b/util.rss"); + + remove_module_root(&root); + } + + #[test] + fn loader_graph_is_deterministic_across_loads() { + let root = temp_module_root("semantic_m1_deterministic"); + let (main_path, _, _) = write_same_stem_fixture(&root); + + let load = || { + let source = std::fs::read_to_string(&main_path).expect("main source readable"); + load_units_for_source_file( + &main_path, + SourceFlavor::RustScript, + &source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed") + .module_graph + }; + let first = load(); + let second = load(); + assert_eq!(first.len(), second.len()); + let sequence = |graph: &ModuleGraph| { + graph + .nodes() + .iter() + .map(|node| (node.module, node.identity.clone())) + .collect::>() + }; + assert_eq!(sequence(&first), sequence(&second)); + + remove_module_root(&root); + } + + #[test] + fn loader_graph_uses_virtual_identity_for_in_memory_modules() { + let path = PathBuf::from("__pd_vm_inmemory__/main.rss"); + let source = "use a::util;\nfn run() { helper(); }\n"; + let options = CompileSourceFileOptions::new() + .with_module_override_source("a/util.rss", "pub fn helper() { 1; }\n"); + + let loaded = load_units_for_source_file(&path, SourceFlavor::RustScript, source, &options) + .expect("virtual load should succeed"); + let graph = loaded.module_graph; + assert_eq!(graph.len(), 2, "virtual root plus overridden module"); + + let main_id = graph + .module_id_for_identity(&path) + .expect("virtual main should be registered"); + let a_id = graph + .module_id_for_identity(PathBuf::from("__pd_vm_inmemory__/a/util.rss").as_path()) + .expect("virtual override module should be registered"); + assert_ne!(main_id, a_id); + + let main_node = graph.node(main_id).expect("main node should exist"); + assert_eq!(main_node.imports.len(), 1); + assert_eq!(main_node.imports[0].target, Some(a_id)); + assert_eq!( + main_node.imports[0].kind, + super::super::modules::ImportTargetKind::FileModule + ); + } + + /// Fixture: `main` imports `a/util` (pub alpha + private helper) and + /// `b/util` (pub beta + private helper). Both helpers are private and + /// same-named; `a/util` also imports a third module `leaf` (pub shared) + /// so the transitive re-export rule is exercised through the real loader. + fn write_symbol_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let leaf_module = a_dir.join("leaf.rss"); + write_source(&leaf_module, "pub fn shared() { 100; }\n", "leaf source"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "use self::leaf;\npub fn alpha() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn beta() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nfn run() { au::alpha(); bu::beta(); }\n", + "main source", + ); + (main_path, a_module, b_module, leaf_module) + } + + fn load_fixture(main_path: &Path) -> ModuleGraph { + let main_source = std::fs::read_to_string(main_path).expect("main source readable"); + load_units_for_source_file( + main_path, + SourceFlavor::RustScript, + &main_source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed") + .module_graph + } + + #[test] + fn loader_records_public_exports_and_private_declarations() { + let root = temp_module_root("semantic_m3_exports"); + let (main_path, a_module, b_module, _) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let a_node = graph.node(a_id).expect("a/util node exists"); + let a_names = |node: &super::super::modules::ModuleNode| { + node.declarations + .iter() + .map(|decl| decl.name.clone()) + .collect::>() + }; + assert_eq!(a_names(a_node), vec!["alpha", "helper"]); + assert_eq!( + a_node + .exports + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + vec!["alpha"], + "only the pub declaration is exported" + ); + assert!( + !graph + .declaration(a_id, "helper") + .expect("private helper exists") + .public + ); + assert_eq!( + graph.symbol_for_export(a_id, "helper"), + None, + "private helpers never enter the export table" + ); + + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + assert_eq!( + graph + .symbol_for_export(b_id, "beta") + .expect("beta is exported") + .module, + b_id, + "each module's exports are owned by that module" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_keeps_imported_bindings_separate_and_blocks_transitive_reexport() { + let root = temp_module_root("semantic_m3_bindings"); + let (main_path, a_module, _, leaf_module) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let a_node = graph.node(a_id).expect("a/util node exists"); + assert_eq!( + a_node.imported_bindings.len(), + 1, + "a/util imports exactly leaf::shared" + ); + let binding = &a_node.imported_bindings[0]; + assert_eq!(binding.local_name, "shared"); + assert_eq!(binding.source_name, "shared"); + assert_eq!( + binding.source_module, + graph + .module_id_for_identity(&leaf_module) + .expect("leaf should be registered") + ); + assert_eq!( + graph + .symbol_for_export(a_id, "shared") + .map(|symbol| symbol.module), + None, + "a/util must not re-export leaf's function" + ); + assert!( + a_node.declarations.iter().all(|decl| decl.name != "shared"), + "the imported function is not a local declaration of a/util" + ); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main should be registered"); + let main_node = graph.node(main_id).expect("main node exists"); + assert_eq!( + main_node.imported_bindings.len(), + 2, + "main imports alpha and beta" + ); + assert!( + main_node + .imported_bindings + .iter() + .all(|binding| binding.local_name == "alpha" || binding.local_name == "beta") + ); + assert!( + main_node.imported_bindings.iter().all(|binding| { + graph + .symbol_for_export(main_id, &binding.local_name) + .is_none() + }), + "main's export table stays empty: no implicit re-export of anything imported" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_assigns_distinct_symbols_to_same_named_private_helpers() { + let root = temp_module_root("semantic_m3_same_named"); + let (main_path, a_module, b_module, _) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + let a_helper = graph + .declaration_symbol(a_id, "helper") + .expect("a helper exists"); + let b_helper = graph + .declaration_symbol(b_id, "helper") + .expect("b helper exists"); + assert_ne!( + a_helper, b_helper, + "same-named private helpers in independent modules own distinct symbols" + ); + assert_eq!(a_helper.module, a_id); + assert_eq!(b_helper.module, b_id); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main should be registered"); + let main_alpha = graph + .declaration_symbol(main_id, "run") + .expect("run exists"); + assert_eq!( + main_alpha, + super::super::modules::SymbolId { + module: main_id, + index: 0 + }, + "root declarations start at symbol index 0" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_symbols_are_deterministic_across_loads() { + let root = temp_module_root("semantic_m3_symbol_determinism"); + let (main_path, a_module, _, _) = write_symbol_fixture(&root); + let first = load_fixture(&main_path); + let second = load_fixture(&main_path); + + let symbol_sequence = |graph: &ModuleGraph| { + graph + .nodes() + .iter() + .map(|node| { + ( + node.module, + node.declarations + .iter() + .map(|decl| (decl.name.clone(), decl.symbol)) + .collect::>(), + node.exports + .iter() + .map(|entry| (entry.name.clone(), entry.symbol)) + .collect::>(), + node.imported_bindings + .iter() + .map(|binding| { + ( + binding.local_name.clone(), + binding.source_symbol, + binding.source_module, + ) + }) + .collect::>(), + ) + }) + .collect::>() + }; + assert_eq!(symbol_sequence(&first), symbol_sequence(&second)); + assert_eq!(first.len(), second.len()); + let _ = a_module; + + remove_module_root(&root); + } } diff --git a/src/compiler/source_loader/graph.rs b/src/compiler/source_loader/graph.rs index 82e95c30..a997a1ec 100644 --- a/src/compiler/source_loader/graph.rs +++ b/src/compiler/source_loader/graph.rs @@ -1,16 +1,18 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use crate::compiler::source_map::SourceMap; use super::super::{ - CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, - linker::{ParsedUnit, sanitize_scope_prefix}, + CompileSourceFileOptions, ParseError, SourceError, SourceFlavor, SourcePathError, frontends, + ir::{Expr, FrontendIr, FunctionDecl, Stmt, TypeSchema}, + linker::{ParsedUnit, module_scope_prefix}, + modules::{ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SymbolId}, }; use super::imports::{ is_builtin_host_namespace_spec, is_module_specifier, is_virtual_host_namespace_spec, - parse_module_imports, resolve_module_path, should_treat_missing_module_as_host_namespace, - strip_import_directives, + parse_module_imports, resolve_module_path, scan_module_imports, + should_treat_missing_module_as_host_namespace, }; use super::model::{ExportedFunctionSignature, ImportClause, ModuleCollectState, ModuleImport}; @@ -21,13 +23,79 @@ pub(super) fn collect_module_units( options: &CompileSourceFileOptions, state: &mut ModuleCollectState, ) -> Result<(), SourcePathError> { - let imports = parse_module_imports(source, flavor, path, options)?; - for import in imports { - let spec = import.spec; + // Register this module in the semantic graph. Registration is + // identity-keyed and idempotent, so the root and every nested module get + // a deterministic `ModuleId`/`SourceId` in first-encounter order. + let current_id = + state + .module_graph + .add_node(path.to_path_buf(), path.display().to_string(), Vec::new()); + // Register the module's raw text in the compilation-wide source map at + // its graph `SourceId`, before any scan or parse that can produce spans + // referencing that id (milestone 5: every span stays owned by its + // module's source). + let current_source_id = state + .module_graph + .node(current_id) + .map(|node| node.source) + .unwrap_or(crate::compiler::modules::SourceId(0)); + state.sources.add_source_at( + current_source_id.0, + path.display().to_string(), + source.to_string(), + ); + let (imports, decls) = scan_module_imports(source, flavor, path, options).map_err(|err| { + // Nested module sources surface their parse errors through the same + // path-prefixed diagnostic shape the compile parse uses. The root is + // scanned (and fails, if at all) in `load_units_for_source_file` + // before this point, so it never receives a prefix here. The scan + // parser numbers spans with its own local source id 0, so the span + // is always rebuilt against the owning module's graph source id — + // offsets from one module must never be interpreted in another. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse.message = format!("{}: {}", path.display(), parse.message); + parse.span = None; + parse = parse.with_line_span_from_source(&state.sources, current_source_id.0); + SourcePathError::Source(SourceError::Parse(parse)) + } + other => other, + } + })?; + for (import_index, import) in imports.iter().enumerate() { + let spec = import.spec.clone(); + let span = decls + .get(import_index) + .map(|decl| decl.span) + .unwrap_or_else(|| crate::compiler::source_map::Span::new(0, 0, 0)); if is_builtin_host_namespace_spec(&spec) { + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::BuiltinNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } if !is_module_specifier(&spec) { + // Plugin-managed host imports (non-RustScript flavors) stay on + // their dedicated resolution path. + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } let resolved = resolve_module_path(path, &spec, options)?; @@ -35,12 +103,37 @@ pub(super) fn collect_module_units( if key == path && is_virtual_host_namespace_spec(&spec, options) { // `use io;` / `use re;` inside files named `io.rss` / `re.rss` should // keep behaving as host-namespace imports instead of self-module cycles. + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } if state.visiting.contains(&key) { return Err(SourcePathError::ImportCycle(key)); } if state.seen.contains(&key) { + // Already loaded: keep the resolved edge pointing at the existing + // node instead of re-collecting the module. + let target = state.module_graph.module_id_for_identity(&key); + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target, + }, + ); continue; } @@ -52,6 +145,17 @@ pub(super) fn collect_module_units( Ok(source) => source, Err(err) => { if should_treat_missing_module_as_host_namespace(&spec, options, &err) { + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } return Err(SourcePathError::Io(err)); @@ -68,22 +172,43 @@ pub(super) fn collect_module_units( )?; state.visiting.pop(); - let module_source = - strip_import_directives(&module_source_raw, SourceFlavor::RustScript, options)?; - let mut module_source_map = SourceMap::new(); - let module_source_id = - module_source_map.add_source(resolved.display().to_string(), module_source.clone()); - let parsed = frontends::parse_source(&module_source, SourceFlavor::RustScript, options) - .map_err(|err| { - SourceError::Parse( - err.with_line_span_from_source(&module_source_map, module_source_id), - ) - }) - .map_err(SourcePathError::Source)?; + let module_imports = parse_module_imports( + &module_source_raw, + SourceFlavor::RustScript, + &resolved, + options, + )?; + let module_source_id = state + .module_graph + .module_id_for_identity(&key) + .and_then(|module| state.module_graph.node(module)) + .map(|node| node.source.0) + .unwrap_or(0); + let mut parsed = frontends::parse_module_source_with_source_id( + &module_source_raw, + SourceFlavor::RustScript, + options, + module_source_id, + ) + .map_err(|mut err| { + // Nested module sources are parsed verbatim (no synthetic + // prelude, no textual rewrite), so the parse already reports the + // owning module's lines; rebuild the span against the + // compilation-wide map and prefix the module path. + err.span = None; + let mut parse = err.with_line_span_from_source(&state.sources, module_source_id); + parse.message = format!("{}: {}", resolved.display(), parse.message); + SourceError::Parse(parse) + })?; + let extern_names = parsed + .implicit_extern_names + .iter() + .map(String::as_str) + .collect::>(); let exports = parsed .functions .iter() - .filter(|func| func.exported) + .filter(|func| func.exported && !extern_names.contains(func.name.as_str())) .map(|func| { ( func.name.clone(), @@ -94,11 +219,36 @@ pub(super) fn collect_module_units( ) }) .collect::>(); + let target = state + .module_graph + .module_id_for_identity(&key) + .expect("module node should be registered during collection"); + record_module_symbols( + state, + target, + &resolved, + &module_imports, + &mut parsed, + options, + )?; state.units.push(ParsedUnit { parsed, - scope_prefix: Some(sanitize_scope_prefix(&resolved)), + scope_identity: Some(module_scope_prefix(&resolved, target)), source_name: resolved.display().to_string(), + module: target, + source_id: module_source_id, }); + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: Some(target), + }, + ); state.module_exports.insert(key.clone(), exports); state.seen.insert(key); } @@ -115,37 +265,125 @@ fn module_source_override<'a>( }) } -pub(super) fn build_rustscript_import_prelude( - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let declared = collect_imported_module_functions(path, imports, module_exports, options)?; - let mut prelude = String::new(); - for (name, signature) in declared { - let type_params = if signature.type_params.is_empty() { - String::new() - } else { - format!("<{}>", signature.type_params.join(", ")) +/// Build the exported-signature table keyed by [`SymbolId`]. +/// +/// The loader validates call sites against the exported arity and type +/// parameters at resolution time (the parse can no longer see them: module +/// sources are parsed verbatim without a synthetic prelude). +fn exported_signature_table( + state: &ModuleCollectState, + graph: &ModuleGraph, +) -> HashMap { + let mut table = HashMap::new(); + for node in graph.nodes() { + let Some(exports) = state.module_exports.get(&node.identity) else { + continue; }; - let args = (0..signature.arity) - .map(|idx| format!("arg{idx}")) - .collect::>() - .join(", "); - prelude.push_str(&format!("pub fn {name}{type_params}({args});\n")); + for entry in &node.exports { + if let Some(signature) = exports.get(&entry.name) { + table.insert(entry.symbol, signature.clone()); + } + } + } + table +} + +/// Namespace portion of a qualified call name (`au::helper` → `au`). +fn namespace_of(qualified: &str) -> &str { + qualified + .split_once("::") + .map(|(namespace, _)| namespace) + .unwrap_or(qualified) +} + +/// Clause-derived namespace alias of one import edge, mirroring the parser's +/// module-namespace alias rules: the `as` alias for namespace imports, the +/// spec stem for all-public imports, and no namespace for named imports. +fn namespace_alias_for_import(import: &ResolvedImport) -> Option { + match &import.clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic => Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_string()), + ImportClause::Named(_) | ImportClause::Prefix(_) => None, } - Ok(prelude) } -pub(super) fn collect_imported_module_functions( +/// File-module import targets that bind `namespace`, either through a clause +/// alias (`use a::util as au;` binds `au`) or through the spec stem +/// (host-form single-segment imports such as `use module;` whose namespace +/// the parser resolved as a host root). +fn file_module_targets_for_namespace( + graph: &ModuleGraph, + module: ModuleId, + namespace: &str, +) -> Vec { + let Some(node) = graph.node(module) else { + return Vec::new(); + }; + let mut targets = Vec::new(); + for import in &node.imports { + if import.kind != ImportTargetKind::FileModule { + continue; + } + let Some(target) = import.target else { + continue; + }; + let stem = Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()); + if (namespace_alias_for_import(import).as_deref() == Some(namespace) + || stem == Some(namespace)) + && !targets.contains(&target) + { + targets.push(target); + } + } + targets +} + +/// Whether any file-module import edge of `module` binds `qualified`'s +/// namespace. Host-form declarations whose namespace names a file module are +/// kept out of the module's declaration table; the resolution pass converts +/// their call sites to [`Expr::ModuleCall`] instead. +fn namespace_has_file_module_target( + graph: &ModuleGraph, + module: ModuleId, + qualified: &str, +) -> bool { + !file_module_targets_for_namespace(graph, module, namespace_of(qualified)).is_empty() +} + +/// One function binding introduced by an import edge, before it is recorded +/// in the module graph. +struct ImportBindingData { + /// Name the importing module binds (`as` alias for named imports). + local_name: String, + /// Name of the declaration in the source module. + source_name: String, + /// Source module once its graph node is known; `None` for host/builtin + /// namespaces that stay on their dedicated resolution paths. + source_module: Option, + /// Line of the `use` directive that introduced the binding. + line: usize, +} + +/// Collect the function bindings a module's imports introduce, structurally. +/// +/// Mirrors the legacy `collect_imported_module_functions` resolution rules +/// (builtin and non-module specifiers skipped, missing virtual host namespaces +/// tolerated) but preserves `as` aliases and resolves the source module in +/// the semantic graph, so the loader can record [`ImportedBinding`]s that +/// stay separate from local declarations. +fn collect_imported_bindings( path: &Path, imports: &[ModuleImport], module_exports: &HashMap>, + graph: &ModuleGraph, options: &CompileSourceFileOptions, -) -> Result, SourcePathError> { - let mut imported_functions = HashMap::::new(); - +) -> Result, SourcePathError> { + let mut bindings = Vec::new(); for import in imports { if is_builtin_host_namespace_spec(&import.spec) { continue; @@ -165,22 +403,22 @@ pub(super) fn collect_imported_module_functions( message: format!("module '{}' did not load", import.spec), }); }; + let source_module = graph.module_id_for_identity(&resolved); match &import.clause { ImportClause::AllPublic | ImportClause::Namespace(_) | ImportClause::Prefix(_) => { - for (name, signature) in exports { - merge_imported_function_signature( - &mut imported_functions, - name, - signature, - path, - import.line, - )?; + for name in exports.keys() { + bindings.push(ImportBindingData { + local_name: name.clone(), + source_name: name.clone(), + source_module, + line: import.line, + }); } } ImportClause::Named(named) => { for binding in named { - let signature = exports.get(&binding.imported).cloned().ok_or_else(|| { + let _signature = exports.get(&binding.imported).cloned().ok_or_else(|| { SourcePathError::InvalidImportSyntax { path: path.to_path_buf(), line: import.line, @@ -190,48 +428,796 @@ pub(super) fn collect_imported_module_functions( ), } })?; - merge_imported_function_signature( - &mut imported_functions, - &binding.imported, - &signature, - path, - import.line, - )?; + bindings.push(ImportBindingData { + local_name: binding.local.clone(), + source_name: binding.imported.clone(), + source_module, + line: import.line, + }); } } } } - - let mut declared = imported_functions.into_iter().collect::>(); - declared.sort_by(|(lhs_name, _), (rhs_name, _)| lhs_name.cmp(rhs_name)); - Ok(declared) + Ok(bindings) } -fn merge_imported_function_signature( - imported_functions: &mut HashMap, - name: &str, - signature: &ExportedFunctionSignature, +/// Attach milestone-3 declaration symbols and imported bindings to a parsed +/// unit's module node, then resolve imported call sites to their target +/// symbols (milestone 4). +/// +/// Runs once per module, right after its unit is parsed: imported-binding +/// mirror declarations and implicit externs are skipped, every remaining +/// function declaration receives a [`SymbolId`] owned by the module, public +/// declarations populate the module's export table, and every import-introduced +/// binding is recorded separately in the module's imported-binding table. +/// The same `symbol` is written back onto the parsed [`FunctionDecl`] so the +/// linker can collect it through `merge_units`. Finally, calls to imported +/// functions and module namespace members are resolved to [`Expr::ModuleCall`] +/// nodes carrying the target [`SymbolId`]. +pub(super) fn record_module_symbols( + state: &mut ModuleCollectState, + module: ModuleId, path: &Path, - line: usize, + imports: &[ModuleImport], + parsed: &mut FrontendIr, + options: &CompileSourceFileOptions, ) -> Result<(), SourcePathError> { - if let Some(existing) = imported_functions.get_mut(name) { - existing.arity = existing.arity.max(signature.arity); - if existing.type_params != signature.type_params { - if existing.type_params.is_empty() { - existing.type_params = signature.type_params.clone(); - } else if !signature.type_params.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, + let bindings = collect_imported_bindings( + path, + imports, + &state.module_exports, + &state.module_graph, + options, + )?; + // Implicit externs (module mode) mirror calls the loader must resolve or + // reject; they never become local declarations or flat entries. + let extern_names = parsed + .implicit_extern_names + .iter() + .cloned() + .collect::>(); + + let module_source_id = state + .module_graph + .node(module) + .map(|node| node.source.0) + .unwrap_or(0); + let decl_lines = collect_function_decl_lines(&parsed.stmts); + let signatures = exported_signature_table(state, &state.module_graph); + + for func in &mut parsed.functions { + if extern_names.contains(func.name.as_str()) { + // Implicit extern (module mode): the resolution pass resolves + // (or rejects) its call sites; never a local decl. + continue; + } + if func.name.contains("::") + && namespace_has_file_module_target(&state.module_graph, module, &func.name) + { + // Host-form declaration whose namespace names a file module + // (single-segment import forms such as `use module;`): the + // resolution pass converts its call sites to `ModuleCall`, so + // the declaration must not become a flat host entry. + continue; + } + // A local declaration whose name is also imported is recorded here + // and then rejected when the import binding is added below: no + // silent shadowing of imported names. + let decl_line = decl_lines + .get(&func.index) + .copied() + .map(|line| line as usize) + .unwrap_or(1); + let symbol = state + .module_graph + .add_declaration(module, &func.name, func.exported) + .map_err(|message| { + // The duplicate symbol diagnostic renders from the owning + // module source: same-named declarations collide inside one + // module only, and the span points at the redeclaration. + let span = state.sources.line_span(module_source_id, decl_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: decl_line, + message: format!("{}: {message}", path.display()), + })) + })?; + func.symbol = Some(symbol); + } + + for binding in bindings { + let Some(source_module) = binding.source_module else { + continue; + }; + let binding_line = binding.line.max(1); + let source_symbol = state + .module_graph + .symbol_for_export(source_module, &binding.source_name) + .ok_or_else(|| { + // Visibility failure: the import directive is the offending + // site, so the span points at the `use` line in the + // importing module's source. + let span = state.sources.line_span(module_source_id, binding_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: binding_line, message: format!( - "function '{name}' declared with conflicting type parameters across imported modules" + "{}: imported function '{}' is not exported by module {}", + path.display(), + binding.source_name, + source_module.0 ), - }); + })) + })?; + state + .module_graph + .add_imported_binding( + module, + ImportedBinding { + local_name: binding.local_name, + source_module, + source_symbol, + source_name: binding.source_name, + }, + ) + .map_err(|message| { + let span = state.sources.line_span(module_source_id, binding_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: binding_line, + message: format!("{}: {message}", path.display()), + })) + })?; + } + + resolve_imported_call_sites( + module, + path, + &state.module_graph, + &state.sources, + &signatures, + &extern_names, + parsed, + ) +} + +fn collect_function_decl_lines(stmts: &[Stmt]) -> HashMap { + let mut lines = HashMap::new(); + record_function_decl_lines(stmts, &mut lines); + lines +} + +fn record_function_decl_lines(stmts: &[Stmt], lines: &mut HashMap) { + for stmt in stmts { + match stmt { + Stmt::FuncDecl { index, line, .. } => { + lines.entry(*index).or_insert(*line); + } + Stmt::IfElse { + then_branch, + else_branch, + .. + } => { + record_function_decl_lines(then_branch, lines); + record_function_decl_lines(else_branch, lines); + } + Stmt::For { + init, post, body, .. + } => { + record_function_decl_lines(std::slice::from_ref(init.as_ref()), lines); + record_function_decl_lines(std::slice::from_ref(post.as_ref()), lines); + record_function_decl_lines(body, lines); + } + Stmt::While { body, .. } => record_function_decl_lines(body, lines), + _ => {} + } + } +} + +/// Resolution context for one module's imported-call pass. +struct CallResolutionContext<'a> { + functions_by_index: HashMap, + /// Direct call names bound by exactly one source module (keyed by the + /// name the importing module binds: `as` alias or source name). + plain_symbols: HashMap, + /// Direct call names bound from several modules with different symbols. + ambiguous_names: HashSet, + /// Exported arity/type-parameter table for signature validation. + signatures: &'a HashMap, + /// Implicit-extern names produced by the parser (module mode). + extern_names: &'a HashSet, + module: ModuleId, + path: &'a Path, + graph: &'a ModuleGraph, + sources: &'a SourceMap, + source_id: u32, +} + +impl<'a> CallResolutionContext<'a> { + /// Resolve one unit-local call name to its target symbol. + /// + /// Names with a namespace separator resolve through the module's + /// file-module import edges (clause alias or spec stem); plain names + /// resolve through the imported-binding table. Returns `Ok(None)` for + /// names that are neither (the caller decides how to report them). + fn target_for_call( + &self, + decl_name: &str, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result, SourcePathError> { + if let Some((namespace, member)) = decl_name.split_once("::") { + return self.target_for_namespace_call( + namespace, member, decl_name, arg_count, type_args, line, + ); + } + if self.ambiguous_names.contains(decl_name) { + return Err(ambiguous_imported_call_error( + self.path, + decl_name, + self.sources, + self.source_id, + line, + )); + } + if let Some(symbol) = self.plain_symbols.get(decl_name) { + self.validate_imported_signature(decl_name, *symbol, arg_count, type_args, line)?; + return Ok(Some(*symbol)); + } + Err(unknown_function_error( + self.path, + decl_name, + self.sources, + self.source_id, + line, + )) + } + + fn target_for_namespace_call( + &self, + namespace: &str, + member: &str, + qualified: &str, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result, SourcePathError> { + if member.contains("::") { + // Multi-level module member paths are not supported; the legacy + // pipeline reported the same call as an unknown namespace call. + return Err(unknown_namespace_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )); + } + let mut found = HashSet::new(); + for target in file_module_targets_for_namespace(self.graph, self.module, namespace) { + if let Some(symbol) = self.graph.symbol_for_export(target, member) { + found.insert(symbol); } } - return Ok(()); + match found.len() { + 0 => { + if self.extern_names.contains(qualified) { + // Multi-segment import form whose namespace or member did + // not resolve to a public export. + Err(unknown_namespace_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )) + } else { + // Host-form declaration through a file-module namespace + // whose module does not export the member: report like + // the legacy parse did for the unqualified name. + Err(unknown_function_error( + self.path, + member, + self.sources, + self.source_id, + line, + )) + } + } + 1 => { + let symbol = found.into_iter().next().expect("exactly one symbol"); + self.validate_imported_signature(qualified, symbol, arg_count, type_args, line)?; + Ok(Some(symbol)) + } + _ => Err(ambiguous_imported_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )), + } } - imported_functions.insert(name.to_string(), signature.clone()); + /// Validate a resolved call against the exported arity and type + /// parameters, mirroring the messages the synthetic prelude used to + /// produce at parse time. + fn validate_imported_signature( + &self, + call_name: &str, + symbol: SymbolId, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result<(), SourcePathError> { + let Some(signature) = self.signatures.get(&symbol) else { + return Ok(()); + }; + let parse_error = |message: String| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: self.sources.line_span(self.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: {message}", self.path.display()), + })) + }; + if usize::from(signature.arity) != arg_count { + return Err(parse_error(format!( + "function '{call_name}' expects {} arguments", + signature.arity + ))); + } + if signature.type_params.is_empty() { + if type_args.is_empty() { + return Ok(()); + } + return Err(parse_error(format!( + "function '{call_name}' does not accept explicit type arguments" + ))); + } + if signature.type_params.len() != type_args.len() { + return Err(parse_error(format!( + "function '{call_name}' expects {} type arguments, got {}", + signature.type_params.len(), + type_args.len() + ))); + } + Ok(()) + } + + /// Resolve one function-value reference to its target symbol. + fn target_for_function_ref( + &self, + name: &str, + line: u32, + ) -> Result, SourcePathError> { + if self.ambiguous_names.contains(name) { + return Err(ambiguous_imported_call_error( + self.path, + name, + self.sources, + self.source_id, + line, + )); + } + if let Some(symbol) = self.plain_symbols.get(name) { + return Ok(Some(*symbol)); + } + Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: self.sources.line_span(self.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: unknown local '{}'", self.path.display(), name), + }))) + } +} + +/// Resolve every call to an imported function to its compiler-owned +/// [`SymbolId`] before unit merge. +/// +/// Module sources are parsed verbatim (no synthetic prelude, no textual +/// rewrite), so call sites reach this pass in the shapes the parser produced: +/// +/// - Direct calls (`helper(...)`) parse as implicit externs. A name bound +/// from exactly one source module maps to that module's symbol; a name +/// bound from several modules is ambiguous and becomes a diagnostic; an +/// unbound name is rejected as an unknown function. +/// - Namespace calls (`au::helper(...)`) parse either as implicit externs +/// carrying the qualified name (multi-segment import forms) or as +/// host-form calls whose namespace the parser treated as a host root +/// (single-segment import forms such as `use module;`). Both resolve +/// through the module's file-module import edges: the clause alias or the +/// spec stem maps the namespace to its target module, and the member must +/// be one of its public exports. +/// - Function values (`let f = helper;`) parse as +/// [`Expr::UnresolvedFunctionRef`] and resolve to +/// [`Expr::ModuleFunctionRef`]. +/// +/// Local calls (declarations that own a symbol) and host/builtin calls are +/// left untouched; the linker remaps them by symbol or keeps their reserved +/// builtin index. +fn resolve_imported_call_sites( + module: ModuleId, + path: &Path, + graph: &ModuleGraph, + sources: &SourceMap, + signatures: &HashMap, + extern_names: &HashSet, + parsed: &mut FrontendIr, +) -> Result<(), SourcePathError> { + let source_id = graph.node(module).map(|node| node.source.0).unwrap_or(0); + let mut plain_symbols = HashMap::::new(); + let mut ambiguous_names = HashSet::::new(); + if let Some(node) = graph.node(module) { + for binding in &node.imported_bindings { + match plain_symbols.entry(binding.local_name.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(binding.source_symbol); + } + std::collections::hash_map::Entry::Occupied(entry) + if *entry.get() != binding.source_symbol => + { + ambiguous_names.insert(binding.local_name.clone()); + } + std::collections::hash_map::Entry::Occupied(_) => {} + } + } + } + + let functions_by_index = parsed + .functions + .iter() + .map(|func| (func.index, func)) + .collect::>(); + + let ctx = CallResolutionContext { + functions_by_index, + plain_symbols, + ambiguous_names, + signatures, + extern_names, + module, + path, + graph, + sources, + source_id, + }; + + let resolve_stmt = |stmt: &mut Stmt| -> Result<(), SourcePathError> { + resolve_stmt_imported_calls(&ctx, stmt) + }; + for stmt in &mut parsed.stmts { + resolve_stmt(stmt)?; + } + for function_impl in parsed.function_impls.values_mut() { + for stmt in &mut function_impl.body_stmts { + resolve_stmt(stmt)?; + } + resolve_expr_imported_calls( + &ctx, + &mut function_impl.body_expr, + function_impl.body_expr_line.max(1), + )?; + } + Ok(()) +} + +fn unknown_function_error( + path: &Path, + name: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: unknown function '{}'", path.display(), name), + })) +} + +/// Validate type arguments on a host import call whose parse-time validation +/// was deferred (non-builtin namespaces that may name file modules). +fn validate_deferred_host_type_args( + ctx: &CallResolutionContext<'_>, + host_name: &str, + type_args: &[TypeSchema], + line: u32, +) -> Result<(), SourcePathError> { + let expected = crate::compiler::parser::host_generic_type_arg_arity(host_name); + let parse_error = |message: String| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: ctx.sources.line_span(ctx.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: {message}", ctx.path.display()), + })) + }; + match expected { + Some(expected) if type_args.is_empty() || expected == type_args.len() => Ok(()), + Some(expected) => Err(parse_error(format!( + "function '{host_name}' expects {expected} type arguments, got {}", + type_args.len() + ))), + None if type_args.is_empty() => Ok(()), + None => Err(parse_error(format!( + "function '{host_name}' does not accept explicit type arguments" + ))), + } +} + +fn unknown_namespace_call_error( + path: &Path, + qualified: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!( + "{}: unknown namespace call '{}'; the module does not export this function", + path.display(), + qualified + ), + })) +} + +fn ambiguous_imported_call_error( + path: &Path, + name: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!( + "{}: call to '{name}' is ambiguous: the name is exported by multiple imported modules; qualify the call with a namespace alias or a named import", + path.display() + ), + })) +} + +fn resolve_stmt_imported_calls( + ctx: &CallResolutionContext<'_>, + stmt: &mut Stmt, +) -> Result<(), SourcePathError> { + let line = stmt_line(stmt); + match stmt { + Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => {} + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + resolve_expr_imported_calls(ctx, expr, line)?; + } + Stmt::ClosureLet { closure, .. } => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + } + Stmt::FuncDecl { .. } => {} + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + for nested in then_branch { + resolve_stmt_imported_calls(ctx, nested)?; + } + for nested in else_branch { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + resolve_stmt_imported_calls(ctx, init)?; + resolve_expr_imported_calls(ctx, condition, line)?; + resolve_stmt_imported_calls(ctx, post)?; + for nested in body { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::While { + condition, body, .. + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + for nested in body { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::Drop { .. } => {} + } Ok(()) } + +fn resolve_expr_imported_calls( + ctx: &CallResolutionContext<'_>, + expr: &mut Expr, + line: u32, +) -> Result<(), SourcePathError> { + match expr { + Expr::Call(index, type_args, args) => { + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + let Some(decl) = ctx.functions_by_index.get(index) else { + // Builtin calls use the reserved builtin index space and are + // not part of the unit's declaration table. + return Ok(()); + }; + if decl.symbol.is_some() { + // Local declaration or host import: resolved by the linker. + // Host imports whose type arguments were deferred at parse + // (non-builtin namespaces that may name file modules) are + // validated against the host generic arity here. + if decl.name.contains("::") { + validate_deferred_host_type_args(ctx, &decl.name, type_args, line)?; + } + return Ok(()); + } + let name = decl.name.as_str(); + if let Some(symbol) = ctx.target_for_call(name, args.len(), type_args, line)? { + *expr = Expr::ModuleCall(symbol, std::mem::take(type_args), std::mem::take(args)); + } else { + return Err(unknown_function_error( + ctx.path, + name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::FunctionRef(index, _type_args) => { + let Some(decl) = ctx.functions_by_index.get(index) else { + return Ok(()); + }; + if decl.symbol.is_none() { + return Err(unknown_function_error( + ctx.path, + &decl.name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::UnresolvedFunctionRef { name, type_args } => { + if let Some(symbol) = ctx.target_for_function_ref(name, line)? { + *expr = Expr::ModuleFunctionRef(symbol, std::mem::take(type_args)); + } else { + return Err(unknown_function_error( + ctx.path, + name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::Bytes(_) + | Expr::String(_) + | Expr::ModuleCall(..) + | Expr::ModuleFunctionRef(..) + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + Expr::OptionalGet { + container, + key, + container_slot: _, + key_slot: _, + } => { + resolve_expr_imported_calls(ctx, container, line)?; + resolve_expr_imported_calls(ctx, key, line)?; + } + Expr::OptionUnwrapOr { + value, + value_slot: _, + fallback, + } => { + resolve_expr_imported_calls(ctx, value, line)?; + resolve_expr_imported_calls(ctx, fallback, line)?; + } + Expr::LocalCall(_, _, args) => { + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + } + Expr::Closure(closure) => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + } + Expr::ClosureCall(closure, args) => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + resolve_expr_imported_calls(ctx, lhs, line)?; + resolve_expr_imported_calls(ctx, rhs, line)?; + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => { + resolve_expr_imported_calls(ctx, inner, line)?; + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + resolve_expr_imported_calls(ctx, then_expr, line)?; + resolve_expr_imported_calls(ctx, else_expr, line)?; + } + Expr::Match { + value_slot: _, + result_slot: _, + value, + arms, + default, + } => { + resolve_expr_imported_calls(ctx, value, line)?; + for (_, arm_expr) in arms.iter_mut() { + resolve_expr_imported_calls(ctx, arm_expr, line)?; + } + resolve_expr_imported_calls(ctx, default, line)?; + } + Expr::Block { stmts, expr } => { + for stmt in stmts.iter_mut() { + resolve_stmt_imported_calls(ctx, stmt)?; + } + resolve_expr_imported_calls(ctx, expr, line)?; + } + } + Ok(()) +} + +/// Source line of one statement, used to attribute unresolved/ambiguous +/// imported-call diagnostics to the owning module source. +fn stmt_line(stmt: &Stmt) -> u32 { + match stmt { + Stmt::Noop { line } + | Stmt::Break { line } + | Stmt::Continue { line } + | Stmt::Drop { line, .. } + | Stmt::ClosureLet { line, .. } + | Stmt::FuncDecl { line, .. } + | Stmt::Let { line, .. } + | Stmt::Assign { line, .. } + | Stmt::Expr { line, .. } + | Stmt::IfElse { line, .. } + | Stmt::For { line, .. } + | Stmt::While { line, .. } => *line, + } +} diff --git a/src/compiler/source_loader/imports.rs b/src/compiler/source_loader/imports.rs index 41281466..b8f8615c 100644 --- a/src/compiler/source_loader/imports.rs +++ b/src/compiler/source_loader/imports.rs @@ -1,10 +1,14 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::builtins::is_builtin_namespace; use super::super::frontends::{is_ident_continue, is_ident_start}; -use super::super::{CompileSourceFileOptions, SourceFlavor, SourcePathError}; -use super::model::{ImportClause, ModuleImport, NamedImport}; +use super::super::modules::{UseDecl, use_path_to_spec}; +use super::super::{ + CompileSourceFileOptions, SharedParserOptions, SourceError, SourceFlavor, SourcePathError, + frontends, +}; +use super::model::ModuleImport; pub(super) fn parse_module_imports( source: &str, @@ -12,235 +16,94 @@ pub(super) fn parse_module_imports( path: &Path, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { - match flavor { - SourceFlavor::RustScript => parse_rustscript_imports(source, path), - SourceFlavor::JavaScript | SourceFlavor::Lua => options - .source_plugin_for_flavor(flavor) - .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? - .parse_module_imports(source, path), - } + scan_module_imports(source, flavor, path, options).map(|(imports, _)| imports) } -fn parse_rustscript_imports( +/// Scan the module imports of one source. +/// +/// For RustScript this parses the source once with the real frontend parser +/// and consumes the structured `use` declaration nodes, so import discovery +/// shares the parser's spans, clauses, and syntax validation instead of +/// treating line-prefix stripping as the authoritative import parser. The +/// paired [`UseDecl`] list is returned alongside the legacy `ModuleImport` +/// list so graph construction can preserve spans. Other flavors keep their +/// plugin-based discovery and contribute no structured declarations. +pub(super) fn scan_module_imports( source: &str, + flavor: SourceFlavor, path: &Path, -) -> Result, SourcePathError> { - let mut imports = Vec::new(); - for (idx, raw_line) in source.lines().enumerate() { - let line_no = idx + 1; - let line = raw_line.trim(); - if line.starts_with("import ") { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line: line_no, - message: "RustScript uses 'use', not 'import'".to_string(), - }); + options: &CompileSourceFileOptions, +) -> Result<(Vec, Vec), SourcePathError> { + match flavor { + SourceFlavor::RustScript => { + let decls = parse_rustscript_use_declarations(source, path)?; + let imports = use_declarations_to_module_imports(path, &decls)?; + Ok((imports, decls)) } - if !line.starts_with("use ") { - continue; + SourceFlavor::JavaScript | SourceFlavor::Lua => { + let imports = options + .source_plugin_for_flavor(flavor) + .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? + .parse_module_imports(source, path)?; + Ok((imports, Vec::new())) } - let tail = line["use ".len()..].trim(); - let (spec, clause) = parse_rustscript_use(path, line_no, tail)?; - imports.push(ModuleImport { - spec, - clause, - line: line_no, - }); } - Ok(imports) } -fn parse_rustscript_use( +/// Parse all `use` directives of a RustScript source into structured nodes. +/// +/// The whole source is parsed with the real frontend parser (with implicit +/// externs enabled and file-path host aliases recorded) so that discovery +/// tolerates calls to functions imported from other modules, which the +/// loader's semantic resolution pass resolves later. +fn parse_rustscript_use_declarations( + source: &str, path: &Path, - line: usize, - tail: &str, -) -> Result<(String, ImportClause), SourcePathError> { - let Some((directive_body, _)) = tail.split_once(';') else { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected ';' at end of use directive".to_string(), - }); - }; - let directive_body = directive_body.trim(); - if directive_body.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - - if let Some(module_path) = directive_body.strip_suffix("::*") { - let spec = rustscript_use_module_to_spec(path, line, module_path.trim())?; - return Ok((spec, ImportClause::AllPublic)); - } - - if let Some(open_idx) = directive_body.find("::{") { - if !directive_body.ends_with('}') { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected '}' to close use list".to_string(), - }); - } - let module_path = directive_body[..open_idx].trim(); - let spec = rustscript_use_module_to_spec(path, line, module_path)?; - let inner = directive_body[open_idx + 3..directive_body.len() - 1].trim(); - if inner == "*" { - return Ok((spec, ImportClause::AllPublic)); - } - if inner.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "use list requires at least one symbol".to_string(), - }); - } - let named = - parse_named_imports(inner).ok_or_else(|| SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: - "invalid use list; expected comma-separated names with optional 'as' aliases" - .to_string(), - })?; - if named.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "use list requires at least one symbol".to_string(), - }); - } - return Ok((spec, ImportClause::Named(named))); - } - - if let Some((module_path, alias)) = directive_body.rsplit_once(" as ") { - let spec = rustscript_use_module_to_spec(path, line, module_path.trim())?; - let alias = alias.trim(); - if !is_valid_ident(alias) { +) -> Result, SourcePathError> { + for (idx, raw_line) in source.lines().enumerate() { + let line = raw_line.trim(); + if line.starts_with("import ") { return Err(SourcePathError::InvalidImportSyntax { path: path.to_path_buf(), - line, - message: "invalid namespace alias in use directive".to_string(), + line: idx + 1, + message: "RustScript uses 'use', not 'import'".to_string(), }); } - return Ok((spec, ImportClause::Namespace(alias.to_string()))); } - let spec = rustscript_use_module_to_spec(path, line, directive_body)?; - Ok((spec, ImportClause::AllPublic)) + let options = CompileSourceFileOptions::default(); + let dialect = frontends::parser_dialect_for_flavor(SourceFlavor::RustScript, &options) + .expect("RustScript parser dialect is always registered"); + let ir = frontends::parse_source_with_dialect( + source, + dialect, + SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + ) + .map_err(|err| SourcePathError::Source(SourceError::Parse(err)))?; + Ok(ir.use_declarations) } -fn rustscript_use_module_to_spec( +fn use_declarations_to_module_imports( path: &Path, - line: usize, - module_path: &str, -) -> Result { - if module_path.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - let segments = module_path - .split("::") - .map(|segment| segment.trim()) - .collect::>(); - if segments.iter().any(|segment| segment.is_empty()) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "invalid module path in use directive".to_string(), - }); - } - - let mut path_prefix = PathBuf::new(); - let mut cursor = 0usize; - while cursor < segments.len() { - match segments[cursor] { - "self" => cursor += 1, - "super" => { - path_prefix.push(".."); - cursor += 1; - } - "crate" => { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "crate:: paths are not supported; use relative module paths" - .to_string(), - }); - } - _ => break, - } - } - - if cursor >= segments.len() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module name after path qualifiers".to_string(), - }); - } - - for segment in &segments[cursor..] { - if !is_valid_ident(segment) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: format!("invalid module path segment '{segment}' in use directive"), - }); - } - path_prefix.push(segment); - } - - let mut spec = path_prefix.to_string_lossy().replace('\\', "/"); - if spec.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - if !spec.ends_with(".rss") { - spec.push_str(".rss"); - } - Ok(spec) -} - -fn parse_named_imports(input: &str) -> Option> { - let mut named = Vec::new(); - for part in input.split(',') { - let entry = part.trim(); - if entry.is_empty() { - continue; - } - - if let Some((imported, local)) = entry.split_once(" as ") { - let imported = imported.trim(); - let local = local.trim(); - if !is_valid_ident(imported) || !is_valid_ident(local) { - return None; - } - named.push(NamedImport { - imported: imported.to_string(), - local: local.to_string(), - }); - continue; - } - - if !is_valid_ident(entry) { - return None; - } - named.push(NamedImport { - imported: entry.to_string(), - local: entry.to_string(), - }); - } - - Some(named) + decls: &[UseDecl], +) -> Result, SourcePathError> { + decls + .iter() + .map(|decl| { + let spec = use_path_to_spec(path, decl.line, &decl.path)?; + Ok(ModuleImport { + spec, + clause: decl.clause.clone(), + line: decl.line, + }) + }) + .collect() } pub(super) fn is_valid_ident(input: &str) -> bool { @@ -278,7 +141,7 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - return Ok(path); + return Ok(module_identity(path)); } if options.module_override_source(spec).is_some() { let parent = base_path @@ -295,7 +158,7 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - return Ok(path); + return Ok(module_identity(path)); } let parent = base_path @@ -312,71 +175,67 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - Ok(path) + Ok(module_identity(path)) +} + +/// Resolve the module identity for a normalized path. +/// +/// Files that exist on disk use their canonical path so that lexically +/// distinct but equivalent paths (`.`, `..`, symlinks) collapse to one +/// module identity for `seen`/`visiting`/exports/overrides. Paths that do not +/// exist on disk (virtual source overrides, in-memory entry points) keep the +/// normalized lexical path as their explicit virtual identity. +pub(super) fn module_identity(path: PathBuf) -> PathBuf { + let normalized = normalize_module_path(path); + if normalized.is_file() + && let Ok(canonical) = normalized.canonicalize() + { + return canonical; + } + normalized +} + +fn normalize_module_path(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => match normalized.components().next_back() { + Some(Component::Normal(_)) => { + normalized.pop(); + } + Some(Component::ParentDir) | None => normalized.push(component.as_os_str()), + Some(Component::RootDir | Component::Prefix(_)) => {} + Some(Component::CurDir) => { + unreachable!("normalized paths omit current-dir components") + } + }, + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized } +/// Prepare source text for the compile parse. +/// +/// RustScript no longer strips `use` directives: the parser consumes every +/// directive into a structured node (host-namespace forms keep their existing +/// dedicated handling), so line-prefix stripping is no longer an authority +/// for import discovery. Other flavors keep plugin-defined stripping. pub(super) fn strip_import_directives( source: &str, flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let stripped = match flavor { - SourceFlavor::RustScript => source - .lines() - .map(|line| { - if line.trim_start().starts_with("use ") - && !is_direct_host_namespace_use_directive_line(line.trim_start()) - && !is_builtin_namespace_use_directive_line(line.trim_start()) - { - String::new() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"), - SourceFlavor::JavaScript | SourceFlavor::Lua => options + match flavor { + SourceFlavor::RustScript => Ok(source.to_string()), + SourceFlavor::JavaScript | SourceFlavor::Lua => Ok(options .source_plugin_for_flavor(flavor) .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? - .strip_import_directives(source), - }; - Ok(stripped) -} - -fn is_direct_host_namespace_use_directive_line(line: &str) -> bool { - let trimmed = line.trim(); - if !trimmed.starts_with("use ") { - return false; - } - let Some((directive_body, _)) = trimmed["use ".len()..].split_once(';') else { - return false; - }; - let directive_body = directive_body.trim(); - if directive_body.contains("::{") || directive_body.ends_with("::*") { - return false; + .strip_import_directives(source)), } - if let Some((namespace, alias)) = directive_body.split_once(" as ") { - return is_virtual_host_namespace_spec( - namespace.trim(), - &CompileSourceFileOptions::default(), - ) && is_valid_ident(alias.trim()); - } - is_virtual_host_namespace_spec(directive_body, &CompileSourceFileOptions::default()) -} - -fn is_builtin_namespace_use_directive_line(line: &str) -> bool { - let trimmed = line.trim(); - if !trimmed.starts_with("use ") { - return false; - } - let Some((directive_body, _)) = trimmed["use ".len()..].split_once(';') else { - return false; - }; - let directive_body = directive_body.trim(); - if let Some((namespace, _alias)) = directive_body.split_once(" as ") { - return is_builtin_namespace(namespace.trim()); - } - is_builtin_namespace(directive_body) } pub(super) fn host_namespace_root_from_spec(spec: &str) -> Option { @@ -415,3 +274,145 @@ pub(super) fn should_treat_missing_module_as_host_namespace( std::io::ErrorKind::NotFound | std::io::ErrorKind::Unsupported ) && is_virtual_host_namespace_spec(spec, options) } + +#[cfg(test)] +mod tests { + use super::super::super::modules::UsePathSegment; + use super::super::SourceFlavor; + use super::super::model::ImportClause; + use super::{ + module_identity, normalize_module_path, parse_module_imports, scan_module_imports, + }; + use std::path::PathBuf; + + #[test] + fn normalize_module_path_preserves_unmatched_parent_components() { + assert_eq!( + normalize_module_path(PathBuf::from("../foo/../../bar")), + PathBuf::from("../../bar") + ); + assert_eq!( + normalize_module_path(PathBuf::from("foo/../../../bar")), + PathBuf::from("../../bar") + ); + } + + #[cfg(unix)] + #[test] + fn normalize_module_path_does_not_escape_absolute_root() { + assert_eq!( + normalize_module_path(PathBuf::from("/foo/../../bar")), + PathBuf::from("/bar") + ); + } + + #[test] + fn module_identity_keeps_normalized_virtual_path_for_missing_files() { + assert_eq!( + module_identity(PathBuf::from("/no/such/dir/../virtual/nested.rss")), + PathBuf::from("/no/such/virtual/nested.rss") + ); + } + + #[test] + fn module_identity_uses_canonical_path_for_existing_files() { + let unique = format!( + "pd-vm-module-identity-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp root should be created"); + let module = root.join("a.rss"); + std::fs::write(&module, "pub fn value() -> int { 1 }\n").expect("module should write"); + + let via_dot = module_identity(root.join("./a.rss")); + let via_parent = module_identity(root.join("sub/../a.rss")); + let canonical = module.canonicalize().expect("module should canonicalize"); + + assert_eq!(via_dot, canonical); + assert_eq!(via_parent, canonical); + assert_eq!(via_dot, via_parent); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn structured_scan_preserves_spans_clauses_and_lines() { + let source = "use self::nested as nested;\nuse sibling::{value as v, other};\nuse super::shared;\nuse io;\n"; + let path = PathBuf::from("/root/pkg/main.rss"); + let (imports, decls) = + scan_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect("scan should succeed"); + + assert_eq!(imports.len(), 4); + assert_eq!(imports[0].spec, "./nested.rss"); + assert_eq!(imports[1].spec, "sibling.rss"); + assert_eq!(imports[2].spec, "../shared.rss"); + assert_eq!(imports[3].spec, "io.rss"); + + assert_eq!(decls.len(), 4); + assert_eq!( + decls[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("nested".to_string()) + ] + ); + assert!(matches!(&decls[0].clause, ImportClause::Namespace(alias) if alias == "nested")); + assert_eq!(decls[0].line, 1); + assert_eq!(decls[1].line, 2); + assert!( + decls[0].span.lo < decls[0].span.hi, + "span must cover the directive" + ); + assert!(matches!(&decls[1].clause, ImportClause::Named(named) if named.len() == 2)); + assert!(matches!(&decls[3].clause, ImportClause::AllPublic)); + } + + #[test] + fn structured_scan_handles_wildcard_and_alias_forms() { + let source = "use a::b::*;\nuse c::d::{x};\nuse e as f;\n"; + let path = PathBuf::from("/root/main.rss"); + let (imports, decls) = + scan_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect("scan should succeed"); + + assert_eq!(imports[0].spec, "a/b.rss"); + assert!(matches!(imports[0].clause, ImportClause::AllPublic)); + assert_eq!(imports[1].spec, "c/d.rss"); + assert!(matches!(&imports[1].clause, ImportClause::Named(named) if named.len() == 1)); + assert_eq!(imports[2].spec, "e.rss"); + assert!(matches!(&imports[2].clause, ImportClause::Namespace(alias) if alias == "f")); + assert_eq!(decls[1].path.len(), 2); + } + + #[test] + fn structured_scan_rejects_import_keyword() { + let source = "import \"./module.rss\";\n"; + let path = PathBuf::from("/root/main.rss"); + let err = + parse_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect_err("import keyword should be rejected"); + assert!( + err.to_string().contains("uses 'use', not 'import'"), + "unexpected error: {err}" + ); + } + + #[test] + fn structured_scan_rejects_crate_paths() { + let source = "use crate::x;\n"; + let path = PathBuf::from("/root/main.rss"); + let err = + parse_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect_err("crate:: paths should be rejected"); + assert!( + err.to_string().contains("crate:: paths are not supported"), + "unexpected error: {err}" + ); + } +} diff --git a/src/compiler/source_loader/line_map.rs b/src/compiler/source_loader/line_map.rs deleted file mode 100644 index 34853d48..00000000 --- a/src/compiler/source_loader/line_map.rs +++ /dev/null @@ -1,165 +0,0 @@ -use super::super::ir::{Expr, FrontendIr, Stmt}; - -pub(super) fn remap_frontend_ir_line_numbers(ir: &mut FrontendIr, prelude_lines: usize) { - let offset = u32::try_from(prelude_lines).unwrap_or(u32::MAX); - for stmt in &mut ir.stmts { - remap_stmt_line_numbers(stmt, offset); - } - for function in ir.function_impls.values_mut() { - for stmt in &mut function.body_stmts { - remap_stmt_line_numbers(stmt, offset); - } - remap_expr_line_numbers(&mut function.body_expr, offset); - } -} - -fn remap_line(line: &mut u32, offset: u32) { - *line = (*line).saturating_sub(offset).max(1); -} - -fn remap_stmt_line_numbers(stmt: &mut Stmt, offset: u32) { - match stmt { - Stmt::Noop { line } - | Stmt::Break { line } - | Stmt::Continue { line } - | Stmt::Drop { line, .. } - | Stmt::ClosureLet { line, .. } - | Stmt::FuncDecl { line, .. } => remap_line(line, offset), - Stmt::Let { expr, line, .. } - | Stmt::Assign { expr, line, .. } - | Stmt::Expr { expr, line } => { - remap_line(line, offset); - remap_expr_line_numbers(expr, offset); - } - Stmt::IfElse { - condition, - then_branch, - else_branch, - line, - } => { - remap_line(line, offset); - remap_expr_line_numbers(condition, offset); - for stmt in then_branch { - remap_stmt_line_numbers(stmt, offset); - } - for stmt in else_branch { - remap_stmt_line_numbers(stmt, offset); - } - } - Stmt::For { - init, - condition, - post, - body, - line, - } => { - remap_line(line, offset); - remap_stmt_line_numbers(init, offset); - remap_expr_line_numbers(condition, offset); - remap_stmt_line_numbers(post, offset); - for stmt in body { - remap_stmt_line_numbers(stmt, offset); - } - } - Stmt::While { - condition, - body, - line, - } => { - remap_line(line, offset); - remap_expr_line_numbers(condition, offset); - for stmt in body { - remap_stmt_line_numbers(stmt, offset); - } - } - } -} - -fn remap_expr_line_numbers(expr: &mut Expr, offset: u32) { - match expr { - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { - for arg in args { - remap_expr_line_numbers(arg, offset); - } - } - Expr::ClosureCall(closure, args) => { - remap_closure_line_numbers(closure, offset); - for arg in args { - remap_expr_line_numbers(arg, offset); - } - } - Expr::Closure(closure) => remap_closure_line_numbers(closure, offset), - Expr::OptionalGet { container, key, .. } => { - remap_expr_line_numbers(container, offset); - remap_expr_line_numbers(key, offset); - } - Expr::OptionUnwrapOr { - value, fallback, .. - } => { - remap_expr_line_numbers(value, offset); - remap_expr_line_numbers(fallback, offset); - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => { - remap_expr_line_numbers(lhs, offset); - remap_expr_line_numbers(rhs, offset); - } - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => { - remap_expr_line_numbers(inner, offset); - } - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - remap_expr_line_numbers(condition, offset); - remap_expr_line_numbers(then_expr, offset); - remap_expr_line_numbers(else_expr, offset); - } - Expr::Match { - value, - arms, - default, - .. - } => { - remap_expr_line_numbers(value, offset); - for (_, arm_expr) in arms { - remap_expr_line_numbers(arm_expr, offset); - } - remap_expr_line_numbers(default, offset); - } - Expr::Block { stmts, expr } => { - for stmt in stmts { - remap_stmt_line_numbers(stmt, offset); - } - remap_expr_line_numbers(expr, offset); - } - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) - | Expr::Var(_) - | Expr::MoveVar(_) - | Expr::MoveField { .. } - | Expr::MoveIndex { .. } => {} - } -} - -fn remap_closure_line_numbers(closure: &mut crate::compiler::ir::ClosureExpr, offset: u32) { - remap_expr_line_numbers(&mut closure.body, offset); -} diff --git a/src/compiler/source_loader/model.rs b/src/compiler/source_loader/model.rs index 172ff65f..4199de19 100644 --- a/src/compiler/source_loader/model.rs +++ b/src/compiler/source_loader/model.rs @@ -2,7 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use super::super::linker::ParsedUnit; - +use super::super::modules::ModuleGraph; +use super::super::source_map::SourceMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FrontendImportSyntax { RustScript, @@ -43,7 +44,10 @@ pub(super) struct ModuleCollectState { pub(super) seen: HashSet, pub(super) units: Vec, pub(super) module_exports: HashMap>, -} -pub(super) struct ImportRewriteResult { - pub(super) source: String, + pub(super) module_graph: ModuleGraph, + /// Compilation-wide source map keyed by the module graph's + /// [`SourceId`](super::super::modules::SourceId) space. Every module's + /// raw text is registered here at its graph source id so spans produced + /// during load and merge resolve to the owning source for diagnostics. + pub(super) sources: SourceMap, } diff --git a/src/compiler/source_loader/rewrite.rs b/src/compiler/source_loader/rewrite.rs deleted file mode 100644 index 4dd305b9..00000000 --- a/src/compiler/source_loader/rewrite.rs +++ /dev/null @@ -1,780 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use super::super::frontends::{is_ident_continue, is_ident_start}; -use super::super::{CompileSourceFileOptions, SourceFlavor, SourcePathError}; - -use super::imports::{ - host_namespace_root_from_spec, is_builtin_host_namespace_spec, is_module_specifier, - is_valid_ident, is_virtual_host_namespace_spec, resolve_module_path, -}; -use super::model::{ExportedFunctionSignature, ImportClause, ImportRewriteResult, ModuleImport}; - -struct ImportCallResolution { - alias_calls: HashMap, - namespace_calls: HashMap>, - namespace_prefix_calls: HashMap, -} - -fn resolve_import_call_paths( - flavor: SourceFlavor, - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let mut alias_calls = HashMap::::new(); - let mut namespace_calls = HashMap::>::new(); - let namespace_prefix_calls = HashMap::::new(); - for import in imports { - if is_builtin_host_namespace_spec(&import.spec) { - continue; - } - if !is_module_specifier(&import.spec) { - if let Some(host_root) = host_namespace_root_from_spec(&import.spec) - && is_virtual_host_namespace_spec(&import.spec, options) - && let Some(host_prefix) = virtual_host_namespace_prefix(flavor, &host_root) - { - match &import.clause { - ImportClause::AllPublic => {} - ImportClause::Named(named) => { - for binding in named { - alias_calls.insert( - binding.local.clone(), - format!("{host_prefix}::{}", binding.imported), - ); - } - } - ImportClause::Namespace(_namespace) => {} - ImportClause::Prefix(_) => {} - } - } - continue; - } - - let resolved = resolve_module_path(path, &import.spec, options)?; - let Some(exports) = module_exports.get(&resolved) else { - if let Some(host_root) = host_namespace_root_from_spec(&import.spec) - && is_virtual_host_namespace_spec(&import.spec, options) - && let Some(host_prefix) = virtual_host_namespace_prefix(flavor, &host_root) - { - match &import.clause { - ImportClause::AllPublic => {} - ImportClause::Named(named) => { - for binding in named { - alias_calls.insert( - binding.local.clone(), - format!("{host_prefix}::{}", binding.imported), - ); - } - } - ImportClause::Namespace(_namespace) => {} - ImportClause::Prefix(_) => {} - } - } - continue; - }; - - match &import.clause { - ImportClause::AllPublic => { - // Bare `use module;` keeps direct calls (`fn_name(...)`) and now also - // supports namespace-style calls (`module::fn_name(...)`) for ergonomics. - if let Some(namespace) = module_default_namespace(&import.spec) { - let entries = namespace_calls.entry(namespace).or_default(); - for name in exports.keys() { - entries.insert(name.clone()); - } - } - } - ImportClause::Named(named) => { - for binding in named { - if !exports.contains_key(&binding.imported) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line: import.line, - message: format!( - "module '{}' has no public function '{}'", - import.spec, binding.imported - ), - }); - } - if binding.local != binding.imported { - alias_calls.insert(binding.local.clone(), binding.imported.clone()); - } - } - } - ImportClause::Namespace(namespace) => { - let entries = namespace_calls.entry(namespace.clone()).or_default(); - for name in exports.keys() { - entries.insert(name.clone()); - } - } - ImportClause::Prefix(prefix) => { - for name in exports.keys() { - alias_calls.insert(format!("{prefix}{name}"), name.clone()); - } - } - } - } - - Ok(ImportCallResolution { - alias_calls, - namespace_calls, - namespace_prefix_calls, - }) -} - -fn virtual_host_namespace_prefix(flavor: SourceFlavor, host_root: &str) -> Option { - match flavor { - SourceFlavor::RustScript => Some(host_root.to_string()), - SourceFlavor::JavaScript | SourceFlavor::Lua => None, - } -} - -pub(super) fn rewrite_imported_call_sites( - source: &str, - flavor: SourceFlavor, - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let resolution = resolve_import_call_paths(flavor, path, imports, module_exports, options)?; - let alias_calls = resolution.alias_calls; - let namespace_calls = resolution.namespace_calls; - let namespace_prefix_calls = resolution.namespace_prefix_calls; - let namespace_wildcards = HashSet::::new(); - let prefix_aliases = Vec::::new(); - - let rewritten = rewrite_host_namespace_call_paths(source, flavor, &namespace_prefix_calls); - - if alias_calls.is_empty() - && namespace_calls.is_empty() - && namespace_wildcards.is_empty() - && prefix_aliases.is_empty() - { - return Ok(ImportRewriteResult { source: rewritten }); - } - - Ok(ImportRewriteResult { - source: rewrite_function_call_paths( - &rewritten, - flavor, - &alias_calls, - &namespace_calls, - &namespace_wildcards, - &prefix_aliases, - ), - }) -} - -fn rewrite_host_namespace_call_paths( - source: &str, - flavor: SourceFlavor, - namespace_prefix_calls: &HashMap, -) -> String { - if namespace_prefix_calls.is_empty() { - return source.to_string(); - } - - let bytes = source.as_bytes(); - let mut out = String::with_capacity(source.len()); - let mut i = 0usize; - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut string_delim: Option = None; - let mut escaped = false; - - while i < bytes.len() { - let b = bytes[i]; - - if let Some(delim) = string_delim { - out.push(b as char); - if escaped { - escaped = false; - } else if b == b'\\' { - escaped = true; - } else if b == delim { - string_delim = None; - } - i += 1; - continue; - } - - if in_line_comment { - out.push(b as char); - if b == b'\n' { - in_line_comment = false; - } - i += 1; - continue; - } - - if in_block_comment { - out.push(b as char); - if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - i += 2; - in_block_comment = false; - continue; - } - i += 1; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - out.push('/'); - i += 2; - in_line_comment = true; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - out.push('/'); - out.push('*'); - i += 2; - in_block_comment = true; - continue; - } - - if b == b'"' || b == b'\'' || b == b'`' { - out.push(b as char); - i += 1; - string_delim = Some(b); - escaped = false; - continue; - } - - if !is_ident_start(b as char) { - out.push(b as char); - i += 1; - continue; - } - - let start = i; - i += 1; - while i < bytes.len() && is_ident_continue(bytes[i] as char) { - i += 1; - } - let ident = &source[start..i]; - - if let Some(prefix) = namespace_prefix_calls.get(ident) - && namespace_call_target_is_function(source, i, flavor) - { - out.push_str(prefix); - continue; - } - - out.push_str(ident); - } - - out -} - -fn namespace_call_target_is_function(source: &str, index: usize, flavor: SourceFlavor) -> bool { - let bytes = source.as_bytes(); - let mut cursor = index; - if !consume_namespace_separator(bytes, &mut cursor, flavor) { - return false; - } - - loop { - while cursor < bytes.len() - && bytes[cursor].is_ascii_whitespace() - && bytes[cursor] != b'\n' - && bytes[cursor] != b'\r' - { - cursor += 1; - } - if cursor >= bytes.len() || !is_ident_start(bytes[cursor] as char) { - return false; - } - cursor += 1; - while cursor < bytes.len() && is_ident_continue(bytes[cursor] as char) { - cursor += 1; - } - - while cursor < bytes.len() - && bytes[cursor].is_ascii_whitespace() - && bytes[cursor] != b'\n' - && bytes[cursor] != b'\r' - { - cursor += 1; - } - if cursor < bytes.len() && bytes[cursor] == b'(' { - return true; - } - if !consume_namespace_separator(bytes, &mut cursor, flavor) { - return false; - } - } -} - -fn consume_namespace_separator(bytes: &[u8], cursor: &mut usize, flavor: SourceFlavor) -> bool { - if *cursor >= bytes.len() { - return false; - } - if flavor == SourceFlavor::RustScript { - if bytes[*cursor] != b':' { - return false; - } - *cursor += 1; - while *cursor < bytes.len() - && bytes[*cursor].is_ascii_whitespace() - && bytes[*cursor] != b'\n' - && bytes[*cursor] != b'\r' - { - *cursor += 1; - } - if *cursor >= bytes.len() || bytes[*cursor] != b':' { - return false; - } - *cursor += 1; - return true; - } - - if bytes[*cursor] == b'.' { - *cursor += 1; - return true; - } - false -} - -fn module_default_namespace(spec: &str) -> Option { - let stem = Path::new(spec).file_stem()?.to_str()?; - if is_valid_ident(stem) { - Some(stem.to_string()) - } else { - None - } -} - -fn skip_inline_whitespace(bytes: &[u8], mut index: usize) -> usize { - while index < bytes.len() - && bytes[index].is_ascii_whitespace() - && bytes[index] != b'\n' - && bytes[index] != b'\r' - { - index += 1; - } - index -} - -fn rustscript_turbofish_call_starts(bytes: &[u8], start: usize) -> bool { - let mut index = skip_inline_whitespace(bytes, start); - if index >= bytes.len() || bytes[index] != b':' { - return false; - } - index = skip_inline_whitespace(bytes, index + 1); - if index >= bytes.len() || bytes[index] != b':' { - return false; - } - index = skip_inline_whitespace(bytes, index + 1); - if index >= bytes.len() || bytes[index] != b'<' { - return false; - } - - let mut depth = 0usize; - while index < bytes.len() { - match bytes[index] { - b'<' => depth += 1, - b'>' => { - depth = depth.saturating_sub(1); - if depth == 0 { - index += 1; - break; - } - } - b'\n' | b'\r' => return false, - _ => {} - } - index += 1; - } - - if depth != 0 { - return false; - } - - index = skip_inline_whitespace(bytes, index); - index < bytes.len() && bytes[index] == b'(' -} - -fn call_starts_after_position(bytes: &[u8], start: usize, flavor: SourceFlavor) -> bool { - let index = skip_inline_whitespace(bytes, start); - if index < bytes.len() && bytes[index] == b'(' { - return true; - } - flavor == SourceFlavor::RustScript && rustscript_turbofish_call_starts(bytes, start) -} - -fn rewrite_function_call_paths( - source: &str, - flavor: SourceFlavor, - alias_calls: &HashMap, - namespace_calls: &HashMap>, - namespace_wildcards: &HashSet, - prefix_aliases: &[String], -) -> String { - let bytes = source.as_bytes(); - let mut out = String::with_capacity(source.len()); - let mut i = 0usize; - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut string_delim: Option = None; - let mut escaped = false; - - while i < bytes.len() { - let b = bytes[i]; - - if let Some(delim) = string_delim { - out.push(b as char); - if escaped { - escaped = false; - } else if b == b'\\' { - escaped = true; - } else if b == delim { - string_delim = None; - } - i += 1; - continue; - } - - if in_line_comment { - out.push(b as char); - if b == b'\n' { - in_line_comment = false; - } - i += 1; - continue; - } - - if in_block_comment { - out.push(b as char); - if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - i += 2; - in_block_comment = false; - continue; - } - i += 1; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - out.push('/'); - i += 2; - in_line_comment = true; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - out.push('/'); - out.push('*'); - i += 2; - in_block_comment = true; - continue; - } - - if b == b'"' || b == b'\'' || b == b'`' { - out.push(b as char); - i += 1; - string_delim = Some(b); - escaped = false; - continue; - } - - if is_ident_start(b as char) { - let start = i; - i += 1; - while i < bytes.len() && is_ident_continue(bytes[i] as char) { - i += 1; - } - let ident = &source[start..i]; - - let namespace_methods = namespace_calls.get(ident); - let namespace_wildcard = namespace_wildcards.contains(ident); - if namespace_methods.is_some() || namespace_wildcard { - let mut j = i; - while j < bytes.len() - && bytes[j].is_ascii_whitespace() - && bytes[j] != b'\n' - && bytes[j] != b'\r' - { - j += 1; - } - - let mut sep_end = None; - if flavor == SourceFlavor::RustScript { - if j < bytes.len() && bytes[j] == b':' { - let mut k = j + 1; - while k < bytes.len() - && bytes[k].is_ascii_whitespace() - && bytes[k] != b'\n' - && bytes[k] != b'\r' - { - k += 1; - } - if k < bytes.len() && bytes[k] == b':' { - sep_end = Some(k + 1); - } - } - } else if j < bytes.len() && bytes[j] == b'.' { - sep_end = Some(j + 1); - } - - if let Some(mut k) = sep_end { - while k < bytes.len() - && bytes[k].is_ascii_whitespace() - && bytes[k] != b'\n' - && bytes[k] != b'\r' - { - k += 1; - } - if k < bytes.len() && is_ident_start(bytes[k] as char) { - let member_start = k; - k += 1; - while k < bytes.len() && is_ident_continue(bytes[k] as char) { - k += 1; - } - let member = &source[member_start..k]; - if call_starts_after_position(bytes, k, flavor) - && (namespace_wildcard - || namespace_methods - .is_some_and(|methods| methods.contains(member))) - { - out.push_str(member); - i = k; - continue; - } - } - } - } - - if let Some(target) = alias_calls.get(ident) - && call_starts_after_position(bytes, i, flavor) - { - out.push_str(target); - continue; - } - - let mut rewritten_by_prefix = false; - for prefix in prefix_aliases { - if !ident.starts_with(prefix) { - continue; - } - let rem = &ident[prefix.len()..]; - if rem.is_empty() || !is_valid_ident(rem) { - continue; - } - if call_starts_after_position(bytes, i, flavor) { - out.push_str(rem); - rewritten_by_prefix = true; - break; - } - } - if rewritten_by_prefix { - continue; - } - - out.push_str(ident); - continue; - } - - out.push(b as char); - i += 1; - } - - out -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::path::{Path, PathBuf}; - - use super::*; - use crate::compiler::CompileSourceFileOptions; - use crate::compiler::source_loader::model::{ - ExportedFunctionSignature, ImportClause, ModuleImport, NamedImport, - }; - - #[test] - fn rustscript_namespace_import_calls_rewrite_to_direct_calls() { - let source = r#" -string::non_empty("rss"); -is_empty(""); -"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ - ModuleImport { - spec: "strings.rss".to_string(), - clause: ImportClause::Namespace("string".to_string()), - line: 1, - }, - ModuleImport { - spec: "strings.rss".to_string(), - clause: ImportClause::Named(vec![NamedImport { - imported: "is_empty".to_string(), - local: "is_empty".to_string(), - }]), - line: 2, - }, - ]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("strings.rss"), - HashMap::from([ - ( - "is_empty".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - ), - ( - "non_empty".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - ), - ]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#" -non_empty("rss"); -is_empty(""); -"# - .trim() - ); - } - - #[test] - fn rustscript_namespace_import_turbofish_calls_rewrite_to_direct_calls() { - let source = r#"collections::dedup::(["rss", "rss"]);"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "collections.rss".to_string(), - clause: ImportClause::Namespace("collections".to_string()), - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("collections.rss"), - HashMap::from([( - "dedup".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: vec!["T".to_string()], - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#"dedup::(["rss", "rss"]);"# - ); - } - - #[test] - fn rustscript_named_import_turbofish_calls_rewrite_to_direct_calls() { - let source = r#"dedup_items::(["rss", "rss"]);"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "collections.rss".to_string(), - clause: ImportClause::Named(vec![NamedImport { - imported: "dedup".to_string(), - local: "dedup_items".to_string(), - }]), - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("collections.rss"), - HashMap::from([( - "dedup".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: vec!["T".to_string()], - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#"dedup::(["rss", "rss"]);"# - ); - } - - #[test] - fn rustscript_all_public_import_namespace_calls_rewrite_to_direct_calls() { - let source = "runtime::sleep(3);\n"; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "runtime.rss".to_string(), - clause: ImportClause::AllPublic, - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("runtime.rss"), - HashMap::from([( - "sleep".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!(rewritten.source.trim(), "sleep(3);"); - } -} diff --git a/src/compiler/source_map.rs b/src/compiler/source_map.rs index 5839a0c0..38163926 100644 --- a/src/compiler/source_map.rs +++ b/src/compiler/source_map.rs @@ -118,6 +118,33 @@ impl SourceMap { id } + /// Register a source at an explicit id (the semantic module graph's + /// [`SourceId`](crate::compiler::modules::SourceId) space) so spans that + /// reference that id resolve to this text. Missing slots are filled with + /// empty placeholders; an already-occupied slot keeps its first text. + pub fn add_source_at( + &mut self, + id: SourceId, + name: impl Into, + text: impl Into, + ) -> SourceId { + let id_usize = id as usize; + while self.files.len() <= id_usize { + let placeholder = self.files.len() as SourceId; + self.files + .push(SourceFile::new(placeholder, String::new(), String::new())); + } + if self.files[id_usize].text.is_empty() && self.files[id_usize].name.is_empty() { + self.files[id_usize] = SourceFile::new(id, name.into(), text.into()); + } + id + } + + /// Display name of the source registered at `id`. + pub fn file_name(&self, id: SourceId) -> Option<&str> { + self.file(id).map(|file| file.name.as_str()) + } + pub fn file(&self, id: SourceId) -> Option<&SourceFile> { self.files.get(id as usize) } diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index ac048e9a..359051ee 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -519,7 +519,9 @@ fn collect_expr_types( | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } - | Expr::FunctionRef(..) => { + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => { let _ = context.infer_expr_type(expr, state); } Expr::OptionalGet { container, key, .. } => { @@ -570,7 +572,7 @@ fn collect_expr_types( ); let _ = context.infer_expr_type(expr, state); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { collect_expr_types( arg, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index 89fc6501..efdfb045 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -922,8 +922,13 @@ impl<'a> TypeContext<'a> { state.get(*root) } } - Expr::FunctionRef(..) | Expr::Closure(_) => BoundType::Callable, - Expr::Call(..) | Expr::LocalCall(..) => self.infer_call_like_expr_type(expr, state), + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Closure(_) => BoundType::Callable, + Expr::Call(..) | Expr::ModuleCall(..) | Expr::LocalCall(..) => { + self.infer_call_like_expr_type(expr, state) + } Expr::ClosureCall(_, _) => self.infer_call_like_expr_type(expr, state), Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) @@ -1061,7 +1066,14 @@ impl<'a> TypeContext<'a> { .unwrap_or(BoundType::Unknown), }, Expr::ClosureCall(closure, args) => self.infer_closure_return(closure, args, state), - Expr::Closure(_) | Expr::FunctionRef(..) => BoundType::Callable, + Expr::Closure(_) + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => BoundType::Callable, + // Resolved module calls carry no per-unit type information; the + // prelude declaration they replaced also had an unknown return + // type, so this matches the legacy behavior. + Expr::ModuleCall(..) => BoundType::Unknown, _ => BoundType::Unknown, } } diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index d8ea130c..28e76a8e 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -1370,6 +1370,8 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1384,7 +1386,7 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> expr_contains_param_add(value, param_slots) || expr_contains_param_add(fallback, param_slots) } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => args + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => args .iter() .any(|arg| expr_contains_param_add(arg, param_slots)), Expr::ClosureCall(closure, args) => { @@ -1445,6 +1447,8 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::MoveField { .. } | Expr::MoveIndex { .. } => false, Expr::OptionalGet { container, key, .. } => { @@ -1453,7 +1457,7 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { Expr::OptionUnwrapOr { value, fallback, .. } => expr_uses_param(value, param_slots) || expr_uses_param(fallback, param_slots), - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { args.iter().any(|arg| expr_uses_param(arg, param_slots)) } Expr::ClosureCall(closure, args) => { @@ -1603,7 +1607,13 @@ pub(super) fn legalize_expr( let _ = legalize_expr(fallback, state, context); context.infer_expr_type(expr, state) } - Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::ModuleCall(..) + | Expr::LocalCall(..) + | Expr::Closure(_) => { legalize_expr_children(expr, state, context); context.infer_call_like_expr_type(expr, state) } @@ -1709,6 +1719,11 @@ pub(super) fn legalize_expr_children( fold_builtin_call(expr, builtin, state); } } + Expr::ModuleCall(_, _, args) => { + for arg in args.iter_mut() { + let _ = legalize_expr(arg, state, context); + } + } Expr::LocalCall(_, _, args) => { for arg in args.iter_mut() { let _ = legalize_expr(arg, state, context); diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index fab96ea6..6a2b61fe 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -556,7 +556,13 @@ pub(super) fn validate_expr( )?, Expr::Var(slot) | Expr::MoveVar(slot) => state.get(*slot), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => state.get(*root), - Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::ModuleCall(..) + | Expr::LocalCall(..) + | Expr::Closure(_) => { validate_expr_children( expr, state, diff --git a/src/lib.rs b/src/lib.rs index 652e1957..9c033066 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,14 +44,18 @@ pub fn builtin_call_index(name: &str) -> Option { BuiltinFunction::from_source_name(name).map(|builtin| builtin.call_index()) } -pub use compiler::diagnostics::{render_compile_error, render_source_error}; +pub use compiler::diagnostics::{ + render_compile_error, render_source_error, render_source_path_error, +}; pub use compiler::source_map::{LineSpanMapping, LoweredSource, SourceId, SourceMap, Span}; pub use compiler::{ AssignmentKind, ClosureExpr, CompileError, CompileSourceFileOptions, CompiledProgram, - CompiledReplProgram, Compiler, Expr, FormatError, FrontendImportSyntax, FrontendIr, - FunctionDecl, ImportClause, InferredLocalTypeHint, LocalIrBuilder, LocalSlot, ModuleImport, - NamedImport, ParseError, ParserDialect, ReplLocalBinding, ReplLocalState, SharedParserOptions, - SourceError, SourceFlavor, SourcePathError, SourcePlugin, Stmt, UnknownInferredLocal, + CompiledReplProgram, Compiler, DeclSymbol, ExportEntry, Expr, FormatError, + FrontendImportSyntax, FrontendIr, FunctionDecl, ImportClause, ImportTargetKind, + ImportedBinding, InferredLocalTypeHint, LocalIrBuilder, LocalSlot, ModuleGraph, ModuleId, + ModuleImport, ModuleNode, NamedImport, ParseError, ParserDialect, ReplLocalBinding, + ReplLocalState, ResolvedImport, SharedParserOptions, SourceError, SourceFlavor, + SourcePathError, SourcePlugin, Stmt, SymbolId, UnknownInferredLocal, UseDecl, UsePathSegment, collect_inferred_local_type_hints, collect_inferred_local_type_hints_at_path_with_options, collect_inferred_local_type_hints_with_options, compile_source, compile_source_at_path_with_flavor_and_options, compile_source_file, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f6967e09..89fc659d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -141,6 +141,7 @@ pub enum CompileErrorKind { InvalidFieldAccess, FunctionParameterTypeConflict, StrictTypingRequired, + UnresolvedModuleCall, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -184,6 +185,7 @@ fn compile_error_kind(err: &vm::CompileError) -> CompileErrorKind { CompileErrorKind::FunctionParameterTypeConflict } vm::CompileError::StrictTypingRequired { .. } => CompileErrorKind::StrictTypingRequired, + vm::CompileError::UnresolvedModuleCall => CompileErrorKind::UnresolvedModuleCall, } } diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 0ccb66ef..024a2bf1 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -2642,8 +2642,8 @@ fn compile_source_file_rustscript_imports_merge_with_scoped_locals() { debug .locals .iter() - .any(|local| local.name == "module::shared"), - "module-scoped local should remain visible in debug metadata" + .any(|local| local.name.ends_with("::shared") && local.name != "shared"), + "module-scoped local should remain visible in debug metadata with a deterministic module-identity scope (milestone 4), not a bare file stem" ); assert!( debug.locals.iter().any(|local| local.name == "shared"), @@ -2858,7 +2858,10 @@ fn compile_source_file_rustscript_imported_direct_capture_multiple_move_is_rejec Err(err) => err, }; match err { - vm::SourcePathError::Source(vm::SourceError::Parse(parse)) => { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { assert!( parse.message.contains("lut") && parse.message.contains("moved"), "unexpected parse error: {parse:?}" diff --git a/tests/compiler/diagnostics_tests.rs b/tests/compiler/diagnostics_tests.rs index 6e68eba3..57651c3f 100644 --- a/tests/compiler/diagnostics_tests.rs +++ b/tests/compiler/diagnostics_tests.rs @@ -95,16 +95,19 @@ pub fn ok() { let _ = fs::remove_dir(&root); match result { - Err(SourcePathError::Source(SourceError::Compile(compile))) => { + Err(SourcePathError::SourceWithMap { + error: SourceError::Compile(compile), + sources, + }) => { assert_eq!( compile.source_name(), Some(module_path.to_string_lossy().as_ref()) ); assert_eq!(compile.line(), Some(2)); - let mut source_map = SourceMap::new(); - source_map.add_source(module_path.display().to_string(), module_source); - let rendered = render_compile_error(&source_map, &compile, false); + // Milestone 5: the compilation-wide map travels with the error, + // so the rendered diagnostic reads the owning module source. + let rendered = render_compile_error(&sources, &compile, false); assert!(rendered.contains(&format!("{}:2:1", module_path.display()))); assert!(rendered.contains("let broken = if cond => {")); assert!(rendered.contains("int vs string")); diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index e6dcdfd8..9bc9ec6f 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -36,6 +36,8 @@ impl SourcePlugin for ConstantPlugin { function_impls: HashMap::new(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), }) } } diff --git a/tests/compiler/module_import_tests.rs b/tests/compiler/module_import_tests.rs index 1973cb0b..10229fcd 100644 --- a/tests/compiler/module_import_tests.rs +++ b/tests/compiler/module_import_tests.rs @@ -16,7 +16,9 @@ fn temp_module_root(prefix: &str) -> PathBuf { ); let root = std::env::temp_dir().join(unique); std::fs::create_dir_all(&root).expect("temp module root should be created"); - root + // Module identities are canonical for existing files; keep expected paths + // canonical too so assertions match under symlinked temp directories. + root.canonicalize().unwrap_or(root) } fn write_source(path: &Path, source: &str, description: &str) { @@ -69,6 +71,128 @@ fn compile_source_file_module_override_path_redirects_import_spec() { remove_module_root(&root); } +#[test] +fn nested_module_override_parse_error_preserves_source_text_and_path() { + let root = temp_module_root("vm_rustscript_nested_override_error_test"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::virtual::nested as nested; + nested::run(); + "#, + "main source", + ); + let override_source = "pub fn run( {"; + let options = CompileSourceFileOptions::new() + .with_module_override_source("virtual/nested.rss", override_source); + + let error = match compile_source_file_with_options(&main_path, options) { + Ok(_) => panic!("invalid override module should fail"), + Err(error) => error, + }; + match error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains("virtual/nested.rss")); + } + error => panic!("expected source-aware nested error, got {error:?}"), + } + + remove_module_root(&root); +} + +#[test] +fn nested_module_strict_unknown_diagnostic_keeps_module_source() { + let root = temp_module_root("vm_rustscript_nested_strict_diag_test"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + write_source( + &nested_path, + "pub fn run() -> unknown { 1 }", + "nested source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("unknown nested annotation should fail in strict RustScript"), + Err(error) => error, + }; + match error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains(&nested_path.display().to_string())); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + remove_module_root(&root); +} + +#[test] +fn strict_nested_diagnostic_path_is_consistent_across_option_entry_points() { + let root_source = "use self::nested as nested;\nnested::run();\n"; + let nested_source = "pub fn run() -> unknown { 1 }"; + let options = + CompileSourceFileOptions::new().with_module_override_source("nested.rss", nested_source); + + let in_memory_error = match vm::compile_source_with_flavor_and_options( + root_source, + SourceFlavor::RustScript, + options.clone(), + ) { + Ok(_) => panic!("strict nested annotation should fail"), + Err(error) => error, + }; + match in_memory_error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains("__pd_vm_inmemory__/nested.rss")); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + let root = temp_module_root("vm_rustscript_nested_strict_entry_test"); + let main_path = root.join("main.rss"); + let at_path_error = match vm::compile_source_at_path_with_flavor_and_options( + &main_path, + root_source, + SourceFlavor::RustScript, + options, + ) { + Ok(_) => panic!("strict nested annotation should fail"), + Err(error) => error, + }; + match at_path_error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!( + parse + .message + .contains(&root.join("nested.rss").display().to_string()) + ); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + remove_module_root(&root); +} + #[test] fn compile_source_file_rustscript_named_import_is_selective() { let root = temp_module_root("vm_rustscript_selective_import_test"); @@ -104,7 +228,10 @@ fn compile_source_file_rustscript_named_import_is_selective() { assert!( matches!( err, - vm::SourcePathError::Source(vm::SourceError::Parse(vm::ParseError { ref message, .. })) + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } if message.contains("unknown function 'add_two'") ), "expected unknown function error, got {err:?}" @@ -211,7 +338,10 @@ fn compile_source_file_rustscript_module_exports_only_pub_functions() { assert!( matches!( err, - vm::SourcePathError::Source(vm::SourceError::Parse(vm::ParseError { ref message, .. })) + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } if message.contains("unknown function 'private_add'") ), "expected unknown function error, got {err:?}" @@ -318,3 +448,466 @@ fn compile_source_file_imported_module_dynamic_slice_end_bindings_work() { remove_module_root(&root); } + +#[test] +fn nested_module_namespace_import_rewrites_sibling_calls() { + let root = temp_module_root("vm_rustscript_nested_namespace_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 7 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling as sibling; + pub fn run() -> int { sibling::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested namespace import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(7)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_named_import_rewrites_sibling_calls() { + let root = temp_module_root("vm_rustscript_nested_named_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 11 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling::{value}; + pub fn run() -> int { value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested named import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(11)]); + remove_module_root(&root); +} + +#[test] +fn nested_module_super_import_resolves_parent_directory_sibling() { + let root = temp_module_root("vm_rustscript_nested_super_import_test"); + write_source( + &root.join("shared.rss"), + r#" + pub fn value() -> int { 13 } + "#, + "parent sibling source", + ); + let package = root.join("pkg"); + std::fs::create_dir_all(&package).expect("package directory should be created"); + write_source( + &package.join("nested.rss"), + r#" + use super::shared as shared; + pub fn run() -> int { shared::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::pkg::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested super import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(13)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_missing_sibling_reports_nested_source() { + let root = temp_module_root("vm_rustscript_nested_missing_import_test"); + write_source( + &root.join("nested.rss"), + r#" + use self::missing as missing; + pub fn run() -> int { missing::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("missing nested sibling should fail"), + Err(error) => error, + }; + assert!( + matches!( + error, + vm::SourcePathError::Io(ref io_error) + if io_error.kind() == std::io::ErrorKind::NotFound + ), + "missing nested sibling should remain a filesystem error: {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn explicit_self_import_cycle_is_detected_after_path_normalization() { + let root = temp_module_root("vm_rustscript_self_cycle_import_test"); + write_source( + &root.join("a.rss"), + r#" + use self::b as b; + pub fn run() -> int { b::run() } + "#, + "a source", + ); + write_source( + &root.join("b.rss"), + r#" + use self::a as a; + pub fn run() -> int { a::run() } + "#, + "b source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + a::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("explicit self import cycle should fail"), + Err(error) => error, + }; + assert!( + matches!(error, vm::SourcePathError::ImportCycle(_)), + "expected import cycle error, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_does_not_reexport_transitive_imports() { + let root = temp_module_root("vm_rustscript_nested_export_boundary_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn leaf() -> int { 19 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling as sibling; + pub fn run() -> int { sibling::leaf() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::leaf(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("transitive import should not be re-exported"), + Err(error) => error, + }; + assert!( + matches!( + error, + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } + if message.contains("nested::leaf") || message.contains("unknown namespace") + ), + "expected transitive export boundary error, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_rewrite_preserves_utf8_values_byte_for_byte() { + let root = temp_module_root("vm_rustscript_nested_utf8_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + // 猫のコメント: the sibling module is untouched by rewriting. + pub fn echo(value: string) -> string { value } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + /* 前置ブロック: 猫 */ + use self::sibling as sibling; + use self::sibling::{echo as echo_named}; + pub fn run() -> string { + let namespace_value = sibling::echo("猫"); + let named_value = echo_named("🐱 にゃん"); + // 行コメント: 猫 + let joined = namespace_value + named_value; + joined + } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested utf-8 imports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("猫🐱 にゃん")], + "UTF-8 literals must survive namespace and named import rewriting" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_consecutive_super_import_resolves_two_levels_up() { + let root = temp_module_root("vm_rustscript_consecutive_super_import_test"); + write_source( + &root.join("shared.rss"), + r#" + pub fn value() -> int { 17 } + "#, + "root sibling source", + ); + let package = root.join("pkg").join("sub"); + std::fs::create_dir_all(&package).expect("package directory should be created"); + write_source( + &package.join("nested.rss"), + r#" + use super::super::shared as shared; + pub fn run() -> int { shared::value() } + "#, + "two-level nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::pkg::sub::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("consecutive super import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(17)]); + + remove_module_root(&root); +} + +#[test] +fn path_aliases_resolve_to_single_module_identity() { + let root = temp_module_root("vm_rustscript_path_alias_identity_test"); + write_source( + &root.join("a.rss"), + r#" + pub fn value() -> int { 23 } + "#, + "module a source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + use a as a2; + let x = a::value(); + let y = a2::value(); + x + y; + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("lexically distinct path aliases should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(46)]); + + remove_module_root(&root); +} + +#[test] +fn import_cycle_detected_across_lexically_distinct_aliases() { + let root = temp_module_root("vm_rustscript_cycle_alias_identity_test"); + write_source( + &root.join("a.rss"), + r#" + use self::b as b; + pub fn run() -> int { b::run() } + "#, + "a source", + ); + write_source( + &root.join("b.rss"), + r#" + use a as a; + pub fn run() -> int { a::run() } + "#, + "b source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + a::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("lexically distinct cycle aliases should fail"), + Err(error) => error, + }; + assert!( + matches!(error, vm::SourcePathError::ImportCycle(_)), + "expected import cycle error across alias forms, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_import_aliases_are_idempotent() { + let root = temp_module_root("vm_rustscript_duplicate_alias_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 29 } + "#, + "sibling source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::sibling as sib; + use self::sibling as sib; + sib::value(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("duplicate import aliases should be idempotent"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(29)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_host_namespace_import_stays_host() { + let root = temp_module_root("vm_rustscript_nested_host_namespace_test"); + write_source( + &root.join("nested.rss"), + r#" + use math; + pub fn run() -> float { math::sqrt(81) } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("nested host namespace import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Float(9.0)]); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m12_tests.rs b/tests/compiler/semantic_module_m12_tests.rs new file mode 100644 index 00000000..55f58492 --- /dev/null +++ b/tests/compiler/semantic_module_m12_tests.rs @@ -0,0 +1,260 @@ +//! Milestones 1-2 of the semantic module system: structured `use` parsing with +//! spans/clauses, deterministic module identities, same-stem uniqueness, and +//! the dedicated host-namespace path. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; +use vm::{ + ImportClause, ParserDialect, SharedParserOptions, UsePathSegment, parse_source_with_dialect, +}; + +/// Minimal dialect for driving the shared frontend parser from tests. +struct TestDialect; + +impl ParserDialect for TestDialect {} + +static TEST_DIALECT: TestDialect = TestDialect; + +fn rustscript_options() -> SharedParserOptions { + SharedParserOptions { + source_id: 0, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + } +} + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn parser_records_structured_use_nodes_with_spans_and_clauses() { + let source = "use self::pkg::nested as nested;\n\ + use sibling::{value as v, other};\n\ + use super::shared;\n\ + use io;\n\ + 1;\n"; + let ir = parse_source_with_dialect(source, &TEST_DIALECT, rustscript_options()) + .expect("source should parse"); + + let decls = &ir.use_declarations; + assert_eq!( + decls.len(), + 4, + "every use directive becomes a structured node" + ); + + // self-qualified namespace import. + assert_eq!( + decls[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("pkg".to_string()), + UsePathSegment::Ident("nested".to_string()), + ] + ); + assert!( + matches!(&decls[0].clause, ImportClause::Namespace(alias) if alias == "nested"), + "namespace alias clause expected" + ); + assert_eq!(decls[0].line, 1); + + // Named import list with an alias. + assert_eq!( + decls[1].path, + vec![UsePathSegment::Ident("sibling".to_string())] + ); + match &decls[1].clause { + ImportClause::Named(named) => { + assert_eq!(named.len(), 2); + assert_eq!(named[0].imported, "value"); + assert_eq!(named[0].local, "v"); + assert_eq!(named[1].imported, "other"); + assert_eq!(named[1].local, "other"); + } + other => panic!("expected named clause, got {other:?}"), + } + assert_eq!(decls[1].line, 2); + + // super-qualified and bare builtin imports. + assert_eq!( + decls[2].path, + vec![ + UsePathSegment::Super, + UsePathSegment::Ident("shared".to_string()) + ] + ); + assert!(matches!(decls[2].clause, ImportClause::AllPublic)); + assert!(matches!(decls[3].clause, ImportClause::AllPublic)); + assert_eq!(decls[3].line, 4); + + // Every span covers exactly its directive text in the source. + for decl in decls { + assert!( + decl.span.lo < decl.span.hi, + "span must cover the directive: {decl:?}" + ); + let text = &source[decl.span.lo..decl.span.hi]; + assert!( + text.starts_with("use ") && text.ends_with(';'), + "span must cover the full directive, got {text:?}" + ); + } +} + +#[test] +fn parser_import_scan_mode_tolerates_file_module_calls() { + // The source-loader discovery parse must accept calls to functions that + // only the later prelude/rewrite step resolves: unknown direct calls and + // namespace calls through multi-segment file-module paths. + let source = "use self::nested as nested;\n\ + nested::run();\n\ + imported_helper(1);\n\ + 1;\n"; + let options = SharedParserOptions { + allow_implicit_externs: true, + import_scan_mode: true, + ..rustscript_options() + }; + let ir = parse_source_with_dialect(source, &TEST_DIALECT, options) + .expect("scan mode must tolerate unresolved module calls"); + assert_eq!(ir.use_declarations.len(), 1); + assert_eq!( + ir.use_declarations[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("nested".to_string()) + ] + ); +} + +#[test] +fn same_stem_modules_in_different_directories_compile_and_run() { + let root = temp_module_root("semantic_m12_same_stem"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + let b_module = b_dir.join("util.rss"); + write_source(&a_module, "pub fn alpha() { 11; }\n", "a/util source"); + write_source(&b_module, "pub fn beta() { 22; }\n", "b/util source"); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-stem modules should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "both same-stem modules must resolve independently" + ); + + remove_module_root(&root); +} + +#[test] +fn host_namespace_imports_keep_dedicated_resolution_path() { + struct ExistsOverride; + + impl HostFunction for ExistsOverride { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + Ok(CallOutcome::Return(vec![Value::Bool(false)].into())) + } + } + + let source = "use io;\nio::exists(\"request_body\");\n"; + let compiled = compile_source(source).expect("host namespace import should compile"); + let mut vm = Vm::new(compiled.program); + vm.bind_function("io::exists", Box::new(ExistsOverride)); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Bool(false)]); +} + +#[test] +fn named_import_with_alias_through_self_resolves_structurally() { + let root = temp_module_root("semantic_m12_named_alias"); + let module_path = root.join("module.rss"); + write_source( + &module_path, + "pub fn echo(value) { value; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::module::{echo as e};\ne(42);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("named alias import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn structured_import_syntax_rejects_crate_paths() { + let err = match compile_source("use crate::x;\n1;\n") { + Ok(_) => panic!("crate:: paths should be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("crate:: paths are not supported"), + "unexpected error: {message}" + ); +} + +#[test] +fn structured_import_syntax_rejects_import_keyword() { + let root = temp_module_root("semantic_m12_import_keyword"); + let main_path = root.join("main.rss"); + write_source(&main_path, "import \"./module.rss\";\n1;\n", "main source"); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("legacy import syntax should be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("uses 'use', not 'import'"), + "unexpected error: {message}" + ); + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m3_tests.rs b/tests/compiler/semantic_module_m3_tests.rs new file mode 100644 index 00000000..80dae118 --- /dev/null +++ b/tests/compiler/semantic_module_m3_tests.rs @@ -0,0 +1,215 @@ +//! Milestone 3 of the semantic module system: declaration symbols owned by +//! modules, public export tables, imported-vs-local separation, duplicate +//! declaration diagnostics, same-named helpers across modules, and no +//! implicit transitive re-export — with bytecode behavior preserved. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// `a/util` exports `alpha` and keeps `hidden` private; `b/util` exports +/// `beta`. Both modules declare a private helper named `helper`. +fn write_public_private_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn alpha() { helper(); }\nfn helper() { 42; }\nfn hidden() { 7; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn beta() { helper(); }\nfn helper() { 42; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + (main_path, a_module, b_module) +} + +#[test] +fn same_named_helpers_across_modules_coexist() { + // Milestone 4 lifts the flat-merge limitation documented by milestone 3: + // same-named private helpers in independent modules now coexist, each + // resolved by its compiler-owned symbol. `alpha` and `beta` each call + // their own module's `helper`. + let root = temp_module_root("semantic_m3_same_helpers"); + let (main_path, _, _) = write_public_private_fixture(&root); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-named helpers should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(42), Value::Int(42)], + "each module's helper must resolve within its own module" + ); + + remove_module_root(&root); +} + +#[test] +fn public_functions_are_importable_private_functions_are_not() { + let root = temp_module_root("semantic_m3_visibility"); + let (main_path, _, _) = write_public_private_fixture(&root); + + // Public export: `alpha` resolves through the namespace import. + write_source( + &main_path, + "use a::util as au;\nau::alpha();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("public export should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // Private declaration: `hidden` is not in a/util's export table, so the + // call cannot resolve through the import. + write_source( + &main_path, + "use a::util as au;\nau::hidden();\n", + "main source", + ); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("private functions must not be importable"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("hidden"), + "diagnostic should name the private function, got: {message}" + ); + + remove_module_root(&root); +} + +#[test] +fn transitive_imports_are_not_reexported() { + // a imports c and uses c::shared internally; the root imports only a. + // `shared` must stay out of a's export table: calling it from the root + // without a direct import is a diagnostic, not a silent re-export. + let root = temp_module_root("semantic_m3_no_reexport"); + let c_module = root.join("c.rss"); + write_source(&c_module, "pub fn shared() { 100; }\n", "c source"); + let a_module = root.join("a.rss"); + write_source( + &a_module, + "use self::c;\npub fn alpha() { c::shared(); }\n", + "a source", + ); + let main_path = root.join("main.rss"); + + write_source(&main_path, "use a;\nalpha();\nshared();\n", "main source"); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("transitive imports must not be re-exported implicitly"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("shared"), + "diagnostic should name the non-reexported function, got: {message}" + ); + + // Positive control: importing c directly makes `shared` resolvable. + write_source( + &main_path, + "use a;\nuse c;\nalpha();\nshared();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("direct import should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(100), Value::Int(100)]); + + remove_module_root(&root); +} + +#[test] +fn imported_name_clashing_with_local_declaration_is_a_diagnostic() { + // The import prelude declares the imported name, so declaring the same + // name locally in the importing module is a duplicate diagnostic instead + // of a silent shadow. + let root = temp_module_root("semantic_m3_import_clash"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + let a_module = a_dir.join("util.rss"); + write_source(&a_module, "pub fn helper() { 1; }\n", "a/util source"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util;\nfn helper() { 2; }\nhelper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("imported name clashing with a local declaration must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("conflicts with a local declaration") + || message.contains("duplicate function 'helper'"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_local_declaration_in_a_module_is_a_diagnostic() { + let root = temp_module_root("semantic_m3_dup_local"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "fn dup() { 1; }\nfn dup() { 2; }\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("duplicate local declarations must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("duplicate function 'dup'"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m4_tests.rs b/tests/compiler/semantic_module_m4_tests.rs new file mode 100644 index 00000000..5a8e43d2 --- /dev/null +++ b/tests/compiler/semantic_module_m4_tests.rs @@ -0,0 +1,343 @@ +//! Milestone 4 of the semantic module system: calls resolve by compiler-owned +//! `SymbolId` before unit merge, the flat linker keys module functions by +//! symbol instead of by source name, and names are deterministically mangled +//! only at the flat bytecode boundary. Same-named declarations in independent +//! modules coexist; local bindings are scoped by full module identity instead +//! of a bare file stem. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// `a/util` and `b/util` both export a public `run` (different bodies) and +/// each keeps a private helper named `helper` that its own `run` calls. +fn write_same_export_fixture(root: &Path) -> PathBuf { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + main_path +} + +#[test] +fn same_exported_function_name_in_two_namespaces_calls_separately() { + let root = temp_module_root("semantic_m4_same_export"); + let main_path = write_same_export_fixture(&root); + + let compiled = compile_source_file(&main_path).expect("same-named exports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "au::run and bu::run must resolve to their own module's export" + ); + + // The flat exported-callable table keeps both exports addressable: the + // first export keeps its bare name and the collision is deterministically + // mangled with the module identity. + let exported_names = vm + .program() + .exported_callables + .iter() + .map(|exported| exported.name.as_str()) + .collect::>(); + assert!( + exported_names.contains(&"run"), + "one export keeps the bare name: {exported_names:?}" + ); + assert_eq!( + exported_names + .iter() + .filter(|name| name.starts_with("run__m")) + .count(), + 1, + "the colliding export is deterministically mangled: {exported_names:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_named_private_helpers_are_resolved_within_their_own_module() { + let root = temp_module_root("semantic_m4_private_helpers"); + let main_path = write_same_export_fixture(&root); + + let compiled = compile_source_file(&main_path).expect("same-named helpers should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "each module's private helper must be the one its own run calls" + ); + + remove_module_root(&root); +} + +#[test] +fn named_import_aliases_resolve_to_distinct_symbols() { + let root = temp_module_root("semantic_m4_named_aliases"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn emit(value) { value * 2; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn emit(value) { value * 3; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util::{emit as twice};\nuse b::util::{emit as thrice};\ntwice(4);\nthrice(4);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("named alias imports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(8), Value::Int(12)], + "each alias must call its own module's emit" + ); + + remove_module_root(&root); +} + +#[test] +fn local_functions_resolve_within_their_own_module() { + // `run` (pub) calls `local` (private) in both modules; the local calls + // must stay inside their declaring module even though both modules define + // same-named functions. + let root = temp_module_root("semantic_m4_local_functions"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn run() { local(); }\nfn local() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn run() { local(); }\nfn local() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("local functions should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(1), Value::Int(2)], + "each module's local function must resolve within its own module" + ); + + remove_module_root(&root); +} + +#[test] +fn ambiguous_direct_call_to_same_name_from_two_modules_is_a_diagnostic() { + // Both modules export `helper` and the root imports both without aliases: + // a bare `helper()` call cannot name a single symbol. The legacy pipeline + // reported a flat merge error; milestone 4 reports the ambiguity and asks + // for a namespace-qualified or named-import call. + let root = temp_module_root("semantic_m4_ambiguous_direct"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn helper() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn helper() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util;\nuse b::util;\nhelper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("ambiguous direct calls must be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("ambiguous"), + "diagnostic should report the ambiguity, got: {message}" + ); + assert!( + message.contains("helper"), + "diagnostic should name the ambiguous function, got: {message}" + ); + + // The same fixture compiles once the calls are namespace-qualified. + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::helper();\nbu::helper();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("qualified calls should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(2)]); + + remove_module_root(&root); +} + +#[test] +fn internal_lowering_is_deterministic_across_repeated_discovery() { + // The same fixture compiled twice must produce byte-identical bytecode: + // module ids, symbol ids, stub names, flat indices, and mangled names are + // all assigned deterministically from discovery order. + let root = temp_module_root("semantic_m4_deterministic"); + let main_path = write_same_export_fixture(&root); + + let compile_bytes = || { + let compiled = compile_source_file(&main_path).expect("compile should succeed"); + vm::encode_program(&compiled.program).expect("program should encode") + }; + + let first = compile_bytes(); + let second = compile_bytes(); + assert_eq!( + first, second, + "internal lowering must be deterministic across discovery passes" + ); + + remove_module_root(&root); +} + +#[test] +fn same_stem_modules_do_not_collide_local_binding_scope_names() { + // Two same-stem modules (`a/util`, `b/util`) both declare a local `x`. + // Milestone 4 scopes non-root locals by full module identity (never a + // bare file stem), so both survive the flat boundary with distinct names. + let root = temp_module_root("semantic_m4_no_basename_scope"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn alpha() { let x = 7; x; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn beta() { let x = 8; x; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-stem modules should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let x_names = debug + .locals + .iter() + .filter(|local| local.name.ends_with("::x")) + .map(|local| local.name.as_str()) + .collect::>(); + assert_eq!( + x_names.len(), + 2, + "both modules' x locals must survive the flat boundary: {x_names:?}" + ); + assert!( + x_names.iter().all(|name| *name != "x"), + "non-root locals must be scoped by module identity, got: {x_names:?}" + ); + assert!( + x_names.iter().all(|name| name.contains("__m")), + "scope identity must encode the compiler-owned module id: {x_names:?}" + ); + assert!( + x_names[0] != x_names[1], + "same-stem modules must not share a scope identity: {x_names:?}" + ); + + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(7), Value::Int(8)]); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m5_tests.rs b/tests/compiler/semantic_module_m5_tests.rs new file mode 100644 index 00000000..023f3d7c --- /dev/null +++ b/tests/compiler/semantic_module_m5_tests.rs @@ -0,0 +1,457 @@ +//! Milestone 5 of the semantic module system: source-owned spans and +//! diagnostics through merge. +//! +//! Every span produced during load/parse/typing/merge references the semantic +//! module graph's `SourceId` space, and the compilation-wide `SourceMap` +//! travels with module-compile errors (`SourcePathError::SourceWithMap`), so +//! rendered diagnostics always read from the owning source. Merging units can +//! never reinterpret one module's offsets or lines against another file. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// Render a module-compile error against the compilation-wide source map it +/// carries (milestone 5): the rendered diagnostic shows the owning file name, +/// line, and code frame. +fn render_path_error(err: &vm::SourcePathError) -> String { + match err { + vm::SourcePathError::SourceWithMap { error, sources } => match error { + vm::SourceError::Parse(parse) => vm::render_source_error(sources, parse, false), + vm::SourceError::Compile(compile) => vm::render_compile_error(sources, compile, false), + }, + other => vm::render_source_path_error(Path::new(""), other, false), + } +} + +#[test] +fn root_parse_error_renders_root_path_and_frame() { + let root = temp_module_root("semantic_m5_root_parse"); + let main_path = root.join("main.rss"); + write_source(&main_path, "fn run() {\nlet x = ;\n}\n", "main source"); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("invalid root source should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "root diagnostic must name the root path, got:\n{rendered}" + ); + assert!( + rendered.contains("let x = ;"), + "root diagnostic must show the root code frame, got:\n{rendered}" + ); + assert!( + rendered.contains("--> "), + "root diagnostic must include a source frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_parse_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_nested_parse"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::nested as nested;\nnested::run();\n", + "main source", + ); + let nested_path = root.join("nested.rss"); + write_source(&nested_path, "pub fn run( {\n", "nested source"); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("malformed nested module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&nested_path.display().to_string()), + "nested parse diagnostic must name the nested path, got:\n{rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "nested parse diagnostic must show the nested code frame, got:\n{rendered}" + ); + assert!( + !rendered.contains("nested::run();"), + "nested parse diagnostic must not show the root frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_typing_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_nested_typing"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::broken as broken;\nbroken::run();\n", + "main source", + ); + let broken_path = root.join("broken.rss"); + write_source( + &broken_path, + "pub fn run() {\nlet cond = 1 == 1;\nlet value = if cond => {\n 1\n} else => {\n \"x\"\n};\nvalue\n}\n", + "broken source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("typed mismatched nested module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("compile error"), + "typing diagnostic must render as a compile error, got:\n{rendered}" + ); + assert!( + rendered.contains(&broken_path.display().to_string()), + "typing diagnostic must name the nested module, got:\n{rendered}" + ); + assert!( + rendered.contains("let value = if cond => {"), + "typing diagnostic must show the nested module's code frame, got:\n{rendered}" + ); + assert!( + rendered.contains("int vs string") || rendered.contains("incompatible"), + "typing diagnostic must keep its detail message, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_function_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_duplicate"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::dupmod as d;\nd::run();\n", + "main source", + ); + let dup_path = root.join("dupmod.rss"); + write_source( + &dup_path, + "fn run() { 1; }\nfn run() { 2; }\n", + "duplicate source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("duplicate declarations should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("duplicate"), + "duplicate diagnostic must say 'duplicate', got:\n{rendered}" + ); + assert!( + rendered.contains(&dup_path.display().to_string()), + "duplicate diagnostic must name the owning module, got:\n{rendered}" + ); + assert!( + rendered.contains("fn run() { 2; }"), + "duplicate diagnostic must point at the redeclaration's frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn private_export_visibility_failure_renders_from_importing_source() { + // `hidden` is private in the module; the named import in main must fail + // and the diagnostic must render from main's own `use` line. + let root = temp_module_root("semantic_m5_visibility"); + let module_path = root.join("module.rss"); + write_source(&module_path, "fn hidden() { 1; }\n", "module source"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module::{hidden};\nhidden();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("importing a private function should fail"), + Err(err) => err, + }; + assert!( + err.to_string().contains("has no public function 'hidden'"), + "visibility diagnostic must name the missing public function, got: {err}" + ); + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "visibility diagnostic must render from the importing module, got:\n{rendered}" + ); + assert!( + rendered.contains("use module::{hidden};"), + "visibility diagnostic must show the importing module's use line, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn unresolved_module_call_renders_from_owning_source() { + // `nested` imports `sibling` privately; main cannot call `leaf` through + // `nested` (no implicit transitive re-export). The unresolved call must + // render from main's own source. + let root = temp_module_root("semantic_m5_unresolved_call"); + let sibling_path = root.join("sibling.rss"); + write_source( + &sibling_path, + "pub fn leaf() -> int { 19 }\n", + "sibling source", + ); + let nested_path = root.join("nested.rss"); + write_source( + &nested_path, + "use self::sibling as sibling;\npub fn run() -> int { sibling::leaf() }\n", + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::nested as nested;\nnested::leaf();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("calling a non re-exported function should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "unresolved call diagnostic must name the calling module, got:\n{rendered}" + ); + assert!( + rendered.contains("nested::leaf();"), + "unresolved call diagnostic must show the call site frame, got:\n{rendered}" + ); + assert!( + !rendered.contains("pub fn run() -> int"), + "unresolved call diagnostic must not show the nested module's frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_line_number_in_different_modules_renders_each_owning_source() { + // Both modules fail strict typing on line 2, but each compilation must + // render its own file's line 2 text. + let root = temp_module_root("semantic_m5_same_line"); + let a_module = root.join("a.rss"); + write_source( + &a_module, + "pub fn run() {\nlet a: unknown = 1;\na\n}\n", + "a module source", + ); + let b_module = root.join("b.rss"); + write_source( + &b_module, + "pub fn run() {\nlet b: unknown = 2;\nb\n}\n", + "b module source", + ); + + let compile_entry = |entry_name: &str, module: &Path| { + let entry = root.join(entry_name); + let module_name = module + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("stem"); + write_source( + &entry, + &format!("use self::{module_name} as m;\nm::run();\n"), + "entry source", + ); + compile_source_file(&entry) + }; + + let err_a = match compile_entry("main_a.rss", &a_module) { + Ok(_) => panic!("a module should fail strict typing"), + Err(err) => err, + }; + let rendered_a = render_path_error(&err_a); + assert!( + rendered_a.contains(&a_module.display().to_string()), + "a diagnostic must name a.rss, got:\n{rendered_a}" + ); + assert!( + rendered_a.contains("let a: unknown = 1;"), + "a diagnostic must show a.rss line 2, got:\n{rendered_a}" + ); + + let err_b = match compile_entry("main_b.rss", &b_module) { + Ok(_) => panic!("b module should fail strict typing"), + Err(err) => err, + }; + let rendered_b = render_path_error(&err_b); + assert!( + rendered_b.contains(&b_module.display().to_string()), + "b diagnostic must name b.rss, got:\n{rendered_b}" + ); + assert!( + rendered_b.contains("let b: unknown = 2;"), + "b diagnostic must show b.rss line 2, got:\n{rendered_b}" + ); + assert!( + !rendered_b.contains("let a: unknown = 1;"), + "b diagnostic must never show a.rss's line 2 text, got:\n{rendered_b}" + ); + + remove_module_root(&root); +} + +#[test] +fn in_memory_override_and_disk_modules_render_their_own_sources() { + // Disk module a/util and in-memory override b/util both fail strict + // typing on line 2 of their own text. The disk failure must render the + // file that exists on disk; the override failure must render the override + // text even though b/util.rss does not exist on disk. + let root = temp_module_root("semantic_m5_override_ownership"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn alpha() {\nlet a: unknown = 1;\na\n}\n", + "a/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + // Phase 1: a/util (disk) is the first failing module; the diagnostic must + // render the disk text of a/util.rss. + let options_phase1 = vm::CompileSourceFileOptions::new() + .with_module_override_source("b/util.rss", "pub fn beta() {\nlet b: unknown = 2;\nb\n}\n"); + let err_phase1 = match compile_source_file_with_options(&main_path, options_phase1) { + Ok(_) => panic!("disk module should fail strict typing"), + Err(err) => err, + }; + let rendered_phase1 = render_path_error(&err_phase1); + assert!( + rendered_phase1.contains(&a_module.display().to_string()), + "disk diagnostic must name a/util.rss, got:\n{rendered_phase1}" + ); + assert!( + rendered_phase1.contains("let a: unknown = 1;"), + "disk diagnostic must show the disk code frame, got:\n{rendered_phase1}" + ); + + // Phase 2: a/util (disk) is valid; the in-memory override for b/util is + // the failing module. The diagnostic must render the override text even + // though no b/util.rss exists on disk. + write_source(&a_module, "pub fn alpha() { 1; }\n", "a/util fixed source"); + let options_phase2 = vm::CompileSourceFileOptions::new() + .with_module_override_source("b/util.rss", "pub fn beta() {\nlet b: unknown = 2;\nb\n}\n"); + let err_phase2 = match compile_source_file_with_options(&main_path, options_phase2) { + Ok(_) => panic!("override module should fail strict typing"), + Err(err) => err, + }; + let rendered_phase2 = render_path_error(&err_phase2); + assert!( + rendered_phase2.contains("__pd_vm_inmemory__/b/util.rss") + || rendered_phase2.contains(&root.join("b/util.rss").display().to_string()), + "override diagnostic must name the virtual b/util identity, got:\n{rendered_phase2}" + ); + assert!( + rendered_phase2.contains("let b: unknown = 2;"), + "override diagnostic must show the override text frame, got:\n{rendered_phase2}" + ); + assert!( + !rendered_phase2.contains("let a: unknown = 1;"), + "override diagnostic must not show a/util's frame, got:\n{rendered_phase2}" + ); + + remove_module_root(&root); +} + +#[test] +fn in_memory_root_error_renders_virtual_path_and_frame() { + // `compile_source_with_flavor_and_options` compiles a virtual root; its + // parse error must render the virtual path and the in-memory frame. + let source = "use self::nested as nested;\nnested::run();\n"; + let options = vm::CompileSourceFileOptions::new() + .with_module_override_source("nested.rss", "pub fn run( {\n"); + + let err = match vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + options, + ) { + Ok(_) => panic!("malformed override module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("__pd_vm_inmemory__/nested.rss"), + "in-memory diagnostic must name the virtual nested path, got:\n{rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "in-memory diagnostic must show the override code frame, got:\n{rendered}" + ); + + // The same fixture through the at-path entry point renders the disk-path + // identity of the override instead. + let root = temp_module_root("semantic_m5_virtual_at_path"); + let main_path = root.join("main.rss"); + let at_path_err = match vm::compile_source_at_path_with_flavor_and_options( + &main_path, + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_module_override_source("nested.rss", "pub fn run( {\n"), + ) { + Ok(_) => panic!("malformed override module should fail"), + Err(err) => err, + }; + let rendered_at_path = render_path_error(&at_path_err); + assert!( + rendered_at_path.contains(&root.join("nested.rss").display().to_string()), + "at-path diagnostic must name the disk-path override identity, got:\n{rendered_at_path}" + ); + assert!( + rendered_at_path.contains("pub fn run( {"), + "at-path diagnostic must show the override code frame, got:\n{rendered_at_path}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m6_tests.rs b/tests/compiler/semantic_module_m6_tests.rs new file mode 100644 index 00000000..df9679c5 --- /dev/null +++ b/tests/compiler/semantic_module_m6_tests.rs @@ -0,0 +1,399 @@ +//! Milestone 6/7 verification: the semantic module pipeline is the sole +//! file-module path. +//! +//! These tests exercise the end-to-end module behavior that the removed +//! textual rewrite/prelude machinery used to provide: wildcard imports, +//! function values of imported functions, generic calls through every import +//! form, single-segment host-form namespace calls, deterministic output, and +//! import-order independence of behavior. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn wildcard_import_exposes_all_public_exports_directly_and_by_namespace() { + let root = temp_module_root("semantic_m6_wildcard"); + write_source( + &root.join("util.rss"), + "pub fn value() -> int { 5 }\npub fn double(x) { x * 2; }\nfn private_helper() { 99; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util::*;\nlet direct = value();\nlet ns = util::double(direct);\nns;\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("wildcard import should compile"); + assert!( + compiled.functions.is_empty(), + "wildcard imports must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(10)]); + + remove_module_root(&root); +} + +#[test] +fn wildcard_import_does_not_expose_private_helpers() { + let root = temp_module_root("semantic_m6_wildcard_private"); + write_source( + &root.join("util.rss"), + "pub fn value() -> int { 5 }\nfn private_helper() { 99; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util::*;\nprivate_helper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("wildcard import must not expose private functions"), + Err(err) => err, + }; + assert!( + err.to_string() + .contains("unknown function 'private_helper'"), + "unexpected diagnostic: {err}" + ); + + remove_module_root(&root); +} + +#[test] +fn imported_function_values_resolve_to_module_symbols() { + let root = temp_module_root("semantic_m6_function_values"); + write_source( + &root.join("util.rss"), + "pub fn add_one(x) { x + 1; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util;\nlet f = add_one;\nf(41);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("function value import should compile"); + assert!( + compiled.functions.is_empty(), + "imported function values must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn generic_calls_work_through_named_namespace_and_alias_import_forms() { + let root = temp_module_root("semantic_m6_generic_forms"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn wrap(value: T) { let copied = value; [copied]; }\n", + "a/util source", + ); + write_source( + &root.join("helpers.rss"), + "pub fn wrap(value: T) { let copied = value; [copied]; }\n", + "helpers source", + ); + + // Named import (direct), all-public namespace call, and aliased + // namespace call all carry explicit type arguments through to the + // exported type parameters. + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::helpers::{wrap as direct_wrap}; + use self::helpers; + use a::util as au; + + let named = direct_wrap::(1); + let namespace_value = helpers::wrap::(2); + let aliased = au::wrap::(3); + named.length + namespace_value.length + aliased.length; + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("generic import forms should compile"); + assert!( + compiled.functions.is_empty(), + "generic imported calls must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(3)]); + + remove_module_root(&root); +} + +#[test] +fn single_segment_module_import_namespace_calls_stay_module_calls() { + // `use module; module::fn()` parses as a host-form call (the parser + // cannot know `module` is a file module); the loader must fix it up to a + // module call instead of emitting a host import. + let root = temp_module_root("semantic_m6_single_segment_ns"); + write_source( + &root.join("module.rss"), + "pub fn public_add(x) { x + 1; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module;\nmodule::public_add(41);\n", + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("single-segment namespace call should compile"); + assert!( + compiled.functions.is_empty(), + "file-module namespace calls must not become host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn single_segment_named_import_missing_member_stays_unknown_function() { + let root = temp_module_root("semantic_m6_single_segment_named"); + write_source( + &root.join("module.rss"), + "pub fn add_one(x) { x + 1; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module::{add_one};\nadd_two(40);\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("unlisted member must fail"), + Err(err) => err, + }; + assert!( + err.to_string().contains("unknown function 'add_two'"), + "unexpected diagnostic: {err}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_exported_name_from_two_modules_resolves_per_namespace() { + // Two modules exporting the same name, imported through aliases; the + // final flat boundary keeps both addressable with deterministic + // module-identity mangling for the colliding name. + let root = temp_module_root("semantic_m6_same_exports"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn helper() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn helper() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::helper();\nbu::helper();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-name exports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(2)]); + + remove_module_root(&root); +} + +#[test] +fn compiled_output_is_deterministic_across_repeated_compilations() { + let root = temp_module_root("semantic_m6_deterministic_bytes"); + write_source( + &root.join("util.rss"), + "pub fn helper() { 1; }\npub fn other() { helper() + 1; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util;\nhelper();\nother();\n", + "main source", + ); + + let compile = || { + let compiled = compile_source_file(&main_path).expect("compile should succeed"); + let function_names = compiled + .functions + .iter() + .map(|func| func.name.clone()) + .collect::>(); + (compiled.program.code.clone(), function_names) + }; + let (first_instructions, first_names) = compile(); + let (second_instructions, second_names) = compile(); + + assert_eq!( + first_names, second_names, + "function table must be identical across compilations" + ); + assert_eq!( + first_instructions, second_instructions, + "bytecode must be identical across compilations" + ); + + remove_module_root(&root); +} + +#[test] +fn import_order_swap_produces_identical_behavior() { + let root = temp_module_root("semantic_m6_import_order"); + write_source( + &root.join("a.rss"), + "pub fn value() -> int { 3 }\n", + "a source", + ); + write_source( + &root.join("b.rss"), + "pub fn value() -> int { 4 }\n", + "b source", + ); + let main_ab = root.join("main_ab.rss"); + write_source( + &main_ab, + "use self::a as a;\nuse self::b as b;\na::value();\nb::value();\n", + "main ab source", + ); + let main_ba = root.join("main_ba.rss"); + write_source( + &main_ba, + "use self::b as b;\nuse self::a as a;\nb::value();\na::value();\n", + "main ba source", + ); + + let run = |path: &Path| -> Vec { + let compiled = compile_source_file(path).expect("compile should succeed"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + vm.stack().to_vec() + }; + assert_eq!(run(&main_ab), vec![Value::Int(3), Value::Int(4)]); + assert_eq!( + run(&main_ba), + vec![Value::Int(4), Value::Int(3)], + "import order must not change which module each call resolves to" + ); + + remove_module_root(&root); +} + +#[test] +fn host_namespace_imports_stay_on_the_host_path_without_rewriting() { + // A virtual host namespace import must compile to host imports even + // though its single-segment form parses like a file-module candidate. + let root = temp_module_root("semantic_m6_host_path"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use myhost;\nmyhost::do_thing(81);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("host namespace import should compile"); + let host_names = compiled + .functions + .iter() + .map(|func| func.name.as_str()) + .collect::>(); + assert!( + host_names.contains(&"myhost::do_thing"), + "host namespace call must remain a host import: {host_names:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn namespace_member_arity_mismatch_is_a_diagnostic() { + let root = temp_module_root("semantic_m6_arity_mismatch"); + write_source( + &root.join("util.rss"), + "pub fn add(x, y) { x + y; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util as u;\nu::add(1);\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("arity mismatch must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("function 'u::add' expects 2 arguments"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index b95d10fa..11328072 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -26,3 +26,23 @@ mod type_inference_tests; #[cfg(feature = "runtime")] #[path = "compiler/whitespace_resilience_tests.rs"] mod whitespace_resilience_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m12_tests.rs"] +mod semantic_module_m12_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m3_tests.rs"] +mod semantic_module_m3_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m4_tests.rs"] +mod semantic_module_m4_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m5_tests.rs"] +mod semantic_module_m5_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m6_tests.rs"] +mod semantic_module_m6_tests;