rules: flag relabel-relabel and insert-insert conflicts
Merge now runs conflict rules over the two edit scripts before
building the pushout: both branches relabeling one O node differently,
or inserting different content at the same slot, stops the merge with
the overlapping origin spans.
The design sketched every rule as a pairwise (edit-A, edit-B) check,
but insert-insert had to be aggregate per slot: a branch inserts a
*sequence* of subtrees (a JSON insert is a comma and a value), and
pairwise hash comparison flags the A-comma against the B-value even
when both branches made the identical insertion. Slots compare their
whole inserted-sequence hashes instead; equal sequences dedupe.
Assisted-by: Claude Fable 5 via Claude Code
diff --git a/src/lib.rs b/src/lib.rs
index a69caad..c94428d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod error;
pub mod hash;
pub mod lang;
pub mod merge;
+mod rules;
pub mod tree;
pub mod zs;
diff --git a/src/merge.rs b/src/merge.rs
index d126797..1939597 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -11,8 +11,9 @@
use std::collections::{HashMap, HashSet};
use std::ops::Range;
-use crate::diff::{Matching, Shape, diff};
+use crate::diff::{Matching, Shape, diff, edits};
use crate::error::Error;
+use crate::rules;
use crate::tree::{NodeId, Tree};
/// Where a merge node's content comes from: a surviving O node, or a
@@ -110,6 +111,19 @@ pub enum MergeOutcome {
pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
let f = diff(o, a);
let g = diff(o, b);
+
+ let ctx = rules::Ctx {
+ o,
+ a,
+ b,
+ f: &f,
+ g: &g,
+ };
+ let conflicts = rules::conflicts(&ctx, &edits(o, a, &f), &edits(o, b, &g));
+ if !conflicts.is_empty() {
+ return Ok(MergeOutcome::Conflicts(conflicts));
+ }
+
let mut builder = Builder {
o,
a,
@@ -392,6 +406,74 @@ mod tests {
assert_merges("json", "[1, 2]", "[1, 2]", "[2]", "[2]")
}
+ fn assert_conflicts(
+ lang: &str,
+ o: &str,
+ a: &str,
+ b: &str,
+ rule: &str,
+ ) -> Result<Vec<Conflict>, Error> {
+ let o = parse(o, lang)?;
+ let a = parse(a, lang)?;
+ let b = parse(b, lang)?;
+ match merge(&o, &a, &b)? {
+ MergeOutcome::Merged(m) => {
+ panic!("expected conflicts, got a merge: {:?}", m.shape(&o, &a, &b));
+ }
+ MergeOutcome::Conflicts(conflicts) => {
+ assert!(!conflicts.is_empty());
+ assert!(
+ conflicts.iter().all(|c| c.rule == rule),
+ "expected only {rule}: {conflicts:?}"
+ );
+ Ok(conflicts)
+ }
+ }
+ }
+
+ #[test]
+ fn conflicting_renames_conflict() -> Result<(), Error> {
+ let conflicts = assert_conflicts(
+ "rust",
+ "fn a() {}",
+ "fn b() {}",
+ "fn c() {}",
+ "relabel-relabel",
+ )?;
+ let a = parse("fn b() {}", "rust")?;
+ let witnessed = conflicts.iter().any(|c| {
+ c.span_a
+ .clone()
+ .and_then(|span| a.source_slice(span))
+ .is_some_and(|text| text == "b")
+ });
+ assert!(witnessed);
+ Ok(())
+ }
+
+ #[test]
+ fn identical_renames_still_merge() -> Result<(), Error> {
+ assert_merges("rust", "fn a() {}", "fn b() {}", "fn b() {}", "fn b() {}")
+ }
+
+ #[test]
+ fn conflicting_insertions_at_one_slot_conflict() -> Result<(), Error> {
+ assert_conflicts(
+ "rust",
+ "fn f() { x(); }",
+ "fn f() { x(); y(); }",
+ "fn f() { x(); z(); }",
+ "insert-insert",
+ )?;
+ Ok(())
+ }
+
+ #[test]
+ fn different_value_insertions_at_one_slot_conflict() -> Result<(), Error> {
+ assert_conflicts("json", "[1]", "[1, 9]", "[1, 8]", "insert-insert")?;
+ Ok(())
+ }
+
#[test]
fn a_wrap_pulls_the_survivor_into_the_graft() -> Result<(), Error> {
// A wraps the array one level deeper; B leaves it alone.
diff --git a/src/rules.rs b/src/rules.rs
new file mode 100644
index 0000000..da3c100
--- /dev/null
+++ b/src/rules.rs
@@ -0,0 +1,147 @@
+//! Conflict rules: each rule inspects an (edit-from-A, edit-from-B)
+//! pair with context and may yield a [`Conflict`]. The pushout applies
+//! common edits once; these rules name the combinations that cannot
+//! merge.
+
+use std::collections::HashMap;
+
+use crate::diff::{Edit, Matching};
+use crate::merge::Conflict;
+use crate::tree::{NodeId, Tree};
+
+/// Everything a rule may consult: the three trees and the two diffs.
+pub(crate) struct Ctx<'t> {
+ pub o: &'t Tree,
+ pub a: &'t Tree,
+ pub b: &'t Tree,
+ pub f: &'t Matching,
+ pub g: &'t Matching,
+}
+
+/// A conflict rule over one (edit-from-A, edit-from-B) pair.
+type PairRule = fn(&Edit, &Edit, &Ctx) -> Option<Conflict>;
+
+const PAIR_RULES: &[PairRule] = &[relabel_relabel];
+
+/// Runs the pairwise rules over every cross-branch edit pair and the
+/// aggregate rules over the whole scripts, deduplicating identical
+/// findings.
+///
+/// insert-insert is aggregate rather than pairwise: a branch's
+/// insertion at one slot is the *sequence* of subtrees it put there
+/// (a JSON insert is a comma and a value), and comparing elements
+/// cross-wise would flag two branches making the identical multi-node
+/// insertion.
+pub(crate) fn conflicts(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit]) -> Vec<Conflict> {
+ let mut found: Vec<Conflict> = Vec::new();
+ for ea in edits_a {
+ for eb in edits_b {
+ for rule in PAIR_RULES {
+ if let Some(conflict) = rule(ea, eb, ctx)
+ && !found.contains(&conflict)
+ {
+ found.push(conflict);
+ }
+ }
+ }
+ }
+ insert_insert(ctx, edits_a, edits_b, &mut found);
+ found
+}
+
+/// relabel-relabel: both branches relabeled the same O node, to
+/// different labels. Identical relabels are one edit made twice and
+/// merge silently.
+fn relabel_relabel(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
+ let (Edit::Relabel(xa, ya), Edit::Relabel(xb, yb)) = (ea, eb) else {
+ return None;
+ };
+ if xa != xb || ctx.a.label(*ya) == ctx.b.label(*yb) {
+ return None;
+ }
+ Some(Conflict {
+ span_o: Some(ctx.o.span(*xa)),
+ span_a: Some(ctx.a.span(*ya)),
+ span_b: Some(ctx.b.span(*yb)),
+ rule: "relabel-relabel",
+ })
+}
+
+/// insert-insert: both branches inserted at the same slot — same
+/// parent image, same nearest preceding matched sibling — but the
+/// inserted subtree sequences differ. Equal sequences at the same
+/// slot are one edit made twice and dedupe instead.
+fn insert_insert(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit], found: &mut Vec<Conflict>) {
+ let slots_a = insert_slots(ctx.a, ctx.f, edits_a);
+ let slots_b = insert_slots(ctx.b, ctx.g, edits_b);
+ for (anchor, seq_a) in &slots_a {
+ let Some(seq_b) = slots_b.get(anchor) else {
+ continue;
+ };
+ let hashes = |tree: &Tree, seq: &[NodeId]| -> Vec<u64> {
+ seq.iter().map(|&n| tree.hash(n)).collect()
+ };
+ if hashes(ctx.a, seq_a) == hashes(ctx.b, seq_b) {
+ continue;
+ }
+ let conflict = Conflict {
+ span_o: Some(ctx.o.span(anchor.0)),
+ span_a: covering_span(ctx.a, seq_a),
+ span_b: covering_span(ctx.b, seq_b),
+ rule: "insert-insert",
+ };
+ if !found.contains(&conflict) {
+ found.push(conflict);
+ }
+ }
+}
+
+/// Groups a branch's top-level insertions by slot, in sibling order
+/// (the edit script lists inserts in pre-order, so same-slot siblings
+/// arrive left to right).
+fn insert_slots(
+ tree: &Tree,
+ matching: &Matching,
+ script: &[Edit],
+) -> HashMap<(NodeId, Option<NodeId>), Vec<NodeId>> {
+ let mut slots: HashMap<(NodeId, Option<NodeId>), Vec<NodeId>> = HashMap::new();
+ for edit in script {
+ let Edit::Insert(node) = edit else {
+ continue;
+ };
+ if let Some(anchor) = insert_anchor(tree, matching, *node) {
+ slots.entry(anchor).or_default().push(*node);
+ }
+ }
+ slots
+}
+
+/// The byte range covering a sequence of nodes.
+fn covering_span(tree: &Tree, seq: &[NodeId]) -> Option<std::ops::Range<usize>> {
+ let start = seq.iter().map(|&n| tree.span(n).start).min()?;
+ let end = seq.iter().map(|&n| tree.span(n).end).max()?;
+ Some(start..end)
+}
+
+/// The slot an inserted node lands in: its parent's O preimage plus
+/// the O preimage of the nearest preceding matched sibling. `None`
+/// for nested insertions (their parent is inserted too — they ride
+/// along with the top-level graft).
+fn insert_anchor(
+ tree: &Tree,
+ matching: &Matching,
+ node: NodeId,
+) -> Option<(NodeId, Option<NodeId>)> {
+ let parent = tree.parent(node)?;
+ let parent_o = matching.preimage(parent)?;
+ let mut left = None;
+ for &sibling in tree.children(parent) {
+ if sibling == node {
+ break;
+ }
+ if let Some(preimage) = matching.preimage(sibling) {
+ left = Some(preimage);
+ }
+ }
+ Some((parent_o, left))
+}