From 55b6186ca5e7bb8ba88c4b9ebdaf31a700dfa14d Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 7 Jul 2026 00:04:21 -0500 Subject: [PATCH 1/2] Implement [use ...] module imports on the EIR backend The EIR lowering inlined imported modules but three gaps broke real multi-file programs on the default backend: - Non-pub fns were never qualified: mirror the interpreter's export rule (all top-level fns when a module declares no pub). - Alias syntax checked for a symbol `as`; the real syntax is the keyword `:as`. Default prefix now uses the full dotted module path. - Checker E05xx module diagnostics (unresolved module, circular dependency) were discarded; all four EIR entry points now fail fast with a new VmErrorKind::ModuleError instead of a confusing NotCallable at the first qualified call site. Adds integration tests: non-pub use, :as alias, nested modules, missing module error, circular import error. Co-Authored-By: Claude Fable 5 --- crates/loon-lang/src/eir/lower.rs | 44 +++++++----- crates/loon-lang/src/eir/vm.rs | 116 ++++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 23 deletions(-) diff --git a/crates/loon-lang/src/eir/lower.rs b/crates/loon-lang/src/eir/lower.rs index 99d65ee..d72b9bf 100644 --- a/crates/loon-lang/src/eir/lower.rs +++ b/crates/loon-lang/src/eir/lower.rs @@ -346,21 +346,16 @@ impl<'a> Lower<'a> { let Some(modpath) = items[1].as_dotted_path() else { continue; }; - // Alias: `[use a/b as c]` → c, else the last path segment. - let alias = if items.len() >= 4 { - if let (ExprKind::Symbol(kw), ExprKind::Symbol(a)) = - (&items[2].kind, &items[3].kind) - { - if kw == "as" { - a.clone() - } else { - modpath.rsplit('/').next().unwrap_or(&modpath).to_string() - } - } else { - modpath.rsplit('/').next().unwrap_or(&modpath).to_string() + // Alias: `[use a.b :as c]` → c, else the full dotted path (the + // interpreter qualifies as `a.b.name`, so we match that). + let alias = if items.len() >= 3 { + match (&items[2].kind, items.get(3).map(|e| &e.kind)) { + (ExprKind::Keyword(kw), Some(ExprKind::Symbol(a))) if kw == "as" => a.clone(), + (ExprKind::Symbol(kw), Some(ExprKind::Symbol(a))) if kw == "as" => a.clone(), + _ => modpath.clone(), } } else { - modpath.rsplit('/').next().unwrap_or(&modpath).to_string() + modpath.clone() }; let file = crate::module::ModuleCache::resolve_path(&modpath, base); @@ -384,29 +379,40 @@ impl<'a> Lower<'a> { let module_forms = sub.expanded_program.clone(); // Recurse first so transitive imports land before this module. self.collect_imports(&module_forms, &dir, visited, imported, qualified); + // Export rule matches the interpreter (module.rs): `pub fn` names + // if any are declared, otherwise every top-level fn. + let mut pub_fns: Vec = Vec::new(); + let mut all_fns: Vec = Vec::new(); for mf in &module_forms { if let ExprKind::List(mitems) = &mf.kind { match mitems.first().map(|e| &e.kind) { // Skip the module's own `use` lines (handled by recursion). Some(ExprKind::Symbol(s)) if s == "use" => continue, - _ => {} - } - // Record qualified names for `pub fn` exports. - if let Some(ExprKind::Symbol(s)) = mitems.first().map(|e| &e.kind) { - if s == "pub" && mitems.len() >= 3 { + Some(ExprKind::Symbol(s)) if s == "fn" && mitems.len() >= 3 => { + if let ExprKind::Symbol(name) = &mitems[1].kind { + all_fns.push(name.clone()); + } + } + Some(ExprKind::Symbol(s)) if s == "pub" && mitems.len() >= 4 => { if let (Some(ExprKind::Symbol(inner)), Some(ExprKind::Symbol(name))) = ( mitems.get(1).map(|e| &e.kind), mitems.get(2).map(|e| &e.kind), ) { if inner == "fn" { - qualified.push((alias.clone(), name.clone())); + pub_fns.push(name.clone()); + all_fns.push(name.clone()); } } } + _ => {} } } imported.push(mf.clone()); } + let exported = if pub_fns.is_empty() { all_fns } else { pub_fns }; + for name in exported { + qualified.push((alias.clone(), name)); + } } } diff --git a/crates/loon-lang/src/eir/vm.rs b/crates/loon-lang/src/eir/vm.rs index dab719d..f676b42 100644 --- a/crates/loon-lang/src/eir/vm.rs +++ b/crates/loon-lang/src/eir/vm.rs @@ -3035,6 +3035,9 @@ pub enum VmErrorKind { StackOverflow, /// `assert-eq` failed with mismatched values. AssertFailed(String, String), + /// A `[use ...]` failed at compile time: unresolved module, private + /// symbol, or circular dependency (checker E05xx diagnostics). + ModuleError(String), /// A replayed program requested a different effect op than the trace /// recorded (or ran past the end of the trace). ReplayDivergence(ReplayDivergence), @@ -3075,6 +3078,7 @@ impl VmErrorKind { VmErrorKind::Trap => "trap", VmErrorKind::StackOverflow => "stack-overflow", VmErrorKind::AssertFailed(..) => "assert-failed", + VmErrorKind::ModuleError(_) => "module-error", VmErrorKind::ReplayDivergence(_) => "replay-divergence", VmErrorKind::UnhandledEffect(_) => "unhandled-effect", VmErrorKind::DivideByZero(_) => "divide-by-zero", @@ -3108,6 +3112,7 @@ impl std::fmt::Display for VmError { VmErrorKind::AssertFailed(actual, expected) => { write!(f, "assertion failed: {actual} != {expected}") } + VmErrorKind::ModuleError(msg) => write!(f, "{msg}"), VmErrorKind::ReplayDivergence(d) => { write!(f, "replay diverged {}", d.message) } @@ -3128,6 +3133,22 @@ impl std::fmt::Display for VmError { } } +/// Surface the first module diagnostic (E05xx: unresolved module, private +/// symbol, circular dependency) from a check pass as a `VmError`. Other +/// checker diagnostics stay non-fatal on the EIR path for now, but a broken +/// `[use ...]` must not fall through to a confusing "value is not callable" +/// at the first qualified call site. +fn module_error(errors: &[crate::errors::LoonDiagnostic]) -> Option { + errors + .iter() + .find(|d| d.code.category() == "module") + .map(|d| VmError { + kind: VmErrorKind::ModuleError(d.what.clone()), + span: d.labels.first().map(|l| l.span), + context: None, + }) +} + // ─── Public API ──────────────────────────────────────────────────────────── /// Run a Loon program through the EIR pipeline: parse → check → lower → VM. @@ -3146,7 +3167,10 @@ fn eval_eir_impl(src: &str, mut checker: crate::check::Checker) -> Result Date: Tue, 7 Jul 2026 00:32:26 -0500 Subject: [PATCH 2/2] Clarify export-set naming and :as fallback per review Co-Authored-By: Claude Fable 5 --- crates/loon-lang/src/eir/lower.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/loon-lang/src/eir/lower.rs b/crates/loon-lang/src/eir/lower.rs index d72b9bf..1d0ebde 100644 --- a/crates/loon-lang/src/eir/lower.rs +++ b/crates/loon-lang/src/eir/lower.rs @@ -351,6 +351,10 @@ impl<'a> Lower<'a> { let alias = if items.len() >= 3 { match (&items[2].kind, items.get(3).map(|e| &e.kind)) { (ExprKind::Keyword(kw), Some(ExprKind::Symbol(a))) if kw == "as" => a.clone(), + // Lenient fallback: accept a bare-symbol `as` too. The + // canonical syntax is the keyword `:as` (what the + // interpreter matches); a bare `as` would otherwise be + // silently ignored here and fail later as NotCallable. (ExprKind::Symbol(kw), Some(ExprKind::Symbol(a))) if kw == "as" => a.clone(), _ => modpath.clone(), } @@ -380,9 +384,11 @@ impl<'a> Lower<'a> { // Recurse first so transitive imports land before this module. self.collect_imports(&module_forms, &dir, visited, imported, qualified); // Export rule matches the interpreter (module.rs): `pub fn` names - // if any are declared, otherwise every top-level fn. + // if any are declared, otherwise every top-level fn. `every_fn` + // intentionally includes pub fns too — it is the complete set, + // used only as the fallback when the module declares no `pub`. let mut pub_fns: Vec = Vec::new(); - let mut all_fns: Vec = Vec::new(); + let mut every_fn: Vec = Vec::new(); for mf in &module_forms { if let ExprKind::List(mitems) = &mf.kind { match mitems.first().map(|e| &e.kind) { @@ -390,7 +396,7 @@ impl<'a> Lower<'a> { Some(ExprKind::Symbol(s)) if s == "use" => continue, Some(ExprKind::Symbol(s)) if s == "fn" && mitems.len() >= 3 => { if let ExprKind::Symbol(name) = &mitems[1].kind { - all_fns.push(name.clone()); + every_fn.push(name.clone()); } } Some(ExprKind::Symbol(s)) if s == "pub" && mitems.len() >= 4 => { @@ -400,7 +406,7 @@ impl<'a> Lower<'a> { ) { if inner == "fn" { pub_fns.push(name.clone()); - all_fns.push(name.clone()); + every_fn.push(name.clone()); } } } @@ -409,7 +415,11 @@ impl<'a> Lower<'a> { } imported.push(mf.clone()); } - let exported = if pub_fns.is_empty() { all_fns } else { pub_fns }; + let exported = if pub_fns.is_empty() { + every_fn + } else { + pub_fns + }; for name in exported { qualified.push((alias.clone(), name)); }