Iter 5b: Cross-Module-Typcheck mit qualifizierten Verweisen
Neue API check_workspace(&Workspace) -> Vec<Diagnostic>; check_module hebt das Modul intern in einen Trivial-Workspace. Term::Var mit genau einem Punkt im Namen ist ein qualifizierter Verweis <prefix>.<def>, aufgelöst über Import-Map (alias-oder-modulname → echter Modulname). Drei neue Diagnostic-Codes: unknown-module, unknown-import, invalid-def-name. ail check lädt jetzt immer via load_workspace; Loader-Fehler werden im JSON-Modus zu strukturierten Diagnostics (module-not-found, module-cycle, …). DESIGN.md und JOURNAL.md dokumentieren die Konvention. Hash-Stabilität: alle ir_snapshot_*- Tests bitidentisch grün, kein neuer AST-Knoten.
This commit is contained in:
+115
-22
@@ -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<ailang_check::Diagnostic> {
|
||||
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<String> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
match def {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<prefix>"}` (Iter 5b)
|
||||
//! - `unknown-import` — `ctx`: `{"module": "<m>", "name": "<def>"}` (Iter 5b)
|
||||
//! - `invalid-def-name` — `ctx`: `{"name": "<n>", "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;
|
||||
|
||||
|
||||
+241
-42
@@ -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<T> = std::result::Result<T, CheckError>;
|
||||
@@ -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<Diagnostic> {
|
||||
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
|
||||
/// `<prefix>.<def>` interpretiert; `<prefix>` 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<Diagnostic> {
|
||||
// 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<CheckedModule> {
|
||||
// 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<BTreeMap<String, IndexMap<String, Type>>> {
|
||||
let mut out: BTreeMap<String, IndexMap<String, Type>> = 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<String, IndexMap<String, Type>>,
|
||||
) -> 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<CheckedModule> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<String, String> = 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 `<self>.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<String, TypeDef>,
|
||||
/// Inverser Index: ctor-name -> Verweis auf die zugehörige ADT.
|
||||
pub ctor_index: IndexMap<String, CtorRef>,
|
||||
/// Import-Map: alias-oder-modulname → tatsächlicher Modulname.
|
||||
/// Genutzt, wenn `Term::Var { name }` einen Punkt enthält
|
||||
/// (qualifizierter Cross-Module-Verweis).
|
||||
pub imports: BTreeMap<String, String>,
|
||||
/// Pro Modul des Workspaces dessen Top-Level-Symboltabelle.
|
||||
/// `check_in_workspace` befüllt das aus `build_module_globals`.
|
||||
pub module_globals: BTreeMap<String, IndexMap<String, Type>>,
|
||||
/// 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)]
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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: `<prefix>.<def>`.
|
||||
|
||||
- `<prefix>` ist ein Import-Alias (`import { module: "X", as: "<prefix>" }`)
|
||||
oder, falls ohne Alias importiert, der Modulname selbst.
|
||||
- `<def>` 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
|
||||
|
||||
@@ -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<Diagnostic>` 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>.<def>`. 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 <entry>` 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.
|
||||
|
||||
@@ -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 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user