Iter 4b: ail diff für semantischen Modul-Vergleich
ail diff <a> <b> [--json] vergleicht zwei Module strukturell per Def-Hash. Vier Kategorien (added/removed/changed/unchanged), alphabetisch sortiert, deterministisches JSON-Schema. Exit 1 bei Unterschieden, Exit 0 bei Identität — skript-tauglich. Kein Typcheck-Zwang, damit man auch kaputte Module diffen kann. Helper def_name/def_kind in ailang-core für stabile Def-Identität.
This commit is contained in:
@@ -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<String>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user