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

rust-lang / annotate-snippets-rs / 25443922302

06 May 2026 03:11PM UTC coverage: 91.059% (+0.2%) from 90.848%
25443922302

push

github

web-flow
Merge pull request #413 from Muscraft/fix-multiline-removals

Correctly highlight multi-line removals that leave only whitespace

39 of 39 new or added lines in 2 files covered. (100.0%)

1 existing line in 1 file now uncovered.

1487 of 1633 relevant lines covered (91.06%)

5.15 hits per line

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

86.2
/src/renderer/source_map.rs
1
use alloc::borrow::Cow;
2
use alloc::string::String;
3
use alloc::{vec, vec::Vec};
4
use core::cmp::{max, min};
5
use core::ops::Range;
6

7
use crate::renderer::{LineAnnotation, LineAnnotationType, char_width, num_overlap};
8
use crate::{Annotation, AnnotationKind, Patch};
9

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

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

33
        let mut current_index = 0;
7✔
34

35
        let mut mapping = vec![];
4✔
36
        for (idx, (line, end_line)) in CursorLines::new(source).enumerate() {
17✔
37
            let line_length = line.len();
8✔
38
            let line_range = current_index..current_index + line_length;
4✔
39
            let end_line_size = end_line.len();
14✔
40

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

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

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

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

88
        if span.start == span.end {
5✔
89
            return (start, start);
4✔
90
        }
91

92
        let end_info = self
4✔
93
            .lines
×
94
            .iter()
95
            .find(|info| span.end >= info.start_byte && span.end < info.end_byte)
18✔
96
            .unwrap_or(self.lines.last().unwrap());
6✔
97
        let (end_char_pos, end_display_pos) = end_info.line
9✔
98
            [0..(span.end - end_info.start_byte).min(end_info.line.len())]
12✔
99
            .chars()
100
            .fold((0, 0), |(char_pos, byte_pos), c| {
16✔
101
                let display = char_width(c);
4✔
102
                (char_pos + 1, byte_pos + display)
6✔
103
            });
104

105
        let mut end = Loc {
106
            line: end_info.line_index,
5✔
107
            char: end_char_pos,
108
            display: end_display_pos,
109
            byte: span.end,
3✔
110
        };
111
        if start.line != end.line && end.byte > end_info.end_byte - end_info.end_line_size {
11✔
112
            end.char += 1;
2✔
113
            end.display += 1;
4✔
114
        }
115

116
        (start, end)
6✔
117
    }
118

119
    pub(crate) fn span_to_snippet(&self, span: Range<usize>) -> Option<&str> {
6✔
120
        self.source.get(span)
6✔
121
    }
122

123
    pub(crate) fn span_to_lines(&self, span: Range<usize>) -> Vec<&LineInfo<'a>> {
5✔
124
        let mut lines = vec![];
4✔
125
        let start = span.start;
5✔
126
        let end = span.end;
4✔
127
        for line_info in &self.lines {
9✔
128
            if start >= line_info.end_byte {
4✔
129
                continue;
×
130
            }
131
            if end < line_info.start_byte {
5✔
132
                break;
×
133
            }
134
            lines.push(line_info);
4✔
135
        }
136

137
        if lines.is_empty() && !self.lines.is_empty() {
10✔
138
            lines.push(self.lines.last().unwrap());
1✔
139
        }
140

141
        lines
4✔
142
    }
143

