use std::path::{Path, PathBuf};
use undercoat::{Options, compile_file};
type Seg = (u32, Option<(u32, u32, u32)>);
fn decode(mappings: &str) -> Vec<Vec<Seg>> {
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let (mut file, mut line, mut col) = (0i64, 0i64, 0i64);
mappings
.split(';')
.map(|row| {
let mut gen_col = 0i64;
let mut segs = Vec::new();
for seg in row.split(',').filter(|s| !s.is_empty()) {
let mut values = Vec::new();
let (mut shift, mut value) = (0u32, 0i64);
for byte in seg.bytes() {
let digit = B64.iter().position(|&b| b == byte).expect("vlq character") as i64;
value |= (digit & 0x1f) << shift;
shift += 5;
if digit & 0x20 == 0 {
let neg = value & 1 != 0;
value >>= 1;
values.push(if neg { -value } else { value });
(shift, value) = (0, 0);
}
}
gen_col += values[0];
if values.len() >= 4 {
file += values[1];
line += values[2];
col += values[3];
segs.push((gen_col as u32, Some((file as u32, line as u32, col as u32))));
} else {
segs.push((gen_col as u32, None));
}
}
segs
})
.collect()
}
fn map_of(compiled: &str) -> serde_json::Value {
serde_json::from_str(compiled).expect("parse source map")
}
fn first_source(segs: &[Seg]) -> Option<(u32, u32, u32)> {
segs.iter().find_map(|(_, src)| *src)
}
fn utf16_len(text: &str) -> usize {
text.chars().map(char::len_utf16).sum()
}
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures-scss-map")
.join(name)
}
fn compile_with_map(dir: &Path) -> (String, serde_json::Value) {
let entry = dir.join("main.scss");
let source = std::fs::read_to_string(&entry).expect("read fixture");
let out = compile_file(
&entry,
&source,
&Options {
source_map: true,
project_root: Some(dir.to_string_lossy().into_owned()),
..Default::default()
},
)
.expect("compile fixture");
(out.css, map_of(&out.map.expect("source map")))
}
#[test]
fn anchors_cover_every_dart_sass_mapped_line() {
let dir = fixture("anchors");
let (css, map) = compile_with_map(&dir);
let expected_css = std::fs::read_to_string(dir.join("main.css")).expect("main.css");
assert_eq!(
css,
expected_css,
"changing the per-line anchors must not change the CSS text ({})",
dir.display()
);
let expected =
map_of(&std::fs::read_to_string(dir.join("main.map.json")).expect("main.map.json"));
let ours = decode(map["mappings"].as_str().expect("mappings"));
let reference = decode(expected["mappings"].as_str().expect("mappings"));
let lines: Vec<&str> = css.split('\n').collect();
assert!(
ours.len() <= lines.len(),
"the number of mapping lines must not exceed the number of output lines"
);
let mut checked = 0;
for (i, ref_segs) in reference.iter().enumerate() {
let Some((ref_line, ref_col)) = first_source(ref_segs).map(|(_, l, c)| (l, c)) else {
continue;
};
let ours_first = ours
.get(i)
.and_then(|segs| first_source(segs))
.unwrap_or_else(|| panic!("L{} is missing a mapping: {:?}", i + 1, lines.get(i)));
assert_eq!(
(ours_first.1, ours_first.2),
(ref_line, ref_col),
"anchor of L{} differs from dart-sass: {:?}",
i + 1,
lines[i]
);
checked += 1;
}
assert!(
checked >= 25,
"too few generated lines covered by the case ({checked}): multi-line selector lists / interpolation / comments / keyframes / extend should all be covered"
);
}
#[test]
fn map_is_structurally_valid() {
let dir = fixture("anchors");
let (css, map) = compile_with_map(&dir);
let sources: Vec<&str> = map["sources"]
.as_array()
.expect("sources")
.iter()
.map(|s| s.as_str().expect("source"))
.collect();
assert_eq!(
sources,
["main.scss"],
"sources should be relative to project_root"
);
let contents: Vec<&str> = map["sourcesContent"]
.as_array()
.expect("sourcesContent")
.iter()
.map(|c| c.as_str().expect("content"))
.collect();
assert_eq!(
contents.len(),
sources.len(),
"sourcesContent must be complete"
);
for (source, content) in sources.iter().zip(&contents) {
assert!(
content.contains("$name: accent;"),
"source text of {source} is incomplete"
);
}
let lines: Vec<&str> = css.split('\n').collect();
let source_lines: Vec<Vec<&str>> = contents.iter().map(|c| c.split('\n').collect()).collect();
let mappings = decode(map["mappings"].as_str().expect("mappings"));
assert!(
mappings.len() <= lines.len(),
"the number of mapping lines must not exceed the number of output lines"
);
let mut total = 0;
for (i, segs) in mappings.iter().enumerate() {
let mut last_col = 0;
for (gen_col, src) in segs {
assert!(
*gen_col >= last_col,
"segment columns on L{} are not increasing",
i + 1
);
assert!(
*gen_col as usize <= utf16_len(lines[i]),
"generated column {} on L{} is out of range (line width {})",
i + 1,
gen_col,
utf16_len(lines[i])
);
last_col = *gen_col;
if let Some((file, line, col)) = src {
let file_lines = source_lines.get(*file as usize).unwrap_or_else(|| {
panic!("source file index {file} on L{} is out of range", i + 1)
});
let text = file_lines
.get(*line as usize)
.unwrap_or_else(|| panic!("source line {line} on L{} is out of range", i + 1));
assert!(
*col as usize <= utf16_len(text),
"source column {col} on L{} is out of range (source line width {})",
i + 1,
utf16_len(text)
);
}
total += 1;
}
}
assert!(total > 0, "the map must not be empty");
assert!(map["names"].as_array().expect("names").is_empty());
assert_eq!(map["version"], 3);
}
#[test]
fn selector_list_continuation_lines_are_mapped() {
let dir = temp_dir("sel-cont");
write(&dir, "main.scss", ".a,\n.b,\n.c > .d {\n color: red;\n}\n");
let (css, map) = compile_with_map(&dir);
assert_eq!(css, ".a,\n.b,\n.c > .d {\n color: red;\n}");
let mappings = decode(map["mappings"].as_str().expect("mappings"));
for (i, expected_col) in [(0usize, 0u32), (1, 0), (2, 0)] {
let first = first_source(&mappings[i])
.unwrap_or_else(|| panic!("selector line {} has no mapping", i + 1));
assert_eq!(
(first.1, first.2),
(0, expected_col),
"anchor of line {}",
i + 1
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn interpolated_selector_line_is_mapped() {
let dir = temp_dir("sel-interp");
write(
&dir,
"main.scss",
"$n: q;\n.#{$n}-a,\n.#{$n}-b { color: red; }\n",
);
let (css, map) = compile_with_map(&dir);
assert_eq!(css, ".q-a,\n.q-b {\n color: red;\n}");
let mappings = decode(map["mappings"].as_str().expect("mappings"));
for i in [0usize, 1] {
let (line, col) = first_source(&mappings[i])
.map(|(_, l, c)| (l, c))
.expect("mapping");
assert_eq!(
(line, col),
(1, 0),
"interpolated selector line {} should anchor to the line where the interpolation starts",
i + 1
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn comment_continuation_lines_are_mapped() {
let dir = temp_dir("comment-map");
write(
&dir,
"main.scss",
"/*!\n * one\n * two\n */\n.a {\n color: red;\n}\n",
);
let (css, map) = compile_with_map(&dir);
assert_eq!(css, "/*!\n * one\n * two\n */\n.a {\n color: red;\n}");
let mappings = decode(map["mappings"].as_str().expect("mappings"));
assert!(mappings.len() >= 4, "every comment line needs a segment");
for segs in mappings.iter().take(4) {
let (line, col) = first_source(segs)
.map(|(_, l, c)| (l, c))
.expect("comment line mapping");
assert_eq!(
(line, col),
(0, 0),
"comment continuation lines should anchor to the `/*` start"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn at_rule_maps_to_the_at_sign() {
let dir = temp_dir("at-map");
write(
&dir,
"main.scss",
".w {\n @media (min-width: 1px) {\n color: red;\n }\n}\n",
);
let (css, map) = compile_with_map(&dir);
assert_eq!(
css,
"@media (min-width: 1px) {\n .w {\n color: red;\n }\n}"
);
let mappings = decode(map["mappings"].as_str().expect("mappings"));
let (line, col) = first_source(&mappings[0])
.map(|(_, l, c)| (l, c))
.expect("mapping");
assert_eq!(
(line, col),
(1, 2),
"`@media` should anchor to the `@` on line 2 (column 2)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn keyframe_selector_list_stays_on_one_line() {
let dir = temp_dir("kf-map");
write(
&dir,
"main.scss",
"@keyframes k {\n 0%,\n 50% {\n opacity: 0;\n }\n}\n",
);
let (css, map) = compile_with_map(&dir);
assert_eq!(css, "@keyframes k {\n 0%, 50% {\n opacity: 0;\n }\n}");
let mappings = decode(map["mappings"].as_str().expect("mappings"));
let (line, col) = first_source(&mappings[1])
.map(|(_, l, c)| (l, c))
.expect("mapping");
assert_eq!(
(line, col),
(1, 2),
"keyframe selectors anchor to the start of the list"
);
assert!(
mappings.len() <= 5,
"the number of mapping lines must not exceed the number of output lines"
);
std::fs::remove_dir_all(&dir).ok();
}
fn temp_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"undercoat-map-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("mkdir temp");
dir
}
fn write(dir: &Path, name: &str, text: &str) {
std::fs::write(dir.join(name), text).expect("write fixture");
}