use processkit::{Error, Result};
use crate::BINARY;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum JjConflictSection {
Diff {
from_label: String,
to_label: String,
lines: Vec<String>,
},
Snapshot {
label: String,
lines: Vec<String>,
},
Base {
label: String,
lines: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct JjConflictRegion {
pub number: u32,
pub total: u32,
pub sections: Vec<JjConflictSection>,
marker_start: String,
marker_end: String,
section_markers: Vec<String>,
}
impl JjConflictRegion {
pub fn sides(&self) -> Vec<Vec<String>> {
let mixed = self.mixed_eol();
self.sections
.iter()
.filter_map(|section| match section {
JjConflictSection::Diff {
to_label, lines, ..
} => Some(join_sublines(
&apply_diff(lines, false),
labeled_no_eol(to_label),
mixed,
)),
JjConflictSection::Snapshot { label, lines } => {
Some(join_sublines(lines, labeled_no_eol(label), mixed))
}
JjConflictSection::Base { .. } => None,
})
.collect()
}
pub fn base(&self) -> Option<Vec<String>> {
let mixed = self.mixed_eol();
self.sections.iter().find_map(|section| match section {
JjConflictSection::Diff {
from_label, lines, ..
} => Some(join_sublines(
&apply_diff(lines, true),
labeled_no_eol(from_label),
mixed,
)),
JjConflictSection::Base { label, lines } => {
Some(join_sublines(lines, labeled_no_eol(label), mixed))
}
JjConflictSection::Snapshot { .. } => None,
})
}
fn mixed_eol(&self) -> bool {
self.sections.iter().any(|section| match section {
JjConflictSection::Snapshot { label, .. } | JjConflictSection::Base { label, .. } => {
labeled_no_eol(label)
}
JjConflictSection::Diff {
from_label,
to_label,
..
} => labeled_no_eol(from_label) || labeled_no_eol(to_label),
})
}
}
const NO_EOL_MARKER: &str = "(no terminating newline)";
fn labeled_no_eol(label: &str) -> bool {
label.trim_end().ends_with(NO_EOL_MARKER)
}
fn join_sublines(sublines: &[String], no_eol: bool, mixed: bool) -> Vec<String> {
let mut content: String = sublines.concat();
if no_eol || mixed {
if content.ends_with("\r\n") {
content.truncate(content.len() - 2);
} else if content.ends_with('\n') {
content.pop();
}
}
content.split_inclusive('\n').map(str::to_string).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JjConflictSegment {
Text(Vec<String>),
Conflict(Box<JjConflictRegion>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JjResolution {
Side(usize),
Base,
}
fn apply_diff(lines: &[String], old: bool) -> Vec<String> {
let keep = if old { ['-', ' '] } else { ['+', ' '] };
lines
.iter()
.filter_map(|line| {
let mut chars = line.chars();
let first = chars.next()?;
keep.contains(&first).then(|| chars.as_str().to_string())
})
.collect()
}
fn marker_run(line: &str, ch: char) -> Option<usize> {
let trimmed = line.trim_end_matches(['\r', '\n']);
let n = trimmed.chars().take_while(|&c| c == ch).count();
let rest = &trimmed[n..];
(n >= 7 && (rest.is_empty() || rest.starts_with(' '))).then_some(n)
}
fn marker_label(line: &str, n: usize) -> String {
line.trim_end_matches(['\r', '\n'])[n..]
.trim_start()
.to_string()
}
fn parse_error(message: String) -> Error {
Error::parse(BINARY, message)
}
fn parse_counter(label: &str) -> Option<(u32, u32)> {
let rest = label.strip_prefix("conflict ")?;
let mut parts = rest.split_whitespace();
let n = parts.next()?.parse().ok()?;
let of = parts.next()?;
let m = parts.next()?.parse().ok()?;
(of == "of").then_some((n, m))
}
pub fn has_conflict_markers(content: &str) -> bool {
content.split_inclusive('\n').any(|line| {
marker_run(line, '<').is_some_and(|n| parse_counter(&marker_label(line, n)).is_some())
})
}
fn looks_git_style(content: &str) -> bool {
let has_run = |ch: char| {
content
.split_inclusive('\n')
.any(|l| marker_run(l, ch).is_some())
};
has_run('<') && has_run('=') && has_run('>')
}
pub fn parse_conflicts(content: &str) -> Result<Vec<JjConflictSegment>> {
if !has_conflict_markers(content) && looks_git_style(content) {
return Err(parse_error(
"git-style conflict markers — parse this file with vcs_git::conflict \
(jj's `git` marker style uses git's grammar)"
.to_string(),
));
}
let mut segments = Vec::new();
let mut text: Vec<String> = Vec::new();
let mut lines = content.split_inclusive('\n');
while let Some(line) = lines.next() {
let counter = marker_run(line, '<')
.map(|n| (n, marker_label(line, n)))
.and_then(|(n, label)| parse_counter(&label).map(|c| (n, c)));
let Some((n, (number, total))) = counter else {
text.push(line.to_string());
continue;
};
if !text.is_empty() {
segments.push(JjConflictSegment::Text(std::mem::take(&mut text)));
}
let marker_start = line.to_string();
let mut sections: Vec<JjConflictSection> = Vec::new();
let mut section_markers: Vec<String> = Vec::new();
let marker_end = loop {
let Some(line) = lines.next() else {
return Err(parse_error(format!(
"unterminated jj conflict {number} of {total}"
)));
};
if marker_run(line, '>') == Some(n) {
let label = marker_label(line, n);
if parse_counter(label.trim_end_matches(" ends").trim_end()).is_some() {
break line.to_string();
}
}
if let Some(m) = marker_run(line, '%').filter(|&m| m == n) {
let from_label = marker_label(line, m)
.trim_start_matches("diff from:")
.trim()
.to_string();
let Some(to_line) = lines.next() else {
return Err(parse_error("diff section missing its `to:` line".into()));
};
if marker_run(to_line, '\\') != Some(m) {
return Err(parse_error(format!(
"diff section: expected a {m}-long `\\` `to:` line, got {:?}",
to_line.trim_end()
)));
}
let to_label = marker_label(to_line, m)
.trim_start_matches("to:")
.trim()
.to_string();
section_markers.push(format!("{line}{to_line}"));
sections.push(JjConflictSection::Diff {
from_label,
to_label,
lines: Vec::new(),
});
continue;
}
if let Some(m) = marker_run(line, '+').filter(|&m| m == n) {
section_markers.push(line.to_string());
sections.push(JjConflictSection::Snapshot {
label: marker_label(line, m),
lines: Vec::new(),
});
continue;
}
if let Some(m) = marker_run(line, '-').filter(|&m| m == n) {
section_markers.push(line.to_string());
sections.push(JjConflictSection::Base {
label: marker_label(line, m),
lines: Vec::new(),
});
continue;
}
match sections.last_mut() {
Some(
JjConflictSection::Diff { lines, .. }
| JjConflictSection::Snapshot { lines, .. }
| JjConflictSection::Base { lines, .. },
) => lines.push(line.to_string()),
None => {
return Err(parse_error(format!(
"content before the first section marker in conflict \
{number}: {:?}",
line.trim_end()
)));
}
}
};
segments.push(JjConflictSegment::Conflict(Box::new(JjConflictRegion {
number,
total,
sections,
marker_start,
marker_end,
section_markers,
})));
}
if !text.is_empty() {
segments.push(JjConflictSegment::Text(text));
}
Ok(segments)
}
pub fn render(segments: &[JjConflictSegment]) -> String {
let mut out = String::new();
for segment in segments {
match segment {
JjConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
JjConflictSegment::Conflict(region) => {
out.push_str(®ion.marker_start);
for (section, marker) in region.sections.iter().zip(®ion.section_markers) {
out.push_str(marker);
let (JjConflictSection::Diff { lines, .. }
| JjConflictSection::Snapshot { lines, .. }
| JjConflictSection::Base { lines, .. }) = section;
lines.iter().for_each(|l| out.push_str(l));
}
out.push_str(®ion.marker_end);
}
}
}
out
}
pub fn resolve(segments: &[JjConflictSegment], resolution: JjResolution) -> Result<String> {
let refuse = |what: String| {
Error::spawn(
BINARY,
std::io::Error::new(std::io::ErrorKind::InvalidInput, what),
)
};
let mut out = String::new();
for segment in segments {
match segment {
JjConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
JjConflictSegment::Conflict(region) => {
let chosen = match resolution {
JjResolution::Side(i) => {
let sides = region.sides();
sides.get(i).cloned().ok_or_else(|| {
refuse(format!(
"conflict {} has {} side(s); Side({i}) does not exist",
region.number,
sides.len()
))
})?
}
JjResolution::Base => region.base().ok_or_else(|| {
refuse(format!("conflict {} records no base", region.number))
})?,
};
chosen.iter().for_each(|l| out.push_str(l));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const DIFF_STYLE: &str = "line 1\n<<<<<<< conflict 1 of 1\n%%%%%%% diff from: rnxsupvw 638ae425 \"base\"\n\\\\\\\\\\\\\\ to: ozvltnxm 92f2b14f \"side-a\"\n-line 2\n+main line 2\n+++++++ xyrusolp ad268d1f \"side-b\"\nfeature line 2\n>>>>>>> conflict 1 of 1 ends\nline 3\n";
const SNAPSHOT_STYLE: &str = "line 1\n<<<<<<< conflict 1 of 1\n+++++++ kttusupp 7eedad44 \"side-a\"\nmain line 2\n------- rzkutuko 4fe1246f \"base\"\nline 2\n+++++++ ukuqwwlw 38f5069b \"side-b\"\nfeature line 2\n>>>>>>> conflict 1 of 1 ends\nline 3\n";
#[test]
fn parses_diff_style_and_materializes_sides() {
let segments = parse_conflicts(DIFF_STYLE).expect("parse");
assert_eq!(segments.len(), 3);
let JjConflictSegment::Conflict(region) = &segments[1] else {
panic!("expected a conflict, got {segments:?}");
};
assert_eq!((region.number, region.total), (1, 1));
assert_eq!(region.sections.len(), 2);
let sides = region.sides();
assert_eq!(sides.len(), 2);
assert_eq!(sides[0], ["main line 2\n"], "diff side = applied new text");
assert_eq!(sides[1], ["feature line 2\n"], "snapshot side verbatim");
assert_eq!(region.base().unwrap(), ["line 2\n"], "diff old text = base");
}
#[test]
fn parses_snapshot_style() {
let segments = parse_conflicts(SNAPSHOT_STYLE).expect("parse");
let JjConflictSegment::Conflict(region) = &segments[1] else {
panic!("expected a conflict");
};
assert_eq!(region.sections.len(), 3);
let sides = region.sides();
assert_eq!(sides[0], ["main line 2\n"]);
assert_eq!(sides[1], ["feature line 2\n"]);
assert_eq!(region.base().unwrap(), ["line 2\n"]);
assert!(
matches!(®ion.sections[1], JjConflictSection::Base { label, .. }
if label.contains("\"base\"")),
);
}
#[test]
fn content_run_ending_in_ends_is_not_the_terminator() {
let input = concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"+++++++ side-a\n",
">>>>>>> recommends\n", "------- base\n",
"line 2\n",
"+++++++ side-b\n",
"feature line 2\n",
">>>>>>> conflict 1 of 1 ends\n", "line 3\n",
);
let segments = parse_conflicts(input).expect("parse");
assert_eq!(segments.len(), 3);
let JjConflictSegment::Conflict(region) = &segments[1] else {
panic!("expected a conflict, got {segments:?}");
};
assert_eq!((region.number, region.total), (1, 1));
assert!(
region.sides()[0].iter().any(|l| l.contains("recommends")),
"the `>>>…recommends` content line is part of side-a, not the terminator"
);
assert_eq!(render(&segments), input);
}
#[test]
fn diff_section_rejects_mismatched_to_marker_length() {
let input = concat!(
"<<<<<<< conflict 1 of 1\n",
"%%%%%%% diff from: ab cd \"base\"\n",
"\\\\\\\\\\\\\\\\ to: ef gh \"side\"\n", "-line\n",
"+new\n",
">>>>>>> conflict 1 of 1 ends\n",
);
let err = parse_conflicts(input).unwrap_err();
assert!(matches!(err, Error::Parse { .. }), "structured parse error");
assert!(
err.to_string().contains("to:"),
"error should point at the malformed `to:` line: {err}"
);
}
#[test]
fn marker_like_content_line_is_not_rejected() {
let plain = "<<<<<<< a line documenting git markers\nmore text\n";
let segs = parse_conflicts(plain).expect("marker-like content is text, not an error");
assert!(segs.iter().all(|s| matches!(s, JjConflictSegment::Text(_))));
assert_eq!(render(&segs), plain, "round-trips");
let mixed = concat!(
"<<<<<<< documentation, not a header\n",
"<<<<<<< conflict 1 of 1\n",
"+++++++ aaa 111 \"side-a\"\n",
"X\n",
">>>>>>> conflict 1 of 1 ends\n",
);
let segs = parse_conflicts(mixed).expect("parse");
assert_eq!(render(&segs), mixed, "round-trips");
assert!(
segs.iter()
.any(|s| matches!(s, JjConflictSegment::Conflict(_))),
"the real region still parses: {segs:?}"
);
}
#[test]
fn render_roundtrips_exactly() {
for sample in [DIFF_STYLE, SNAPSHOT_STYLE] {
let segments = parse_conflicts(sample).expect("parse");
assert_eq!(render(&segments), sample, "roundtrip");
}
let eof = DIFF_STYLE.trim_end_matches("line 3\n");
let eof = &eof[..eof.len() - 1]; let segments = parse_conflicts(eof).expect("parse");
assert_eq!(render(&segments), eof);
}
#[test]
fn resolve_picks_sides_and_base() {
let segments = parse_conflicts(DIFF_STYLE).expect("parse");
assert_eq!(
resolve(&segments, JjResolution::Side(0)).unwrap(),
"line 1\nmain line 2\nline 3\n"
);
assert_eq!(
resolve(&segments, JjResolution::Side(1)).unwrap(),
"line 1\nfeature line 2\nline 3\n"
);
assert_eq!(
resolve(&segments, JjResolution::Base).unwrap(),
"line 1\nline 2\nline 3\n"
);
assert!(resolve(&segments, JjResolution::Side(2)).is_err());
}
fn check_eol(input: &str, side0: &str, side1: &str, base: &str) {
let segments = parse_conflicts(input).expect("parse");
assert_eq!(
render(&segments),
input,
"render must round-trip byte-exact"
);
assert_eq!(
resolve(&segments, JjResolution::Side(0)).unwrap(),
side0,
"side 0"
);
assert_eq!(
resolve(&segments, JjResolution::Side(1)).unwrap(),
side1,
"side 1"
);
assert_eq!(
resolve(&segments, JjResolution::Base).unwrap(),
base,
"base"
);
}
#[test]
fn resolve_honors_missing_terminating_newline() {
check_eol(
concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"+++++++ aaa 111 \"side-a\" (no terminating newline)\n",
"main 2\n",
"%%%%%%% diff from: bbb 222 \"base\"\n",
"\\\\\\\\\\\\\\ to: ccc 333 \"side-b\"\n",
"-line 2\n",
"+feat 2\n",
" \n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\nmain 2", "line 1\nfeat 2\n", "line 1\nline 2\n", );
check_eol(
concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"%%%%%%% diff from: bbb 222 \"base\"\n",
"\\\\\\\\\\\\\\ to: aaa 111 \"side-a\"\n",
"-line 2\n",
"+main 2\n",
" \n",
"+++++++ ccc 333 \"side-b\" (no terminating newline)\n",
"feat 2\n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\nmain 2\n",
"line 1\nfeat 2",
"line 1\nline 2\n",
);
check_eol(
concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"%%%%%%% diff from: bbb 222 \"base\" (no terminating newline)\n",
"\\\\\\\\\\\\\\ to: aaa 111 \"side-a\"\n",
"-line 2\n",
"+main 2\n",
"+\n",
"+++++++ ccc 333 \"side-b\"\n",
"feat 2\n",
"\n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\nmain 2\n",
"line 1\nfeat 2\n",
"line 1\nline 2", );
check_eol(
concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"%%%%%%% diff from: bbb 222 \"base\"\n",
"\\\\\\\\\\\\\\ to: aaa 111 \"side-a\" (no terminating newline)\n",
"-line 2\n",
"-\n",
"+main 2\n",
"+++++++ ccc 333 \"side-b\" (no terminating newline)\n",
"feat 2\n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\nmain 2",
"line 1\nfeat 2",
"line 1\nline 2\n",
);
}
#[test]
fn resolve_honors_missing_newline_with_crlf() {
check_eol(
concat!(
"line 1\r\n",
"<<<<<<< conflict 1 of 1\r\n",
"+++++++ aaa 111 \"side-a\" (no terminating newline)\r\n",
"main 2\r\n",
"%%%%%%% diff from: bbb 222 \"base\"\r\n",
"\\\\\\\\\\\\\\ to: ccc 333 \"side-b\"\r\n",
"-line 2\r\n",
"+feat 2\r\n",
" \r\n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\r\nmain 2", "line 1\r\nfeat 2\r\n", "line 1\r\nline 2\r\n", );
}
#[test]
fn resolve_handles_three_sided_conflict_with_missing_newline() {
let input = concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"%%%%%%% diff from: aaa 111 \"base\"\n",
"\\\\\\\\\\\\\\ to: bbb 222 \"sa\"\n",
"-line 2\n",
"+AAA\n",
" \n",
"%%%%%%% diff from: aaa 111 \"base\"\n",
"\\\\\\\\\\\\\\ to: ccc 333 \"sb\"\n",
"-line 2\n",
"+BBB\n",
" \n",
"+++++++ ddd 444 \"sc\" (no terminating newline)\n",
"CCC\n",
">>>>>>> conflict 1 of 1 ends",
);
let segs = parse_conflicts(input).expect("parse");
assert_eq!(render(&segs), input, "render round-trips");
assert_eq!(
resolve(&segs, JjResolution::Side(0)).unwrap(),
"line 1\nAAA\n"
);
assert_eq!(
resolve(&segs, JjResolution::Side(1)).unwrap(),
"line 1\nBBB\n"
);
assert_eq!(
resolve(&segs, JjResolution::Side(2)).unwrap(),
"line 1\nCCC", );
assert_eq!(
resolve(&segs, JjResolution::Base).unwrap(),
"line 1\nline 2\n"
);
}
#[test]
fn resolve_handles_snapshot_style_with_missing_newline_and_base() {
check_eol(
concat!(
"line 1\n",
"<<<<<<< conflict 1 of 1\n",
"+++++++ aaa 111 \"sa\" (no terminating newline)\n",
"AAA\n",
"------- bbb 222 \"base\"\n",
"line 2\n",
"\n",
"+++++++ ccc 333 \"sb\"\n",
"BBB\n",
"\n",
">>>>>>> conflict 1 of 1 ends",
),
"line 1\nAAA", "line 1\nBBB\n", "line 1\nline 2\n", );
}
#[test]
fn multi_region_counters_parse() {
let two = format!(
"{}middle\n{}",
DIFF_STYLE,
DIFF_STYLE
.replace("conflict 1 of 1", "conflict 2 of 2")
.replace("line 1\n", "")
.replace("line 3\n", "")
);
let segments = parse_conflicts(&two).expect("parse");
let counters: Vec<(u32, u32)> = segments
.iter()
.filter_map(|s| match s {
JjConflictSegment::Conflict(r) => Some((r.number, r.total)),
_ => None,
})
.collect();
assert_eq!(counters, [(1, 1), (2, 2)]);
}
#[test]
fn git_style_and_malformed_are_rejected() {
let git_style = "<<<<<<< abc 123 \"side-a\"\nx\n||||||| base\ny\n=======\nz\n>>>>>>> def\n";
let err = parse_conflicts(git_style).unwrap_err();
assert!(matches!(err, Error::Parse { .. }), "structured parse error");
assert!(
err.to_string().contains("vcs_git::conflict"),
"git-style error should redirect to vcs_git::conflict: {err}"
);
assert!(parse_conflicts("<<<<<<< conflict 1 of 1\nstray content\n").is_err());
assert!(has_conflict_markers(DIFF_STYLE));
assert!(!has_conflict_markers(git_style), "git markers aren't jj's");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn conflict_line() -> impl Strategy<Value = String> {
prop_oneof![
(1u32..3, 1u32..3).prop_map(|(n, m)| format!("<<<<<<< conflict {n} of {m}\n")),
(1u32..3, 1u32..3).prop_map(|(n, m)| format!(">>>>>>> conflict {n} of {m} ends\n")),
Just("%%%%%%% diff from: ab cd \"basé\"\n".to_string()),
Just("\\\\\\\\\\\\\\ to: ef gh \"side\"\n".to_string()),
Just("+++++++ ij kl \"side-b\"\n".to_string()),
Just("------- mn op \"base\"\n".to_string()),
"[-+ ]?[a-zé]{0,10}\n", ]
}
fn conflict_doc() -> impl Strategy<Value = String> {
prop::collection::vec(conflict_line(), 0..30).prop_map(|lines| lines.concat())
}
proptest! {
#[test]
fn parse_never_panics_on_arbitrary_text(s in any::<String>()) {
let _ = has_conflict_markers(&s);
if let Ok(segments) = parse_conflicts(&s) {
prop_assert_eq!(render(&segments), s.clone());
for seg in &segments {
if let JjConflictSegment::Conflict(r) = seg {
let _ = r.sides();
let _ = r.base();
}
}
}
}
#[test]
fn parse_never_panics_on_structured_text(s in conflict_doc()) {
let _ = parse_conflicts(&s);
}
#[test]
fn render_roundtrips_whatever_parses(s in conflict_doc()) {
if let Ok(segments) = parse_conflicts(&s) {
prop_assert_eq!(render(&segments), s);
}
}
}
}