144
    pub(crate) fn annotated_lines(
7✔
145
        &self,
146
        annotations: Vec<Annotation<'a>>,
147
        fold: bool,
148
    ) -> (usize, Vec<AnnotatedLineInfo<'a>>) {
149
        let source_len = self.source.len();
12✔
150
        if let Some(bigger) = annotations.iter().find_map(|x| {
12✔
151
            // Allow highlighting one past the last character in the source.
152
            if source_len + 1 < x.span.end {
12✔
153
                Some(&x.span)
1✔
154
            } else {
155
                None
6✔
156
            }
157
        }) {
158
            panic!("Annotation range `{bigger:?}` is beyond the end of buffer `{source_len}`")
2✔
159
        }
160

161
        let mut annotated_line_infos = self
6✔
162
            .lines
×
163
            .iter()
164
            .map(|info| AnnotatedLineInfo {
18✔
165
                line: info.line,
6✔
166
                line_index: info.line_index,
6✔
167
                annotations: vec![],
6✔
168
                keep: false,
×
169
            })
170
            .collect::<Vec<_>>();
171
        let mut multiline_annotations = vec![];
6✔
172

173
        for Annotation {
10✔
174
            span,
6✔
175
            label,
6✔
176
            kind,
6✔
177
            highlight_source,
6✔
178
        } in annotations
18✔
179
        {
180
            let (lo, mut hi) = self.span_to_locations(span.clone());
12✔
181
            if kind == AnnotationKind::Visible {
4✔
182
                for line_idx in lo.line..=hi.line {
4✔
183
                    self.keep_line(&mut annotated_line_infos, line_idx);
4✔
184
                }
185
                continue;
×
186
            }
187
            // Watch out for "empty spans". If we get a span like 6..6, we
188
            // want to just display a `^` at 6, so convert that to
189
            // 6..7. This is degenerate input, but it's best to degrade
190
            // gracefully -- and the parser likes to supply a span like
191
            // that for EOF, in particular.
192

193
            if lo.display == hi.display && lo.line == hi.line {
14✔
194
                hi.display += 1;
4✔
195
            }
196

197
            if lo.line == hi.line {
5✔
198
                let line_ann = LineAnnotation {
199
                    start: lo,
200
                    end: hi,
201
                    kind,
202
                    label,
203
                    annotation_type: LineAnnotationType::Singleline,
204
                    highlight_source,
205
                };
206
                self.add_annotation_to_file(&mut annotated_line_infos, lo.line, line_ann);
10✔
207
            } else {
208
                multiline_annotations.push(MultilineAnnotation {
8✔
209
                    depth: 1,
×
210
                    start: lo,
×
211
                    end: hi,
4✔
212
                    kind,
×
213
                    label,
4✔
214
                    overlaps_exactly: false,
×
215
                    highlight_source,
×
216
                });
217
            }
218
        }
219

220
        let mut primary_spans = vec![];
6✔
221

222
        // Find overlapping multiline annotations, put them at different depths
223
        multiline_annotations.sort_by_key(|ml| (ml.start.line, usize::MAX - ml.end.line));
14✔
224
        for (outer_i, ann) in multiline_annotations.clone().into_iter().enumerate() {
12✔
225
            if ann.kind.is_primary() {
8✔
226
                primary_spans.push((ann.start, ann.end));
4✔
227
            }
228
            for (inner_i, a) in &mut multiline_annotations.iter_mut().enumerate() {
8✔
229
                // Move all other multiline annotations overlapping with this one
230
                // one level to the right.
231
                if !ann.same_span(a)
10✔
232
                    && num_overlap(ann.start.line, ann.end.line, a.start.line, a.end.line, true)
4✔
233
                {
234
                    a.increase_depth();
2✔
235
                } else if ann.same_span(a) && outer_i != inner_i {
12✔
236
                    a.overlaps_exactly = true;
2✔
237
                } else {
238
                    if primary_spans
16✔
239
                        .iter()
240
                        .any(|(s, e)| a.start == *s && a.end == *e)
12✔
241
                    {
242
                        a.kind = AnnotationKind::Primary;
4✔
243
                    }
244
                    break;
×
245
                }
246
            }
247
        }
248

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

322
        if fold {
6✔
323
            annotated_line_infos.retain(|l| !l.annotations.is_empty() || l.keep);
18✔
324
        }
325

326
        (max_depth, annotated_line_infos)
6✔
327
    }
328

