• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rust-lang / annotate-snippets-rs / 20484286331

24 Dec 2025 10:33AM UTC coverage: 90.181% (+0.05%) from 90.127%
20484286331

Pull #357

github

web-flow
Merge 2fb93f5f3 into 3a106da43
Pull Request #357: fix: Fix Unicode highlight alignment in patches

9 of 9 new or added lines in 1 file covered. (100.0%)

61 existing lines in 2 files now uncovered.

1497 of 1660 relevant lines covered (90.18%)

4.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.0
/src/renderer/source_map.rs
1
use crate::renderer::{
2
    char_width, normalize_whitespace, num_overlap, LineAnnotation, LineAnnotationType,
3
};
4
use crate::{Annotation, AnnotationKind, Patch};
5
use std::borrow::Cow;
6
use std::cmp::{max, min};
7
use std::ops::Range;
8

9
#[derive(Debug)]
10
pub(crate) struct SourceMap<'a> {
11
    lines: Vec<LineInfo<'a>>,
12
    pub(crate) source: &'a str,
13
}
14

15
impl<'a> SourceMap<'a> {
16
    pub(crate) fn new(source: &'a str, line_start: usize) -> Self {
7✔
17
        // Empty sources do have a "line", but it is empty, so we need to add
18
        // a line with an empty string to the source map.
19
        if source.is_empty() {
5✔
20
            return Self {
2✔
21
                lines: vec![LineInfo {
4✔
22
                    line: "",
×
23
                    line_index: line_start,
×
24
                    start_byte: 0,
×
UNCOV
25
                    end_byte: 0,
×
26
                    end_line_size: 0,
×
27
                }],
UNCOV
28
                source,
×
29
            };
30
        }
31

32
        let mut current_index = 0;
6✔
33

34
        let mut mapping = vec![];
6✔
35
        for (idx, (line, end_line)) in CursorLines::new(source).enumerate() {
18✔
36
            let line_length = line.len();
10✔
37
            let line_range = current_index..current_index + line_length;
6✔
38
            let end_line_size = end_line.len();
12✔
39

40
            mapping.push(LineInfo {
6✔
41
                line,
×
42
                line_index: line_start + idx,
6✔
43
                start_byte: line_range.start,
×
44
                end_byte: line_range.end + end_line_size,
6✔
UNCOV
45
                end_line_size,
×
46
            });
47

48
            current_index += line_length + end_line_size;
6✔
49
        }
50
        Self {
51
            lines: mapping,
52
            source,
53
        }
54
    }
55

56
    pub(crate) fn get_line(&self, idx: usize) -> Option<&'a str> {
3✔
57
        self.lines
3✔
58
            .iter()
59
            .find(|l| l.line_index == idx)
9✔
60
            .map(|info| info.line)
9✔
61
    }
62

63
    pub(crate) fn span_to_locations(&self, span: Range<usize>) -> (Loc, Loc) {
6✔
64
        let start_info = self
6✔
UNCOV
65
            .lines
×
66
            .iter()
67
            .find(|info| span.start >= info.start_byte && span.start < info.end_byte)
18✔
68
            .unwrap_or(self.lines.last().unwrap());
6✔
69
        let (mut start_char_pos, start_display_pos, start_normalized_pos) = start_info.line
12✔
70
            [0..(span.start - start_info.start_byte).min(start_info.line.len())]
6✔
71
            .chars()
72
            .fold((0, 0, 0), |(char_pos, display_pos, normalized_pos), c| {
17✔
73
                let display = char_width(c);
6✔
74
                let normalized = normalize_whitespace(&c.to_string()).chars().count();
6✔
75
                (
76
                    char_pos + 1,
4✔
77
                    display_pos + display,
6✔
78
                    normalized_pos + normalized,
4✔
79
                )
80
            });
81
        // correct the char pos if we are highlighting the end of a line
82
        if (span.start - start_info.start_byte).saturating_sub(start_info.line.len()) > 0 {
12✔
83
            start_char_pos += 1;
2✔
84
        }
85
        let start = Loc {
86
            line: start_info.line_index,
4✔
87
            char: start_char_pos,
88
            display: start_display_pos,
89
            normalized: start_normalized_pos,
90
            byte: span.start,
5✔
91
        };
92

93
        if span.start == span.end {
4✔
94
            return (start, start);
3✔
95
        }
96

97
        let end_info = self
4✔
UNCOV
98
            .lines
×
99
            .iter()
100
            .find(|info| span.end >= info.start_byte && span.end < info.end_byte)
14✔
101
            .unwrap_or(self.lines.last().unwrap());
6✔
102
        let (end_char_pos, end_display_pos, end_normalized_pos) = end_info.line
9✔
103
            [0..(span.end - end_info.start_byte).min(end_info.line.len())]
10✔
104
            .chars()
105
            .fold((0, 0, 0), |(char_pos, display_pos, normalized_pos), c| {
14✔
106
                let display = char_width(c);
3✔
107
                let normalized = normalize_whitespace(&c.to_string()).chars().count();
5✔
108
                (
109
                    char_pos + 1,
5✔
110
                    display_pos + display,
6✔
111
                    normalized_pos + normalized,
3✔
112
                )
113
            });
114

115
        let mut end = Loc {
116
            line: end_info.line_index,
3✔
117
            char: end_char_pos,
118
            display: end_display_pos,
119
            normalized: end_normalized_pos,
120
            byte: span.end,
4✔
121
        };
122
        if start.line != end.line && end.byte > end_info.end_byte - end_info.end_line_size {
9✔
123
            end.char += 1;
2✔
124
            end.display += 1;
4✔
125
        }
126

127
        (start, end)
4✔
128
    }
