Skip to main content

gpui_component/diff/
document.rs

1use std::{ops::Range, sync::Arc};
2
3use gpui::SharedString;
4
5use super::conflict::DiffConflict;
6use smallvec::{SmallVec, smallvec};
7use unicode_segmentation::UnicodeSegmentation as _;
8
9/// Which source version a position belongs to.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum DiffSide {
12    Original,
13    Modified,
14}
15
16impl DiffSide {
17    pub(crate) fn other(self) -> Self {
18        match self {
19            Self::Original => Self::Modified,
20            Self::Modified => Self::Original,
21        }
22    }
23}
24
25/// A one-based source line, independent of the visual layout.
26///
27/// The file is identified by its [`DiffFile::path`], which stays stable when a
28/// newer revision of the patch reorders its files.
29#[derive(Clone, Debug, PartialEq, Eq, Hash)]
30pub struct DiffLinePosition {
31    path: SharedString,
32    side: DiffSide,
33    line: usize,
34}
35
36impl DiffLinePosition {
37    /// Creates a position, clamping the line to at least one.
38    pub fn new(path: impl Into<SharedString>, side: DiffSide, line: usize) -> Self {
39        Self {
40            path: path.into(),
41            side,
42            line: line.max(1),
43        }
44    }
45    /// The [`DiffFile::path`] of the file.
46    pub fn path(&self) -> &SharedString {
47        &self.path
48    }
49    pub fn side(&self) -> DiffSide {
50        self.side
51    }
52    pub fn line(&self) -> usize {
53        self.line
54    }
55}
56
57/// An inclusive range of source lines in one file, identified by its
58/// [`DiffFile::path`].
59///
60/// A range starts at `start` on [`Self::side`] and ends at `end` on
61/// [`Self::end_side`]. When both sides are the same it covers that side's lines
62/// between the two numbers, in either order. A range ending on the other side
63/// covers the patch body between the two lines in patch order, as a Unified
64/// view shows it.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct DiffLineRange {
67    path: SharedString,
68    side: DiffSide,
69    start: usize,
70    end_side: DiffSide,
71    end: usize,
72}
73
74impl DiffLineRange {
75    /// Creates a range on one side, clamping the lines to at least one.
76    pub fn new(path: impl Into<SharedString>, side: DiffSide, start: usize, end: usize) -> Self {
77        Self {
78            path: path.into(),
79            side,
80            start: start.max(1),
81            end_side: side,
82            end: end.max(1),
83        }
84    }
85    /// Ends the range on `side`, keeping `end` as its line on that side.
86    pub fn with_end_side(mut self, side: DiffSide) -> Self {
87        self.end_side = side;
88        self
89    }
90    /// The [`DiffFile::path`] of the file.
91    pub fn path(&self) -> &SharedString {
92        &self.path
93    }
94    /// The side `start` is on.
95    pub fn side(&self) -> DiffSide {
96        self.side
97    }
98    pub fn start(&self) -> usize {
99        self.start
100    }
101    /// The side `end` is on; the same as [`Self::side`] unless set.
102    pub fn end_side(&self) -> DiffSide {
103        self.end_side
104    }
105    pub fn end(&self) -> usize {
106        self.end
107    }
108    pub(crate) fn start_position(&self) -> DiffLinePosition {
109        DiffLinePosition::new(self.path.clone(), self.side, self.start)
110    }
111    pub(crate) fn end_position(&self) -> DiffLinePosition {
112        DiffLinePosition::new(self.path.clone(), self.end_side, self.end)
113    }
114    /// Whether the range covers lines on one side only.
115    pub(crate) fn is_single_side(&self) -> bool {
116        self.side == self.end_side
117    }
118}
119
120/// One supplied source line and its display form.
121#[derive(Debug)]
122pub(crate) struct SourceLine {
123    line_number: usize,
124    text: SharedString,
125    display: SharedString,
126    chunks: SmallVec<[DisplayChunk; 1]>,
127    counterpart: Option<usize>,
128    // Only expanded tabs need an entry; ordinary UTF-8 bytes map by identity
129    // plus the cumulative expansion of earlier tabs.
130    display_tabs: Vec<DisplayTab>,
131    source: Range<usize>,
132    content_end: usize,
133}
134
135#[derive(Debug)]
136struct DisplayChunk {
137    range: Range<usize>,
138    text: SharedString,
139}
140
141#[derive(Debug)]
142struct DisplayTab {
143    source_offset: usize,
144    display_offset: usize,
145    width: usize,
146}
147
148impl SourceLine {
149    /// The one-based line number in the source file.
150    pub(crate) fn line_number(&self) -> usize {
151        self.line_number
152    }
153    /// The source content, without its line ending.
154    pub(crate) fn text(&self) -> &SharedString {
155        &self.text
156    }
157    /// The content with tabs expanded, as painted.
158    pub(crate) fn display(&self) -> &SharedString {
159        &self.display
160    }
161    /// Display ranges painted as separate text runs, bounding shaping cost.
162    pub(crate) fn chunk_ranges(&self) -> impl Iterator<Item = Range<usize>> + '_ {
163        self.chunks.iter().map(|chunk| chunk.range.clone())
164    }
165    /// The index of the paired line on the other side, if any.
166    pub(crate) fn counterpart(&self) -> Option<usize> {
167        self.counterpart
168    }
169    /// The byte range in the side's source, including the line ending.
170    pub(crate) fn source(&self) -> Range<usize> {
171        self.source.clone()
172    }
173    /// The source offset where the line ending begins.
174    pub(crate) fn content_end(&self) -> usize {
175        self.content_end
176    }
177
178    /// Reuses the retained text for a projected chunk. The fallback supports
179    /// valid partial ranges without making ordinary rendering allocate.
180    pub(crate) fn chunk_text(&self, range: &Range<usize>) -> SharedString {
181        if range.start == 0 && range.end == self.display.len() {
182            return self.display.clone();
183        }
184        let ix = self
185            .chunks
186            .partition_point(|chunk| chunk.range.start < range.start);
187        if let Some(chunk) = self.chunks.get(ix)
188            && chunk.range == *range
189        {
190            return chunk.text.clone();
191        }
192        self.display
193            .get(range.clone())
194            .map_or_else(SharedString::default, SharedString::from)
195    }
196
197    pub(crate) fn source_offset(&self, display_offset: usize) -> usize {
198        let display_offset = display_offset.min(self.display.len());
199        let preceding = self
200            .display_tabs
201            .partition_point(|tab| tab.display_offset <= display_offset);
202        let Some(tab) = preceding.checked_sub(1).map(|ix| &self.display_tabs[ix]) else {
203            return display_offset;
204        };
205        if display_offset < tab.display_offset + tab.width {
206            tab.source_offset
207        } else {
208            display_offset - (tab.display_offset + tab.width - tab.source_offset - 1)
209        }
210    }
211    pub(crate) fn display_range(&self, source: Range<usize>) -> Range<usize> {
212        self.display_offset(source.start)..self.display_offset(source.end)
213    }
214
215    fn display_offset(&self, source_offset: usize) -> usize {
216        let source_offset = source_offset.min(self.text.len());
217        let preceding = self
218            .display_tabs
219            .partition_point(|tab| tab.source_offset < source_offset);
220        let expansion = preceding.checked_sub(1).map_or(0, |ix| {
221            let tab = &self.display_tabs[ix];
222            tab.display_offset + tab.width - tab.source_offset - 1
223        });
224        source_offset + expansion
225    }
226}
227
228/// One row of the side-by-side alignment: a line on either or both sides.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub(crate) struct LinePair {
231    original: Option<usize>,
232    modified: Option<usize>,
233    changed: bool,
234}
235
236impl LinePair {
237    pub(crate) fn new(original: Option<usize>, modified: Option<usize>, changed: bool) -> Self {
238        Self {
239            original,
240            modified,
241            changed,
242        }
243    }
244    pub(crate) fn original(&self) -> Option<usize> {
245        self.original
246    }
247    pub(crate) fn modified(&self) -> Option<usize> {
248        self.modified
249    }
250    pub(crate) fn is_changed(&self) -> bool {
251        self.changed
252    }
253}
254
255/// One `@@` hunk: its pairs and the source lines it supplies on each side.
256#[derive(Clone, Debug)]
257pub(crate) struct Hunk {
258    pairs: Range<usize>,
259    original: Range<usize>,
260    modified: Range<usize>,
261    label: SharedString,
262    /// The one-based original lines the header declares.
263    original_lines: Range<usize>,
264}
265
266impl Hunk {
267    pub(crate) fn new(
268        pairs: Range<usize>,
269        original: Range<usize>,
270        modified: Range<usize>,
271        label: SharedString,
272        original_lines: Range<usize>,
273    ) -> Self {
274        Self {
275            pairs,
276            original,
277            modified,
278            label,
279            original_lines,
280        }
281    }
282    /// Original lines between the previous hunk, or the file start, and this
283    /// one that the patch does not supply.
284    pub(crate) fn hidden_lines_before(&self, previous: Option<&Hunk>) -> usize {
285        let after = previous.map_or(1, |previous| previous.original_lines.end);
286        self.original_lines.start.saturating_sub(after)
287    }
288    pub(crate) fn pairs(&self) -> Range<usize> {
289        self.pairs.clone()
290    }
291    pub(crate) fn lines(&self, side: DiffSide) -> Range<usize> {
292        match side {
293            DiffSide::Original => self.original.clone(),
294            DiffSide::Modified => self.modified.clone(),
295        }
296    }
297    /// The `@@` header line.
298    pub(crate) fn label(&self) -> &SharedString {
299        &self.label
300    }
301}
302
303/// One side of a file: its path, unless the side is missing such as
304/// `/dev/null`, and the source fragments the patch supplies.
305#[derive(Default)]
306pub(crate) struct FileSide {
307    path: Option<SharedString>,
308    source: SharedString,
309    lines: Vec<SourceLine>,
310}
311
312impl FileSide {
313    /// Builds a side from its concatenated source and each line's number and
314    /// byte range in it.
315    pub(crate) fn new(
316        path: Option<SharedString>,
317        source: String,
318        lines: impl IntoIterator<Item = (usize, Range<usize>)>,
319    ) -> Self {
320        let lines = lines
321            .into_iter()
322            .map(|(line_number, range)| source_line(&source, range, line_number))
323            .collect();
324        Self {
325            path,
326            source: source.into(),
327            lines,
328        }
329    }
330}
331
332/// How a file changed, in Git's status terms.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
334#[non_exhaustive]
335pub enum DiffFileStatus {
336    Added,
337    Deleted,
338    Modified,
339    Renamed,
340    Copied,
341    /// A whole file shown for reference, from [`DiffFile::unchanged`].
342    Unchanged,
343    /// A working file with conflict markers, from [`DiffFile::parse_conflicts`].
344    Conflicted,
345}
346
347/// One line of the patch body in patch order: an unchanged line on both
348/// sides, or a deleted or added line on one side.
349#[derive(Clone, Copy, Debug, PartialEq, Eq)]
350pub(crate) struct PatchLine {
351    original: Option<usize>,
352    modified: Option<usize>,
353}
354
355impl PatchLine {
356    pub(crate) fn line(&self, side: DiffSide) -> Option<usize> {
357        match side {
358            DiffSide::Original => self.original,
359            DiffSide::Modified => self.modified,
360        }
361    }
362}
363
364struct DocumentInner {
365    path: SharedString,
366    status: DiffFileStatus,
367    original: FileSide,
368    modified: FileSide,
369    pairs: Vec<LinePair>,
370    hunks: Vec<Hunk>,
371    additions: usize,
372    deletions: usize,
373    extended_headers: Vec<SharedString>,
374    binary: bool,
375    /// Width of the widest line number, in digits.
376    line_number_digits: usize,
377    /// The patch body in order; each side's lines map back into it.
378    patch_lines: Vec<PatchLine>,
379    patch_ixs: [Vec<usize>; 2],
380    conflicts: Vec<DiffConflict>,
381}
382
383/// One changed file parsed from a unified or Git diff.
384///
385/// Only source fragments present in the patch are retained; unavailable
386/// context is never reconstructed or compared. Cloning is cheap.
387#[derive(Clone)]
388pub struct DiffFile {
389    inner: Arc<DocumentInner>,
390    language: Option<SharedString>,
391}
392
393impl DiffFile {
394    /// Parses every file in a unified or Git diff, in patch order, without
395    /// computing differences. Syntax emphasis is prepared later by
396    /// [`super::DiffState`] on a background thread.
397    pub fn parse(patch: &str) -> Result<Vec<Self>, super::parser::DiffParseError> {
398        super::parser::parse(patch)
399    }
400
401    /// Assembles a parsed file, pairing counterpart lines and deriving its
402    /// display path, change counts and line-number width.
403    pub(crate) fn new(
404        status: DiffFileStatus,
405        mut original: FileSide,
406        mut modified: FileSide,
407        pairs: Vec<LinePair>,
408        hunks: Vec<Hunk>,
409        extended_headers: Vec<SharedString>,
410        binary: bool,
411    ) -> Self {
412        let (mut additions, mut deletions) = (0, 0);
413        for pair in &pairs {
414            // Conflict lines are unresolved alternatives, not additions.
415            if pair.changed && status != DiffFileStatus::Conflicted {
416                additions += usize::from(pair.modified.is_some());
417                deletions += usize::from(pair.original.is_some());
418            }
419            if let (Some(old), Some(new)) = (pair.original, pair.modified) {
420                original.lines[old].counterpart = Some(new);
421                modified.lines[new].counterpart = Some(old);
422            }
423        }
424        let widest = [&original, &modified]
425            .iter()
426            .filter_map(|side| side.lines.last())
427            .map(|line| line.line_number)
428            .max()
429            .unwrap_or(1);
430        let path = modified
431            .path
432            .clone()
433            .or_else(|| original.path.clone())
434            .unwrap_or_default();
435        let (patch_lines, patch_ixs) = patch_order(&pairs, &hunks, &original, &modified);
436        Self {
437            inner: Arc::new(DocumentInner {
438                path,
439                status,
440                original,
441                modified,
442                pairs,
443                hunks,
444                additions,
445                deletions,
446                extended_headers,
447                binary,
448                line_number_digits: widest.max(1).ilog10() as usize + 1,
449                patch_lines,
450                patch_ixs,
451                conflicts: Vec::new(),
452            }),
453            language: None,
454        }
455    }
456
457    /// Overrides the syntax language detected from the path, using a
458    /// highlighter language name such as `rust` or `javascript`.
459    pub fn with_language(mut self, language: impl Into<SharedString>) -> Self {
460        self.language = Some(language.into());
461        self
462    }
463    /// The syntax language: the override, or the one detected from the path.
464    pub fn language(&self) -> SharedString {
465        self.language
466            .clone()
467            .unwrap_or_else(|| super::presentation::detected_language(self.path()))
468    }
469    pub(crate) fn language_override(&self) -> Option<&SharedString> {
470        self.language.as_ref()
471    }
472    /// How the file changed.
473    pub fn status(&self) -> DiffFileStatus {
474        self.inner.status
475    }
476
477    /// The path to show for this file: the modified path, or the original path
478    /// of a deleted file.
479    pub fn path(&self) -> &SharedString {
480        &self.inner.path
481    }
482    /// The original path, or `None` for an added file.
483    pub fn original_path(&self) -> Option<&SharedString> {
484        self.inner.original.path.as_ref()
485    }
486    /// The modified path, or `None` for a deleted file.
487    pub fn modified_path(&self) -> Option<&SharedString> {
488        self.inner.modified.path.as_ref()
489    }
490    /// Git extended header lines, such as file modes, similarity, renames
491    /// and the binary marker, as they appear in the patch.
492    pub fn extended_headers(&self) -> &[SharedString] {
493        &self.inner.extended_headers
494    }
495    /// Whether the patch reports binary contents rather than textual hunks.
496    pub fn is_binary(&self) -> bool {
497        self.inner.binary
498    }
499    pub fn additions(&self) -> usize {
500        self.inner.additions
501    }
502    pub fn deletions(&self) -> usize {
503        self.inner.deletions
504    }
505    /// Whether the file changes, including an added or deleted empty file and
506    /// metadata-only changes such as a mode change or pure rename.
507    pub fn has_changes(&self) -> bool {
508        self.additions() > 0
509            || self.deletions() > 0
510            || !self.inner.extended_headers.is_empty()
511            || self.inner.binary
512            || self.original_path().is_none()
513            || self.modified_path().is_none()
514            || self.status() == DiffFileStatus::Conflicted
515    }
516
517    /// Whether the file has one column in every display mode.
518    pub(crate) fn is_single_column(&self) -> bool {
519        matches!(
520            self.status(),
521            DiffFileStatus::Unchanged | DiffFileStatus::Conflicted
522        )
523    }
524    /// Merge conflicts in working-file order; slice indices identify resolutions.
525    pub fn conflicts(&self) -> &[DiffConflict] {
526        &self.inner.conflicts
527    }
528    pub(crate) fn with_conflicts(mut self, conflicts: Vec<DiffConflict>) -> Self {
529        Arc::get_mut(&mut self.inner)
530            .expect("a newly built file is not shared")
531            .conflicts = conflicts;
532        self
533    }
534
535    pub(crate) fn lines_count(&self, side: DiffSide) -> usize {
536        self.lines(side).len()
537    }
538
539    /// Copies patch-provided lines. Unavailable context is omitted, without
540    /// inventing source lines.
541    pub(crate) fn text_for_lines(&self, side: DiffSide, start: usize, end: usize) -> String {
542        let lines = self.lines(side);
543        let start_ix = lines.partition_point(|line| line.line_number < start);
544        let end_ix = lines.partition_point(|line| line.line_number <= end);
545        let selected = &lines[start_ix..end_ix];
546        let (Some(first), Some(last)) = (selected.first(), selected.last()) else {
547            return String::new();
548        };
549        self.source(side)[first.source.start..last.source.end].to_owned()
550    }
551
552    /// The one-based line number of the line at `index` on `side`.
553    pub(crate) fn line_number(&self, side: DiffSide, index: usize) -> usize {
554        self.lines(side)[index].line_number
555    }
556    pub(crate) fn line_index(&self, side: DiffSide, line: usize) -> Option<usize> {
557        self.lines(side)
558            .binary_search_by_key(&line, |source| source.line_number)
559            .ok()
560    }
561
562    pub(crate) fn pairs(&self) -> &[LinePair] {
563        &self.inner.pairs
564    }
565    pub(crate) fn hunks(&self) -> &[Hunk] {
566        &self.inner.hunks
567    }
568    /// Width of the widest supplied line number, in digits.
569    pub(crate) fn line_number_digits(&self) -> usize {
570        self.inner.line_number_digits
571    }
572
573    pub(crate) fn patch_lines(&self) -> &[PatchLine] {
574        &self.inner.patch_lines
575    }
576    /// The position of a side's line in [`Self::patch_lines`].
577    pub(crate) fn patch_ix(&self, side: DiffSide, ix: usize) -> usize {
578        self.inner.patch_ixs[side as usize][ix]
579    }
580
581    pub(crate) fn side_path(&self, side: DiffSide) -> Option<&SharedString> {
582        self.side(side).path.as_ref()
583    }
584
585    fn side(&self, side: DiffSide) -> &FileSide {
586        match side {
587            DiffSide::Original => &self.inner.original,
588            DiffSide::Modified => &self.inner.modified,
589        }
590    }
591    pub(crate) fn lines(&self, side: DiffSide) -> &[SourceLine] {
592        &self.side(side).lines
593    }
594    pub(crate) fn source(&self, side: DiffSide) -> &str {
595        &self.side(side).source
596    }
597}
598
599/// Orders the patch body as Git writes it: within each run of changes, every
600/// deletion precedes every addition. Runs never cross hunk boundaries.
601fn patch_order(
602    pairs: &[LinePair],
603    hunks: &[Hunk],
604    original: &FileSide,
605    modified: &FileSide,
606) -> (Vec<PatchLine>, [Vec<usize>; 2]) {
607    let mut lines = Vec::with_capacity(pairs.len());
608    let mut ixs = [vec![0; original.lines.len()], vec![0; modified.lines.len()]];
609    let ranges = if hunks.is_empty() {
610        vec![0..pairs.len()]
611    } else {
612        hunks.iter().map(Hunk::pairs).collect()
613    };
614    for range in ranges {
615        let mut ix = range.start;
616        while ix < range.end {
617            if !pairs[ix].changed {
618                let pair = pairs[ix];
619                lines.push(PatchLine {
620                    original: pair.original,
621                    modified: pair.modified,
622                });
623                ix += 1;
624                continue;
625            }
626            let start = ix;
627            while ix < range.end && pairs[ix].changed {
628                ix += 1;
629            }
630            for pair in &pairs[start..ix] {
631                if let Some(line) = pair.original {
632                    lines.push(PatchLine {
633                        original: Some(line),
634                        modified: None,
635                    });
636                }
637            }
638            for pair in &pairs[start..ix] {
639                if let Some(line) = pair.modified {
640                    lines.push(PatchLine {
641                        original: None,
642                        modified: Some(line),
643                    });
644                }
645            }
646        }
647    }
648    for (patch_ix, line) in lines.iter().enumerate() {
649        if let Some(ix) = line.original {
650            ixs[0][ix] = patch_ix;
651        }
652        if let Some(ix) = line.modified {
653            ixs[1][ix] = patch_ix;
654        }
655    }
656    (lines, ixs)
657}
658
659/// Builds the display form of one source line from its range in `source`.
660fn source_line(source: &str, range: Range<usize>, line_number: usize) -> SourceLine {
661    let raw = &source[range.clone()];
662    let content = match raw.strip_suffix('\n') {
663        Some(content) => content.strip_suffix('\r').unwrap_or(content),
664        None => raw,
665    };
666    let text = SharedString::from(content.to_owned());
667    let (display, display_tabs): (SharedString, Vec<DisplayTab>) = if content.contains('\t') {
668        let mut display = String::with_capacity(content.len());
669        let mut display_tabs = Vec::new();
670        let mut column = 0;
671        for (ix, ch) in content.char_indices() {
672            if ch == '\t' {
673                let spaces = 4 - column % 4;
674                display_tabs.push(DisplayTab {
675                    source_offset: ix,
676                    display_offset: display.len(),
677                    width: spaces,
678                });
679                for _ in 0..spaces {
680                    display.push(' ');
681                }
682                column += spaces;
683            } else {
684                display.push(ch);
685                column += 1;
686            }
687        }
688        (display.into(), display_tabs)
689    } else {
690        (text.clone(), Vec::new())
691    };
692    let chunks = display_chunks(&display)
693        .into_iter()
694        .map(|range| {
695            let text = if range.start == 0 && range.end == display.len() {
696                display.clone()
697            } else {
698                SharedString::from(display[range.clone()].to_owned())
699            };
700            DisplayChunk { range, text }
701        })
702        .collect();
703    SourceLine {
704        line_number,
705        text,
706        display,
707        chunks,
708        counterpart: None,
709        display_tabs,
710        content_end: range.start + content.len(),
711        source: range,
712    }
713}
714
715#[cfg(test)]
716pub(crate) fn source_lines(text: &str) -> Vec<SourceLine> {
717    let mut offset = 0;
718    text.split_inclusive('\n')
719        .enumerate()
720        .map(|(index, line)| {
721            let range = offset..offset + line.len();
722            offset = range.end;
723            source_line(text, range, index + 1)
724        })
725        .collect()
726}
727
728/// Bound native selection projection while preserving grapheme boundaries.
729/// Exceptionally large clusters split only at UTF-8 boundaries.
730pub(crate) fn display_chunks(text: &str) -> SmallVec<[Range<usize>; 1]> {
731    const MAX_BYTES: usize = 512;
732    if text.len() <= MAX_BYTES {
733        return smallvec![0..text.len()];
734    }
735    let mut chunks = SmallVec::new();
736    let mut start = 0;
737    let mut end = 0;
738    for (ix, grapheme) in text.grapheme_indices(true) {
739        if ix + grapheme.len() - start > MAX_BYTES && start < ix {
740            chunks.push(start..ix);
741            start = ix;
742        }
743        if grapheme.len() > MAX_BYTES {
744            for (relative, character) in grapheme.char_indices() {
745                let offset = ix + relative;
746                if offset + character.len_utf8() - start > MAX_BYTES {
747                    chunks.push(start..offset);
748                    start = offset;
749                }
750            }
751        }
752        end = ix + grapheme.len();
753    }
754    if start < end {
755        chunks.push(start..end);
756    }
757    chunks
758}
759
760/// Test fixtures describe complete file versions; they are turned into a
761/// full-context patch and pass through the production parser.
762#[cfg(test)]
763pub(crate) mod fixture {
764    use similar::{ChangeTag, TextDiff};
765
766    use super::DiffFile;
767
768    fn quoted(name: &str) -> String {
769        format!(
770            "\"{}\"",
771            name.replace('\\', "\\\\")
772                .replace('\"', "\\\"")
773                .replace('\n', "\\n")
774                .replace('\r', "\\r")
775                .replace('\t', "\\t")
776        )
777    }
778
779    /// Builds a document from optional complete sources. Hunk rows are
780    /// removed because these fixtures predate hunk navigation.
781    pub(crate) fn document(
782        original: Option<(&str, &str)>,
783        modified: Option<(&str, &str)>,
784    ) -> DiffFile {
785        let old = original.map_or("", |(_, text)| text);
786        let new = modified.map_or("", |(_, text)| text);
787        let old_lines: Vec<&str> = old.split_inclusive('\n').collect();
788        let new_lines: Vec<&str> = new.split_inclusive('\n').collect();
789        let old_name = original.map_or_else(|| "/dev/null".to_owned(), |(name, _)| quoted(name));
790        let new_name = modified.map_or_else(|| "/dev/null".to_owned(), |(name, _)| quoted(name));
791        // Write one full-context hunk directly: `similar`'s unified writer
792        // does not preserve CRLF line endings.
793        let diff = TextDiff::from_slices(&old_lines, &new_lines);
794        let mut patch = format!("--- {old_name}\n+++ {new_name}\n");
795        if !old_lines.is_empty() || !new_lines.is_empty() {
796            patch.push_str(&format!(
797                "@@ -{},{} +{},{} @@\n",
798                usize::from(!old_lines.is_empty()),
799                old_lines.len(),
800                usize::from(!new_lines.is_empty()),
801                new_lines.len()
802            ));
803            for op in diff.ops() {
804                for change in diff.iter_changes(op) {
805                    patch.push(match change.tag() {
806                        ChangeTag::Equal => ' ',
807                        ChangeTag::Delete => '-',
808                        ChangeTag::Insert => '+',
809                    });
810                    patch.push_str(change.value());
811                    if !change.value().ends_with('\n') {
812                        patch.push_str("\n\\ No newline at end of file\n");
813                    }
814                }
815            }
816        }
817        if original.is_none() {
818            patch.insert_str(0, "diff --git a/fixture b/fixture\nnew file mode 100644\n");
819        } else if modified.is_none() {
820            patch.insert_str(
821                0,
822                "diff --git a/fixture b/fixture\ndeleted file mode 100644\n",
823            );
824        }
825        let mut document = DiffFile::parse(&patch)
826            .unwrap_or_else(|error| panic!("invalid fixture patch: {error}\n{patch}"))
827            .remove(0);
828        std::sync::Arc::get_mut(&mut document.inner)
829            .unwrap()
830            .hunks
831            .clear();
832        document
833    }
834
835    pub(crate) fn modified(name: &str, original: &str, modified: &str) -> DiffFile {
836        document(Some((name, original)), Some((name, modified)))
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::{fixture, *};
843
844    #[test]
845    fn comparison_preserves_source_and_alignment() {
846        fn assert_send_sync<T: Send + Sync>() {}
847        assert_send_sync::<DiffFile>();
848        let doc = fixture::modified(
849            "a.rs",
850            "same\r\n旧值\r\nremoved\r\ntail",
851            "same\r\n新值\r\ntail\r\n",
852        );
853        assert_eq!((doc.additions(), doc.deletions()), (2, 3));
854        assert_eq!(
855            doc.inner.pairs.first().unwrap(),
856            &LinePair {
857                original: Some(0),
858                modified: Some(0),
859                changed: false
860            }
861        );
862        for side in [DiffSide::Original, DiffSide::Modified] {
863            assert_eq!(doc.text_for_lines(side, 1, usize::MAX), doc.source(side));
864        }
865        let same = fixture::modified("empty", "", "");
866        assert!(!same.has_changes());
867        assert_eq!(same.lines_count(DiffSide::Original), 0);
868        let added = fixture::document(None, Some(("new", "one\n\n")));
869        assert_eq!((added.additions(), added.deletions()), (2, 0));
870        assert!(added.inner.pairs.iter().all(|p| p.original.is_none()));
871        let deleted = fixture::document(Some(("old", "one\n")), None);
872        assert_eq!((deleted.additions(), deleted.deletions()), (0, 1));
873        assert!(fixture::document(None, Some(("empty", ""))).has_changes());
874    }
875
876    #[test]
877    fn alignment_covers_each_source_line_once() {
878        for (old, new) in [
879            ("one\ntwo\n", "one\ninserted\ntwo\n"),
880            ("one\nremoved\ntwo\n", "one\ntwo\n"),
881            ("old one\nold two\n", "new one\n"),
882            (
883                "repeat\nold\nrepeat\nsame\nold again\n",
884                "repeat\nnew\nrepeat\nsame\nnew again\n",
885            ),
886            ("same\n", "same\r\n"),
887            ("same\n", "same"),
888            ("a\rb", "a\rc"),
889            ("a\rb\nlast\r", "a\rc\nlast\r\n"),
890            ("", "\n"),
891            ("\n", ""),
892        ] {
893            let doc = fixture::modified("a.txt", old, new);
894            assert_eq!(
895                doc.inner
896                    .pairs
897                    .iter()
898                    .filter_map(|pair| pair.original)
899                    .collect::<Vec<_>>(),
900                (0..doc.lines_count(DiffSide::Original)).collect::<Vec<_>>()
901            );
902            assert_eq!(
903                doc.inner
904                    .pairs
905                    .iter()
906                    .filter_map(|pair| pair.modified)
907                    .collect::<Vec<_>>(),
908                (0..doc.lines_count(DiffSide::Modified)).collect::<Vec<_>>()
909            );
910            assert_eq!(
911                doc.deletions(),
912                doc.inner
913                    .pairs
914                    .iter()
915                    .filter(|pair| pair.changed && pair.original.is_some())
916                    .count()
917            );
918            assert_eq!(
919                doc.additions(),
920                doc.inner
921                    .pairs
922                    .iter()
923                    .filter(|pair| pair.changed && pair.modified.is_some())
924                    .count()
925            );
926            assert!(doc.has_changes());
927            for pair in &doc.inner.pairs {
928                if !pair.changed {
929                    let old_line = &doc.lines(DiffSide::Original)[pair.original.unwrap()];
930                    let new_line = &doc.lines(DiffSide::Modified)[pair.modified.unwrap()];
931                    assert_eq!(&old[old_line.source.clone()], &new[new_line.source.clone()]);
932                }
933            }
934        }
935        let doc = fixture::modified("a.txt", "same\nold\ntail\n", "same\nnew\nextra\ntail\n");
936        assert_eq!(
937            doc.inner.pairs[1],
938            LinePair {
939                original: Some(1),
940                modified: Some(1),
941                changed: true
942            }
943        );
944        assert_eq!(
945            doc.inner.pairs[2],
946            LinePair {
947                original: None,
948                modified: Some(2),
949                changed: true
950            }
951        );
952        assert_eq!(doc.text_for_lines(DiffSide::Modified, 2, 3), "new\nextra\n");
953        assert_eq!(doc.text_for_lines(DiffSide::Original, 99, 100), "");
954        let bare_cr = fixture::modified("a.txt", "a\rb", "a\rb");
955        assert!(!bare_cr.has_changes());
956        assert_eq!(bare_cr.lines_count(DiffSide::Original), 1);
957        assert_eq!(bare_cr.inner.pairs.len(), 1);
958    }
959
960    #[test]
961    fn source_display_mapping_preserves_tabs_and_unicode() {
962        let lines = source_lines("é\t中\t\r\nlast\r");
963        let first = &lines[0];
964        assert_eq!(first.text.as_str(), "é\t中\t");
965        assert_eq!(first.display.as_str(), "é   中   ");
966        assert_eq!(first.source, 0..9);
967        assert_eq!(first.content_end, 7);
968        assert_eq!(first.display_tabs.len(), 2);
969        assert_eq!(first.display_range(2..3), 2..5);
970        assert_eq!(first.display_range(6..7), 8..11);
971        assert_eq!(
972            first.display_range(0..first.text.len()),
973            0..first.display.len()
974        );
975        for offset in 2..5 {
976            assert_eq!(first.source_offset(offset), 2);
977        }
978        assert_eq!(first.source_offset(5), 3);
979        assert_eq!(first.source_offset(first.display.len()), first.text.len());
980        assert_eq!(first.source_offset(usize::MAX), first.text.len());
981        assert_eq!(lines[1].text.as_str(), "last\r");
982        assert!(lines[1].display_tabs.is_empty());
983        assert_eq!(lines[1].source_offset(2), 2);
984        assert_eq!(lines[1].source_offset(usize::MAX), lines[1].text.len());
985        assert_eq!(lines[1].display_range(1..4), 1..4);
986        let plain_unicode = source_lines("中é");
987        assert!(plain_unicode[0].display_tabs.is_empty());
988        assert_eq!(plain_unicode[0].display_range(3..5), 3..5);
989        assert_eq!(plain_unicode[0].source_offset(3), 3);
990        assert!(source_lines("").is_empty());
991        assert_eq!(source_lines("\n").len(), 1);
992        // Compare every byte against the former dense mapping, including
993        // UTF-8 interiors and all spaces belonging to each expanded tab.
994        for (display_offset, source_offset) in
995            [0, 1, 2, 2, 2, 3, 4, 5, 6, 6, 6, 7].into_iter().enumerate()
996        {
997            assert_eq!(first.source_offset(display_offset), source_offset);
998        }
999        for (source_offset, display_offset) in [0, 1, 2, 5, 6, 7, 8, 11].into_iter().enumerate() {
1000            assert_eq!(
1001                first.display_range(source_offset..source_offset),
1002                display_offset..display_offset
1003            );
1004        }
1005        let adjacent_tabs = source_lines("\t\té\t");
1006        assert_eq!(adjacent_tabs[0].display.as_str(), "        é   ");
1007        assert_eq!(adjacent_tabs[0].display_tabs.len(), 3);
1008        for (source_offset, display_offset) in [0, 4, 8, 9, 10, 13].into_iter().enumerate() {
1009            assert_eq!(
1010                adjacent_tabs[0].display_range(source_offset..source_offset),
1011                display_offset..display_offset
1012            );
1013        }
1014        let long_line = source_lines("a long source line without tabs uses shared heap storage");
1015        assert_eq!(long_line[0].text.as_ptr(), long_line[0].display.as_ptr());
1016        let long_tab_line = source_lines(&format!("{}\t", "a".repeat(100_000)));
1017        assert_eq!(long_tab_line[0].display_tabs.len(), 1);
1018    }
1019
1020    #[test]
1021    fn display_chunks_retain_complete_utf8_source() {
1022        let ordinary = "an ordinary line uses the existing shared display string";
1023        let long_grapheme = format!("a{}", "\u{301}".repeat(600));
1024        for text in [
1025            String::new(),
1026            ordinary.to_owned(),
1027            "é👩‍💻中".repeat(200),
1028            long_grapheme,
1029        ] {
1030            let chunks = display_chunks(&text);
1031            let mut previous_end = 0;
1032            for range in &chunks {
1033                assert_eq!(range.start, previous_end);
1034                assert!(range.len() <= 512);
1035                assert!(text.is_char_boundary(range.start) && text.is_char_boundary(range.end));
1036                previous_end = range.end;
1037            }
1038            assert_eq!(previous_end, text.len());
1039            assert_eq!(
1040                chunks
1041                    .iter()
1042                    .map(|range| &text[range.clone()])
1043                    .collect::<String>(),
1044                text
1045            );
1046            if text.is_empty() {
1047                continue;
1048            }
1049            let lines = source_lines(&text);
1050            let line = &lines[0];
1051            assert_eq!(line.chunks.len(), chunks.len());
1052            assert_eq!(line.chunk_text(&(0..line.display.len())), line.display);
1053            for chunk in &line.chunks {
1054                let retained = line.chunk_text(&chunk.range);
1055                assert_eq!(retained.as_str(), &text[chunk.range.clone()]);
1056                if retained.len() > 23 {
1057                    assert_eq!(retained.as_ptr(), chunk.text.as_ptr());
1058                }
1059            }
1060        }
1061        let normal_graphemes = "é👩‍💻中".repeat(200);
1062        let boundaries = normal_graphemes
1063            .grapheme_indices(true)
1064            .map(|(ix, _)| ix)
1065            .chain([normal_graphemes.len()])
1066            .collect::<Vec<_>>();
1067        for range in display_chunks(&normal_graphemes) {
1068            assert!(boundaries.contains(&range.start) && boundaries.contains(&range.end));
1069        }
1070        let line = &source_lines(ordinary)[0];
1071        assert_eq!(line.display.as_ptr(), line.chunks[0].text.as_ptr());
1072    }
1073}