diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 08d7a5e..e42aad1 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -69,6 +69,20 @@ enum Cmd { #[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, + }, } fn main() -> Result<()> { @@ -258,6 +272,20 @@ fn main() -> Result<()> { } } } + Cmd::Diff { a, b, json } => { + let ma = ailang_core::load_module(&a)?; + let mb = ailang_core::load_module(&b)?; + let report = build_diff(&ma, &mb); + if json { + let v = diff_report_to_json(&report); + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + print!("{}", render_diff_text(&report)); + } + if !report.is_identical() { + std::process::exit(1); + } + } Cmd::Deps { path, of, json } => { let m = ailang_core::load_module(&path)?; let mut entries = Vec::new(); @@ -357,3 +385,210 @@ fn walk_term(t: &ailang_core::Term, out: &mut std::collections::BTreeSet } } } + +// --- 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, + " {: &str { + def.name() +} + +/// Externer Helper: Diskriminator-Tag einer Definition (`fn`, `const`, `type`). +/// Identisch mit dem `kind`-Feld in der JSON-Repräsentation. +pub fn def_kind(def: &Def) -> &'static str { + match def { + Def::Fn(_) => "fn", + Def::Const(_) => "const", + Def::Type(_) => "type", + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TypeDef { pub name: String, diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 09651c9..1a4005f 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -8,7 +8,9 @@ pub mod canonical; pub mod hash; pub mod pretty; -pub use ast::{ConstDef, Def, FnDef, Import, Literal, Module, Term, Type}; +pub use ast::{ + def_kind, def_name, ConstDef, Def, FnDef, Import, Literal, Module, Term, Type, +}; pub use hash::def_hash; #[derive(Debug, thiserror::Error)]