129

130
    pub(crate) fn span_to_snippet(&self, span: Range<usize>) -> Option<&str> {
3✔
131
        self.source.get(span)
3✔
132
    }
133

134
    pub(crate) fn span_to_lines(&self, span: Range<usize>) -> Vec<&LineInfo<'a>> {
3✔
135
        let mut lines = vec![];
3✔
136
        let start = span.start;
3✔
137
        let end = span.end;
3✔
138
        for line_info in &self.lines {
6✔
139
            if start >= line_info.end_byte {
3✔
UNCOV
140
                continue;
×
141
            }
142
            if end < line_info.start_byte {
3✔
UNCOV
143
                break;
×
144
            }
145
            lines.push(line_info);
3✔
146
        }
147

148
        if lines.is_empty() && !self.lines.is_empty() {
7✔
149
            lines.push(self.lines.last().unwrap());
1✔
150
        }
151

152
        lines
3✔
153
    }
154

155
    pub(crate) fn annotated_lines(
5✔
156
        &self,
157
        annotations: Vec<Annotation<'a>>,
158
        fold: bool,
159
    ) -> (usize, Vec<AnnotatedLineInfo<'a>>) {
160
        let source_len = self.source.len();
12✔
161
        if let Some(bigger) = annotations.iter().find_map(|x| {
12✔
162
            // Allow highlighting one past the last character in the source.
163
            if source_len + 1 < x.span.end {
12✔
164
                Some(&x.span)
1✔
165
            } else {
166
                None
6✔
167
            }
168
        }) {
169
            panic!("Annotation range `{bigger:?}` is beyond the end of buffer `{source_len}`")
2✔
170
        }
171

172
        let mut annotated_line_infos = self
6✔
UNCOV
173
            .lines
×
174
            .iter()
175
            .map(|info| AnnotatedLineInfo {
18✔
176
                line: info.line,
6✔
177
                line_index: info.line_index,
6✔
178
                annotations: vec![],
6✔
UNCOV
179
                keep: false,
×
180
            })
181
            .collect::<Vec<_>>();
182
        let mut multiline_annotations = vec![];
6✔
183

184
        for Annotation {
10✔
185
            span,
6✔
186
            label,
6✔
187
            kind,
6✔
188
            highlight_source,
6✔
189
        } in annotations
18✔
190
        {
191
            let (lo, mut hi) = self.span_to_locations(span.clone());
12✔
192
            if kind == AnnotationKind::Visible {
4✔
193
                for line_idx in lo.line..=hi.line {
4✔
194
                    self.keep_line(&mut annotated_line_infos, line_idx);
4✔
195
                }
UNCOV
196
                continue;
×
197
            }
198
            // Watch out for "empty spans". If we get a span like 6..6, we
199
            // want to just display a `^` at 6, so convert that to
200
            // 6..7. This is degenerate input, but it's best to degrade
201
            // gracefully -- and the parser likes to supply a span like
202
            // that for EOF, in particular.
203

204
            if lo.display == hi.display && lo.line == hi.line {
13✔
205
                hi.display += 1;
3✔
206
            }
207

208
            if lo.line == hi.line {
3✔
209
                let line_ann = LineAnnotation {
210
                    start: lo,
211
                    end: hi,
212
                    kind,
213
                    label,
214
                    annotation_type: LineAnnotationType::Singleline,
215
                    highlight_source,
216
                };
217
                self.add_annotation_to_file(&mut annotated_line_infos, lo.line, line_ann);
8✔
218
            } else {
219
                multiline_annotations.push(MultilineAnnotation {
8✔
UNCOV
220
                    depth: 1,
×
UNCOV
221
                    start: lo,
×
222
                    end: hi,
3✔
UNCOV
223
                    kind,
×
224
                    label,
4✔
UNCOV
225
                    overlaps_exactly: false,
×
UNCOV
226
                    highlight_source,
×
227
                });
228
            }
229
        }
230

231
        let mut primary_spans = vec![];
5✔
232

233
        // Find overlapping multiline annotations, put them at different depths
234
        multiline_annotations.sort_by_key(|ml| (ml.start.line, usize::MAX - ml.end.line));
12✔
235
        for (outer_i, ann) in multiline_annotations.clone().into_iter().enumerate() {
7✔
236
            if ann.kind.is_primary() {
8✔
237
                primary_spans.push((ann.start, ann.end));
4✔
238
            }
239
            for (inner_i, a) in &mut multiline_annotations.iter_mut().enumerate() {
8✔
240
                // Move all other multiline annotations overlapping with this one
241
                // one level to the right.
242
                if !ann.same_span(a)
9✔
243
                    && num_overlap(ann.start.line, ann.end.line, a.start.line, a.end.line, true)
4✔
244
                {
245
                    a.increase_depth();
2✔
246
                } else if ann.same_span(a) && outer_i != inner_i {
9✔
247
                    a.overlaps_exactly = true;
2✔
248
                } else {
249
                    if primary_spans
15✔
250
                        .iter()
251
                        .any(|(s, e)| a.start == *s && a.end == *e)
12✔
252
                    {
253
                        a.kind = AnnotationKind::Primary;
4✔
254
                    }
UNCOV
255
                    break;
×
256
                }
257
            }
258
        }
259

260
        let mut max_depth = 0; // max overlapping multiline spans
4✔
261
        for ann in &multiline_annotations {
7✔
262
            max_depth = max(max_depth, ann.depth);
8✔
263
        }
264
        // Change order of multispan depth to minimize the number of overlaps in the ASCII art.
265
        for a in &mut multiline_annotations {
8✔
266
            a.depth = max_depth - a.depth + 1;
6✔
267
        }
268
        for ann in multiline_annotations {
7✔
269
            let mut end_ann = ann.as_end();
6✔
270
            if ann.overlaps_exactly {
5✔
271
                end_ann.annotation_type = LineAnnotationType::Singleline;
2✔
272
            } else {
273
                // avoid output like
274
                //
275
                //  |        foo(
276
                //  |   _____^
277
                //  |  |_____|
278
                //  | ||         bar,
279
                //  | ||     );
280
                //  | ||      ^
281
                //  | ||______|
282
                //  |  |______foo
283
                //  |         baz
284
                //
285
                // and instead get
286
                //
287
                //  |       foo(
288
                //  |  _____^
289
                //  | |         bar,
290
                //  | |     );
291
                //  | |      ^
292
                //  | |      |
293
                //  | |______foo
294
                //  |        baz
295
                self.add_annotation_to_file(
4✔
UNCOV
296
                    &mut annotated_line_infos,
×
297
                    ann.start.line,
3✔
298
                    ann.as_start(),
3✔
299
                );
300
                // 4 is the minimum vertical length of a multiline span when presented: two lines
301
                // of code and two lines of underline. This is not true for the special case where
302
                // the beginning doesn't have an underline, but the current logic seems to be
303
                // working correctly.
304
                let middle = min(ann.start.line + 4, ann.end.line);
4✔
305
                // We'll show up to 4 lines past the beginning of the multispan start.
306
                // We will *not* include the tail of lines that are only whitespace, a comment or
307
                // a bare delimiter.
308
                let filter = |s: &str| {
3✔
309
                    let s = s.trim();
3✔
310
                    // Consider comments as empty, but don't consider docstrings to be empty.
311
                    !(s.starts_with("//") && !(s.starts_with("///") || s.starts_with("//!")))
7✔
312
                        // Consider lines with nothing but whitespace, a single delimiter as empty.
313
                        && !["", "{", "}", "(", ")", "[", "]"].contains(&s)
3✔
314
                };
315
                let until = (ann.start.line..middle)
7✔
316
                    .rev()
317
                    .filter_map(|line| self.get_line(line).map(|s| (line + 1, s)))
15✔
318
                    .find(|(_, s)| filter(s))
9✔
319
                    .map_or(ann.start.line, |(line, _)| line);
8✔
320
                for line in ann.start.line + 1..until {
4✔
321
                    // Every `|` that joins the beginning of the span (`___^`) to the end (`|__^`).
322
                    self.add_annotation_to_file(&mut annotated_line_infos, line, ann.as_line());
7✔
323
                }
324
                let line_end = ann.end.line - 1;
4✔
325
                let end_is_empty = self.get_line(line_end).map_or(false, |s| !filter(s));
16✔
326
                if middle < line_end && !end_is_empty {
8✔
327
                    self.add_annotation_to_file(&mut annotated_line_infos, line_end, ann.as_line());
2✔
328
                }
329
            }
330
            self.add_annotation_to_file(&mut annotated_line_infos, end_ann.end.line, end_ann);
3✔
331
        }
332

333
        if fold {
3✔
334
            annotated_line_infos.retain(|l| !l.annotations.is_empty() || l.keep);
10✔
335
        }
336

337
        (max_depth, annotated_line_infos)
3✔
338
    }
