//! 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 { 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 { 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, text: Vec, } #[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 { 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 { 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 { 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) -> Option> { fn search<'b>(n: Node<'b>, target: &Range) -> Option> { 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 { 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 { 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], sep: &[u8]) -> Vec { 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]) -> Vec { 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>, conflicts: Vec, notes: Vec, had_conflict: bool, } fn merge_blocks_inner( base: &[Block], ours: &[Block], theirs: &[Block], ctx: Option<&RecurseCtx>, ) -> InnerMerge { let mut emitted: HashSet = HashSet::new(); let mut output: Vec> = Vec::new(); let mut conflicts: Vec = Vec::new(); let mut notes: Vec = 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> { 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 { 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 { 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:?}"), } } }