Skip to main content

praxis_source/
snippet.rs

1//! Shared source-snippet rendering: the `path:line:col` header, the numbered
2//! source line(s) a span touches, and a caret underline.
3//!
4//! Both the compiler [`Renderer`](crate::diagnostic::Renderer) and the crash
5//! debugger's `render_source_span` build their snippets through this module, so
6//! the caret behavior (clamping to the visible line, multi-line span handling)
7//! stays identical across the two surfaces and is tested in one place.
8//!
9//! Design notes:
10//!
11//! - A span's carets never overrun the visible line: the underline on each
12//!   rendered line is clamped to that line's content extent, so a multi-line
13//!   span (e.g. the whole `fn`) cannot draw a caret run wider than the source.
14//! - For a span that crosses several lines we underline each covered line:
15//!   from the span start to the line end on the first line, the full content
16//!   width on middle lines, and from the line start to the span end on the last
17//!   line (the rustc/ariadne convention). The number of rendered lines is
18//!   capped so a pathological whole-file span stays readable.
19//! - The `message` (when non-empty) trails the carets on the *first* underlined
20//!   line, matching §8.2 (`^^^^ this value is Text`).
21//! - **A column is a count of characters, not of bytes.** Spans are byte
22//!   ranges — that is what every other layer needs — but a rendered column is a
23//!   position in the line as it is *printed*, and the two only coincide in
24//!   ASCII: one `λ` earlier on the line would otherwise push the caret one
25//!   column right, and a `λ` under the caret would draw two carets for one
26//!   character. The conversion belongs here, not in `LineMap`: `LineCol`
27//!   round-trips through `linecol_to_offset` and is a byte column on purpose.
28//! - **A header column is 1-based, like its line.** `LineCol::col` counts from
29//!   zero so it can be inverted; the `+ 1` is applied here, at the one place a
30//!   column is printed, and `LineMap` is unaffected. The caret padding keeps
31//!   the 0-based number: it is a count of characters to skip, not a position.
32
33use std::fmt::Write;
34
35use crate::file::SourceFile;
36use crate::line_map::{LineCol, LineMap};
37use crate::span::{BytePos, FileSpan};
38use crate::style::{Palette, Severity as StyleSeverity, Style};
39
40/// The maximum number of source lines a single snippet may render before it is
41/// collapsed. A span covering more lines shows its first `HEAD_LINES` lines and
42/// its final line, with an ellipsis between, so a whole-file span stays a
43/// glance, not a dump.
44pub const MAX_SNIPPET_LINES: usize = 5;
45/// Lines kept at the head of a collapsed multi-line span before the ellipsis.
46const HEAD_LINES: u32 = 3;
47
48/// A secondary underline label carried alongside a caret run. `Plain` draws
49/// just the carets; `Labelled(text)` appends ` {text}` after them on the first
50/// underlined line (the §8.2 `^^^^ this value is Text` form).
51#[derive(Clone, Copy, Default)]
52pub enum CaretLabel<'a> {
53    /// No trailing text — carets only.
54    #[default]
55    Plain,
56    /// Append `text` after the carets on the first underlined line.
57    Labelled(&'a str),
58}
59
60impl<'a> CaretLabel<'a> {
61    fn text(self) -> Option<&'a str> {
62        match self {
63            CaretLabel::Plain => None,
64            CaretLabel::Labelled(s) => Some(s),
65        }
66    }
67}
68
69/// Render the `path:line:col` header followed by the numbered source line(s)
70/// the span touches, with a clamped caret underline.
71///
72/// `label` is shown after the carets on the first underlined line. The output
73/// is appended to `out` with no leading/trailing blank line; callers frame it.
74///
75/// The layout matches §8.2:
76///
77/// ```text
78///   day03.px:18:14
79///   18 | total += line
80///      |          ^^^^ this value is Text
81/// ```
82pub fn render_span_snippet(
83    file: &SourceFile,
84    span: FileSpan,
85    label: CaretLabel<'_>,
86    out: &mut String,
87) {
88    render_span_snippet_with_limits(file, span, label, out, MAX_SNIPPET_LINES);
89}
90
91/// Like [`render_span_snippet`], but renders up to `max_lines` source lines
92/// before collapsing the rest to an ellipsis. Pass a large `max_lines` (e.g.
93/// `u32::MAX`) to disable collapsing entirely — the crash debugger uses this for
94/// its `source` command, where the whole function body (including the faulting
95/// line) must be visible, not elided.
96pub fn render_span_snippet_with_limits(
97    file: &SourceFile,
98    span: FileSpan,
99    label: CaretLabel<'_>,
100    out: &mut String,
101    max_lines: usize,
102) {
103    render_span_snippet_styled(file, span, label, out, max_lines, &Palette::plain(), None);
104}
105
106/// The fully-parameterized snippet renderer: `palette` controls ANSI styling,
107/// `sev` (when `Some`) colors the caret run in the matching severity color.
108/// `sev = None` draws plain carets (used by the crash debugger and notes).
109pub fn render_span_snippet_styled(
110    file: &SourceFile,
111    span: FileSpan,
112    label: CaretLabel<'_>,
113    out: &mut String,
114    max_lines: usize,
115    palette: &Palette,
116    sev: Option<StyleSeverity>,
117) {
118    let line_map = file.line_map();
119    let text = file.text();
120    let start = span.span.start();
121    let end = span.span.end();
122    let LineCol {
123        line: start_line,
124        col: byte_col,
125    } = line_map.offset_to_linecol(start);
126    // The header column is the printed one, so it agrees with the caret below
127    // it. `byte_col` is the distance from the line start in bytes, which is how
128    // the line start is recovered.
129    //
130    // **`+ 1` because a header column is 1-based**, like its line.
131    // `LineCol::col` counts from zero — deliberately, so `linecol_to_offset`
132    // can invert it. The caret does not get the `+ 1`: it is drawn from a
133    // *count* of characters to skip, which is the 0-based number.
134    let col = char_width(text, BytePos(start.to_u32() - byte_col), start) + 1;
135
136    out.push('\n');
137    let loc = palette.paint(
138        Style::Location,
139        &format!("  {}:{}:{}", file.path().display(), start_line, col),
140    );
141    let _ = writeln!(out, "{loc}");
142
143    // A zero-length span still underlines its single position; a non-empty span
144    // may cross several lines. The end's line is the line of its last covered
145    // byte (end is exclusive, so end-1 is the last byte inside the span). A span
146    // end past EOF (e.g. a synthetic wide span) is clamped to the last real
147    // content byte so it does not map to a phantom line after the final newline.
148    let text_len = text.len() as u32;
149    let last_content = end.to_u32().min(text_len).saturating_sub(1);
150    let end_line = if end > start && last_content >= start.to_u32() {
151        line_map.offset_to_linecol(BytePos(last_content)).line
152    } else {
153        start_line
154    };
155
156    let span_lines = end_line.saturating_sub(start_line) + 1;
157    let ellide = span_lines as usize > max_lines;
158    let last_line = end_line;
159
160    let mut first_underline_done = false;
161    let mut line = start_line;
162    while line <= last_line {
163        // When there are too many lines, show the first HEAD_LINES then jump to
164        // the final line with an ellipsis separator.
165        if ellide && line > start_line + HEAD_LINES - 1 && line < last_line {
166            let _ = writeln!(out, "  ...");
167            line = last_line;
168            continue;
169        }
170
171        let (line_text, line_start, content_end) = line_text(text, line_map, line);
172        let gutter = palette.paint(Style::Location, &format!("  {line} | "));
173        let _ = writeln!(out, "{gutter}{line_text}");
174
175        render_caret_line(
176            out,
177            text,
178            line,
179            last_line,
180            start,
181            end,
182            line_start,
183            content_end,
184            line == start_line,
185            &mut first_underline_done,
186            label,
187            palette,
188            sev,
189        );
190
191        line += 1;
192    }
193}
194
195/// Render the caret underline for a single source line, clamped to that line's
196/// intersection with `[start, end)`.
197#[allow(clippy::too_many_arguments)]
198fn render_caret_line(
199    out: &mut String,
200    text: &str,
201    line: u32,
202    last_line: u32,
203    start: BytePos,
204    end: BytePos,
205    line_start: BytePos,
206    content_end: BytePos,
207    is_start_line: bool,
208    first_underline_done: &mut bool,
209    label: CaretLabel<'_>,
210    palette: &Palette,
211    sev: Option<StyleSeverity>,
212) {
213    let multi = last_line != line || (end > content_end && is_start_line);
214    // Only draw a caret if this line intersects the span. An empty span draws a
215    // single caret on its start line.
216    let seg_start = start.max(line_start);
217    let seg_end = if end == start {
218        // Zero-length span: a single caret at the position.
219        seg_start
220    } else {
221        end.min(content_end).max(seg_start)
222    };
223    if seg_end < seg_start && !(end == start && is_start_line) {
224        return;
225    }
226
227    let gutter_width = last_line.to_string().len();
228    let pad: String = " ".repeat(gutter_width);
229    let gutter = palette.paint(Style::Location, &format!("  {pad} | "));
230    let _ = write!(out, "{gutter}");
231    // **Characters, not bytes.** The padding has to be as wide as the line's
232    // text is *printed*, and the caret run as wide as what it underlines.
233    let caret_col = char_width(text, line_start, seg_start);
234    for _ in 0..caret_col {
235        out.push(' ');
236    }
237    let count = if end == start {
238        1
239    } else {
240        char_width(text, seg_start, seg_end).max(1)
241    };
242    let carets = "^".repeat(count);
243    let carets = match sev {
244        Some(s) => palette.paint(Style::Caret(s), &carets),
245        None => carets,
246    };
247    let _ = write!(out, "{carets}");
248    if !*first_underline_done {
249        if let Some(msg) = label.text() {
250            let _ = write!(out, " {msg}");
251        }
252        *first_underline_done = true;
253    }
254    if multi {
255        let _ = write!(out, "...");
256    }
257    out.push('\n');
258}
259
260/// How many **characters** `text[from..to]` holds — the printed width of a byte
261/// range, which is what a column and a caret run are measured in.
262///
263/// Out-of-range or non-boundary offsets fall back to the byte count: a caret
264/// that is one column off is better than a panic, and every caller here passes
265/// offsets that came from a span, which are boundaries.
266fn char_width(text: &str, from: BytePos, to: BytePos) -> usize {
267    let lo = from.to_u32() as usize;
268    let hi = (to.to_u32() as usize).max(lo);
269    text.get(lo..hi)
270        .map_or_else(|| hi - lo, |slice| slice.chars().count())
271}
272
273/// The trimmed text of `line` (1-based) and its `[line_start, content_end)`
274/// byte extent. `content_end` excludes the line terminator.
275fn line_text<'a>(text: &'a str, line_map: &LineMap, line: u32) -> (&'a str, BytePos, BytePos) {
276    let (line_start, line_end) = line_map
277        .line_range(line)
278        .unwrap_or((BytePos::ZERO, BytePos::ZERO));
279    let bytes = text.as_bytes();
280    let s = line_start.to_usize();
281    let e = (line_end.to_u32() as usize).min(bytes.len());
282    let line_text = std::str::from_utf8(&bytes[s..e])
283        .unwrap_or("<invalid utf-8>")
284        .trim_end_matches(['\n', '\r']);
285    let content_end = LineMap::trim_line_terminator(bytes, line_start, line_end);
286    (line_text, line_start, content_end)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::file::SourceMap;
293    use crate::span::Span;
294
295    fn render(file_text: &str, start: u32, end: u32, label: CaretLabel<'_>) -> String {
296        let map = SourceMap::new();
297        let id = map.intern("f.px", file_text);
298        let file = map.get(id).unwrap();
299        let span = FileSpan::new(id, Span::new(start, end));
300        let mut out = String::new();
301        render_span_snippet(&file, span, label, &mut out);
302        out
303    }
304
305    #[test]
306    fn single_line_span_with_label() {
307        // "total += line" — "line" at 9..13.
308        let out = render(
309            "total += line\n",
310            9,
311            13,
312            CaretLabel::Labelled("this value is Text"),
313        );
314        // The caret `|` lines up under the source line's `|` (both at column 4).
315        insta::assert_snapshot!(out, @r#"
316  f.px:1:10
317  1 | total += line
318    |          ^^^^ this value is Text
319"#);
320    }
321
322    #[test]
323    fn single_line_span_plain() {
324        let out = render("ab\ncd\n", 0, 1, CaretLabel::Plain);
325        insta::assert_snapshot!(out, @r#"
326  f.px:1:1
327  1 | ab
328    | ^
329"#);
330    }
331
332    #[test]
333    fn multi_line_span_underlines_each_line() {
334        let src = "fn main() -> Int {\n    out(\"x\")\n}\n";
335        let end = src.len() as u32;
336        let out = render(src, 0, end, CaretLabel::Plain);
337        insta::assert_snapshot!(out, @r#"
338  f.px:1:1
339  1 | fn main() -> Int {
340    | ^^^^^^^^^^^^^^^^^^...
341  2 |     out("x")
342    | ^^^^^^^^^^^^...
343  3 | }
344    | ^
345"#);
346    }
347
348    #[test]
349    fn huge_span_collapses_to_head_plus_last() {
350        let src = "a\nb\nc\nd\ne\nf\ng\nh\n";
351        let end = src.len() as u32;
352        let out = render(src, 0, end, CaretLabel::Plain);
353        insta::assert_snapshot!(out, @r#"
354  f.px:1:1
355  1 | a
356    | ^...
357  2 | b
358    | ^...
359  3 | c
360    | ^...
361  ...
362  8 | h
363    | ^
364"#);
365    }
366
367    #[test]
368    fn caret_never_overflows_visible_line() {
369        // Span deliberately extends past line 1's content into line 2. The
370        // caret on line 1 must stop at the line end, not run on past "short",
371        // and a past-EOF end must not invent a phantom line 3.
372        let src = "short\nnext line\n";
373        let out = render(src, 2, 99, CaretLabel::Plain);
374        insta::assert_snapshot!(out, @r#"
375  f.px:1:3
376  1 | short
377    |   ^^^...
378  2 | next line
379    | ^^^^^^^^^
380"#);
381    }
382
383    /// **A column is a count of characters, not of bytes**, for both the
384    /// padding before the carets and the length of the caret run.
385    ///
386    /// `λ` is two bytes, so `name` sits at **byte** 15 and **column** 13.
387    #[test]
388    fn a_caret_counts_characters_and_not_bytes() {
389        let src = "var y = λλ + name\n";
390        let start = src.find("name").expect("the needle") as u32;
391        assert_eq!(start, 15, "the byte offset is what a span carries");
392        let out = render(src, start, start + 4, CaretLabel::Plain);
393        insta::assert_snapshot!(out, @r#"
394  f.px:1:14
395  1 | var y = λλ + name
396    |              ^^^^
397"#);
398
399        // And the run itself: two `λ` under the caret are two carets, not four.
400        let out = render(src, 8, 12, CaretLabel::Plain);
401        insta::assert_snapshot!(out, @r#"
402  f.px:1:9
403  1 | var y = λλ + name
404    |         ^^
405"#);
406    }
407
408    #[test]
409    fn empty_span_draws_single_caret() {
410        let out = render("abc\n", 1, 1, CaretLabel::Plain);
411        insta::assert_snapshot!(out, @r#"
412  f.px:1:2
413  1 | abc
414    |  ^
415"#);
416    }
417
418    /// **A header column is 1-based**, like its line, and it names the
419    /// character the caret is drawn under.
420    ///
421    /// Asserted as a *relation* rather than as another literal snapshot: the
422    /// column is read back out of the header and used to index the source line,
423    /// so the property survives a reworded header, and the caret's own offset is
424    /// checked against it. The snapshots above pin the rendering; this pins what
425    /// the number means.
426    #[test]
427    fn a_header_column_is_one_based_and_lands_on_the_caret() {
428        for (src, start, len, expect_char) in [
429            ("abc\n", 0u32, 1u32, 'a'),
430            ("abc\n", 2, 1, 'c'),
431            ("total += line\n", 9, 4, 'l'),
432            // A multi-byte character earlier on the line: the column counts
433            // characters, so the `+ 1` must not be applied to bytes.
434            ("var y = λλ + name\n", 15, 4, 'n'),
435        ] {
436            let out = render(src, start, start + len, CaretLabel::Plain);
437            let header = out.lines().find(|l| l.contains("f.px:")).expect("header");
438            let column: usize = header
439                .rsplit(':')
440                .next()
441                .expect("a column")
442                .trim()
443                .parse()
444                .expect("the column is a number");
445            assert!(
446                column >= 1,
447                "a column is 1-based, got {column} in {header:?}"
448            );
449            let line: Vec<char> = src.lines().next().expect("a line").chars().collect();
450            assert_eq!(
451                line[column - 1],
452                expect_char,
453                "column {column} of {src:?} should be {expect_char:?}"
454            );
455            // And the caret line skips exactly `column - 1` characters after the
456            // `    | ` gutter, so header and caret name the same character.
457            let caret_line = out.lines().find(|l| l.contains('^')).expect("a caret");
458            let carets_at = caret_line.find('^').expect("a caret") - "    | ".len();
459            assert_eq!(
460                carets_at,
461                column - 1,
462                "the caret in {caret_line:?} disagrees with the header {header:?}"
463            );
464        }
465    }
466}