339

340
    fn add_annotation_to_file(
3✔
341
        &self,
342
        annotated_line_infos: &mut Vec<AnnotatedLineInfo<'a>>,
343
        line_index: usize,
344
        line_ann: LineAnnotation<'a>,
345
    ) {
346
        if let Some(line_info) = annotated_line_infos
11✔
347
            .iter_mut()
348
            .find(|line_info| line_info.line_index == line_index)
10✔
349
        {
350
            line_info.annotations.push(line_ann);
8✔
351
        } else {
UNCOV
352
            let info = self
×
UNCOV
353
                .lines
×
354
                .iter()
UNCOV
355
                .find(|l| l.line_index == line_index)
×
356
                .unwrap();
UNCOV
357
            annotated_line_infos.push(AnnotatedLineInfo {
×
UNCOV
358
                line: info.line,
×
UNCOV
359
                line_index,
×
360
                annotations: vec![line_ann],
×
361
                keep: false,
×
362
            });
363
            annotated_line_infos.sort_by_key(|l| l.line_index);
×
364
        }
365
    }
366

367
    fn keep_line(&self, annotated_line_infos: &mut Vec<AnnotatedLineInfo<'a>>, line_index: usize) {
2✔
368
        if let Some(line_info) = annotated_line_infos
6✔
369
            .iter_mut()
370
            .find(|line_info| line_info.line_index == line_index)
6✔
371
        {
372
            line_info.keep = true;
2✔
373
        } else {
UNCOV
374
            let info = self
×
UNCOV
375
                .lines
×
376
                .iter()
UNCOV
377
                .find(|l| l.line_index == line_index)
×
378
                .unwrap();
UNCOV
379
            annotated_line_infos.push(AnnotatedLineInfo {
×
UNCOV
380
                line: info.line,
×
UNCOV
381
                line_index,
×
UNCOV
382
                annotations: vec![],
×
UNCOV
383
                keep: true,
×
384
            });
UNCOV
385
            annotated_line_infos.sort_by_key(|l| l.line_index);
×
386
        }
387
    }
