//! `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, }, /// 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, }, /// Listet, welche Symbole jede Definition aufruft (statisch). Deps { path: PathBuf, /// Nur für ein Symbol; ohne Argument: für alle. #[arg(long)] of: Option, #[arg(long)] json: 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, }, /// 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 } => { 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, }); 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}"); } 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 } => { 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 } => { 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 { 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(); // 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); 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, }); } } } } // Added: was nur in B vorkommt. 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), }); } } // 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, } } 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, " {: