1use processkit::{Error, Result};
17
18use crate::BINARY;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ResolutionSide {
29 Ours,
31 Base,
33 Theirs,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct ConflictRegion {
45 pub ours_label: String,
47 pub base_label: Option<String>,
49 pub theirs_label: String,
51 pub ours: Vec<String>,
53 pub base: Option<Vec<String>>,
55 pub theirs: Vec<String>,
57 pub marker_len: usize,
59 marker_ours: String,
61 marker_base: Option<String>,
62 marker_sep: String,
63 marker_end: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ConflictSegment {
75 Text(Vec<String>),
77 Conflict(Box<ConflictRegion>),
79}
80
81pub fn has_conflict_markers(content: &str) -> bool {
84 content
85 .split_inclusive('\n')
86 .any(|line| marker_run(line, '<').is_some_and(|n| n >= 7))
87}
88
89fn marker_run(line: &str, ch: char) -> Option<usize> {
92 let trimmed = line.trim_end_matches(['\r', '\n']);
93 let n = trimmed.chars().take_while(|&c| c == ch).count();
94 if n == 0 {
95 return None;
96 }
97 let rest = &trimmed[n..];
98 (rest.is_empty() || rest.starts_with(' ')).then_some(n)
99}
100
101fn marker_label(line: &str, n: usize) -> String {
103 line.trim_end_matches(['\r', '\n'])[n..]
104 .trim_start()
105 .to_string()
106}
107
108fn parse_error(message: String) -> Error {
109 Error::parse(BINARY, message)
110}
111
112pub fn parse_conflicts(content: &str) -> Result<Vec<ConflictSegment>> {
121 let mut segments = Vec::new();
122 let mut text: Vec<String> = Vec::new();
123 let mut lines = content.split_inclusive('\n').peekable();
124
125 while let Some(line) = lines.next() {
126 let Some(n) = marker_run(line, '<').filter(|&n| n >= 7) else {
135 text.push(line.to_string());
136 continue;
137 };
138 if !text.is_empty() {
139 segments.push(ConflictSegment::Text(std::mem::take(&mut text)));
140 }
141
142 let marker_ours = line.to_string();
143 let ours_label = marker_label(line, n);
144 let mut ours = Vec::new();
145 let mut base: Option<Vec<String>> = None;
146 let mut marker_base = None;
147 let mut base_label = None;
148
149 let marker_sep = loop {
151 let Some(line) = lines.next() else {
152 return Err(parse_error(format!(
153 "unterminated conflict (no ======= after {:?})",
154 marker_ours.trim_end()
155 )));
156 };
157 if base.is_none() && marker_run(line, '|') == Some(n) {
161 base_label = Some(marker_label(line, n));
162 marker_base = Some(line.to_string());
163 base = Some(Vec::new());
164 continue;
165 }
166 if marker_run(line, '=') == Some(n) {
167 break line.to_string();
168 }
169 match &mut base {
170 Some(base_lines) => base_lines.push(line.to_string()),
171 None => ours.push(line.to_string()),
172 }
173 };
174
175 let mut theirs = Vec::new();
177 let marker_end = loop {
178 let Some(line) = lines.next() else {
179 return Err(parse_error(format!(
180 "unterminated conflict (no >>>>>>> after {:?})",
181 marker_ours.trim_end()
182 )));
183 };
184 if marker_run(line, '>') == Some(n) {
185 break line.to_string();
186 }
187 theirs.push(line.to_string());
188 };
189 let theirs_label = marker_label(&marker_end, n);
190
191 segments.push(ConflictSegment::Conflict(Box::new(ConflictRegion {
192 ours_label,
193 base_label,
194 theirs_label,
195 ours,
196 base,
197 theirs,
198 marker_len: n,
199 marker_ours,
200 marker_base,
201 marker_sep,
202 marker_end,
203 })));
204 }
205 if !text.is_empty() {
206 segments.push(ConflictSegment::Text(text));
207 }
208 Ok(segments)
209}
210
211pub fn render(segments: &[ConflictSegment]) -> String {
214 let mut out = String::new();
215 for segment in segments {
216 match segment {
217 ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
218 ConflictSegment::Conflict(region) => {
219 out.push_str(®ion.marker_ours);
220 region.ours.iter().for_each(|l| out.push_str(l));
221 if let Some(marker) = ®ion.marker_base {
222 out.push_str(marker);
223 if let Some(base) = ®ion.base {
224 base.iter().for_each(|l| out.push_str(l));
225 }
226 }
227 out.push_str(®ion.marker_sep);
228 region.theirs.iter().for_each(|l| out.push_str(l));
229 out.push_str(®ion.marker_end);
230 }
231 }
232 }
233 out
234}
235
236pub fn resolve(segments: &[ConflictSegment], side: ResolutionSide) -> Result<String> {
241 let mut out = String::new();
242 for segment in segments {
243 match segment {
244 ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
245 ConflictSegment::Conflict(region) => {
246 let chosen = match side {
247 ResolutionSide::Ours => ®ion.ours,
248 ResolutionSide::Theirs => ®ion.theirs,
249 ResolutionSide::Base => region.base.as_ref().ok_or_else(|| {
250 Error::spawn(
251 BINARY,
252 std::io::Error::new(
253 std::io::ErrorKind::InvalidInput,
254 "cannot resolve to Base: this conflict records no base \
255 (2-way `merge` style; use diff3/zdiff3)",
256 ),
257 )
258 })?,
259 };
260 chosen.iter().for_each(|l| out.push_str(l));
261 }
262 }
263 }
264 Ok(out)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 const MERGE_2WAY: &str =
272 "line 1\n<<<<<<< HEAD\nmain line 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
273 const DIFF3: &str = "line 1\n<<<<<<< HEAD\nmain line 2\n||||||| 0b025ce\nline 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
274
275 #[test]
276 fn parses_two_way_merge_style() {
277 let segments = parse_conflicts(MERGE_2WAY).expect("parse");
278 assert_eq!(segments.len(), 3);
279 let ConflictSegment::Conflict(region) = &segments[1] else {
280 panic!("expected a conflict, got {segments:?}");
281 };
282 assert_eq!(region.ours_label, "HEAD");
283 assert_eq!(region.theirs_label, "feature");
284 assert_eq!(region.ours, ["main line 2\n"]);
285 assert_eq!(region.theirs, ["feature line 2\n"]);
286 assert!(region.base.is_none());
287 assert_eq!(region.marker_len, 7);
288 }
289
290 #[test]
291 fn parses_diff3_with_base() {
292 let segments = parse_conflicts(DIFF3).expect("parse");
293 let ConflictSegment::Conflict(region) = &segments[1] else {
294 panic!("expected a conflict");
295 };
296 assert_eq!(region.base_label.as_deref(), Some("0b025ce"));
297 assert_eq!(region.base.as_deref(), Some(&["line 2\n".to_string()][..]));
298 }
299
300 #[test]
305 fn repeated_base_marker_line_is_base_content() {
306 let s = "<<<<<<<< HEAD\n|||||||| base\n|||||||| base\n========\n>>>>>>>> branché\n";
307 let segments = parse_conflicts(s).expect("parse");
308 let ConflictSegment::Conflict(region) = &segments[0] else {
309 panic!("expected a conflict, got {segments:?}");
310 };
311 assert_eq!(
312 region.base.as_deref(),
313 Some(&["|||||||| base\n".to_string()][..]),
314 "the second |-run line is content of the base section"
315 );
316 assert_eq!(render(&segments), s, "roundtrip must be byte-exact");
317 }
318
319 #[test]
322 fn render_roundtrips_exactly() {
323 let crlf = "a\r\n<<<<<<< HEAD\r\nours\r\n=======\r\ntheirs\r\n>>>>>>> b\r\nz\r\n";
324 let wide = "<<<<<<<<<<<<<<< HEAD\nours\n===============\ntheirs\n>>>>>>>>>>>>>>> b\n";
325 let eof = "x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b";
326 for sample in [MERGE_2WAY, DIFF3, crlf, wide, eof] {
327 let segments = parse_conflicts(sample).expect("parse");
328 assert_eq!(render(&segments), sample, "roundtrip");
329 }
330 let segments = parse_conflicts(wide).unwrap();
332 let ConflictSegment::Conflict(region) = &segments[0] else {
333 panic!()
334 };
335 assert_eq!(region.marker_len, 15);
336 }
337
338 #[test]
339 fn resolve_takes_one_side_everywhere() {
340 let two = format!("{MERGE_2WAY}between\n{MERGE_2WAY}");
341 let segments = parse_conflicts(&two).expect("parse");
342 assert_eq!(
343 resolve(&segments, ResolutionSide::Ours).unwrap(),
344 "line 1\nmain line 2\nline 3\nbetween\nline 1\nmain line 2\nline 3\n"
345 );
346 assert_eq!(
347 resolve(&segments, ResolutionSide::Theirs).unwrap(),
348 "line 1\nfeature line 2\nline 3\nbetween\nline 1\nfeature line 2\nline 3\n"
349 );
350 assert!(resolve(&segments, ResolutionSide::Base).is_err());
352
353 let diff3 = parse_conflicts(DIFF3).expect("parse");
354 assert_eq!(
355 resolve(&diff3, ResolutionSide::Base).unwrap(),
356 "line 1\nline 2\nline 3\n"
357 );
358 }
359
360 #[test]
361 fn empty_sides_and_clean_files_parse() {
362 let deletion = "<<<<<<< HEAD\n=======\nkept\n>>>>>>> b\n";
364 let segments = parse_conflicts(deletion).expect("parse");
365 assert_eq!(resolve(&segments, ResolutionSide::Ours).unwrap(), "");
366 let clean = parse_conflicts("just\ntext\n").expect("parse");
368 assert_eq!(clean.len(), 1);
369 assert!(!has_conflict_markers("just\ntext\n"));
370 assert!(has_conflict_markers(MERGE_2WAY));
371 }
372
373 #[test]
374 fn malformed_files_are_parse_errors() {
375 for bad in [
378 "<<<<<<< HEAD\nours\n", "<<<<<<< HEAD\nours\n=======\ntheirs\n", ] {
381 assert!(
382 matches!(parse_conflicts(bad), Err(Error::Parse { .. })),
383 "{bad:?} must fail"
384 );
385 }
386 }
387
388 #[test]
392 fn marker_like_content_outside_a_region_is_text() {
393 for content in [
394 "Heading\n=======\nbody\n", "a\n=======================\nb\n", ">>>>>>> deep email quote\nreply\n", "code: a <<<<<<< b\n", ] {
399 let segments = parse_conflicts(content).expect("parses as text, no error");
400 assert!(
401 segments
402 .iter()
403 .all(|s| matches!(s, ConflictSegment::Text(_))),
404 "{content:?} must be all text, got {segments:?}"
405 );
406 assert_eq!(render(&segments), content, "round-trips byte-exact");
407 }
408 }
409}
410
411#[cfg(test)]
416mod proptests {
417 use super::*;
418 use proptest::prelude::*;
419
420 fn conflict_line() -> impl Strategy<Value = String> {
424 prop_oneof![
425 (7usize..16).prop_map(|n| format!("{} HEAD\n", "<".repeat(n))),
426 (7usize..16).prop_map(|n| format!("{}\n", "=".repeat(n))),
427 (7usize..16).prop_map(|n| format!("{} branché\n", ">".repeat(n))),
428 (7usize..16).prop_map(|n| format!("{} base\n", "|".repeat(n))),
429 "[a-zé<>=|]{0,14}\r?\n", Just("\n".to_string()),
431 ]
432 }
433
434 fn conflict_doc() -> impl Strategy<Value = String> {
435 prop::collection::vec(conflict_line(), 0..30).prop_map(|lines| lines.concat())
436 }
437
438 proptest! {
439 #[test]
440 fn parse_never_panics_on_arbitrary_text(s in any::<String>()) {
441 let _ = has_conflict_markers(&s);
442 if let Ok(segments) = parse_conflicts(&s) {
446 prop_assert_eq!(render(&segments), s);
447 }
448 }
449
450 #[test]
451 fn parse_never_panics_on_structured_text(s in conflict_doc()) {
452 let _ = parse_conflicts(&s);
453 }
454
455 #[test]
458 fn render_roundtrips_whatever_parses(s in conflict_doc()) {
459 if let Ok(segments) = parse_conflicts(&s) {
460 prop_assert_eq!(render(&segments), s);
461 }
462 }
463
464 #[test]
466 fn marker_free_files_are_a_single_text_segment(s in "[a-zé \t\r\n]{0,80}") {
467 prop_assume!(!has_conflict_markers(&s));
468 let segments = parse_conflicts(&s).expect("no markers → Ok");
469 prop_assert_eq!(render(&segments), s);
470 }
471 }
472}