use std::fmt::Write;
use crate::file::SourceFile;
use crate::line_map::{LineCol, LineMap};
use crate::span::{BytePos, FileSpan};
use crate::style::{Palette, Severity as StyleSeverity, Style};
pub const MAX_SNIPPET_LINES: usize = 5;
const HEAD_LINES: u32 = 3;
#[derive(Clone, Copy, Default)]
pub enum CaretLabel<'a> {
#[default]
Plain,
Labelled(&'a str),
}
impl<'a> CaretLabel<'a> {
fn text(self) -> Option<&'a str> {
match self {
CaretLabel::Plain => None,
CaretLabel::Labelled(s) => Some(s),
}
}
}
pub fn render_span_snippet(
file: &SourceFile,
span: FileSpan,
label: CaretLabel<'_>,
out: &mut String,
) {
render_span_snippet_with_limits(file, span, label, out, MAX_SNIPPET_LINES);
}
pub fn render_span_snippet_with_limits(
file: &SourceFile,
span: FileSpan,
label: CaretLabel<'_>,
out: &mut String,
max_lines: usize,
) {
render_span_snippet_styled(file, span, label, out, max_lines, &Palette::plain(), None);
}
pub fn render_span_snippet_styled(
file: &SourceFile,
span: FileSpan,
label: CaretLabel<'_>,
out: &mut String,
max_lines: usize,
palette: &Palette,
sev: Option<StyleSeverity>,
) {
let line_map = file.line_map();
let text = file.text();
let start = span.span.start();
let end = span.span.end();
let LineCol {
line: start_line,
col: byte_col,
} = line_map.offset_to_linecol(start);
let col = char_width(text, BytePos(start.to_u32() - byte_col), start) + 1;
out.push('\n');
let loc = palette.paint(
Style::Location,
&format!(" {}:{}:{}", file.path().display(), start_line, col),
);
let _ = writeln!(out, "{loc}");
let text_len = text.len() as u32;
let last_content = end.to_u32().min(text_len).saturating_sub(1);
let end_line = if end > start && last_content >= start.to_u32() {
line_map.offset_to_linecol(BytePos(last_content)).line
} else {
start_line
};
let span_lines = end_line.saturating_sub(start_line) + 1;
let ellide = span_lines as usize > max_lines;
let last_line = end_line;
let mut first_underline_done = false;
let mut line = start_line;
while line <= last_line {
if ellide && line > start_line + HEAD_LINES - 1 && line < last_line {
let _ = writeln!(out, " ...");
line = last_line;
continue;
}
let (line_text, line_start, content_end) = line_text(text, line_map, line);
let gutter = palette.paint(Style::Location, &format!(" {line} | "));
let _ = writeln!(out, "{gutter}{line_text}");
render_caret_line(
out,
text,
line,
last_line,
start,
end,
line_start,
content_end,
line == start_line,
&mut first_underline_done,
label,
palette,
sev,
);
line += 1;
}
}
#[allow(clippy::too_many_arguments)]
fn render_caret_line(
out: &mut String,
text: &str,
line: u32,
last_line: u32,
start: BytePos,
end: BytePos,
line_start: BytePos,
content_end: BytePos,
is_start_line: bool,
first_underline_done: &mut bool,
label: CaretLabel<'_>,
palette: &Palette,
sev: Option<StyleSeverity>,
) {
let multi = last_line != line || (end > content_end && is_start_line);
let seg_start = start.max(line_start);
let seg_end = if end == start {
seg_start
} else {
end.min(content_end).max(seg_start)
};
if seg_end < seg_start && !(end == start && is_start_line) {
return;
}
let gutter_width = last_line.to_string().len();
let pad: String = " ".repeat(gutter_width);
let gutter = palette.paint(Style::Location, &format!(" {pad} | "));
let _ = write!(out, "{gutter}");
let caret_col = char_width(text, line_start, seg_start);
for _ in 0..caret_col {
out.push(' ');
}
let count = if end == start {
1
} else {
char_width(text, seg_start, seg_end).max(1)
};
let carets = "^".repeat(count);
let carets = match sev {
Some(s) => palette.paint(Style::Caret(s), &carets),
None => carets,
};
let _ = write!(out, "{carets}");
if !*first_underline_done {
if let Some(msg) = label.text() {
let _ = write!(out, " {msg}");
}
*first_underline_done = true;
}
if multi {
let _ = write!(out, "...");
}
out.push('\n');
}
fn char_width(text: &str, from: BytePos, to: BytePos) -> usize {
let lo = from.to_u32() as usize;
let hi = (to.to_u32() as usize).max(lo);
text.get(lo..hi)
.map_or_else(|| hi - lo, |slice| slice.chars().count())
}
fn line_text<'a>(text: &'a str, line_map: &LineMap, line: u32) -> (&'a str, BytePos, BytePos) {
let (line_start, line_end) = line_map
.line_range(line)
.unwrap_or((BytePos::ZERO, BytePos::ZERO));
let bytes = text.as_bytes();
let s = line_start.to_usize();
let e = (line_end.to_u32() as usize).min(bytes.len());
let line_text = std::str::from_utf8(&bytes[s..e])
.unwrap_or("<invalid utf-8>")
.trim_end_matches(['\n', '\r']);
let content_end = LineMap::trim_line_terminator(bytes, line_start, line_end);
(line_text, line_start, content_end)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file::SourceMap;
use crate::span::Span;
fn render(file_text: &str, start: u32, end: u32, label: CaretLabel<'_>) -> String {
let map = SourceMap::new();
let id = map.intern("f.px", file_text);
let file = map.get(id).unwrap();
let span = FileSpan::new(id, Span::new(start, end));
let mut out = String::new();
render_span_snippet(&file, span, label, &mut out);
out
}
#[test]
fn single_line_span_with_label() {
let out = render(
"total += line\n",
9,
13,
CaretLabel::Labelled("this value is Text"),
);
insta::assert_snapshot!(out, @r#"
f.px:1:10
1 | total += line
| ^^^^ this value is Text
"#);
}
#[test]
fn single_line_span_plain() {
let out = render("ab\ncd\n", 0, 1, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:1
1 | ab
| ^
"#);
}
#[test]
fn multi_line_span_underlines_each_line() {
let src = "fn main() -> Int {\n out(\"x\")\n}\n";
let end = src.len() as u32;
let out = render(src, 0, end, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:1
1 | fn main() -> Int {
| ^^^^^^^^^^^^^^^^^^...
2 | out("x")
| ^^^^^^^^^^^^...
3 | }
| ^
"#);
}
#[test]
fn huge_span_collapses_to_head_plus_last() {
let src = "a\nb\nc\nd\ne\nf\ng\nh\n";
let end = src.len() as u32;
let out = render(src, 0, end, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:1
1 | a
| ^...
2 | b
| ^...
3 | c
| ^...
...
8 | h
| ^
"#);
}
#[test]
fn caret_never_overflows_visible_line() {
let src = "short\nnext line\n";
let out = render(src, 2, 99, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:3
1 | short
| ^^^...
2 | next line
| ^^^^^^^^^
"#);
}
#[test]
fn a_caret_counts_characters_and_not_bytes() {
let src = "var y = λλ + name\n";
let start = src.find("name").expect("the needle") as u32;
assert_eq!(start, 15, "the byte offset is what a span carries");
let out = render(src, start, start + 4, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:14
1 | var y = λλ + name
| ^^^^
"#);
let out = render(src, 8, 12, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:9
1 | var y = λλ + name
| ^^
"#);
}
#[test]
fn empty_span_draws_single_caret() {
let out = render("abc\n", 1, 1, CaretLabel::Plain);
insta::assert_snapshot!(out, @r#"
f.px:1:2
1 | abc
| ^
"#);
}
#[test]
fn a_header_column_is_one_based_and_lands_on_the_caret() {
for (src, start, len, expect_char) in [
("abc\n", 0u32, 1u32, 'a'),
("abc\n", 2, 1, 'c'),
("total += line\n", 9, 4, 'l'),
("var y = λλ + name\n", 15, 4, 'n'),
] {
let out = render(src, start, start + len, CaretLabel::Plain);
let header = out.lines().find(|l| l.contains("f.px:")).expect("header");
let column: usize = header
.rsplit(':')
.next()
.expect("a column")
.trim()
.parse()
.expect("the column is a number");
assert!(
column >= 1,
"a column is 1-based, got {column} in {header:?}"
);
let line: Vec<char> = src.lines().next().expect("a line").chars().collect();
assert_eq!(
line[column - 1],
expect_char,
"column {column} of {src:?} should be {expect_char:?}"
);
let caret_line = out.lines().find(|l| l.contains('^')).expect("a caret");
let carets_at = caret_line.find('^').expect("a caret") - " | ".len();
assert_eq!(
carets_at,
column - 1,
"the caret in {caret_line:?} disagrees with the header {header:?}"
);
}
}
}