Skip to main content

lex_vcs/
body_merge.rs

1//! Typed three-way merge over the canonical AST (#838, Tier 4).
2//!
3//! Whole-function merge (`crate::merge`) treats two divergent bodies
4//! for one sig as an indivisible `ModifyModify` conflict. But two
5//! agents editing *different* subtrees of the same function — different
6//! `match` arms, different `let` bindings, the two sides of an `if` —
7//! have made disjoint changes that compose cleanly. Git can't know a
8//! hunk is a match arm; working on the canonical AST, we can.
9//!
10//! [`merge_bodies`] is a pure structural three-way merge of three
11//! [`CExpr`]s (base / ours / theirs). It auto-merges when the two edit
12//! sets touch disjoint subtrees and reports a [`BodyMerge::Conflict`]
13//! when they overlap or when the shapes can't be aligned. It makes no
14//! type judgement — that's the caller's job (the store type-checks the
15//! merged body through the same gate every other write goes through);
16//! a body that merges structurally but not by type is still a conflict.
17//!
18//! The algorithm is the classic recursive three-way merge:
19//!
20//! * `ours == theirs` → both sides made the same edit; take it.
21//! * `base == ours`   → only theirs changed; take theirs.
22//! * `base == theirs` → only ours changed; take ours.
23//! * otherwise both changed differently: recurse into the children if
24//!   all three nodes are the same shape and their children align 1:1,
25//!   merging child-by-child; a conflict in any child, a shape
26//!   mismatch, or a length mismatch that can't be aligned, conflicts
27//!   the whole node.
28//!
29//! Length-changing edits (an inserted arg, a deleted statement) on a
30//! node *both* sides also edited are conservatively a conflict in this
31//! slice: aligning insertions/deletions across two sides is the
32//! `recursive`-strategy territory the op-log notes as future work. The
33//! common, valuable case — disjoint edits to same-arity structures —
34//! merges.
35
36use lex_ast::{Arm, CExpr, RecordField};
37
38/// Result of a three-way body merge.
39#[derive(Debug, Clone, PartialEq)]
40pub enum BodyMerge {
41    /// The two edit sets composed; here is the merged expression.
42    Merged(CExpr),
43    /// The edits overlap (or the shapes can't be aligned). The caller
44    /// falls back to a whole-function `ModifyModify` conflict.
45    Conflict,
46}
47
48/// Three-way merge of a function body. See the module docs.
49pub fn merge_bodies(base: &CExpr, ours: &CExpr, theirs: &CExpr) -> BodyMerge {
50    match merge3(base, ours, theirs) {
51        Some(e) => BodyMerge::Merged(e),
52        None => BodyMerge::Conflict,
53    }
54}
55
56/// The recursive core: `None` is a conflict, `Some` the merged node.
57fn merge3(base: &CExpr, ours: &CExpr, theirs: &CExpr) -> Option<CExpr> {
58    // Fast paths — hold for *every* node kind, leaves included.
59    if ours == theirs {
60        return Some(ours.clone());
61    }
62    if base == ours {
63        return Some(theirs.clone());
64    }
65    if base == theirs {
66        return Some(ours.clone());
67    }
68
69    // Both sides changed this node, differently. Only same-shape nodes
70    // with 1:1-alignable children can be merged further.
71    use CExpr::*;
72    match (base, ours, theirs) {
73        (
74            Call { callee: cb, args: ab },
75            Call { callee: co, args: ao },
76            Call { callee: ct, args: at },
77        ) => Some(Call {
78            callee: Box::new(merge3(cb, co, ct)?),
79            args: merge3_seq(ab, ao, at)?,
80        }),
81
82        (
83            Let { name: nb, ty: tb, value: vb, body: bb },
84            Let { name: no, ty: to, value: vo, body: bo },
85            Let { name: nt, ty: tt, value: vt, body: bt },
86        ) => Some(Let {
87            name: pick3(nb, no, nt)?,
88            ty: pick3(tb, to, tt)?,
89            value: Box::new(merge3(vb, vo, vt)?),
90            body: Box::new(merge3(bb, bo, bt)?),
91        }),
92
93        (
94            Match { scrutinee: sb, arms: amb },
95            Match { scrutinee: so, arms: amo },
96            Match { scrutinee: st, arms: amt },
97        ) => Some(Match {
98            scrutinee: Box::new(merge3(sb, so, st)?),
99            arms: merge3_arms(amb, amo, amt)?,
100        }),
101
102        (
103            Block { statements: sb, result: rb },
104            Block { statements: so, result: ro },
105            Block { statements: st, result: rt },
106        ) => Some(Block {
107            statements: merge3_seq(sb, so, st)?,
108            result: Box::new(merge3(rb, ro, rt)?),
109        }),
110
111        (
112            Constructor { name: nb, args: ab },
113            Constructor { name: no, args: ao },
114            Constructor { name: nt, args: at },
115        ) => Some(Constructor {
116            name: pick3(nb, no, nt)?,
117            args: merge3_seq(ab, ao, at)?,
118        }),
119
120        (
121            TupleLit { items: ib },
122            TupleLit { items: io },
123            TupleLit { items: it },
124        ) => Some(TupleLit { items: merge3_seq(ib, io, it)? }),
125
126        (
127            ListLit { items: ib },
128            ListLit { items: io },
129            ListLit { items: it },
130        ) => Some(ListLit { items: merge3_seq(ib, io, it)? }),
131
132        (
133            RecordLit { fields: fb },
134            RecordLit { fields: fo },
135            RecordLit { fields: ft },
136        ) => Some(RecordLit { fields: merge3_fields(fb, fo, ft)? }),
137
138        (
139            FieldAccess { value: vb, field: fb },
140            FieldAccess { value: vo, field: fo },
141            FieldAccess { value: vt, field: ft },
142        ) => Some(FieldAccess {
143            value: Box::new(merge3(vb, vo, vt)?),
144            field: pick3(fb, fo, ft)?,
145        }),
146
147        (
148            BinOp { op: ob, lhs: lb, rhs: rb },
149            BinOp { op: oo, lhs: lo, rhs: ro },
150            BinOp { op: ot, lhs: lt, rhs: rt },
151        ) => Some(BinOp {
152            op: pick3(ob, oo, ot)?,
153            lhs: Box::new(merge3(lb, lo, lt)?),
154            rhs: Box::new(merge3(rb, ro, rt)?),
155        }),
156
157        (
158            UnaryOp { op: ob, expr: eb },
159            UnaryOp { op: oo, expr: eo },
160            UnaryOp { op: ot, expr: et },
161        ) => Some(UnaryOp {
162            op: pick3(ob, oo, ot)?,
163            expr: Box::new(merge3(eb, eo, et)?),
164        }),
165
166        (
167            Return { value: vb },
168            Return { value: vo },
169            Return { value: vt },
170        ) => Some(Return { value: Box::new(merge3(vb, vo, vt)?) }),
171
172        (
173            Lambda { params: pb, return_type: rtb, effects: eb, effect_row_var: rvb, body: bb },
174            Lambda { params: po, return_type: rto, effects: eo, effect_row_var: rvo, body: bo },
175            Lambda { params: pt, return_type: rtt, effects: et, effect_row_var: rvt, body: bt },
176        ) => Some(Lambda {
177            params: pick3(pb, po, pt)?,
178            return_type: pick3(rtb, rto, rtt)?,
179            effects: pick3(eb, eo, et)?,
180            effect_row_var: pick3(rvb, rvo, rvt)?,
181            body: Box::new(merge3(bb, bo, bt)?),
182        }),
183
184        // Leaves (Literal, Var) reach here only when both sides changed
185        // them differently — an overlap, so a conflict. Mismatched
186        // node kinds are also a conflict: a subtree replaced on one
187        // side and edited on the other can't be composed structurally.
188        _ => None,
189    }
190}
191
192/// Merge three same-length sequences element-wise. A length difference
193/// on a node both sides edited is a conflict (see module docs).
194fn merge3_seq(base: &[CExpr], ours: &[CExpr], theirs: &[CExpr]) -> Option<Vec<CExpr>> {
195    if base.len() != ours.len() || base.len() != theirs.len() {
196        return None;
197    }
198    let mut out = Vec::with_capacity(base.len());
199    for i in 0..base.len() {
200        out.push(merge3(&base[i], &ours[i], &theirs[i])?);
201    }
202    Some(out)
203}
204
205/// Merge three arm lists: same arm count, patterns picked three-way
206/// (an arm whose *pattern* both sides changed differently conflicts),
207/// bodies merged recursively. Two agents editing different arms is the
208/// case this exists for.
209fn merge3_arms(base: &[Arm], ours: &[Arm], theirs: &[Arm]) -> Option<Vec<Arm>> {
210    if base.len() != ours.len() || base.len() != theirs.len() {
211        return None;
212    }
213    let mut out = Vec::with_capacity(base.len());
214    for i in 0..base.len() {
215        out.push(Arm {
216            pattern: pick3(&base[i].pattern, &ours[i].pattern, &theirs[i].pattern)?,
217            body: merge3(&base[i].body, &ours[i].body, &theirs[i].body)?,
218        });
219    }
220    Some(out)
221}
222
223/// Merge three record-field lists: same length and field names in the
224/// same order (record fields are canonicalized to a stable order), each
225/// value merged recursively.
226fn merge3_fields(
227    base: &[RecordField],
228    ours: &[RecordField],
229    theirs: &[RecordField],
230) -> Option<Vec<RecordField>> {
231    if base.len() != ours.len() || base.len() != theirs.len() {
232        return None;
233    }
234    let mut out = Vec::with_capacity(base.len());
235    for i in 0..base.len() {
236        let name = pick3(&base[i].name, &ours[i].name, &theirs[i].name)?;
237        out.push(RecordField {
238            name,
239            value: merge3(&base[i].value, &ours[i].value, &theirs[i].value)?,
240        });
241    }
242    Some(out)
243}
244
245/// Three-way pick for a non-recursive field: same rule as the node
246/// fast path. `None` when both sides changed it to different values.
247fn pick3<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> {
248    if ours == theirs {
249        Some(ours.clone())
250    } else if base == ours {
251        Some(theirs.clone())
252    } else if base == theirs {
253        Some(ours.clone())
254    } else {
255        None
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// Parse a single fn from source and return its canonical body.
264    fn body(src: &str) -> CExpr {
265        let prog = lex_syntax::parse_source(src).expect("parse");
266        let stages = lex_ast::canonicalize_program(&prog);
267        for st in stages {
268            if let lex_ast::Stage::FnDecl(fd) = st {
269                return fd.body;
270            }
271        }
272        panic!("no fn in source");
273    }
274
275    fn merged(base: &str, ours: &str, theirs: &str) -> CExpr {
276        match merge_bodies(&body(base), &body(ours), &body(theirs)) {
277            BodyMerge::Merged(e) => e,
278            BodyMerge::Conflict => panic!("expected a clean merge, got Conflict"),
279        }
280    }
281
282    fn is_conflict(base: &str, ours: &str, theirs: &str) -> bool {
283        matches!(merge_bodies(&body(base), &body(ours), &body(theirs)), BodyMerge::Conflict)
284    }
285
286    #[test]
287    fn one_sided_change_takes_that_side() {
288        // ours changed, theirs didn't → take ours; and vice-versa.
289        let base = "fn f(x :: Int) -> Int { x }\n";
290        let ours = "fn f(x :: Int) -> Int { x + 1 }\n";
291        assert_eq!(merged(base, ours, base), body(ours));
292        assert_eq!(merged(base, base, ours), body(ours));
293    }
294
295    #[test]
296    fn identical_edit_both_sides_is_not_a_conflict() {
297        let base = "fn f(x :: Int) -> Int { x }\n";
298        let same = "fn f(x :: Int) -> Int { x + 1 }\n";
299        assert_eq!(merged(base, same, same), body(same));
300    }
301
302    #[test]
303    fn disjoint_match_arms_auto_merge() {
304        // The headline case: ours edits arm A's body, theirs edits arm
305        // B's body. Git conflicts; we compose.
306        let base = "\
307fn classify(n :: Int) -> Int {
308  match n {
309    0 => 10,
310    _ => 20,
311  }
312}
313";
314        let ours = "\
315fn classify(n :: Int) -> Int {
316  match n {
317    0 => 11,
318    _ => 20,
319  }
320}
321";
322        let theirs = "\
323fn classify(n :: Int) -> Int {
324  match n {
325    0 => 10,
326    _ => 22,
327  }
328}
329";
330        let want = "\
331fn classify(n :: Int) -> Int {
332  match n {
333    0 => 11,
334    _ => 22,
335  }
336}
337";
338        assert_eq!(merged(base, ours, theirs), body(want));
339    }
340
341    #[test]
342    fn same_match_arm_edited_both_sides_conflicts() {
343        let base = "\
344fn classify(n :: Int) -> Int {
345  match n {
346    0 => 10,
347    _ => 20,
348  }
349}
350";
351        let ours = "\
352fn classify(n :: Int) -> Int {
353  match n {
354    0 => 11,
355    _ => 20,
356  }
357}
358";
359        let theirs = "\
360fn classify(n :: Int) -> Int {
361  match n {
362    0 => 12,
363    _ => 20,
364  }
365}
366";
367        assert!(is_conflict(base, ours, theirs));
368    }
369
370    #[test]
371    fn disjoint_let_bindings_auto_merge() {
372        // ours edits the value binding, theirs edits the body — two
373        // different subtrees of the same `let`.
374        let base = "fn f(x :: Int) -> Int {\n  let y := x\n  y\n}\n";
375        let ours = "fn f(x :: Int) -> Int {\n  let y := x + 1\n  y\n}\n";
376        let theirs = "fn f(x :: Int) -> Int {\n  let y := x\n  y + 100\n}\n";
377        let want = "fn f(x :: Int) -> Int {\n  let y := x + 1\n  y + 100\n}\n";
378        assert_eq!(merged(base, ours, theirs), body(want));
379    }
380
381    #[test]
382    fn disjoint_binop_operands_auto_merge() {
383        // ours edits the lhs, theirs edits the rhs.
384        let base = "fn f(x :: Int) -> Int { x + x }\n";
385        let ours = "fn f(x :: Int) -> Int { (x + 1) + x }\n";
386        let theirs = "fn f(x :: Int) -> Int { x + (x + 2) }\n";
387        let want = "fn f(x :: Int) -> Int { (x + 1) + (x + 2) }\n";
388        assert_eq!(merged(base, ours, theirs), body(want));
389    }
390
391    #[test]
392    fn a_length_changing_edit_on_a_both_edited_node_conflicts() {
393        // ours appends an arg to a call; theirs edits an existing arg.
394        // Aligning an insertion against an edit is out of scope for
395        // this slice → conflict (safe fallback).
396        let base = "fn f(x :: Int) -> Int { g(x, x) }\nfn g(a :: Int, b :: Int) -> Int { a }\n";
397        let ours = "fn f(x :: Int) -> Int { g(x, x, x) }\nfn g(a :: Int, b :: Int) -> Int { a }\n";
398        let theirs = "fn f(x :: Int) -> Int { g(x, x + 9) }\nfn g(a :: Int, b :: Int) -> Int { a }\n";
399        assert!(is_conflict(base, ours, theirs));
400    }
401
402    #[test]
403    fn kind_replaced_one_side_edited_other_conflicts() {
404        // ours replaces the whole body with a different node kind;
405        // theirs edits inside the original → can't compose.
406        let base = "fn f(x :: Int) -> Int { x + x }\n";
407        let ours = "fn f(x :: Int) -> Int { 42 }\n";
408        let theirs = "fn f(x :: Int) -> Int { x + (x + 1) }\n";
409        assert!(is_conflict(base, ours, theirs));
410    }
411}