#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphSet {
Unicode,
Ascii,
}
impl GlyphSet {
const fn top(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{2577}",
GlyphSet::Ascii => ",",
}
}
const fn vertical(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{2502}",
GlyphSet::Ascii => "|",
}
}
const fn bottom(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{2575}",
GlyphSet::Ascii => "'",
}
}
const fn top_left(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{250c}",
GlyphSet::Ascii => ",",
}
}
const fn bottom_left(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{2514}",
GlyphSet::Ascii => "'",
}
}
const fn horizontal(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{2500}",
GlyphSet::Ascii => "-",
}
}
}
const TAB_WIDTH: usize = 4;
const CARET: char = '^';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub line: usize,
pub col: usize,
pub length: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame<'a> {
pub url: &'a str,
pub line: usize,
pub col: usize,
pub name: &'a str,
}
impl Frame<'_> {
fn render_inner(&self) -> String {
format!("{} {}:{} {}", self.url, self.line, self.col, self.name)
}
}
#[must_use]
pub fn render_frames(frames: &[Frame<'_>]) -> String {
let mut out = String::new();
for (i, f) in frames.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(" ");
out.push_str(&f.render_inner());
}
out
}
fn split_lines(source: &str) -> Vec<&str> {
let mut lines = Vec::new();
let bytes = source.as_bytes();
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\n' => {
lines.push(&source[start..i]);
i += 1;
start = i;
}
b'\r' => {
lines.push(&source[start..i]);
i += 1;
if i < bytes.len() && bytes[i] == b'\n' {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
lines.push(&source[start..]);
lines
}
fn expand_tabs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch == '\t' {
for _ in 0..TAB_WIDTH {
out.push(' ');
}
} else {
out.push(ch);
}
}
out
}
fn display_width_of_prefix(line: &str, cols: usize) -> usize {
let mut width = 0usize;
for ch in line.chars().take(cols) {
width += if ch == '\t' { TAB_WIDTH } else { 1 };
}
width
}
fn digit_count(n: usize) -> usize {
let mut n = n;
let mut digits = 1;
while n >= 10 {
n /= 10;
digits += 1;
}
digits
}
fn blank_gutter(width: usize) -> String {
let mut s = String::with_capacity(width + 1);
for _ in 0..width + 1 {
s.push(' ');
}
s
}
fn numbered_gutter(line_no: usize, width: usize) -> String {
let digits = digit_count(line_no);
let mut s = String::with_capacity(width + 1);
for _ in 0..width.saturating_sub(digits) {
s.push(' ');
}
push_usize(&mut s, line_no);
s.push(' ');
s
}
fn push_usize(out: &mut String, n: usize) {
if n >= 10 {
push_usize(out, n / 10);
}
let digit = (n % 10) as u8 + b'0';
out.push(digit as char);
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn render_interp_error_snippet(
source: &str,
line: usize,
col_start: usize,
col_end: usize,
resolved: &str,
resolved_col: usize,
url: &str,
glyphs: GlyphSet,
) -> String {
let lines = split_lines(source);
let src_line = lines.get(line.saturating_sub(1)).copied().unwrap_or("");
let width = digit_count(line);
let pad = blank_gutter(width);
let marker = match glyphs {
GlyphSet::Unicode => "\u{2501}",
GlyphSet::Ascii => "=",
};
let arrow = match glyphs {
GlyphSet::Unicode => "\u{250c}\u{2500}\u{2500}>",
GlyphSet::Ascii => ",-->",
};
let mut out = String::new();
out.push_str(&pad);
out.push_str(arrow);
out.push(' ');
out.push_str(url);
out.push('\n');
out.push_str(&format!("{line:>width$} {} {src_line}\n", glyphs.vertical()));
out.push_str(&pad);
out.push_str(glyphs.vertical());
out.push(' ');
for _ in 1..col_start {
out.push(' ');
}
for _ in col_start..col_end {
out.push('^');
}
out.push_str(" \n");
out.push_str(&pad);
out.push_str(glyphs.bottom());
out.push('\n');
out.push_str(&pad);
out.push_str(glyphs.top());
out.push('\n');
out.push_str(&format!("{:>width$} {} {resolved}\n", 1, glyphs.vertical()));
out.push_str(&pad);
out.push_str(glyphs.vertical());
out.push(' ');
for _ in 1..resolved_col {
out.push(' ');
}
out.push_str(marker);
out.push_str(" error in interpolated output\n");
out.push_str(&pad);
out.push_str(glyphs.bottom());
out
}
pub fn render_snippet(source: &str, span: Span, frames: &[Frame<'_>], glyphs: GlyphSet) -> String {
let lines = split_lines(source);
let start_idx = span.line.saturating_sub(1).min(lines.len().saturating_sub(1));
let start_col0 = span.col.saturating_sub(1);
let (end_idx, end_col0) = resolve_end(&lines, start_idx, start_col0, span.length);
let max_line_no = end_idx + 1;
let width = digit_count(max_line_no);
let mut out = String::new();
if start_idx == end_idx {
render_single_line(&mut out, &lines, start_idx, start_col0, end_col0, width, glyphs);
} else {
render_multi_line(
&mut out, &lines, start_idx, start_col0, end_idx, end_col0, width, glyphs,
);
}
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(glyphs.bottom());
if !frames.is_empty() {
out.push('\n');
out.push_str(&render_frames(frames));
}
out
}
fn resolve_end(lines: &[&str], start_idx: usize, start_col0: usize, length: usize) -> (usize, usize) {
let mut idx = start_idx;
let mut col = start_col0;
let mut remaining = length;
loop {
let line = lines.get(idx).copied().unwrap_or("");
let mut consumed_cols = 0usize;
for ch in line.chars().skip(col) {
let blen = ch.len_utf8();
if remaining < blen {
return (idx, col + consumed_cols);
}
remaining -= blen;
consumed_cols += 1;
}
if remaining == 0 || idx + 1 >= lines.len() {
return (idx, col + consumed_cols);
}
remaining = remaining.saturating_sub(1);
idx += 1;
col = 0;
if remaining == 0 {
return (idx, 0);
}
}
}
fn render_single_line(
out: &mut String,
lines: &[&str],
idx: usize,
start_col0: usize,
end_col0: usize,
width: usize,
glyphs: GlyphSet,
) {
let line = lines.get(idx).copied().unwrap_or("");
let v = glyphs.vertical();
out.push_str(&blank_gutter(width));
out.push_str(glyphs.top());
out.push('\n');
out.push_str(&numbered_gutter(idx + 1, width));
out.push_str(v);
out.push(' ');
out.push_str(&expand_tabs(line));
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
let pad = display_width_of_prefix(line, start_col0);
for _ in 0..pad {
out.push(' ');
}
let caret_w = display_width_of_prefix_range(line, start_col0, end_col0);
for _ in 0..caret_w {
out.push(CARET);
}
}
fn display_width_of_prefix_range(line: &str, from: usize, to: usize) -> usize {
if to <= from {
return 1;
}
let mut width = 0usize;
for ch in line.chars().skip(from).take(to - from) {
width += if ch == '\t' { TAB_WIDTH } else { 1 };
}
width.max(1)
}
#[allow(clippy::too_many_arguments)]
fn render_multi_line(
out: &mut String,
lines: &[&str],
start_idx: usize,
start_col0: usize,
end_idx: usize,
end_col0: usize,
width: usize,
glyphs: GlyphSet,
) {
let v = glyphs.vertical();
let h = glyphs.horizontal();
out.push_str(&blank_gutter(width));
out.push_str(glyphs.top());
let first = lines.get(start_idx).copied().unwrap_or("");
out.push('\n');
out.push_str(&numbered_gutter(start_idx + 1, width));
out.push_str(v);
out.push(' ');
out.push(' '); out.push(' ');
out.push_str(&expand_tabs(first));
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
out.push_str(glyphs.top_left());
let lead = display_width_of_prefix(first, start_col0) + 1;
for _ in 0..lead {
out.push_str(h);
}
out.push(CARET);
for li in (start_idx + 1)..=end_idx {
let text = lines.get(li).copied().unwrap_or("");
out.push('\n');
out.push_str(&numbered_gutter(li + 1, width));
out.push_str(v);
out.push(' ');
out.push_str(v);
out.push(' ');
out.push_str(&expand_tabs(text));
}
let last = lines.get(end_idx).copied().unwrap_or("");
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
out.push_str(glyphs.bottom_left());
let tail = display_width_of_prefix(last, end_col0);
for _ in 0..tail {
out.push_str(h);
}
out.push(CARET);
}
#[must_use]
pub fn render_error(message: &str, source: &str, url: &str, span: Span, glyphs: GlyphSet) -> String {
let frame = Frame {
url,
line: span.line,
col: span.col,
name: "root stylesheet",
};
let mut out = format!("Error: {message}\n");
out.push_str(&render_snippet(source, span, &[frame], glyphs));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_lines_handles_all_terminators() {
assert_eq!(split_lines("a\nb\r\nc\rd"), vec!["a", "b", "c", "d"]);
assert_eq!(split_lines(""), vec![""]);
assert_eq!(split_lines("a\n"), vec!["a", ""]);
}
#[test]
fn tabs_expand_to_four_spaces_everywhere() {
assert_eq!(expand_tabs("\tb"), " b");
assert_eq!(expand_tabs("a\tb"), "a b");
assert_eq!(expand_tabs("\t\tb"), " b");
assert_eq!(expand_tabs(" \tb"), " b");
}
#[test]
fn digit_count_basic() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(123), 3);
}
#[test]
fn undefined_variable_unicode() {
let src = "a {\n b: $undefined;\n}\n";
let span = Span {
line: 2,
col: 6,
length: "$undefined".len(),
};
let got = render_error(
"Undefined variable.",
src,
"/tmp/input.scss",
span,
GlyphSet::Unicode,
);
let expected = "\
Error: Undefined variable.
\u{2577}
2 \u{2502} b: $undefined;
\u{2502} ^^^^^^^^^^
\u{2575}
/tmp/input.scss 2:6 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn undefined_variable_ascii() {
let src = "a {\n b: $undefined;\n}\n";
let span = Span {
line: 2,
col: 6,
length: "$undefined".len(),
};
let got = render_error(
"Undefined variable.",
src,
"/tmp/input.scss",
span,
GlyphSet::Ascii,
);
let expected = "\
Error: Undefined variable.
,
2 | b: $undefined;
| ^^^^^^^^^^
'
/tmp/input.scss 2:6 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn at_error_span_at_line_one() {
let src = "@error \"boom #{1 + 1}\";\n";
let span = Span {
line: 1,
col: 1,
length: "@error \"boom #{1 + 1}\"".len(),
};
let got = render_error("\"boom 2\"", src, "/tmp/input.scss", span, GlyphSet::Unicode);
let expected = "\
Error: \"boom 2\"
\u{2577}
1 \u{2502} @error \"boom #{1 + 1}\";
\u{2502} ^^^^^^^^^^^^^^^^^^^^^^
\u{2575}
/tmp/input.scss 1:1 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn wide_gutter_double_digit_line() {
let mut src = String::new();
for _ in 0..11 {
src.push('\n');
}
src.push_str("a { b: $x; }\n");
let span = Span {
line: 12,
col: 8,
length: "$x".len(),
};
let got = render_error(
"Undefined variable.",
&src,
"/tmp/input.scss",
span,
GlyphSet::Unicode,
);
let expected = "\
Error: Undefined variable.
\u{2577}
12 \u{2502} a { b: $x; }
\u{2502} ^^
\u{2575}
/tmp/input.scss 12:8 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn tab_indent_expands_in_source_and_caret() {
let src = "a {\n\tb: $x;\n}\n";
let span = Span {
line: 2,
col: 5,
length: "$x".len(),
};
let got = render_error(
"Undefined variable.",
src,
"/tmp/input.scss",
span,
GlyphSet::Unicode,
);
let expected = "\
Error: Undefined variable.
\u{2577}
2 \u{2502} b: $x;
\u{2502} ^^
\u{2575}
/tmp/input.scss 2:5 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn multi_line_span_unicode_arms() {
let src = "a{b: (1px +\n2s)}\n";
let start_byte = byte_index(src, 1, 7);
let end_byte = byte_index(src, 2, 3); let span = Span {
line: 1,
col: 7,
length: end_byte - start_byte,
};
let frame = Frame {
url: "/tmp/input.scss",
line: 1,
col: 7,
name: "root stylesheet",
};
let got = render_snippet(src, span, &[frame], GlyphSet::Unicode);
let expected = concat!(
" \u{2577}\n",
"1 \u{2502} a{b: (1px +\n",
" \u{2502} \u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}^\n",
"2 \u{2502} \u{2502} 2s)}\n",
" \u{2502} \u{2514}\u{2500}\u{2500}^\n",
" \u{2575}\n",
" /tmp/input.scss 1:7 root stylesheet",
);
assert_eq!(got, expected);
}
#[test]
fn multi_line_span_ascii_arms() {
let src = "a{b: (1px +\n2s)}\n";
let start_byte = byte_index(src, 1, 7);
let end_byte = byte_index(src, 2, 3);
let span = Span {
line: 1,
col: 7,
length: end_byte - start_byte,
};
let frame = Frame {
url: "/tmp/input.scss",
line: 1,
col: 7,
name: "root stylesheet",
};
let got = render_snippet(src, span, &[frame], GlyphSet::Ascii);
let expected = concat!(
" ,\n",
"1 | a{b: (1px +\n",
" | ,-------^\n",
"2 | | 2s)}\n",
" | '--^\n",
" '\n",
" /tmp/input.scss 1:7 root stylesheet",
);
assert_eq!(got, expected);
}
#[test]
fn zero_length_span_one_caret() {
let src = "a {\n b: 1 +\n";
let span = Span {
line: 2,
col: 9,
length: 0,
};
let got = render_error(
"Expected expression.",
src,
"/tmp/input.scss",
span,
GlyphSet::Unicode,
);
let expected = "\
Error: Expected expression.
\u{2577}
2 \u{2502} b: 1 +
\u{2502} ^
\u{2575}
/tmp/input.scss 2:9 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn frames_stack_outermost_root() {
let frames = [
Frame {
url: "/tmp/input.scss",
line: 2,
col: 7,
name: "f()",
},
Frame {
url: "/tmp/input.scss",
line: 2,
col: 7,
name: "root stylesheet",
},
];
let got = render_frames(&frames);
let expected = " /tmp/input.scss 2:7 f()\n /tmp/input.scss 2:7 root stylesheet";
assert_eq!(got, expected);
}
#[test]
fn out_of_range_does_not_panic() {
let src = "a {}\n";
let span = Span {
line: 99,
col: 99,
length: 99,
};
let got = render_error("x", src, "-", span, GlyphSet::Unicode);
assert!(got.starts_with("Error: x"));
}
fn byte_index(src: &str, line: usize, col: usize) -> usize {
let mut cur_line = 1usize;
let mut cur_col = 1usize;
for (i, ch) in src.char_indices() {
if cur_line == line && cur_col == col {
return i;
}
if ch == '\n' {
cur_line += 1;
cur_col = 1;
} else {
cur_col += 1;
}
}
src.len()
}
#[test]
fn live_dart_parity() {
if std::env::var("SASSO_DIAG_LIVE").as_deref() != Ok("1") {
return;
}
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "sass".to_string());
let ml_src = "a{b: (1px +\n2s)}\n";
let ml_len = byte_index(ml_src, 2, 3) - byte_index(ml_src, 1, 7);
let cases: &[(&str, &str, Span, GlyphSet, bool)] = &[
(
"a {\n b: $undefined;\n}\n",
"Undefined variable.",
Span {
line: 2,
col: 6,
length: 10,
},
GlyphSet::Unicode,
false,
),
(
"a {\n b: $undefined;\n}\n",
"Undefined variable.",
Span {
line: 2,
col: 6,
length: 10,
},
GlyphSet::Ascii,
true,
),
(
"@error \"x\";\n",
"\"x\"",
Span {
line: 1,
col: 1,
length: 10,
},
GlyphSet::Unicode,
false,
),
(
"a {\n\tb: $x;\n}\n",
"Undefined variable.",
Span {
line: 2,
col: 5,
length: 2,
},
GlyphSet::Unicode,
false,
),
(
ml_src,
"1px and 2s have incompatible units.",
Span {
line: 1,
col: 7,
length: ml_len,
},
GlyphSet::Unicode,
false,
),
(
ml_src,
"1px and 2s have incompatible units.",
Span {
line: 1,
col: 7,
length: ml_len,
},
GlyphSet::Ascii,
true,
),
];
for (i, (src, msg, span, glyphs, no_unicode)) in cases.iter().enumerate() {
let dir = std::env::temp_dir().join(format!("sasso-diag-{}-{}", std::process::id(), i));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("input.scss");
std::fs::write(&path, src).expect("write fixture");
let mut cmd = std::process::Command::new(&bin);
cmd.arg(&path).arg("--no-color");
if *no_unicode {
cmd.arg("--no-unicode");
}
let output = match cmd.output() {
Ok(o) => o,
Err(_) => return, };
let stderr = String::from_utf8_lossy(&output.stderr);
let path_str = path.to_string_lossy().to_string();
let ours = render_error(msg, src, &path_str, *span, *glyphs);
let dart = stderr.trim_end_matches('\n');
assert_eq!(ours, dart, "\n--- ours ---\n{ours}\n--- dart ---\n{dart}\n");
}
}
}