LeVCS/crates/levcs-merge/src/tree_sitter_handler.rs

949 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Tree-sitter handler (§6.3.2).
//!
//! Parses base, ours, and theirs with a tree-sitter grammar and performs a
//! structural three-way merge over the named children of the source root,
//! recursing into nested blocks when both sides modify the same outer
//! block (e.g., two engineers each adding a different method to the same
//! `impl` / `class`).
//!
//! Algorithm:
//! 1. Parse all three. If any parse produces a tree with errors, return
//! `NotApplicable`; the cascade then falls through to the textual
//! handler (§6.3.3).
//! 2. Extract top-level named children. Each child is identified by
//! `(kind, name)` where `name` comes from the grammar's `name` /
//! `declarator` field; or, for nodes without a recoverable name, by
//! `(kind, blake3(text))` so that identical anonymous items collapse.
//! 3. Three-way merge over the union of identities: independent
//! additions/deletions/modifications are auto-resolved.
//! 4. When both sides modify the same identity AND the language is one
//! whose containers don't depend on indentation, attempt **recursive
//! merge**: descend into that block's body, treat its named children
//! as the new identity space, run the same algorithm. Only commit
//! the recursive result when the outer "frame" (everything in the
//! block that isn't the body) is identical between ours and theirs
//! *and* the inner merge is conflict-free. Anything else falls back
//! to a block-level conflict.
//! 5. Reconstruct the file by emitting kept blocks in ours's source
//! order, then appending blocks added only by theirs. Top-level
//! blocks are joined with a blank line; recursive splices preserve
//! the inter-child glue (whitespace, indentation) of ours's body.
//!
//! Conflict granularity is the smallest named container in which the
//! conflict is genuinely unresolvable. Concurrent edits inside the same
//! method body still produce a single block-level conflict at that
//! method, since method bodies generally lack the structural identity
//! recursion needs.
use std::collections::HashSet;
use std::ops::Range;
use std::path::Path;
use tree_sitter::{Language, Node, Parser, Tree};
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
/// One of the languages shipped with the built-in handler set (§6.3.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lang {
Rust,
Python,
JavaScript,
TypeScript,
Go,
C,
Cpp,
Java,
Ruby,
Shell,
}
impl Lang {
pub fn handler_name(self) -> &'static str {
match self {
Lang::Rust => "tree-sitter:rust",
Lang::Python => "tree-sitter:python",
Lang::JavaScript => "tree-sitter:javascript",
Lang::TypeScript => "tree-sitter:typescript",
Lang::Go => "tree-sitter:go",
Lang::C => "tree-sitter:c",
Lang::Cpp => "tree-sitter:cpp",
Lang::Java => "tree-sitter:java",
Lang::Ruby => "tree-sitter:ruby",
Lang::Shell => "tree-sitter:shell",
}
}
pub fn from_extension(ext: &str) -> Option<Lang> {
match ext {
"rs" => Some(Lang::Rust),
"py" => Some(Lang::Python),
"js" | "mjs" => Some(Lang::JavaScript),
"ts" => Some(Lang::TypeScript),
"go" => Some(Lang::Go),
"c" | "h" => Some(Lang::C),
"cpp" | "cc" | "hpp" => Some(Lang::Cpp),
"java" => Some(Lang::Java),
"rb" => Some(Lang::Ruby),
"sh" | "bash" => Some(Lang::Shell),
_ => None,
}
}
fn language(self) -> Language {
match self {
Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
Lang::Python => tree_sitter_python::LANGUAGE.into(),
Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
Lang::Go => tree_sitter_go::LANGUAGE.into(),
Lang::C => tree_sitter_c::LANGUAGE.into(),
Lang::Cpp => tree_sitter_cpp::LANGUAGE.into(),
Lang::Java => tree_sitter_java::LANGUAGE.into(),
Lang::Ruby => tree_sitter_ruby::LANGUAGE.into(),
Lang::Shell => tree_sitter_bash::LANGUAGE.into(),
}
}
pub fn all() -> &'static [Lang] {
&[
Lang::Rust,
Lang::Python,
Lang::JavaScript,
Lang::TypeScript,
Lang::Go,
Lang::C,
Lang::Cpp,
Lang::Java,
Lang::Ruby,
Lang::Shell,
]
}
/// Brace-delimited languages can be safely re-emitted block-by-block
/// without breaking the parse, so recursion is sound. Indentation-
/// sensitive grammars (Python, Ruby) and the shell can't tolerate the
/// child-list reformatting recursion does, so we keep them at
/// top-level granularity. The on-wire/test behaviour for these is
/// unchanged from the previous flat implementation.
fn supports_recursion(self) -> bool {
matches!(
self,
Lang::Rust
| Lang::JavaScript
| Lang::TypeScript
| Lang::Go
| Lang::C
| Lang::Cpp
| Lang::Java
)
}
}
pub struct TreeSitterHandler {
lang: Lang,
}
impl TreeSitterHandler {
pub fn new(lang: Lang) -> Self {
Self { lang }
}
fn parse(&self, src: &[u8]) -> Option<Tree> {
let mut p = Parser::new();
p.set_language(&self.lang.language()).ok()?;
let tree = p.parse(src, None)?;
if tree.root_node().has_error() {
return None;
}
Some(tree)
}
}
#[derive(Clone, Debug)]
struct Block {
kind: String,
key: BlockKey,
range: Range<usize>,
text: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum BlockKey {
Named(String, String),
Anon(String, [u8; 32]),
}
/// State threaded through every level of recursion: the parsed trees and
/// raw source bytes for each side, plus the language. The recursive
/// helpers use it to find a Block's original Node (so they can read its
/// `body` field) without having to thread Node references — Node
/// lifetimes are tied to a specific Tree borrow and don't compose well
/// with the Block struct.
struct RecurseCtx<'a> {
lang: Lang,
base_tree: &'a Tree,
ours_tree: &'a Tree,
theirs_tree: &'a Tree,
base_src: &'a [u8],
ours_src: &'a [u8],
theirs_src: &'a [u8],
}
fn extract_name(node: Node, src: &[u8]) -> Option<String> {
if let Some(n) = node.child_by_field_name("name") {
if let Ok(t) = n.utf8_text(src) {
return Some(t.to_string());
}
}
// Rust `impl_item` has no `name` field — its identity is determined
// by the (optional) trait and the type being implemented. We
// synthesise "Trait for Type" / "Type" as the identity so two
// different sides editing the same impl block actually match up.
// Without this they fall through to the anonymous-hash key and get
// treated as unrelated additions.
if node.kind() == "impl_item" {
let trait_name = node
.child_by_field_name("trait")
.and_then(|n| n.utf8_text(src).ok());
let type_name = node
.child_by_field_name("type")
.and_then(|n| n.utf8_text(src).ok());
match (trait_name, type_name) {
(Some(tr), Some(ty)) => return Some(format!("{tr} for {ty}")),
(None, Some(ty)) => return Some(ty.to_string()),
_ => {}
}
}
if let Some(n) = node.child_by_field_name("declarator") {
if let Some(name) = extract_name(n, src) {
return Some(name);
}
let mut cur = n.walk();
for child in n.named_children(&mut cur) {
if matches!(
child.kind(),
"identifier" | "type_identifier" | "field_identifier"
) {
if let Ok(t) = child.utf8_text(src) {
return Some(t.to_string());
}
}
}
}
None
}
fn block_from_node(node: Node, src: &[u8]) -> Block {
let kind = node.kind().to_string();
let range = node.byte_range();
let text = src[range.clone()].to_vec();
let key = match extract_name(node, src) {
Some(n) => BlockKey::Named(kind.clone(), n),
None => BlockKey::Anon(kind.clone(), *blake3::hash(&text).as_bytes()),
};
Block {
kind,
key,
range,
text,
}
}
fn top_level_blocks(tree: &Tree, src: &[u8]) -> Vec<Block> {
let root = tree.root_node();
let mut cur = root.walk();
root.named_children(&mut cur)
.map(|n| block_from_node(n, src))
.collect()
}
/// Children of a body container (the named-block-bearing direct children).
/// Used during recursion to obtain the inner identity space of a
/// container node like `class_body`, `declaration_list`, `block`, etc.
fn body_children(body: Node, src: &[u8]) -> Vec<Block> {
let mut cur = body.walk();
body.named_children(&mut cur)
.map(|n| block_from_node(n, src))
.collect()
}
/// Locate the AST node within `tree` whose byte range matches `target`
/// exactly. Implemented as a guided descent that prunes any subtree whose
/// own byte range doesn't span the target — O(depth × siblings) in
/// practice, plenty fast since recursion is only attempted on conflict.
fn find_named_node<'a>(tree: &'a Tree, target: &Range<usize>) -> Option<Node<'a>> {
fn search<'b>(n: Node<'b>, target: &Range<usize>) -> Option<Node<'b>> {
if n.byte_range() == *target {
return Some(n);
}
if n.start_byte() > target.start || n.end_byte() < target.end {
return None;
}
let mut cur = n.walk();
for c in n.named_children(&mut cur) {
if let Some(found) = search(c, target) {
return Some(found);
}
}
None
}
search(tree.root_node(), target)
}
fn lookup<'a>(blocks: &'a [Block], key: &BlockKey) -> Option<&'a Block> {
blocks.iter().find(|b| &b.key == key)
}
fn make_region(
kind: &str,
desc: &str,
base: Option<&Block>,
ours: Option<&Block>,
theirs: Option<&Block>,
) -> ConflictRegion {
fn r(b: Option<&Block>) -> Range<usize> {
b.map(|b| b.range.clone()).unwrap_or(0..0)
}
ConflictRegion {
description: format!("{desc} on {kind}"),
base: r(base),
ours: r(ours),
theirs: r(theirs),
}
}
fn conflict_marker_block(ours: &[u8], theirs: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"<<<<<<< ours\n");
out.extend_from_slice(ours);
if !ours.is_empty() && !ours.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(b"=======\n");
out.extend_from_slice(theirs);
if !theirs.is_empty() && !theirs.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(b">>>>>>> theirs");
out
}
fn join_blocks_with(blocks: &[Vec<u8>], sep: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for (i, b) in blocks.iter().enumerate() {
if i > 0 {
out.extend_from_slice(sep);
}
out.extend_from_slice(b);
}
if !out.is_empty() && !out.ends_with(b"\n") {
out.push(b'\n');
}
out
}
fn join_top_level(blocks: &[Vec<u8>]) -> Vec<u8> {
join_blocks_with(blocks, b"\n\n")
}
/// Result of a per-block merge operation: the bytes to emit (possibly
/// containing conflict markers) plus any conflict regions and notes the
/// caller should surface to the user. Splitting the (text, conflicts)
/// pair from the joining step lets recursive callers preserve ours's
/// inter-child glue when reconstructing a parent block's body.
struct InnerMerge {
blocks: Vec<Vec<u8>>,
conflicts: Vec<ConflictRegion>,
notes: Vec<MergeNote>,
had_conflict: bool,
}
fn merge_blocks_inner(
base: &[Block],
ours: &[Block],
theirs: &[Block],
ctx: Option<&RecurseCtx>,
) -> InnerMerge {
let mut emitted: HashSet<BlockKey> = HashSet::new();
let mut output: Vec<Vec<u8>> = Vec::new();
let mut conflicts: Vec<ConflictRegion> = Vec::new();
let mut notes: Vec<MergeNote> = Vec::new();
let mut had_conflict = false;
for o in ours {
if !emitted.insert(o.key.clone()) {
continue;
}
let b = lookup(base, &o.key);
let t = lookup(theirs, &o.key);
match (b, t) {
(None, None) => {
output.push(o.text.clone());
}
(None, Some(tb)) => {
if o.text == tb.text {
output.push(o.text.clone());
} else {
had_conflict = true;
conflicts.push(make_region(
&o.kind,
"concurrent additions diverge",
None,
Some(o),
Some(tb),
));
output.push(conflict_marker_block(&o.text, &tb.text));
}
}
(Some(bb), None) => {
if o.text == bb.text {
// ours unchanged, theirs deleted → honour deletion
} else {
had_conflict = true;
conflicts.push(make_region(
&o.kind,
"modify-vs-delete",
Some(bb),
Some(o),
None,
));
output.push(conflict_marker_block(&o.text, &[]));
notes.push(MergeNote {
message: format!("{}: modified by ours, deleted by theirs", o.kind),
});
}
}
(Some(bb), Some(tb)) => {
let ours_changed = o.text != bb.text;
let theirs_changed = tb.text != bb.text;
match (ours_changed, theirs_changed) {
(false, false) => output.push(o.text.clone()),
(true, false) => output.push(o.text.clone()),
(false, true) => output.push(tb.text.clone()),
(true, true) => {
if o.text == tb.text {
output.push(o.text.clone());
notes.push(MergeNote {
message: format!("{}: identical edits on both sides", o.kind),
});
} else if let Some(merged) =
ctx.and_then(|c| try_recursive_clean(c, bb, o, tb))
{
output.push(merged);
notes.push(MergeNote {
message: format!(
"{}: merged disjoint edits via recursive descent",
o.kind
),
});
} else {
had_conflict = true;
conflicts.push(make_region(
&o.kind,
"concurrent edits",
Some(bb),
Some(o),
Some(tb),
));
output.push(conflict_marker_block(&o.text, &tb.text));
}
}
}
}
}
}
for t in theirs {
if emitted.contains(&t.key) {
continue;
}
emitted.insert(t.key.clone());
let b = lookup(base, &t.key);
match b {
None => {
output.push(t.text.clone());
}
Some(bb) => {
if t.text == bb.text {
// theirs unchanged, ours deleted → honour deletion
} else {
had_conflict = true;
conflicts.push(make_region(
&t.kind,
"delete-vs-modify",
Some(bb),
None,
Some(t),
));
output.push(conflict_marker_block(&[], &t.text));
notes.push(MergeNote {
message: format!("{}: deleted by ours, modified by theirs", t.kind),
});
}
}
}
}
InnerMerge {
blocks: output,
conflicts,
notes,
had_conflict,
}
}
/// Try to merge a single conflicted block by descending into its body and
/// merging its inner named children. Returns `Some(text)` on success —
/// the bytes that should replace the would-have-been conflict marker —
/// and `None` on any of:
/// - language doesn't support recursion (indent-sensitive),
/// - no `body` field on either side's outer node,
/// - outer "frame" (text outside the body) differs between ours and
/// theirs, meaning the header/footer themselves conflict,
/// - the block has no identifiable inner structure (all anonymous),
/// - the recursive child merge produced any conflict.
///
/// On success, the splice preserves ours's body prefix (text before the
/// first inner child), suffix (text after the last inner child), and
/// inter-child separator (the glue used between ours's first two
/// children, defaulting to `\n\n`). This keeps indentation and bracing
/// style consistent with ours rather than emitting a stylistically dead
/// `\n\n`-joined list — important because recursive merges land in
/// downstream review tools and uglier output is harder to skim.
fn try_recursive_clean(
ctx: &RecurseCtx,
base: &Block,
ours: &Block,
theirs: &Block,
) -> Option<Vec<u8>> {
if !ctx.lang.supports_recursion() {
return None;
}
let base_node = find_named_node(ctx.base_tree, &base.range)?;
let ours_node = find_named_node(ctx.ours_tree, &ours.range)?;
let theirs_node = find_named_node(ctx.theirs_tree, &theirs.range)?;
let base_body = base_node.child_by_field_name("body")?;
let ours_body = ours_node.child_by_field_name("body")?;
let theirs_body = theirs_node.child_by_field_name("body")?;
let oo = ours_node.byte_range();
let oob = ours_body.byte_range();
let to = theirs_node.byte_range();
let tob = theirs_body.byte_range();
// Frame = the outer block bytes outside the body. If those differ
// between ours and theirs, the *header* (or footer) itself is in
// conflict — recursing would silently discard one side's edit.
let ours_prefix = &ctx.ours_src[oo.start..oob.start];
let ours_suffix = &ctx.ours_src[oob.end..oo.end];
let theirs_prefix = &ctx.theirs_src[to.start..tob.start];
let theirs_suffix = &ctx.theirs_src[tob.end..to.end];
if ours_prefix != theirs_prefix || ours_suffix != theirs_suffix {
return None;
}
let bc = body_children(base_body, ctx.base_src);
let oc = body_children(ours_body, ctx.ours_src);
let tc = body_children(theirs_body, ctx.theirs_src);
if bc.is_empty() && oc.is_empty() && tc.is_empty() {
return None;
}
// Recursion only buys something when at least one of the inner blocks
// has a recoverable identity. If everything is anonymous, body diffs
// collapse to text-level and we'd match arbitrary content together.
if bc
.iter()
.chain(&oc)
.chain(&tc)
.all(|b| matches!(b.key, BlockKey::Anon(..)))
{
return None;
}
let inner = merge_blocks_inner(&bc, &oc, &tc, Some(ctx));
if inner.had_conflict {
return None;
}
// Splice the merged inner blocks back into ours's outer text. We use
// ours's body slot — its prefix, suffix, and inter-child separator —
// so the spliced result matches ours's existing style.
let body_rel_start = oob.start - oo.start;
let body_rel_end = oob.end - oo.start;
let body_text = &ours.text[body_rel_start..body_rel_end];
let (prefix, suffix) = body_prefix_suffix(body_text, &oc, oob.start);
let sep = inter_child_separator(body_text, &oc, oob.start);
let merged_children = join_blocks_with(&inner.blocks, &sep);
// join_blocks_with appends a trailing newline to keep top-level files
// POSIX-compliant; that's the wrong choice when splicing into the
// middle of an outer block — the suffix already carries whatever the
// body ended with.
let merged_children = strip_trailing_newline(&merged_children);
let mut out = Vec::new();
out.extend_from_slice(&ours.text[..body_rel_start]);
out.extend_from_slice(prefix);
out.extend_from_slice(&merged_children);
out.extend_from_slice(suffix);
out.extend_from_slice(&ours.text[body_rel_end..]);
Some(out)
}
fn body_prefix_suffix<'a>(
body_text: &'a [u8],
children: &[Block],
body_start_abs: usize,
) -> (&'a [u8], &'a [u8]) {
if children.is_empty() {
return (body_text, &[]);
}
let first_rel = children[0].range.start - body_start_abs;
let last_rel = children.last().unwrap().range.end - body_start_abs;
(&body_text[..first_rel], &body_text[last_rel..])
}
fn inter_child_separator(body_text: &[u8], children: &[Block], body_start_abs: usize) -> Vec<u8> {
if children.len() < 2 {
return b"\n\n".to_vec();
}
let end_first = children[0].range.end - body_start_abs;
let start_second = children[1].range.start - body_start_abs;
body_text[end_first..start_second].to_vec()
}
fn strip_trailing_newline(b: &[u8]) -> Vec<u8> {
if b.ends_with(b"\n") {
b[..b.len() - 1].to_vec()
} else {
b.to_vec()
}
}
impl MergeHandler for TreeSitterHandler {
fn name(&self) -> &str {
self.lang.handler_name()
}
fn applicable(&self, _path: &Path, _base: &[u8], _ours: &[u8], _theirs: &[u8]) -> bool {
// Applicability is decided in `merge`; if any input fails to parse
// we return NotApplicable and the cascade advances to textual.
true
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let (b_tree, o_tree, t_tree) =
match (self.parse(base), self.parse(ours), self.parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().to_string(),
status: MergeStatus::NotApplicable,
};
}
};
let bb = top_level_blocks(&b_tree, base);
let ob = top_level_blocks(&o_tree, ours);
let tb = top_level_blocks(&t_tree, theirs);
if bb.is_empty() && ob.is_empty() && tb.is_empty() {
return MergeResult {
handler: self.name().to_string(),
status: MergeStatus::NotApplicable,
};
}
let ctx = RecurseCtx {
lang: self.lang,
base_tree: &b_tree,
ours_tree: &o_tree,
theirs_tree: &t_tree,
base_src: base,
ours_src: ours,
theirs_src: theirs,
};
let inner = merge_blocks_inner(&bb, &ob, &tb, Some(&ctx));
let merged = join_top_level(&inner.blocks);
if inner.had_conflict {
MergeResult {
handler: self.name().to_string(),
status: MergeStatus::Conflict {
regions: inner.conflicts,
partial: merged,
},
}
} else {
MergeResult {
handler: self.name().to_string(),
status: MergeStatus::Merged {
content: merged,
notes: inner.notes,
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::CascadeEngine;
use std::path::Path;
fn run(lang: Lang, base: &str, ours: &str, theirs: &str) -> MergeResult {
let h = TreeSitterHandler::new(lang);
h.merge(
Path::new("file"),
base.as_bytes(),
ours.as_bytes(),
theirs.as_bytes(),
)
}
#[test]
fn rust_disjoint_function_additions_merge() {
let base = "fn a() {}\n";
let ours = "fn a() {}\n\nfn b() {}\n";
let theirs = "fn a() {}\n\nfn c() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
assert_eq!(result.handler, "tree-sitter:rust");
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a()"));
assert!(s.contains("fn b()"));
assert!(s.contains("fn c()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn rust_concurrent_edits_to_same_function_conflict() {
let base = "fn greet() {\n println!(\"hi\");\n}\n";
let ours = "fn greet() {\n println!(\"hello\");\n}\n";
let theirs = "fn greet() {\n println!(\"hey\");\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, partial } => {
assert_eq!(regions.len(), 1);
assert!(regions[0].description.contains("function_item"));
let s = std::str::from_utf8(&partial).unwrap();
assert!(s.contains("<<<<<<< ours"));
assert!(s.contains(">>>>>>> theirs"));
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn rust_one_sided_edit_takes_that_side() {
let base = "fn a() {}\n\nfn b() { let x = 1; }\n";
let ours = "fn a() {}\n\nfn b() { let x = 1; }\n";
let theirs = "fn a() {}\n\nfn b() { let x = 2; }\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("let x = 2;"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn parse_failure_falls_through_to_not_applicable() {
let bad = "fn a( {}\n"; // syntax error
let ok = "fn a() {}\n";
let result = run(Lang::Rust, ok, bad, ok);
assert!(matches!(result.status, MergeStatus::NotApplicable));
}
#[test]
fn cascade_routes_rust_files_to_tree_sitter_then_falls_through_on_parse_error() {
let engine = CascadeEngine::default();
// Parse error on ours → falls through to textual.
let base = b"fn a() { 1 }\n";
let ours = b"fn a( {} 1 }\n"; // broken
let theirs = b"fn a() { 2 }\n";
let res = engine.merge_file(Path::new("x.rs"), base, ours, theirs);
// Textual is the fallback; it must not advertise itself as
// tree-sitter:rust.
assert_ne!(res.handler, "tree-sitter:rust");
}
#[test]
fn python_disjoint_class_method_additions_merge() {
let base = "def a():\n return 1\n";
let ours = "def a():\n return 1\n\ndef b():\n return 2\n";
let theirs = "def a():\n return 1\n\ndef c():\n return 3\n";
let result = run(Lang::Python, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("def a"));
assert!(s.contains("def b"));
assert!(s.contains("def c"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn deletion_on_one_side_is_honoured() {
let base = "fn a() {}\n\nfn b() {}\n";
let ours = "fn a() {}\n";
let theirs = "fn a() {}\n\nfn b() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a()"));
assert!(!s.contains("fn b()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn modify_vs_delete_is_a_conflict() {
let base = "fn a() {}\n\nfn b() {}\n";
let ours = "fn a() {}\n\nfn b() { 1 }\n";
let theirs = "fn a() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, .. } => {
assert!(regions
.iter()
.any(|r| r.description.contains("modify-vs-delete")));
}
other => panic!("expected Conflict, got {other:?}"),
}
}
// ---------- recursion ----------
#[test]
fn rust_impl_disjoint_method_additions_merge_via_recursion() {
// The flat handler would mark this a top-level conflict because
// both sides modified `impl Foo`. With recursion we descend into
// the impl body and merge `fn b` and `fn c` as disjoint adds.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Foo {\n fn a(&self) {}\n fn b(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn c(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, notes } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a("), "must keep existing method: {s}");
assert!(s.contains("fn b("), "must keep ours-side addition: {s}");
assert!(s.contains("fn c("), "must keep theirs-side addition: {s}");
assert!(s.contains("impl Foo"), "must preserve outer header");
assert!(
notes
.iter()
.any(|n| n.message.contains("recursive descent")),
"merge note must record that recursion fired: {notes:?}"
);
}
other => panic!("expected Merged (via recursion), got {other:?}"),
}
}
#[test]
fn rust_impl_concurrent_edits_to_same_method_keep_outer_conflict() {
// Both sides edit fn a's body — the inner method bodies are
// anonymous statements, no recoverable identity, so recursion
// refuses and we get a clean outer-block conflict.
let base = "impl Foo {\n fn a(&self) { 1 }\n}\n";
let ours = "impl Foo {\n fn a(&self) { 2 }\n}\n";
let theirs = "impl Foo {\n fn a(&self) { 3 }\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, .. } => {
assert!(
regions.iter().any(|r| r.description.contains("impl_item")
|| r.description.contains("function_item")),
"expected outer-block conflict: {:?}",
regions
);
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn rust_impl_header_rename_and_body_add_does_not_silently_recurse() {
// ours renames the impl header (Foo → Bar), theirs adds a method.
// A naive recursion would pick up theirs's method into ours's
// renamed impl, silently discarding the rename mismatch. We
// require the outer frame to be identical, so this stays an
// outer conflict.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Bar {\n fn a(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn d(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
// Frames diverge → no recursion → outer-level conflict reported.
assert!(matches!(result.status, MergeStatus::Conflict { .. }));
}
#[test]
fn java_class_disjoint_method_additions_merge_via_recursion() {
let base = "class Foo {\n void a() {}\n}\n";
let ours = "class Foo {\n void a() {}\n void b() {}\n}\n";
let theirs = "class Foo {\n void a() {}\n void c() {}\n}\n";
let result = run(Lang::Java, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("void a("));
assert!(s.contains("void b("));
assert!(s.contains("void c("));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn javascript_class_disjoint_method_additions_merge_via_recursion() {
let base = "class Foo {\n a() { return 1; }\n}\n";
let ours = "class Foo {\n a() { return 1; }\n b() { return 2; }\n}\n";
let theirs = "class Foo {\n a() { return 1; }\n c() { return 3; }\n}\n";
let result = run(Lang::JavaScript, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("a()"));
assert!(s.contains("b()"));
assert!(s.contains("c()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn rust_recursion_preserves_inter_child_indentation() {
// The splice should re-use ours's " " indent between methods,
// not just join with "\n\n". Otherwise the merged file is valid
// but ugly enough to confuse downstream review.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Foo {\n fn a(&self) {}\n fn b(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn c(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
// The added method should begin with the same 4-space
// indent used for fn b in ours — i.e., the merged file
// contains " fn c", not "fn c" at column 0.
assert!(
s.contains(" fn c"),
"indentation must match ours's body style: {s}"
);
}
other => panic!("expected Merged, got {other:?}"),
}
}
}