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.
This commit is contained in:
+673
-135
@@ -22,6 +22,10 @@ enum Cmd {
|
||||
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 },
|
||||
@@ -31,15 +35,26 @@ enum Cmd {
|
||||
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.
|
||||
/// 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 {
|
||||
@@ -82,6 +97,13 @@ enum Cmd {
|
||||
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.
|
||||
@@ -98,99 +120,153 @@ enum Cmd {
|
||||
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,
|
||||
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()))
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||
|
||||
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 {
|
||||
print!("{}", ailang_core::pretty::manifest(&m));
|
||||
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 } => {
|
||||
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}");
|
||||
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 {
|
||||
// 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));
|
||||
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 } => {
|
||||
@@ -340,18 +416,33 @@ 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)?);
|
||||
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 {
|
||||
print!("{}", render_diff_text(&report));
|
||||
}
|
||||
if !report.is_identical() {
|
||||
std::process::exit(1);
|
||||
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 } => {
|
||||
@@ -409,31 +500,159 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
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}");
|
||||
}
|
||||
}
|
||||
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(", "));
|
||||
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(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -617,19 +836,40 @@ impl DiffReport {
|
||||
}
|
||||
|
||||
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 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);
|
||||
@@ -661,7 +901,6 @@ fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport {
|
||||
}
|
||||
}
|
||||
|
||||
// Added: was nur in B vorkommt.
|
||||
for (name, def_b) in &map_b {
|
||||
if !map_a.contains_key(*name) {
|
||||
added.push(DiffEntry {
|
||||
@@ -672,22 +911,12 @@ fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
(added, removed, changed, unchanged)
|
||||
}
|
||||
|
||||
fn diff_report_to_json(r: &DiffReport) -> serde_json::Value {
|
||||
@@ -789,3 +1018,312 @@ fn render_diff_text(r: &DiffReport) -> String {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -265,6 +265,218 @@ fn workspace_build_runs_imported_fn() {
|
||||
assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5");
|
||||
}
|
||||
|
||||
/// Schützt Iter 5d: `ail manifest --workspace --json` listet Defs aus allen
|
||||
/// Modulen des Workspaces — sichtbar an einem `module`-Feld pro Eintrag.
|
||||
#[test]
|
||||
fn manifest_workspace_lists_all_defs() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let entry = workspace.join("examples").join("ws_main.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args([
|
||||
"manifest",
|
||||
entry.to_str().unwrap(),
|
||||
"--workspace",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.expect("ail manifest --workspace failed to run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail manifest --workspace exited non-zero; stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
|
||||
|
||||
let symbols = v["symbols"].as_array().expect("symbols must be array");
|
||||
let modules: Vec<&str> = symbols
|
||||
.iter()
|
||||
.filter_map(|s| s.get("module").and_then(|m| m.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
modules.contains(&"ws_main"),
|
||||
"expected at least one symbol with module=ws_main; got {modules:?}"
|
||||
);
|
||||
assert!(
|
||||
modules.contains(&"ws_lib"),
|
||||
"expected at least one symbol with module=ws_lib; got {modules:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Schützt Iter 5d: `ail describe --workspace ws_lib.add` löst die
|
||||
/// qualifizierte Punkt-Notation in das tatsächlich importierte Modul auf.
|
||||
#[test]
|
||||
fn describe_workspace_resolves_qualified_name() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let entry = workspace.join("examples").join("ws_main.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args([
|
||||
"describe",
|
||||
entry.to_str().unwrap(),
|
||||
"ws_lib.add",
|
||||
"--workspace",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.expect("ail describe --workspace failed to run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail describe --workspace exited non-zero; stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
v.get("module").and_then(|m| m.as_str()),
|
||||
Some("ws_lib"),
|
||||
"module field must be ws_lib; got {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("name").and_then(|n| n.as_str()),
|
||||
Some("add"),
|
||||
"def name must be add; got {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Schützt Iter 5d: `ail deps --workspace` enthält eine Cross-Module-Edge
|
||||
/// `ws_main.main -> ws_lib.add`, weil `ws_main` `ws_lib.add(2,3)` aufruft.
|
||||
#[test]
|
||||
fn deps_workspace_includes_cross_module() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let entry = workspace.join("examples").join("ws_main.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
|
||||
.output()
|
||||
.expect("ail deps --workspace failed to run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail deps --workspace exited non-zero; stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
|
||||
let edges = v["edges"].as_array().expect("edges must be array");
|
||||
|
||||
let has_cross_edge = edges.iter().any(|e| {
|
||||
e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main")
|
||||
&& e.get("from_def").and_then(|s| s.as_str()) == Some("main")
|
||||
&& e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib")
|
||||
&& e.get("to_def").and_then(|s| s.as_str()) == Some("add")
|
||||
});
|
||||
assert!(
|
||||
has_cross_edge,
|
||||
"expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Schützt Iter 5d: `ail diff --workspace` erkennt ein zusätzliches Modul
|
||||
/// im B-Workspace als `added_modules`-Eintrag und exitet mit Code 1.
|
||||
#[test]
|
||||
fn diff_workspace_added_module() {
|
||||
use std::fs;
|
||||
|
||||
fn write_module(dir: &Path, name: &str, body: serde_json::Value) {
|
||||
let p = dir.join(format!("{name}.ail.json"));
|
||||
fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
let dir_a = std::env::temp_dir().join(format!(
|
||||
"ailang_diff_ws_a_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir_b = std::env::temp_dir().join(format!(
|
||||
"ailang_diff_ws_b_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir_a);
|
||||
let _ = fs::remove_dir_all(&dir_b);
|
||||
fs::create_dir_all(&dir_a).unwrap();
|
||||
fs::create_dir_all(&dir_b).unwrap();
|
||||
|
||||
// Beide Workspaces haben ein leeres Modul `root`. B importiert
|
||||
// zusätzlich `extra`. Loader-Konvention: `<root_dir>/<name>.ail.json`,
|
||||
// Modul-Name muss zum Dateinamen passen.
|
||||
let empty_root = serde_json::json!({
|
||||
"schema": "ailang/v0",
|
||||
"name": "root",
|
||||
"imports": [],
|
||||
"defs": [],
|
||||
});
|
||||
let root_with_import = serde_json::json!({
|
||||
"schema": "ailang/v0",
|
||||
"name": "root",
|
||||
"imports": [{ "module": "extra" }],
|
||||
"defs": [],
|
||||
});
|
||||
let extra = serde_json::json!({
|
||||
"schema": "ailang/v0",
|
||||
"name": "extra",
|
||||
"imports": [],
|
||||
"defs": [],
|
||||
});
|
||||
|
||||
write_module(&dir_a, "root", empty_root);
|
||||
write_module(&dir_b, "root", root_with_import);
|
||||
write_module(&dir_b, "extra", extra);
|
||||
|
||||
let entry_a = dir_a.join("root.ail.json");
|
||||
let entry_b = dir_b.join("root.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args([
|
||||
"diff",
|
||||
entry_a.to_str().unwrap(),
|
||||
entry_b.to_str().unwrap(),
|
||||
"--workspace",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.expect("ail diff --workspace failed to run");
|
||||
|
||||
let code = output.status.code().expect("process terminated by signal");
|
||||
assert_eq!(
|
||||
code,
|
||||
1,
|
||||
"expected exit 1 when workspaces differ; stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
|
||||
|
||||
let added = v["added_modules"]
|
||||
.as_array()
|
||||
.expect("added_modules must be array");
|
||||
assert!(
|
||||
added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")),
|
||||
"expected `extra` in added_modules; got {stdout}"
|
||||
);
|
||||
let removed = v["removed_modules"]
|
||||
.as_array()
|
||||
.expect("removed_modules must be array");
|
||||
assert!(
|
||||
removed.is_empty(),
|
||||
"expected removed_modules empty; got {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
|
||||
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
|
||||
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
|
||||
|
||||
Reference in New Issue
Block a user