merge: construct the pushout on the conflict-free path
Survivors are O nodes both diffs kept; a relabeling branch takes over
a survivor's origin tag (identical relabels dedupe); branch insertions
graft after the nearest preceding placed sibling, A before B in the
same slot. Grafted subtrees pull in survivors matched beneath them, so
a branch wrapping a node one level deeper carries it along. Insertion
dedupe is cross-branch only — two equal subtrees inserted by one
branch are two real insertions, a lesson the Figure 2/3 comma count
taught immediately — and each A graft absorbs at most one equal B
insertion so multiplicities add up.

Every node in the result carries an Origin tag (O, A, or B node id) so
span-based synthesis can emit original bytes verbatim.

Assisted-by: Claude Fable 5 via Claude Code
change wumsrxnzwwysmozktyrmnsxvrxxlumtp
commit 2b8e116080bc6aaede39887721fab1919dbe1373
author Alpha Chen <alpha@kejadlen.dev>
date
parent trrzypmv
diff --git a/src/lib.rs b/src/lib.rs
index da1eba0..a69caad 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,6 +3,7 @@ pub mod diff;
 pub mod error;
 pub mod hash;
 pub mod lang;
+pub mod merge;
 pub mod tree;
 pub mod zs;
 
diff --git a/src/merge.rs b/src/merge.rs
new file mode 100644
index 0000000..d126797
--- /dev/null
+++ b/src/merge.rs
@@ -0,0 +1,406 @@
+//! The merge pushout: given f: O→A and g: O→B, construct M so both
+//! branches' edits land exactly once.
+//!
+//! Survivors are O nodes with images under both diffs; relabels apply
+//! to survivors (identical relabels dedupe); inserted branch subtrees
+//! graft at their parent's image after the nearest preceding placed
+//! sibling, A's before B's in the same slot, equal insertions deduped
+//! by subtree hash. Conflict rules refine this construction; the
+//! conflict-free path is built here.
+
+use std::collections::{HashMap, HashSet};
+use std::ops::Range;
+
+use crate::diff::{Matching, Shape, diff};
+use crate::error::Error;
+use crate::tree::{NodeId, Tree};
+
+/// Where a merge node's content comes from: a surviving O node, or a
+/// node contributed by one branch (an insertion, or a relabel target).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Origin {
+    O(NodeId),
+    A(NodeId),
+    B(NodeId),
+}
+
+/// An index into a [`MergedTree`] arena.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MergedId(usize);
+
+#[derive(Debug)]
+struct MergedNode {
+    origin: Origin,
+    children: Vec<MergedId>,
+}
+
+/// The merge result: every node tagged with the tree it came from, so
+/// synthesis can emit original bytes span by span.
+#[derive(Debug)]
+pub struct MergedTree {
+    nodes: Vec<MergedNode>,
+    root: MergedId,
+}
+
+impl MergedTree {
+    /// The root node.
+    pub fn root(&self) -> MergedId {
+        self.root
+    }
+
+    /// The node's origin tag.
+    pub fn origin(&self, id: MergedId) -> Origin {
+        self.node(id).origin
+    }
+
+    /// The node's children, in merged order.
+    pub fn children(&self, id: MergedId) -> &[MergedId] {
+        &self.node(id).children
+    }
+
+    /// The merged tree's shape, resolving each origin against the tree
+    /// it points into.
+    pub fn shape(&self, o: &Tree, a: &Tree, b: &Tree) -> Shape {
+        self.shape_of(o, a, b, self.root)
+    }
+
+    fn shape_of(&self, o: &Tree, a: &Tree, b: &Tree, id: MergedId) -> Shape {
+        let (tree, node) = match self.node(id).origin {
+            Origin::O(n) => (o, n),
+            Origin::A(n) => (a, n),
+            Origin::B(n) => (b, n),
+        };
+        Shape {
+            kind_id: tree.kind_id(node),
+            label: tree.label(node).map(String::from),
+            children: self
+                .node(id)
+                .children
+                .iter()
+                .map(|&child| self.shape_of(o, a, b, child))
+                .collect(),
+        }
+    }
+
+    fn node(&self, id: MergedId) -> &MergedNode {
+        // MergedIds are arena indices handed out by this tree.
+        #[allow(clippy::indexing_slicing)]
+        &self.nodes[id.0]
+    }
+}
+
+/// One merge conflict: the overlapping origin spans and the rule that
+/// fired. Spans are `None` when the rule has no witness in that tree.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Conflict {
+    pub span_o: Option<Range<usize>>,
+    pub span_a: Option<Range<usize>>,
+    pub span_b: Option<Range<usize>>,
+    pub rule: &'static str,
+}
+
+/// A merge either produces an origin-tagged tree or a set of conflicts.
+#[derive(Debug)]
+pub enum MergeOutcome {
+    Merged(MergedTree),
+    Conflicts(Vec<Conflict>),
+}
+
+/// Merges A and B against their common origin O.
+pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
+    let f = diff(o, a);
+    let g = diff(o, b);
+    let mut builder = Builder {
+        o,
+        a,
+        b,
+        f: &f,
+        g: &g,
+        nodes: Vec::new(),
+        placed: HashSet::new(),
+        consumed: HashSet::new(),
+    };
+    let root = builder.build_survivor(o.root());
+    Ok(MergeOutcome::Merged(MergedTree {
+        nodes: builder.nodes,
+        root,
+    }))
+}
+
+/// Which branch a graft comes from, with its diff to O and to-M pull
+/// direction bundled where needed.
+#[derive(Clone, Copy)]
+enum Branch {
+    A,
+    B,
+}
+
+/// A graft placed in a slot: its subtree hash for cross-branch dedupe
+/// and whether it can still absorb a duplicate B insertion.
+struct SlotEntry {
+    hash: u64,
+    id: MergedId,
+    dedupes_b: bool,
+}
+
+struct Builder<'t> {
+    o: &'t Tree,
+    a: &'t Tree,
+    b: &'t Tree,
+    f: &'t Matching,
+    g: &'t Matching,
+    nodes: Vec<MergedNode>,
+    /// Survivors already materialized at their O position.
+    placed: HashSet<NodeId>,
+    /// Survivors pulled inside a graft; their O position skips them.
+    consumed: HashSet<NodeId>,
+}
+
+impl Builder<'_> {
+    fn survives(&self, node: NodeId) -> bool {
+        self.f.image(node).is_some() && self.g.image(node).is_some()
+    }
+
+    /// Materializes a survivor: origin picks the relabeling branch when
+    /// one relabeled it (identical relabels dedupe by taking A's), and
+    /// children interleave the spliced O base with branch grafts.
+    fn build_survivor(&mut self, x: NodeId) -> MergedId {
+        self.placed.insert(x);
+
+        // Unwraps guarded by survives(); the root always survives
+        // because align always pairs roots.
+        let in_a = self.f.image(x);
+        let in_b = self.g.image(x);
+        let origin = match (in_a, in_b) {
+            (Some(ya), _) if self.a.label(ya) != self.o.label(x) => Origin::A(ya),
+            (_, Some(yb)) if self.b.label(yb) != self.o.label(x) => Origin::B(yb),
+            _ => Origin::O(x),
+        };
+
+        // Base: the surviving frontier of x's O children, in O order.
+        let base = self.splice(x);
+        let base_position: HashMap<NodeId, usize> =
+            base.iter().enumerate().map(|(i, &c)| (c, i)).collect();
+
+        // Slots: grafts landing before base[i] live in slots[i]; the
+        // final slot holds end-of-list grafts. Each graft records its
+        // subtree hash so an identical B insertion in the same slot
+        // dedupes against A's.
+        let mut slots: Vec<Vec<SlotEntry>> = Vec::new();
+        slots.resize_with(base.len().saturating_add(1), Vec::new);
+
+        if let Some(ya) = in_a {
+            self.graft_branch(Branch::A, ya, &base_position, &mut slots);
+        }
+        if let Some(yb) = in_b {
+            self.graft_branch(Branch::B, yb, &base_position, &mut slots);
+        }
+
+        // Materialize: slot grafts, then the base survivor they precede.
+        let mut children: Vec<MergedId> = Vec::new();
+        for (i, &c) in base.iter().enumerate() {
+            if let Some(slot) = slots.get(i) {
+                children.extend(slot.iter().map(|entry| entry.id));
+            }
+            if !self.consumed.contains(&c) {
+                children.push(self.build_survivor(c));
+            }
+        }
+        if let Some(slot) = slots.last() {
+            children.extend(slot.iter().map(|entry| entry.id));
+        }
+
+        self.push(MergedNode { origin, children })
+    }
+
+    /// Walks one branch image's children, grafting insertions into the
+    /// slot after the nearest preceding child whose preimage sits in
+    /// the base.
+    ///
+    /// Dedupe is cross-branch only: a B insertion equal (by subtree
+    /// hash) to an A insertion in the same slot is the same edit made
+    /// twice and lands once, but one branch inserting two equal
+    /// subtrees is two real insertions. Each A entry can absorb at
+    /// most one B duplicate so multiplicities still add up.
+    fn graft_branch(
+        &mut self,
+        branch: Branch,
+        parent_image: NodeId,
+        base_position: &HashMap<NodeId, usize>,
+        slots: &mut [Vec<SlotEntry>],
+    ) {
+        let (tree, matching) = match branch {
+            Branch::A => (self.a, self.f),
+            Branch::B => (self.b, self.g),
+        };
+        let mut cursor = 0usize;
+        for &child in tree.children(parent_image) {
+            match matching.preimage(child) {
+                Some(c) => {
+                    if let Some(&pos) = base_position.get(&c) {
+                        cursor = pos.saturating_add(1);
+                    }
+                }
+                None => {
+                    let hash = tree.hash(child);
+                    if matches!(branch, Branch::B)
+                        && let Some(slot) = slots.get_mut(cursor)
+                        && let Some(twin) = slot
+                            .iter_mut()
+                            .find(|entry| entry.dedupes_b && entry.hash == hash)
+                    {
+                        twin.dedupes_b = false;
+                        continue;
+                    }
+                    let id = self.graft(branch, child);
+                    if let Some(slot) = slots.get_mut(cursor) {
+                        slot.push(SlotEntry {
+                            hash,
+                            id,
+                            dedupes_b: matches!(branch, Branch::A),
+                        });
+                    }
+                }
+            }
+        }
+    }
+
+    /// Materializes an inserted branch subtree. Matched descendants
+    /// whose preimage survives are pulled in (marked consumed so their
+    /// O position skips them); already-placed survivors stay where the
+    /// O walk put them.
+    fn graft(&mut self, branch: Branch, node: NodeId) -> MergedId {
+        let (tree, matching) = match branch {
+            Branch::A => (self.a, self.f),
+            Branch::B => (self.b, self.g),
+        };
+        let mut children: Vec<MergedId> = Vec::new();
+        for &child in tree.children(node) {
+            match matching.preimage(child) {
+                None => children.push(self.graft(branch, child)),
+                Some(c) => {
+                    if self.survives(c) && !self.placed.contains(&c) && !self.consumed.contains(&c)
+                    {
+                        self.consumed.insert(c);
+                        children.push(self.build_survivor(c));
+                    }
+                    // Matched but not a survivor (the other branch
+                    // deleted it), or already placed: contributes
+                    // nothing here. The insert-delete conflict rule
+                    // owns the first case.
+                }
+            }
+        }
+        let origin = match branch {
+            Branch::A => Origin::A(node),
+            Branch::B => Origin::B(node),
+        };
+        self.push(MergedNode { origin, children })
+    }
+
+    /// The surviving frontier of x's children: survivors stay, deleted
+    /// interiors are spliced through to their surviving descendants.
+    fn splice(&self, x: NodeId) -> Vec<NodeId> {
+        let mut out = Vec::new();
+        for &c in self.o.children(x) {
+            if self.survives(c) {
+                out.push(c);
+            } else {
+                out.extend(self.splice(c));
+            }
+        }
+        out
+    }
+
+    fn push(&mut self, node: MergedNode) -> MergedId {
+        let id = MergedId(self.nodes.len());
+        self.nodes.push(node);
+        id
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::check;
+    use crate::diff::Shape;
+    use crate::error::Error;
+    use crate::lang::Lang;
+
+    fn parse(src: &str, lang_name: &str) -> Result<Tree, Error> {
+        let lang = Lang::by_name(lang_name).ok_or(Error::UnknownLanguage {
+            path: lang_name.into(),
+        })?;
+        Tree::parse(src, lang)
+    }
+
+    /// Merges and asserts the outcome is structurally the expected
+    /// source, and that the expected source passes the checker.
+    fn assert_merges(lang: &str, o: &str, a: &str, b: &str, expected: &str) -> Result<(), Error> {
+        let o = parse(o, lang)?;
+        let a = parse(a, lang)?;
+        let b = parse(b, lang)?;
+        let expected = parse(expected, lang)?;
+        match merge(&o, &a, &b)? {
+            MergeOutcome::Merged(m) => {
+                assert_eq!(m.shape(&o, &a, &b), Shape::of(&expected));
+            }
+            MergeOutcome::Conflicts(conflicts) => {
+                panic!("expected a clean merge, got conflicts: {conflicts:?}");
+            }
+        }
+        let report = check::check(&o, &a, &b, &expected);
+        assert!(report.is_correct(), "{:?}", report.violations);
+        Ok(())
+    }
+
+    #[test]
+    fn figure_2_3_disjoint_edits_both_land() -> Result<(), Error> {
+        assert_merges(
+            "json",
+            "[1, 2, 3]",
+            "[1, 2, 4, 5, 3]",
+            "[1, 6, 3]",
+            "[1, 6, 4, 5, 3]",
+        )
+    }
+
+    #[test]
+    fn identical_insertions_dedupe() -> Result<(), Error> {
+        assert_merges("json", "[1, 2]", "[1, 9, 2]", "[1, 9, 2]", "[1, 9, 2]")
+    }
+
+    #[test]
+    fn identical_deletions_merge_silently() -> Result<(), Error> {
+        assert_merges("json", "[1, 2]", "[1]", "[1]", "[1]")
+    }
+
+    #[test]
+    fn rename_and_insert_in_different_functions_both_land() -> Result<(), Error> {
+        assert_merges(
+            "rust",
+            "fn a() { x(); }\nfn b() { y(); }",
+            "fn c() { x(); }\nfn b() { y(); }",
+            "fn a() { x(); }\nfn b() { y(); z(); }",
+            "fn c() { x(); }\nfn b() { y(); z(); }",
+        )
+    }
+
+    #[test]
+    fn one_sided_edits_pass_through() -> Result<(), Error> {
+        assert_merges("json", "[1, 2]", "[1, 2, 3]", "[1, 2]", "[1, 2, 3]")?;
+        assert_merges("json", "[1, 2]", "[1, 2]", "[2]", "[2]")
+    }
+
+    #[test]
+    fn a_wrap_pulls_the_survivor_into_the_graft() -> Result<(), Error> {
+        // A wraps the array one level deeper; B leaves it alone.
+        assert_merges(
+            "json",
+            "{\"k\": [1, 2]}",
+            "{\"k\": [[1, 2]]}",
+            "{\"k\": [1, 2]}",
+            "{\"k\": [[1, 2]]}",
+        )
+    }
+}