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