diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 6f35ca6..567602c 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -22,6 +22,10 @@ enum Cmd { path: PathBuf, #[arg(long)] json: bool, + /// Lädt rekursiv alle Module des Workspace und listet ihre Defs + /// gemeinsam auf. Default-Modus bleibt Single-Modul. + #[arg(long)] + workspace: bool, }, /// Gibt das Modul in Textform aus (Pretty-Printer). Render { path: PathBuf }, @@ -31,15 +35,26 @@ enum Cmd { name: String, #[arg(long)] json: bool, + /// Lädt den Workspace und sucht in allen Modulen. + /// `name` darf in Punktnotation (`.`) angegeben werden; + /// ohne Punkt: erst Eintrittsmodul, dann Fallback alle Module + /// (Fehler `ambiguous-name`, falls mehrdeutig). + #[arg(long)] + workspace: bool, }, /// Listet, welche Symbole jede Definition aufruft (statisch). Deps { path: PathBuf, - /// Nur für ein Symbol; ohne Argument: für alle. + /// Nur für ein Symbol; ohne Argument: für alle. Im Workspace-Modus + /// darf der Name in Punktnotation (`.`) sein. #[arg(long)] of: Option, #[arg(long)] json: bool, + /// Workspace-Modus: Edges sind cross-module-fähig + /// (`. -> .`). + #[arg(long)] + workspace: bool, }, /// Typprüft ein Modul. Check { @@ -82,6 +97,13 @@ enum Cmd { b: PathBuf, #[arg(long)] json: bool, + /// Vergleicht zwei Workspaces (Eintrittsmodule + transitive Imports) + /// modulweise. `added_modules`/`removed_modules` für komplett + /// hinzugekommene oder entfernte Module, `changed_modules` für + /// Module mit unterschiedlichem Hash; pro changed-Modul die übliche + /// Single-Modul-Sub-Diff-Struktur. + #[arg(long)] + workspace: bool, }, /// Lädt einen Workspace (Eintrittsmodul + transitive Imports) und /// listet alle erreichbaren Module mit Hash und Def-Anzahl. @@ -98,99 +120,153 @@ enum Cmd { fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Manifest { path, json } => { - let m = ailang_core::load_module(&path)?; - if json { - let entries: Vec<_> = m - .defs - .iter() - .map(|d| { - let h = ailang_core::def_hash(d); - let (kind, ty, effects) = match d { - ailang_core::Def::Fn(f) => { - let effects = match &f.ty { - ailang_core::Type::Fn { effects, .. } => effects.clone(), - _ => vec![], - }; - ( - "fn", - ailang_core::pretty::type_to_string(&f.ty), - effects, - ) - } - ailang_core::Def::Const(c) => ( - "const", - ailang_core::pretty::type_to_string(&c.ty), - vec![], - ), - ailang_core::Def::Type(t) => { - let s = t - .ctors - .iter() - .map(|c| { - if c.fields.is_empty() { - c.name.clone() - } else { - format!( - "{}({})", - c.name, - c.fields - .iter() - .map(ailang_core::pretty::type_to_string) - .collect::>() - .join(", ") - ) - } - }) - .collect::>() - .join(" | "); - ("type", s, vec![]) - } - }; - serde_json::json!({ - "name": d.name(), - "kind": kind, - "type": ty, - "effects": effects, - "hash": h, - }) - }) - .collect(); - let out = serde_json::json!({ - "module": m.name, - "schema": m.schema, - "symbols": entries, + Cmd::Manifest { path, json, workspace } => { + if workspace { + // Workspace-Modus: alle Module laden und ihre Defs gemeinsam + // alphabetisch nach (modul, name) ausgeben. + let ws = ailang_core::load_workspace(&path)?; + let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new(); + for (mod_name, m) in &ws.modules { + for d in &m.defs { + entries.push((mod_name.clone(), d)); + } + } + entries.sort_by(|a, b| { + a.0.cmp(&b.0).then_with(|| a.1.name().cmp(b.1.name())) }); - println!("{}", serde_json::to_string_pretty(&out)?); + + if json { + let symbols: Vec<_> = entries + .iter() + .map(|(mod_name, d)| { + let (kind, ty, effects) = def_summary(d); + serde_json::json!({ + "module": mod_name, + "name": d.name(), + "kind": kind, + "type": ty, + "effects": effects, + "hash": ailang_core::def_hash(d), + }) + }) + .collect(); + let out = serde_json::json!({ + "workspace": ws.entry, + "schema": ailang_core::SCHEMA, + "symbols": symbols, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + // Text-Form: pro Eintrag `. :: ![effs] `. + let label_width = entries + .iter() + .map(|(m, d)| m.len() + 1 + d.name().len()) + .max() + .unwrap_or(0); + for (mod_name, d) in &entries { + let (_, ty, effects) = def_summary(d); + let label = format!("{mod_name}.{}", d.name()); + let eff = if effects.is_empty() { + String::new() + } else { + format!(" ![{}]", effects.join(",")) + }; + println!( + "{: = m + .defs + .iter() + .map(|d| { + let (kind, ty, effects) = def_summary(d); + serde_json::json!({ + "name": d.name(), + "kind": kind, + "type": ty, + "effects": effects, + "hash": ailang_core::def_hash(d), + }) + }) + .collect(); + let out = serde_json::json!({ + "module": m.name, + "schema": m.schema, + "symbols": entries, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + print!("{}", ailang_core::pretty::manifest(&m)); + } } } Cmd::Render { path } => { let m = ailang_core::load_module(&path)?; print!("{}", ailang_core::pretty::module(&m)); } - Cmd::Describe { path, name, json } => { - let m = ailang_core::load_module(&path)?; - let def = m - .defs - .iter() - .find(|d| d.name() == name) - .with_context(|| format!("no def `{name}` in module `{}`", m.name))?; - if json { - let s = serde_json::to_string_pretty(def)?; - println!("{s}"); + Cmd::Describe { path, name, json, workspace } => { + if workspace { + let ws = ailang_core::load_workspace(&path)?; + let (mod_name, def) = resolve_describe_name(&ws, &name)?; + if json { + // Wir reichen die Def selbst durch und ergänzen das + // Modul, damit Konsumenten den Kontext kennen. + let mut v = serde_json::to_value(def)?; + if let Some(obj) = v.as_object_mut() { + obj.insert( + "module".to_string(), + serde_json::Value::String(mod_name.clone()), + ); + obj.insert( + "hash".to_string(), + serde_json::Value::String(ailang_core::def_hash(def)), + ); + } + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + let m = ws.modules.get(&mod_name).unwrap(); + let one = ailang_core::Module { + schema: m.schema.clone(), + name: m.name.clone(), + imports: vec![], + defs: vec![def.clone()], + }; + let h = ailang_core::def_hash(def); + println!("module: {}", mod_name); + println!("hash: {h}"); + print!("{}", ailang_core::pretty::module(&one)); + } } else { - // Pretty-form: render module mit nur dieser Def. - let one = ailang_core::Module { - schema: m.schema.clone(), - name: m.name.clone(), - imports: vec![], - defs: vec![def.clone()], - }; - let h = ailang_core::def_hash(def); - println!("hash: {h}"); - print!("{}", ailang_core::pretty::module(&one)); + let m = ailang_core::load_module(&path)?; + let def = m + .defs + .iter() + .find(|d| d.name() == name) + .with_context(|| format!("no def `{name}` in module `{}`", m.name))?; + if json { + let s = serde_json::to_string_pretty(def)?; + println!("{s}"); + } else { + // Pretty-form: render module mit nur dieser Def. + let one = ailang_core::Module { + schema: m.schema.clone(), + name: m.name.clone(), + imports: vec![], + defs: vec![def.clone()], + }; + let h = ailang_core::def_hash(def); + println!("hash: {h}"); + print!("{}", ailang_core::pretty::module(&one)); + } } } Cmd::Check { path, json } => { @@ -340,18 +416,33 @@ fn main() -> Result<()> { } } } - Cmd::Diff { a, b, json } => { - let ma = ailang_core::load_module(&a)?; - let mb = ailang_core::load_module(&b)?; - let report = build_diff(&ma, &mb); - if json { - let v = diff_report_to_json(&report); - println!("{}", serde_json::to_string_pretty(&v)?); + Cmd::Diff { a, b, json, workspace } => { + if workspace { + let ws_a = ailang_core::load_workspace(&a)?; + let ws_b = ailang_core::load_workspace(&b)?; + let report = build_workspace_diff(&ws_a, &ws_b); + if json { + let v = workspace_diff_report_to_json(&report); + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + print!("{}", render_workspace_diff_text(&report)); + } + if !report.is_identical() { + std::process::exit(1); + } } else { - print!("{}", render_diff_text(&report)); - } - if !report.is_identical() { - std::process::exit(1); + let ma = ailang_core::load_module(&a)?; + let mb = ailang_core::load_module(&b)?; + let report = build_diff(&ma, &mb); + if json { + let v = diff_report_to_json(&report); + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + print!("{}", render_diff_text(&report)); + } + if !report.is_identical() { + std::process::exit(1); + } } } Cmd::Workspace { entry, json } => { @@ -409,31 +500,159 @@ fn main() -> Result<()> { } } } - Cmd::Deps { path, of, json } => { - let m = ailang_core::load_module(&path)?; - let mut entries = Vec::new(); - for d in &m.defs { - if let Some(filter) = &of { - if d.name() != filter { - continue; + Cmd::Deps { path, of, json, workspace } => { + if workspace { + let ws = ailang_core::load_workspace(&path)?; + + // `--of NAME`: optional, akzeptiert Punktnotation + // (`.`) oder einen Bare-Namen (matcht in allen + // Modulen, in denen die Def existiert). + let of_filter: Option<(Option, String)> = of.as_ref().map(|s| { + if let Some(idx) = s.find('.') { + let m = s[..idx].to_string(); + let d = s[idx + 1..].to_string(); + (Some(m), d) + } else { + (None, s.clone()) + } + }); + + // Edges sammeln: (from_module, from_def, target). + // `target` ist entweder `Edge::Def { to_module, to_def }` + // oder `Edge::Effect(eff/op)`. + let mut def_edges: Vec<(String, String, String, String)> = Vec::new(); + let mut effect_edges: Vec<(String, String, String)> = Vec::new(); + + for (mod_name, m) in &ws.modules { + // Import-Map des Moduls — nötig zur Cross-Modul-Auflösung. + let import_map = build_import_map(m); + + for d in &m.defs { + if let Some((mf, df)) = &of_filter { + if d.name() != df { + continue; + } + if let Some(mf) = mf { + if mod_name != mf { + continue; + } + } + } + + let refs = collect_refs(d); + for r in &refs { + // Effekt-Refs sind als `effect:/` kodiert + // (siehe `walk_term`). + if let Some(rest) = r.strip_prefix("effect:") { + effect_edges.push(( + mod_name.clone(), + d.name().to_string(), + rest.to_string(), + )); + continue; + } + // ctor:* / type:* — keine Cross-Module-Defs für + // den MVP-Sprachstand; als opaker Marker mit + // leerem to_module durchreichen. + if r.starts_with("ctor:") || r.starts_with("type:") { + def_edges.push(( + mod_name.clone(), + d.name().to_string(), + String::new(), + r.clone(), + )); + continue; + } + // Var-Ref: kann lokal oder qualifiziert (`pre.def`) sein. + if let Some(idx) = r.find('.') { + let pre = &r[..idx]; + let to_def = &r[idx + 1..]; + let to_module = import_map + .get(pre) + .cloned() + .unwrap_or_else(|| pre.to_string()); + def_edges.push(( + mod_name.clone(), + d.name().to_string(), + to_module, + to_def.to_string(), + )); + } else { + // Lokale Referenz — bleibt im selben Modul. + def_edges.push(( + mod_name.clone(), + d.name().to_string(), + mod_name.clone(), + r.clone(), + )); + } + } + } + } + + def_edges.sort(); + effect_edges.sort(); + + if json { + let mut edges_json: Vec = Vec::new(); + for (fm, fd, tm, td) in &def_edges { + edges_json.push(serde_json::json!({ + "from_module": fm, + "from_def": fd, + "to_module": tm, + "to_def": td, + })); + } + for (fm, fd, eff) in &effect_edges { + edges_json.push(serde_json::json!({ + "from_module": fm, + "from_def": fd, + "effect": eff, + })); + } + let out = serde_json::json!({ + "workspace": ws.entry, + "edges": edges_json, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + for (fm, fd, tm, td) in &def_edges { + if tm.is_empty() { + println!("{fm}.{fd} -> {td}"); + } else { + println!("{fm}.{fd} -> {tm}.{td}"); + } + } + for (fm, fd, eff) in &effect_edges { + println!("{fm}.{fd} -> effect:{eff}"); } } - let mut refs: Vec = collect_refs(d).into_iter().collect(); - refs.sort(); - entries.push((d.name().to_string(), refs)); - } - if json { - let arr: Vec<_> = entries - .iter() - .map(|(n, r)| serde_json::json!({ "name": n, "refs": r })) - .collect(); - println!("{}", serde_json::to_string_pretty(&arr)?); } else { - for (n, refs) in entries { - if refs.is_empty() { - println!("{n:>20} -"); - } else { - println!("{n:>20} -> {}", refs.join(", ")); + let m = ailang_core::load_module(&path)?; + let mut entries = Vec::new(); + for d in &m.defs { + if let Some(filter) = &of { + if d.name() != filter { + continue; + } + } + let mut refs: Vec = collect_refs(d).into_iter().collect(); + refs.sort(); + entries.push((d.name().to_string(), refs)); + } + if json { + let arr: Vec<_> = entries + .iter() + .map(|(n, r)| serde_json::json!({ "name": n, "refs": r })) + .collect(); + println!("{}", serde_json::to_string_pretty(&arr)?); + } else { + for (n, refs) in entries { + if refs.is_empty() { + println!("{n:>20} -"); + } else { + println!("{n:>20} -> {}", refs.join(", ")); + } } } } @@ -617,19 +836,40 @@ impl DiffReport { } fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport { + let (added, removed, changed, unchanged) = diff_def_lists(&a.defs, &b.defs); + DiffReport { + module_a: a.name.clone(), + module_b: b.name.clone(), + added, + removed, + changed, + unchanged, + } +} + +/// Reine Listen-Diff-Berechnung für zwei Def-Slices. Wird sowohl vom +/// Single-Modul-Diff als auch — pro `changed_module` — vom Workspace-Diff +/// verwendet, damit die 4-Kategorie-Logik nur an einer Stelle lebt. +fn diff_def_lists( + a_defs: &[ailang_core::Def], + b_defs: &[ailang_core::Def], +) -> (Vec, Vec, Vec, Vec) { use std::collections::BTreeMap; - let map_a: BTreeMap<&str, &ailang_core::Def> = - a.defs.iter().map(|d| (ailang_core::def_name(d), d)).collect(); - let map_b: BTreeMap<&str, &ailang_core::Def> = - b.defs.iter().map(|d| (ailang_core::def_name(d), d)).collect(); + let map_a: BTreeMap<&str, &ailang_core::Def> = a_defs + .iter() + .map(|d| (ailang_core::def_name(d), d)) + .collect(); + let map_b: BTreeMap<&str, &ailang_core::Def> = b_defs + .iter() + .map(|d| (ailang_core::def_name(d), d)) + .collect(); let mut added = Vec::new(); let mut removed = Vec::new(); let mut changed = Vec::new(); let mut unchanged = Vec::new(); - // Removed + (un)changed: alles aus A. for (name, def_a) in &map_a { let hash_a = ailang_core::def_hash(def_a); let kind_a = ailang_core::def_kind(def_a); @@ -661,7 +901,6 @@ fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport { } } - // Added: was nur in B vorkommt. for (name, def_b) in &map_b { if !map_a.contains_key(*name) { added.push(DiffEntry { @@ -672,22 +911,12 @@ fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport { } } - // BTreeMap-Iteration ist bereits alphabetisch — keine extra-Sortierung - // nötig, aber explizit absichern, falls die Reihenfolge der Quelle - // jemals umgestellt wird. added.sort_by(|x, y| x.name.cmp(&y.name)); removed.sort_by(|x, y| x.name.cmp(&y.name)); changed.sort_by(|x, y| x.name.cmp(&y.name)); unchanged.sort_by(|x, y| x.name.cmp(&y.name)); - DiffReport { - module_a: a.name.clone(), - module_b: b.name.clone(), - added, - removed, - changed, - unchanged, - } + (added, removed, changed, unchanged) } fn diff_report_to_json(r: &DiffReport) -> serde_json::Value { @@ -789,3 +1018,312 @@ fn render_diff_text(r: &DiffReport) -> String { } out } + +// --- Workspace-fähige Helfer (Iter 5d) ------------------------------------ + +/// Kompakte Zusammenfassung einer Def für Manifest-Ausgaben (Single- und +/// Workspace-Modus teilen diesen Helper). Liefert (kind, type-string, +/// effects). +fn def_summary(d: &ailang_core::Def) -> (&'static str, String, Vec) { + match d { + ailang_core::Def::Fn(f) => { + let effects = match &f.ty { + ailang_core::Type::Fn { effects, .. } => effects.clone(), + _ => vec![], + }; + ("fn", ailang_core::pretty::type_to_string(&f.ty), effects) + } + ailang_core::Def::Const(c) => ( + "const", + ailang_core::pretty::type_to_string(&c.ty), + vec![], + ), + ailang_core::Def::Type(t) => { + let s = t + .ctors + .iter() + .map(|c| { + if c.fields.is_empty() { + c.name.clone() + } else { + format!( + "{}({})", + c.name, + c.fields + .iter() + .map(ailang_core::pretty::type_to_string) + .collect::>() + .join(", ") + ) + } + }) + .collect::>() + .join(" | "); + ("type", s, vec![]) + } + } +} + +/// Auflösung für `ail describe --workspace `. +/// +/// 1. `name` enthält genau einen Punkt → `.` strikt auflösen. +/// 2. Sonst zuerst im Eintrittsmodul suchen; nur fallback auf andere Module, +/// wenn dort nichts. Mehrere Treffer ergeben `ambiguous-name`. +fn resolve_describe_name<'ws>( + ws: &'ws ailang_core::Workspace, + name: &str, +) -> Result<(String, &'ws ailang_core::Def)> { + if let Some(idx) = name.find('.') { + let mod_name = &name[..idx]; + let def_name = &name[idx + 1..]; + let m = ws.modules.get(mod_name).with_context(|| { + format!("no module `{mod_name}` in workspace `{}`", ws.entry) + })?; + let def = m + .defs + .iter() + .find(|d| d.name() == def_name) + .with_context(|| { + format!("no def `{def_name}` in module `{mod_name}`") + })?; + return Ok((mod_name.to_string(), def)); + } + + // Bare-Name: erst Eintrittsmodul. + if let Some(entry_mod) = ws.modules.get(&ws.entry) { + if let Some(def) = entry_mod.defs.iter().find(|d| d.name() == name) { + return Ok((ws.entry.clone(), def)); + } + } + + // Fallback: alle Module einsammeln; bei Mehrdeutigkeit Fehler. + let mut hits: Vec<(String, &ailang_core::Def)> = Vec::new(); + for (mod_name, m) in &ws.modules { + if mod_name == &ws.entry { + continue; + } + for d in &m.defs { + if d.name() == name { + hits.push((mod_name.clone(), d)); + } + } + } + match hits.len() { + 0 => Err(anyhow::anyhow!( + "no def `{name}` in workspace `{}`", + ws.entry + )), + 1 => Ok(hits.into_iter().next().unwrap()), + _ => { + let modules: Vec = + hits.iter().map(|(m, _)| m.clone()).collect(); + Err(anyhow::anyhow!( + "[ambiguous-name] def `{name}` exists in multiple modules: {}", + modules.join(", ") + )) + } + } +} + +/// Map ` -> ` für ein einzelnes Modul. `` ist +/// der Import-Alias falls gesetzt, sonst der Modulname selbst. Modulname +/// ohne Punkt. +fn build_import_map(m: &ailang_core::Module) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for imp in &m.imports { + let prefix = imp.alias.clone().unwrap_or_else(|| imp.module.clone()); + map.insert(prefix, imp.module.clone()); + } + map +} + +// --- Workspace-Diff ------------------------------------------------------- + +struct WorkspaceDiffReport { + workspace_a: String, + workspace_b: String, + added_modules: Vec, + removed_modules: Vec, + unchanged_modules: Vec, + changed_modules: Vec, +} + +struct ModuleDiffEntry { + name: String, + hash: String, +} + +struct ChangedModuleEntry { + name: String, + hash_a: String, + hash_b: String, + added: Vec, + removed: Vec, + changed: Vec, + unchanged: Vec, +} + +impl WorkspaceDiffReport { + fn is_identical(&self) -> bool { + self.added_modules.is_empty() + && self.removed_modules.is_empty() + && self.changed_modules.is_empty() + } +} + +fn build_workspace_diff( + a: &ailang_core::Workspace, + b: &ailang_core::Workspace, +) -> WorkspaceDiffReport { + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut unchanged = Vec::new(); + let mut changed = Vec::new(); + + for (name, ma) in &a.modules { + let hash_a = ailang_core::module_hash(ma); + match b.modules.get(name) { + None => removed.push(ModuleDiffEntry { + name: name.clone(), + hash: hash_a, + }), + Some(mb) => { + let hash_b = ailang_core::module_hash(mb); + if hash_a == hash_b { + unchanged.push(ModuleDiffEntry { + name: name.clone(), + hash: hash_a, + }); + } else { + let (sub_added, sub_removed, sub_changed, sub_unchanged) = + diff_def_lists(&ma.defs, &mb.defs); + changed.push(ChangedModuleEntry { + name: name.clone(), + hash_a, + hash_b, + added: sub_added, + removed: sub_removed, + changed: sub_changed, + unchanged: sub_unchanged, + }); + } + } + } + } + for (name, mb) in &b.modules { + if !a.modules.contains_key(name) { + added.push(ModuleDiffEntry { + name: name.clone(), + hash: ailang_core::module_hash(mb), + }); + } + } + + added.sort_by(|x, y| x.name.cmp(&y.name)); + removed.sort_by(|x, y| x.name.cmp(&y.name)); + unchanged.sort_by(|x, y| x.name.cmp(&y.name)); + changed.sort_by(|x, y| x.name.cmp(&y.name)); + + WorkspaceDiffReport { + workspace_a: a.entry.clone(), + workspace_b: b.entry.clone(), + added_modules: added, + removed_modules: removed, + unchanged_modules: unchanged, + changed_modules: changed, + } +} + +fn workspace_diff_report_to_json(r: &WorkspaceDiffReport) -> serde_json::Value { + let mod_entry = |e: &ModuleDiffEntry| { + serde_json::json!({ "name": e.name, "hash": e.hash }) + }; + let entry = |e: &DiffEntry| { + serde_json::json!({ + "name": e.name, + "hash": e.hash, + "kind": e.kind, + }) + }; + let changed_entry = |c: &ChangedEntry| { + serde_json::json!({ + "name": c.name, + "hash_a": c.hash_a, + "hash_b": c.hash_b, + "kind_a": c.kind_a, + "kind_b": c.kind_b, + }) + }; + let changed_mod = |c: &ChangedModuleEntry| { + serde_json::json!({ + "name": c.name, + "hash_a": c.hash_a, + "hash_b": c.hash_b, + "added": c.added.iter().map(entry).collect::>(), + "removed": c.removed.iter().map(entry).collect::>(), + "changed": c.changed.iter().map(changed_entry).collect::>(), + "unchanged": c.unchanged.iter().map(entry).collect::>(), + }) + }; + serde_json::json!({ + "workspace_a": r.workspace_a, + "workspace_b": r.workspace_b, + "added_modules": r.added_modules.iter().map(mod_entry).collect::>(), + "removed_modules": r.removed_modules.iter().map(mod_entry).collect::>(), + "unchanged_modules": r.unchanged_modules.iter().map(mod_entry).collect::>(), + "changed_modules": r.changed_modules.iter().map(changed_mod).collect::>(), + }) +} + +fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String { + use std::fmt::Write; + + let mut out = String::new(); + let _ = writeln!(out, "workspace diff: {} -> {}", r.workspace_a, r.workspace_b); + + if r.is_identical() && r.unchanged_modules.is_empty() { + let _ = writeln!(out, "no changes"); + return out; + } + + for m in &r.added_modules { + let _ = writeln!(out, "+ module {} {}", m.name, m.hash); + } + for m in &r.removed_modules { + let _ = writeln!(out, "- module {} {}", m.name, m.hash); + } + for c in &r.changed_modules { + let _ = writeln!( + out, + "~ module {} {} -> {}", + c.name, c.hash_a, c.hash_b + ); + for e in &c.added { + let _ = writeln!(out, " + {} ({}) {}", e.name, e.kind, e.hash); + } + for e in &c.removed { + let _ = writeln!(out, " - {} ({}) {}", e.name, e.kind, e.hash); + } + for ce in &c.changed { + let kind = if ce.kind_a == ce.kind_b { + ce.kind_a.to_string() + } else { + format!("{} -> {}", ce.kind_a, ce.kind_b) + }; + let _ = writeln!( + out, + " ~ {} ({}) {} -> {}", + ce.name, kind, ce.hash_a, ce.hash_b + ); + } + for e in &c.unchanged { + let _ = + writeln!(out, " {} ({}) {} (unchanged)", e.name, e.kind, e.hash); + } + } + for m in &r.unchanged_modules { + let _ = writeln!(out, " module {} {} (unchanged)", m.name, m.hash); + } + + out +} diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 2853961..a7819a4 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -265,6 +265,218 @@ fn workspace_build_runs_imported_fn() { assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5"); } +/// Schützt Iter 5d: `ail manifest --workspace --json` listet Defs aus allen +/// Modulen des Workspaces — sichtbar an einem `module`-Feld pro Eintrag. +#[test] +fn manifest_workspace_lists_all_defs() { + 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([ + "manifest", + entry.to_str().unwrap(), + "--workspace", + "--json", + ]) + .output() + .expect("ail manifest --workspace failed to run"); + + assert!( + output.status.success(), + "ail manifest --workspace exited non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + + let symbols = v["symbols"].as_array().expect("symbols must be array"); + let modules: Vec<&str> = symbols + .iter() + .filter_map(|s| s.get("module").and_then(|m| m.as_str())) + .collect(); + assert!( + modules.contains(&"ws_main"), + "expected at least one symbol with module=ws_main; got {modules:?}" + ); + assert!( + modules.contains(&"ws_lib"), + "expected at least one symbol with module=ws_lib; got {modules:?}" + ); +} + +/// Schützt Iter 5d: `ail describe --workspace ws_lib.add` löst die +/// qualifizierte Punkt-Notation in das tatsächlich importierte Modul auf. +#[test] +fn describe_workspace_resolves_qualified_name() { + 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([ + "describe", + entry.to_str().unwrap(), + "ws_lib.add", + "--workspace", + "--json", + ]) + .output() + .expect("ail describe --workspace failed to run"); + + assert!( + output.status.success(), + "ail describe --workspace exited non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + + assert_eq!( + v.get("module").and_then(|m| m.as_str()), + Some("ws_lib"), + "module field must be ws_lib; got {stdout}" + ); + assert_eq!( + v.get("name").and_then(|n| n.as_str()), + Some("add"), + "def name must be add; got {stdout}" + ); +} + +/// Schützt Iter 5d: `ail deps --workspace` enthält eine Cross-Module-Edge +/// `ws_main.main -> ws_lib.add`, weil `ws_main` `ws_lib.add(2,3)` aufruft. +#[test] +fn deps_workspace_includes_cross_module() { + 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(["deps", entry.to_str().unwrap(), "--workspace", "--json"]) + .output() + .expect("ail deps --workspace failed to run"); + + assert!( + output.status.success(), + "ail deps --workspace exited non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + let edges = v["edges"].as_array().expect("edges must be array"); + + let has_cross_edge = edges.iter().any(|e| { + e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main") + && e.get("from_def").and_then(|s| s.as_str()) == Some("main") + && e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib") + && e.get("to_def").and_then(|s| s.as_str()) == Some("add") + }); + assert!( + has_cross_edge, + "expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}" + ); +} + +/// Schützt Iter 5d: `ail diff --workspace` erkennt ein zusätzliches Modul +/// im B-Workspace als `added_modules`-Eintrag und exitet mit Code 1. +#[test] +fn diff_workspace_added_module() { + use std::fs; + + fn write_module(dir: &Path, name: &str, body: serde_json::Value) { + let p = dir.join(format!("{name}.ail.json")); + fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap(); + } + + let dir_a = std::env::temp_dir().join(format!( + "ailang_diff_ws_a_{}", + std::process::id() + )); + let dir_b = std::env::temp_dir().join(format!( + "ailang_diff_ws_b_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + fs::create_dir_all(&dir_a).unwrap(); + fs::create_dir_all(&dir_b).unwrap(); + + // Beide Workspaces haben ein leeres Modul `root`. B importiert + // zusätzlich `extra`. Loader-Konvention: `/.ail.json`, + // Modul-Name muss zum Dateinamen passen. + let empty_root = serde_json::json!({ + "schema": "ailang/v0", + "name": "root", + "imports": [], + "defs": [], + }); + let root_with_import = serde_json::json!({ + "schema": "ailang/v0", + "name": "root", + "imports": [{ "module": "extra" }], + "defs": [], + }); + let extra = serde_json::json!({ + "schema": "ailang/v0", + "name": "extra", + "imports": [], + "defs": [], + }); + + write_module(&dir_a, "root", empty_root); + write_module(&dir_b, "root", root_with_import); + write_module(&dir_b, "extra", extra); + + let entry_a = dir_a.join("root.ail.json"); + let entry_b = dir_b.join("root.ail.json"); + + let output = Command::new(ail_bin()) + .args([ + "diff", + entry_a.to_str().unwrap(), + entry_b.to_str().unwrap(), + "--workspace", + "--json", + ]) + .output() + .expect("ail diff --workspace failed to run"); + + let code = output.status.code().expect("process terminated by signal"); + assert_eq!( + code, + 1, + "expected exit 1 when workspaces differ; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + + let added = v["added_modules"] + .as_array() + .expect("added_modules must be array"); + assert!( + added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")), + "expected `extra` in added_modules; got {stdout}" + ); + let removed = v["removed_modules"] + .as_array() + .expect("removed_modules must be array"); + assert!( + removed.is_empty(), + "expected removed_modules empty; got {stdout}" + ); +} + /// 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