388

389
    pub(crate) fn splice_lines<'b>(
4✔
390
        &'a self,
391
        mut patches: Vec<Patch<'b>>,
392
        fold: bool,
393
    ) -> Option<SplicedLines<'b>> {
394
        fn push_trailing(
3✔
395
            buf: &mut String,
396
            line_opt: Option<&str>,
397
            lo: &Loc,
398
            hi_opt: Option<&Loc>,
399
        ) -> usize {
400
            let mut line_count = 0;
3✔
401
            // Convert CharPos to Usize, as CharPose is character offset
402
            // Extract low index and high index
403
            let (lo, hi_opt) = (lo.char, hi_opt.map(|hi| hi.char));
9✔
404
            if let Some(line) = line_opt {
3✔
405
                if let Some(lo) = line.char_indices().map(|(i, _)| i).nth(lo) {
12✔
406
                    // Get high index while account for rare unicode and emoji with char_indices
407
                    let hi_opt = hi_opt.and_then(|hi| line.char_indices().map(|(i, _)| i).nth(hi));
15✔
408
                    match hi_opt {
3✔
409
                        // If high index exist, take string from low to high index
410
                        Some(hi) if hi > lo => {
6✔
411
                            // count how many '\n' exist
412
                            line_count = line[lo..hi].matches('\n').count();
3✔
413
                            buf.push_str(&line[lo..hi]);
3✔
414
                        }
UNCOV
415
                        Some(_) => (),
×
416
                        // If high index absence, take string from low index till end string.len
UNCOV
417
                        None => {
×
418
                            // count how many '\n' exist
419
                            line_count = line[lo..].matches('\n').count();
3✔
420
                            buf.push_str(&line[lo..]);
3✔
421
                        }
422
                    }
423
                }
424
                // If high index is None
425
                if hi_opt.is_none() {
3✔
426
                    buf.push('\n');
3✔
427
                }
428
            }
429
            line_count
3✔
430
        }
