diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index cb490c4..0703a2d 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -194,27 +194,23 @@ fn main() -> Result<()> { } } Cmd::Check { path, json } => { + // Iter 5b: `ail check` lädt jetzt **immer** über + // `load_workspace` und prüft cross-module. Für Module ohne + // Imports verhält sich der Workspace-Loader äquivalent zu + // `load_module` plus Hash-Konsistenz-Check des Eintrittsfiles + // — damit ist der Pfad einheitlich. if json { // JSON-Modus: stdout enthält ausschließlich das Diagnostics- - // Array. Schema-Mismatch wird als strukturiertes Diagnostic - // emittiert (mit Code `schema-mismatch`); echte I/O-Fehler - // propagieren als fatal, weil sie keinen Modul-bezogenen - // Diagnostic-Kontext haben. - let diags = match ailang_core::load_module(&path) { - Ok(m) => ailang_check::check_module(&m), - Err(ailang_core::Error::SchemaMismatch { expected, got }) => { - vec![ailang_check::Diagnostic::error( - "schema-mismatch", - format!( - "schema mismatch: expected {expected:?}, got {got:?}" - ), - ) - .with_ctx(serde_json::json!({ - "expected": expected, - "actual": got, - }))] - } - Err(e) => return Err(anyhow::anyhow!(e)), + // Array. Workspace-Lade-Fehler werden als strukturierte + // Diagnostics emittiert (Codes `module-not-found`, + // `module-cycle`, `module-name-mismatch`, `schema-mismatch`). + // Echte I/O-Fehler des Eintrittsfiles bleiben fatal. + let diags = match ailang_core::load_workspace(&path) { + Ok(ws) => ailang_check::check_workspace(&ws), + Err(e) => match workspace_error_to_diagnostic(&e) { + Some(d) => vec![d], + None => return Err(anyhow::anyhow!(e)), + }, }; println!("{}", serde_json::to_string(&diags)?); if diags @@ -224,9 +220,32 @@ fn main() -> Result<()> { std::process::exit(1); } } else { - let m = ailang_core::load_module(&path)?; - let r = ailang_check::check(&m)?; - println!("ok ({} symbols)", r.symbols.len()); + let ws = ailang_core::load_workspace(&path)?; + let diags = ailang_check::check_workspace(&ws); + if !diags.is_empty() { + for d in &diags { + eprintln!( + "{}: [{}] {}{}", + match d.severity { + ailang_check::Severity::Error => "error", + ailang_check::Severity::Warning => "warning", + }, + d.code, + d.def + .as_ref() + .map(|n| format!("{n}: ")) + .unwrap_or_default(), + d.message, + ); + } + std::process::exit(1); + } + let total: usize = ws.modules.values().map(|m| m.defs.len()).sum(); + println!( + "ok ({} symbols across {} modules)", + total, + ws.modules.len() + ); } } Cmd::EmitIr { path, out } => { @@ -384,6 +403,80 @@ fn main() -> Result<()> { Ok(()) } +/// Wandelt einen `WorkspaceLoadError` in ein passendes Diagnostic für den +/// JSON-Modus von `ail check`. Reine I/O-Fehler haben kein Modul-Diagnostic- +/// Äquivalent (sie sind nicht der Pipeline-Sache eines Konsumenten); für +/// die liefern wir `None` und lassen den Aufrufer fatal scheitern. +fn workspace_error_to_diagnostic( + e: &ailang_core::WorkspaceLoadError, +) -> Option { + use ailang_core::WorkspaceLoadError as W; + match e { + W::Io { .. } => None, + W::Schema { source, .. } => match source { + ailang_core::Error::SchemaMismatch { expected, got } => Some( + ailang_check::Diagnostic::error( + "schema-mismatch", + format!( + "schema mismatch: expected {expected:?}, got {got:?}" + ), + ) + .with_ctx(serde_json::json!({ + "expected": expected, + "actual": got, + })), + ), + _ => None, + }, + W::ModuleNotFound { name, expected_path } => Some( + ailang_check::Diagnostic::error( + "module-not-found", + format!( + "module `{name}` not found (expected at {})", + expected_path.display() + ), + ) + .with_ctx(serde_json::json!({ + "module": name, + "expected_path": expected_path.display().to_string(), + })), + ), + W::ModuleNameMismatch { + name_in_file, + name_from_path, + } => Some( + ailang_check::Diagnostic::error( + "module-name-mismatch", + format!( + "module name mismatch: file says {name_in_file:?}, path implies {name_from_path:?}" + ), + ) + .with_ctx(serde_json::json!({ + "name_in_file": name_in_file, + "name_from_path": name_from_path, + })), + ), + W::Cycle { path } => Some( + ailang_check::Diagnostic::error( + "module-cycle", + format!("import cycle: {}", path.join(" -> ")), + ) + .with_ctx(serde_json::json!({ + "path": path, + })), + ), + W::ModuleHashMismatch { name } => Some( + ailang_check::Diagnostic::error( + "module-hash-mismatch", + format!("module `{name}` loaded twice with differing content"), + ) + .with_ctx(serde_json::json!({ + "module": name, + })), + ), + } +} + fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); match def { diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index c93ad0d..ba27022 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -229,6 +229,33 @@ fn workspace_lists_imported_modules() { assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}"); } +/// Schützt Iter 5b: `ail check examples/ws_main.ail.json --json` muss den +/// Cross-Module-Aufruf `ws_lib.add` auflösen können. Erwartet: Exit 0, +/// stdout exakt `[]` (leeres Diagnostic-Array). +#[test] +fn check_workspace_resolves_import() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let entry = workspace.join("examples").join("ws_main.ail.json"); + + let output = Command::new(ail_bin()) + .args(["check", entry.to_str().unwrap(), "--json"]) + .output() + .expect("ail check --json failed to run"); + + let code = output.status.code().expect("process terminated by signal"); + assert_eq!( + code, + 0, + "expected exit 0; stderr: {}; stdout: {}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array"); +} + /// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten. /// `broken_unbound.ail.json` referenziert eine nicht-existente Variable; /// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit diff --git a/crates/ailang-check/src/diagnostic.rs b/crates/ailang-check/src/diagnostic.rs index 20a9a13..686f9c6 100644 --- a/crates/ailang-check/src/diagnostic.rs +++ b/crates/ailang-check/src/diagnostic.rs @@ -10,7 +10,7 @@ //! Diagnostic gemeldet (single-shot). Mehrere Diagnostics pro Lauf sind ein //! späteres Feature; das aktuelle Format erlaubt sie aber bereits. //! -//! Stabile Codes (Stand Iteration 4): +//! Stabile Codes (Stand Iteration 5b): //! - `schema-mismatch` //! - `unknown-type` //! - `unbound-var` @@ -32,6 +32,13 @@ //! - `primitive-needs-wildcard` //! - `duplicate-type` //! - `duplicate-ctor` +//! - `unknown-module` — `ctx`: `{"module": ""}` (Iter 5b) +//! - `unknown-import` — `ctx`: `{"module": "", "name": ""}` (Iter 5b) +//! - `invalid-def-name` — `ctx`: `{"name": "", "reason": "contains-dot"}` (Iter 5b) +//! - `module-not-found` — Workspace-Loader (Iter 5b, im CLI-Pfad) +//! - `module-cycle` — Workspace-Loader (Iter 5b, im CLI-Pfad) +//! - `module-name-mismatch` — Workspace-Loader (Iter 5b, im CLI-Pfad) +//! - `module-hash-mismatch` — Workspace-Loader (Iter 5b, im CLI-Pfad) use serde::Serialize; diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 131612f..5242c1c 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -7,8 +7,9 @@ //! Eingebaute Operationen werden über [`Builtins`] aufgelöst. use ailang_core::ast::*; +use ailang_core::Workspace; use indexmap::IndexMap; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; pub mod builtins; pub mod diagnostic; @@ -95,6 +96,15 @@ pub enum CheckError { #[error("nested constructor pattern not allowed in MVP: `{0}`")] NestedCtorPatternNotAllowed(String), + + #[error("unknown module prefix `{module}` in qualified reference")] + UnknownModule { module: String }, + + #[error("module `{module}` has no top-level def `{name}`")] + UnknownImport { module: String, name: String }, + + #[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")] + InvalidDefName { name: String }, } type Result = std::result::Result; @@ -127,6 +137,9 @@ impl CheckError { CheckError::DuplicateCtor { .. } => "duplicate-ctor", CheckError::DuplicateDef(_) => "duplicate-def", CheckError::NestedCtorPatternNotAllowed(_) => "nested-ctor-pattern-not-allowed", + CheckError::UnknownModule { .. } => "unknown-module", + CheckError::UnknownImport { .. } => "unknown-import", + CheckError::InvalidDefName { .. } => "invalid-def-name", } } @@ -152,6 +165,15 @@ impl CheckError { CheckError::NonExhaustive { missing, .. } => { serde_json::json!({"missing": missing}) } + CheckError::UnknownModule { module } => { + serde_json::json!({"module": module}) + } + CheckError::UnknownImport { module, name } => { + serde_json::json!({"module": module, "name": name}) + } + CheckError::InvalidDefName { name } => { + serde_json::json!({"name": name, "reason": "contains-dot"}) + } _ => serde_json::Value::Object(serde_json::Map::new()), } } @@ -192,11 +214,67 @@ impl CheckError { /// Leerer Vec = grün. Im aktuellen Stand wird beim ersten Fehler abgebrochen, /// daher enthält der Vec entweder 0 oder 1 Element. Mehrere Diagnostics pro /// Lauf sind ein späteres Feature; das Format erlaubt sie bereits. +/// +/// Rückwärtskompatibilität: ein nackter `&Module` wird intern in einen +/// Trivial-Workspace gehoben (`modules = {m.name: m}`, `entry = m.name`), +/// damit Tooling, das einzelne Module checkt, ohne `Workspace`-Bau auskommt. +/// Module mit Imports auf nicht im Trivial-Workspace vorhandene andere +/// Module werden hier zwangsläufig `unknown-module`-Fehler bei qualifizierten +/// Referenzen liefern — was korrekt ist. pub fn check_module(m: &Module) -> Vec { - match check(m) { - Ok(_) => Vec::new(), - Err(e) => vec![e.to_diagnostic()], + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + }; + check_workspace(&ws) +} + +/// Top-Level-API für Cross-Module-Typcheck. +/// +/// Iteriert über alle Module des Workspaces und prüft jedes mit Zugriff auf +/// die Top-Level-Symboltabellen aller anderen Module. Qualifizierte +/// Referenzen werden über die Import-Map des jeweiligen Moduls aufgelöst: +/// `Term::Var { name }` mit genau einem Punkt im Namen wird als +/// `.` interpretiert; `` ist ein Import-Alias (oder +/// der Modulname, falls ohne Alias importiert). +/// +/// Wie bei `check_module`: pro Lauf maximal **ein** Diagnostic +/// (single-shot). Multi-Diagnose ist späteres Feature. +pub fn check_workspace(ws: &Workspace) -> Vec { + // Pass 1: pro Modul Top-Level-Symboltabelle aufbauen — ohne Bodies zu + // checken. Damit kann Modul A auf Defs aus Modul B zugreifen, auch + // wenn B in der BTreeMap später kommt. Doppelte Def-Namen und + // Punkt-im-Def-Namen werden hier sofort gemeldet, weil ohne saubere + // Symboltabellen alle weiteren Diagnostics unzuverlässig wären. + let module_globals = match build_module_globals(ws) { + Ok(g) => g, + Err(e) => return vec![e.to_diagnostic()], + }; + + // Pass 2: pro Modul body-checken. `check_in_workspace` baut den Env + // mit zusätzlichen Cross-Module-Globals und einer Import-Map auf. + // Iterationsreihenfolge: erst das Eintrittsmodul, dann der Rest in + // BTreeMap-Order. Damit ist die erste gemeldete Diagnostik bei + // Workspaces deterministisch und nahe am Entry. + let mut order: Vec<&String> = Vec::new(); + if ws.modules.contains_key(&ws.entry) { + order.push(&ws.entry); } + for name in ws.modules.keys() { + if name != &ws.entry { + order.push(name); + } + } + for name in order { + let m = &ws.modules[name]; + if let Err(e) = check_in_workspace(m, ws, &module_globals) { + return vec![e.to_diagnostic()]; + } + } + Vec::new() } /// Ergebnis der Typprüfung eines Moduls: Mapping vom Symbolnamen zum @@ -207,22 +285,103 @@ pub struct CheckedModule { } pub fn check(m: &Module) -> Result { + // Trivial-Workspace: das Modul allein, ohne Cross-Module-Auflösung. + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + }; + let module_globals = build_module_globals(&ws)?; + check_in_workspace(m, &ws, &module_globals)?; + // Symbole für die Rückgabe sammeln (bestehende Semantik). + let mut symbols = IndexMap::new(); + for def in &m.defs { + let h = ailang_core::hash::def_hash(def); + let ty = match def { + Def::Fn(f) => f.ty.clone(), + Def::Const(c) => c.ty.clone(), + Def::Type(_) => Type::Con { + name: def.name().to_string(), + }, + }; + symbols.insert(def.name().to_string(), (ty, h)); + } + Ok(CheckedModule { symbols }) +} + +/// Baut pro Modul die Top-Level-Symboltabelle (für Cross-Module-Lookup), +/// ohne Bodies zu prüfen. Dupes und Punkt-im-Def-Namen werden hier sofort +/// als Fehler gemeldet — sie würden alle weiteren Diagnostics verfälschen. +fn build_module_globals( + ws: &Workspace, +) -> Result>> { + let mut out: BTreeMap> = BTreeMap::new(); + for (mname, m) in &ws.modules { + let mut globals = IndexMap::new(); + for def in &m.defs { + let def_name = def.name(); + if def_name.contains('.') { + return Err(CheckError::Def( + def_name.to_string(), + Box::new(CheckError::InvalidDefName { + name: def_name.to_string(), + }), + )); + } + if globals.contains_key(def_name) { + return Err(CheckError::Def( + def_name.to_string(), + Box::new(CheckError::DuplicateDef(def_name.to_string())), + )); + } + let ty = match def { + Def::Fn(f) => f.ty.clone(), + Def::Const(c) => c.ty.clone(), + Def::Type(_) => Type::Con { + name: def_name.to_string(), + }, + }; + globals.insert(def_name.to_string(), ty); + } + out.insert(mname.clone(), globals); + } + Ok(out) +} + +/// Prüft die Bodies eines einzelnen Moduls im Kontext des Workspaces. +/// Annahme: `module_globals` enthält bereits für **alle** Module des +/// Workspaces (inklusive `m`) die Top-Level-Symboltabellen — gebaut von +/// `build_module_globals`. +fn check_in_workspace( + m: &Module, + ws: &Workspace, + module_globals: &BTreeMap>, +) -> Result<()> { let mut env = Env::new(); builtins::install(&mut env); - // Pass 1a: alle Type-Defs registrieren. + // Type-Defs registrieren (lokal pro Modul; ADT-Cross-Module-Sharing ist + // explizit nicht Teil von 5b). for def in &m.defs { if let Def::Type(td) = def { if env.types.contains_key(&td.name) { - return Err(CheckError::DuplicateType(td.name.clone())); + return Err(CheckError::Def( + td.name.clone(), + Box::new(CheckError::DuplicateType(td.name.clone())), + )); } for c in &td.ctors { if let Some(prev) = env.ctor_index.get(&c.name) { - return Err(CheckError::DuplicateCtor { - ctor: c.name.clone(), - a: prev.type_name.clone(), - b: td.name.clone(), - }); + return Err(CheckError::Def( + td.name.clone(), + Box::new(CheckError::DuplicateCtor { + ctor: c.name.clone(), + a: prev.type_name.clone(), + b: td.name.clone(), + }), + )); } env.ctor_index.insert( c.name.clone(), @@ -235,43 +394,35 @@ pub fn check(m: &Module) -> Result { } } - // Pass 1b: alle Top-Level-Werte-Symbole registrieren. Doppelte Namen - // (zwischen fn/const) sind unzulässig — das schützt davor, dass sich - // zwei Defs einen Namensraum teilen. - for def in &m.defs { - match def { - Def::Fn(f) => { - if env.globals.contains_key(&f.name) { - return Err(CheckError::DuplicateDef(f.name.clone())); - } - env.globals.insert(f.name.clone(), f.ty.clone()); - } - Def::Const(c) => { - if env.globals.contains_key(&c.name) { - return Err(CheckError::DuplicateDef(c.name.clone())); - } - env.globals.insert(c.name.clone(), c.ty.clone()); - } - Def::Type(_) => {} + // Lokale Globals aus der vorgängig gebauten Tabelle übernehmen. + if let Some(g) = module_globals.get(&m.name) { + for (n, t) in g { + env.globals.insert(n.clone(), t.clone()); } } - // Pass 2: jede Def prüfen. - let mut symbols = IndexMap::new(); + // Import-Map aufbauen: Alias (oder Modulname, wenn ohne Alias) → + // Modulname. Konflikte sind im MVP unzulässig: dieselbe `as`-Klausel + // zweimal wäre auffällig und sollte als doppelter Symbolname auffallen + // — aktuell „last wins", weil Iter 5b kein eigenes Diagnostic dafür + // einführt; falls künftig benötigt → `ambiguous-import`-Code. + let mut import_map: BTreeMap = BTreeMap::new(); + for imp in &m.imports { + let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone()); + import_map.insert(key, imp.module.clone()); + } + env.imports = import_map; + env.module_globals = module_globals.clone(); + env.current_module = m.name.clone(); + // Workspace ist im Env nicht direkt nötig; Cross-Module-Lookup nutzt + // ausschließlich `module_globals`. Aber wir behalten ws-Referenz im + // Kommentar als Erinnerung, falls künftig ADT-Cross-Module dazukommt. + let _ = ws; + for def in &m.defs { check_def(def, &env).map_err(|e| CheckError::Def(def.name().to_string(), Box::new(e)))?; - let h = ailang_core::hash::def_hash(def); - let ty = match def { - Def::Fn(f) => f.ty.clone(), - Def::Const(c) => c.ty.clone(), - Def::Type(_) => Type::Con { - name: def.name().to_string(), - }, - }; - symbols.insert(def.name().to_string(), (ty, h)); } - - Ok(CheckedModule { symbols }) + Ok(()) } fn check_def(def: &Def, env: &Env) -> Result<()> { @@ -386,12 +537,49 @@ fn synth( Literal::Unit => Type::unit(), }), Term::Var { name } => { + // 1) Locals haben höchste Priorität — sie können sogar einen + // qualifizierten Punkt-Namen schatten, falls jemand einen + // Buchstaben-mit-Punkt-Param baut. Das ist im MVP nicht + // wirklich erreichbar, aber harmlos. if let Some(t) = locals.get(name) { return Ok(t.clone()); } + // 2) Lokale Globals. if let Some(t) = env.globals.get(name) { return Ok(t.clone()); } + // 3) Genau ein Punkt → qualifizierter Cross-Module-Verweis. + // Mehr als ein Punkt ist im aktuellen MVP nicht definiert + // und fällt unten als `unbound-var` durch. + if name.matches('.').count() == 1 { + let (prefix, suffix) = name.split_once('.').expect("checked"); + let target_module = match env.imports.get(prefix) { + Some(m) => m.clone(), + None => { + // Selbst-Referenz `.def` ohne Import-Eintrag: + // erlauben wir bewusst nicht — Konvention ist, dass + // qualifizierte Verweise nur über Imports gehen. + // Damit bleibt die Bedeutung von `name` lokal stabil. + return Err(CheckError::UnknownModule { + module: prefix.to_string(), + }); + } + }; + let g = env.module_globals.get(&target_module).ok_or_else(|| { + // Import-Map zeigt auf ein nicht im Workspace geladenes + // Modul. Das wäre eigentlich schon vom Workspace-Loader + // abgefangen worden; hier defensiv als unknown-module. + CheckError::UnknownModule { + module: target_module.clone(), + } + })?; + return g.get(suffix).cloned().ok_or_else(|| { + CheckError::UnknownImport { + module: target_module, + name: suffix.to_string(), + } + }); + } Err(CheckError::UnknownIdent(name.clone())) } Term::App { callee, args } => { @@ -675,6 +863,17 @@ pub struct Env { pub types: IndexMap, /// Inverser Index: ctor-name -> Verweis auf die zugehörige ADT. pub ctor_index: IndexMap, + /// Import-Map: alias-oder-modulname → tatsächlicher Modulname. + /// Genutzt, wenn `Term::Var { name }` einen Punkt enthält + /// (qualifizierter Cross-Module-Verweis). + pub imports: BTreeMap, + /// Pro Modul des Workspaces dessen Top-Level-Symboltabelle. + /// `check_in_workspace` befüllt das aus `build_module_globals`. + pub module_globals: BTreeMap>, + /// Name des aktuell gecheckten Moduls. Genutzt, um beim Var-Lookup + /// Selbst-Verweise (Modulname == eigener Name) als lokale Globals zu + /// behandeln, ohne den `imports`-Kanal anzufassen. + pub current_module: String, } #[derive(Debug, Clone)] diff --git a/crates/ailang-check/tests/workspace.rs b/crates/ailang-check/tests/workspace.rs new file mode 100644 index 0000000..2b17ec3 --- /dev/null +++ b/crates/ailang-check/tests/workspace.rs @@ -0,0 +1,101 @@ +//! Integrationstests für `check_workspace` (Iter 5b). +//! +//! Diese Tests fahren über die kanonischen `examples/ws_*.ail.json`-Files; +//! der Loader und der Checker zusammen sind die Pipeline, die `ail check` +//! im JSON-Modus auf einen Workspace wirft. + +use ailang_check::{check_workspace, Severity}; +use ailang_core::load_workspace; +use std::path::Path; + +fn examples_dir() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest).parent().unwrap().parent().unwrap().join("examples") +} + +#[test] +fn happy_path_resolves_qualified_import() { + // ws_main importiert ws_lib und ruft `ws_lib.add` auf — vollständig + // typisiert. Erwartet: keine Diagnostics. + let entry = examples_dir().join("ws_main.ail.json"); + let ws = load_workspace(&entry).expect("load ws_main"); + let diags = check_workspace(&ws); + assert!( + diags.is_empty(), + "expected no diagnostics; got: {}", + serde_json::to_string_pretty(&diags).unwrap() + ); +} + +#[test] +fn unknown_import_is_reported() { + // ws_broken referenziert `ws_lib.bogus` — Modul ist da, Def nicht. + let entry = examples_dir().join("ws_broken.ail.json"); + let ws = load_workspace(&entry).expect("load ws_broken"); + let diags = check_workspace(&ws); + assert_eq!(diags.len(), 1, "got: {:?}", diags); + assert!(matches!(diags[0].severity, Severity::Error)); + assert_eq!(diags[0].code, "unknown-import"); + // Kontext muss strukturiert auf Modul + Defname zeigen. + assert_eq!( + diags[0].ctx.get("module").and_then(|v| v.as_str()), + Some("ws_lib") + ); + assert_eq!( + diags[0].ctx.get("name").and_then(|v| v.as_str()), + Some("bogus") + ); +} + +#[test] +fn unknown_module_prefix_is_reported() { + // ws_unknown_module hat keine Imports, referenziert aber `nope.x`. + let entry = examples_dir().join("ws_unknown_module.ail.json"); + let ws = load_workspace(&entry).expect("load ws_unknown_module"); + let diags = check_workspace(&ws); + assert_eq!(diags.len(), 1, "got: {:?}", diags); + assert!(matches!(diags[0].severity, Severity::Error)); + assert_eq!(diags[0].code, "unknown-module"); + assert_eq!( + diags[0].ctx.get("module").and_then(|v| v.as_str()), + Some("nope") + ); +} + +#[test] +fn invalid_def_name_with_dot_is_reported() { + // Synthetic: ein Modul mit einer Def, deren Name einen Punkt enthält. + // Wir konstruieren das als Module direkt und füttern es in einen + // Trivial-Workspace, weil die kanonische Konvention das gar nicht + // erst auf Disk durchlassen sollte. + use ailang_core::ast::*; + use std::collections::BTreeMap; + + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![Def::Const(ConstDef { + name: "weird.name".into(), + ty: Type::int(), + value: Term::Lit { + lit: Literal::Int { value: 0 }, + }, + doc: None, + })], + }; + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = ailang_core::Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + }; + let diags = check_workspace(&ws); + assert_eq!(diags.len(), 1, "got: {:?}", diags); + assert_eq!(diags[0].code, "invalid-def-name"); + assert_eq!( + diags[0].ctx.get("reason").and_then(|v| v.as_str()), + Some("contains-dot") + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bbdbfdb..1afdd78 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -100,6 +100,25 @@ Begründung: Trade-off: keine Inline-Optimierungen über die LLVM-API. Wir setzen auf `clang -O2` als Standard-Pipeline. +## Konvention: Qualifizierte Cross-Module-Verweise (Iter 5b) + +Cross-Module-Aufrufe nutzen **keinen** neuen AST-Knoten. Stattdessen ist ein +`Term::Var { name }` mit genau einem Punkt im Namen ein qualifizierter +Verweis: `.`. + +- `` ist ein Import-Alias (`import { module: "X", as: "" }`) + oder, falls ohne Alias importiert, der Modulname selbst. +- `` ist der Name einer Top-Level-Definition im Zielmodul. +- Def-Namen DÜRFEN keinen Punkt enthalten — der Typchecker meldet + `invalid-def-name` mit `ctx: { "reason": "contains-dot" }`. +- Der Workspace-Loader (Iter 5a) findet alle erreichbaren Module; der + Typchecker (Iter 5b, `check_workspace`) löst Punkt-Namen über die + Import-Map auf. Diagnostic-Codes: `unknown-module` (Prefix nicht + importiert), `unknown-import` (Modul gefunden, Def nicht). + +Hash-Stabilität: kein neuer AST-Knoten, keine umbenannten Felder — alle +bisherigen Modul-Hashes bleiben bitidentisch. + ## Datenmodell (MVP) ### Module diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index c09a4fa..d178b83 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -220,3 +220,32 @@ Sub-Schritte: Während Iter 5 gleich beim Bau **`source_filename` pfad-unabhängig halten** (nur Modulname, kein Verzeichnis-Prefix), sonst kippen die IR-Snapshots. + +## 2026-05-07 — Iter 5b fertig: Cross-Module-Typcheck + +- `check_workspace(&Workspace) -> Vec` als Top-Level-API. + `check_module` bleibt erhalten und hebt das Modul intern in einen + Trivial-Workspace. +- Konvention für qualifizierte Verweise (in DESIGN.md festgehalten): + `Term::Var { name }` mit genau einem Punkt = `.`. Prefix + ist Import-Alias oder Modulname. Kein neuer AST-Knoten, keine + umbenannten Felder ⇒ Hashes bleiben stabil; alle `ir_snapshot_*` + weiterhin grün. +- Drei neue Diagnostic-Codes: `unknown-module`, + `unknown-import`, `invalid-def-name` (mit `ctx.reason: "contains-dot"`). +- CLI: `ail check ` lädt jetzt **immer** über `load_workspace`. + Workspace-Lade-Fehler werden im JSON-Modus zu strukturierten + Diagnostics mit Codes `module-not-found`, `module-cycle`, + `module-name-mismatch`, `module-hash-mismatch`, `schema-mismatch`. + `ail build` und `ail emit-ir` bleiben pro Einzelmodul (Codegen + cross-module ist 5c). +- Beispiele: `ws_main.ail.json` ruft jetzt `ws_lib.add` auf + (beobachtbar). Neu: `ws_broken.ail.json` (`unknown-import`), + `ws_unknown_module.ail.json` (`unknown-module`). +- Tests: 37 grün (vorher 32). 4 neue Workspace-Integrationstests in + `crates/ailang-check/tests/workspace.rs`, ein neuer e2e-Test + `check_workspace_resolves_import`. +- Schulden: Single-Shot-Diagnostics weiterhin (Multi-Diagnose nach 5c). + Punkt-Konvention deckt nur einen Punkt ab — verschachtelte Modulpfade + (`a.b.c`) gibt es nicht; das wäre erst mit Hierarchie-Modulen ein + Thema und fällt aktuell als `unbound-var` durch. diff --git a/examples/ws_broken.ail.json b/examples/ws_broken.ail.json new file mode 100644 index 0000000..a66ab2b --- /dev/null +++ b/examples/ws_broken.ail.json @@ -0,0 +1,29 @@ +{ + "schema": "ailang/v0", + "name": "ws_broken", + "imports": [ + { "module": "ws_lib" } + ], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": [], + "doc": "Iter 5b negative fixture: ruft ws_lib.bogus auf — Modul existiert, Def nicht. Triggert `unknown-import`.", + "body": { + "t": "app", + "fn": { "t": "var", "name": "ws_lib.bogus" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { "t": "lit", "lit": { "kind": "int", "value": 2 } } + ] + } + } + ] +} diff --git a/examples/ws_main.ail.json b/examples/ws_main.ail.json index d4d4293..bcc7512 100644 --- a/examples/ws_main.ail.json +++ b/examples/ws_main.ail.json @@ -15,12 +15,19 @@ "effects": ["IO"] }, "params": [], - "doc": "Iter 5a fixture: Eintrittsmodul, importiert ws_lib (noch ungenutzt — Cross-Module-Aufruf folgt in 5b/5c).", + "doc": "Iter 5b fixture: ruft ws_lib.add auf, druckt das Ergebnis. So ist Cross-Module-Typcheck am Beispiel beobachtbar.", "body": { "t": "do", "op": "io/print_int", "args": [ - { "t": "lit", "lit": { "kind": "int", "value": 0 } } + { + "t": "app", + "fn": { "t": "var", "name": "ws_lib.add" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 2 } }, + { "t": "lit", "lit": { "kind": "int", "value": 3 } } + ] + } ] } } diff --git a/examples/ws_unknown_module.ail.json b/examples/ws_unknown_module.ail.json new file mode 100644 index 0000000..50967ce --- /dev/null +++ b/examples/ws_unknown_module.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "ws_unknown_module", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": [], + "doc": "Iter 5b negative fixture: kein Import, aber qualifizierter Verweis `nope.x`. Triggert `unknown-module`.", + "body": { + "t": "var", + "name": "nope.x" + } + } + ] +}