use std::ops::Range;
use std::time::Duration;
use similar::{Algorithm, DiffTag, TextDiff};
const DIFF_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChunkKind {
Stable,
Ours,
Theirs,
Agree,
Conflict,
}
#[derive(Debug, Clone)]
pub struct MergeChunk {
pub id: usize,
pub kind: ChunkKind,
pub base: Vec<String>,
pub ours: Vec<String>,
pub theirs: Vec<String>,
pub base_start: usize,
pub ours_start: usize,
pub theirs_start: usize,
}
impl MergeChunk {
pub fn ours_lines(&self) -> &[String] {
if self.kind == ChunkKind::Stable {
&self.base
} else {
&self.ours
}
}
pub fn theirs_lines(&self) -> &[String] {
if self.kind == ChunkKind::Stable {
&self.base
} else {
&self.theirs
}
}
}
#[derive(Debug, Clone)]
pub struct MergeResult {
pub chunks: Vec<MergeChunk>,
pub conflicts: usize,
pub ours_changes: usize,
pub theirs_changes: usize,
pub agree: usize,
pub ours_label: Option<String>,
pub theirs_label: Option<String>,
}
#[derive(Debug, Clone)]
struct Hunk {
base_range: Range<usize>,
lines: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Side {
Ours,
Theirs,
}
#[derive(Debug, Clone)]
struct SidedHunk {
side: Side,
hunk: Hunk,
}
pub fn merge_text(base: &str, ours: &str, theirs: &str) -> MergeResult {
let ours_diff = TextDiff::configure()
.algorithm(Algorithm::Myers)
.timeout(DIFF_TIMEOUT)
.diff_lines(base, ours);
let theirs_diff = TextDiff::configure()
.algorithm(Algorithm::Myers)
.timeout(DIFF_TIMEOUT)
.diff_lines(base, theirs);
let base_lines: Vec<String> = (0..ours_diff.old_len())
.filter_map(|i| ours_diff.old_slice(i))
.map(clean_line)
.collect();
let groups = group_hunks(extract_hunks(&ours_diff), extract_hunks(&theirs_diff));
build_chunks(&base_lines, groups)
}
fn clean_line(line: &str) -> String {
line.trim_end_matches(['\r', '\n']).to_owned()
}
fn extract_hunks(diff: &TextDiff<'_, '_, str>) -> Vec<Hunk> {
diff.ops()
.iter()
.filter(|op| op.tag() != DiffTag::Equal)
.map(|op| Hunk {
base_range: op.old_range(),
lines: op
.new_range()
.filter_map(|i| diff.new_slice(i))
.map(clean_line)
.collect(),
})
.collect()
}
fn collides(a: &Range<usize>, b: &Range<usize>) -> bool {
match (a.is_empty(), b.is_empty()) {
(true, true) => a.start == b.start,
(true, false) => b.start <= a.start && a.start <= b.end,
(false, true) => a.start <= b.start && b.start <= a.end,
(false, false) => a.start.max(b.start) < a.end.min(b.end),
}
}
fn group_hunks(ours: Vec<Hunk>, theirs: Vec<Hunk>) -> Vec<Vec<SidedHunk>> {
let mut all: Vec<SidedHunk> = ours
.into_iter()
.map(|hunk| SidedHunk {
side: Side::Ours,
hunk,
})
.chain(theirs.into_iter().map(|hunk| SidedHunk {
side: Side::Theirs,
hunk,
}))
.collect();
all.sort_by_key(|s| {
(
s.hunk.base_range.start,
s.hunk.base_range.end,
s.side == Side::Theirs,
)
});
let mut groups: Vec<(Range<usize>, Vec<SidedHunk>)> = Vec::new();
for sided in all {
let range = sided.hunk.base_range.clone();
match groups.last_mut() {
Some((merged, members)) if collides(merged, &range) => {
merged.start = merged.start.min(range.start);
merged.end = merged.end.max(range.end);
members.push(sided);
}
_ => groups.push((range, vec![sided])),
}
}
groups.into_iter().map(|(_, members)| members).collect()
}
fn side_content(
base_lines: &[String],
lo: usize,
hi: usize,
group: &[SidedHunk],
side: Side,
) -> Vec<String> {
let mut out = Vec::new();
let mut pos = lo;
for sided in group.iter().filter(|s| s.side == side) {
out.extend_from_slice(&base_lines[pos..sided.hunk.base_range.start]);
out.extend(sided.hunk.lines.iter().cloned());
pos = sided.hunk.base_range.end;
}
out.extend_from_slice(&base_lines[pos..hi]);
out
}
fn build_chunks(base_lines: &[String], groups: Vec<Vec<SidedHunk>>) -> MergeResult {
let mut chunks: Vec<MergeChunk> = Vec::new();
let mut conflicts = 0usize;
let mut ours_changes = 0usize;
let mut theirs_changes = 0usize;
let mut agree = 0usize;
let mut base_pos = 0usize;
let mut base_no = 1usize;
let mut ours_no = 1usize;
let mut theirs_no = 1usize;
let push_stable = |chunks: &mut Vec<MergeChunk>,
lines: &[String],
base_no: &mut usize,
ours_no: &mut usize,
theirs_no: &mut usize| {
chunks.push(MergeChunk {
id: chunks.len(),
kind: ChunkKind::Stable,
base: lines.to_vec(),
ours: Vec::new(),
theirs: Vec::new(),
base_start: *base_no,
ours_start: *ours_no,
theirs_start: *theirs_no,
});
*base_no += lines.len();
*ours_no += lines.len();
*theirs_no += lines.len();
};
for group in groups {
let lo = group
.iter()
.map(|s| s.hunk.base_range.start)
.min()
.unwrap_or(0);
let hi = group
.iter()
.map(|s| s.hunk.base_range.end)
.max()
.unwrap_or(lo);
if lo > base_pos {
push_stable(
&mut chunks,
&base_lines[base_pos..lo],
&mut base_no,
&mut ours_no,
&mut theirs_no,
);
}
let ours_lines = side_content(base_lines, lo, hi, &group, Side::Ours);
let theirs_lines = side_content(base_lines, lo, hi, &group, Side::Theirs);
let has_ours = group.iter().any(|s| s.side == Side::Ours);
let has_theirs = group.iter().any(|s| s.side == Side::Theirs);
let kind = match (has_ours, has_theirs) {
(true, false) => ChunkKind::Ours,
(false, true) => ChunkKind::Theirs,
_ if ours_lines == theirs_lines => ChunkKind::Agree,
_ => ChunkKind::Conflict,
};
match kind {
ChunkKind::Ours => ours_changes += 1,
ChunkKind::Theirs => theirs_changes += 1,
ChunkKind::Agree => agree += 1,
ChunkKind::Conflict => conflicts += 1,
ChunkKind::Stable => {}
}
let (ours_len, theirs_len) = (ours_lines.len(), theirs_lines.len());
chunks.push(MergeChunk {
id: chunks.len(),
kind,
base: base_lines[lo..hi].to_vec(),
ours: ours_lines,
theirs: theirs_lines,
base_start: base_no,
ours_start: ours_no,
theirs_start: theirs_no,
});
base_no += hi - lo;
ours_no += ours_len;
theirs_no += theirs_len;
base_pos = hi;
}
if base_pos < base_lines.len() {
push_stable(
&mut chunks,
&base_lines[base_pos..],
&mut base_no,
&mut ours_no,
&mut theirs_no,
);
}
MergeResult {
chunks,
conflicts,
ours_changes,
theirs_changes,
agree,
ours_label: None,
theirs_label: None,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConflictParseError {
#[error("未检测到 git 冲突标记(<<<<<<< / ======= / >>>>>>>)")]
NoMarkers,
#[error("第 {0} 行附近的冲突标记不完整或顺序错误")]
Malformed(usize),
}
enum Section {
Common,
Ours,
Base,
Theirs,
}
pub fn parse_conflict_file(text: &str) -> Result<MergeResult, ConflictParseError> {
let mut chunks: Vec<MergeChunk> = Vec::new();
let mut conflicts = 0usize;
let mut ours_label: Option<String> = None;
let mut theirs_label: Option<String> = None;
let mut base_no = 1usize;
let mut ours_no = 1usize;
let mut theirs_no = 1usize;
let mut common: Vec<String> = Vec::new();
let mut ours: Vec<String> = Vec::new();
let mut base: Vec<String> = Vec::new();
let mut theirs: Vec<String> = Vec::new();
let mut section = Section::Common;
let mut conflict_start = 0usize;
let flush_common = |chunks: &mut Vec<MergeChunk>,
common: &mut Vec<String>,
base_no: &mut usize,
ours_no: &mut usize,
theirs_no: &mut usize| {
if common.is_empty() {
return;
}
let lines = std::mem::take(common);
let len = lines.len();
chunks.push(MergeChunk {
id: chunks.len(),
kind: ChunkKind::Stable,
base: lines,
ours: Vec::new(),
theirs: Vec::new(),
base_start: *base_no,
ours_start: *ours_no,
theirs_start: *theirs_no,
});
*base_no += len;
*ours_no += len;
*theirs_no += len;
};
for (idx, raw) in text.lines().enumerate() {
let line = raw.trim_end_matches('\r');
let line_no = idx + 1;
if let Some(rest) = line.strip_prefix("<<<<<<<") {
if !matches!(section, Section::Common) {
return Err(ConflictParseError::Malformed(line_no));
}
flush_common(
&mut chunks,
&mut common,
&mut base_no,
&mut ours_no,
&mut theirs_no,
);
conflict_start = line_no;
let label = rest.trim();
if ours_label.is_none() && !label.is_empty() {
ours_label = Some(label.to_owned());
}
section = Section::Ours;
} else if matches!(section, Section::Ours) && line.starts_with("|||||||") {
section = Section::Base;
} else if matches!(section, Section::Ours | Section::Base)
&& line.len() >= 7
&& line.bytes().all(|b| b == b'=')
{
section = Section::Theirs;
} else if let Some(rest) = line.strip_prefix(">>>>>>>") {
if !matches!(section, Section::Theirs) {
return Err(ConflictParseError::Malformed(line_no));
}
let label = rest.trim();
if theirs_label.is_none() && !label.is_empty() {
theirs_label = Some(label.to_owned());
}
let (base_lines, ours_lines, theirs_lines) = (
std::mem::take(&mut base),
std::mem::take(&mut ours),
std::mem::take(&mut theirs),
);
let lens = (base_lines.len(), ours_lines.len(), theirs_lines.len());
chunks.push(MergeChunk {
id: chunks.len(),
kind: ChunkKind::Conflict,
base_start: base_no,
ours_start: ours_no,
theirs_start: theirs_no,
base: base_lines,
ours: ours_lines,
theirs: theirs_lines,
});
base_no += lens.0;
ours_no += lens.1;
theirs_no += lens.2;
conflicts += 1;
section = Section::Common;
} else {
let owned = line.to_owned();
match section {
Section::Common => common.push(owned),
Section::Ours => ours.push(owned),
Section::Base => base.push(owned),
Section::Theirs => theirs.push(owned),
}
}
}
if !matches!(section, Section::Common) {
return Err(ConflictParseError::Malformed(conflict_start));
}
if conflicts == 0 {
return Err(ConflictParseError::NoMarkers);
}
flush_common(
&mut chunks,
&mut common,
&mut base_no,
&mut ours_no,
&mut theirs_no,
);
Ok(MergeResult {
chunks,
conflicts,
ours_changes: 0,
theirs_changes: 0,
agree: 0,
ours_label,
theirs_label,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(result: &MergeResult) -> Vec<ChunkKind> {
result.chunks.iter().map(|c| c.kind).collect()
}
#[test]
fn identical_inputs_are_stable() {
let result = merge_text("a\nb\n", "a\nb\n", "a\nb\n");
assert_eq!(kinds(&result), vec![ChunkKind::Stable]);
assert_eq!(result.conflicts, 0);
}
#[test]
fn non_overlapping_changes_merge() {
let result = merge_text("a\nb\nc\nd\n", "A\nb\nc\nd\n", "a\nb\nc\nD\n");
assert_eq!(
kinds(&result),
vec![ChunkKind::Ours, ChunkKind::Stable, ChunkKind::Theirs]
);
assert_eq!(result.chunks[0].ours, vec!["A"]);
assert_eq!(result.chunks[0].base, vec!["a"]);
assert_eq!(result.chunks[2].theirs, vec!["D"]);
assert_eq!(result.conflicts, 0);
assert_eq!(result.ours_changes, 1);
assert_eq!(result.theirs_changes, 1);
}
#[test]
fn identical_changes_are_agree() {
let result = merge_text("a\nb\nc\n", "a\nB\nc\n", "a\nB\nc\n");
assert_eq!(
kinds(&result),
vec![ChunkKind::Stable, ChunkKind::Agree, ChunkKind::Stable]
);
assert_eq!(result.agree, 1);
assert_eq!(result.chunks[1].ours, result.chunks[1].theirs);
}
#[test]
fn conflicting_change_detected() {
let result = merge_text("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
assert_eq!(result.conflicts, 1);
let conflict = &result.chunks[1];
assert_eq!(conflict.kind, ChunkKind::Conflict);
assert_eq!(conflict.base, vec!["b"]);
assert_eq!(conflict.ours, vec!["X"]);
assert_eq!(conflict.theirs, vec!["Y"]);
}
#[test]
fn insertions_at_same_point_conflict() {
let result = merge_text("a\nb\n", "a\nx\nb\n", "a\ny\nb\n");
assert_eq!(result.conflicts, 1);
let conflict = &result.chunks[1];
assert!(conflict.base.is_empty());
assert_eq!(conflict.ours, vec!["x"]);
assert_eq!(conflict.theirs, vec!["y"]);
}
#[test]
fn delete_vs_edit_conflicts() {
let result = merge_text("a\nb\nc\n", "a\nc\n", "a\nB\nc\n");
assert_eq!(result.conflicts, 1);
let conflict = &result.chunks[1];
assert!(conflict.ours.is_empty());
assert_eq!(conflict.theirs, vec!["B"]);
}
#[test]
fn adjacent_changes_stay_separate() {
let result = merge_text("a\nb\nc\nd\n", "A\nB\nc\nd\n", "a\nb\nC\nD\n");
assert_eq!(kinds(&result), vec![ChunkKind::Ours, ChunkKind::Theirs]);
assert_eq!(result.conflicts, 0);
}
#[test]
fn empty_base_same_addition_agrees() {
let result = merge_text("", "x\n", "x\n");
assert_eq!(kinds(&result), vec![ChunkKind::Agree]);
}
#[test]
fn empty_base_different_additions_conflict() {
let result = merge_text("", "x\n", "y\n");
assert_eq!(kinds(&result), vec![ChunkKind::Conflict]);
}
#[test]
fn one_side_unchanged_keeps_other() {
let result = merge_text("a\nb\n", "a\nB\n", "a\nb\n");
assert_eq!(kinds(&result), vec![ChunkKind::Stable, ChunkKind::Ours]);
assert_eq!(result.theirs_changes, 0);
}
#[test]
fn line_numbers_track_each_side() {
let result = merge_text("a\nb\nc\n", "a\nx\ny\nb\nc\n", "a\nb\nC\n");
assert_eq!(
kinds(&result),
vec![
ChunkKind::Stable,
ChunkKind::Ours,
ChunkKind::Stable,
ChunkKind::Theirs
]
);
let theirs_chunk = &result.chunks[3];
assert_eq!(theirs_chunk.base_start, 3);
assert_eq!(theirs_chunk.ours_start, 5);
assert_eq!(theirs_chunk.theirs_start, 3);
}
#[test]
fn crlf_lines_are_clean() {
let result = merge_text("a\r\nb\r\n", "a\r\nB\r\n", "a\r\nb\r\n");
let all_lines = result
.chunks
.iter()
.flat_map(|c| c.base.iter().chain(c.ours.iter()).chain(c.theirs.iter()));
for line in all_lines {
assert!(!line.contains('\r'));
}
}
#[test]
fn parses_standard_conflict_markers() {
let text = "a\n<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> feature/demo\nb\n";
let result = parse_conflict_file(text).unwrap();
assert_eq!(
kinds(&result),
vec![ChunkKind::Stable, ChunkKind::Conflict, ChunkKind::Stable]
);
assert_eq!(result.conflicts, 1);
assert_eq!(result.ours_label.as_deref(), Some("HEAD"));
assert_eq!(result.theirs_label.as_deref(), Some("feature/demo"));
let conflict = &result.chunks[1];
assert_eq!(conflict.ours, vec!["x"]);
assert_eq!(conflict.theirs, vec!["y"]);
assert!(conflict.base.is_empty());
assert_eq!(conflict.ours_start, 2);
assert_eq!(conflict.theirs_start, 2);
}
#[test]
fn parses_diff3_style_base_section() {
let text =
"<<<<<<< HEAD\nx\n||||||| merged common ancestors\no\n=======\ny\n>>>>>>> main\n";
let result = parse_conflict_file(text).unwrap();
let conflict = &result.chunks[0];
assert_eq!(conflict.kind, ChunkKind::Conflict);
assert_eq!(conflict.base, vec!["o"]);
assert_eq!(conflict.ours, vec!["x"]);
assert_eq!(conflict.theirs, vec!["y"]);
}
#[test]
fn parses_multiple_conflicts() {
let text = "a\n<<<<<<<\nx\n=======\ny\n>>>>>>>\nb\n<<<<<<<\np\n=======\nq\n>>>>>>>\n";
let result = parse_conflict_file(text).unwrap();
assert_eq!(result.conflicts, 2);
assert!(result.ours_label.is_none());
let second = &result.chunks[3];
assert_eq!(second.ours_start, 4); assert_eq!(second.theirs_start, 4); }
#[test]
fn conflict_file_without_markers_errors() {
assert!(matches!(
parse_conflict_file("hello\nworld\n"),
Err(ConflictParseError::NoMarkers)
));
}
#[test]
fn unterminated_conflict_errors() {
assert!(matches!(
parse_conflict_file("a\n<<<<<<< HEAD\nx\n=======\ny\n"),
Err(ConflictParseError::Malformed(2))
));
}
#[test]
fn nested_marker_errors() {
assert!(matches!(
parse_conflict_file("<<<<<<< HEAD\n<<<<<<< again\n"),
Err(ConflictParseError::Malformed(2))
));
}
#[test]
fn separator_outside_conflict_is_content() {
let text = "title\n=======\n<<<<<<<\nx\n=======\ny\n>>>>>>>\n";
let result = parse_conflict_file(text).unwrap();
assert_eq!(result.chunks[0].base, vec!["title", "======="]);
assert_eq!(result.conflicts, 1);
}
#[test]
fn conflict_file_with_crlf_is_clean() {
let text = "a\r\n<<<<<<< HEAD\r\nx\r\n=======\r\ny\r\n>>>>>>> main\r\n";
let result = parse_conflict_file(text).unwrap();
let conflict = &result.chunks[1];
assert_eq!(conflict.ours, vec!["x"]);
assert_eq!(conflict.theirs, vec!["y"]);
}
}