Skip to main content

grit_lib/
combined_diff_patch.rs

1//! Git-style combined merge diff hunks (`diff --cc` / `diff --combined`).
2//!
3//! This approximates Git's `combine-diff.c` behaviour: per-parent Myers diffs against
4//! the merge result, lost-line lists with LCS coalescing, dense (`--cc`) hunk
5//! suppression, and `@@@` headers.
6
7use similar::{capture_diff_slices, Algorithm, DiffOp};
8
9/// Whitespace handling for combined diffs (Git `xdl_opts` subset).
10#[derive(Debug, Clone, Copy, Default)]
11pub struct CombinedDiffWsOptions {
12    /// `ignore_all_space` field.
13    pub ignore_all_space: bool,
14    /// `ignore_space_change` field.
15    pub ignore_space_change: bool,
16    /// `ignore_space_at_eol` field.
17    pub ignore_space_at_eol: bool,
18    /// `ignore_cr_at_eol` field.
19    pub ignore_cr_at_eol: bool,
20}
21
22impl CombinedDiffWsOptions {
23    #[must_use]
24    /// `any` method.
25    pub fn any(self) -> bool {
26        self.ignore_all_space
27            || self.ignore_space_change
28            || self.ignore_space_at_eol
29            || self.ignore_cr_at_eol
30    }
31}
32
33#[derive(Clone)]
34struct LostSeg {
35    text: String,
36    parent_map: u32,
37}
38
39fn strip_trailing_cr(s: &str, ignore: bool) -> &str {
40    if ignore && s.ends_with('\r') {
41        &s[..s.len().saturating_sub(1)]
42    } else {
43        s
44    }
45}
46
47fn line_key(line: &str, ws: CombinedDiffWsOptions) -> String {
48    let s = strip_trailing_cr(line, ws.ignore_cr_at_eol);
49    if ws.ignore_all_space {
50        s.chars().filter(|c| !c.is_whitespace()).collect()
51    } else if ws.ignore_space_change {
52        s.split_whitespace().collect::<Vec<_>>().join(" ")
53    } else if ws.ignore_space_at_eol {
54        s.trim_end_matches(|c: char| c.is_whitespace()).to_string()
55    } else {
56        s.to_string()
57    }
58}
59
60fn lines_match(a: &str, b: &str, ws: CombinedDiffWsOptions) -> bool {
61    line_key(a, ws) == line_key(b, ws)
62}
63
64/// Coalesce `incoming` lost segments into `base` using LCS (Git `coalesce_lines`).
65fn coalesce_lost(
66    base: Vec<LostSeg>,
67    incoming: Vec<LostSeg>,
68    parent_bit: u32,
69    ws: CombinedDiffWsOptions,
70) -> Vec<LostSeg> {
71    if incoming.is_empty() {
72        return base;
73    }
74    if base.is_empty() {
75        return incoming;
76    }
77    let ob = base.len();
78    let nw = incoming.len();
79    #[derive(Clone, Copy)]
80    enum Dir {
81        Match,
82        Base,
83        New,
84    }
85    let mut lcs = vec![vec![0usize; nw + 1]; ob + 1];
86    let mut dir = vec![vec![Dir::Base; nw + 1]; ob + 1];
87    for item in dir[0].iter_mut().skip(1) {
88        *item = Dir::New;
89    }
90    for row in dir.iter_mut().skip(1) {
91        row[0] = Dir::Base;
92    }
93    for i in 1..=ob {
94        for j in 1..=nw {
95            if lines_match(&base[i - 1].text, &incoming[j - 1].text, ws) {
96                lcs[i][j] = lcs[i - 1][j - 1] + 1;
97                dir[i][j] = Dir::Match;
98            } else if lcs[i][j - 1] >= lcs[i - 1][j] {
99                lcs[i][j] = lcs[i][j - 1];
100                dir[i][j] = Dir::New;
101            } else {
102                lcs[i][j] = lcs[i - 1][j];
103                dir[i][j] = Dir::Base;
104            }
105        }
106    }
107    let mut out: Vec<LostSeg> = Vec::new();
108    let mut i = ob;
109    let mut j = nw;
110    while i > 0 || j > 0 {
111        match dir[i][j] {
112            Dir::Match => {
113                let mut seg = base[i - 1].clone();
114                seg.parent_map |= parent_bit;
115                out.push(seg);
116                i -= 1;
117                j -= 1;
118            }
119            Dir::New => {
120                out.push(incoming[j - 1].clone());
121                j -= 1;
122            }
123            Dir::Base => {
124                out.push(base[i - 1].clone());
125                i -= 1;
126            }
127        }
128    }
129    out.reverse();
130    out
131}
132
133struct Sline {
134    /// Lost lines shown before this result line (`-` / `--` rows).
135    lost: Vec<LostSeg>,
136    /// Pending lost lines for this result line before coalescing.
137    plost: Vec<LostSeg>,
138    /// Bitmask: parent P had this result line unchanged.
139    flag: u32,
140    bol: String,
141    p_lno: Vec<u32>,
142}
143
144fn split_lines_with_incomplete(text: &str) -> (Vec<String>, usize) {
145    if text.is_empty() {
146        return (Vec::new(), 0);
147    }
148    let lines: Vec<String> = text.lines().map(str::to_owned).collect();
149    let cnt = lines.len();
150    (lines, cnt)
151}
152
153fn combine_one_parent(
154    slines: &mut [Sline],
155    cnt: usize,
156    parent_lines: &[String],
157    n: usize,
158    _num_parent: usize,
159    ws: CombinedDiffWsOptions,
160) {
161    let nmask = 1u32 << n;
162    let old_keys: Vec<String> = parent_lines.iter().map(|l| line_key(l, ws)).collect();
163    let new_keys: Vec<String> = slines[..cnt].iter().map(|s| line_key(&s.bol, ws)).collect();
164    let ops = capture_diff_slices(Algorithm::Myers, &old_keys, &new_keys);
165
166    // Group consecutive non-Equal ops into hunks, mirroring xdiff's hunk emission.
167    // Within each hunk we replicate combine-diff.c's consume_hunk()/consume_line():
168    // the per-hunk "lost bucket" is chosen from the result-side hunk start, and all
169    // '-' lines of the hunk are appended to that one bucket in order.
170    let mut idx = 0usize;
171    while idx < ops.len() {
172        if matches!(ops[idx], DiffOp::Equal { .. }) {
173            idx += 1;
174            continue;
175        }
176        // Collect a maximal run of change ops as one hunk.
177        let start = idx;
178        let mut del_lines: Vec<usize> = Vec::new();
179        let mut ins_lines: Vec<usize> = Vec::new();
180        // new-begin (nb) and new-count (nn): result-side coordinates of the hunk.
181        let mut nb: Option<usize> = None;
182        let mut nn = 0usize;
183        while idx < ops.len() && !matches!(ops[idx], DiffOp::Equal { .. }) {
184            match ops[idx] {
185                DiffOp::Delete {
186                    old_index,
187                    old_len,
188                    new_index,
189                    ..
190                } => {
191                    if nb.is_none() {
192                        nb = Some(new_index);
193                    }
194                    for k in 0..old_len {
195                        del_lines.push(old_index + k);
196                    }
197                }
198                DiffOp::Insert {
199                    old_index: _,
200                    new_index,
201                    new_len,
202                } => {
203                    if nb.is_none() {
204                        nb = Some(new_index);
205                    }
206                    for k in 0..new_len {
207                        ins_lines.push(new_index + k);
208                        nn += 1;
209                    }
210                }
211                DiffOp::Replace {
212                    old_index,
213                    old_len,
214                    new_index,
215                    new_len,
216                } => {
217                    if nb.is_none() {
218                        nb = Some(new_index);
219                    }
220                    for k in 0..old_len {
221                        del_lines.push(old_index + k);
222                    }
223                    for k in 0..new_len {
224                        ins_lines.push(new_index + k);
225                        nn += 1;
226                    }
227                }
228                DiffOp::Equal { .. } => unreachable!(),
229            }
230            idx += 1;
231        }
232        let _ = start;
233        // nb here is 0-based (result index where the hunk's first change sits).
234        // Git's consume_hunk uses 1-based nb: bucket = sline[nb-1] normally, or
235        // sline[nb] when the hunk adds nothing to the result (nn == 0). With our
236        // 0-based nb, that translates to: bucket = nb (nn == 0) or nb (since
237        // sline[(nb+1)-1] == sline[nb]). Concretely git's nb_1based = nb0 + 1.
238        let nb0 = nb.unwrap_or(0);
239        let bucket = if nn == 0 {
240            // Lost lines hang on the result line *after* the removed span.
241            (nb0).min(cnt)
242        } else {
243            // Lost lines hang on the first result line of the hunk.
244            nb0.min(cnt)
245        };
246        let bucket = bucket.min(cnt);
247        for &oi in &del_lines {
248            slines[bucket].plost.push(LostSeg {
249                text: parent_lines[oi].clone(),
250                parent_map: nmask,
251            });
252        }
253        for &ni in &ins_lines {
254            if ni < cnt {
255                slines[ni].flag |= nmask;
256            }
257        }
258    }
259
260    let mut p_lno = 1u32;
261    for (lno, sline) in slines.iter_mut().take(cnt + 1).enumerate() {
262        sline.p_lno[n] = p_lno;
263        if !sline.plost.is_empty() {
264            let incoming = std::mem::take(&mut sline.plost);
265            sline.lost = coalesce_lost(std::mem::take(&mut sline.lost), incoming, nmask, ws);
266        }
267        for seg in &sline.lost {
268            if seg.parent_map & nmask != 0 {
269                p_lno = p_lno.saturating_add(1);
270            }
271        }
272        if lno < cnt && sline.flag & nmask == 0 {
273            p_lno = p_lno.saturating_add(1);
274        }
275    }
276    // Trailer: p_lno[cnt + 1] is the end line number, read when a hunk extends to
277    // the end of the file (matches Git's `sline[cnt + 1].p_lno[n] = p_lno`).
278    if let Some(trailer) = slines.get_mut(cnt + 1) {
279        trailer.p_lno[n] = p_lno;
280    }
281}
282
283fn interesting(s: &Sline, all_mask: u32) -> bool {
284    (s.flag & all_mask) != 0 || !s.lost.is_empty()
285}
286
287fn find_next(slines: &[Sline], mark: u32, mut i: usize, cnt: usize, want_unmarked: bool) -> usize {
288    while i <= cnt {
289        let marked = slines[i].flag & mark != 0;
290        if want_unmarked != marked {
291            return i;
292        }
293        i += 1;
294    }
295    cnt + 1
296}
297
298fn adjust_hunk_tail(slines: &[Sline], all_mask: u32, hunk_begin: usize, mut i: usize) -> usize {
299    if hunk_begin < i && slines[i - 1].flag & all_mask == 0 {
300        i -= 1;
301    }
302    i
303}
304
305fn give_context(slines: &mut [Sline], cnt: usize, num_parent: usize, context: usize) {
306    let all_mask = (1u32 << num_parent) - 1;
307    let mark = 1u32 << num_parent;
308    let no_pre_delete = 2u32 << num_parent;
309
310    let mut i = find_next(slines, mark, 0, cnt, false);
311    if cnt < i {
312        return;
313    }
314
315    while i <= cnt {
316        let mut j = i.saturating_sub(context);
317        while j < i {
318            if slines[j].flag & mark == 0 {
319                slines[j].flag |= no_pre_delete;
320            }
321            slines[j].flag |= mark;
322            j += 1;
323        }
324
325        loop {
326            j = find_next(slines, mark, i, cnt, true);
327            if cnt < j {
328                return;
329            }
330            let k = find_next(slines, mark, j, cnt, false);
331            let j_adj = adjust_hunk_tail(slines, all_mask, i, j);
332
333            if k < j_adj + context {
334                let mut t = j;
335                while t < k {
336                    slines[t].flag |= mark;
337                    t += 1;
338                }
339                i = k;
340                continue;
341            }
342
343            i = k;
344            let k_end = (j + context).min(cnt + 1);
345            let mut t = j;
346            while t < k_end {
347                slines[t].flag |= mark;
348                t += 1;
349            }
350            break;
351        }
352    }
353}
354
355fn make_hunks(slines: &mut [Sline], cnt: usize, num_parent: usize, dense: bool, context: usize) {
356    let all_mask = (1u32 << num_parent) - 1;
357    let mark = 1u32 << num_parent;
358
359    for sline in slines.iter_mut().take(cnt + 1) {
360        if interesting(sline, all_mask) {
361            sline.flag |= mark;
362        } else {
363            sline.flag &= !mark;
364        }
365    }
366
367    if dense {
368        let mut i = 0usize;
369        while i <= cnt {
370            while i <= cnt && slines[i].flag & mark == 0 {
371                i += 1;
372            }
373            if cnt < i {
374                break;
375            }
376            let hunk_begin = i;
377            let mut j = i + 1;
378            while j <= cnt {
379                if slines[j].flag & mark == 0 {
380                    let j_adj = adjust_hunk_tail(slines, all_mask, hunk_begin, j);
381                    let la = (j_adj + context).min(cnt + 1);
382                    let mut contin = false;
383                    let mut la2 = la;
384                    while la2 > j {
385                        la2 -= 1;
386                        if slines[la2].flag & mark != 0 {
387                            contin = true;
388                            break;
389                        }
390                    }
391                    if !contin {
392                        break;
393                    }
394                    j = la2;
395                }
396                j += 1;
397            }
398            let hunk_end = j;
399
400            let mut same_diff = 0u32;
401            let mut has_interesting = false;
402            let mut jj = hunk_begin;
403            while jj < hunk_end && !has_interesting {
404                let mut this_diff = slines[jj].flag & all_mask;
405                if this_diff != 0 {
406                    if same_diff == 0 {
407                        same_diff = this_diff;
408                    } else if same_diff != this_diff {
409                        has_interesting = true;
410                        break;
411                    }
412                }
413                let ll_iter = slines[jj].lost.iter();
414                for seg in ll_iter {
415                    if has_interesting {
416                        break;
417                    }
418                    this_diff = seg.parent_map;
419                    if same_diff == 0 {
420                        same_diff = this_diff;
421                    } else if same_diff != this_diff {
422                        has_interesting = true;
423                    }
424                }
425                jj += 1;
426            }
427            if !has_interesting && same_diff != 0 && same_diff != all_mask {
428                for sline in slines.iter_mut().take(hunk_end).skip(hunk_begin) {
429                    sline.flag &= !mark;
430                }
431            }
432            i = hunk_end;
433        }
434    }
435
436    give_context(slines, cnt, num_parent, context);
437}
438
439fn show_parent_lno(slines: &[Sline], l0: usize, l1: usize, n: usize, null_ctx: u32) -> String {
440    let a = slines[l0].p_lno[n];
441    let b = slines[l1].p_lno[n];
442    format!(" -{a},{}", b.saturating_sub(a).saturating_sub(null_ctx))
443}
444
445/// Line role in a combined (`--cc`) hunk (derived from parent-column markers).
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum CombinedStructuredLineKind {
448    /// Unchanged relative to all parents that have the line.
449    Context,
450    /// Present on the merge result but not on at least one parent (`+` column).
451    Add,
452    /// Removed from at least one parent (`-` column).
453    Del,
454}
455
456impl CombinedStructuredLineKind {
457    /// Classify a combined line from Git parent-column markers.
458    #[must_use]
459    pub fn from_parent_markers(parents: &[char]) -> Self {
460        if parents.contains(&'-') {
461            Self::Del
462        } else if parents.contains(&'+') {
463            Self::Add
464        } else {
465            Self::Context
466        }
467    }
468}
469
470/// One line in a combined (`--cc`) hunk: parent-column markers plus text.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct CombinedStructuredLine {
473    /// One marker per parent: space, `+`, or `-` (Git combined diff columns).
474    pub parents: Vec<char>,
475    /// Line text without parent-column prefixes.
476    pub text: String,
477    /// Add / delete / context for JSON and CLI rendering.
478    pub kind: CombinedStructuredLineKind,
479}
480
481/// A combined hunk (`@@@` header and body lines).
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct CombinedStructuredHunk {
484    /// Full `@@@` header line (without trailing newline).
485    pub header: String,
486    /// Body lines in display order.
487    pub lines: Vec<CombinedStructuredLine>,
488}
489
490/// Structured combined diff body (no `diff --cc` file header).
491#[derive(Debug, Clone, PartialEq, Eq, Default)]
492pub struct CombinedStructuredTextDiff {
493    /// Hunks in file order.
494    pub hunks: Vec<CombinedStructuredHunk>,
495}
496
497fn dump_slines_structured(
498    slines: &[Sline],
499    cnt: usize,
500    num_parent: usize,
501    context: usize,
502) -> CombinedStructuredTextDiff {
503    let mark = 1u32 << num_parent;
504    let no_pre_delete = 2u32 << num_parent;
505    let mut hunks = Vec::new();
506    let mut lno = 0usize;
507
508    loop {
509        while lno <= cnt && slines[lno].flag & mark == 0 {
510            lno += 1;
511        }
512        if cnt < lno {
513            break;
514        }
515        let h_start = lno;
516        let mut h_end = lno + 1;
517        while h_end <= cnt && slines[h_end].flag & mark != 0 {
518            h_end += 1;
519        }
520        let mut rlines = h_end - h_start;
521        if cnt < h_end {
522            rlines = rlines.saturating_sub(1);
523        }
524        let mut null_ctx = 0u32;
525        if context == 0 {
526            for sl in slines.iter().take(h_end).skip(h_start) {
527                if sl.flag & (mark - 1) == 0 {
528                    null_ctx = null_ctx.saturating_add(1);
529                }
530            }
531            rlines = rlines.saturating_sub(null_ctx as usize);
532        }
533        let mut header = String::new();
534        for _ in 0..=num_parent {
535            header.push('@');
536        }
537        for n in 0..num_parent {
538            header.push_str(&show_parent_lno(slines, h_start, h_end, n, null_ctx));
539        }
540        header.push_str(&format!(" +{},{} ", h_start + 1, rlines));
541        for _ in 0..=num_parent {
542            header.push('@');
543        }
544        let mut lines = Vec::new();
545
546        while lno < h_end {
547            let sl = &slines[lno];
548            lno += 1;
549            let show_lost = if sl.flag & no_pre_delete == 0 {
550                sl.lost.as_slice()
551            } else {
552                &[][..]
553            };
554            for seg in show_lost {
555                let mut parents = Vec::with_capacity(num_parent);
556                for j in 0..num_parent {
557                    parents.push(if seg.parent_map & (1u32 << j) != 0 {
558                        '-'
559                    } else {
560                        ' '
561                    });
562                }
563                lines.push(CombinedStructuredLine {
564                    kind: CombinedStructuredLineKind::from_parent_markers(&parents),
565                    parents,
566                    text: seg.text.clone(),
567                });
568            }
569            if cnt < lno {
570                break;
571            }
572            let sl = &slines[lno - 1];
573            if sl.flag & (mark - 1) == 0 && context == 0 {
574                continue;
575            }
576            let mut parents = Vec::with_capacity(num_parent);
577            let mut p_mask = 1u32;
578            for _ in 0..num_parent {
579                parents.push(if p_mask & sl.flag != 0 { '+' } else { ' ' });
580                p_mask <<= 1;
581            }
582            lines.push(CombinedStructuredLine {
583                kind: CombinedStructuredLineKind::from_parent_markers(&parents),
584                parents,
585                text: sl.bol.clone(),
586            });
587        }
588        hunks.push(CombinedStructuredHunk { header, lines });
589    }
590    CombinedStructuredTextDiff { hunks }
591}
592
593fn dump_slines(slines: &[Sline], cnt: usize, num_parent: usize, context: usize) -> String {
594    let mark = 1u32 << num_parent;
595    let no_pre_delete = 2u32 << num_parent;
596    let mut out = String::new();
597    let mut lno = 0usize;
598
599    loop {
600        while lno <= cnt && slines[lno].flag & mark == 0 {
601            lno += 1;
602        }
603        if cnt < lno {
604            break;
605        }
606        let h_start = lno;
607        let mut h_end = lno + 1;
608        while h_end <= cnt && slines[h_end].flag & mark != 0 {
609            h_end += 1;
610        }
611        let mut rlines = h_end - h_start;
612        if cnt < h_end {
613            rlines = rlines.saturating_sub(1);
614        }
615        let mut null_ctx = 0u32;
616        if context == 0 {
617            for sl in slines.iter().take(h_end).skip(h_start) {
618                if sl.flag & (mark - 1) == 0 {
619                    null_ctx = null_ctx.saturating_add(1);
620                }
621            }
622            rlines = rlines.saturating_sub(null_ctx as usize);
623        }
624
625        // Git emits `num_parent + 1` `@` characters on each side of a combined hunk
626        // header (e.g. `@@@` for a two-parent merge).
627        for _ in 0..=num_parent {
628            out.push('@');
629        }
630        for n in 0..num_parent {
631            out.push_str(&show_parent_lno(slines, h_start, h_end, n, null_ctx));
632        }
633        out.push_str(&format!(" +{},{} ", h_start + 1, rlines));
634        for _ in 0..=num_parent {
635            out.push('@');
636        }
637        out.push('\n');
638
639        while lno < h_end {
640            let sl = &slines[lno];
641            lno += 1;
642            let show_lost = if sl.flag & no_pre_delete == 0 {
643                sl.lost.as_slice()
644            } else {
645                &[][..]
646            };
647            // Each combined line is prefixed with exactly `num_parent` columns
648            // (one per parent), like Git's dump_sline(); there is no extra leading
649            // result-marker column.
650            for seg in show_lost {
651                for j in 0..num_parent {
652                    if seg.parent_map & (1u32 << j) != 0 {
653                        out.push('-');
654                    } else {
655                        out.push(' ');
656                    }
657                }
658                out.push_str(&seg.text);
659                out.push('\n');
660            }
661            if cnt < lno {
662                break;
663            }
664            let sl = &slines[lno - 1];
665            if sl.flag & (mark - 1) == 0 && context == 0 {
666                continue;
667            }
668            let mut p_mask = 1u32;
669            for _ in 0..num_parent {
670                if p_mask & sl.flag != 0 {
671                    out.push('+');
672                } else {
673                    out.push(' ');
674                }
675                p_mask <<= 1;
676            }
677            out.push_str(&sl.bol);
678            out.push('\n');
679        }
680    }
681    out
682}
683
684fn reuse_parent(slines: &mut [Sline], cnt: usize, i: usize, j: usize) {
685    let im = 1u32 << i;
686    let jm = 1u32 << j;
687    for sline in slines.iter_mut().take(cnt + 1) {
688        for seg in &mut sline.lost {
689            if seg.parent_map & jm != 0 {
690                seg.parent_map |= im;
691            }
692        }
693        if sline.flag & jm != 0 {
694            sline.flag |= im;
695        }
696        sline.p_lno[i] = sline.p_lno[j];
697    }
698    // Mirror the trailer (sline[cnt + 1]) so an EOF-spanning hunk reports the right
699    // end line number for the reused parent.
700    if let Some(trailer) = slines.get_mut(cnt + 1) {
701        trailer.p_lno[i] = trailer.p_lno[j];
702    }
703}
704
705fn prepare_combined_slines(
706    parent_texts: &[String],
707    result_text: &str,
708    ws: CombinedDiffWsOptions,
709) -> Option<(Vec<Sline>, usize, usize)> {
710    let num_parent = parent_texts.len();
711    if num_parent == 0 {
712        return None;
713    }
714    let (res_lines, cnt) = split_lines_with_incomplete(result_text);
715    if cnt == 0 && result_text.is_empty() && parent_texts.iter().all(String::is_empty) {
716        return None;
717    }
718    let mut slines: Vec<Sline> = (0..=cnt + 1)
719        .map(|idx| Sline {
720            lost: Vec::new(),
721            plost: Vec::new(),
722            flag: 0,
723            bol: if idx < cnt {
724                res_lines[idx].clone()
725            } else {
726                String::new()
727            },
728            p_lno: vec![0; num_parent],
729        })
730        .collect();
731    let parents: Vec<Vec<String>> = parent_texts
732        .iter()
733        .map(|p| p.lines().map(str::to_owned).collect())
734        .collect();
735    for i in 0..num_parent {
736        let mut reused = false;
737        for j in 0..i {
738            if parents[i] == parents[j] {
739                reuse_parent(&mut slines, cnt, i, j);
740                reused = true;
741                break;
742            }
743        }
744        if !reused {
745            combine_one_parent(&mut slines, cnt, &parents[i], i, num_parent, ws);
746        }
747    }
748    Some((slines, cnt, num_parent))
749}
750
751/// Structured combined diff body: `@@@` hunks with per-parent columns on each line.
752#[must_use]
753pub fn structured_combined_diff_body(
754    parent_texts: &[String],
755    result_text: &str,
756    context: usize,
757    dense: bool,
758    ws: CombinedDiffWsOptions,
759) -> CombinedStructuredTextDiff {
760    let Some((mut slines, cnt, num_parent)) =
761        prepare_combined_slines(parent_texts, result_text, ws)
762    else {
763        return CombinedStructuredTextDiff::default();
764    };
765    make_hunks(&mut slines, cnt, num_parent, dense, context);
766    dump_slines_structured(&slines, cnt, num_parent, context)
767}
768
769/// Serialize [`CombinedStructuredTextDiff`] to Git combined patch body text.
770#[must_use]
771pub fn format_combined_structured_body(diff: &CombinedStructuredTextDiff) -> String {
772    let mut out = String::new();
773    for hunk in &diff.hunks {
774        out.push_str(&hunk.header);
775        out.push('\n');
776        for line in &hunk.lines {
777            for ch in &line.parents {
778                out.push(*ch);
779            }
780            out.push_str(&line.text);
781            out.push('\n');
782        }
783    }
784    out
785}
786
787/// Combined diff body: `@@@` hunks and parent/result lines (no `diff --cc` header).
788#[must_use]
789pub fn format_combined_diff_body(
790    parent_texts: &[String],
791    result_text: &str,
792    context: usize,
793    dense: bool,
794    ws: CombinedDiffWsOptions,
795) -> String {
796    let Some((mut slines, cnt, num_parent)) =
797        prepare_combined_slines(parent_texts, result_text, ws)
798    else {
799        return String::new();
800    };
801    make_hunks(&mut slines, cnt, num_parent, dense, context);
802    dump_slines(&slines, cnt, num_parent, context)
803}