949 lines
35 KiB
Rust
949 lines
35 KiB
Rust
//! 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:?}"),
|
||
}
|
||
}
|
||
}
|