431

432
        let source_len = self.source.len();
6✔
433
        if let Some(bigger) = patches.iter().find_map(|x| {
6✔
434
            // Allow patching one past the last character in the source.
435
            if source_len + 1 < x.span.end {
6✔
436
                Some(&x.span)
1✔
437
            } else {
438
                None
3✔
439
            }
440
        }) {
441
            panic!("Patch span `{bigger:?}` is beyond the end of buffer `{source_len}`")
2✔
442
        }
443

444
        // Assumption: all spans are in the same file, and all spans
445
        // are disjoint. Sort in ascending order.
446
        patches.sort_by_key(|p| p.span.start);
12✔
447

448
        // Find the bounding span.
449
        let (lo, hi) = if fold {
10✔
450
            let lo = patches.iter().map(|p| p.span.start).min()?;
12✔
451
            let hi = patches.iter().map(|p| p.span.end).max()?;
9✔
452
            (lo, hi)
3✔
453
        } else {
454
            (0, source_len)
1✔
455
        };
456

457
        let lines = self.span_to_lines(lo..hi);
3✔
458

459
        let mut highlights = vec![];
3✔
460
        // To build up the result, we do this for each span:
461
        // - push the line segment trailing the previous span
462
        //   (at the beginning a "phantom" span pointing at the start of the line)
463
        // - push lines between the previous and current span (if any)
464
        // - if the previous and current span are not on the same line
465
        //   push the line segment leading up to the current span
466
        // - splice in the span substitution
467
        //
468
        // Finally push the trailing line segment of the last span
469
        let (mut prev_hi, _) = self.span_to_locations(lo..hi);
6✔
470
        prev_hi.char = 0;
3✔
471
        let mut prev_line = lines.first().map(|line| line.line);
9✔
472
        let mut buf = String::new();
3✔
473

474
        let trimmed_patches = patches
3✔
475
            .into_iter()
476
            // If this is a replacement of, e.g. `"a"` into `"ab"`, adjust the
477
            // suggestion and snippet to look as if we just suggested to add
478
            // `"b"`, which is typically much easier for the user to understand.
479
            .map(|part| part.trim_trivial_replacements(self.source))
9✔
480
            .collect::<Vec<_>>();
481
        let mut line_highlight = vec![];
3✔
482
        // We need to keep track of the difference between the existing code and the added
483
        // or deleted code in order to point at the correct column *after* substitution.
484
        let mut acc = 0;
