Files
AILang/crates/ail/src/main.rs
T
Brummel 2114fb2f6a Iter 5d: --workspace-Modus für manifest, describe, deps, diff
Vier Tooling-Subkommandos können jetzt auf dem ganzen Workspace
arbeiten. Default bleibt single-modul (Rückwärtskompat). Manifest
listet alphabetisch nach (modul, name) mit module-Feld; describe
nimmt Punktnotation ws_lib.add an, ambig-Erkennung fallback;
deps gibt Cross-Module-Edges {from_module,from_def,to_module,to_def}
aus, Effekt-Ops separat; diff vergleicht workspace-übergreifend mit
added/removed/changed/unchanged_modules und nested Sub-Diff pro
changed_module. Helper diff_def_lists wird von Single- und
Workspace-Diff geteilt.
2026-05-07 11:46:28 +02:00

1330 lines
48 KiB
Rust

//! `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 (`<modul>.<def>`) 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 (`<modul>.<def>`) sein.
#[arg(long)]
of: Option<String>,
#[arg(long)]
json: bool,
/// Workspace-Modus: Edges sind cross-module-fähig
/// (`<from-mod>.<from-def> -> <to-mod>.<to-def>`).
#[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<PathBuf>,
},
/// Komplette Pipeline: check + emit-ir + clang -> Binary.
Build {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
/// 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 `<modul>.<def> :: <typ> ![effs] <hash>`.
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!(
"{:<width$} :: {}{} [{}]",
label,
ty,
eff,
ailang_core::def_hash(d),
width = label_width,
);
}
}
} else {
let m = ailang_core::load_module(&path)?;
if json {
let entries: Vec<_> = 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} {:<width$} {} {:>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
// (`<modul>.<def>`) oder einen Bare-Namen (matcht in allen
// Modulen, in denen die Def existiert).
let of_filter: Option<(Option<String>, 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:<eff>/<op>` 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<serde_json::Value> = 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<String> = 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<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 {
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<String>) {
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<DiffEntry>,
removed: Vec<DiffEntry>,
changed: Vec<ChangedEntry>,
unchanged: Vec<DiffEntry>,
}
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<DiffEntry>, Vec<DiffEntry>, Vec<ChangedEntry>, Vec<DiffEntry>) {
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::<Vec<_>>(),
"removed": r.removed.iter().map(entry).collect::<Vec<_>>(),
"changed": r.changed.iter().map(changed).collect::<Vec<_>>(),
"unchanged": r.unchanged.iter().map(entry).collect::<Vec<_>>(),
})
}
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,
"+ {:<width$} ({}) {}",
e.name,
e.kind,
e.hash,
width = name_width
);
}
for e in &r.removed {
let _ = writeln!(
out,
"- {:<width$} ({}) {}",
e.name,
e.kind,
e.hash,
width = name_width
);
}
for c in &r.changed {
// Wenn sich der Kind geändert hat (z. B. const → fn), beide zeigen.
let kind = if c.kind_a == c.kind_b {
c.kind_a.to_string()
} else {
format!("{} -> {}", c.kind_a, c.kind_b)
};
let _ = writeln!(
out,
"~ {:<width$} ({}) {} -> {}",
c.name,
kind,
c.hash_a,
c.hash_b,
width = name_width
);
}
for e in &r.unchanged {
let _ = writeln!(
out,
" {:<width$} ({}) {} (unchanged)",
e.name,
e.kind,
e.hash,
width = name_width
);
}
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<String>) {
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::<Vec<_>>()
.join(", ")
)
}
})
.collect::<Vec<_>>()
.join(" | ");
("type", s, vec![])
}
}
}
/// Auflösung für `ail describe --workspace <name>`.
///
/// 1. `name` enthält genau einen Punkt → `<modul>.<def>` 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<String> =
hits.iter().map(|(m, _)| m.clone()).collect();
Err(anyhow::anyhow!(
"[ambiguous-name] def `{name}` exists in multiple modules: {}",
modules.join(", ")
))
}
}
}
/// Map `<prefix> -> <module-name>` für ein einzelnes Modul. `<prefix>` ist
/// der Import-Alias falls gesetzt, sonst der Modulname selbst. Modulname
/// ohne Punkt.
fn build_import_map(m: &ailang_core::Module) -> std::collections::BTreeMap<String, String> {
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<ModuleDiffEntry>,
removed_modules: Vec<ModuleDiffEntry>,
unchanged_modules: Vec<ModuleDiffEntry>,
changed_modules: Vec<ChangedModuleEntry>,
}
struct ModuleDiffEntry {
name: String,
hash: String,
}
struct ChangedModuleEntry {
name: String,
hash_a: String,
hash_b: String,
added: Vec<DiffEntry>,
removed: Vec<DiffEntry>,
changed: Vec<ChangedEntry>,
unchanged: Vec<DiffEntry>,
}
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::<Vec<_>>(),
"removed": c.removed.iter().map(entry).collect::<Vec<_>>(),
"changed": c.changed.iter().map(changed_entry).collect::<Vec<_>>(),
"unchanged": c.unchanged.iter().map(entry).collect::<Vec<_>>(),
})
};
serde_json::json!({
"workspace_a": r.workspace_a,
"workspace_b": r.workspace_b,
"added_modules": r.added_modules.iter().map(mod_entry).collect::<Vec<_>>(),
"removed_modules": r.removed_modules.iter().map(mod_entry).collect::<Vec<_>>(),
"unchanged_modules": r.unchanged_modules.iter().map(mod_entry).collect::<Vec<_>>(),
"changed_modules": r.changed_modules.iter().map(changed_mod).collect::<Vec<_>>(),
})
}
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
}