//! `ail` — CLI für AILang. //! //! Subcommands sind so geschnitten, dass jedes einzelne Tool dem LLM einen //! kleinen, fokussierten Kontext liefert (manifest = Übersicht; describe = //! Detail; emit-ir = exakte Maschinensicht; build = Pipeline-Validierung). use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::path::{Path, PathBuf}; #[derive(Parser)] #[command(name = "ail", version, about = "AILang toolchain")] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Lädt ein Modul und gibt eine kompakte Symboltabelle aus. Manifest { 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 }, /// Gibt eine einzelne Definition als JSON oder Pretty-Text aus. Describe { path: PathBuf, 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. 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 { path: PathBuf, /// Strukturierte Diagnostics als JSON-Array auf stdout. /// Exit-Code 1, wenn mindestens ein Error gemeldet wird. #[arg(long)] json: bool, }, /// Schreibt LLVM IR (.ll) für das Modul. EmitIr { path: PathBuf, #[arg(short, long)] out: Option, }, /// Komplette Pipeline: check + emit-ir + clang -> Binary. Build { path: PathBuf, #[arg(short, long)] out: Option, /// Optimierung (z. B. `-O2`); default `-O0` für Debugbarkeit. #[arg(long, default_value = "-O0")] opt: String, }, /// Listet eingebaute Operationen mit ihren Signaturen. Builtins { #[arg(long)] json: bool, }, /// Semantischer Modul-Diff per Def-Hash. /// /// Vergleicht zwei Module rein strukturell auf Top-Level-Defs: /// pro Name werden die Hashes der canonical Bytes verglichen. Das /// Diff funktioniert auch, wenn ein Modul gerade nicht typecheckt — /// nur das Schema und die JSON-Form müssen ladbar sein. /// /// Exit-Code: 0 wenn keine Änderungen (außer `unchanged`), sonst 1. Diff { a: PathBuf, 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. /// /// Iter 5a: nur das Listing. Cross-Module-Typcheck/Codegen folgt in /// 5b/5c; bestehende Subkommandos arbeiten weiter pro Einzelmodul. Workspace { entry: PathBuf, #[arg(long)] json: bool, }, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { 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())) }); 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, 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 { 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 } => { // 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. 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 .iter() .any(|d| matches!(d.severity, ailang_check::Severity::Error)) { std::process::exit(1); } } else { 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 } => { // Iter 5c: Workspace-Lowering. Bei Single-Modul-Programmen ist // der Workspace effektiv ein Trivial-Workspace mit einem Modul. 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 ir = ailang_codegen::lower_workspace(&ws)?; match out { Some(p) => { std::fs::write(&p, ir)?; eprintln!("wrote {}", p.display()); } None => print!("{ir}"), } } Cmd::Build { path, out, opt } => { // Iter 5c: gleiche Pipeline wie `emit-ir`, aber clang ruft am Ende. 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 ir = ailang_codegen::lower_workspace(&ws)?; let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id())); std::fs::create_dir_all(&tmpdir)?; let ll_path = tmpdir.join(format!("{}.ll", ws.entry)); std::fs::write(&ll_path, &ir)?; let out_bin = out.unwrap_or_else(|| { Path::new(".").join(&ws.entry).with_extension("") }); let status = std::process::Command::new("clang") .arg(&opt) .arg("-o") .arg(&out_bin) .arg(&ll_path) .status() .context("running clang")?; if !status.success() { anyhow::bail!( "clang failed (status {}); ll at {}", status, ll_path.display() ); } eprintln!("built {}", out_bin.display()); } Cmd::Builtins { json } => { let list = ailang_check::builtins::list(); if json { let arr: Vec<_> = list .iter() .map(|(n, s)| serde_json::json!({ "name": n, "sig": s })) .collect(); println!("{}", serde_json::to_string_pretty(&arr)?); } else { for (n, sig) in list { println!("{n:<16} {sig}"); } } } 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 { 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 } => { let ws = ailang_core::load_workspace(&entry)?; // Alphabetisch über Modul-Namen iterieren (BTreeMap-Order ist // bereits sortiert; explizit absichern). let mut entries: Vec<(String, String, usize)> = ws .modules .iter() .map(|(name, m)| { ( name.clone(), ailang_core::module_hash(m), m.defs.len(), ) }) .collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); if json { let arr: Vec<_> = entries .iter() .map(|(name, hash, defs)| { serde_json::json!({ "name": name, "hash": hash, "defs": defs, }) }) .collect(); let out = serde_json::json!({ "entry": ws.entry, "modules": arr, }); println!("{}", serde_json::to_string_pretty(&out)?); } else { // Spaltenbreite an längstem Modulnamen ausrichten. Erste Zeile // markiert das Eintrittsmodul mit `*`. let name_width = entries .iter() .map(|(n, _, _)| n.len()) .max() .unwrap_or(0) .max(6); println!("entry: {}", ws.entry); for (name, hash, defs) in &entries { let marker = if *name == ws.entry { "*" } else { " " }; println!( "{marker} {:3} defs", name, hash, defs, width = name_width, ); } } } 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}"); } } } else { 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(", ")); } } } } } } 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 { ailang_core::Def::Fn(f) => walk_term(&f.body, &mut out), ailang_core::Def::Const(c) => walk_term(&c.value, &mut out), ailang_core::Def::Type(td) => { // Eine Typedef referenziert die Typen ihrer Felder. for c in &td.ctors { for ft in &c.fields { if let ailang_core::Type::Con { name } = ft { out.insert(format!("type:{name}")); } } } } } out } fn walk_term(t: &ailang_core::Term, out: &mut std::collections::BTreeSet) { use ailang_core::Term; match t { Term::Lit { .. } => {} Term::Var { name } => { out.insert(name.clone()); } Term::App { callee, args } => { walk_term(callee, out); for a in args { walk_term(a, out); } } Term::Let { value, body, .. } => { walk_term(value, out); walk_term(body, out); } Term::If { cond, then, else_ } => { walk_term(cond, out); walk_term(then, out); walk_term(else_, out); } Term::Do { op, args } => { // Effekt-Ops als `effect:io/print_int` markieren, damit man sie // von normalen Funktionsaufrufen trennen kann. out.insert(format!("effect:{op}")); for a in args { walk_term(a, out); } } Term::Ctor { type_name, ctor, args } => { out.insert(format!("ctor:{type_name}/{ctor}")); for a in args { walk_term(a, out); } } Term::Match { scrutinee, arms } => { walk_term(scrutinee, out); for arm in arms { if let ailang_core::ast::Pattern::Ctor { ctor, .. } = &arm.pat { out.insert(format!("ctor:{ctor}")); } walk_term(&arm.body, out); } } } } // --- ail diff ------------------------------------------------------------- /// Rein struktureller Modul-Diff. Top-Level-Defs werden per `name` /// identifiziert und per BLAKE3-16-Hex der canonical Bytes verglichen. struct DiffReport { module_a: String, module_b: String, added: Vec, removed: Vec, changed: Vec, unchanged: Vec, } struct DiffEntry { name: String, hash: String, kind: &'static str, } struct ChangedEntry { name: String, hash_a: String, hash_b: String, kind_a: &'static str, kind_b: &'static str, } impl DiffReport { fn is_identical(&self) -> bool { self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty() } } 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 mut added = Vec::new(); let mut removed = Vec::new(); let mut changed = Vec::new(); let mut unchanged = Vec::new(); 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); match map_b.get(*name) { None => removed.push(DiffEntry { name: (*name).to_string(), hash: hash_a, kind: kind_a, }), Some(def_b) => { let hash_b = ailang_core::def_hash(def_b); let kind_b = ailang_core::def_kind(def_b); if hash_a == hash_b { unchanged.push(DiffEntry { name: (*name).to_string(), hash: hash_a, kind: kind_a, }); } else { changed.push(ChangedEntry { name: (*name).to_string(), hash_a, hash_b, kind_a, kind_b, }); } } } } for (name, def_b) in &map_b { if !map_a.contains_key(*name) { added.push(DiffEntry { name: (*name).to_string(), hash: ailang_core::def_hash(def_b), kind: ailang_core::def_kind(def_b), }); } } 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)); (added, removed, changed, unchanged) } fn diff_report_to_json(r: &DiffReport) -> serde_json::Value { let entry = |e: &DiffEntry| { serde_json::json!({ "name": e.name, "hash": e.hash, "kind": e.kind, }) }; let changed = |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, }) }; serde_json::json!({ "module_a": r.module_a, "module_b": r.module_b, "added": r.added.iter().map(entry).collect::>(), "removed": r.removed.iter().map(entry).collect::>(), "changed": r.changed.iter().map(changed).collect::>(), "unchanged": r.unchanged.iter().map(entry).collect::>(), }) } fn render_diff_text(r: &DiffReport) -> String { use std::fmt::Write; let mut out = String::new(); let _ = writeln!(out, "diff: {} -> {}", r.module_a, r.module_b); if r.is_identical() && r.unchanged.is_empty() { let _ = writeln!(out, "no changes"); return out; } // Einheitliche Spaltenbreite für Namens-/Kind-Spalte, damit Hashes // visuell aligned sind. Längster Name bestimmt die Breite. let name_width = r .added .iter() .map(|e| e.name.len()) .chain(r.removed.iter().map(|e| e.name.len())) .chain(r.changed.iter().map(|e| e.name.len())) .chain(r.unchanged.iter().map(|e| e.name.len())) .max() .unwrap_or(0); for e in &r.added { let _ = writeln!( out, "+ {: {}", c.kind_a, c.kind_b) }; let _ = writeln!( out, "~ {: {}", c.name, kind, c.hash_a, c.hash_b, width = name_width ); } for e in &r.unchanged { let _ = writeln!( out, " {: (&'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 }