2114fb2f6a
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.
1330 lines
48 KiB
Rust
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
|
|
}
|