329
    fn add_annotation_to_file(
6✔
330
        &self,
331
        annotated_line_infos: &mut Vec<AnnotatedLineInfo<'a>>,
332
        line_index: usize,
333
        line_ann: LineAnnotation<'a>,
334
    ) {
335
        if let Some(line_info) = annotated_line_infos
18✔
336
            .iter_mut()
337
            .find(|line_info| line_info.line_index == line_index)
16✔
338
        {
339
            line_info.annotations.push(line_ann);
10✔
340
        } else {
341
            let info = self
×
342
                .lines
×
343
                .iter()
344
                .find(|l| l.line_index == line_index)
×
345
                .unwrap();
346
            annotated_line_infos.push(AnnotatedLineInfo {
×
347
                line: info.line,
×
348
                line_index,
×
349
                annotations: vec![line_ann],
×
350
                keep: false,
×
351
            });
352
            annotated_line_infos.sort_by_key(|l| l.line_index);
×
353
        }
354
    }
355

356
    fn keep_line(&self, annotated_line_infos: &mut Vec<AnnotatedLineInfo<'a>>, line_index: usize) {
2✔
357
        if let Some(line_info) = annotated_line_infos
6✔
358
            .iter_mut()
359
            .find(|line_info| line_info.line_index == line_index)
6✔
360
        {
361
            line_info.keep = true;
2✔
362
        } else {
363
            let info = self
×
364
                .lines
×
365
                .iter()
366
                .find(|l| l.line_index == line_index)
×
367
                .unwrap();
368
            annotated_line_infos.push(AnnotatedLineInfo {
×
369
                line: info.line,
×
370
                line_index,
×
371
                annotations: vec![],
×
372
                keep: true,
×
373
            });
374
            annotated_line_infos.sort_by_key(|l| l.line_index);
×
375
        }
376
    }
377

378
    pub(crate) fn splice_lines<'b>(
6✔
379
        &'a self,
380
        mut patches: Vec<Patch<'b>>,
381
        fold: bool,
382
    ) -> Option<SplicedLines<'b>> {
383
        fn push_trailing(buf: &mut String, line_opt: Option<&str>, lo: &Loc, hi_opt: Option<&Loc>) {
4✔
384
            // Convert CharPos to Usize, as CharPose is character offset
385
            // Extract low index and high index
386
            let (lo, hi_opt) = (lo.char, hi_opt.map(|hi| hi.char));
12✔
387
            if let Some(line) = line_opt {
4✔
388
                if let Some(lo) = line.char_indices().map(|(i, _)| i).nth(lo) {
16✔
389
                    // Get high index while account for rare unicode and emoji with char_indices
390
                    let hi_opt = hi_opt.and_then(|hi| line.char_indices().map(|(i, _)| i).nth(hi));
20✔
391
                    match hi_opt {
4✔
392
                        // If high index exist, take string from low to high index
393
                        Some(hi) if hi > lo => buf.push_str(&line[lo..hi]),
8✔
UNCOV
394
                        Some(_) => (),
×
395
                        // If high index absence, take string from low index till end string.len
396
                        None => buf.push_str(&line[lo..]),
4✔
397
                    }
398
                }
399
                // If high index is None
400
                if hi_opt.is_none() {
4✔
401
                    buf.push('\n');
4✔
402
                }
403
            }
404
        }
405

406
        let source_len = self.source.len();
9✔
407
        if let Some(bigger) = patches.iter().find_map(|x| {
9✔
408
            // Allow patching one past the last character in the source.
409
            if source_len + 1 < x.span.end {
8✔
410
                Some(&x.span)
1✔
411
            } else {
412
                None
5✔
413
            }
414
        }) {
415
            panic!("Patch span `{bigger:?}` is beyond the end of buffer `{source_len}`")
2✔
416
        }
417

418
        // Assumption: all spans are in the same file, and all spans
419
        // are disjoint. Sort in ascending order.
420
        patches.sort_by_key(|p| p.span.start);
15✔
421

422
        // Find the bounding span.
423
        let (lo, hi) = if fold {
15✔
424
            let lo = patches.iter().map(|p| p.span.start).min()?;
18✔
425
            let hi = patches.iter().map(|p| p.span.end).max()?;
13✔
426
            (lo, hi)
5✔
427
        } else {
428
            (0, source_len)
1✔
429
        };
430

