a20ab93c66
Symbol-Mangling-Schema einheitlich auf @ail_<modul>_<def> umgestellt (auch für Single-Modul-Programme), String-Globals als @.str_<modul>_<idx>. main bleibt LLVM-/C-ABI-Eintrittspunkt und ist ein Trampoline auf @ail_<entry>_main. lower_workspace emittiert eine einzige .ll für den ganzen Workspace, alphabetisch nach Modulname, Cross-Module-Calls über Import-Map aufgelöst. ail build / ail emit-ir laufen jetzt durch den Workspace-Pfad. IR-Snapshots regeneriert, neuer ws_main-Snapshot. E2E-Test workspace_build_runs_imported_fn prüft, dass das Binary die importierte Funktion korrekt aufruft. Schuld #19 (source_filename) durch einheitliches <entry>.ail-Schema geschlossen.
792 lines
27 KiB
Rust
792 lines
27 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,
|
|
},
|
|
/// 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<String>,
|
|
#[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<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,
|
|
},
|
|
/// 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::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.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} {:<width$} {} {:>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<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 {
|
|
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::<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
|
|
}
|