Skip to main content

lex_vcs/
compute_diff.rs

1//! AST-level structural diff between two sets of `FnDecl`s.
2//!
3//! Moved from `lex-cli/src/diff.rs` so both the CLI (`lex diff`
4//! command) and the HTTP API (`lex serve`) can compute a [`DiffReport`]
5//! without introducing a circular dependency. `lex-vcs` is the right
6//! home because it already owns `DiffReport` and `diff_to_ops`.
7
8use crate::diff_report::{
9    AddRemove, BodyPatch, DiffReport, EffectChanges, Modified, Renamed,
10};
11use lex_ast::{stage_canonical_hash_hex, CExpr, Effect, EffectArg, FnDecl, Stage, TypeExpr};
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13
14/// Compute a structural diff between two named fn-decl maps.
15///
16/// `body_patches` controls whether body-level expression diffs are
17/// emitted inside each `Modified` entry. Pass `true` for rich output
18/// (CLI / review); `false` for a signature-only diff (faster).
19pub fn compute_diff(
20    a: &BTreeMap<String, FnDecl>,
21    b: &BTreeMap<String, FnDecl>,
22    body_patches: bool,
23) -> DiffReport {
24    let mut report = DiffReport::default();
25    let names_a: BTreeSet<&String> = a.keys().collect();
26    let names_b: BTreeSet<&String> = b.keys().collect();
27
28    let only_a: Vec<&String> = names_a.difference(&names_b).copied().collect();
29    let only_b: Vec<&String> = names_b.difference(&names_a).copied().collect();
30
31    // Detect renames: for each name only-in-A, check if any only-in-B
32    // has a body whose canonical-AST hash matches (modulo the fn
33    // name itself). sig_id over the FnDecl with name normalized
34    // serves as the structural-identity key.
35    //
36    // `body_hash` clones the FnDecl and does a full canonical
37    // serialize + SHA-256 — not O(1). The naive nested loop below
38    // used to recompute `body_hash(fb)` on every outer (`only_a`)
39    // iteration even though `fb` never changes, making this
40    // O(|only_a| * |only_b|) hashes instead of O(|only_a| + |only_b|).
41    // Harmless at the tiny scale these sets used to have, but
42    // `pkg_publish_handler` calls this once per uploaded file with
43    // `only_a` sized to the *entire* tenant's historical function
44    // set — that combination hung the server for 38+ minutes on a
45    // real publish (alpibrusl/lex-lang#813). Precomputing each
46    // `only_b` hash once turns the nested loop into O(1) lookups.
47    let mut hash_to_bs: HashMap<String, Vec<&String>> = HashMap::new();
48    for &bn in &only_b {
49        hash_to_bs.entry(body_hash(&b[bn])).or_default().push(bn);
50    }
51    let mut renamed_pairs: Vec<(String, String)> = Vec::new();
52    let mut consumed_a: BTreeSet<String> = BTreeSet::new();
53    let mut consumed_b: BTreeSet<String> = BTreeSet::new();
54    for &an in &only_a {
55        let fa = &a[an];
56        let fa_norm_id = body_hash(fa);
57        let Some(candidates) = hash_to_bs.get(&fa_norm_id) else { continue };
58        let Some(&bn) = candidates.iter().find(|bn| !consumed_b.contains(**bn)) else { continue };
59        renamed_pairs.push((an.clone(), bn.clone()));
60        consumed_a.insert(an.clone());
61        consumed_b.insert(bn.clone());
62    }
63
64    for &n in &only_a {
65        if consumed_a.contains(n) { continue; }
66        let fd = &a[n];
67        report.removed.push(AddRemove {
68            name: n.clone(),
69            signature: render_signature(fd),
70            old_sig_id: lex_ast::sig_id(&Stage::FnDecl(fd.clone())),
71        });
72    }
73    for &n in &only_b {
74        if consumed_b.contains(n) { continue; }
75        let fd = &b[n];
76        report.added.push(AddRemove {
77            name: n.clone(),
78            signature: render_signature(fd),
79            old_sig_id: None,
80        });
81    }
82    for (an, bn) in &renamed_pairs {
83        let fa = &a[an];
84        let fd = &b[bn];
85        report.renamed.push(Renamed {
86            from: an.clone(),
87            to: bn.clone(),
88            signature: render_signature(fd),
89            old_sig_id: lex_ast::sig_id(&Stage::FnDecl(fa.clone())).unwrap_or_default(),
90        });
91    }
92
93    // Modified: same name on both sides; compare bodies.
94    for n in names_a.intersection(&names_b) {
95        let fa = &a[*n];
96        let fb = &b[*n];
97        let sig_a = render_signature(fa);
98        let sig_b = render_signature(fb);
99        if body_hash(fa) == body_hash(fb) && sig_a == sig_b { continue; }
100
101        let patches = if body_patches {
102            let mut patches = Vec::new();
103            diff_expr(&fa.body, &fb.body, "body", &mut patches, 4);
104            patches
105        } else { Vec::new() };
106
107        let effect_changes = effect_diff(&fa.effects, &fb.effects);
108        report.modified.push(Modified {
109            name: (*n).clone(),
110            signature_before: sig_a.clone(),
111            signature_after: sig_b.clone(),
112            signature_changed: sig_a != sig_b,
113            effect_changes,
114            body_patches: patches,
115            old_sig_id: lex_ast::sig_id(&Stage::FnDecl(fa.clone())).unwrap_or_default(),
116        });
117    }
118    report
119}
120
121/// Hash of the function's structural identity, used for rename
122/// detection. Excludes the function's name (so `fn foo -> Int { 1 }`
123/// and `fn bar -> Int { 1 }` share a hash) but includes everything
124/// else: params, effects, return type, body.
125fn body_hash(fd: &FnDecl) -> String {
126    let mut anon = fd.clone();
127    anon.name = String::new();
128    let stage = Stage::FnDecl(anon);
129    stage_canonical_hash_hex(&stage)
130}
131
132/// Walk two CExprs in parallel; record the first divergence at each
133/// child position. `depth` caps recursion so a tiny per-fn diff
134/// doesn't degenerate into hundreds of micro-changes.
135fn diff_expr(a: &CExpr, b: &CExpr, path: &str, out: &mut Vec<BodyPatch>, depth: u32) {
136    if depth == 0 { return; }
137    let kind_a = node_kind(a);
138    let kind_b = node_kind(b);
139    if kind_a != kind_b {
140        out.push(BodyPatch {
141            op: "Replace".into(), node_path: path.into(),
142            from_kind: kind_a.into(), to_kind: kind_b.into(),
143        });
144        return;
145    }
146    // Same kind: recurse into structurally-equivalent children.
147    match (a, b) {
148        (CExpr::Literal { value: la }, CExpr::Literal { value: lb }) => {
149            if la != lb {
150                out.push(BodyPatch {
151                    op: "Replace".into(), node_path: path.into(),
152                    from_kind: "Literal".into(), to_kind: "Literal".into(),
153                });
154            }
155        }
156        (CExpr::Var { name: na }, CExpr::Var { name: nb }) => {
157            if na != nb {
158                out.push(BodyPatch {
159                    op: "Replace".into(), node_path: path.into(),
160                    from_kind: format!("Var({na})"), to_kind: format!("Var({nb})"),
161                });
162            }
163        }
164        (CExpr::Call { callee: ca, args: aa },
165         CExpr::Call { callee: cb, args: ab }) => {
166            diff_expr(ca, cb, &format!("{path}.callee"), out, depth - 1);
167            diff_args(aa, ab, &format!("{path}.args"), out, depth);
168        }
169        (CExpr::Let { name: na, value: va, body: ba, .. },
170         CExpr::Let { name: nb, value: vb, body: bb, .. }) => {
171            if na != nb {
172                out.push(BodyPatch {
173                    op: "Replace".into(),
174                    node_path: format!("{path}.name"),
175                    from_kind: format!("Let({na})"),
176                    to_kind:   format!("Let({nb})"),
177                });
178            }
179            diff_expr(va, vb, &format!("{path}.value"), out, depth - 1);
180            diff_expr(ba, bb, &format!("{path}.body"),  out, depth - 1);
181        }
182        (CExpr::Match { scrutinee: sa, arms: ams },
183         CExpr::Match { scrutinee: sb, arms: bms }) => {
184            diff_expr(sa, sb, &format!("{path}.scrutinee"), out, depth - 1);
185            let n = ams.len().max(bms.len());
186            for i in 0..n {
187                let p = format!("{path}.arms[{i}]");
188                match (ams.get(i), bms.get(i)) {
189                    (Some(a), Some(b)) =>
190                        diff_expr(&a.body, &b.body, &p, out, depth - 1),
191                    (Some(_), None) => out.push(BodyPatch {
192                        op: "Deleted".into(), node_path: p,
193                        from_kind: "MatchArm".into(), to_kind: "(removed)".into(),
194                    }),
195                    (None, Some(_)) => out.push(BodyPatch {
196                        op: "Inserted".into(), node_path: p,
197                        from_kind: "(none)".into(), to_kind: "MatchArm".into(),
198                    }),
199                    (None, None) => break,
200                }
201            }
202        }
203        (CExpr::Block { statements: sa, result: ra },
204         CExpr::Block { statements: sb, result: rb }) => {
205            diff_args(sa, sb, &format!("{path}.statements"), out, depth);
206            diff_expr(ra, rb, &format!("{path}.result"), out, depth - 1);
207        }
208        (CExpr::FieldAccess { value: va, field: fa },
209         CExpr::FieldAccess { value: vb, field: fb }) => {
210            diff_expr(va, vb, &format!("{path}.value"), out, depth - 1);
211            if fa != fb {
212                out.push(BodyPatch {
213                    op: "Replace".into(), node_path: format!("{path}.field"),
214                    from_kind: format!("Field({fa})"), to_kind: format!("Field({fb})"),
215                });
216            }
217        }
218        (CExpr::Lambda { body: ba, .. }, CExpr::Lambda { body: bb, .. }) => {
219            diff_expr(ba, bb, &format!("{path}.body"), out, depth - 1);
220        }
221        // For shapes we don't unfold further, mark the node itself
222        // as edited (same kind, content differs) — finer detail can
223        // come in a follow-up.
224        _ => {
225            out.push(BodyPatch {
226                op: "Replace".into(), node_path: path.into(),
227                from_kind: kind_a.into(), to_kind: kind_b.into(),
228            });
229        }
230    }
231}
232
233fn diff_args(a: &[CExpr], b: &[CExpr], path: &str, out: &mut Vec<BodyPatch>, depth: u32) {
234    let n = a.len().max(b.len());
235    for i in 0..n {
236        let p = format!("{path}[{i}]");
237        match (a.get(i), b.get(i)) {
238            (Some(x), Some(y)) => diff_expr(x, y, &p, out, depth - 1),
239            (Some(x), None) => out.push(BodyPatch {
240                op: "Deleted".into(), node_path: p,
241                from_kind: node_kind(x).into(), to_kind: "(removed)".into(),
242            }),
243            (None, Some(y)) => out.push(BodyPatch {
244                op: "Inserted".into(), node_path: p,
245                from_kind: "(none)".into(), to_kind: node_kind(y).into(),
246            }),
247            (None, None) => break,
248        }
249    }
250}
251
252fn node_kind(e: &CExpr) -> &'static str {
253    match e {
254        CExpr::Literal { .. }     => "Literal",
255        CExpr::Var { .. }         => "Var",
256        CExpr::Call { .. }        => "Call",
257        CExpr::Let { .. }         => "Let",
258        CExpr::Match { .. }       => "Match",
259        CExpr::Block { .. }       => "Block",
260        CExpr::Constructor { .. } => "Constructor",
261        CExpr::RecordLit { .. }   => "RecordLit",
262        CExpr::TupleLit { .. }    => "TupleLit",
263        CExpr::ListLit { .. }     => "ListLit",
264        CExpr::FieldAccess { .. } => "FieldAccess",
265        CExpr::Lambda { .. }      => "Lambda",
266        CExpr::BinOp { .. }       => "BinOp",
267        CExpr::UnaryOp { .. }     => "UnaryOp",
268        CExpr::Return { .. }      => "Return",
269    }
270}
271
272pub fn render_signature(fd: &FnDecl) -> String {
273    let params: Vec<String> = fd.params.iter()
274        .map(|p| format!("{} :: {}", p.name, render_type(&p.ty))).collect();
275    let eff = if fd.effects.is_empty() { String::new() } else {
276        let labels: Vec<String> = fd.effects.iter().map(effect_label).collect();
277        format!("[{}] ", labels.join(", "))
278    };
279    format!("fn {}({}) -> {}{}", fd.name, params.join(", "),
280        eff, render_type(&fd.return_type))
281}
282
283/// Render an effect with its arg if present: `fs_read("/tmp")`,
284/// `net("api.example.com")`, or just `io`. Used by both signature
285/// rendering and effect-diff so the same string identifies the
286/// same effect in either context.
287pub fn effect_label(e: &Effect) -> String {
288    match &e.arg {
289        Some(EffectArg::Str { value })   => format!("{}({:?})", e.name, value),
290        Some(EffectArg::Int { value })   => format!("{}({})",   e.name, value),
291        Some(EffectArg::Ident { value }) => format!("{}({})",   e.name, value),
292        None => e.name.clone(),
293    }
294}
295
296/// Set-style diff over two effect lists. Order-insensitive within
297/// the lists; ordering of the output is sorted-by-label so the
298/// JSON is stable.
299fn effect_diff(a: &[Effect], b: &[Effect]) -> EffectChanges {
300    let labels_a: BTreeSet<String> = a.iter().map(effect_label).collect();
301    let labels_b: BTreeSet<String> = b.iter().map(effect_label).collect();
302    let added:   Vec<String> = labels_b.difference(&labels_a).cloned().collect();
303    let removed: Vec<String> = labels_a.difference(&labels_b).cloned().collect();
304    EffectChanges {
305        before:  labels_a.into_iter().collect(),
306        after:   labels_b.into_iter().collect(),
307        added,
308        removed,
309    }
310}
311
312fn render_type(t: &TypeExpr) -> String {
313    match t {
314        TypeExpr::Named { name, args } => {
315            if args.is_empty() { name.clone() }
316            else {
317                let parts: Vec<String> = args.iter().map(render_type).collect();
318                format!("{name}[{}]", parts.join(", "))
319            }
320        }
321        TypeExpr::Tuple { items } => {
322            let parts: Vec<String> = items.iter().map(render_type).collect();
323            format!("({})", parts.join(", "))
324        }
325        TypeExpr::Record { fields } => {
326            let parts: Vec<String> = fields.iter()
327                .map(|f| format!("{} :: {}", f.name, render_type(&f.ty))).collect();
328            format!("{{ {} }}", parts.join(", "))
329        }
330        TypeExpr::RecordWithSpreads { spreads, fields } => {
331            let mut parts: Vec<String> = spreads.iter().map(|s| format!("...{}", s)).collect();
332            parts.extend(fields.iter().map(|f| format!("{} :: {}", f.name, render_type(&f.ty))));
333            format!("{{ {} }}", parts.join(", "))
334        }
335        TypeExpr::Function { params, effects, effect_row_var, ret } => {
336            let parts: Vec<String> = params.iter().map(render_type).collect();
337            let eff = if effects.is_empty() && effect_row_var.is_none() { String::new() } else {
338                let mut names: Vec<String> = effects.iter().map(|e| e.name.clone()).collect();
339                if let Some(v) = effect_row_var { names.push(format!("| {}", v)); }
340                format!("[{}] ", names.join(", "))
341            };
342            format!("({}) -> {}{}", parts.join(", "), eff, render_type(ret))
343        }
344        TypeExpr::Union { variants } => variants.iter().map(|v| match &v.payload {
345            Some(p) => format!("{}({})", v.name, render_type(p)),
346            None => v.name.clone(),
347        }).collect::<Vec<_>>().join(" | "),
348        TypeExpr::Refined { base, binding, .. } => {
349            // Render compactly: `Base{x | …}`. The full predicate is
350            // captured in the canonical AST and contributes to
351            // OpId hashing via lex-vcs's content-addressing — this
352            // string is for diagnostics only. (#209 slice 1)
353            format!("{}{{{} | …}}", render_type(base), binding)
354        }
355    }
356}