431
        let lines = self.span_to_lines(lo..hi);
4✔
432

433
        let mut highlights = vec![];
5✔
434
        // To build up the result, we do this for each span:
435
        // - push the line segment trailing the previous span
436
        //   (at the beginning a "phantom" span pointing at the start of the line)
437
        // - push lines between the previous and current span (if any)
438
        // - if the previous and current span are not on the same line
439
        //   push the line segment leading up to the current span
440
        // - splice in the span substitution
441
        //
442
        // Finally push the trailing line segment of the last span
443
        let (mut prev_hi, _) = self.span_to_locations(lo..hi);
9✔
444
        prev_hi.char = 0;
4✔
445
        let mut prev_line = lines.first().map(|line| line.line);
13✔
446
        let mut buf = String::new();
4✔
447

448
        let trimmed_patches = patches
5✔
449
            .into_iter()
450
            // If this is a replacement of, e.g. `"a"` into `"ab"`, adjust the
451
            // suggestion and snippet to look as if we just suggested to add
452
            // `"b"`, which is typically much easier for the user to understand.
453
            .map(|part| part.trim_trivial_replacements(self.source))
13✔
454
            .collect::<Vec<_>>();
455
        let mut line_highlight = vec![];
5✔
456
        // We need to keep track of the difference between the existing code and the added
457
        // or deleted code in order to point at the correct column *after* substitution.
458
        let mut acc = 0;
5✔
459
        for part in &trimmed_patches {
8✔
460
            let (cur_lo, cur_hi) = self.span_to_locations(part.span.clone());
8✔
461
            if prev_hi.line == cur_lo.line {
4✔
462
                push_trailing(&mut buf, prev_line, &prev_hi, Some(&cur_lo));
8✔
463
            } else {
464
                acc = 0;
1✔
465
                highlights.push(core::mem::take(&mut line_highlight));
2✔
466
                push_trailing(&mut buf, prev_line, &prev_hi, None);
1✔
467
                // push lines between the previous and current span (if any)
468
                for idx in prev_hi.line + 1..(cur_lo.line) {
1✔
469
                    if let Some(line) = self.get_line(idx) {
2✔
470
                        buf.push_str(line.as_ref());
1✔
471
                        buf.push('\n');
1✔
472
                        highlights.push(core::mem::take(&mut line_highlight));
1✔
473
                    }
474
                }
475
                if let Some(cur_line) = self.get_line(cur_lo.line) {
1✔
476
                    let end = match cur_line.char_indices().nth(cur_lo.char) {
2✔
477
                        Some((i, _)) => i,
1✔
478
                        None => cur_line.len(),
1✔
479
                    };
480
                    buf.push_str(&cur_line[..end]);
1✔
481
                }
482
            }
483
            // Add a whole line highlight per line in the snippet.
484
            let len: isize = part
8✔
485
                .replacement
×
486
                .split('\n')
487
                .next()
488
                .unwrap_or(&part.replacement)
4✔
489
                .chars()
490
                .map(|c| match c {
12✔
491
                    '\t' => 4,
×
492
                    _ => 1,
4✔
493
                })
494
                .sum();
495
            line_highlight.push(SubstitutionHighlight {
4✔
496
                start: (cur_lo.char as isize + acc) as usize,
4✔
497
                end: (cur_lo.char as isize + acc + len) as usize,
8✔
498
            });
499
            buf.push_str(&part.replacement);
4✔
500
            // Account for the difference between the width of the current code and the
501
            // snippet being suggested, so that the *later* suggestions are correctly
502
            // aligned on the screen. Note that cur_hi and cur_lo can be on different
503
            // lines, so cur_hi.col can be smaller than cur_lo.col
504
            acc += len - (cur_hi.char as isize - cur_lo.char as isize);
4✔
505
            prev_hi = cur_hi;
5✔
506
            prev_line = self.get_line(prev_hi.line);
10✔
507
            for line in part.replacement.split('\n').skip(1) {
4✔
508
                acc = 0;
2✔
509
                highlights.push(core::mem::take(&mut line_highlight));
2✔
510
                let end: usize = line
2✔
511
                    .chars()
512
                    .map(|c| match c {
6✔
513
                        '\t' => 4,
×
514
                        _ => 1,
2✔
515
                    })
516
                    .sum();
517
                line_highlight.push(SubstitutionHighlight { start: 0, end });
2✔
518
            }
519
        }
