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}