3✔
485
        for part in &trimmed_patches {
6✔
486
            let (cur_lo, cur_hi) = self.span_to_locations(part.span.clone());
6✔
487
            if prev_hi.line == cur_lo.line {
3✔
488
                let mut count = push_trailing(&mut buf, prev_line, &prev_hi, Some(&cur_lo));
6✔
489
                while count > 0 {
3✔
UNCOV
490
                    highlights.push(std::mem::take(&mut line_highlight));
×
UNCOV
491
                    acc = 0;
×
UNCOV
492
                    count -= 1;
×
493
                }
494
            } else {
495
                acc = 0;
1✔
496
                highlights.push(std::mem::take(&mut line_highlight));
2✔
497
                let mut count = push_trailing(&mut buf, prev_line, &prev_hi, None);
1✔
498
                while count > 0 {
1✔
UNCOV
499
                    highlights.push(std::mem::take(&mut line_highlight));
×
UNCOV
500
                    count -= 1;
×
501
                }
502
                // push lines between the previous and current span (if any)
503
                for idx in prev_hi.line + 1..(cur_lo.line) {
2✔
504
                    if let Some(line) = self.get_line(idx) {
2✔
505
                        buf.push_str(line.as_ref());
1✔
506
                        buf.push('\n');
1✔
507
                        highlights.push(std::mem::take(&mut line_highlight));
1✔
508
                    }
509
                }
510
                if let Some(cur_line) = self.get_line(cur_lo.line) {
1✔
511
                    let end = match cur_line.char_indices().nth(cur_lo.char) {
2✔
512
                        Some((i, _)) => i,
1✔
513
                        None => cur_line.len(),
1✔
514
                    };
515
                    buf.push_str(&cur_line[..end]);
1✔
516
                }
517
            }
518
            // Add a whole line highlight per line in the snippet.
519
            let len: isize = part
6✔
UNCOV
520
                .replacement
×
521
                .split('\n')
522
                .next()
523
                .unwrap_or(&part.replacement)
3✔
524
                .chars()
525
                .map(|c| match c {
9✔
UNCOV
526
                    '\t' => 4,
×
527
                    _ => 1,
3✔
528
                })
529
                .sum();
530
            line_highlight.push(SubstitutionHighlight {
3✔
531
                start: (cur_lo.char as isize + acc) as usize,
3✔
532
                end: (cur_lo.char as isize + acc + len) as usize,
6✔
533
            });
534
            buf.push_str(&part.replacement);
3✔
535
            // Account for the difference between the width of the current code and the
536
            // snippet being suggested, so that the *later* suggestions are correctly
537
            // aligned on the screen. Note that cur_hi and cur_lo can be on different
538
            // lines, so cur_hi.col can be smaller than cur_lo.col
539
            acc += len - (cur_hi.char as isize - cur_lo.char as isize);
3✔
540
            prev_hi = cur_hi;
3✔
541
            prev_line = self.get_line(prev_hi.line);
6✔
542
            for line in part.replacement.split('\n').skip(1) {
3✔
543
                acc = 0;
2✔
544
                highlights.push(std::mem::take(&mut line_highlight));
2✔
545
                let end: usize = line
2✔
546
                    .chars()
547
                    .map(|c| match c {
6✔
UNCOV
548
                        '\t' => 4,
×
549
                        _ => 1,
2✔
550
                    })
551
                    .sum();
552
                line_highlight.push(SubstitutionHighlight { start: 0, end });
2✔
553
            }
554
        }
555
        highlights.push(std::mem::take(&mut line_highlight));
3✔
556
        if fold {
3✔
557
            // if the replacement already ends with a newline, don't print the next line
558
            if !buf.ends_with('\n') {
6✔
559
                push_trailing(&mut buf, prev_line, &prev_hi, None);
3✔
560
            }
561
        } else {
562
            // Add the trailing part of the source after the last patch
563
            if let Some(snippet) = self.span_to_snippet(prev_hi.byte..source_len) {
4✔
564
                buf.push_str(snippet);
2✔
565
                for _ in snippet.matches('\n') {
2✔
566
                    highlights.push(std::mem::take(&mut line_highlight));
2✔
567
                }
568
            }
569
        }
570
        // remove trailing newlines
571
        while buf.ends_with('\n') {
6✔
572
            buf.pop();
6✔
573
        }
574
        if highlights.iter().all(|parts| parts.is_empty()) {
15✔
UNCOV
575
            None
×
576
        } else {
577
            Some((buf, trimmed_patches, highlights))
3✔
578
        }
579
    }
580
}
581

582
#[derive(Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]
583
pub(crate) struct MultilineAnnotation<'a> {
584
    pub depth: usize,
585
    pub start: Loc,
586
    pub end: Loc,
587
    pub kind: AnnotationKind,
588
    pub label: Option<Cow<'a, str>>,
589
    pub overlaps_exactly: bool,
590
    pub highlight_source: bool,
591
}
592

593
impl<'a> MultilineAnnotation<'a> {
594
    pub(crate) fn increase_depth(&mut self) {
2✔
595
        self.depth += 1;
2✔
596
    }
597

598
    /// Compare two `MultilineAnnotation`s considering only the `Span` they cover.
599
    pub(crate) fn same_span(&self, other: &MultilineAnnotation<'_>) -> bool {
3✔
600
        self.start == other.start && self.end == other.end
3✔
601
    }
602

603
    pub(crate) fn as_start(&self) -> LineAnnotation<'a> {
4✔
604
        LineAnnotation {
605
            start: self.start,
4✔
606
            end: Loc {
4✔
607
                line: self.start.line,
608
                char: self.start.char + 1,
609
                display: self.start.display + 1,
610
                normalized: self.start.normalized + 1,
611
                byte: self.start.byte + 1,
612
            },
613
            kind: self.kind,
4✔
614
            label: None,
615
            annotation_type: LineAnnotationType::MultilineStart(self.depth),
4✔
616
            highlight_source: self.highlight_source,
4✔
617
        }
618
    }