520
        highlights.push(core::mem::take(&mut line_highlight));
4✔
521
        if fold {
4✔
522
            // if the replacement already ends with a newline, don't print the next line
523
            if !buf.ends_with('\n') {
9✔
524
                push_trailing(&mut buf, prev_line, &prev_hi, None);
5✔
525
            }
526
        } else {
527
            // Add the trailing part of the source after the last patch
528
            if let Some(snippet) = self.span_to_snippet(prev_hi.byte..source_len) {
4✔
529
                buf.push_str(snippet);
2✔
530
                for _ in snippet.matches('\n') {
2✔
531
                    highlights.push(core::mem::take(&mut line_highlight));
2✔
532
                }
533
            }
534
        }
535
        // remove trailing newlines
536
        while buf.ends_with('\n') {
9✔
537
            buf.pop();
9✔
538
        }
539

540
        let (bounding_lo, bounding_hi) = self.span_to_locations(lo..hi);
9✔
541
        let line_count = bounding_hi.line.saturating_sub(bounding_lo.line) + 1;
4✔
542
        let mut replaced_highlights: Vec<Vec<SubstitutionHighlight>> = vec![Vec::new(); line_count];
11✔
543
        for part in &trimmed_patches {
12✔
544
            let (cur_lo, cur_hi) = self.span_to_locations(part.span.clone());
11✔
545
            for line in cur_lo.line..=cur_hi.line {
4✔
546
                let start = if line == cur_lo.line { cur_lo.char } else { 0 };
5✔
547
                let end = if line == cur_hi.line {
8✔
548
                    cur_hi.char
4✔
549
                } else {
550
                    self.get_line(line).unwrap_or_default().chars().count()
6✔
551
                };
552
                replaced_highlights[line - bounding_lo.line]
9✔
553
                    .push(SubstitutionHighlight { start, end });
4✔
554
            }
555
        }
556

557
        if highlights.iter().all(|parts| parts.is_empty()) {
20✔
558
            None
×
559
        } else {
560
            Some((buf, trimmed_patches, highlights, replaced_highlights))
6✔
561
        }
562
    }
563
}
564

565
#[derive(Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]
566
pub(crate) struct MultilineAnnotation<'a> {
567
    pub depth: usize,
568
    pub start: Loc,
569
    pub end: Loc,
570
    pub kind: AnnotationKind,
571
    pub label: Option<Cow<'a, str>>,
572
    pub overlaps_exactly: bool,
573
    pub highlight_source: bool,
574
}
575

576
impl<'a> MultilineAnnotation<'a> {
577
    pub(crate) fn increase_depth(&mut self) {
2✔
578
        self.depth += 1;
2✔
579
    }
580

581
    /// Compare two `MultilineAnnotation`s considering only the `Span` they cover.
582
    pub(crate) fn same_span(&self, other: &MultilineAnnotation<'_>) -> bool {
4✔
583
        self.start == other.start && self.end == other.end
4✔
584
    }
585

586
    pub(crate) fn as_start(&self) -> LineAnnotation<'a> {
5✔
587
        LineAnnotation {
588
            start: self.start,
5✔
589
            end: Loc {
5✔
590
                line: self.start.line,
591
                char: self.start.char + 1,
592
                display: self.start.display + 1,
593
                byte: self.start.byte + 1,
594
            },
595
            kind: self.kind,
5✔
596
            label: None,
597
            annotation_type: LineAnnotationType::MultilineStart(self.depth),
5✔
598
            highlight_source: self.highlight_source,
5✔
599
        }
600
    }
601

602
    pub(crate) fn as_end(&self) -> LineAnnotation<'a> {
