#[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 secondary(self) -> char {
match self {
GlyphSet::Unicode => '\u{2501}',
GlyphSet::Ascii => '=',
}
}
const fn arm_start(self) -> &'static str {
match self {
GlyphSet::Unicode => "\u{250c}",
GlyphSet::Ascii => "/",
}
}
const fn arm_end(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
}
#[must_use]
pub fn trim_empty_span_to_content(source: &str, span: Span) -> Span {
if span.length > 0 {
return span;
}
let lines = split_lines(source);
let base = source.as_ptr() as usize;
let idx = span.line.saturating_sub(1).min(lines.len().saturating_sub(1));
let line = lines[idx];
let col0 = span.col.saturating_sub(1);
let line_start = line.as_ptr() as usize - base;
let offset = line_start + line.char_indices().nth(col0).map_or(line.len(), |(b, _)| b);
if offset > source.len() || !source[offset..].trim().is_empty() {
return span;
}
let Some((i, l)) = lines.iter().enumerate().rev().find(|(_, l)| !l.trim().is_empty()) else {
return span;
};
Span {
line: i + 1,
col: l.chars().count() + 1,
length: 0,
}
}
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(source, &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
}
pub struct Secondary<'a> {
pub url: &'a str,
pub source: &'a str,
pub span: Span,
pub label: &'a str,
}
pub fn render_labelled_snippet(
url: &str,
source: &str,
span: Span,
label: &str,
secondaries: &[Secondary<'_>],
frames: &[Frame<'_>],
glyphs: GlyphSet,
) -> String {
let mut groups: Vec<(&str, &str, Vec<Entry<'_>>)> = vec![(
url,
source,
vec![Entry {
span,
label,
primary: true,
}],
)];
for sec in secondaries {
let entry = Entry {
span: sec.span,
label: sec.label,
primary: false,
};
match groups
.iter_mut()
.find(|(u, s, _)| *u == sec.url && *s == sec.source)
{
Some((_, _, entries)) => entries.push(entry),
None => groups.push((sec.url, sec.source, vec![entry])),
}
}
let multi_file = groups.len() > 1;
let arm = groups
.iter()
.any(|(_, s, entries)| entries.iter().any(|e| span_crosses_lines(s, e.span)));
let mut out = String::new();
for (i, (group_url, group_source, entries)) in groups.iter_mut().enumerate() {
if i > 0 {
out.push('\n');
}
entries.sort_by_key(|e| (e.span.line, !e.primary));
render_group(
&mut out,
multi_file.then_some(*group_url),
group_source,
entries,
arm,
glyphs,
);
}
if !frames.is_empty() {
out.push('\n');
out.push_str(&render_frames(frames));
}
out
}
struct Entry<'a> {
span: Span,
label: &'a str,
primary: bool,
}
struct Placed<'a> {
label: &'a str,
primary: bool,
start_idx: usize,
start_col0: usize,
end_idx: usize,
end_col0: usize,
starts_at_edge: bool,
}
impl Placed<'_> {
fn crosses_lines(&self) -> bool {
self.start_idx != self.end_idx
}
}
fn render_group(
out: &mut String,
url: Option<&str>,
source: &str,
entries: &[Entry<'_>],
arm: bool,
glyphs: GlyphSet,
) {
let lines = split_lines(source);
let v = glyphs.vertical();
let h = glyphs.horizontal();
let last_line = lines.len().saturating_sub(1);
let placed: Vec<Placed<'_>> = entries
.iter()
.map(|e| {
let start_idx = e.span.line.saturating_sub(1).min(last_line);
let start_col0 = e.span.col.saturating_sub(1);
let (end_idx, end_col0) = resolve_end(source, &lines, start_idx, start_col0, e.span.length);
let first = lines.get(start_idx).copied().unwrap_or("");
Placed {
label: e.label,
primary: e.primary,
start_idx,
start_col0,
end_idx,
end_col0,
starts_at_edge: first.chars().take(start_col0).all(char::is_whitespace),
}
})
.collect();
let mut line_nos: Vec<usize> = Vec::with_capacity(placed.len());
for p in &placed {
for li in p.start_idx..=p.end_idx {
if !line_nos.contains(&li) {
line_nos.push(li);
}
}
}
line_nos.sort_unstable();
let max_line_no = line_nos.last().copied().unwrap_or(0) + 1;
let elides = line_nos.windows(2).any(|w| w[1] > w[0] + 1);
let width = if elides {
digit_count(max_line_no).max(3)
} else {
digit_count(max_line_no)
};
out.push_str(&blank_gutter(width));
out.push_str(if url.is_some() {
glyphs.top_left()
} else {
glyphs.top()
});
if let Some(u) = url {
out.push_str(h);
out.push_str(h);
out.push('>');
out.push(' ');
out.push_str(u);
}
let mut prev: Option<usize> = None;
for &li in &line_nos {
if let Some(p) = prev {
if li > p + 1 {
out.push('\n');
out.push_str(&elision_gutter(width));
out.push_str(v);
}
}
prev = Some(li);
let armed = placed
.iter()
.find(|p| p.crosses_lines() && p.start_idx <= li && li <= p.end_idx);
let slot = match armed {
Some(a) if a.start_idx == li => {
if a.starts_at_edge {
glyphs.arm_start()
} else {
" "
}
}
Some(_) => v,
None => " ",
};
push_source_line(out, &lines, li, width, elides, arm.then_some(slot), glyphs);
for p in placed.iter().filter(|p| !p.crosses_lines() && p.start_idx == li) {
let line = lines.get(li).copied().unwrap_or("");
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
let row_slot = match armed {
Some(a) if a.start_idx == li && !a.starts_at_edge => " ",
Some(_) => v,
None => " ",
};
push_arm(out, arm.then_some(row_slot));
for _ in 0..display_width_of_prefix(line, p.start_col0) {
out.push(' ');
}
let mark = if p.primary { CARET } else { glyphs.secondary() };
for _ in 0..display_width_of_prefix_range(line, p.start_col0, p.end_col0) {
out.push(mark);
}
push_label(out, p.label);
}
let Some(a) = armed else { continue };
if a.start_idx == li && !a.starts_at_edge {
let first = lines.get(li).copied().unwrap_or("");
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
out.push_str(glyphs.top_left());
for _ in 0..display_width_of_prefix(first, a.start_col0) + 1 {
out.push_str(h);
}
out.push(CARET);
}
if a.end_idx == li {
let last = lines.get(li).copied().unwrap_or("");
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(v);
out.push(' ');
out.push_str(glyphs.bottom_left());
if last.chars().skip(a.end_col0).all(char::is_whitespace) {
for _ in 0..3 {
out.push_str(h);
}
} else {
for _ in 0..display_width_of_prefix(last, a.end_col0) {
out.push_str(h);
}
out.push(CARET);
}
push_label(out, a.label);
}
}
out.push('\n');
out.push_str(&blank_gutter(width));
out.push_str(glyphs.bottom());
}
fn push_source_line(
out: &mut String,
lines: &[&str],
idx: usize,
width: usize,
elides: bool,
slot: Option<&str>,
glyphs: GlyphSet,
) {
out.push('\n');
out.push_str(&aligned_gutter(idx + 1, width, elides));
out.push_str(glyphs.vertical());
out.push(' ');
push_arm(out, slot);
out.push_str(&expand_tabs(lines.get(idx).copied().unwrap_or("")));
}
fn push_arm(out: &mut String, slot: Option<&str>) {
if let Some(slot) = slot {
out.push_str(slot);
out.push(' ');
}
}
fn push_label(out: &mut String, label: &str) {
if !label.is_empty() {
out.push(' ');
out.push_str(label);
}
}
fn aligned_gutter(line_no: usize, width: usize, elides: bool) -> String {
if !elides {
return numbered_gutter(line_no, width);
}
let mut s = line_no.to_string();
while s.len() < width {
s.push(' ');
}
s.push(' ');
s
}
fn elision_gutter(width: usize) -> String {
let mut s = String::from("...");
while s.len() < width {
s.push(' ');
}
s.push(' ');
s
}
#[must_use]
pub fn span_line_range(source: &str, span: Span) -> (usize, usize) {
let lines = split_lines(source);
let start_idx = span.line.saturating_sub(1).min(lines.len().saturating_sub(1));
let (end_idx, _) = resolve_end(source, &lines, start_idx, span.col.saturating_sub(1), span.length);
(start_idx + 1, end_idx + 1)
}
pub fn span_crosses_lines(source: &str, span: Span) -> bool {
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, _) = resolve_end(source, &lines, start_idx, start_col0, span.length);
end_idx != start_idx
}
fn terminator_len(source: &str, lines: &[&str], idx: usize) -> usize {
let base = source.as_ptr() as usize;
match (lines.get(idx), lines.get(idx + 1)) {
(Some(cur), Some(next)) => {
let cur_end = cur.as_ptr() as usize - base + cur.len();
(next.as_ptr() as usize - base).saturating_sub(cur_end).max(1)
}
_ => 1,
}
}
fn resolve_end(
source: &str,
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(terminator_len(source, lines, idx));
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();
let first = lines.get(start_idx).copied().unwrap_or("");
let last = lines.get(end_idx).copied().unwrap_or("");
let start_at_edge = first.chars().take(start_col0).all(char::is_whitespace);
let end_at_edge = last.chars().skip(end_col0).all(char::is_whitespace);
out.push_str(&blank_gutter(width));
out.push_str(glyphs.top());
out.push('\n');
out.push_str(&numbered_gutter(start_idx + 1, width));
out.push_str(v);
out.push(' ');
if start_at_edge {
out.push_str(glyphs.arm_start());
} else {
out.push(' ');
}
out.push(' ');
out.push_str(&expand_tabs(first));
if !start_at_edge {
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(' ');
if li == end_idx && end_at_edge {
out.push_str(glyphs.arm_end());
} else {
out.push_str(v);
}
out.push(' ');
out.push_str(&expand_tabs(text));
}
if !end_at_edge {
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 {
#[test]
fn labelled_spans_match_dart() {
let ascii = GlyphSet::Ascii;
let src = "@mixin m($x) { a: $x; }\n.a { @include m; }\n";
let got = render_labelled_snippet(
"t.scss",
src,
Span {
line: 2,
col: 6,
length: 10,
},
"invocation",
&[Secondary {
url: "t.scss",
source: src,
span: Span {
line: 1,
col: 8,
length: 5,
},
label: "declaration",
}],
&[],
ascii,
);
assert_eq!(
got,
" ,\n\
1 | @mixin m($x) { a: $x; }\n\
\x20 | ===== declaration\n\
2 | .a { @include m; }\n\
\x20 | ^^^^^^^^^^ invocation\n\
\x20 '"
);
let mut far = String::from("@mixin m($x) { a: $x; }\n");
for i in 0..8 {
far.push_str(&format!("// {i}\n"));
}
far.push_str(".a { @include m; }\n");
let got = render_labelled_snippet(
"t.scss",
&far,
Span {
line: 10,
col: 6,
length: 10,
},
"invocation",
&[Secondary {
url: "t.scss",
source: &far,
span: Span {
line: 1,
col: 8,
length: 5,
},
label: "declaration",
}],
&[],
ascii,
);
assert_eq!(
got,
" ,\n\
1 | @mixin m($x) { a: $x; }\n\
\x20 | ===== declaration\n\
... |\n\
10 | .a { @include m; }\n\
\x20 | ^^^^^^^^^^ invocation\n\
\x20 '"
);
let src = "@use \"_a\" as *;\n@use \"_b\" as *;\n.x { @include m; }\n";
let secs: Vec<Secondary<'_>> = (1..=2)
.map(|line| Secondary {
url: "t.scss",
source: src,
span: Span {
line,
col: 1,
length: 14,
},
label: "includes mixin",
})
.collect();
let got = render_labelled_snippet(
"t.scss",
src,
Span {
line: 3,
col: 6,
length: 10,
},
"mixin use",
&secs,
&[],
ascii,
);
assert_eq!(
got,
" ,\n\
1 | @use \"_a\" as *;\n\
\x20 | ============== includes mixin\n\
2 | @use \"_b\" as *;\n\
\x20 | ============== includes mixin\n\
3 | .x { @include m; }\n\
\x20 | ^^^^^^^^^^ mixin use\n\
\x20 '"
);
let entry = ".a { b: red(#abc, 1); }\n";
let builtin = "@function red($color) {\n";
let got = render_labelled_snippet(
"t.scss",
entry,
Span {
line: 1,
col: 9,
length: 12,
},
"invocation",
&[Secondary {
url: "sass:color",
source: builtin,
span: Span {
line: 1,
col: 11,
length: 11,
},
label: "declaration",
}],
&[],
ascii,
);
assert_eq!(
got,
" ,--> t.scss\n\
1 | .a { b: red(#abc, 1); }\n\
\x20 | ^^^^^^^^^^^^ invocation\n\
\x20 '\n\
\x20 ,--> sass:color\n\
1 | @function red($color) {\n\
\x20 | =========== declaration\n\
\x20 '"
);
let src = ".a { b: get_((x: 1), x); }\n";
let got = render_labelled_snippet(
"t.scss",
src,
Span {
line: 1,
col: 14,
length: 6,
},
"value",
&[Secondary {
url: "t.scss",
source: src,
span: Span {
line: 1,
col: 9,
length: 15,
},
label: "unknown function treated as plain CSS",
}],
&[],
ascii,
);
assert_eq!(
got,
" ,\n\
1 | .a { b: get_((x: 1), x); }\n\
\x20 | ^^^^^^ value\n\
\x20 | =============== unknown function treated as plain CSS\n\
\x20 '"
);
}
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");
}
}
}