619

620
    pub(crate) fn as_end(&self) -> LineAnnotation<'a> {
3✔
621
        LineAnnotation {
622
            start: Loc {
3✔
623
                line: self.end.line,
624
                char: self.end.char.saturating_sub(1),
625
                display: self.end.display.saturating_sub(1),
626
                normalized: self.end.normalized.saturating_sub(1),
627
                byte: self.end.byte.saturating_sub(1),
628
            },
629
            end: self.end,
3✔
630
            kind: self.kind,
3✔
631
            label: self.label.clone(),
3✔
632
            annotation_type: LineAnnotationType::MultilineEnd(self.depth),
3✔
633
            highlight_source: self.highlight_source,
3✔
634
        }
635
    }
636

637
    pub(crate) fn as_line(&self) -> LineAnnotation<'a> {
3✔
638
        LineAnnotation {
639
            start: Loc::default(),
4✔
640
            end: Loc::default(),
4✔
641
            kind: self.kind,
4✔
642
            label: None,
643
            annotation_type: LineAnnotationType::MultilineLine(self.depth),
4✔
644
            highlight_source: self.highlight_source,
4✔
645
        }
646
    }
647
}
648

649
#[derive(Debug)]
650
pub(crate) struct LineInfo<'a> {
651
    pub(crate) line: &'a str,
652
    pub(crate) line_index: usize,
653
    pub(crate) start_byte: usize,
654
    pub(crate) end_byte: usize,
655
    end_line_size: usize,
656
}
657

658
#[derive(Debug)]
659
pub(crate) struct AnnotatedLineInfo<'a> {
660
    pub(crate) line: &'a str,
661
    pub(crate) line_index: usize,
662
    pub(crate) annotations: Vec<LineAnnotation<'a>>,
663
    pub(crate) keep: bool,
664
}
665

666
/// A source code location used for error reporting.
667
#[derive(Clone, Copy, Debug, Default, PartialOrd, Ord, PartialEq, Eq)]
668
pub(crate) struct Loc {
669
    /// The (1-based) line number.
670
    pub(crate) line: usize,
671
    /// The (0-based) column offset.
672
    pub(crate) char: usize,
673
    /// The (0-based) column offset when displayed.
674
    pub(crate) display: usize,
675
    /// The (0-based) column offset when normalized.
676
    pub(crate) normalized: usize,
677
    /// The (0-based) byte offset.
678
    pub(crate) byte: usize,
679
}
680

681
struct CursorLines<'a>(&'a str);
682

683
impl CursorLines<'_> {
684
    fn new(src: &str) -> CursorLines<'_> {
6✔
685
        CursorLines(src)
686
    }
687
}
688

689
#[derive(Copy, Clone, Debug, PartialEq)]
690
enum EndLine {
691
    Eof,
692
    Lf,
693
    Crlf,
694
}
695

696
impl EndLine {
697
    /// The number of characters this line ending occupies in bytes.
698
    pub(crate) fn len(self) -> usize {
6✔
699
        match self {
6✔
700
            EndLine::Eof => 0,
701
            EndLine::Lf => 1,
702
            EndLine::Crlf => 2,
703
        }
704
    }
705
}
706

707
impl<'a> Iterator for CursorLines<'a> {
708
    type Item = (&'a str, EndLine);
709

710
    fn next(&mut self) -> Option<Self::Item> {
6✔
711
        if self.0.is_empty() {
6✔
712
            None
6✔
713
        } else {
714
            self.0
6✔
715
                .find('\n')
716
                .map(|x| {
10✔
717
                    let ret = if 0 < x {
11✔
718
                        if self.0.as_bytes()[x - 1] == b'\r' {
16✔
719
                            (&self.0[..x - 1], EndLine::Crlf)
3✔
720
                        } else {
721
                            (&self.0[..x], EndLine::Lf)
3✔
722
                        }
723
                    } else {
724
                        ("", EndLine::Lf)
5✔
725
                    };
726
                    self.0 = &self.0[x + 1..];
9✔
727
                    ret
3✔
728
                })
729
                .or_else(|| {
10✔
730
                    let ret = Some((self.0, EndLine::Eof));
4✔
731
                    self.0 = "";
5✔
UNCOV
732
                    ret
×
733
                })
734
        }
735
    }
736
}
737

738
pub(crate) type SplicedLines<'a> = (
739
    String,
740
    Vec<TrimmedPatch<'a>>,
741
    Vec<Vec<SubstitutionHighlight>>,
742
);
743