5✔
603
        LineAnnotation {
604
            start: Loc {
5✔
605
                line: self.end.line,
606
                char: self.end.char.saturating_sub(1),
607
                display: self.end.display.saturating_sub(1),
608
                byte: self.end.byte.saturating_sub(1),
609
            },
610
            end: self.end,
5✔
611
            kind: self.kind,
5✔
612
            label: self.label.clone(),
5✔
613
            annotation_type: LineAnnotationType::MultilineEnd(self.depth),
5✔
614
            highlight_source: self.highlight_source,
5✔
615
        }
616
    }
617

618
    pub(crate) fn as_line(&self) -> LineAnnotation<'a> {
3✔
619
        LineAnnotation {
620
            start: Loc::default(),
3✔
621
            end: Loc::default(),
3✔
622
            kind: self.kind,
3✔
623
            label: None,
624
            annotation_type: LineAnnotationType::MultilineLine(self.depth),
3✔
625
            highlight_source: self.highlight_source,
3✔
626
        }
627
    }
628
}
629

630
#[derive(Debug)]
631
pub(crate) struct LineInfo<'a> {
632
    pub(crate) line: &'a str,
633
    pub(crate) line_index: usize,
634
    pub(crate) start_byte: usize,
635
    pub(crate) end_byte: usize,
636
    end_line_size: usize,
637
}
638

639
#[derive(Debug)]
640
pub(crate) struct AnnotatedLineInfo<'a> {
641
    pub(crate) line: &'a str,
642
    pub(crate) line_index: usize,
643
    pub(crate) annotations: Vec<LineAnnotation<'a>>,
644
    pub(crate) keep: bool,
645
}
646

647
/// A source code location used for error reporting.
648
#[derive(Clone, Copy, Debug, Default, PartialOrd, Ord, PartialEq, Eq)]
649
pub(crate) struct Loc {
650
    /// The (1-based) line number.
651
    pub(crate) line: usize,
652
    /// The (0-based) column offset.
653
    pub(crate) char: usize,
654
    /// The (0-based) column offset when displayed.
655
    pub(crate) display: usize,
656
    /// The (0-based) byte offset.
657
    pub(crate) byte: usize,
658
}
659

660
struct CursorLines<'a>(&'a str);
661

662
impl CursorLines<'_> {
663
    fn new(src: &str) -> CursorLines<'_> {
6✔
664
        CursorLines(src)
665
    }
666
}
667

668
#[derive(Copy, Clone, Debug, PartialEq)]
669
enum EndLine {
670
    Eof,
671
    Lf,
672
    Crlf,
673
}
674

675
impl EndLine {
676
    /// The number of characters this line ending occupies in bytes.
677
    pub(crate) fn len(self) -> usize {
5✔
678
        match self {
7✔
679
            EndLine::Eof => 0,
5✔
680
            EndLine::Lf => 1,
4✔
681
            EndLine::Crlf => 2,
1✔
682
        }
683
    }
684
}
685

686
impl<'a> Iterator for CursorLines<'a> {
687
    type Item = (&'a str, EndLine);
688

689
    fn next(&mut self) -> Option<Self::Item> {
4✔
690
        if self.0.is_empty() {
6✔
691
            None
5✔
692
        } else {
693
            self.0
5✔
694
                .find('\n')
695
                .map(|x| {
11✔
696
                    let ret = if 0 < x {
9✔
697
                        if self.0.as_bytes()[x - 1] == b'\r' {
18✔
698
                            (&self.0[..x - 1], EndLine::Crlf)
2✔
699
                        } else {
700
                            (&self.0[..x], EndLine::Lf)
3✔
701
                        }
702
                    } else {
703
                        ("", EndLine::Lf)
4✔
704
                    };
705
                    self.0 = &self.0[x + 1..];
9✔
706
                    ret
5✔
707
                })
708
                .or_else(|| {
10✔
709
                    let ret = Some((self.0, EndLine::Eof));
3✔
710
                    self.0 = "";
3✔
711
                    ret
×
712
                })
713
        }
714
    }
715
}
716

717
pub(crate) type SplicedLines<'a> = (
718
    String,
719
    Vec<TrimmedPatch<'a>>,
720
    // Char spans to highlight per line of the post-substitution output.
721
    Vec<Vec<SubstitutionHighlight>>,
722
    // Char spans of the replaced (original) code, per original line in the
723
    // bounding range covered by the splice.
724
    Vec<Vec<SubstitutionHighlight>>,
725
);
726

