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 字符") 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,
"逐行锚点的改动不允许改变 CSS 文本({})",
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(), "mappings 行数不应超过产物行数");
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{} 缺映射: {:?}", i + 1, lines.get(i)));
assert_eq!(
(ours_first.1, ours_first.2),
(ref_line, ref_col),
"L{} 的锚点与 dart-sass 不一致: {:?}",
i + 1,
lines[i]
);
checked += 1;
}
assert!(
checked >= 25,
"用例覆盖的生成行太少({checked}):多行选择器列表 / 插值 / 注释 / keyframes / extend 都应覆盖到"
);
}
#[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 应是相对 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 必须完整");
for (source, content) in sources.iter().zip(&contents) {
assert!(content.contains("$name: accent;"), "{source} 原文不完整");
}
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(),
"mappings 行数不应超过产物行数"
);
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, "L{} 的 segment 列未递增", i + 1);
assert!(
*gen_col as usize <= utf16_len(lines[i]),
"L{} 生成列 {} 越界(行宽 {})",
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!("L{} 的源文件下标 {file} 越界", i + 1));
let text = file_lines
.get(*line as usize)
.unwrap_or_else(|| panic!("L{} 的源行 {line} 越界", i + 1));
assert!(
*col as usize <= utf16_len(text),
"L{} 的源列 {col} 越界(源行宽 {})",
i + 1,
utf16_len(text)
);
}
total += 1;
}
}
assert!(total > 0, "map 不能是空的");
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!("选择器第 {} 行没有映射", i + 1));
assert_eq!((first.1, first.2), (0, expected_col), "第 {} 行锚点", 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("映射");
assert_eq!(
(line, col),
(1, 0),
"插值选择器第 {} 行应锚到 `#{{` 所在行",
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, "注释的每一行都要有 segment");
for segs in mappings.iter().take(4) {
let (line, col) = first_source(segs)
.map(|(_, l, c)| (l, c))
.expect("注释行映射");
assert_eq!((line, col), (0, 0), "注释续行都应锚到 `/*` 起点");
}
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("映射");
assert_eq!((line, col), (1, 2), "`@media` 应锚到第 2 行的 `@`(列 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("映射");
assert_eq!((line, col), (1, 2), "keyframe 选择器锚到列表起点");
assert!(mappings.len() <= 5, "mappings 行数不应超过产物行数");
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");
}