undercoat 0.12.1

Native Rust compiler for Less and Sass/SCSS — byte-compatible with lessc and dart-sass, no Node runtime
Documentation
//! **Per-line anchor** regression for the SCSS sourcemap: expectations come from
//! dart-sass 1.35.2 (the version WDT uses).
//!
//! Only "coverage" is compared: every generated line dart covers must be covered by us as
//! well, with the same source line and column in the first segment. The whole JSON is
//! **not** compared: dart writes a single segment per declaration, while we additionally
//! record the start and end of property names and values, so segment density is an
//! implementation detail (pinning it down would turn implementation details into a spec).
//!
//! Expected files are generated by `scripts/style-diff/scss-map-oracle.cjs` (CI does not
//! depend on node).

use std::path::{Path, PathBuf};

use undercoat::{Options, compile_file};

/// One mapping: `(generated column, source file index, source line, source column)`; when
/// the quadruple is absent only the generated column is present.
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")
}

/// The first segment from `dart-sass` (the segment carrying source info with the smallest
/// `gen_col`).
fn first_source(segs: &[Seg]) -> Option<(u32, u32, u32)> {
    segs.iter().find_map(|(_, src)| *src)
}

/// UTF-16 code unit length (column semantics of sourcemap v3).
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();
    // Trailing generated lines that are entirely unmapped get no `;` (the sourcemap may
    // omit them), so the line count can only be smaller.
    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;
    }
    // The case itself must be big enough: multi-line selector lists / interpolated selectors /
    // comment continuation lines / keyframes / extend
    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"
    );

    // sourcesContent corresponds one-to-one with sources and is complete
    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);
}

/// Multi-line selector lists: every output line needs its own segment (dart gives all
/// lines the same source position).
#[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();
}

/// Interpolated selectors: the whole list anchors to the "list start" in the source
/// (i.e. the line containing `#{`).
#[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();
}

/// Every line of a block comment (including continuation lines of a `/*!` license block)
/// needs a segment.
#[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();
}

/// At-rules anchor to the column of `@` (`name_span` points at the name, and serialization
/// shifts one column left).
#[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();
}

/// A keyframe selector list is not broken across lines (dart joins with `, `), so there is
/// only one segment.
#[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");
}