727
/// Used to translate between `Span`s and byte positions within a single output line in highlighted
728
/// code of structured suggestions.
729
#[derive(Debug, Clone, Copy)]
730
pub(crate) struct SubstitutionHighlight {
731
    pub(crate) start: usize,
732
    pub(crate) end: usize,
733
}
734

735
#[derive(Clone, Debug)]
736
pub(crate) struct TrimmedPatch<'a> {
737
    pub(crate) original_span: Range<usize>,
738
    pub(crate) span: Range<usize>,
739
    pub(crate) replacement: Cow<'a, str>,
740
}
741

742
impl<'a> TrimmedPatch<'a> {
743
    pub(crate) fn is_addition(&self, sm: &SourceMap<'_>) -> bool {
2✔
744
        !self.replacement.is_empty() && !self.replaces_meaningful_content(sm)
2✔
745
    }
746

747
    pub(crate) fn is_deletion(&self, sm: &SourceMap<'_>) -> bool {
5✔
748
        self.replacement.trim().is_empty() && self.replaces_meaningful_content(sm)
6✔
749
    }
750

751
    pub(crate) fn is_replacement(&self, sm: &SourceMap<'_>) -> bool {
4✔
752
        !self.replacement.is_empty() && self.replaces_meaningful_content(sm)
5✔
753
    }
754

755
    /// Whether this is a replacement that overwrites source with a snippet
756
    /// in a way that isn't a superset of the original string. For example,
757
    /// replacing "abc" with "abcde" is not destructive, but replacing it
758
    /// it with "abx" is, since the "c" character is lost.
759
    pub(crate) fn is_destructive_replacement(&self, sm: &SourceMap<'_>) -> bool {
3✔
760
        self.is_replacement(sm)
4✔
761
            && sm
×
762
                .span_to_snippet(self.span.clone())
3✔
763
                .is_none_or(|s| as_substr(s.trim(), self.replacement.trim()).is_none())
9✔
764
    }
765

766
    fn replaces_meaningful_content(&self, sm: &SourceMap<'_>) -> bool {
6✔
767
        sm.span_to_snippet(self.span.clone())
6✔
768
            .map_or(!self.span.is_empty(), |snippet| !snippet.trim().is_empty())
17✔
769
    }
770
}
771

772
/// Given an original string like `AACC`, and a suggestion like `AABBCC`, try to detect
773
/// the case where a substring of the suggestion is "sandwiched" in the original, like
774
/// `BB` is. Return the length of the prefix, the "trimmed" suggestion, and the length
775
/// of the suffix.
776
pub(crate) fn as_substr<'a>(
4✔
777
    original: &'a str,
778
    suggestion: &'a str,
779
) -> Option<(usize, &'a str, usize)> {
780
    if let Some(stripped) = suggestion.strip_prefix(original) {
8✔
781
        Some((original.len(), stripped, 0))
4✔
782
    } else if let Some(stripped) = suggestion.strip_suffix(original) {
7✔
783
        Some((0, stripped, original.len()))
2✔
784
    } else {
785
        let common_prefix = original
×
786
            .chars()
787
            .zip(suggestion.chars())
3✔
788
            .take_while(|(c1, c2)| c1 == c2)
9✔
789
            .map(|(c, _)| c.len_utf8())
7✔
790
            .sum();
791
        let original = &original[common_prefix..];
3✔
792
        let suggestion = &suggestion[common_prefix..];
3✔
793
        if let Some(stripped) = suggestion.strip_suffix(original) {
7✔
794
            let common_suffix = original.len();
1✔
795
            Some((common_prefix, stripped, common_suffix))
1✔
796
        } else {
797
            None
3✔
798
        }
799
    }
800
}
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