744
/// Used to translate between `Span`s and byte positions within a single output line in highlighted
745
/// code of structured suggestions.
746
#[derive(Debug, Clone, Copy)]
747
pub(crate) struct SubstitutionHighlight {
748
    pub(crate) start: usize,
749
    pub(crate) end: usize,
750
}
751

752
#[derive(Clone, Debug)]
753
pub(crate) struct TrimmedPatch<'a> {
754
    pub(crate) original_span: Range<usize>,
755
    pub(crate) span: Range<usize>,
756
    pub(crate) replacement: Cow<'a, str>,
757
}
758

759
impl<'a> TrimmedPatch<'a> {
760
    pub(crate) fn is_addition(&self, sm: &SourceMap<'_>) -> bool {
2✔
761
        !self.replacement.is_empty() && !self.replaces_meaningful_content(sm)
2✔
762
    }
763

764
    pub(crate) fn is_deletion(&self, sm: &SourceMap<'_>) -> bool {
3✔
765
        self.replacement.trim().is_empty() && self.replaces_meaningful_content(sm)
3✔
766
    }
767

768
    pub(crate) fn is_replacement(&self, sm: &SourceMap<'_>) -> bool {
3✔
769
        !self.replacement.is_empty() && self.replaces_meaningful_content(sm)
3✔
770
    }
771

772
    /// Whether this is a replacement that overwrites source with a snippet
773
    /// in a way that isn't a superset of the original string. For example,
774
    /// replacing "abc" with "abcde" is not destructive, but replacing it
775
    /// it with "abx" is, since the "c" character is lost.
776
    pub(crate) fn is_destructive_replacement(&self, sm: &SourceMap<'_>) -> bool {
3✔
777
        self.is_replacement(sm)
3✔
778
            && !sm
3✔
779
                .span_to_snippet(self.span.clone())
3✔
780
                // This should use `is_some_and` when our MSRV is >= 1.70
781
                .map_or(false, |s| {
6✔
782
                    as_substr(s.trim(), self.replacement.trim()).is_some()
3✔
783
                })
784
    }
785

786
    fn replaces_meaningful_content(&self, sm: &SourceMap<'_>) -> bool {
3✔
787
        sm.span_to_snippet(self.span.clone())
3✔
788
            .map_or(!self.span.is_empty(), |snippet| !snippet.trim().is_empty())
9✔
789
    }
790
}
791

792
/// Given an original string like `AACC`, and a suggestion like `AABBCC`, try to detect
793
/// the case where a substring of the suggestion is "sandwiched" in the original, like
794
/// `BB` is. Return the length of the prefix, the "trimmed" suggestion, and the length
795
/// of the suffix.
796
pub(crate) fn as_substr<'a>(
3✔
797
    original: &'a str,
798
    suggestion: &'a str,
799
) -> Option<(usize, &'a str, usize)> {
800
    if let Some(stripped) = suggestion.strip_prefix(original) {
6✔
801
        Some((original.len(), stripped, 0))
3✔
802
    } else if let Some(stripped) = suggestion.strip_suffix(original) {
7✔
803
        Some((0, stripped, original.len()))
2✔
804
    } else {
UNCOV
805
        let common_prefix = original
×
806
            .chars()
807
            .zip(suggestion.chars())
3✔
808
            .take_while(|(c1, c2)| c1 == c2)
9✔
809
            .map(|(c, _)| c.len_utf8())
7✔
810
            .sum();
811
        let original = &original[common_prefix..];
3✔
812
        let suggestion = &suggestion[common_prefix..];
3✔
813
        if let Some(stripped) = suggestion.strip_suffix(original) {
7✔
814
            let common_suffix = original.len();
1✔
815
            Some((common_prefix, stripped, common_suffix))
1✔
816
        } else {
817
            None
3✔
818
        }
819
    }
820
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc