//! Live parity tests against dart-sass.
//!
//! These are **opt-in**: they only run when `SASSO_PARITY=1` is set and
//! a dart-sass CLI is reachable (via `$SASS_BIN`, default `npx --yes sass`).
//! Otherwise each test returns early so a plain `cargo test` stays fast and
//! offline. CI sets the env var and installs dart-sass.
use std::io::Write as _;
use std::process::{Command, Stdio};
use sasso::{compile, FsImporter, Options};
fn enabled() -> bool {
std::env::var("SASSO_PARITY").map(|v| v != "0").unwrap_or(false)
}
/// Compile `scss` with dart-sass (expanded, via stdin), returning its CSS.
fn dart_sass(scss: &str) -> Option<String> {
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "npx".to_string());
let mut cmd = if bin == "npx" {
let mut c = Command::new("npx");
c.args(["--yes", "sass", "--no-source-map", "--stdin"]);
c
} else {
let mut c = Command::new(bin);
c.args(["--no-source-map", "--stdin"]);
c
};
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(scss.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
fn assert_parity(scss: &str) {
if !enabled() {
return;
}
let ours = compile(scss, &Options::default()).expect("our compile failed");
match dart_sass(scss) {
Some(theirs) => assert_eq!(ours, theirs, "\n--- scss ---\n{scss}\n"),
None => eprintln!("skipping parity case: dart-sass unavailable"),
}
}
/// Assert both sasso and dart-sass REJECT `scss` (error-spec parity).
fn assert_error_parity(scss: &str) {
if !enabled() {
return;
}
assert!(
compile(scss, &Options::default()).is_err(),
"\nexpected our compile to fail:\n--- scss ---\n{scss}\n"
);
assert!(
dart_sass(scss).is_none(),
"\nexpected dart-sass to fail too:\n--- scss ---\n{scss}\n"
);
}
#[test]
fn parity_variables_nesting() {
assert_parity("$c: #336699;\n.a {\n color: $c;\n .b { color: lighten($c, 10%); }\n &:hover { color: mix($c, white, 50%); }\n}\n");
}
#[test]
fn parity_colors() {
assert_parity("$brand: #2a7ae2;\n.x {\n color: rgba($brand, 0.5);\n background: darken($brand, 15%);\n border-color: hsl(120, 50%, 40%);\n width: percentage(0.25);\n}\n");
}
#[test]
fn parity_nesting_combinators() {
assert_parity(".a, .b {\n margin: 0;\n > .c { padding: 1px; }\n &.active { color: red; }\n .d & { color: blue; }\n}\n.menu { li + li { margin-left: 5px; } }\n");
}
#[test]
fn parity_interpolation() {
assert_parity("$name: warning;\n$i: 3;\n.icon-#{$name} { content: \"#{$name}-#{$i}\"; }\n.col-#{$i} { width: 10px * $i; }\n");
}
#[test]
fn parity_lists_and_functions() {
assert_parity("$stack: \"Helvetica Neue\", Arial, sans-serif;\n.t {\n font-family: $stack;\n margin: 1px 2px 3px 4px;\n transform: translateX(10px);\n width: calc(100% - 20px);\n}\n");
}
#[test]
fn parity_if_else() {
assert_parity("$t: dark;\n.a {\n @if $t == dark { color: white; background: black; }\n @else if $t == light { color: black; }\n @else { color: gray; }\n padding: 1px;\n}\n@if 2 > 1 { .b { x: 1; } } @else { .c { y: 2; } }\n");
}
#[test]
fn parity_loops() {
assert_parity("@for $i from 1 through 3 {\n .col-#{$i} { width: $i * 10px; }\n}\n@each $a, $b in (x 1), (y 2) {\n .pair-#{$a} { order: $b; }\n}\n.counter {\n $i: 0;\n @while $i < 3 { p-#{$i}: $i * 2px; $i: $i + 1; }\n}\n");
}
#[test]
fn parity_functions_and_mixins() {
assert_parity("@function clamp-val($v, $min: 0, $max: 100) {\n @if $v < $min { @return $min; }\n @else if $v > $max { @return $max; }\n @return $v;\n}\n@function sum($nums...) {\n $t: 0;\n @each $n in $nums { $t: $t + $n; }\n @return $t;\n}\n@mixin box($pad, $color: blue) { padding: $pad; color: $color; }\n@mixin surround { border: 1px solid; @content; margin: 0; }\n.a {\n z-index: clamp-val(150);\n order: sum(1, 2, 3, 4);\n @include box(4px);\n}\n.b { @include surround { background: yellow; } }\n");
}
#[test]
fn parity_modulo_sign() {
// Sass modulo is a floored modulo whose result takes the divisor's sign.
assert_parity(
"a {\n b: 1.2 % -4.7;\n c: -1.2 % 4.7;\n d: 5 % 3;\n e: 10px % 3px;\n f: -8 % 3;\n}\n",
);
}
#[test]
fn parity_at_rule_font_face() {
assert_parity("@font-face {\n font-family: \"My Font\";\n src: url(font.woff);\n}\n");
}
#[test]
fn parity_at_rule_page_and_unknown() {
assert_parity(
"@page {\n margin: 1cm;\n}\n@foo bar baz {\n a: b;\n}\n@blockless;\n@with-prelude value;\n",
);
}
#[test]
fn parity_at_rule_prelude_interpolation() {
// `#{…}` inside an unknown at-rule prelude is resolved everywhere, including
// inside a quoted string (the quotes stay literal); an escaped `\#{` and a
// bare `#` are left alone.
assert_parity(
"$d: \"x.com\";\n@foo url(https://#{$d}/), \"foo#{'ba' + 'r'}baz\", foo#{'ba' + 'r'}baz {\n .a { b: c }\n}\n",
);
assert_parity("@foo \"plain\", \"has # hash\", \"esc \\#{no}\", url(\"a#b\") { x { y: z } }\n");
// The same template path backs selectors, so `#{…}` inside a quoted
// attribute value resolves too.
assert_parity("$z: zzz;\na[data-foo=\"#{$z}\"] { color: red; }\n");
}
#[test]
fn parity_large_numbers() {
// Huge literals print as plain decimals, scientific notation expands,
// and fractions round to ten places exactly like dart-sass.
assert_parity(concat!(
"a {\n",
" big: 99999999999999999999999999999;\n",
" neg: -123456789012345;\n",
" sci: 1e20;\n",
" sci2: 1.5e3;\n",
" sci3: 1e-3;\n",
" unit: 1e3px;\n",
" third: (1 / 3);\n",
" precise: 0.1 + 0.2;\n",
"}\n",
));
// An int64-representable integer prints its exact decimal expansion (the
// native dart VM int path; the npx JS build prints the shortest
// round-trip `…800`, but sass-spec expectations come from the VM).
assert_eq!(
ours("a {b: 1234567890123456789}\n"),
"a {\n b: 1234567890123456768;\n}\n"
);
// Past int64 the saturating round-trip fails and the shortest form wins.
assert_eq!(
ours("a {b: 92233720368547758070}\n"),
"a {\n b: 92233720368547760000;\n}\n"
);
}
#[test]
fn parity_calc() {
// Fully numeric calc() interiors simplify and unwrap; mixed ones keep a
// canonical calc() with folded numeric subtrees and minimal parens.
assert_parity(concat!(
"a {\n",
" c1: calc(1px + 2px);\n",
" c2: calc(100% / 4);\n",
" c3: calc(2 * 3);\n",
" c4: calc(50px);\n",
" c5: calc((1 + 2) * 3px);\n",
" k1: calc(100% - 50px);\n",
" k2: calc(var(--a) + 1px);\n",
" k3: calc(1px + 1em);\n",
" k4: calc(1px + 2px + 1%);\n",
" k5: calc(3px * 2 + 1%);\n",
" k6: calc(1% + -1px);\n",
" k7: calc(1px + (2% * var(--c)));\n",
" k8: calc(1px - (2% + var(--c)));\n",
" k9: calc(1px + (var(--c)));\n",
" pi: calc(pi);\n",
" e: calc(e);\n",
" pi2: calc(pi * 2);\n",
" pimix: calc(pi * (1% + 1px));\n",
"}\n",
));
}
#[test]
fn parity_slash_division() {
// The deprecated `/` keeps a slash spelling between number literals but
// performs real division once an operand is computed, parenthesized,
// read from a variable, or passed through a Sass function.
assert_parity(concat!(
"$x: 8px;\n",
"@function id($v) {@return $v}\n",
"a {\n",
" keep: 16px/1.5;\n",
" chain: 1/2/3;\n",
" list: 1 2/3 4;\n",
" comma: 1, 2/3, 4;\n",
" same-unit: 10px/5px;\n",
" paren: (10px / 2);\n",
" computed: (1 + 1) / 2;\n",
" var: $x / 2;\n",
" func: inspect(10px / 2);\n",
" unknown: foo(1/2);\n",
"}\n",
));
}
#[test]
fn parity_at_rule_bubbling() {
assert_parity(
".a {\n color: black;\n @media-like screen {\n color: red;\n }\n .b { color: green; }\n}\n",
);
}
#[test]
fn parity_at_rule_nested_and_mixed() {
assert_parity("@outer one {\n @inner two {\n .a { color: red; }\n }\n}\n@foo {\n a: 1;\n b: 2;\n .x { c: 3; }\n d: 4;\n}\n");
}
#[test]
fn parity_warn_debug_emit_no_css() {
// @warn/@debug write to stderr only; the emitted CSS must be identical
// to the same stylesheet without them.
assert_parity("@warn \"a heads up\";\n.a {\n @debug 1 + 2;\n color: red;\n}\n");
}
#[test]
fn at_error_aborts_compilation() {
// @error must abort with an Error (not emit CSS). This runs offline.
let res = compile("@error \"boom\";\n.a { color: red; }\n", &Options::default());
assert!(res.is_err(), "@error should abort compilation");
}
#[test]
fn parity_supports() {
assert_parity("@supports (display: grid) {\n .a { display: grid; }\n}\n@supports (display: grid) and (gap: 1rem) {\n .b { gap: 1rem; }\n}\n@supports not (display: grid) {\n .c { float: left; }\n}\n@supports (a: b) or (c: d) {\n .d { x: y; }\n}\n");
}
#[test]
fn parity_supports_nested_and_blockless() {
assert_parity(
"@supports (a: b) and ((c: d) or (e: f)) {\n .g { h: i; }\n}\n@supports (x: y) {@inner foo}\n",
);
}
#[test]
fn parity_at_root_inline() {
assert_parity(
".a {\n color: red;\n @at-root .b {\n color: green;\n }\n}\n@at-root .c {\n color: blue;\n}\n",
);
}
#[test]
fn parity_at_root_block() {
assert_parity(".a {\n .b {\n @at-root .c {\n x: y;\n }\n }\n}\n.outer {\n @at-root {\n .single { z: 1; }\n }\n}\n");
}
#[test]
fn parity_keyframes() {
assert_parity("@keyframes slide {\n from { left: 0; }\n 50% { left: 50px; }\n to { left: 100px; }\n}\n@-webkit-keyframes spin {\n from { transform: rotate(0); }\n to { transform: rotate(360deg); }\n}\n");
}
#[test]
fn parity_keyframes_list_and_interpolation() {
assert_parity("$name: bounce;\n@keyframes #{$name} {\n 0%, 100% { opacity: 0; }\n 50% { opacity: 1; }\n}\n.a {\n @keyframes nested-#{1 + 1} { from { top: 0; } }\n}\n");
}
#[test]
fn parity_unit_converting_arithmetic() {
assert_parity(
".a {\n w: 1in + 1cm;\n x: 1cm + 1in;\n y: 5s - 100ms;\n z: 10px % 3pt;\n cmp: 1in > 2cm;\n mix: 5 + 1px;\n turn: 1turn + 90deg;\n}\n",
);
}
#[test]
fn parity_color_hue_family() {
assert_parity(
"a {\n b: adjust-hue(red, 540);\n c: adjust-hue(blue, 0);\n d: adjust-hue(red, -180);\n e: adjust-hue(red, 60rad);\n f: complement(aqua);\n g: invert(red);\n h: invert(#b37399, 80%);\n}\n",
);
}
#[test]
fn parity_calc_unit_folding() {
assert_parity(
".a {\n a: calc(1in + 1cm);\n b: calc(1cm + 1in);\n c: calc(5s - 100ms);\n d: calc(1px + 1pt);\n e: calc(10px / 2cm);\n f: calc(1turn + 90deg);\n g: calc(100% - 10px);\n h: calc(1px + 1vw);\n}\n",
);
}
#[test]
fn parity_math_builtins() {
assert_parity(
".a {\n a: sign(-5px);\n b: pow(2, 3);\n c: sqrt(4);\n d: log(8, 2);\n e: hypot(3px, 4cm);\n f: sin(30deg);\n g: cos(0);\n h: tan(45deg);\n i: asin(0.5);\n j: atan2(1, 1);\n k: rem(10px, 3pt);\n l: mod(-10, 3);\n}\n",
);
}
#[test]
fn parity_min_max_clamp_routing() {
assert_parity(
".a {\n a: min(1px, 2px);\n b: max(1, 2, 3);\n c: clamp(1px, 5px, 3px);\n d: min(1px + 1px, 2vw);\n e: min(1in, 2cm);\n f: min(50%, 30%);\n g: clamp(1px, 2vw, 3px);\n h: max(2px, min(1px, 2vw));\n i: min(1px, var(--x));\n}\n",
);
}
#[test]
fn parity_color_filter_overloads() {
assert_parity(
"a {\n b: invert(10%);\n c: grayscale(15%);\n d: saturate(50%);\n e: grayscale(var(--c));\n f: invert(calc(1 + 2));\n}\n",
);
}
#[test]
fn parity_color_legacy_named_and_alpha() {
assert_parity(
"a {\n b: lighten(red, 100%);\n c: darken(red, 100%);\n d: lighten(red, 14%);\n e: mix(red, red);\n f: mix(red, white, 50%);\n g: rgba(red, 1);\n h: rgba(#102030, 1);\n i: rgba(red, 0.5);\n}\n",
);
}
#[test]
fn parity_media_query_grammar() {
// Logic operators, modifiers, ranges, nested parens, interpolation and
// SassScript inside feature values; an empty body produces no output.
assert_parity(concat!(
"$w: width;\n",
"@media (a) and (b) { x { y: z; } }\n",
"@media (a)or (b) { x { y: z; } }\n",
"@media not a { x { y: z; } }\n",
"@media not (a) { x { y: z; } }\n",
"@media only screen and (color) { x { y: z; } }\n",
"@media a AnD nOt (b) { x { y: z; } }\n",
"@media (not (a)) { x { y: z; } }\n",
"@media ((a) and (b)) { x { y: z; } }\n",
"@media (min-width: 100px + 50px) { x { y: z; } }\n",
"@media ($w < 600px) { x { y: z; } }\n",
"@media (50px + 50px < width < 600px) { x { y: z; } }\n",
"@media (a) and #{\"(b) and (c)\"} { x { y: z; } }\n",
"@media screen { }\n",
"@media screen, print { x { y: z; } }\n",
));
}
#[test]
fn media_rejects_malformed_queries() {
// dart-sass rejects these; sasso must error rather than pass them through.
for src in [
"@media a or (b) { x { y: z; } }\n",
"@media (a) and (b) or (c) { x { y: z; } }\n",
"@media a and { x { y: z; } }\n",
"@media not { x { y: z; } }\n",
"@media not(a) { x { y: z; } }\n",
"@media (a) and(b) { x { y: z; } }\n",
"@media (1 < width < 2 < 3) { x { y: z; } }\n",
"@media (1px > width < 2px) { x { y: z; } }\n",
"@media (width < = 100px) { x { y: z; } }\n",
] {
let res = compile(src, &Options::default());
assert!(res.is_err(), "expected error for malformed media: {src}");
}
}
#[test]
fn media_rejects_bare_declarations_at_root() {
// A bare declaration directly in a media block without an enclosing style
// rule is an error in dart-sass; with a style rule it is allowed.
assert!(compile("@media screen { color: red; }\n", &Options::default()).is_err());
assert!(compile("@media a { @media b { color: red; } }\n", &Options::default()).is_err());
assert_parity(".x {\n @media screen {\n color: red;\n }\n}\n");
}
/// Compile `scss` and return our CSS (panicking on error), for direct
/// expected-output assertions that do not need a live dart-sass.
fn ours(scss: &str) -> String {
compile(scss, &Options::default()).expect("our compile failed")
}
/// Compile indented-syntax `sass` and return our CSS (panicking on error).
fn ours_sass(sass: &str) -> String {
compile(sass, &Options::default().with_syntax(sasso::Syntax::Sass)).expect("our indented compile failed")
}
/// Compile `scss` and return the error message (panicking on success).
fn ours_err(scss: &str) -> String {
compile(scss, &Options::default())
.expect_err("compile unexpectedly succeeded")
.to_string()
}
#[test]
fn rgb_hsl_special_value_passthrough() {
// A special channel argument (var/env/calc) preserves the call,
// comma-joined, when there are three components.
assert_eq!(
ours("a {b: rgb(var(--foo), 2, 3)}\n"),
"a {\n b: rgb(var(--foo), 2, 3);\n}\n"
);
assert_eq!(
ours("a {b: rgb(var(--foo) 2 3)}\n"),
"a {\n b: rgb(var(--foo), 2, 3);\n}\n"
);
assert_eq!(
ours("a {b: rgb(calc(1px + 1%), 2, 3, 0.4)}\n"),
"a {\n b: rgb(calc(1px + 1%), 2, 3, 0.4);\n}\n"
);
assert_eq!(
ours("a {b: hsl(var(--x) 50% 50%)}\n"),
"a {\n b: hsl(var(--x), 50%, 50%);\n}\n"
);
// A concrete color plus a special alpha decomposes to channels.
assert_eq!(
ours("a {b: rgb(blue, var(--foo))}\n"),
"a {\n b: rgb(0, 0, 255, var(--foo));\n}\n"
);
// Wrong component count is preserved verbatim (space-joined).
assert_eq!(
ours("a {b: rgb(var(--foo) 2)}\n"),
"a {\n b: rgb(var(--foo) 2);\n}\n"
);
assert_eq!(
ours("a {b: rgb(var(--foo))}\n"),
"a {\n b: rgb(var(--foo));\n}\n"
);
assert_eq!(
ours("a {b: rgb(var(--foo), 0.4)}\n"),
"a {\n b: rgb(var(--foo), 0.4);\n}\n"
);
}
#[test]
fn rgb_hsl_plain_number_channels_keep_function_spelling() {
// The one-argument channels list computes a color but keeps the rgb()/hsl()
// spelling (it never collapses to hex).
assert_eq!(ours("a {b: rgb(18 52 86)}\n"), "a {\n b: rgb(18, 52, 86);\n}\n");
assert_eq!(
ours("a {b: rgb(1 2 3 / 0.5)}\n"),
"a {\n b: rgba(1, 2, 3, 0.5);\n}\n"
);
assert_eq!(
ours("a {b: rgb(190 173 237 / 1)}\n"),
"a {\n b: rgb(190, 173, 237);\n}\n"
);
assert_eq!(
ours("a {b: hsl(120 50% 50% / 0.4)}\n"),
"a {\n b: hsla(120, 50%, 50%, 0.4);\n}\n"
);
// Two-argument (color, alpha) form still collapses to a computed color.
assert_eq!(ours("a {b: rgb(#123, 1)}\n"), "a {\n b: #112233;\n}\n");
assert_eq!(
ours("a {b: rgb(#123, 0.5)}\n"),
"a {\n b: rgba(17, 34, 51, 0.5);\n}\n"
);
// hsl saturation floors at 0, lightness is left unclamped, hue normalizes.
assert_eq!(
ours("a {b: hsl(0, 500%, 50%)}\n"),
"a {\n b: hsl(0, 500%, 50%);\n}\n"
);
assert_eq!(
ours("a {b: hsl(0, -100%, 50%)}\n"),
"a {\n b: hsl(0, 0%, 50%);\n}\n"
);
assert_eq!(
ours("a {b: hsl(0, 100%, -100%)}\n"),
"a {\n b: hsl(0, 100%, -100%);\n}\n"
);
assert_eq!(
ours("a {b: hsl(360, 50%, 50%)}\n"),
"a {\n b: hsl(0, 50%, 50%);\n}\n"
);
}
#[test]
fn hwb_global_conversion_and_passthrough() {
// Plain-number channels convert HWB -> HSL spelling.
assert_eq!(
ours("a {b: hwb(180 30% 40%)}\n"),
"a {\n b: hsl(180, 33.3333333333%, 45%);\n}\n"
);
assert_eq!(
ours("a {b: hwb(180 30% 40% / 0.5)}\n"),
"a {\n b: hsla(180, 33.3333333333%, 45%, 0.5);\n}\n"
);
assert_eq!(
ours("a {b: hwb(180 30% 40% / 1)}\n"),
"a {\n b: hsl(180, 33.3333333333%, 45%);\n}\n"
);
// Special and `none` channels preserve the call verbatim, space-joined,
// with a bare numeric hue suffixed `deg`.
assert_eq!(
ours("a {b: hwb(var(--c) 30% 40%)}\n"),
"a {\n b: hwb(var(--c) 30% 40%);\n}\n"
);
assert_eq!(
ours("a {b: hwb(none 30% 40%)}\n"),
"a {\n b: hwb(none 30% 40%);\n}\n"
);
assert_eq!(
ours("a {b: hwb(0 none 40%)}\n"),
"a {\n b: hwb(0deg none 40%);\n}\n"
);
assert_eq!(
ours("a {b: hwb(0 30% none)}\n"),
"a {\n b: hwb(0deg 30% none);\n}\n"
);
}
#[test]
fn lab_family_validation_and_passthrough() {
// Well-formed, fully numeric and special-value calls are preserved verbatim.
assert_eq!(ours("a {b: lab(1% 2 3)}\n"), "a {\n b: lab(1% 2 3);\n}\n");
assert_eq!(
ours("a {b: lab(var(--foo) 2 3)}\n"),
"a {\n b: lab(var(--foo) 2 3);\n}\n"
);
assert_eq!(
ours("a {b: lab(var(--foo) 2)}\n"),
"a {\n b: lab(var(--foo) 2);\n}\n"
);
assert_eq!(
ours("a {b: lab(from var(--c) l a b)}\n"),
"a {\n b: lab(from var(--c) l a b);\n}\n"
);
assert_eq!(ours("a {b: lch(1% 2 3deg)}\n"), "a {\n b: lch(1% 2 3deg);\n}\n");
// Malformed calls raise validation errors.
for src in [
"a {b: lab(1% 2)}\n",
"a {b: lab(1% 2 3 0.4)}\n",
"a {b: lab(c 2 3)}\n",
"a {b: lab(1px 2 3)}\n",
"a {b: lab(1% 2 3/0.4px)}\n",
"a {b: lab()}\n",
"a {b: lab(1%, 2, 3, 0.4)}\n",
"a {b: lab((1%, 2, 3))}\n",
"a {b: lch(1% 2 3px)}\n",
"a {b: lch(1% 2 3%)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
}
#[test]
fn color_function_validation_and_passthrough() {
// Well-formed and special/relative calls are preserved verbatim.
assert_eq!(
ours("a {b: color(srgb 0.1 0.2 0.3)}\n"),
"a {\n b: color(srgb 0.1 0.2 0.3);\n}\n"
);
assert_eq!(
ours("a {b: color(srgb calc(infinity) 0 0)}\n"),
"a {\n b: color(srgb calc(infinity) 0 0);\n}\n"
);
assert_eq!(
ours("a {b: color(from var(--c) srgb r g b)}\n"),
"a {\n b: color(from var(--c) srgb r g b);\n}\n"
);
// Malformed calls raise validation errors.
for src in [
"a {b: color()}\n",
"a {b: color(srgb)}\n",
"a {b: color(1 2 3)}\n",
"a {b: color(foo 1 2 3)}\n",
"a {b: color(srgb 0.1 0.2)}\n",
"a {b: color(srgb 0.1 0.2 0.3 0.4)}\n",
"a {b: color(srgb c 0.2 0.3)}\n",
"a {b: color(srgb 0.1px 0.2 0.3)}\n",
"a {b: color((srgb, 0.1, 0.2, 0.3))}\n",
"a {b: color(srgb (0.1 0.2 0.3))}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
}
#[test]
fn rgb_degenerate_calc_constants_fold() {
// calc(infinity)/calc(-infinity)/calc(NaN) fold to floating-point channel
// and alpha values for the legacy rgb function (clamped, NaN -> bound).
assert_eq!(
ours("a {b: rgb(calc(infinity), 0, 0, 0.5)}\n"),
"a {\n b: rgba(255, 0, 0, 0.5);\n}\n"
);
assert_eq!(
ours("a {b: rgb(calc(-infinity), 0, 0, 0.5)}\n"),
"a {\n b: rgba(0, 0, 0, 0.5);\n}\n"
);
assert_eq!(
ours("a {b: rgb(calc(NaN), 0, 0, 0.5)}\n"),
"a {\n b: rgba(0, 0, 0, 0.5);\n}\n"
);
assert_eq!(
ours("a {b: rgb(0, 0, 0, calc(infinity))}\n"),
"a {\n b: rgb(0, 0, 0);\n}\n"
);
assert_eq!(
ours("a {b: rgb(0, 0, 0, calc(-infinity))}\n"),
"a {\n b: rgba(0, 0, 0, 0);\n}\n"
);
assert_eq!(
ours("a {b: rgb(0, 0, 0, calc(NaN))}\n"),
"a {\n b: rgba(0, 0, 0, 0);\n}\n"
);
// A non-degenerate calc is still a special value preserved verbatim, and a
// degenerate calc in a modern color function (color()) is preserved too.
assert_eq!(
ours("a {b: rgb(calc(1px + 1%), 2, 3, 0.4)}\n"),
"a {\n b: rgb(calc(1px + 1%), 2, 3, 0.4);\n}\n"
);
assert_eq!(
ours("a {b: color(srgb calc(infinity) 0 0)}\n"),
"a {\n b: color(srgb calc(infinity) 0 0);\n}\n"
);
}
#[test]
fn modern_if_function() {
// The modern CSS `if()` conditional: `css()` conditions emit the whole
// call verbatim; `sass()`/bare conditions are evaluated (first truthy
// clause wins, else otherwise); the legacy `if($c, $t, $f)` builtin still
// works. Each case byte-matches dart-sass.
for src in [
// verbatim css() forms (single, else, raw chars, not, and/or, parens)
"a {b: if(css(): c)}\n",
"a {b: if(css(): c; else: d)}\n",
"a {b: if(css(!@#$%^&*(){}[]_-+=|:;''\"\"<>,./?): c)}\n",
"a {b: if(not css(): c)}\n",
"a {b: if(not (css()): c)}\n",
"a {b: if(css(1) and css(2): c)}\n",
"a {b: if(css(1) and (css(2)): c)}\n",
"a {b: if(css(1) or css(2) or css(3): c)}\n",
"a {b: if((css()): c)}\n",
"a {b: if((css(1) and css(2)): c)}\n",
// arbitrary substitutions (var/attr/nested if/interpolation), verbatim
"a {b: if(var(--not) css(): c)}\n",
"a {b: if(css(1) var(--and) css(2): c)}\n",
"a {b: if(css() if(else: var(--and-clause)): c)}\n",
"a {b: if(css(1) #{\"and\"} css(2): c)}\n",
"a {b: if(#{css}(): c)}\n",
"a {b: if(css(#{1 + 1}): c)}\n",
// evaluated sass()/bare conditions
"a {b: if(sass(true): c; else: d)}\n",
"a {b: if(sass(false): c; else: d)}\n",
"$a: true;\nb {c: if(sass($a): d; else: e)}\n",
"a {b: if(not sass(true): c; else: d)}\n",
"a {b: if(sass(true) and sass(false): c; else: d)}\n",
"a {b: if(sass(false) or sass(true): c; else: d)}\n",
// sass folded into a verbatim css() chain
"a {b: if(sass(true) and css(): c; else: d)}\n",
"a {b: if(sass(false) or css(): c; else: d)}\n",
"a {b: if(css(1) and sass(true) and css(2): c; else: d)}\n",
"a {b: if(sass(true) and (var(--not) css()): c)}\n",
// short-circuit, else-only, evaluated values
"a {b: if(sass(true): c; sass($undefined): d)}\n",
"a {b: if(else: c)}\n",
"a {b: if(css(): 1 + 2)}\n",
"a {b: if(css(): (1 2 3))}\n",
// legacy builtin stays intact
"a {b: if(true, 1px, 2px)}\n",
"a {b: if(false, 1px, 2px)}\n",
] {
assert_parity(src);
}
}
#[test]
fn modern_if_rejects_invalid_conditions() {
// dart-sass rejects these; sasso must error too (mixing and/or, `not`
// after a conjunction, reserved keyword as a function, and a `sass()`
// sharing a boolean level with an unparenthesised arbitrary substitution).
for src in [
"a {b: if(css(1) and css(2) or css(3): c)}\n",
"a {b: if(css(1) and not css(2): c)}\n",
"a {b: if(not not css(): c)}\n",
"a {b: if(not and(): d)}\n",
"a {b: if(css(1) and(css(2)): d)}\n",
"a {b: if(sass(true) and css(1) var(--and) css(2): c)}\n",
"a {b: if((sass(true)) and css(1) var(--and) css(2): c)}\n",
"a {b: if(css(1): c, css(2): d)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for invalid modern if(): {src}"
);
}
}
#[test]
fn parity_plain_css_import_passthrough() {
// A `url(...)` URL, a `.css`/protocol URL, or any URL with trailing
// media-query / `supports()` modifiers is a plain CSS `@import`, emitted
// verbatim in source order rather than inlined.
assert_parity("@import url(\"a.css\") print;\n");
assert_parity("@import url(whatever);\n");
assert_parity("@import \"a.css\";\n");
assert_parity("@import \"http://foo.com/bar\";\n");
assert_parity("@import \"a\" b;\n");
assert_parity("@import \"a.css\" supports(calc(1));\n");
assert_parity("@import \"a.css\" supports(--a: );\n");
assert_parity("@import \"a\" b, (c: d) and (e: f), g;\n");
// A `supports()`/function modifier ends the argument at a top-level comma,
// so the following URL starts a fresh `@import`; a bare media type does not.
assert_parity("@import \"a\" supports(b: c), \"d.css\";\n");
assert_parity("@import \"b\" c(d), \"e.css\";\n");
// Comments around the URL and modifiers are stripped.
assert_parity("@import \"a.css\" /**/ b;\n");
assert_parity("@import \"a.css\" b /**/;\n");
// Interpolation inside a CSS-import modifier is resolved.
assert_parity("@import \"b\" c#{\"a\"}d;\n");
// `#{…}` inside a `url("…")` import string is resolved (the bare quoted
// `@import "…"` form keeps interpolation literal, so it is not tested here).
assert_parity("$p: http;\n$f: Sans;\n@import url(\"#{$p}://x.com/c?family=#{$f}\");\n");
}
#[test]
fn parity_import_modifier_grammar() {
// The structural `@import` modifier grammar (dart `tryImportModifiers`):
// a `supports(<query>)` re-serializes canonically — `supports( a: b)` ->
// `supports(a: b)`, a paren'd declaration unwraps (`supports((a: b))` ->
// `supports(a: b)`), conditions/negations/functions keep one paren pair,
// custom-property values stay verbatim; media queries canonicalize
// (`and(c: d)` -> `and (c: d)`); unknown identifier/function runs pass
// through space-joined.
assert_parity(
"@import \"a.css\" supports( a: b);\n@import \"a.css\" supports((a: b));\n@import \"a.css\" supports((a: b) and (c: d));\n@import \"a.css\" supports(not (a: b));\n@import \"a.css\" supports(a(b));\n@import \"a.css\" supports(calc(1));\n@import \"a.css\" supports(--a: b);\n@import \"a.css\" supports(--a: ,);\n",
);
assert_parity(
"@import \"a\" b and(c: d), e;\n@import \"a\" supports(b: c) (d: e);\n@import \"a\" b c d(e) supports(f: g) h i j(k) l m (n: o), (p: q);\n@import \"b\" c(d), \"e.css\";\n",
);
// Wrong-order modifiers are rejected ("expected ';'." / identifier).
assert_error_parity("@import \"a\" (b: c) supports(d: e);\n");
assert_error_parity("@import \"a\" b, supports(c: d);\n");
assert_error_parity("@import \"a\" b, \"c\";\n");
assert_error_parity("@import url(\"a.css\") supports(--a:);\n");
}
/// A trivial in-memory [`Importer`] for offline `@import` inlining tests.
struct MapImporter(std::collections::HashMap<String, String>);
impl sasso::Importer for MapImporter {
fn resolve(&self, path: &str) -> Option<String> {
self.0.get(path).cloned()
}
}
#[test]
fn import_inlines_sass_partials() {
let mut files = std::collections::HashMap::new();
files.insert("p".to_string(), "x { y: z }".to_string());
files.insert(
"nested".to_string(),
"b { color: red; nested { x: y } }".to_string(),
);
let imp = MapImporter(files);
let opts = Options::default().with_importer(&imp);
// A bare quoted string with no modifiers is inlined at the top level.
let css = compile("@import \"p\";\n", &opts).expect("import compile failed");
assert_eq!(css, "x {\n y: z;\n}\n");
// A nested `@import` runs the imported statements under the current parent
// selector, so the imported rules nest beneath it.
let css = compile("a {\n @import \"nested\";\n}\n", &opts).expect("nested import failed");
assert_eq!(css, "a b {\n color: red;\n}\na b nested {\n x: y;\n}\n");
}
#[test]
fn import_reimports_and_detects_cycles() {
let mut files = std::collections::HashMap::new();
files.insert("p".to_string(), "x { y: z }".to_string());
files.insert("alpha".to_string(), "@import \"beta\";".to_string());
files.insert("beta".to_string(), "@import \"alpha\";".to_string());
let imp = MapImporter(files);
let opts = Options::default().with_importer(&imp);
// Re-importing an already-finished file emits its content again (`@import`
// re-evaluates), rather than being silently deduplicated.
let css = compile("@import \"p\", \"p\";\n", &opts).expect("import compile failed");
assert_eq!(css, "x {\n y: z;\n}\n\nx {\n y: z;\n}\n");
// A load cycle is an error rather than a silent skip or an infinite loop.
assert!(compile("@import \"alpha\";\n", &opts).is_err());
}
#[test]
fn bracketed_list_literals() {
// `[ ... ]` produces a bracketed list that serializes wrapped in square
// brackets, preserving the interior separator and nesting (parenthesized
// unbracketed lists flatten; nested bracketed lists stay nested).
assert_parity("x { b: []; }\n");
assert_parity("x { b: [c]; }\n");
assert_parity("x { b: [c d]; }\n");
assert_parity("x { b: [a, b]; }\n");
assert_parity("x { b: [[]]; }\n");
assert_parity("x { b: [[c]]; }\n");
assert_parity("x { b: [[c] [d]]; }\n");
assert_parity("x { b: [()]; }\n");
assert_parity("x { b: [(c,)]; }\n");
assert_parity("x { b: [(c,) (d e)]; }\n");
}
#[test]
fn comments_in_value_position() {
// Loud `/* */` and silent `//` comments act as whitespace between value
// tokens, and as operator separators (`1 /**/+/**/ 2`).
assert_parity("a {\n b: c // d\n}\n");
assert_parity("a {\n b: c /* d */ e;\n}\n");
assert_parity("a {\n c: 1 /**/+/**/ 2;\n}\n");
assert_parity("a {\n c: 1/**/+/**/2;\n}\n");
assert_parity("a {\n c: 1 +/**/ 2;\n}\n");
assert_parity("a {\n c: a /**/ b;\n}\n");
}
#[test]
fn calc_and_math_infinity_nan() {
// Non-finite calc results serialize as `calc(infinity)` / `calc(NaN)` /
// `calc(-infinity)` (with `* 1unit` when they carry a unit), and the math
// functions accept the bare `infinity`/`-infinity`/`NaN`/`pi`/`e`
// constants and re-wrap non-finite results in a calculation.
assert_parity("a { b: calc(1/0); }\n");
assert_parity("a { b: calc(10px / 0); }\n");
assert_parity("a { b: calc(0/0); }\n");
assert_parity("a { b: calc(-1/0); }\n");
assert_parity("a { b: atan(infinity); }\n");
assert_parity("a { b: atan(-infinity); }\n");
assert_parity("a { b: sin(infinity); }\n");
assert_parity("a { b: abs(infinity); }\n");
assert_parity("a { b: sign(infinity); }\n");
assert_parity("a { b: exp(-infinity); }\n");
assert_parity("a { b: pow(infinity, 2); }\n");
assert_parity("a { b: min(infinity, 5); }\n");
assert_parity("a { b: max(5, infinity); }\n");
assert_parity("a { b: min(NaN, 5); }\n");
assert_parity("a { b: clamp(1, infinity, 10); }\n");
assert_parity("a { b: cos(pi); }\n");
}
#[test]
fn round_strategies_and_steps() {
// round() as a CSS calculation: strategy keyword + step, the two-argument
// nearest-with-step form, unit coercion, a zero step (NaN), and the
// non-finite cases — all byte-matched to dart-sass.
assert_parity("a { b: round(nearest, 117px, 25px); }\n");
assert_parity("a { b: round(up, 101px, 25px); }\n");
assert_parity("a { b: round(down, 122px, 25px); }\n");
assert_parity("a { b: round(to-zero, 120px, 25px); }\n");
assert_parity("a { b: round(to-zero, -120px, -25px); }\n");
assert_parity("a { b: round(up, 12px, -7px); }\n");
assert_parity("a { b: round(117, 25); }\n");
assert_parity("a { b: round(117cm, 25mm); }\n");
assert_parity("a { b: round(4.6); }\n");
assert_parity("a { b: round(nearest, 10px, 0px); }\n");
assert_parity("a { b: round(nearest, infinity, 5); }\n");
assert_parity("a { b: round(nearest, -infinity, 5); }\n");
assert_parity("a { b: round(nearest, infinity, infinity); }\n");
assert_parity("a { b: round(1px, 10%); }\n");
assert_parity("a { b: round(1%, 2%); }\n");
assert_parity("a { b: round(1foo, 2bar); }\n");
}
#[test]
fn trailing_commas_in_params_and_args() {
// Trailing commas are allowed after ordinary params, defaulted params, the
// rest param, and call arguments.
assert_parity("@function a($b, ) { @return $b; }\nc { d: a(e, ); }\n");
assert_parity("@function a($b: 1, ) { @return $b; }\nc { d: a(); }\n");
assert_parity("@mixin m($b, $c..., ) { d: $b; e: $c; }\nf { @include m(1, 2, 3); }\n");
}
#[test]
fn splat_argument_expansion() {
// A list splat spreads into positional args (with explicit positionals
// bound first), and a map splat spreads into keyword args.
assert_parity("a { b: rgb([1, 2]..., 3); }\n");
assert_parity("a { b: rgb([1, 2]..., $blue: 3); }\n");
assert_parity("@function id($a, $b, $c) { @return $a $b $c; }\nx { y: id(1, [2, 3]...); }\n");
assert_parity("@function f($a, $b) { @return $a $b; }\nx { y: f((a: 1, b: 2)...); }\n");
}
#[test]
fn map_literals_and_builtins() {
// Map literals serialize via inspect(); the global map functions and
// @each over a map all byte-match dart-sass.
assert_parity("a { b: inspect((c: 1, d: 2)); }\n");
assert_parity("a { b: inspect((c: (d: 1), \"e\": f g)); }\n");
assert_parity("a { b: map-get((c: d), c); }\n");
assert_parity("a { b: map-keys((c: 1, d: 2)); }\n");
assert_parity("a { b: map-values((c: 1, d: 2)); }\n");
assert_parity("a { b: map-has-key((c: d), c); }\n");
assert_parity("@each $k, $v in (a: 1, b: 2) { x-#{$k} { y: $v; } }\n");
}
#[test]
fn special_css_functions_verbatim() {
// calc/element/expression (with/without vendor prefix) and unprefixed
// type() preserve their arguments verbatim: vendor-prefixed and uppercase
// names lower-case, `%`/`@`/`=`/punctuation and IE-hack syntax pass
// through, loud comments survive, silent comments drop, whitespace
// collapses, and `#{}` resolves — all byte-matched to dart-sass.
assert_parity("a { b: -a-calc(/**/ c); }\n");
assert_parity("a { b: -a-calc(c /**/); }\n");
assert_parity("a {\n b: -a-calc(//\n c);\n}\n");
assert_parity("a {\n b: -a-calc(c //\n );\n}\n");
assert_parity("a { b: element(c d); }\n");
assert_parity("a { b: expression(a=b); }\n");
assert_parity("a { b: expression(opacity=80); }\n");
assert_parity("a { b: TYPE(0); }\n");
assert_parity("a { b: type(@#$%^&*({[]})_-+=); }\n");
assert_parity("a { b: -A-CALC(0); }\n");
assert_parity("a { b: -C-ELEMENT(0); }\n");
assert_parity("a { b: -C-EXPRESSION(#{1 + 1}); }\n");
assert_parity("a { b: -a-calc( c d ); }\n");
}
#[test]
fn css_custom_function_mixin_passthrough() {
// A `@function`/`@mixin` whose name begins with `--`, or any non-lowercase
// spelling of the keyword, is a plain CSS custom function/mixin: emitted
// verbatim. Top-level declaration values stay literal (`$b`, `1 + 1`,
// arbitrary characters) with whitespace collapsed; interpolated properties
// evaluate as SassScript; `#{}` resolves. Byte-matched to dart-sass.
assert_parity("@function --a(--b <color>) {result: c}\n");
assert_parity("@function --a() returns <ident> {result: b}\n");
assert_parity("@function --#{a}() {result: b}\n");
assert_parity("@function --a() {\n result: $b;\n}\n");
assert_parity("@function --a() {\n result: 1 + 1;\n}\n");
assert_parity("@function --a() {\n result: #{1 + 1};\n}\n");
assert_parity("@function --a() {\n result: {}#&%^*;\n}\n");
assert_parity("@function --a() {\n RESULT: {b: c};\n}\n");
assert_parity("@function --a() {\n #{result}: 1 + 1;\n}\n");
assert_parity("@FUNCTION --a() {\n result: $b;\n}\n");
assert_parity("@FUNCTION foo() {\n result: $b;\n}\n");
assert_parity("@MIXIN foo {}\n");
assert_parity("@MIXIN --a {}\n");
// A non-custom lowercase `@function`/`@mixin` is still a Sass definition.
assert_parity("@function foo() { @return 1px * 2; }\na { b: foo(); }\n");
assert_parity("@mixin foo { x: y; }\na { @include foo; }\n");
}
#[test]
fn special_url_function_passthrough() {
// url() is recognised case-insensitively and with an optional vendor
// prefix. A plain unquoted URL is emitted as a bare lower-cased `url(...)`
// (the vendor prefix is dropped), tolerating `!` and other url-safe
// characters and resolving `#{}` (including inside quoted strings). When
// the contents are SassScript (a `$variable`) the call falls back to a
// normal function so its arguments evaluate, keeping the original name.
assert_parity("a { b: url(!); }\n");
assert_parity("a { b: URL(!); }\n");
assert_parity("a { b: URL(http://c.d/e!f); }\n");
assert_parity("a { b: -c-url(0); }\n");
assert_parity("a { b: -c-url(http://d.e/f!g); }\n");
assert_parity("a { b: -c-url(#{0}); }\n");
assert_parity("a { b: url(c, d); }\n");
assert_parity("$a: b;\nc { d: url($a); }\n");
assert_parity("$a: b;\nc { d: -e-url($a); }\n");
assert_parity("$f: bar;\na {\n foo: url($f);\n foo: url(#{$f});\n foo: url(\"x?v=#{$f}\");\n}\n");
}
#[test]
fn extended_named_colors() {
// Every one of the 148 CSS named colors must resolve and feed color
// functions; previously extended names like `plum` errored as "not a
// color". Each rule round-trips through rgba() so the exact channel
// values are byte-matched to dart-sass.
assert_parity("a { b: rgba(plum, 0.5); }\n");
assert_parity("a { b: rgba(rebeccapurple, 0.5); }\n");
assert_parity("a { b: rgba(darkslategray, 0.5); }\n");
assert_parity("a { b: desaturate(plum, 14%); }\n");
assert_parity("a { b: rgba(cornflowerblue, 0.25); }\n");
assert_parity("a { b: rgba(mediumspringgreen, 0.75); }\n");
assert_parity("a { b: rgba(lightgoldenrodyellow, 1); }\n");
}
#[test]
fn legacy_color_argument_forms() {
// The single-argument `$channels` list form, the `rgb($color, $alpha)`
// two-argument form (positional and named), and the `none`-channel
// verbatim spelling (a bare hue gains `deg`) — all byte-matched.
assert_parity("a { b: hsl($channels: 0 100% 50%); }\n");
assert_parity("a { b: rgb($channels: 1 2 3); }\n");
assert_parity("a { b: rgb($color: #123, $alpha: 0.5); }\n");
assert_parity("a { b: rgb($alpha: 0.5, $color: blue); }\n");
assert_parity("a { b: rgb(red, 0.5); }\n");
assert_parity("a { b: hsl(0 none 50%); }\n");
assert_parity("a { b: hsl(0 100% none); }\n");
assert_parity("a { b: hsl(none 100% 50%); }\n");
}
#[test]
fn hsl_degenerate_calc_channels() {
// A degenerate calc() channel keeps the hsl() spelling, coercing each
// channel like dart-sass: hue reduces mod 360 to calc(NaN); saturation
// and lightness gain `* 1%`, with saturation clamping non-positive/NaN
// to 0%. Byte-matched to dart-sass.
assert_parity("a { b: hsl(calc(infinity), 100%, 50%); }\n");
assert_parity("a { b: hsl(calc(-infinity), 100%, 50%); }\n");
assert_parity("a { b: hsl(calc(NaN), 100%, 50%); }\n");
assert_parity("a { b: hsl(0, calc(infinity), 50%); }\n");
assert_parity("a { b: hsl(0, calc(-infinity), 50%); }\n");
assert_parity("a { b: hsl(0, calc(NaN), 50%); }\n");
assert_parity("a { b: hsl(0, 100%, calc(infinity)); }\n");
assert_parity("a { b: hsl(0, 100%, calc(-infinity)); }\n");
assert_parity("a { b: hsl(0, 100%, calc(NaN)); }\n");
}
#[test]
fn mix_srgb_method_matches_legacy() {
// The `srgb`/`rgb` interpolation methods reproduce the legacy mix this
// build computes, and must byte-match dart-sass (other spaces require
// full color-space interpolation and are validated elsewhere).
assert_parity("a { b: mix(red, blue, $method: srgb); }\n");
assert_parity("a { b: mix(red, blue, $method: rgb); }\n");
assert_parity("a { b: mix(red, blue, 25%, $method: srgb); }\n");
}
#[test]
fn relative_color_from_is_preserved() {
// A relative-color `rgb(from … )`/`hsl(from … )` call is kept verbatim
// rather than computed or rejected by the channel-count check.
assert_parity("a { b: rgb(from red r g b); }\n");
assert_parity("a { b: hsl(from red h s l); }\n");
assert_parity("a { b: rgb(from var(--c) r g b); }\n");
}
#[test]
fn slash_with_special_value_forms_slash() {
// dart-sass: `/` between non-number operands (a calc()/var()/unquoted
// string/list, or a number divided by a non-number) does not divide — it
// forms a slash-separated unquoted string `left/right`. A `calc()` that
// folds to a number keeps the slash spelling too. A color on the *left*
// of `/` is the one case that still errors ("Undefined operation").
assert_parity("a { b: calc(1)/2; }\n");
assert_parity("a { b: 1/calc(2); }\n");
assert_parity("a { b: calc(1)/calc(2); }\n");
assert_parity("a { b: calc(2px)/calc(4px); }\n");
assert_parity("a { b: calc(1px + 1%)/2; }\n");
assert_parity("a { b: 2/calc(1px + 1%); }\n");
assert_parity("a { b: calc(1px + 1%)/calc(2px + 2%); }\n");
assert_parity("a { b: foo / 2; }\n");
assert_parity("a { b: var(--x) / 2; }\n");
assert_parity("a { b: 2 / var(--x); }\n");
assert_parity("a { b: (1 2) / 3; }\n");
assert_parity("a { b: 2 / red; }\n");
}
#[test]
fn calc_infinity_nan_constants() {
// `infinity`/`-infinity`/`nan` are calc() numeric constants (like
// `pi`/`e`), resolved case-insensitively. They fold through arithmetic
// (`infinity * 2` -> `infinity`) and canonicalize their spelling; a
// unit-carrying non-finite renders (and parenthesizes) as `infinity * 1px`.
assert_parity("a { b: calc(infinity * 2); }\n");
assert_parity("a { b: calc(-infinity * 2); }\n");
assert_parity("a { b: calc(NAN * 2); }\n");
assert_parity("a { b: calc(InFiNiTy); }\n");
assert_parity("a { b: calc(nan); }\n");
assert_parity("a { b: calc(infinity * (1% + 1px)); }\n");
assert_parity("a { b: calc((1/0) * (1% + 1px)); }\n");
assert_parity("a { b: calc(infinity * 1px); }\n");
assert_parity("a { b: calc(2 * infinity * 1px); }\n");
assert_parity("a { b: calc(var(--c) / (infinity * 1px)); }\n");
assert_parity("a { b: calc(var(--c) - (infinity * 1px)); }\n");
// The degenerate-constant color channels still resolve (the calc value
// keeps the spelling the color builtins inspect).
assert_parity("a { b: rgb(calc(infinity), 0, 0, 0.5); }\n");
assert_parity("a { b: rgb(calc(NaN), 0, 0, 0.5); }\n");
}
#[test]
fn calc_wrapping_complete_calculation_flattens() {
// `calc()` wrapping a single already-complete calculation drops the
// redundant outer `calc()` (dart-sass): `calc(min(1%, 2px))` -> `min(…)`.
// A real operation inside keeps the wrapper, and a non-calculation leaf
// (`var()`, unknown function) keeps its `calc()`.
assert_parity("a { b: calc(min(1%, 2px)); }\n");
assert_parity("a { b: calc(max(1%, 2px)); }\n");
assert_parity("a { b: calc(clamp(1%, 2px, 3%)); }\n");
assert_parity("a { b: calc(round(1%, 2px)); }\n");
assert_parity("a { b: calc(calc-size(1%, 2px)); }\n");
assert_parity("a { b: calc(min(1%, 2px) + 1px); }\n");
assert_parity("a { b: calc(var(--x)); }\n");
assert_parity("a { b: calc(unknownfn(1%, 2px)); }\n");
}
#[test]
fn calc_relative_length_cross_dimension_errors() {
// A relative length (`em`, `ch`, `vw`, …) is a known *length*, so mixing
// it with another dimension in calc() `+`/`-` is incompatible (dart-sass
// errors), even though it is not convertible to an absolute length.
for src in [
"a {b: calc(1ch + 1deg)}\n",
"a {b: calc(1em + 1s)}\n",
"a {b: calc(1vw + 1hz)}\n",
"a {b: calc(1rem + 1dpi)}\n",
"a {b: calc(1vmax - 1khz)}\n",
"a {b: calc(1ex + 1grad)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
// Two lengths (even when one is relative and not convertible) are
// compatible and preserved; `%`, `fr`, and unknown units never error.
assert_parity("a { b: calc(1px + 1vw); }\n");
assert_parity("a { b: calc(1em + 1px); }\n");
assert_parity("a { b: calc(1ch + 1em); }\n");
assert_parity("a { b: calc(1fr + 1px); }\n");
assert_parity("a { b: calc(1% + 1deg); }\n");
assert_parity("a { b: calc(1foo + 1deg); }\n");
}
#[test]
fn calc_value_plus_strictness() {
// A calculation may only be `+`-concatenated with a string; against any
// other operand (number, color, bool, list, another calculation)
// dart-sass raises "Undefined operation".
for src in [
"a {b: calc(var(--c)) + 1}\n",
"a {b: 1 + calc(var(--c))}\n",
"a {b: calc(var(--c)) + calc(var(--d))}\n",
"a {b: calc(var(--c)) + true}\n",
"a {b: calc(var(--c)) + red}\n",
"a {b: red + calc(var(--c))}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
// Concatenation with a string is allowed; a calc on the left inherits the
// right string's quotedness.
assert_parity("a { b: calc(var(--c)) + foo; }\n");
assert_parity("a { b: foo + calc(var(--c)); }\n");
assert_parity("a { b: calc(var(--c)) + \"x\"; }\n");
assert_parity("a { b: \"x\" + calc(var(--c)); }\n");
}
#[test]
fn calc_operand_value_strictness() {
// A value resolved into a calc() that is not a number, calculation, or
// unquoted special string — a null, bool, color, list, map, or quoted
// string (typically via a `$variable`) — is rejected.
for src in [
"$a: null;\nb {c: calc($a)}\n",
"$a: true;\nb {c: calc($a)}\n",
"$a: blue;\nb {c: calc($a)}\n",
"$a: 1 2 3;\nb {c: calc($a)}\n",
"$a: (1, 2);\nb {c: calc($a)}\n",
"$a: (b: c);\nb {c: calc($a)}\n",
"$a: \"foo\";\nb {c: calc($a)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
// A number, var(), interpolation, or plain ident operand is fine.
assert_parity("a { b: calc(foo); }\n");
assert_parity("a { b: calc(var(--x)); }\n");
assert_parity("a { b: calc(#{foo}); }\n");
}
#[test]
fn calc_space_list_grammar() {
// A space-separated run inside calc() is only legal when it carries a
// var()/env() substitution or interpolation (spliced verbatim); a run of
// ordinary operands has no operator between adjacent terms ("Missing math
// operator.").
assert_parity("a { b: calc(var(--c) 1); }\n");
assert_parity("a { b: calc(1 var(--c)); }\n");
assert_parity("a { b: calc(1 var(--c) 2); }\n");
assert_parity("a { b: calc(#{\"1 +\"} 2); }\n");
assert_parity("a { b: calc(1 #{\"+ 2\"}); }\n");
assert_parity("a { b: calc(1 #{\"+ 2 +\"} 3); }\n");
for src in [
"a {b: calc(1 2)}\n",
"a {b: calc(c 1 2)}\n",
"a {b: calc(1 2 c)}\n",
"a {b: calc(1 (3))}\n",
"a {b: calc(1 calc(1px + 1%))}\n",
"$c: 1;\n$d: 2;\na {b: calc($c $d)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
}
#[test]
fn calc_rejects_non_arithmetic_operators() {
// Only `+`/`-`/`*`/`/` are valid in a calculation; modulo, comparisons,
// and `and`/`or` are rejected ("This operation can't be used in a
// calculation.").
for src in [
"a {b: calc(1px % 2px)}\n",
"a {b: calc(1 > 2)}\n",
"a {b: calc(1 == 2)}\n",
"a {b: calc(1 and 2)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
}
}
#[test]
fn calc_name_is_case_insensitive() {
// `calc` is recognized case-insensitively and its interior simplified;
// a vendor-prefixed form stays a verbatim special function.
assert_parity("a { b: CaLc(1px); }\n");
assert_parity("a { b: Calc(2); }\n");
assert_parity("a { b: CALC(1px + 1%); }\n");
assert_parity("a { b: -webkit-calc(1px + 1%); }\n");
}
#[test]
fn slash_chain_keeps_spelling_through_special_value() {
// A slash-division operand keeps its chained spelling when the other side
// of a `/` is a special value: `1 / 2 / foo()` -> `1/2/foo()`, not the
// collapsed quotient `0.5/foo()`.
assert_parity("a { b: 1 / 2 / foo(); }\n");
assert_parity("a { b: 1/2/foo(); }\n");
}
#[test]
fn progid_long_filter_syntax_is_preserved() {
// IE `progid:Name.Name(...)` long-filter syntax (with `:`, `.`, `=`, and
// `#hex` inside the arg list) is preserved verbatim; the `progid` keyword
// and any vendor prefix are lower-cased while the `.Name` chain keeps its
// case. Interpolation resolves; a backslash escapes the next character so
// an escaped `\(`/`\)` does not affect parenthesis nesting.
assert_parity(
"foo { filter: progid:DXImageTransform.Microsoft.gradient(GradientType=1, startColorstr=#c0ff3300, endColorstr=#ff000000); }\n",
);
assert_parity("a { b: -C-PROGID:D(#{0}); }\n");
assert_parity("a { b: progid:c(/**/ d); }\n");
assert_parity("a { b: progid:foo.bar(x=1), progid:baz.qux(y=2); }\n");
assert_parity("a { b: progid:c(opacity=20\\)); }\n");
}
#[test]
fn lone_percent_is_a_value_token() {
// A `%` with no left operand is a standalone unquoted-string value (not the
// modulo operator), so the IE/CSS `attr(c, %)` placeholder round-trips and
// a bare `%` survives in any argument position. A whitespace-surrounded `%`
// remains the modulo operator.
assert_parity("a { b: %; }\n");
assert_parity("a { b: attr(c, %); }\n");
assert_parity("a { b: rgb(attr(c, %), 2, 3); }\n");
assert_parity("a { b: rgb(1, 2, attr(c, %)); }\n");
assert_parity("a { b: foo(1, %, 2); }\n");
assert_parity("a { b: 7 % 3; }\n");
}
#[test]
fn quoted_string_line_continuation_is_removed() {
// A backslash immediately followed by a CSS newline inside a quoted string
// is a line continuation: both the backslash and the newline are dropped,
// joining the two physical lines (leading whitespace on the next line is
// preserved). Byte-matched to dart-sass.
assert_parity("a { b: \"line1 \\\n line2\"; }\n");
assert_parity("a { b: \"x\\\ny\"; }\n");
assert_parity("a { b: 'a\\\nb\\\nc'; }\n");
}
#[test]
fn parent_selector_as_value() {
// `&` in value position resolves to the current selector: a single
// selector, a comma list, and a nested/descendant selector. At the
// document root `&` is `null` (interpolates to empty). A content block
// passed to a mixin without `@content` is an error.
assert_eq!(ours("a {\n b: &;\n}\n"), "a {\n b: a;\n}\n");
assert_eq!(ours(".x, .y {\n c: &;\n}\n"), ".x, .y {\n c: .x, .y;\n}\n");
assert_eq!(
ours(".foo {\n .bar {\n d: &;\n }\n}\n"),
".foo .bar {\n d: .foo .bar;\n}\n"
);
// `&` is always a comma list, so `nth(&, 1)` is the first complex
// selector and a descendant selector reports two space-separated items.
assert_eq!(ours(".x, .y {\n c: nth(&, 1);\n}\n"), ".x, .y {\n c: .x;\n}\n");
assert_eq!(
ours(".a .b {\n c: length(nth(&, 1));\n}\n"),
".a .b {\n c: 2;\n}\n"
);
// Interpolation of the root `&` (null) yields the empty string.
assert_eq!(ours("a {\n c: \"#{&}!\";\n}\n"), "a {\n c: \"a!\";\n}\n");
// A selector that resolves to nothing (`#{&}` at the root) is rejected.
assert!(compile("#{&} {\n foo {\n bar: baz;\n }\n}\n", &Options::default()).is_err());
// A content block for a mixin that never uses `@content` is an error.
assert!(compile(
"@mixin m() { x: 1; }\na { @include m { y: 2; } }\n",
&Options::default()
)
.is_err());
}
#[test]
fn parent_selector_placement_strictness() {
// `&` must begin a compound selector and a top-level `&` may not carry an
// identifier suffix — matching dart-sass's parser rules. These run offline.
// Non-initial `&` is always an error (parent or not).
assert!(compile("p {\n b& {c: d}\n}\n", &Options::default()).is_err());
assert!(compile("p {\n [b]& {c: d}\n}\n", &Options::default()).is_err());
assert!(compile("p {\n .x& {c: d}\n}\n", &Options::default()).is_err());
assert!(compile(":not(a > b)& {c: d}\n", &Options::default()).is_err());
// A top-level `&` with an identifier suffix is an error.
assert!(compile("&a {b: c}\n", &Options::default()).is_err());
assert!(compile("&-x {b: c}\n", &Options::default()).is_err());
assert!(compile("@at-rule {\n &b {c: d}\n}\n", &Options::default()).is_err());
// But a suffix under a real parent is allowed (it concatenates).
assert_eq!(ours(".x {\n &a {c: d}\n}\n"), ".xa {\n c: d;\n}\n");
// And these valid placements still compile (each `&` begins a compound).
assert!(compile("p {\n &.foo {c: d}\n}\n", &Options::default()).is_ok());
assert!(compile("p {\n &:hover {c: d}\n}\n", &Options::default()).is_ok());
assert!(compile("p {\n & > & {c: d}\n}\n", &Options::default()).is_ok());
assert!(compile("p {\n &[a~=b] {c: d}\n}\n", &Options::default()).is_ok());
}
#[test]
fn placeholder_selector_must_be_named() {
// A bare `%` (or `%` not followed by an identifier name-start char) is
// "Expected identifier." in dart-sass. Keyframe percentage selectors
// (`10%`, `1e2%`) are not placeholders and must still compile.
assert!(compile("% {\n a: b;\n}\n", &Options::default()).is_err());
assert!(compile("%.bar {\n a: b;\n}\n", &Options::default()).is_err());
assert!(compile(".a % {\n c: d;\n}\n", &Options::default()).is_err());
assert_eq!(
ours("@keyframes a {\n 10% {\n c: d;\n }\n}\n"),
"@keyframes a {\n 10% {\n c: d;\n }\n}\n"
);
assert_eq!(
ours("@keyframes a {\n from, 15%, to {\n c: d;\n }\n}\n"),
"@keyframes a {\n from, 15%, to {\n c: d;\n }\n}\n"
);
}
#[test]
fn attribute_selector_modifier_strictness() {
// An attribute modifier must be a single ASCII letter directly before the
// closing `]`. Invalid forms (no operator, too long, non-letter, trailing
// space) error with `expected "]"`; valid forms still compile. Offline.
assert!(compile("[a b] {c: d}\n", &Options::default()).is_err());
assert!(compile("[a=b cd] {c: d}\n", &Options::default()).is_err());
assert!(compile("[a=b _] {c: d}\n", &Options::default()).is_err());
assert!(compile("[a=b 1] {c: d}\n", &Options::default()).is_err());
assert!(compile("[a=b i ] {c: d}\n", &Options::default()).is_err());
assert!(compile("[charset i] {c: d}\n", &Options::default()).is_err());
// Valid attribute selectors compile, including single-letter modifiers,
// a modifier glued to a quoted value, namespaces, and `]` inside a value.
assert!(compile("[a] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[a=b i] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[a=b I] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[a=b c] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[a=\"b\"i] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[*|a=b i] {c: d}\n", &Options::default()).is_ok());
assert!(compile("[a=\"]\"] {c: d}\n", &Options::default()).is_ok());
}
#[test]
fn attribute_selector_emit_normalization() {
// Expanded-mode attribute selectors serialize canonically: whitespace
// around the operator and at the edges is removed, a quoted value that is
// a plain CSS identifier is unquoted, and a trailing modifier is preceded
// by a single space — byte-matched to dart-sass.
assert_eq!(ours("a[\n b]\n {c: d}\n"), "a[b] {\n c: d;\n}\n");
assert_eq!(ours("a[b=\n c]\n {d: e}\n"), "a[b=c] {\n d: e;\n}\n");
assert_eq!(ours("a[b\n =c]\n {d: e}\n"), "a[b=c] {\n d: e;\n}\n");
assert_eq!(ours("[a=\"b\"i] {c: d}\n"), "[a=b i] {\n c: d;\n}\n");
assert_eq!(ours("[a=\"b\"] {c: d}\n"), "[a=b] {\n c: d;\n}\n");
// Non-identifier values stay quoted; `]` inside a value is preserved.
assert_eq!(ours("[a=\"b c\"] {d: e}\n"), "[a=\"b c\"] {\n d: e;\n}\n");
assert_eq!(ours("[a=\"--b\"] {d: e}\n"), "[a=\"--b\"] {\n d: e;\n}\n");
assert_eq!(ours("[a=\"]\"] {d: e}\n"), "[a=\"]\"] {\n d: e;\n}\n");
}
#[test]
fn math_unit_rules_and_arity() {
// atan2/hypot preserve their call verbatim when an operand is a `%`
// (context-dependent) or an unknown/relative unit can't be combined; an
// all-compatible call still folds. Byte-matched to dart-sass. Offline.
assert_eq!(ours("a {b: atan2(1%, 2%)}\n"), "a {\n b: atan2(1%, 2%);\n}\n");
assert_eq!(
ours("a {b: atan2(1px, 10%)}\n"),
"a {\n b: atan2(1px, 10%);\n}\n"
);
assert_eq!(
ours("a {b: atan2(1foo, 2bar)}\n"),
"a {\n b: atan2(1foo, 2bar);\n}\n"
);
assert_eq!(
ours("a {b: atan2(1foo, 2foo)}\n"),
"a {\n b: 26.5650511771deg;\n}\n"
);
assert_eq!(ours("a {b: hypot(1%, 2%)}\n"), "a {\n b: hypot(1%, 2%);\n}\n");
assert_eq!(
ours("a {b: hypot(1foo, 2foo)}\n"),
"a {\n b: 2.2360679775foo;\n}\n"
);
// mod/rem fold equal unknown units but preserve a real+unknown mix.
assert_eq!(ours("a {b: mod(1%, 2%)}\n"), "a {\n b: 1%;\n}\n");
assert_eq!(ours("a {b: mod(5px, 3%)}\n"), "a {\n b: mod(5px, 3%);\n}\n");
// Calc-style math names fold case-insensitively.
assert_eq!(ours("a {b: SiN(1deg)}\n"), "a {\n b: 0.0174524064;\n}\n");
assert_eq!(ours("a {b: AbS(-2)}\n"), "a {\n b: 2;\n}\n");
// A known cross-dimension or unitless/real mix is an error.
assert!(compile("a {b: atan2(1deg, 1px)}\n", &Options::default()).is_err());
assert!(compile("a {b: atan2(1, 1px)}\n", &Options::default()).is_err());
assert!(compile("a {b: mod(16px, 5deg)}\n", &Options::default()).is_err());
// Too many arguments to a fixed-arity function is an error.
assert!(compile("a {b: sin(0, 0)}\n", &Options::default()).is_err());
assert!(compile("a {b: abs(1, 2)}\n", &Options::default()).is_err());
assert!(compile("a {b: pow(1, 2, 3)}\n", &Options::default()).is_err());
}
#[test]
fn math_random_in_range() {
// random() is a unitless float in [0, 1); random($limit) is an integer in
// [1, $limit]. The draw is nondeterministic, so assert range membership
// rather than an exact value. Offline.
for _ in 0..200 {
let css = ours("a {b: random()}\n");
let v: f64 = css
.trim()
.trim_start_matches("a {")
.trim()
.trim_start_matches("b:")
.trim()
.trim_end_matches('}')
.trim()
.trim_end_matches(';')
.trim()
.parse()
.expect("random() should emit a bare number");
assert!((0.0..1.0).contains(&v), "random() out of range: {v}");
let css = ours("a {b: random(5)}\n");
let v: f64 = css
.trim()
.trim_start_matches("a {")
.trim()
.trim_start_matches("b:")
.trim()
.trim_end_matches('}')
.trim()
.trim_end_matches(';')
.trim()
.parse()
.expect("random(5) should emit a bare integer");
assert!((1.0..=5.0).contains(&v), "random(5) out of range: {v}");
assert_eq!(v, v.round(), "random(5) must be an integer: {v}");
}
// A non-positive or non-integer limit errors.
assert!(compile("a {b: random(0)}\n", &Options::default()).is_err());
assert!(compile("a {b: random(-1)}\n", &Options::default()).is_err());
assert!(compile("a {b: random(1.5)}\n", &Options::default()).is_err());
}
#[test]
fn math_min_max_clamp_unit_rules() {
// min/max fold compatible/convertible units to the winning argument's own
// unit, preserve mutually-incomparable clusters, and error on a known
// cross-dimension pair. Byte-matched to dart-sass. Offline.
assert_eq!(ours("a {b: min(1px, 1in, 1cm)}\n"), "a {\n b: 1px;\n}\n");
assert_eq!(ours("a {b: max(1px, 1in, 1cm)}\n"), "a {\n b: 1in;\n}\n");
assert_eq!(ours("a {b: min(3d, 2, 1e)}\n"), "a {\n b: 1e;\n}\n");
assert_eq!(ours("a {b: min(1px, 2vw)}\n"), "a {\n b: min(1px, 2vw);\n}\n");
assert_eq!(ours("a {b: min(1c, 2d)}\n"), "a {\n b: min(1c, 2d);\n}\n");
assert_eq!(ours("a {b: min(1%, 2%)}\n"), "a {\n b: 1%;\n}\n");
assert!(compile("a {b: min(1s, 2px)}\n", &Options::default()).is_err());
assert!(compile("a {b: max(1px, 2px, 3s)}\n", &Options::default()).is_err());
// clamp checks `min` first (so `clamp(3, 5, 1)` is `1`), keeps the winning
// argument's unit, errors on a known cross-dimension pair, and preserves a
// lone non-number argument.
assert_eq!(ours("a {b: clamp(1px, 1in, 1cm)}\n"), "a {\n b: 1cm;\n}\n");
assert_eq!(ours("a {b: clamp(3, 5, 1)}\n"), "a {\n b: 1;\n}\n");
assert_eq!(ours("a {b: clamp(5, 1, 3)}\n"), "a {\n b: 5;\n}\n");
assert_eq!(
ours("a {b: clamp(1px, 2vw, 3px)}\n"),
"a {\n b: clamp(1px, 2vw, 3px);\n}\n"
);
assert_eq!(
ours("a {b: clamp(var(--c))}\n"),
"a {\n b: clamp(var(--c));\n}\n"
);
assert!(compile("a {b: clamp(1s, 2px, 3px)}\n", &Options::default()).is_err());
assert!(compile("a {b: clamp(1px)}\n", &Options::default()).is_err());
}
#[test]
fn math_round_strategy_preservation() {
// An explicit three-argument round() preserves its strategy keyword when
// the units keep it from simplifying (round(nearest, 1px, 10%) keeps
// `nearest`), while the implicit two-argument form does not. A strategy
// with an unsimplifiable value but no step preserves rather than erroring.
// Byte-matched to dart-sass. Offline.
assert_eq!(
ours("a {b: round(nearest, 1px, 10%)}\n"),
"a {\n b: round(nearest, 1px, 10%);\n}\n"
);
assert_eq!(
ours("a {b: round(1px, 10%)}\n"),
"a {\n b: round(1px, 10%);\n}\n"
);
assert_eq!(
ours("a {b: round(up, 1px, 10%)}\n"),
"a {\n b: round(up, 1px, 10%);\n}\n"
);
assert_eq!(
ours("a {c: round(up, var(--c))}\n"),
"a {\n c: round(up, var(--c));\n}\n"
);
// A strategy with a real number but no step is still an error.
assert!(compile("a {b: round(nearest, 5)}\n", &Options::default()).is_err());
assert!(compile("a {b: round(up, 5)}\n", &Options::default()).is_err());
}
#[test]
fn selector_comment_stripping() {
// dart-sass treats a loud `/* */` or silent `//` comment inside a selector
// as whitespace: it is dropped and a separator is left, so the selector
// normaliser collapses it. Byte-matched to dart-sass. Offline.
assert_eq!(ours("a /**/ {b: c}\n"), "a {\n b: c;\n}\n");
assert_eq!(ours("a /**/ b {x: y}\n"), "a b {\n x: y;\n}\n");
assert_eq!(ours("a/**/b {x: y}\n"), "a b {\n x: y;\n}\n");
assert_eq!(ours("a /***/ b {x: y}\n"), "a b {\n x: y;\n}\n");
assert_eq!(ours("a //\n {b: c}\n"), "a {\n b: c;\n}\n");
// A loud comment that is a standalone statement is still emitted.
assert_eq!(
ours("a {\n /* keep */\n b: c;\n}\n"),
"a {\n /* keep */\n b: c;\n}\n"
);
}
#[test]
fn declaration_property_comment_stripping() {
// A loud or silent comment between a declaration's property name and the
// `:` is dropped (the property template strips it as whitespace, and the
// emitter trims). Byte-matched to dart-sass. Offline.
assert_eq!(ours("a {b /**/ : c}\n"), "a {\n b: c;\n}\n");
assert_eq!(ours("a {b //\n : c}\n"), "a {\n b: c;\n}\n");
assert_eq!(ours("a { color : red ; }\n"), "a {\n color: red;\n}\n");
}
#[test]
fn at_rule_prelude_comment_stripping() {
// `@supports` and `@-moz-document` use structured grammars: top-level
// trivia comments are dropped, but comments inside parentheses are kept.
assert_eq!(
ours("@supports (a: b) /**/ {c {d: e}}\n"),
"@supports (a: b) {\n c {\n d: e;\n }\n}\n"
);
assert_eq!(
ours("@supports (a: b) //\n {c {d: e}}\n"),
"@supports (a: b) {\n c {\n d: e;\n }\n}\n"
);
assert_eq!(
ours("@supports (a /**/ b) {c {d: e}}\n"),
"@supports (a /**/ b) {\n c {\n d: e;\n }\n}\n"
);
// Unknown directives keep a loud comment verbatim but drop a silent one.
assert_eq!(ours("@a b /**/\n"), "@a b /**/;\n");
assert_eq!(ours("@a b //\n"), "@a b;\n");
assert_eq!(ours("@a /**/ b\n"), "@a b;\n");
assert_eq!(ours("@a b /**/ {}\n"), "@a b /**/ {}\n");
assert_eq!(ours("@a b //\n {}\n"), "@a b {}\n");
}
#[test]
fn legacy_channels_special_slash_alpha() {
// When the trailing `channel / alpha` slash crosses a special value
// (var/calc/attr) or a `none`, it evaluates to an unquoted `X/Y` string
// rather than a numeric slash. The one-argument channels form must still
// peel off the alpha and emit dart-sass's normalized spelling. Byte-matched
// to `npx sass`. Offline.
// Three plain-or-special channels with a special channel or alpha → the
// legacy comma form (the alpha becomes the fourth comma item).
assert_eq!(
ours("a{x: rgb(1 2 var(--f) / 0.4)}\n"),
"a {\n x: rgb(1, 2, var(--f), 0.4);\n}\n"
);
assert_eq!(
ours("a{x: rgb(1 2 3 / var(--f))}\n"),
"a {\n x: rgb(1, 2, 3, var(--f));\n}\n"
);
assert_eq!(
ours("a{x: hsl(1 2% 3%/var(--a))}\n"),
"a {\n x: hsl(1, 2%, 3%, var(--a));\n}\n"
);
// A wrong channel count with a special value keeps the original spelling
// verbatim (the `/` alpha separator stays glued).
assert_eq!(
ours("a{x: rgb(var(--f) 2 / 0.4)}\n"),
"a {\n x: rgb(var(--f) 2/0.4);\n}\n"
);
assert_eq!(
ours("a{x: rgb(var(--f) / 0.4)}\n"),
"a {\n x: rgb(var(--f)/0.4);\n}\n"
);
// A `none` keyword (no special function) keeps the space/slash spelling;
// hsl gives a bare-number hue an explicit `deg`.
assert_eq!(
ours("a{x: rgb(0 255 127 / none)}\n"),
"a {\n x: rgb(0 255 127 / none);\n}\n"
);
assert_eq!(
ours("a{x: rgb(0 none 127 / 0.5)}\n"),
"a {\n x: rgb(0 none 127 / 0.5);\n}\n"
);
assert_eq!(
ours("a{x: hsl(180 none 50% / 0.5)}\n"),
"a {\n x: hsl(180deg none 50% / 0.5);\n}\n"
);
// hwb: a special function keeps the verbatim glued spelling; a `none`
// keeps the spaced `deg`/slash spelling.
assert_eq!(
ours("a{x: hwb(0 30% 40% / none)}\n"),
"a {\n x: hwb(0deg 30% 40% / none);\n}\n"
);
assert_eq!(
ours("a{x: hwb(0 30% 40% / var(--a))}\n"),
"a {\n x: hwb(0 30% 40%/var(--a));\n}\n"
);
}
#[test]
fn color_function_degenerate_calc() {
// A degenerate `calc()` (`NaN`/`infinity`/`-infinity`) in a `color()` call
// is folded the way dart-sass folds it, and the result serializes in the
// modern (space-around-`/`) form. A degenerate channel is preserved; a
// degenerate alpha folds to a number (`infinity` = opaque/omitted,
// `-infinity`/`NaN` = 0). Byte-matched to `npx sass`. Offline.
assert_eq!(
ours("a{x: color(srgb 0 0 calc(infinity) / 0.5)}\n"),
"a {\n x: color(srgb 0 0 calc(infinity) / 0.5);\n}\n"
);
assert_eq!(
ours("a{x: color(srgb 0 0 calc(NaN) / 0.5)}\n"),
"a {\n x: color(srgb 0 0 calc(NaN) / 0.5);\n}\n"
);
assert_eq!(
ours("a{x: color(srgb 0 0 0 / calc(infinity))}\n"),
"a {\n x: color(srgb 0 0 0);\n}\n"
);
assert_eq!(
ours("a{x: color(srgb 0 0 0 / calc(-infinity))}\n"),
"a {\n x: color(srgb 0 0 0 / 0);\n}\n"
);
assert_eq!(
ours("a{x: color(srgb 0 0 0 / calc(NaN))}\n"),
"a {\n x: color(srgb 0 0 0 / 0);\n}\n"
);
// A non-degenerate special channel/alpha keeps the original glued spelling.
assert_eq!(
ours("a{x: color(srgb 0 0 var(--x) / 0.5)}\n"),
"a {\n x: color(srgb 0 0 var(--x)/0.5);\n}\n"
);
// A degenerate channel with no alpha is unchanged.
assert_eq!(
ours("a{x: color(srgb calc(infinity) 0 0)}\n"),
"a {\n x: color(srgb calc(infinity) 0 0);\n}\n"
);
}
#[test]
fn legacy_channels_non_number_channel_error() {
// A one-argument channels list whose first channel is a non-`from`,
// non-number value (a quoted `"from"` or a bare keyword like `c`) reports
// a per-channel error before the channel-count check, matching dart-sass.
// The channel name is per-space (`red`/`hue`) for the first three and
// `channel <N>` beyond. Offline.
let err = |scss: &str| {
compile(scss, &Options::default())
.err()
.map(|e| e.to_string())
.unwrap_or_default()
};
assert!(err("a{b: rgb(\"from\" #aaa r g b)}\n")
.contains("$channels: Expected red channel to be a number, was \"from\"."));
assert!(
err("a{b: rgb(c #aaa r g b)}\n").contains("$channels: Expected red channel to be a number, was c.")
);
assert!(
err("a{b: hsl(c #aaa h s l)}\n").contains("$channels: Expected hue channel to be a number, was c.")
);
assert!(
err("a{b: hwb(c #aaa h w b)}\n").contains("$channels: Expected hue channel to be a number, was c.")
);
assert!(err("a{b: rgb(1 c d)}\n").contains("$channels: Expected green channel to be a number, was c."));
assert!(err("a{b: rgb(1 2 3 c d)}\n").contains("$channels: Expected channel 4 to be a number, was c."));
// A `from`-relative call (even with a var() base and a slash alpha) is kept
// verbatim, not reported as an error.
assert_eq!(
ours("a{x: rgb(from var(--c) r g b / 25%)}\n"),
"a {\n x: rgb(from var(--c) r g b/25%);\n}\n"
);
}
#[test]
fn non_finite_number_serializes_as_calc() {
// A bare non-finite number value serializes like dart-sass: a unitless
// infinity/-infinity/NaN prints as `calc(infinity)`/`calc(-infinity)`/
// `calc(NaN)`, and a unit-bearing one as `calc(infinity * 1px)`.
// `1e400` overflows the f64 literal to +Infinity, `-1e400` to -Infinity.
assert_eq!(ours("a {b: 1e400}\n"), "a {\n b: calc(infinity);\n}\n");
assert_eq!(ours("a {b: -1e400}\n"), "a {\n b: calc(-infinity);\n}\n");
// Unit-bearing non-finite values keep their unit as a `* 1<unit>` operand.
assert_eq!(
ours("a {b: (1px / 0) * 1}\n"),
"a {\n b: calc(infinity * 1px);\n}\n"
);
assert_eq!(ours("a {b: (0px / 0) * 1}\n"), "a {\n b: calc(NaN * 1px);\n}\n");
// Interpolation produces the same calc form (no longer a bare `Infinity`).
assert_eq!(ours("a {b: #{1e400}}\n"), "a {\n b: calc(infinity);\n}\n");
}
#[test]
fn list_is_bracketed_and_zip() {
// `is-bracketed` reports the bracket flag; a bare value, an empty list,
// and a plain space/comma list are all `false`.
assert_eq!(ours("a {b: is-bracketed([a b c])}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: is-bracketed(a b c)}\n"), "a {\n b: false;\n}\n");
assert_eq!(ours("a {b: is-bracketed(())}\n"), "a {\n b: false;\n}\n");
// `zip` interleaves corresponding elements into a comma list of space
// lists, truncating to the shortest input.
assert_eq!(
ours("a {b: zip(1px 2px, solid dashed, red blue)}\n"),
"a {\n b: 1px solid red, 2px dashed blue;\n}\n"
);
assert_eq!(ours("a {b: zip(1 2 3, c d)}\n"), "a {\n b: 1 c, 2 d;\n}\n");
// A single list zips each element into its own one-element row.
assert_eq!(ours("a {b: zip(a b c)}\n"), "a {\n b: a, b, c;\n}\n");
}
#[test]
fn meta_feature_exists_known_set() {
// `feature-exists` is `true` for dart-sass's fixed feature set (quoted or
// unquoted), and `false` for any other name.
assert_eq!(ours("a {b: feature-exists(at-error)}\n"), "a {\n b: true;\n}\n");
assert_eq!(
ours("a {b: feature-exists(global-variable-shadowing)}\n"),
"a {\n b: true;\n}\n"
);
assert_eq!(
ours("a {b: feature-exists(\"custom-property\")}\n"),
"a {\n b: true;\n}\n"
);
assert_eq!(
ours("a {b: feature-exists(units-level-3)}\n"),
"a {\n b: true;\n}\n"
);
assert_eq!(
ours("a {b: feature-exists(extend-selector-pseudoclass)}\n"),
"a {\n b: true;\n}\n"
);
assert_eq!(ours("a {b: feature-exists(nope)}\n"), "a {\n b: false;\n}\n");
}
#[test]
fn equality_is_unit_and_format_aware() {
// Numbers compare across convertible units (`1in == 96px`), but unitless
// vs unit-bearing and incompatible units stay unequal. Units are
// case-sensitive in `==` (`1PX != 1px`).
assert_eq!(ours("a {b: 1in == 96px}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: 1cm == 10mm}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: 100grad == 90deg}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: 1s == 1000ms}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: 1 == 1px}\n"), "a {\n b: false;\n}\n");
assert_eq!(ours("a {b: 1px == 1em}\n"), "a {\n b: false;\n}\n");
assert_eq!(ours("a {b: 1PX == 1px}\n"), "a {\n b: false;\n}\n");
// Colors compare resolved channels fuzzily: a named color equals an HSL
// color that resolves to the same sRGB channels within epsilon.
assert_eq!(
ours("a {b: purple == hsl(300, 100%, 25.098039215686%)}\n"),
"a {\n b: true;\n}\n"
);
// Genuinely different colors stay unequal.
assert_eq!(ours("a {b: red == hsl(0, 0%, 50%)}\n"), "a {\n b: false;\n}\n");
}
/// Phase-1a parity proof (offline). The `@extend` engine keys its ~28 maps/sets
/// by `render()` strings; Phase 1c/1d migrate those to typed `Complex`/`Simple`
/// keys, which is behaviour-preserving iff `render()` is INJECTIVE over the
/// typed model — no two structurally-distinct selectors share a render string
/// (the converse is free: `render()` is pure and a derived `Hash` agrees with
/// `Eq`). `selector.rs::assert_render_injective` guards this at the engine's key
/// sites in debug builds; compiling a broad spread of `@extend` inputs here
/// (`:is`/`:not`/`:has` args, attributes, pseudo-elements, combinators,
/// namespaces, placeholders, universal, `nth-of`) fires the guard on any
/// violation. The full sass-spec suite was also run guard-active with zero
/// violations, byte-identical output.
#[test]
fn selector_render_injective() {
for scss in [
".a {c: d}\n:is(.x, .y) .z[w=v i] {@extend .a}\n",
".a {c: d}\n* > .b ~ .c:not(.d):has(> .e) {@extend .a}\n",
".a {c: d}\nns|el::before, *|*:hover {@extend .a}\n",
"%p {c: d}\n.q:nth-child(2n + 1 of .r) {@extend %p}\n",
".a.b {c: d}\n.x {@extend .a}\n.y {@extend .b}\n",
"#id:not(:is(.a, [b])) {c: d}\n.z {@extend #id}\n",
] {
// In a debug build the render-injectivity guard panics on any violation.
let _ = ours(scss);
}
}
#[test]
fn extend_basic_and_placeholders() {
// A class extend adds the extender as an alternative selector.
assert_eq!(
ours(".foo {a: b}\n.bar {@extend .foo}\n"),
".foo, .bar {\n a: b;\n}\n"
);
// A placeholder rule emits nothing on its own, but its body surfaces under
// the extending selector(s).
assert_eq!(ours("%p {color: red}\n"), "");
assert_eq!(
ours("%p {color: red}\n.a {@extend %p}\n"),
".a {\n color: red;\n}\n"
);
// Nested target: the extender replaces the matched compound in place.
assert_eq!(
ours(".foo .bar {a: b}\n.baz {@extend .bar}\n"),
".foo .bar, .foo .baz {\n a: b;\n}\n"
);
// Compound unification across two extends, with the within-compound product.
assert_eq!(
ours(".foo.bar {a: b}\n.baz {@extend .foo}\n.bang {@extend .bar}\n"),
".foo.bar, .foo.bang, .bar.baz, .baz.bang {\n a: b;\n}\n"
);
// !optional suppresses the "target not found" error.
assert_eq!(
ours(".a {x: y; @extend .missing !optional}\n"),
".a {\n x: y;\n}\n"
);
}
#[test]
fn extend_trim_and_chain_order() {
// Redundant subselectors are trimmed: `.baz` supersedes `.foo.baz` etc.
assert_eq!(
ours(".foo.bar {a: b}\n.baz {@extend .foo; @extend .bar}\n"),
".foo.bar, .baz {\n a: b;\n}\n"
);
// The universal selector supersedes the bare class, so only `-a *` remains.
assert_eq!(
ours("%-a .foo {a: b}\n* {@extend .foo} -a {@extend %-a}\n"),
"-a * {\n a: b;\n}\n"
);
// Chained extends keep dart-sass's reverse-registration ordering of
// same-target extenders.
assert_eq!(
ours(".foo {a: b}\n.bar {@extend .foo}\n.baz {@extend .bar}\n.bip {@extend .bar}\n"),
".foo, .bar, .bip, .baz {\n a: b;\n}\n"
);
// Two direct extenders of the same target also come out reversed.
assert_eq!(
ours(".foo {a: b}\n.bar {@extend .foo}\n.baz {@extend .foo}\n"),
".foo, .baz, .bar {\n a: b;\n}\n"
);
}
#[test]
fn extend_weaves_multi_component_extenders() {
// A multi-component extender interweaves its parents with the matched
// selector's parents in all order-preserving ways (dart-sass `weave`).
assert_eq!(
ours(".baz .bip .foo {a: b}\nfoo .grank bar {@extend .foo}\n"),
".baz .bip .foo, .baz .bip foo .grank bar, foo .grank .baz .bip bar {\n a: b;\n}\n"
);
// Identical parent prefixes unify to a single woven selector.
assert_eq!(
ours(".baz .bip .foo {a: b}\n.baz .bip bar {@extend .foo}\n"),
".baz .bip .foo, .baz .bip bar {\n a: b;\n}\n"
);
}
#[test]
fn extend_universal_and_element_unification() {
// `*|*` unified into a compound with a class drops the universal entirely.
assert_eq!(
ours("%-a .foo.bar {a: b}\n*|* {@extend .foo} -a {@extend %-a}\n"),
"-a .bar {\n a: b;\n}\n"
);
// A namespaced universal target keeps its namespace where it can't unify away.
assert_eq!(
ours("%-a ns|*.foo {a: b}\n* {@extend .foo} -a {@extend %-a}\n"),
"-a ns|*.foo {\n a: b;\n}\n"
);
// A namespaced type extender unifies with `*` to the concrete element.
assert_eq!(
ours("%-a *.foo {a: b}\n*|a {@extend .foo} -a {@extend %-a}\n"),
"-a *.foo, -a a {\n a: b;\n}\n"
);
}
#[test]
fn extend_pseudo_class_and_element_ordering() {
// A unified pseudo-class keeps its order after an existing pseudo-class.
assert_eq!(
ours("%-a :foo.baz {a: b}\n:bar {@extend .baz} -a {@extend %-a}\n"),
"-a :foo.baz, -a :foo:bar {\n a: b;\n}\n"
);
// Pseudo-classes always sort before a pseudo-element in the result.
assert_eq!(
ours(".foo:bar {a: b}\n.baz::bang {@extend .foo}\n"),
".foo:bar, .baz:bar::bang {\n a: b;\n}\n"
);
// `:not()` unifies as an ordinary pseudo-class.
assert_eq!(
ours("%-a :not(.foo).baz {a: b}\n:not(.bar) {@extend .baz} -a {@extend %-a}\n"),
"-a :not(.foo).baz, -a :not(.foo):not(.bar) {\n a: b;\n}\n"
);
}
#[test]
fn extend_across_media_is_an_error() {
// An `@extend` inside `@media` may not extend a selector defined at the
// document root.
assert!(compile(
".foo { a: b }\n@media print { .bar { @extend .foo } }\n",
&Options::default()
)
.is_err());
// Both target and extender inside the same media context is fine.
assert_eq!(
ours("@media print { .a { x: y } .b { @extend .a } }\n"),
"@media print {\n .a, .b {\n x: y;\n }\n}\n"
);
// A bare `@extend` directly inside `@at-root` (no enclosing rule) errors.
assert!(compile(
".a { x: y }\n.b { @at-root (with: media) { @extend .a } }\n",
&Options::default()
)
.is_err());
}
#[test]
fn placeholder_inside_pseudo_arguments() {
// A nonexistent `%placeholder` is removed from `:is()`/`:not()` arguments.
assert_eq!(ours("a:not(%b) {x: y}\n"), "a {\n x: y;\n}\n");
assert_eq!(ours(":not(%b) {x: y}\n"), "* {\n x: y;\n}\n");
assert_eq!(ours("a:is(%b, c) {x: y}\n"), "a:is(c) {\n x: y;\n}\n");
assert_eq!(ours("a:not(%b, c) {x: y}\n"), "a:not(c) {\n x: y;\n}\n");
// A solo `%placeholder` in a matches-any pseudo removes the whole rule.
assert_eq!(ours("a:is(%b) {x: y}\n"), "");
}
#[test]
fn unquoted_value_escapes_are_canonicalized() {
// A CSS escape in an unquoted value decodes to its code point and then
// re-serializes per dart-sass's identifier rules: printable name chars
// become literal, control chars use the `\<hex> ` form, and other
// punctuation is backslash-escaped.
assert_eq!(ours("a {b: \\41}\n"), "a {\n b: A;\n}\n");
assert_eq!(ours("a {b: \\41 BC}\n"), "a {\n b: ABC;\n}\n");
assert_eq!(ours("a {b: \\9}\n"), "a {\n b: \\9 ;\n}\n");
assert_eq!(ours("a {b: \\0}\n"), "a {\n b: \\0 ;\n}\n");
// A leading digit (or one right after a leading hyphen) is hex-escaped;
// the same digit mid-identifier stays literal.
assert_eq!(ours("a {b: \\30 x}\n"), "a {\n b: \\30 x;\n}\n");
assert_eq!(ours("a {b: q\\30 x}\n"), "a {\n b: q0x;\n}\n");
assert_eq!(ours("a {b: -\\30 x}\n"), "a {\n b: -\\30 x;\n}\n");
// A `-` produced by an escape at identifier start is backslash-escaped,
// but a literal leading `-` stays bare.
assert_eq!(ours("a {b: \\2d a}\n"), "a {\n b: \\-a;\n}\n");
assert_eq!(ours("a {b: \\2d\\2d}\n"), "a {\n b: \\--;\n}\n");
assert_eq!(ours("a {b: -\\2d}\n"), "a {\n b: -\\-;\n}\n");
assert_eq!(ours("a {b: a\\2d}\n"), "a {\n b: a-;\n}\n");
// Printable punctuation gets a literal backslash; a literal backslash
// round-trips.
assert_eq!(ours("a {b: \\21}\n"), "a {\n b: \\!;\n}\n");
assert_eq!(ours("a {b: \\7f}\n"), "a {\n b: \\7f ;\n}\n");
assert_eq!(ours("a {b: \\\\}\n"), "a {\n b: \\\\;\n}\n");
}
#[test]
fn quoted_string_escapes_are_normalized() {
// Quoted strings decode escapes to code points and re-serialize per
// dart-sass: printable chars pass through, `\#{` becomes a literal `#{`,
// and only the quote char, backslash, and control chars are re-escaped.
assert_eq!(ours("a {b: \"\\41\"}\n"), "a {\n b: \"A\";\n}\n");
assert_eq!(ours("a {b: \"x\\#{y}\"}\n"), "a {\n b: \"x#{y}\";\n}\n");
assert_eq!(ours("a {b: \"\\#{y}\"}\n"), "a {\n b: \"#{y}\";\n}\n");
// Tab (0x09) stays literal inside quotes; DEL is hex-escaped.
assert_eq!(ours("a {b: \"\\9\"}\n"), "a {\n b: \"\t\";\n}\n");
assert_eq!(ours("a {b: \"\\7f\"}\n"), "a {\n b: \"\\7f\";\n}\n");
// A control escape gets a trailing space only when the next char would
// extend the escape (a hex digit).
assert_eq!(ours("a {b: \"\\1 0\"}\n"), "a {\n b: \"\\1 0\";\n}\n");
assert_eq!(ours("a {b: \"\\1 a\"}\n"), "a {\n b: \"\\1 a\";\n}\n");
// A string containing `\"` but no `'` is rewrapped in single quotes; one
// containing both keeps double quotes and escapes the inner `"`.
assert_eq!(ours("a {b: \"a\\\"b\"}\n"), "a {\n b: 'a\"b';\n}\n");
assert_eq!(ours("a {b: \"a'b\\\"c\"}\n"), "a {\n b: \"a'b\\\"c\";\n}\n");
// A literal backslash round-trips as `\\`.
assert_eq!(ours("a {b: \"\\\\\"}\n"), "a {\n b: \"\\\\\";\n}\n");
}
#[test]
fn unquoted_url_contents_escapes_are_canonicalized() {
// CSS escapes inside plain (unquoted) `url(...)` contents decode and
// re-serialize with the identifier body rules: name chars (including a
// leading digit or `-`) stay literal, control chars use `\<hex> `, and
// other punctuation is backslash-escaped. `\#{` stays a literal `#{`.
assert_eq!(ours("a {b: url(\\41)}\n"), "a {\n b: url(A);\n}\n");
assert_eq!(ours("a {b: url(\\41 bc)}\n"), "a {\n b: url(Abc);\n}\n");
assert_eq!(ours("a {b: url(\\30)}\n"), "a {\n b: url(0);\n}\n");
assert_eq!(ours("a {b: url(\\2d)}\n"), "a {\n b: url(-);\n}\n");
assert_eq!(ours("a {b: url(\\9)}\n"), "a {\n b: url(\\9 );\n}\n");
assert_eq!(ours("a {b: url(\\7f)}\n"), "a {\n b: url(\\7f );\n}\n");
assert_eq!(ours("a {b: url(\\21)}\n"), "a {\n b: url(\\!);\n}\n");
assert_eq!(ours("a {b: url(\\))}\n"), "a {\n b: url(\\));\n}\n");
assert_eq!(ours("a {b: url(\\#{})}\n"), "a {\n b: url(\\#{});\n}\n");
}
#[test]
fn non_ascii_output_declares_utf8_charset() {
use sasso::OutputStyle;
// Non-ASCII output (here produced by a unicode escape) gets a leading
// `@charset "UTF-8";` in expanded output and a UTF-8 BOM in compressed
// output, matching dart-sass. Pure-ASCII output gets neither.
assert_eq!(
ours("a {b: url(\\2603)}\n"),
"@charset \"UTF-8\";\na {\n b: url(\u{2603});\n}\n"
);
assert_eq!(ours("a {b: c}\n"), "a {\n b: c;\n}\n");
let compressed = compile(
"a {b: url(\\2603)}\n",
&Options::default().with_style(OutputStyle::Compressed),
)
.expect("compile failed");
assert_eq!(compressed, "\u{FEFF}a{b:url(\u{2603})}");
}
#[test]
fn leading_utf8_bom_is_stripped() {
// A leading UTF-8 BOM in the source is dropped before parsing, so it never
// appears in the output and never triggers a spurious `@charset`.
assert_eq!(ours("\u{FEFF}foo {bar: baz}\n"), "foo {\n bar: baz;\n}\n");
}
#[test]
fn unicode_range_tokens_parse_and_preserve() {
// CSS unicode-range values: plain ranges, `-end` ranges, and `?`
// wildcards. The original case is preserved (`u+1a2b` stays lowercase).
assert_eq!(ours("a {b: U+1}\n"), "a {\n b: U+1;\n}\n");
assert_eq!(ours("a {b: U+123456}\n"), "a {\n b: U+123456;\n}\n");
assert_eq!(ours("a {b: u+1a2b}\n"), "a {\n b: u+1a2b;\n}\n");
assert_eq!(ours("a {b: U+4??}\n"), "a {\n b: U+4??;\n}\n");
assert_eq!(ours("a {b: U+0-7F}\n"), "a {\n b: U+0-7F;\n}\n");
assert_eq!(
ours("a {b: U+1A2B3C-10FFFF}\n"),
"a {\n b: U+1A2B3C-10FFFF;\n}\n"
);
// A `?`-wildcard token is terminal: a directly-following identifier
// becomes a fresh space-list element (`U+A?BCDE` -> `U+A? BCDE`,
// `U+A?-BCDE` -> `U+A? -BCDE`), while `-<digit>` continues as a
// subtraction whose unquoted-string join keeps the source spelling.
assert_eq!(ours("a {b: U+A?BCDE}\n"), "a {\n b: U+A? BCDE;\n}\n");
assert_eq!(ours("a {b: U+A?-BCDE}\n"), "a {\n b: U+A? -BCDE;\n}\n");
assert_eq!(ours("a {b: U+A?-1234}\n"), "a {\n b: U+A?-1234;\n}\n");
// Malformed ranges still error like dart-sass.
assert!(compile("a {b: U+}\n", &Options::default()).is_err());
assert!(compile("a {b: U+1234567}\n", &Options::default()).is_err());
assert!(compile("a {b: U+123-456-ABC}\n", &Options::default()).is_err());
}
#[test]
fn minus_operator_string_joins_non_numbers() {
// dart-sass's `-` operator subtracts two numbers but otherwise produces an
// unquoted string join `<left>-<right>`, with each side keeping its own
// serialization (quoted strings keep their quotes).
assert_eq!(ours("a {b: foo - 1}\n"), "a {\n b: foo-1;\n}\n");
assert_eq!(ours("a {b: 1 - foo}\n"), "a {\n b: 1-foo;\n}\n");
assert_eq!(ours("a {b: foo - bar}\n"), "a {\n b: foo-bar;\n}\n");
assert_eq!(ours("a {b: \"q\" - 1}\n"), "a {\n b: \"q\"-1;\n}\n");
assert_eq!(ours("a {b: 1 - \"q\"}\n"), "a {\n b: 1-\"q\";\n}\n");
assert_eq!(ours("a {b: red - foo}\n"), "a {\n b: red-foo;\n}\n");
// Two numbers still subtract numerically.
assert_eq!(ours("a {b: 10 - 20}\n"), "a {\n b: -10;\n}\n");
assert_eq!(ours("a {b: 1px - 2px}\n"), "a {\n b: -1px;\n}\n");
}
#[test]
fn single_equals_ms_filter_operator_in_args() {
// A lone `=` inside a function argument list is the lowest-precedence
// Microsoft-filter operator: both sides are evaluated and joined with `=`
// (no spaces) into an unquoted string. Surrounding whitespace is dropped.
assert_eq!(ours("a {b: foo(a=b)}\n"), "a {\n b: foo(a=b);\n}\n");
assert_eq!(ours("a {b: foo(1=2)}\n"), "a {\n b: foo(1=2);\n}\n");
assert_eq!(ours("a {b: foo(a = b)}\n"), "a {\n b: foo(a=b);\n}\n");
assert_eq!(ours("a {b: foo(a=b=c)}\n"), "a {\n b: foo(a=b=c);\n}\n");
assert_eq!(ours("a {b: foo((1 + 2)=3)}\n"), "a {\n b: foo(3=3);\n}\n");
assert_eq!(ours("a {b: foo(a b = c d)}\n"), "a {\n b: foo(a b=c d);\n}\n");
assert_eq!(ours("a {b: foo(1 + 2 = 3 + 4)}\n"), "a {\n b: foo(3=7);\n}\n");
// `==` stays the equality operator inside arguments.
assert_eq!(ours("a {b: foo(1 == 1)}\n"), "a {\n b: foo(true);\n}\n");
}
#[test]
fn global_alpha_microsoft_filter_overload() {
// The global `alpha()` with unquoted `name=value` arguments is the
// proprietary IE filter overload: it passes through verbatim as a CSS
// function rather than being treated as a color accessor.
assert_eq!(
ours("a {b: alpha(opacity=80)}\n"),
"a {\n b: alpha(opacity=80);\n}\n"
);
assert_eq!(ours("a {b: alpha(c=d)}\n"), "a {\n b: alpha(c=d);\n}\n");
assert_eq!(
ours("a {b: alpha(c=d, e=f, g=h)}\n"),
"a {\n b: alpha(c=d, e=f, g=h);\n}\n"
);
// A real color argument still routes to the normal alpha accessor.
assert_eq!(ours("a {b: alpha(red)}\n"), "a {\n b: 1;\n}\n");
}
#[test]
fn unary_plus_and_minus_on_non_numbers() {
// Unary `-`/`+` negate / identity a number, but on any other operand they
// prepend the sign as an unquoted string (dart-sass `unaryMinus`/
// `unaryPlus`). Whitespace may separate the operator from its operand.
assert_eq!(ours("a {b: (- red)}\n"), "a {\n b: -red;\n}\n");
assert_eq!(ours("a {b: (+ red)}\n"), "a {\n b: +red;\n}\n");
assert_eq!(ours("a {b: +hello}\n"), "a {\n b: +hello;\n}\n");
assert_eq!(ours("a {b: + hello}\n"), "a {\n b: +hello;\n}\n");
assert_eq!(ours("a {b: (+- red)}\n"), "a {\n b: +-red;\n}\n");
assert_eq!(ours("a {b: (- \"q\")}\n"), "a {\n b: -\"q\";\n}\n");
assert_eq!(ours("a {b: (+ \"q\")}\n"), "a {\n b: +\"q\";\n}\n");
// Numbers keep arithmetic semantics.
assert_eq!(ours("a {b: +10}\n"), "a {\n b: 10;\n}\n");
assert_eq!(ours("a {b: (- 5)}\n"), "a {\n b: -5;\n}\n");
assert_eq!(ours("a {b: -10px + 10px}\n"), "a {\n b: 0px;\n}\n");
// A `-` glued to an identifier stays part of the identifier.
assert_eq!(ours("a {b: -webkit-box}\n"), "a {\n b: -webkit-box;\n}\n");
}
#[test]
fn number_rounds_half_away_from_zero_at_tenth_place() {
// dart-sass rounds to 10 decimal places half away from zero, not half to
// even. `1.5e-10` -> `0.0000000002`, `0.99999999995` -> `1`, and
// `0.30000000005` -> `0.3000000001`. Verified byte-for-byte against
// `npx sass --no-source-map --stdin`.
assert_eq!(ours("a {b: 0.00000000015}\n"), "a {\n b: 0.0000000002;\n}\n");
assert_eq!(ours("a {b: 0.00000000035}\n"), "a {\n b: 0.0000000004;\n}\n");
assert_eq!(ours("a {b: 0.99999999995}\n"), "a {\n b: 1;\n}\n");
assert_eq!(ours("a {b: 1.99999999995}\n"), "a {\n b: 2;\n}\n");
assert_eq!(ours("a {b: 0.30000000005}\n"), "a {\n b: 0.3000000001;\n}\n");
// Values that already round-tripped correctly must stay unchanged.
assert_eq!(ours("a {b: 0.0000000001}\n"), "a {\n b: 0.0000000001;\n}\n");
assert_eq!(ours("a {b: 0.1}\n"), "a {\n b: 0.1;\n}\n");
assert_eq!(
ours("a {b: 123456789.12345678905}\n"),
"a {\n b: 123456789.12345679;\n}\n"
);
}
#[test]
fn opaque_hex_literals_preserve_authored_spelling() {
// dart-sass emits an opaque 3-/6-digit hex literal exactly as authored,
// keeping its length and case. The 4-/8-digit alpha forms, by contrast,
// are canonicalized. Verified byte-for-byte against
// `npx sass --no-source-map --stdin`.
assert_eq!(ours("a {b: #fff}\n"), "a {\n b: #fff;\n}\n");
assert_eq!(ours("a {b: #FFF}\n"), "a {\n b: #FFF;\n}\n");
assert_eq!(ours("a {b: #aaa}\n"), "a {\n b: #aaa;\n}\n");
assert_eq!(ours("a {b: #FFAA00}\n"), "a {\n b: #FFAA00;\n}\n");
assert_eq!(ours("a {b: #ABCABC}\n"), "a {\n b: #ABCABC;\n}\n");
assert_eq!(ours("a {b: #aaaaaa}\n"), "a {\n b: #aaaaaa;\n}\n");
// 4-/8-digit opaque alpha forms canonicalize to lowercase 6-digit hex.
assert_eq!(ours("a {b: #369f}\n"), "a {\n b: #336699;\n}\n");
assert_eq!(ours("a {b: #112233ff}\n"), "a {\n b: #112233;\n}\n");
// Compressed output ignores the authored spelling and shortens.
use sasso::OutputStyle;
let compressed = compile(
"a {b: #FFAA00}\n",
&Options::default().with_style(OutputStyle::Compressed),
)
.expect("compile failed");
assert_eq!(compressed, "a{b:#fa0}");
}
#[test]
fn selector_nest_and_append() {
// `selector-nest` joins selectors as descendants and resolves `&`; the
// result is a comma list of space lists rendered as the usual selector
// string. `selector-append` joins with no descendant combinator (the
// leading compound of each suffix merges onto the prefix's trailing one).
// All outputs byte-verified against dart-sass.
assert_eq!(ours("a {b: selector-nest(c, d)}\n"), "a {\n b: c d;\n}\n");
assert_eq!(
ours("a {b: selector-nest(\".a, .b\", \".c, .d\")}\n"),
"a {\n b: .a .c, .a .d, .b .c, .b .d;\n}\n"
);
assert_eq!(
ours("a {b: selector-nest(\".a .b\", \"&:hover\")}\n"),
"a {\n b: .a .b:hover;\n}\n"
);
assert_eq!(
ours("a {b: selector-nest(\".p1 .p2\", \".x & .y\")}\n"),
"a {\n b: .x .p1 .p2 .y;\n}\n"
);
assert_eq!(ours("a {b: selector-append(c, d)}\n"), "a {\n b: cd;\n}\n");
assert_eq!(
ours("a {b: selector-append(\".a .b\", \".c .d\")}\n"),
"a {\n b: .a .b.c .d;\n}\n"
);
assert_eq!(
ours("a {b: selector-append(\".a, .b\", \".c .d, .e\")}\n"),
"a {\n b: .a.c .d, .a.e, .b.c .d, .b.e;\n}\n"
);
}
#[test]
fn selector_extend_and_replace() {
// `selector-extend` adds the extender wherever the (compound) extendee
// matches, keeping the original; `selector-replace` drops the matched
// original. Compound extendees (`.a.b`) match a compound only when all
// their simples are present. All outputs byte-verified against dart-sass.
assert_eq!(ours("a {b: selector-extend(c, c, d)}\n"), "a {\n b: c, d;\n}\n");
assert_eq!(
ours("a {b: selector-extend(\".a .b\", \".b\", \".c\")}\n"),
"a {\n b: .a .b, .a .c;\n}\n"
);
assert_eq!(
ours("a {b: selector-extend(\".a.b .c\", \".a.b\", \".x\")}\n"),
"a {\n b: .a.b .c, .x .c;\n}\n"
);
// `.a .c` does NOT match the compound `.a.b`, so it is unchanged.
assert_eq!(
ours("a {b: selector-extend(\".a .c\", \".a.b\", \".x\")}\n"),
"a {\n b: .a .c;\n}\n"
);
assert_eq!(ours("a {b: selector-replace(c, c, d)}\n"), "a {\n b: d;\n}\n");
assert_eq!(
ours("a {b: selector-replace(\".a.b.c\", \".a.b\", \".x\")}\n"),
"a {\n b: .c.x;\n}\n"
);
assert_eq!(
ours("a {b: selector-replace(\".x\", \".c\", \".d\")}\n"),
"a {\n b: .x;\n}\n"
);
}
#[test]
fn selector_unify_superselector_simple_and_parse() {
// `selector-unify` yields the selectors matching both inputs (or `null`
// when nothing unifies — the declaration is then dropped). `is-superselector`
// tests selector-list containment. `simple-selectors` splits one compound
// into its simples; `selector-parse` round-trips a selector string. All
// outputs byte-verified against dart-sass.
assert_eq!(
ours("a {b: selector-unify(\".c\", \".d\")}\n"),
"a {\n b: .c.d;\n}\n"
);
assert_eq!(
ours("a {b: selector-unify(\".a .b\", \".c .d\")}\n"),
"a {\n b: .a .c .b.d, .c .a .b.d;\n}\n"
);
// Incompatible type selectors don't unify; `null` drops the declaration,
// leaving the rule empty and therefore omitted entirely (as dart-sass does).
assert_eq!(ours("a {b: selector-unify(a, b)}\n"), "");
assert_eq!(ours("a {b: is-superselector(c, d)}\n"), "a {\n b: false;\n}\n");
assert_eq!(
ours("a {b: is-superselector(\".a\", \".a.b\")}\n"),
"a {\n b: true;\n}\n"
);
assert_eq!(
ours("a {b: simple-selectors(\".c.d\")}\n"),
"a {\n b: .c, .d;\n}\n"
);
assert_eq!(
ours("a {b: simple-selectors(\"a.b.c:hover\")}\n"),
"a {\n b: a, .b, .c, :hover;\n}\n"
);
assert_eq!(
ours("a {b: selector-parse(\".c, .d\")}\n"),
"a {\n b: .c, .d;\n}\n"
);
assert_eq!(
ours("a {b: selector-parse(\".a > .b, .c + .d\")}\n"),
"a {\n b: .a > .b, .c + .d;\n}\n"
);
}
#[test]
fn function_exists_recognizes_builtins() {
// `function-exists` reports `true` for a built-in function and `false`
// for an unknown name (dart-sass `function-exists`).
assert_eq!(ours("a {b: function-exists(rgb)}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: function-exists(\"rgb\")}\n"), "a {\n b: true;\n}\n");
assert_eq!(ours("a {b: function-exists(c)}\n"), "a {\n b: false;\n}\n");
// Arity and type validation matches dart-sass.
assert!(compile("a {b: function-exists()}\n", &Options::default()).is_err());
assert!(compile("a {b: function-exists(a, b, c)}\n", &Options::default()).is_err());
assert!(compile("a {b: function-exists(2px)}\n", &Options::default()).is_err());
}
#[test]
fn get_function_validates_arity_and_type() {
// `get-function` raises dart-sass's arity / type errors before resolution.
assert!(compile("a {b: get-function()}\n", &Options::default()).is_err());
assert!(compile("a {b: get-function(c, true, d, e)}\n", &Options::default()).is_err());
assert!(compile("a {b: get-function(2px)}\n", &Options::default()).is_err());
// A well-formed call now yields a first-class function reference. Used
// directly as a declaration value it is not a valid CSS value (dart-sass
// `get-function("rgb") isn't a valid CSS value.`); it is meant to be
// invoked via `call()`.
assert!(compile("a {b: get-function(rgb)}\n", &Options::default()).is_err());
assert_eq!(
ours("a {b: call(get-function(rgb), 1, 2, 3)}\n"),
"a {\n b: rgb(1, 2, 3);\n}\n"
);
}
#[test]
fn nested_property_sets() {
// A bare property set (`prop: { … }`) namespaces each child as
// `prop-<child>`; an empty value needs no whitespace after the colon.
assert_eq!(ours("a { b: { c: d } }\n"), "a {\n b-c: d;\n}\n");
assert_eq!(ours("a { b:{ c: d } }\n"), "a {\n b-c: d;\n}\n");
assert_eq!(ours("a { b: { -c: d } }\n"), "a {\n b--c: d;\n}\n");
// The value-plus-block form (`prop: value { … }`) emits the value first,
// then the namespaced children — but only when whitespace follows the
// colon; `b:c { … }` is a style rule, not a property set.
assert_eq!(ours("a { b: c { d: e } }\n"), "a {\n b: c;\n b-d: e;\n}\n");
assert_eq!(ours("a { b:c { d: e } }\n"), "a b:c {\n d: e;\n}\n");
// Property sets nest, joining each level with `-`, and interleave with a
// following sibling separated by `;`.
assert_eq!(
ours("a { b: { c: { d: e }; f: g } }\n"),
"a {\n b-c-d: e;\n b-f: g;\n}\n"
);
// A custom-property child (literal `--`) may not be nested.
assert!(compile("a { b: { --d: e } }\n", &Options::default()).is_err());
}
#[test]
fn custom_property_declarations() {
// A literal `--` name takes a verbatim value: SassScript is not evaluated,
// only `#{…}` interpolation resolves.
assert_eq!(ours("a { --x: 1 + 2; }\n"), "a {\n --x: 1 + 2;\n}\n");
assert_eq!(ours("a { --x: #{1 + 2}; }\n"), "a {\n --x: 3;\n}\n");
// Interpolation resolves even inside a quoted string in the value.
assert_eq!(ours("a { --x: \"c#{1 + 2}d\"; }\n"), "a {\n --x: \"c3d\";\n}\n");
// A partially interpolated name beginning literally with `--` keeps the
// raw value, while a fully/initially interpolated name is real SassScript.
assert_eq!(
ours("a { --#{only}-name: 1 + 2; }\n"),
"a {\n --only-name: 1 + 2;\n}\n"
);
assert_eq!(ours("a { #{--entire}: 1 + 2; }\n"), "a {\n --entire: 3;\n}\n");
// `!important` in a custom-property value is a literal value character.
assert_eq!(
ours("a { --x: value !important; }\n"),
"a {\n --x: value !important;\n}\n"
);
}
#[test]
fn var_env_argument_evaluation() {
// `var()`/`env()` are plain CSS functions whose arguments are real
// SassScript: the fallback evaluates, whitespace normalises, and a
// `var($args...)` splat expands — matching dart-sass byte for byte.
assert_eq!(ours("a {b: var()}\n"), "a {\n b: var();\n}\n");
assert_eq!(ours("a {b: var(--c)}\n"), "a {\n b: var(--c);\n}\n");
// The fallback argument is evaluated.
assert_eq!(ours("a {b: var(--c, 1 + 2)}\n"), "a {\n b: var(--c, 3);\n}\n");
assert_eq!(
ours("a {b: var(--c, \"d\" + \"e\")}\n"),
"a {\n b: var(--c, \"de\");\n}\n"
);
// Surrounding whitespace around the trailing comma normalises.
assert_eq!(ours("a {b: var(--c , )}\n"), "a {\n b: var(--c, );\n}\n");
assert_eq!(ours("a {b: var(--c ,)}\n"), "a {\n b: var(--c, );\n}\n");
// `allowEmptySecondArg`: a trailing comma after exactly the first argument
// keeps an empty second argument — but only for the name `var`
// (case-insensitively), never for `env`.
assert_eq!(ours("a {b: var(--c,)}\n"), "a {\n b: var(--c, );\n}\n");
assert_eq!(ours("a {b: VaR(--c,)}\n"), "a {\n b: VaR(--c, );\n}\n");
assert_eq!(ours("a {b: env(--c, )}\n"), "a {\n b: env(--c);\n}\n");
// A trailing comma after the *second* argument is an ordinary ignorable
// trailing comma (no empty arg added).
assert_eq!(ours("a {b: var(--c, d,)}\n"), "a {\n b: var(--c, d);\n}\n");
// A parenthesised comma list spreads its items as separate arguments.
assert_eq!(
ours("a {b: var(--c, (1, 2))}\n"),
"a {\n b: var(--c, 1, 2);\n}\n"
);
// `var($args...)` splat expands list/single rest arguments.
assert_eq!(
ours("$name: --c; a {b: var($name...)}\n"),
"a {\n b: var(--c);\n}\n"
);
assert_eq!(
ours("$args: --c, d; a {b: var($args...)}\n"),
"a {\n b: var(--c, d);\n}\n"
);
// A trailing comma after a rest argument is normal (no empty arg).
assert_eq!(ours("$n: --c; a {b: var($n...,)}\n"), "a {\n b: var(--c);\n}\n");
}
#[test]
fn calc_nested_parenthesization() {
// A nested calc() whose interior is an unresolved interpolation that is
// not a clean single token is parenthesized when spliced into the
// surrounding calculation (whitespace / `*` / `/` are ambiguous).
assert_eq!(
ours("a {b: calc(calc(#{\"c*\"}))}\n"),
"a {\n b: calc((c*));\n}\n"
);
assert_eq!(
ours("a {b: calc(calc(#{\"c/\"}))}\n"),
"a {\n b: calc((c/));\n}\n"
);
assert_eq!(
ours("a {b: calc(calc(#{\"c \"}))}\n"),
"a {\n b: calc((c ));\n}\n"
);
// A `var()` substitution inside a nested calc is always grouped.
assert_eq!(
ours("a {b: calc(1 + calc(var(--c)))}\n"),
"a {\n b: calc(1 + (var(--c)));\n}\n"
);
// A clean identifier, a hyphenated token, a number, an operation, and a
// complete sub-calculation all flatten without extra parentheses.
assert_eq!(ours("a {b: calc(calc(#{c}))}\n"), "a {\n b: calc(c);\n}\n");
assert_eq!(ours("a {b: calc(calc(c-d))}\n"), "a {\n b: calc(c-d);\n}\n");
assert_eq!(ours("a {b: calc(calc(c + d))}\n"), "a {\n b: calc(c + d);\n}\n");
assert_eq!(
ours("a {b: calc(1 + calc(min(1%, 2px)))}\n"),
"a {\n b: calc(1 + min(1%, 2px));\n}\n"
);
// A top-level (non-nested) calc keeps its own interpolation bare.
assert_eq!(ours("a {b: calc(#{\"c*\"})}\n"), "a {\n b: calc(c*);\n}\n");
}
#[test]
fn adjacent_quoted_string_schema() {
// A quoted string that abuts an adjacent atom with no whitespace forms an
// implicit space-separated list — including same-type quotes nested inside
// interpolation, which dart-sass parses as a "string schema".
assert_eq!(
ours("a {b: \"[\"'foo'\"]\"}\n"),
"a {\n b: \"[\" \"foo\" \"]\";\n}\n"
);
assert_eq!(ours("a {b: \"x\"\"y\"}\n"), "a {\n b: \"x\" \"y\";\n}\n");
// A string followed by, or preceded by, a non-string atom also lists.
assert_eq!(ours("a {b: \"x\"foo}\n"), "a {\n b: \"x\" foo;\n}\n");
assert_eq!(ours("a {b: foo\"x\"}\n"), "a {\n b: foo \"x\";\n}\n");
assert_eq!(ours("a {b: 1\"x\"}\n"), "a {\n b: 1 \"x\";\n}\n");
assert_eq!(
ours("a {b: gamme \"x\"delta}\n"),
"a {\n b: gamme \"x\" delta;\n}\n"
);
// A same-type quote nested inside interpolation: the inner string schema
// resolves and re-quotes around the surrounding string.
assert_eq!(
ours("a {b: \"[#{\"[\"'foo'\"]\"}]\"}\n"),
"a {\n b: \"[[ foo ]]\";\n}\n"
);
assert_eq!(ours("a {b: #{\"[\"'foo'\"]\"}}\n"), "a {\n b: [ foo ];\n}\n");
// Adjacency must not swallow a map key-value colon: a quoted-string map
// key still parses (`("a": 1)`).
assert_eq!(
ours("$m: (\"a\": 1); a {b: map-get($m, \"a\")}\n"),
"a {\n b: 1;\n}\n"
);
}
#[test]
fn supports_condition_serialization() {
// A declaration condition normalizes spacing (`(a:b)` -> `(a: b)`) and
// evaluates SassScript on both sides.
assert_eq!(ours("@supports (a:b) {@c}\n"), "@supports (a: b) {\n @c;\n}\n");
assert_eq!(
ours("@supports (1 + 1: b) {@c}\n"),
"@supports (2: b) {\n @c;\n}\n"
);
assert_eq!(
ours("@supports (a: 1 + 1) {@c}\n"),
"@supports (a: 2) {\n @c;\n}\n"
);
// Redundant nested parentheses around a declaration collapse.
assert_eq!(
ours("@supports ((((a: b)))) {@c}\n"),
"@supports (a: b) {\n @c;\n}\n"
);
// `and`/`or`/`not` operators and grouping parentheses.
assert_eq!(
ours("@supports (a: b) and ((c: d) or (e: f)) {@g}\n"),
"@supports (a: b) and ((c: d) or (e: f)) {\n @g;\n}\n"
);
assert_eq!(
ours("@supports not (a: b) {@c}\n"),
"@supports not (a: b) {\n @c;\n}\n"
);
// A custom-property value keeps no space after the colon and stays verbatim.
assert_eq!(
ours("@supports (--a: b) {@c}\n"),
"@supports (--a: b) {\n @c;\n}\n"
);
// A `calc()` (and `min`/`clamp`/…) is kept unsimplified inside a `@supports`
// declaration, while a `#{…}` interpolation simplifies as usual.
assert_eq!(
ours("@supports (a: calc(1 + 2)) {@d}\n"),
"@supports (a: calc(1 + 2)) {\n @d;\n}\n"
);
assert_eq!(
ours("@supports (a: clamp(0, 1, 2)) {@d}\n"),
"@supports (a: clamp(0, 1, 2)) {\n @d;\n}\n"
);
assert_eq!(
ours("@supports (a: #{calc(1 + 2)}) {@d}\n"),
"@supports (a: 3) {\n @d;\n}\n"
);
// Trivia comments inside the condition are stripped; loud comments in an
// "anything" value are preserved.
assert_eq!(
ours("@supports (a /**/: b) {c {d: e}}\n"),
"@supports (a: b) {\n c {\n d: e;\n }\n}\n"
);
// A `supports()`-style function call and an arbitrary "anything" value pass
// through verbatim.
assert_eq!(ours("@supports a(b) {@c}\n"), "@supports a(b) {\n @c;\n}\n");
assert_eq!(ours("@supports (a b) {@c}\n"), "@supports (a b) {\n @c;\n}\n");
// A lone interpolation is spliced in unquoted.
assert_eq!(
ours("@supports #{\"(a: b)\"} and (c: 1 + 1) {@d}\n"),
"@supports (a: b) and (c: 2) {\n @d;\n}\n"
);
// An empty/placeholder-only body produces no output.
assert_eq!(ours("@supports (a: b) {}\n"), "");
assert_eq!(ours("@supports (a: b) { %c {d: e} }\n"), "");
// Malformed conditions are rejected.
assert!(compile("@supports a {@b}\n", &Options::default()).is_err());
assert!(compile("@supports (not a) {@b}\n", &Options::default()).is_err());
assert!(compile(
"@supports (a: b) and (c: d) or (e: f) {@g}\n",
&Options::default()
)
.is_err());
// Live parity for the same constructs.
assert_parity("@supports (a:b) {@c}\n");
assert_parity("@supports (a: calc(1 + 2)) and #{\"(c: d)\"} {x {y: z}}\n");
assert_parity("@supports (--a: b //\n ) {c {d: e}}\n");
}
#[test]
fn nested_rule_declaration_comment_bubbling_order() {
// A declaration or loud comment that FOLLOWS a nested rule must emit AFTER
// the bubbled-out nested rule, in source order: dart-sass splits the parent
// block around each bubbled child rather than hoisting one combined block.
// Loud comment between two nested rules.
assert_eq!(
ours("a {\n b {c: d}\n /* */\n e {f: g}\n}\n"),
"a b {\n c: d;\n}\na {\n /* */\n}\na e {\n f: g;\n}\n"
);
// Loud comment then a declaration, both after the nested rule.
assert_eq!(
ours("a {\n b {c: d}\n /* */\n e: f;\n}\n"),
"a b {\n c: d;\n}\na {\n /* */\n e: f;\n}\n"
);
// A trailing loud comment after the nested rule.
assert_eq!(
ours("a {\n b {c: d}\n /* */\n}\n"),
"a b {\n c: d;\n}\na {\n /* */\n}\n"
);
// A declaration BEFORE the nested rule stays in the leading block.
assert_eq!(
ours("a {\n x: y;\n b {c: d}\n}\n"),
"a {\n x: y;\n}\na b {\n c: d;\n}\n"
);
// Declarations on both sides of the nested rule split into two blocks.
assert_eq!(
ours("a {\n w: x;\n b {c: d}\n y: z;\n}\n"),
"a {\n w: x;\n}\na b {\n c: d;\n}\na {\n y: z;\n}\n"
);
assert_parity("a {\n b {c: d}\n /* */\n e {f: g}\n}\n");
assert_parity("a {\n w: x;\n b {c: d}\n y: z;\n}\n");
}
#[test]
fn adjacent_compound_selector_separation() {
// A bare type/element selector appearing mid-compound is a separate adjacent
// compound that dart-sass joins with a descendant combinator (`[a]b` ->
// `[a] b`). This only fires for an identifier-led type, not for `*`, classes,
// ids, attributes, or pseudos.
assert_eq!(ours("[a]b {c: d}\n"), "[a] b {\n c: d;\n}\n");
assert_eq!(ours("a[b]c {d: e}\n"), "a[b] c {\n d: e;\n}\n");
assert_eq!(ours(":not(.x)b {c: d}\n"), ":not(.x) b {\n c: d;\n}\n");
assert_eq!(ours(".x[a]b.c {d: e}\n"), ".x[a] b.c {\n d: e;\n}\n");
assert_eq!(ours("[a]ns|b {c: d}\n"), "[a] ns|b {\n c: d;\n}\n");
assert_eq!(ours("*b {c: d}\n"), "* b {\n c: d;\n}\n");
// No separation before `*`, classes, ids, attributes, pseudos.
assert_eq!(ours("[a]* {c: d}\n"), "[a]* {\n c: d;\n}\n");
assert_eq!(ours("[a].c {d: e}\n"), "[a].c {\n d: e;\n}\n");
assert_eq!(ours("[a]#c {d: e}\n"), "[a]#c {\n d: e;\n}\n");
assert_eq!(ours("[a][c] {d: e}\n"), "[a][c] {\n d: e;\n}\n");
assert_eq!(ours("[a]:hover {c: d}\n"), "[a]:hover {\n c: d;\n}\n");
// A keyframe stop like `1e2%` is NOT a selector and must stay verbatim.
assert_eq!(
ours("@keyframes a {\n 1e2% {c: d}\n}\n"),
"@keyframes a {\n 1e2% {\n c: d;\n }\n}\n"
);
assert_parity("[a]b {c: d}\n");
assert_parity(".x[a]b.c {d: e}\n");
assert_parity(":not(.x)b {c: d}\n");
}
#[test]
fn bogus_combinator_selectors_are_omitted() {
// Double combinators are always invalid CSS: the complex selector is omitted.
assert_eq!(ours("> > a {b: c}\n"), "");
assert_eq!(ours("+ ~ a {b: c}\n"), "");
assert_eq!(ours("a > + b {c: d}\n"), "");
assert_eq!(ours("a~>b {c: d}\n"), "");
assert_eq!(ours("a + ~ {b: c}\n"), "");
// A trailing combinator is valid only for nesting: the leaf block is dropped
// but the selector still serves as a parent for nested rules.
assert_eq!(ours("a > {b: c}\n"), "");
assert_eq!(ours("a + {b: c}\n"), "");
assert_eq!(ours("a > {b: c; d {e: f}}\n"), "a > d {\n e: f;\n}\n");
// A leading combinator at the top level is kept (nesting deprecation only).
assert_eq!(ours("> a {b: c}\n"), "> a {\n b: c;\n}\n");
// In a comma list, only the bogus complex selector is dropped.
assert_eq!(ours("a, > > b {x: y}\n"), "a {\n x: y;\n}\n");
// Selector pseudos: leading/trailing/double combinators inside `:is()` etc.
// are bogus; a valid interior selector is kept.
assert_eq!(ours(":is(> a) {b: c}\n"), "");
assert_eq!(ours(":is(a >) {b: c}\n"), "");
assert_eq!(ours(":is(a > + b) {c: d}\n"), "");
assert_eq!(ours(":is(a > b) {c: d}\n"), ":is(a > b) {\n c: d;\n}\n");
assert_eq!(ours(":not(a >) {b: c}\n"), "");
// `:has()` is a relative selector list: a single leading combinator is OK,
// but double/trailing combinators are still bogus.
assert_eq!(ours(":has(> a) {b: c}\n"), ":has(> a) {\n b: c;\n}\n");
assert_eq!(ours(":has(+ a) {b: c}\n"), ":has(+ a) {\n b: c;\n}\n");
assert_eq!(ours(":has(+ ~ a) {b: c}\n"), "");
assert_eq!(ours(":has(a >) {b: c}\n"), "");
// `:global`/`:local` keep their argument verbatim (not selector-parsed).
assert_eq!(ours(":global(> a) {b: c}\n"), ":global(> a) {\n b: c;\n}\n");
assert_eq!(ours(":local(> a) {b: c}\n"), ":local(> a) {\n b: c;\n}\n");
assert_parity("> > a {b: c}\n");
assert_parity("a > {b: c; d {e: f}}\n");
assert_parity(":is(> a) {b: c}\n");
assert_parity(":has(> a) {b: c}\n");
assert_parity(":global(> a) {b: c}\n");
assert_parity("a, > > b {x: y}\n");
}
#[test]
fn top_level_parent_selector_is_literal() {
// At the document root (no enclosing style rule) a parent selector `&` has
// no parent to substitute, so dart-sass keeps it literal rather than
// dropping it.
assert_eq!(ours("& {a: b}\n"), "& {\n a: b;\n}\n");
assert_eq!(ours("&.foo {a: b}\n"), "&.foo {\n a: b;\n}\n");
assert_eq!(ours("& .foo {a: b}\n"), "& .foo {\n a: b;\n}\n");
assert_eq!(ours("a & {b: c}\n"), "a & {\n b: c;\n}\n");
assert_eq!(ours("& & {a: b}\n"), "& & {\n a: b;\n}\n");
// The same holds inside a bare unknown at-rule (no selector context).
assert_eq!(ours("@a {\n & {b: c}\n}\n"), "@a {\n & {\n b: c;\n }\n}\n");
// A `&` with a suffix is still rejected at the top level.
assert!(compile("&foo {a: b}\n", &Options::default()).is_err());
assert!(compile("&-foo {a: b}\n", &Options::default()).is_err());
assert_parity("& {a: b}\n");
assert_parity("&.foo {a: b}\n");
assert_parity("@a {\n & {b: c}\n}\n");
}
#[test]
fn reference_combinator_is_rejected() {
// Reference combinators (`/foo/`) are no longer valid CSS: dart-sass rejects
// any top-level `/` in a selector with "expected selector.".
assert!(compile(".foo /bar/ .baz {\n a: b;\n}\n", &Options::default()).is_err());
assert!(compile(".a/.b {x: y}\n", &Options::default()).is_err());
// A `/` inside an attribute value is fine.
assert_eq!(ours("a[href^=\"/\"] {x: y}\n"), "a[href^=\"/\"] {\n x: y;\n}\n");
}
#[test]
fn font_face_does_not_carry_parent_selector() {
// `@font-face` (exactly, unprefixed) holds plain declarations: dart-sass does
// NOT carry the enclosing style-rule selector into its body, unlike `@page`,
// `@-moz-font-face`, or an unknown directive.
assert_eq!(
ours("a {\n b: c;\n @font-face { d: e }\n}\n"),
"a {\n b: c;\n}\n@font-face {\n d: e;\n}\n"
);
assert_eq!(
ours("a { b { c { @font-face { e: f } g: h; } } }\n"),
"@font-face {\n e: f;\n}\na b c {\n g: h;\n}\n"
);
// `@page` and unknown directives DO carry the parent selector into their
// (bubbled-out) body.
assert_eq!(
ours("a {\n @page { d: e }\n}\n"),
"@page {\n a {\n d: e;\n }\n}\n"
);
assert_eq!(
ours("a {\n @foo { d: e }\n}\n"),
"@foo {\n a {\n d: e;\n }\n}\n"
);
assert_parity("a {\n b: c;\n @font-face { d: e }\n}\n");
assert_parity("a { b { c { @font-face { e: f } g: h; } } }\n");
assert_parity("a {\n @page { d: e }\n}\n");
}
#[test]
fn childless_at_rule_stays_in_rule_block() {
// A childless at-rule (`@e f;`) inside a style rule stays in the parent
// block, interleaved with declarations in source order, rather than bubbling
// out to the document root (unlike a block at-rule).
assert_eq!(ours("a {\n @b c;\n}\n"), "a {\n @b c;\n}\n");
assert_eq!(
ours("a {\n b {c: d}\n @e f;\n g: h\n}\n"),
"a b {\n c: d;\n}\na {\n @e f;\n g: h;\n}\n"
);
assert_eq!(
ours("a {\n b {c: d}\n @e f;\n g {h: i}\n}\n"),
"a b {\n c: d;\n}\na {\n @e f;\n}\na g {\n h: i;\n}\n"
);
// A childless `@charset` is stripped at the top level but kept inside a rule.
assert_eq!(ours("@charset \"utf-8\";\na {b: c}\n"), "a {\n b: c;\n}\n");
assert_eq!(
ours("a {\n @charset \"x\";\n b: c;\n}\n"),
"a {\n @charset \"x\";\n b: c;\n}\n"
);
assert_parity("a {\n @b c;\n}\n");
assert_parity("a {\n b {c: d}\n @e f;\n g {h: i}\n}\n");
assert_parity("a {\n @charset \"x\";\n b: c;\n}\n");
}
#[test]
fn extend_combinator_weave() {
// The @extend engine must weave extenders that contain combinators
// (`>`, `+`, `~`) rather than falling back to plain concatenation, matching
// dart-sass's `_weaveParents` / `_mergeTrailingCombinators` algorithm.
// Two following-sibling combinators interleave in all orderings, plus the
// unified compound.
assert_eq!(
ours(".a ~ x {a: b}\n.b ~ y {@extend x}\n"),
".a ~ x, .a ~ .b ~ y, .b ~ .a ~ y, .a.b ~ y {\n a: b;\n}\n"
);
// `~` extending a `+` target: sibling/next-sibling merge.
assert_eq!(
ours(".a + x {a: b}\n.b ~ y {@extend x}\n"),
".a + x, .b ~ .a + y, .b.a + y {\n a: b;\n}\n"
);
// A `+` target with a `.a.b ~` extender yields both the woven and the
// merged-compound branch (a regression guard for trailing-combinator
// superselector/trim handling).
assert_eq!(
ours(".a + x {a: b}\n.a.b ~ y {@extend x}\n"),
".a + x, .a.b ~ .a + y, .a.b + y {\n a: b;\n}\n"
);
// Child combinator: the sibling extender is woven after the child.
assert_eq!(
ours(".a > x {a: b}\n.b ~ y {@extend x}\n"),
".a > x, .a > .b ~ y {\n a: b;\n}\n"
);
// Two child combinators in extendee and extender unify their compounds.
assert_eq!(
ours(".a > .b + x {a: b}\n.c > .d + y {@extend x}\n"),
".a > .b + x, .a.c > .b.d + y {\n a: b;\n}\n"
);
// A nested extender with a child selector weaves around the child.
assert_eq!(
ours(".baz .foo {a: b}\nfoo > bar {@extend .foo}\n"),
".baz .foo, .baz foo > bar {\n a: b;\n}\n"
);
// A multi-component extender is woven into the descendant context of the
// matched compound (dart-sass `_unifyExtenders`/`unifyComplex`).
assert_eq!(
ours(".a .b {@extend .e}\n.e .x {x: y}\n"),
".e .x, .a .b .x {\n x: y;\n}\n"
);
// A leading-combinator "child selector hack" (`> .foo`) is preserved through
// extension, including a multi-component extender that keeps its combinator.
assert_eq!(
ours("> .foo {a: b}\nfoo > bar {@extend .foo}\n"),
"> .foo, > foo > bar {\n a: b;\n}\n"
);
// Live parity for the same constructs.
assert_parity(".a ~ x {a: b}\n.b ~ y {@extend x}\n");
assert_parity(".a + x {a: b}\n.a.b ~ y {@extend x}\n");
assert_parity(".a > .b + x {a: b}\n.c > .d + y {@extend x}\n");
assert_parity("a + b c .c1 {a: b}\na c .c2 {@extend .c1}\n");
assert_parity(".a .b {@extend .e}\n.e .x {x: y}\n");
assert_parity("> .foo {a: b}\nfoo > bar {@extend .foo}\n");
}
#[test]
fn extend_pseudo_element_superselector() {
// A compound extender that contains a pseudo-element must NOT be trimmed away
// by its pseudo-element-free sibling (a pseudo-element changes the target of
// a compound), and the extender's other simples are themselves extended
// transitively.
assert_eq!(
ours("%x#bar {a: b}\n%y, %y::fblthp {@extend %x}\nz {@extend %y}\n"),
"z#bar, z#bar::fblthp {\n a: b;\n}\n"
);
assert_eq!(
ours("%x#bar {a: b}\n%y, %y:first-line {@extend %x}\nz {@extend %y}\n"),
"z#bar, z#bar:first-line {\n a: b;\n}\n"
);
assert_parity("%x#bar {a: b}\n%y, %y::fblthp {@extend %x}\nz {@extend %y}\n");
assert_parity("%x#bar {a: b}\n%y, %y:before {@extend %x}\nz {@extend %y}\n");
}
#[test]
fn extend_self_referential_pseudo_converges() {
// issue_2055: a `:not(...)`/`:has(...)` extender whose argument names the
// extended target (`.thing`) is self-referential. dart-sass derives the
// exact bounded nesting via `addSelector`'s pre-extension plus the
// self-inclusive `_extendExistingExtensions` snapshot; sasso reproduces it
// (`pseudo_arg_has_target_deep` gating + single registration-order pass).
// Byte-locked to dart-sass 1.100.0 so the convergence can't silently drift.
let input = r#"
:not(.thing) {
color: red;
}
:not(.thing[disabled]) {
@extend .thing;
background: blue;
}
:has(:not(.thing[disabled])) {
@extend .thing;
background: blue;
}
"#;
let expected = r#":not(.thing):not(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))) {
color: red;
}
:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))):not([disabled]:has(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))):not([disabled]:has(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))))))) {
background: blue;
}
:has(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))):not([disabled]:has(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))):not([disabled]:has(:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled])))):not([disabled]:not(.thing[disabled]):not([disabled]:has(:not(.thing[disabled]):not([disabled]:not(.thing[disabled]))))))))) {
background: blue;
}
"#;
assert_eq!(ours(input), expected);
}
#[test]
fn fs_importer_partial_extension_and_import_only_resolution() {
use std::fs;
use std::path::PathBuf;
// A unique scratch dir under the OS temp directory.
let dir = std::env::temp_dir().join(format!(
"sasso-import-resolve-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&dir).expect("create scratch dir");
let imp = sasso::FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let write = |name: &str, body: &str| fs::write(dir.join(name), body).expect("write fixture");
let rm = |name: &str| {
let _ = fs::remove_file(dir.join(name));
};
// An import-only file (`other.import.scss`) takes precedence over the
// normal partial (`_other.scss`), matching dart-sass's load order.
write("other.import.scss", "a {import-only: true}\n");
write("_other.scss", "a {import-only: false}\n");
assert_eq!(
compile("@import \"other\";\n", &opts).expect("import-only resolution"),
"a {\n import-only: true;\n}\n"
);
// A real-world import-only file re-exports with `@forward`, which this
// build can't inline; resolution falls back to the normal file instead of
// failing on the unsupported `@forward`.
write("other.import.scss", "@forward \"other\";\n");
write("_other.scss", "b {c: fallback}\n");
assert_eq!(
compile("@import \"other\";\n", &opts).expect("fallback past @forward import-only"),
"b {\n c: fallback;\n}\n"
);
// Two candidates at the same precedence tier (non-partial + partial) are
// ambiguous, so the import fails rather than silently picking one.
rm("other.import.scss");
write("other.scss", "x {y: nonpartial}\n");
write("_other.scss", "x {y: partial}\n");
assert!(compile("@import \"other\";\n", &opts).is_err());
// Cleanup (best effort).
let _: Result<(), _> = fs::remove_dir_all(PathBuf::from(&dir));
}
#[test]
fn parity_calc_math_function_simplification() {
// The math calculations evaluate their arguments as calculations: a
// fully-numeric argument computes, but an argument still carrying a
// `var()` keeps the whole call as a preserved calculation with its numeric
// subtree folded.
assert_parity(concat!(
"a {\n",
" a1: sqrt(2);\n",
" a2: sin(1deg);\n",
" a3: pow(2, 3);\n",
" a4: hypot(3, 4);\n",
" b1: sqrt(1px + 2px - var(--c));\n",
" b2: sin(3px - 1px + var(--c));\n",
" b3: sin(var(--c));\n",
" b4: pow(3px - 1px + var(--c), 4px + 10px);\n",
" b5: log(3px - 1px + var(--c), var(--e));\n",
"}\n",
));
}
#[test]
fn parity_calc_size() {
// `calc-size()` keeps its sizing target verbatim and evaluates its value
// as a calculation, folding the numeric subtree and lower-casing the name.
assert_parity(concat!(
"a {\n",
" c1: calc-size(var(--foo));\n",
" c2: calc-size(auto, 5% - 20px + size);\n",
" c3: calc-size(auto, 100px - 20px + size);\n",
" c4: CaLc-size(auto, size - 20px);\n",
"}\n",
));
}
#[test]
fn parity_calc_complex_unit_in_sum_errors() {
// A `+`/`-` operand that resolves to a number with complex units — a
// compound unit (`1px * 1px`) or an inverse unit (`1 / 1px`) — is rejected
// ("Number calc(...) isn't compatible with CSS calculations."), while a
// standalone compound/inverse calculation and a `var()`-bearing product
// stay preserved.
for src in [
"a {b: calc(1px + 1px*1px)}\n",
"a {b: calc(1px + 1/1px)}\n",
"a {b: calc(1 + 1/1px)}\n",
"a {b: calc(1% + 1s / 2px)}\n",
"a {b: calc(1px*1s + 1px*1px)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected complex-unit sum to error: {src}"
);
}
assert_parity(concat!(
"a {\n",
" k1: calc(1px * 1px);\n",
" k2: calc(1 / 1px);\n",
" k3: calc(1px + 2% * var(--c));\n",
" k4: calc(1px + 100% / var(--x));\n",
"}\n",
));
}
#[test]
fn parity_abs_calc_vs_global() {
// `abs()` preserves as the CSS calculation when its argument references a
// `var()` (folding the numeric subtree), but a plain-number argument keeps
// the legacy Sass global behaviour.
assert_parity(concat!(
"a {\n",
" c1: abs(1px + 2px - var(--c));\n",
" c2: abs(var(--c));\n",
" g1: abs(1 + 1px);\n",
" g2: abs(-3);\n",
" g3: abs(-3px);\n",
"}\n",
));
}
#[test]
fn parity_unary_on_unresolved_calculation_errors() {
// A unary `+`/`-` applied to a calculation that did not reduce to a number
// has no defined operation and is rejected, while negating a calculation
// that unwraps to a number still works.
assert!(compile("a {b: +calc(var(--c))}\n", &Options::default()).is_err());
assert!(compile("a {b: -(calc(var(--c)))}\n", &Options::default()).is_err());
assert_parity("a {b: -calc(1px)}\n");
}
#[test]
fn parity_calc_unary_operator() {
// Inside a calculation only a tight sign on a numeric literal is legal; a
// whitespace-separated or parenthesised/variable unary `+`/`-` is rejected.
for src in [
"a {b: calc(+ 1px)}\n",
"a {b: calc(- 1px)}\n",
"a {b: calc(1px + - 2px)}\n",
"a {b: calc(-(1px))}\n",
"a {b: calc(+(1px))}\n",
"a {b: calc(-(1 + 2))}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected calc unary operator to error: {src}"
);
}
assert_parity(concat!(
"a {\n",
" k1: calc(+1px);\n",
" k2: calc(-1px);\n",
" k3: calc(2 * +3);\n",
" k4: calc(2 + +3);\n",
" k5: calc(-var(--c));\n",
"}\n",
));
}
#[test]
fn parity_clamp_calculation() {
// A three-argument `clamp()` evaluates its operands as calculations: an
// operation/`var()` argument keeps the call preserved, all-number arguments
// clamp, and a complex-unit operand is rejected.
assert!(compile("a {b: clamp(1px*1px, 2%*2%, 3px*3px)}\n", &Options::default()).is_err());
assert!(compile("a {b: clamp(7 % 3, 2, 3)}\n", &Options::default()).is_err());
assert!(compile("a {b: clamp(1s, 2px, 3px)}\n", &Options::default()).is_err());
assert_parity(concat!(
"a {\n",
" m1: clamp(1% + 1px, 2px, 3px);\n",
" m2: clamp(1px, 1% + 2px, 3px);\n",
" m3: clamp(1px, 2px, 1% + 3px);\n",
" r1: clamp(1px, 2px, 3px);\n",
" r2: clamp(1px, 5px, 3px);\n",
" p1: clamp(1px, 2vw, 3px);\n",
"}\n",
));
}
#[test]
fn parity_extend_into_pseudo_arguments() {
// Extending a target buried inside a `:not()`/`:is()` selector-pseudo
// argument (dart-sass `_extendPseudo`): `:not()` with a single-complex arg
// splits into multiple `:not()`s merged into the compound, while matchish
// pseudos rewrite their argument list in place.
assert_parity(".a {@extend .c}\n:not(.c) {x: y}\n");
assert_parity(".a {@extend .c}\n.b {@extend .d}\n:not(.c):not(.d) {x: y}\n");
assert_parity(".a {@extend .c}\n:is(.c) {x: y}\n");
assert_parity(".a .b {@extend .c}\n:is(.c) {x: y}\n");
// `:not` drops complex extension results when the original arg had none.
assert_parity(".a .b {@extend .c}\n:not(.c) {x: y}\n");
// Regression test for sass/dart-sass#191: nested `:not(:not(...))` isn't
// expanded.
assert_parity(":not(:not(.x)) {a: b}\n:not(.y) {@extend .x}\n");
}
#[test]
fn parity_extend_graph_fixpoint() {
// An extender produced by one `@extend` is itself extended by another,
// transitively — including targets inside pseudo arguments (the
// extension-graph fixpoint). `:is(midstream)` extends `upstream`, and
// `midstream` (inside that `:is`) is then extended by `downstream`.
assert_parity(":is(midstream) {@extend upstream}\ndownstream {@extend midstream}\nupstream {a: b}\n");
// The result of `:not(.c)` being extended is itself extendable: `:not(.b)`
// (produced by extending `.c`) is a target extended by `.a`.
assert_parity(".a {@extend :not(.b)}\n.b {@extend .c}\n:not(.c) {x: y}\n");
// A self-extend must terminate: `.c` extends itself and a pseudo-target
// `:not(.c)` extends its own contained class.
assert_parity(".c, .a .b .c, .a .c .b {x: y; @extend .c}\n");
assert_parity(":not(.c) {@extend .c}\n.c {x: y}\n");
}
#[test]
fn parity_custom_property_whitespace() {
// A custom property emits its value verbatim after the colon: no space is
// inserted, leading/trailing whitespace is preserved, an inline whitespace
// run collapses to its last character, and a trailing newline becomes a
// single space. An empty value emits `--x:;`.
assert_parity(".a {\n --empty:;\n --interp:#{\"\"};\n --lead: value;\n --tight:value;\n --trail: c ;\n --collapse: 1 2\t\t3;\n --func: foo(bar);\n}\n");
assert_parity("a {\n --nl: c\n;\n}\n");
assert_parity("a {\n --brace: c\n}\n");
}
#[test]
fn parity_selector_escape_canonicalization() {
// dart-sass re-serializes selector identifier escapes: a leading digit
// becomes a hex escape with a trailing space, a non-leading digit drops its
// escape, and a numeric escape of a printable non-name char (`$`, `(`)
// becomes the escaped character itself.
assert_parity(".\\31u {a: b;}\n");
assert_parity(".a\\31u {a: b;}\n");
assert_parity(".a\\31 u {a: b;}\n");
assert_parity(".u\\24 {a: b;}\n");
assert_parity(".u\\$ {a: b;}\n");
assert_parity(".u#{'\\\\28'} { a: b; }\n");
assert_parity("\\64iv {a: b;}\n");
}
#[test]
fn parity_variable_scoping_semi_global() {
// A rule-scoped assignment to a variable that exists only globally creates a
// local instead of rewriting the global; a control-flow scope is
// semi-global so it updates an existing enclosing variable but cannot create
// a global from inside a rule.
assert_parity("$x: root;\ndiv { $x: local; v: $x; }\nafter { x: $x; }\n");
assert_parity("div {\n $x: 10;\n span { $x: 20; }\n v: $x;\n}\n");
assert_parity(
"$x: root;\ndiv {\n @for $i from 1 through 1 { $x: looped; }\n v: $x;\n}\nafter { x: $x; }\n",
);
assert_parity("$x: 0;\n@for $i from 1 through 3 { $x: $x + 1; }\nafter { x: $x; }\n");
assert_parity("div {\n $y: 1;\n @for $i from 1 through 3 { $y: $y + 5; y: $y; }\n after: $y;\n}\n");
}
#[test]
fn parity_unquoted_string_newline() {
// An unquoted string serializes a newline as a space, dropping a space that
// immediately follows it; inside a quoted string the same characters are
// re-escaped (`\a`) rather than collapsed.
assert_parity(".a {\n output: #{\"\\0_\\a_\\A\"};\n}\n");
assert_parity(".a {\n output: \"[#{\"\\0_\\a_\\A\"}]\";\n}\n");
assert_parity("a { x: foo#{\"b\\a c\"}; }\n");
}
#[test]
fn parity_use_builtin_modules() {
// `@use "sass:<mod>"` exposes the built-in module members under a namespace
// (default, `as ns`, and `as *`), reusing the existing global builtin
// implementations. Covers math functions/variables/`math.div`, string,
// list, map, color (legacy), and meta members.
assert_parity(
"@use \"sass:math\";\na {\n pi: math.$pi;\n e: math.$e;\n div: math.div(10, 3);\n divu: math.div(10px, 2);\n abs: math.abs(-5);\n pct: math.percentage(0.2);\n unit: math.unit(5px);\n iu: math.is-unitless(5);\n comp: math.compatible(1px, 1cm);\n}\n",
);
assert_parity(
"@use \"sass:math\" as m;\n@use \"sass:string\";\n@use \"sass:list\" as *;\na {\n s: string.length(\"abc\");\n up: string.to-upper-case(\"ab\");\n q: string.quote(foo);\n sep: separator(1 2 3);\n len: length(1 2 3);\n nth: nth((a b c), 2);\n round: m.round(1.6);\n}\n",
);
assert_parity(
"@use \"sass:map\";\n@use \"sass:color\";\n@use \"sass:meta\";\n$m: (a: 1, b: 2);\na {\n g: map.get($m, a);\n k: meta.inspect(map.keys($m));\n adj: color.adjust(#123456, $red: 10);\n mix: color.mix(red, blue);\n ie: color.ie-hex-str(#abcdef);\n tof: meta.type-of(5px);\n}\n",
);
}
#[test]
fn parity_colorspace_math() {
// CSS Color 4 color-space-aware colors: `color()` predefined spaces,
// lab/lch/oklab/oklch with real values (lightness as a percentage,
// clamping, `deg` hues), and the `sass:color` modern members
// `space`/`channel`/`to-space`/`is-legacy`/`is-missing`/`is-in-gamut`/
// `same`. All byte-matched to `npx sass`.
assert_parity(
"@use \"sass:color\";\na {\n c1: color(srgb 0.2 0.5 0.7);\n c2: color(srgb 1.1 -0.2 0.3);\n c3: color(srgb 10% 20% 30% / 0.5);\n c4: oklch(0.1 0.2 3deg);\n c5: oklch(1.1 0.2 3deg);\n c6: lab(50 40 30);\n c7: lch(50 40 270);\n c8: oklab(0.5 0.1 -0.05);\n c9: color(xyz 0.1 0.2 0.3);\n c10: color(xyz-d50 0.1 0.2 0.3);\n c11: color(display-p3-linear 0.5 0.6 0.7);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n o1: color.to-space(red, oklch);\n o2: color.to-space(red, lab);\n o3: color.to-space(red, display-p3);\n o4: color.to-space(red, xyz);\n o5: color.to-space(red, xyz-d50);\n o6: color.to-space(red, prophoto-rgb);\n o7: color.to-space(red, rec2020);\n o8: color.to-space(red, lch);\n o9: color.to-space(red, oklab);\n o10: color.to-space(red, srgb-linear);\n o11: color.to-space(red, hsl);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n s1: color.space(red);\n s2: color.space(oklch(0.5 0.1 90deg));\n ch1: color.channel(red, \"red\", $space: srgb);\n ch2: color.channel(color.to-space(red, oklch), \"lightness\");\n ch3: color.channel(oklch(0.5 0.1 90deg), \"hue\");\n leg1: color.is-legacy(red);\n leg2: color.is-legacy(oklch(0.5 0.1 90deg));\n miss: color.is-missing(color(srgb none 0.5 0.7), \"red\");\n gam1: color.is-in-gamut(color(srgb 1.5 0 0));\n gam2: color.is-in-gamut(red);\n same1: color.same(red, color.to-space(red, oklch));\n same2: color.same(red, blue);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n n1: oklch(none 0.2 3deg);\n n2: color(srgb none 0.5 0.7);\n n3: hsl(none 50% 40%);\n n4: hsl(120 none 40%);\n n5: rgb(none 100 100);\n n6: rgb(100 100 100 / none);\n n7: hwb(none 30% 40%);\n}\n",
);
}
#[test]
fn parity_color_mix_interpolation() {
// color.mix() with a CSS Color 4 $method interpolates in the named space
// with premultiplied alpha and the hue interpolation methods, returning a
// result in the first color's space. Byte-matched to `npx sass`.
assert_parity(
"@use \"sass:color\";\na {\n m1: color.mix(red, blue, 25%, oklch);\n m2: color.mix(red, blue, $method: lab);\n m3: color.mix(red, blue, 25%, srgb);\n m4: color.mix(red, green, $method: xyz);\n m5: color.mix(rgba(red, 0.5), blue, $method: srgb);\n m6: color.mix(oklch(0.5 0.1 90), oklch(0.7 0.2 200), $method: oklch);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n s: color.mix(oklch(0.5 0.1 30), oklch(0.5 0.1 190), $method: oklch shorter hue);\n l: color.mix(oklch(0.5 0.1 30), oklch(0.5 0.1 190), $method: oklch longer hue);\n i: color.mix(oklch(0.5 0.1 30), oklch(0.5 0.1 190), $method: oklch increasing hue);\n d: color.mix(oklch(0.5 0.1 30), oklch(0.5 0.1 190), $method: oklch decreasing hue);\n w: color.mix(red, green, 20%, lch longer hue);\n ci: color.mix(oklch(0.5 0.1 30), oklch(0.5 0.1 190), $method: oKlCh LONger HUE);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n pl: color.mix(lch(30% 0% 0deg), lch(50% 10% 120deg), $method: hsl);\n}\n",
);
}
#[test]
fn parity_color_modify_in_space() {
// color.change/adjust/scale with an explicit $space convert to that space,
// apply the per-channel operation (with adjust clamping lightness/chroma,
// scale moving toward the channel bound), and convert back to the color's
// original space. Byte-matched to `npx sass`.
assert_parity(
"@use \"sass:color\";\na {\n c1: color.change(red, $lightness: 50%, $space: oklch);\n c2: color.change(color(srgb 0.2 0.5 0.7), $red: 0.9, $space: srgb);\n c3: color.change(oklch(0.5 0.1 90), $lightness: 0.7);\n c4: color.change(red, $lightness: 50%);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n a1: color.adjust(red, $lightness: 0.1, $space: oklch);\n a2: color.adjust(oklch(0.8 0.1 90), $lightness: 0.5, $space: oklch);\n a3: color.adjust(oklch(0.5 0.1 90), $chroma: -0.5, $space: oklch);\n a4: color.adjust(lab(80 0 0), $lightness: 50, $space: lab);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n s1: color.scale(red, $lightness: 50%, $space: oklch);\n s2: color.scale(oklch(0.5 0.1 90), $chroma: 50%, $space: oklch);\n s3: color.scale(lab(50 40 30), $a: 50%, $space: lab);\n s4: color.scale(color(srgb 0.5 0.5 0.5), $red: 50%, $space: srgb);\n}\n",
);
}
#[test]
fn parity_color_invert_in_space() {
// color.invert($color, $weight, $space) inverts each channel in the named
// space (rgb/lightness toward max, lab/oklab a/b negate, hue +180, chroma
// unchanged, hwb whiteness/blackness swap), mixing toward the original by
// (1 - weight). Byte-matched to `npx sass`.
assert_parity(
"@use \"sass:color\";\na {\n s: color.invert(color(srgb 0.2 0.5 0.8), $space: srgb);\n lab: color.invert(lab(20% -30 110), $space: lab);\n lch: color.invert(lch(20% 80 50deg), $space: lch);\n hsl: color.invert(hsl(120 50% 40%), $space: hsl);\n hwb: color.invert(hwb(120 30% 40%), $space: hwb);\n w: color.invert(color(a98-rgb 0.1 0.4 0.8), 0%, $space: a98-rgb);\n legacy: color.invert(#123456);\n legacy_w: color.invert(#123456, 30%);\n}\n",
);
}
#[test]
fn parity_map_module_gaps() {
// `map.set`/`map.deep-merge`/`map.deep-remove` plus the nested key-path
// overloads of `map.get`/`map.has-key`/`map.merge`/`map.remove`, all
// serialized via `meta.inspect`. Also the empty-list separator of
// `map.keys(())`/`map.values(())` (comma, not space).
assert_parity(
"@use \"sass:meta\";\n@use \"sass:map\";\na {\n set: meta.inspect(map.set((c: (d: e)), c, f, g));\n setn: meta.inspect(map.set($map: (c: d), $key: c, $value: e));\n dm: meta.inspect(map.deep-merge((c: (d: e, f: g)), (c: (j: 1, f: 2))));\n dr: meta.inspect(map.deep-remove((c: (d: e, f: g, h: i)), c, f));\n}\n",
);
assert_parity(
"@use \"sass:meta\";\n@use \"sass:map\";\na {\n get: map.get((c: (d: (e: f))), c, d, e);\n getp: meta.inspect(map.get((c: (d: (e: f))), c, d));\n has: map.has-key((c: (d: (e: f))), c, d, e);\n hasn: map.has-key((c: (d: (e: f))), c, d, g);\n merge: meta.inspect(map.merge((c: 1), c, d, (e: f)));\n rem: meta.inspect(map.remove($map: (c: d, e: f), $key: c));\n}\n",
);
assert_parity(
"@use \"sass:list\";\n@use \"sass:map\";\na {\n sep: list.separator(map.keys(()));\n sepv: list.separator(map.values(()));\n}\n",
);
}
#[test]
fn parity_string_split_and_inspect_brackets() {
// `string.split` returns a bracketed comma list preserving the input's
// quotedness, with an optional split `$limit`; `meta.inspect` renders
// bracketed lists and map keys/values with the right parenthesization.
assert_parity(
"@use \"sass:string\";\na {\n s: string.split(\"a, b, c\", \", \");\n l: string.split(\"a, b, c, d\", \", \", 2);\n e: string.split(\"Helvetica\", \"\");\n u: string.split(abc, \"\");\n nf: string.split(\"a, b, c\", \"&\");\n}\n",
);
assert_parity(
"@use \"sass:meta\";\na {\n a: meta.inspect([1, 2, 3]);\n b: meta.inspect([]);\n c: meta.inspect([[1, 2] [3, 4]]);\n d: meta.inspect(((1, 2): 3, (4, 5): 6));\n e: meta.inspect((1: 2 3, 4: 5 6));\n}\n",
);
// A single-element list keeps its separator's trailing token: `(1,)` for a
// comma list and `(1/)` for a slash list; a space list is just `1`.
assert_parity(
"@use \"sass:list\";\n@use \"sass:meta\";\na {\n c: meta.inspect((1,));\n s: meta.inspect(list.append((), 1, slash));\n sp: meta.inspect(list.append((), 1, space));\n m: meta.inspect(list.join((1,), (2,), slash));\n}\n",
);
// A map key/value that is an unbracketed comma list is parenthesized for any
// length: an empty comma list (e.g. an empty arglist) becomes `(())`, while
// an empty undecided list `()` stays bare.
assert_parity(
"@use \"sass:list\";\n@use \"sass:meta\";\n@function args($a...) { @return $a; }\na {\n ea: meta.inspect((p: args()));\n el: meta.inspect((p: ()));\n ec: meta.inspect((p: list.join((), (), comma)));\n}\n",
);
}
#[test]
fn parity_meta_exists_predicates() {
// The `sass:meta` existence predicates resolve against the evaluator's
// scopes / definitions: variable-exists / global-variable-exists,
// mixin-exists, function-exists (user and built-in), and content-exists.
assert_parity(
"@use \"sass:meta\";\n$g: 1;\n@mixin gm() {}\n@function gf() { @return 1; }\na {\n $l: 2;\n v_local: meta.variable-exists(l);\n v_global: meta.variable-exists(g);\n v_none: meta.variable-exists(nope);\n gv_yes: meta.global-variable-exists(g);\n gv_no: meta.global-variable-exists(l);\n mx: meta.mixin-exists(gm);\n mx_no: meta.mixin-exists(nope);\n fn_user: meta.function-exists(gf);\n fn_builtin: meta.function-exists(rgb);\n fn_no: meta.function-exists(nope);\n}\n",
);
assert_parity(
"@use \"sass:meta\";\n@mixin outer() {\n @if meta.content-exists() { @content; }\n @else { had: none; }\n}\na { @include outer() { got: yes; } }\nb { @include outer(); }\n",
);
}
#[test]
fn parity_meta_first_class_functions() {
// `meta.get-function` returns a first-class reference (built-in, user, or
// plain-CSS), `meta.call` invokes it (positional / named / splat), and the
// reference inspects as `get-function("name")`, has type `function`, and
// compares by built-in name / user-definition identity.
assert_parity(
"@use \"sass:meta\";\n@function add-two($v) { @return $v + 2; }\n$u: meta.get-function(add-two);\n$b: meta.get-function(round);\n$c: meta.get-function(round, $css: true);\na {\n user: meta.call($u, 10);\n builtin: meta.call($b, 0.6);\n css: meta.call($c, 0.6);\n rgb_pos: meta.call(meta.get-function(\"rgb\"), 1, 2, 3);\n rgb_named: meta.call(meta.get-function(\"rgb\"), $blue: 1, $green: 2, $red: 3);\n insp: meta.inspect($b);\n tof: meta.type-of($b);\n}\n",
);
assert_parity(
"@use \"sass:meta\";\n@function ud() { @return null; }\na {\n eq_builtin: meta.get-function(lighten) == meta.get-function(lighten);\n ne_builtin: meta.get-function(lighten) == meta.get-function(darken);\n eq_user: meta.get-function(ud) == meta.get-function(ud);\n}\n",
);
}
#[test]
fn parity_list_join_append_brackets_and_maps() {
// `list.join` inherits list1's bracketing (overridable via `$bracketed`)
// and `list.append` keeps the source list's brackets; both treat a map as a
// comma list of `key value` entries.
assert_parity(
"@use \"sass:list\";\na {\n jb_both: list.join([c d], [e f]);\n jb_first: list.join([c d], e f);\n jb_second: list.join(c d, [e f]);\n jb_force_t: list.join(c, d, $bracketed: true);\n jb_force_f: list.join([c], [d], $bracketed: false);\n jb_pos: list.join(c, d, comma, true);\n jmap: list.join((c: d, e: f), (g: h));\n ap_b: list.append([], 1);\n ap_map: list.append((c: d, e: f), g);\n}\n",
);
}
#[test]
fn parity_math_numeric_module_members() {
// `math.clamp` / `math.min` / `math.max` / `math.round` are the numeric
// forms (unit-aware, error on non-numbers / incompatible units), distinct
// from the global CSS-calc functions; plus `math.log($x, null)` (natural
// log) and the `math.$min-number` subnormal constant.
assert_parity(
"@use \"sass:math\";\na {\n cl_num: math.clamp(0, 1, 2);\n cl_max: math.clamp(0, 2, 1);\n cl_inv: math.clamp(1, 2, 0);\n cl_unit: math.clamp(180deg, 1turn, 360deg);\n mn: math.min(3px, 1px, 2px);\n mn_conv: math.min(1cm, 5mm);\n mx: math.max(1, 2, 3);\n rnd: math.round(1.6);\n ln: math.log(2, null);\n}\n",
);
assert_parity("@use \"sass:math\";\na { b: math.$min-number * 1e300 * 1e39; }\n");
}
/// Compile `files["input.scss"]` with the on-disk module system (writing every
/// entry to a temp dir, resolving `@use`/`@forward` through `FsImporter`) and
/// assert byte-parity with dart-sass run on the same directory.
fn assert_module_parity(files: &[(&str, &str)]) {
if !enabled() {
return;
}
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let id = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("sasso-modtest-{}-{}", std::process::id(), id));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
for (name, content) in files {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create subdir");
}
std::fs::write(&path, content).expect("write module file");
}
let input = std::fs::read_to_string(dir.join("input.scss")).expect("read input");
let importer = FsImporter::new(vec![dir.clone()]);
let ours = compile(&input, &Options::default().with_importer(&importer)).expect("our compile failed");
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "npx".to_string());
let mut cmd = if bin == "npx" {
let mut c = Command::new("npx");
c.args(["--yes", "sass", "--no-source-map", "input.scss"]);
c
} else {
let mut c = Command::new(bin);
c.args(["--no-source-map", "input.scss"]);
c
};
let out = cmd
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.expect("run dart-sass");
let _ = std::fs::remove_dir_all(&dir);
if !out.status.success() {
eprintln!("skipping module parity case: dart-sass errored");
return;
}
let theirs = String::from_utf8(out.stdout).expect("utf8");
assert_eq!(ours, theirs, "\n--- input ---\n{input}\n");
}
#[test]
fn parity_plain_css_module() {
// A `.css` file loaded via `@use` is parsed in plain-CSS mode: nesting is
// preserved (not flattened), `&` stays literal, and declaration values are
// emitted verbatim (no SassScript). Sass functions like `rgb`/`grayscale`
// are kept as CSS, not evaluated.
assert_module_parity(&[
("input.scss", "@use \"plain\";\n"),
(
"plain.css",
"a {\n b {c: d}\n &.e {f: g}\n h: rgb(10, 20, 30);\n}\ni, j {k {l: m}}\n",
),
]);
// A plain CSS file never inlines `@import`; every form is emitted verbatim,
// including an `@import` nested inside a rule.
assert_module_parity(&[
("input.scss", "@use \"plain\";\n"),
(
"plain.css",
"@import \"whatever\";\n@import url(whatever);\n@import url(\"whatever\");\na {b: c}\nd {\n @import \"foo\";\n}\n",
),
]);
}
#[test]
fn parity_use_user_module() {
// `@use "file"` loads a user partial once, emits its CSS, and exposes its
// variables, functions, and mixins under the default namespace.
assert_module_parity(&[
(
"_other.scss",
"$color: red;\n@function double($x) { @return $x * 2; }\n@mixin box { border: 1px solid; }\n.from-other { content: \"o\"; }\n",
),
(
"input.scss",
"@use \"other\";\n.a {\n color: other.$color;\n width: other.double(5px);\n @include other.box;\n}\n",
),
]);
}
#[test]
fn parity_use_namespace_and_star() {
// `as ns` overrides the namespace; `as *` exposes members unprefixed.
assert_module_parity(&[
("_lib.scss", "$v: 7;\n@function f($x) { @return $x + 1; }\n"),
(
"input.scss",
"@use \"lib\" as l;\n@use \"lib\" as *;\n.a { x: l.$v; y: f(9); z: $v; }\n",
),
]);
}
#[test]
fn parity_forward_reexport() {
// `@forward` re-exports another module's members; `as prefix-*` prefixes
// them and `show`/`hide` filter them.
assert_module_parity(&[
(
"_lib.scss",
"$color: red;\n@function double($x) { @return $x * 2; }\n@mixin m { x: 1; }\n",
),
("_mid.scss", "@forward \"lib\" as lib-*;\n"),
(
"input.scss",
"@use \"mid\";\n.a { c: mid.$lib-color; w: mid.lib-double(3); @include mid.lib-m; }\n",
),
]);
}
#[test]
fn parity_use_and_forward_with_config() {
// `with (...)` overrides a module's `!default` variables; a `@forward ...
// with` default yields to a downstream `@use ... with` override.
assert_module_parity(&[
(
"_conf.scss",
"$a: 1 !default;\n$b: 2 !default;\n.c { x: $a; y: $b; }\n",
),
("_midw.scss", "@forward \"conf\" with ($a: 100 !default);\n"),
(
"input.scss",
"@use \"midw\" with ($a: 999, $b: 200);\n.r { v: midw.$a; w: midw.$b; }\n",
),
]);
}
// ---------------------------------------------------------------------------
// Indented (`.sass`) syntax parity.
// ---------------------------------------------------------------------------
/// Compile `sass` (indented syntax) with dart-sass via `--stdin --indented`.
fn dart_sass_indented(sass: &str) -> Option<String> {
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "npx".to_string());
let mut cmd = if bin == "npx" {
let mut c = Command::new("npx");
c.args(["--yes", "sass", "--no-source-map", "--stdin", "--indented"]);
c
} else {
let mut c = Command::new(bin);
c.args(["--no-source-map", "--stdin", "--indented"]);
c
};
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(sass.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
/// Byte-verify our indented-syntax output against dart-sass.
fn assert_sass_parity(sass: &str) {
if !enabled() {
return;
}
let ours = compile(sass, &Options::default().with_syntax(sasso::Syntax::Sass))
.expect("our indented compile failed");
match dart_sass_indented(sass) {
Some(theirs) => assert_eq!(ours, theirs, "\n--- sass ---\n{sass}\n"),
None => eprintln!("skipping indented parity case: dart-sass unavailable"),
}
}
#[test]
fn parity_sass_basic_rule_and_decls() {
assert_sass_parity("a\n b: c\n d: e\n");
assert_sass_parity("$x: 1\n\ne\n f: $x + 2\n");
}
#[test]
fn parity_sass_nesting_and_parent() {
assert_sass_parity("a\n color: red\n &:hover\n color: blue\n");
}
#[test]
fn parity_sass_control_flow() {
assert_sass_parity("@for $i from 1 through 3\n .c-#{$i}\n width: $i * 10px\n");
assert_sass_parity("@each $c in red, green\n .#{$c}\n color: $c\n");
}
#[test]
fn parity_sass_callables() {
assert_sass_parity("@function double($x)\n @return $x * 2\n\na\n width: double(5px)\n");
assert_sass_parity("@mixin box($w)\n width: $w\n height: $w\n\na\n @include box(4px)\n");
}
#[test]
fn parity_sass_shorthand_mixin() {
// `=name` defines a mixin, `+name` includes it.
assert_sass_parity("=box($w)\n width: $w\n\na\n +box(4px)\n");
}
#[test]
fn parity_sass_multiline_continuation() {
assert_sass_parity("@function a($b,\n $c)\n @return $b + $c\n\nd\n e: a(1, 2)\n");
}
#[test]
fn parity_sass_comments() {
assert_sass_parity("// silent\na\n b: c // trailing\n");
assert_sass_parity("/* loud */\na\n b: c\n");
}
#[test]
fn parity_sass_custom_property() {
assert_sass_parity("a\n --b: c d\n");
}
#[test]
fn parity_sass_imports_scss_partial() {
// A `.sass` entrypoint importing a `.scss` partial: each file parses with
// the front-end matching its extension.
if !enabled() {
return;
}
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let id = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("sasso-xsyntax-{}-{}", std::process::id(), id));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join("_pal.scss"), "$c: red;\n.a { color: $c; }\n").expect("write");
let input = "@import \"pal\"\nb\n x: $c\n";
std::fs::write(dir.join("input.sass"), input).expect("write");
let importer = FsImporter::new(vec![dir.clone()]);
let ours = compile(
input,
&Options::default()
.with_syntax(sasso::Syntax::Sass)
.with_importer(&importer),
)
.expect("our cross-syntax compile failed");
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "npx".to_string());
let mut cmd = if bin == "npx" {
let mut c = Command::new("npx");
c.args(["--yes", "sass", "--no-source-map", "input.sass"]);
c
} else {
let mut c = Command::new(bin);
c.args(["--no-source-map", "input.sass"]);
c
};
let out = cmd
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.expect("run dart-sass");
let _ = std::fs::remove_dir_all(&dir);
if !out.status.success() {
eprintln!("skipping cross-syntax parity case: dart-sass errored");
return;
}
let theirs = String::from_utf8(out.stdout).expect("utf8");
assert_eq!(ours, theirs, "\n--- input.sass ---\n{input}\n");
}
#[test]
fn parity_selector_separators_in_groups() {
// Regression coverage for the borrowed-slice rewrite of `split_commas` and
// `tokenize_complex` (perf: drop per-part/per-token String allocation). A
// comma or `>`/`+`/`~` that lives inside a quoted string, an attribute
// selector, or a pseudo `(...)` argument must NOT be treated as a top-level
// separator — only genuine depth-0 ones are.
assert_parity("a[title=\"x, y\"] { color: red; }\n");
assert_parity("a[data-op=\"a > b\"] { color: red; }\n");
assert_parity(":is(.a > .b, .c + .d) .e { color: red; }\n");
assert_parity(".x:not(.a ~ .b) { color: red; }\n");
assert_parity(":where(.a, .b) .c { color: red; }\n");
// Genuine top-level list + combinators, with nesting: exercises
// split_commas on the parent list and tokenize_complex on the child.
assert_parity(".a, .b {\n > .c + .d ~ .e { color: red; }\n}\n");
assert_parity(".a, .b {\n .c, .d { color: red; }\n}\n");
}
#[test]
fn parity_color_modify_unit_leniency() {
// dart-sass does not hard-error on a non-`%` unit for the legacy hsl
// `lightness`/`saturation` channels of `color.adjust`/`color.change`, nor on
// a unit for `$alpha` (it warns to stderr and uses the value); legacy hwb
// `whiteness`/`blackness` still strictly require `%`.
assert_parity(concat!(
"@use \"sass:color\";\n",
"a {\n",
" b: color.adjust(red, $lightness: 10in);\n",
" c: color.adjust(red, $saturation: -10in);\n",
" d: color.adjust(red, $alpha: -0.3%);\n",
" e: color.adjust(red, $alpha: -0.3px);\n",
" f: color.change(red, $lightness: 50in);\n",
"}\n",
));
}
#[test]
fn parity_color_alpha_ms_filter_overload() {
// `color.alpha()` with one or more `<identifier>=value` arguments is the
// proprietary Microsoft filter overload, passed through verbatim (dart-sass
// warns to stderr) rather than enforcing the one-argument color signature.
assert_parity(concat!(
"@use \"sass:color\";\n",
"a {\n",
" b: color.alpha(c=d);\n",
" c: color.alpha(c=d, e=f, g=h);\n",
"}\n",
));
}
/// Compile `scss` with dart-sass, returning its first `Error:` line (the
/// message), or `None` if dart-sass succeeded or is unavailable.
fn dart_sass_error(scss: &str) -> Option<String> {
let bin = std::env::var("SASS_BIN").unwrap_or_else(|_| "npx".to_string());
let mut cmd = if bin == "npx" {
let mut c = Command::new("npx");
c.args(["--yes", "sass", "--no-source-map", "--stdin"]);
c
} else {
let mut c = Command::new(bin);
c.args(["--no-source-map", "--stdin"]);
c
};
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.ok()?;
child.stdin.take()?.write_all(scss.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
if out.status.success() {
return None;
}
let stderr = String::from_utf8(out.stderr).ok()?;
stderr
.lines()
.find(|l| l.starts_with("Error: "))
.map(|l| l.trim_start_matches("Error: ").to_string())
}
#[test]
fn parity_color_modify_missing_channel_errors() {
// `adjust`/`scale`/`invert` reject a missing (`none`) channel — and, after a
// conversion to an explicit `$space`, a powerless one — with dart-sass's
// exact "modifying missing channels" message.
if !enabled() {
return;
}
let cases = [
"@use \"sass:color\";\na {b: color.adjust(rgb(none 0 0), $red: 10)}\n",
"@use \"sass:color\";\na {b: color.adjust(rgb(0 0 0 / none), $alpha: 0.1)}\n",
"@use \"sass:color\";\na {b: color.adjust(grey, $hue: 10deg, $space: hsl)}\n",
"@use \"sass:color\";\na {b: color.scale(rgb(none 0 0), $red: 10%)}\n",
"@use \"sass:color\";\na {b: color.invert(grey, $space: hsl)}\n",
// A non-number `adjust` alpha (including `none`) is a type error.
"@use \"sass:color\";\na {b: color.adjust(red, $alpha: c)}\n",
"@use \"sass:color\";\na {b: color.adjust(red, $alpha: none)}\n",
];
for scss in cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
// Strip our leading `Error: ` and trailing `(line:col)` so the
// core message can be compared against dart-sass's.
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping missing-channel parity case: dart-sass unavailable"),
}
}
}
#[test]
fn parity_color_adjust_hue_non_legacy_error() {
// The legacy `adjust-hue()` getter only supports legacy colors; a non-legacy
// color (e.g. lch) errors with dart-sass's exact message.
if !enabled() {
return;
}
let scss = "a {b: adjust-hue(lch(0% 0 0deg), 10deg)}\n";
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping adjust-hue non-legacy parity case: dart-sass unavailable"),
}
}
#[test]
fn parity_selector_extend_multiple_extendees() {
// selector.extend()'s extendee may be a list of compound selectors (as a
// string or a Sass list), each applied as a separate extension target and
// resolved to a fixpoint so a selector matching several targets collapses.
assert_parity("@use \"sass:selector\";\na { b: selector.extend(\"c.d\", \"c, .d\", \".e\"); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.extend(\"c.d.e.f\", \"c.d, .e.f\", \".g\"); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.extend(\"c.d\", (c, \".d\"), \".e\"); }\n");
}
#[test]
fn parity_selector_arg_coercion_lists() {
// Selector arguments may be a Sass list (comma list of strings, or a comma
// list whose items are space lists of strings), coerced to a selector the
// same way dart-sass does for selector.parse/nest/is-superselector.
assert_parity("@use \"sass:selector\";\na { b: selector.parse((c d, e f)); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.parse((c \"d\", e \"f\")); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.nest((c, d e), \"f\"); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.is-superselector((c, d e), (c, d e)); }\n");
}
#[test]
fn parity_selector_append_type_suffix() {
// selector.append accepts a plain type selector as the suffix's leading
// simple (`.c` + `d` -> `.cd`); a universal or namespaced type is rejected
// (tested for errors via the spec suite, not here).
assert_parity("@use \"sass:selector\";\na { b: selector.append(\".c\", \"d\"); }\n");
assert_parity("@use \"sass:selector\";\na { b: selector.append(\"d\", \".c\"); }\n");
}
#[test]
fn parity_first_class_mixins_apply() {
// `meta.get-mixin` captures a mixin reference; `@include meta.apply(...)`
// invokes it with forwarded arguments and an optional `@content` block.
assert_parity(concat!(
"@use \"sass:meta\";\n",
"@mixin add-two($v) { b: $v + 2; }\n",
"$ref: meta.get-mixin(add-two);\n",
"a { @include meta.apply($ref, 10); }\n",
"@mixin wrap { c { @content; } }\n",
"d { @include meta.apply(meta.get-mixin(wrap)) { e: f; } }\n",
"g { h: meta.inspect(meta.get-mixin(add-two)); ",
"i: meta.get-mixin(add-two) == meta.get-mixin(add-two); }\n",
));
}
#[test]
fn parity_list_fn_arg_validation() {
// Fixed-arity list builtins reject extra positional arguments and unknown
// named arguments, matching dart-sass (which rejects them with the same
// "Only N argument(s) allowed…" / "No parameter named $X." errors). Both
// compilers must reject every case below; valid named calls still succeed.
for src in [
"@use \"sass:list\";\na {b: list.length((), 1)}\n",
"@use \"sass:list\";\na {b: list.nth(1 2, 1, 3)}\n",
"@use \"sass:list\";\na {b: list.index(1 2, 1, 3)}\n",
"@use \"sass:list\";\na {b: list.separator(1 2, 1)}\n",
"@use \"sass:list\";\na {b: list.is-bracketed(1 2, 1)}\n",
"@use \"sass:list\";\na {b: list.set-nth(c d, 1, 2, 3)}\n",
"@use \"sass:list\";\na {b: list.join(c, d, $invalid: true)}\n",
"@use \"sass:list\";\na {b: list.append(c, d, e, f)}\n",
] {
assert!(
compile(src, &Options::default()).is_err(),
"expected error for {src}"
);
if enabled() {
assert!(dart_sass(src).is_none(), "dart-sass unexpectedly accepted {src}");
}
}
// A valid named-argument call still compiles in both implementations.
assert_parity("@use \"sass:list\";\na {b: list.join((1), (2), $separator: comma)}\n");
}
#[test]
fn parity_list_separator_and_bracket_preservation() {
// `set-nth` keeps the source list's bracketing, and `join`/`append` treat a
// single-element (or empty) space list as having an *undecided* separator,
// so it defers to the other operand's separator — matching dart-sass.
assert_parity("@use \"sass:list\";\na {b: list.set-nth([c, d], 2, e)}\n");
assert_parity("@use \"sass:list\";\na {b: list.join([1], (2, 3, 4))}\n");
assert_parity("@use \"sass:list\";\na {b: list.join((1), (2 3 4))}\n");
assert_parity("@use \"sass:list\";\na {b: list.append((1), 2)}\n");
}
#[test]
fn parity_color_hwb_out_of_range_negative_saturation() {
// An out-of-range hwb color converts to hsl with a NEGATIVE raw saturation;
// dart-sass normalizes it by flipping the hue 180 degrees and taking |sat|
// (e.g. hwb(20deg 200% -125%) -> hsl(200, 11.11%, 212.5%), not hsl(20, -11.11%, …)).
assert_parity("a { b: hwb(20deg 200% -125%); }\n");
assert_parity("@use \"sass:color\";\na { b: color.to-space(hwb(20deg 200% -125%), hsl); }\n");
// Normal hwb / hsl (non-negative saturation) must be unaffected.
assert_parity("a { b: hwb(20deg 30% 40%); }\n");
assert_parity("a { b: hsl(20, 50%, 50%); }\n");
}
#[test]
fn parity_list_slash_and_slash_color_channels() {
// list.slash builds a slash-separated list (1 / 2 / 3), with the inspect
// parenthesization rules (a comma sub-list gets parens, a space one doesn't).
assert_parity("@use \"sass:list\";\na { b: list.slash(1, 2, 3); }\n");
assert_parity("@use \"sass:list\";\na { b: list.slash(1 2, 3 4); }\n");
assert_parity(
"@use \"sass:list\";\n@use \"sass:meta\";\na { b: meta.inspect(list.slash((1, 2), 3)); }\n",
);
// A slash list as color channels is the `<channels> / <alpha>` form: exactly
// two elements, the first an unbracketed space list.
assert_parity("@use \"sass:list\";\na { b: lab(list.slash(1% 2 3, 0.5)); }\n");
assert_parity("@use \"sass:list\";\na { b: rgb(list.slash(1 2 3, 0.5)); }\n");
}
#[test]
fn parity_color_module_hwb_comma_and_validation() {
// `sass:color` exposes a comma-form `color.hwb($hue, $whiteness, $blackness,
// $alpha: 1)` (the global `hwb()` is modern-only). It accepts the comma and
// the single space-list forms; the gamut-normalized whiteness/blackness are
// reported by channel introspection while serialization keeps an exact sRGB
// round-trip (so an out-of-gamut achromatic hwb stays hue 0, not flipped
// 180 degrees by the negative-saturation path).
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 50%, 0%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0 50% 0%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 50%, 0%, 0.5)}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 150%, 0%)}\n");
assert_parity("a {b: hwb(0 80% 50%)}\n");
assert_parity("a {b: hwb(120 80% 50%)}\n");
assert_parity(
"@use \"sass:color\";\na {b: color.channel(color.hwb(0, 150%, 0%), \"whiteness\", $space: hwb)}\n",
);
if !enabled() {
return;
}
// Both color.hwb and the modern global hwb reject unitless/wrong-unit
// whiteness/blackness, bracketed/comma single lists, and (for the module
// form) more than four arguments — with dart-sass's exact messages.
for scss in [
"@use \"sass:color\";\na {b: color.hwb(0, 30, 40%, 0.5)}\n",
"@use \"sass:color\";\na {b: color.hwb(0, 30%, 40px, 0.5)}\n",
"a {b: hwb(0 30 40%)}\n",
"a {b: hwb(0 30% 40)}\n",
"@use \"sass:color\";\na {b: color.hwb([0 30% 40%])}\n",
"@use \"sass:color\";\na {b: color.hwb((0, 30%, 40%))}\n",
"@use \"sass:color\";\na {b: color.hwb(0, 30%, 40%, 0.5, 0)}\n",
] {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping hwb error parity case: dart-sass unavailable"),
}
}
}
#[test]
fn parity_color_convert_achromatic_to_hsl_hwb() {
// Converting a truly achromatic color (gray/white from a wide-gamut space)
// to hsl/hwb must yield dart-sass's canonical hue 0 / saturation 0 — the
// XYZ round-trip leaves floating-point chroma residue that would otherwise
// read as a spurious hue (180/300deg) and, near l=0/1, an unstable
// saturation. A truly chromatic (or explicitly-built achromatic) color is
// unaffected.
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(a98-rgb 1 1 1), hsl)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(a98-rgb 0.5 0.5 0.5), hsl)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(a98-rgb 0.5 0.5 0.5), hwb)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(prophoto-rgb 0.5 0.5 0.5), hsl)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(rec2020 0.3 0.3 0.3), hwb)}\n");
// Chromatic conversions and explicitly-built achromatic hsl keep their hue.
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(a98-rgb 0.8 0.2 0.4), hsl)}\n");
assert_parity("@use \"sass:color\";\na {b: color.channel(hsl(300, 0%, 50%), \"hue\")}\n");
assert_parity("a {b: hsl(300, 0%, 50%)}\n");
}
#[test]
fn parity_color_convert_missing_channel_carry() {
// CSS Color 4 carries a missing component into the analogous channel of the
// target space; the xyz channels are analogous to rgb (Reds: r/x, Greens:
// g/y, Blues: b/z), so a missing rgb channel survives a round-trip to xyz.
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(a98-rgb none 0.5 0.5), xyz)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(srgb 0.5 0.5 none), xyz)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(xyz none 0.2 0.3), a98-rgb)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(color(xyz 0.1 0.2 none), display-p3)}\n");
// a/b are not analogous to xyz/rgb; Lightness carries across lab/oklab.
assert_parity("@use \"sass:color\";\na {b: color.to-space(lab(50% none 30), xyz)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(lab(none 20 30), oklab)}\n");
// color.same converts none -> 0 (NO carry): two colors that realize
// differently are not the same even if both carry a missing component.
assert_parity(
"@use \"sass:color\";\na {b: color.same(color(rec2020 0.5 none 0.2), color(xyz 0.174805932224126 none 0.058901333881161))}\n",
);
assert_parity(
"@use \"sass:color\";\na {b: color.same(color(display-p3 0.1 0.3 none), color(display-p3 0.1 0.3 0))}\n",
);
}
#[test]
fn parity_color_lab_degenerate_calc_channels() {
// A degenerate calc() channel (calc(NaN)/calc(±infinity)) makes a real
// lab-family COLOR (not a verbatim string): meta.type-of is `color`, the
// serialization keeps the calc() spelling, a degenerate lightness clamps
// (NaN→0 / +inf→max), chroma floors at 0, and a/b/hue keep calc().
assert_parity("a {b: lab(1% calc(NaN) -3)}\n");
assert_parity("a {b: lab(1% calc(infinity) -3)}\n");
assert_parity("a {b: lab(1% calc(-infinity) -3)}\n");
assert_parity("a {b: lch(50% calc(NaN) 30)}\n");
assert_parity("a {b: lch(50% calc(infinity) 30)}\n");
assert_parity("a {b: lch(50% 20 calc(NaN))}\n");
assert_parity("a {b: lab(calc(NaN) 2 3)}\n");
assert_parity("a {b: lab(calc(infinity) 2 3)}\n");
assert_parity("a {b: oklab(50% calc(infinity) 0.1)}\n");
assert_parity("a {b: oklch(0.5 calc(NaN) 30)}\n");
assert_parity("@use \"sass:meta\";\na {b: meta.type-of(lab(1% calc(NaN) -3))}\n");
assert_parity("@use \"sass:color\";\na {b: color.space(lch(50% calc(infinity) 30))}\n");
}
#[test]
fn parity_color_hwb_serialization_and_alpha_fold() {
// A non-opaque integer hwb (pure red, sRGB 255 0 0) uses the hsl comma form
// hsla(...), not rgba(); only a fully-opaque integer hwb collapses to a
// named/hex color.
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 0%, 0%, 0.5)}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 0%, 0%, 45.6%)}\n");
assert_parity("a {b: hwb(0 0% 0% / 0.5)}\n");
assert_parity("a {b: hwb(0 0% 0%)}\n");
assert_parity("a {b: hwb(120 0% 0%)}\n");
// A degenerate calc() alpha folds to a clamped number (NaN→0, +inf→1, -inf→0).
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 30%, 40%, calc(NaN))}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 30%, 40%, calc(infinity))}\n");
assert_parity("@use \"sass:color\";\na {b: color.hwb(0, 30%, 40%, calc(-infinity))}\n");
}
#[test]
fn parity_color_hwb_achromatic_serialization() {
// An achromatic hwb (whiteness+blackness fills the gamut, collapsing chroma
// to grey) has a powerless hue; dart-sass serializes it with hue 0, not the
// floating-point residue of the hwb→rgb round-trip.
assert_parity("a {b: hwb(90 0% 100%)}\n");
assert_parity("a {b: hwb(270 80% 100%)}\n");
assert_parity("a {b: hwb(0 50% 50%)}\n");
assert_parity("a {b: hwb(200 60% 60%)}\n");
// A chromatic hwb keeps its hue.
assert_parity("a {b: hwb(90 0% 0%)}\n");
assert_parity("a {b: hwb(120 30% 20%)}\n");
}
#[test]
fn parity_selector_unify_id_conflict() {
// Two distinct ids cannot share a compound: selector.unify yields null
// rather than an impossible `#a#b`. A same id (or an id alongside non-id
// simples) still unifies.
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"#a\", \"#b\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"#s1-1 > .s1-2\", \"#s2-1 > .s2-2\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"#a.x\", \"#a.y\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"#a\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\".c\", \".d\")}\n");
}
#[test]
fn parity_meta_load_css() {
// `@include meta.load-css($url)` loads a module and emits its CSS without
// binding a namespace; `$with` configures its !default variables.
assert_module_parity(&[
("_other.scss", "$color: blue !default;\na { color: $color; }\n"),
(
"input.scss",
"@use \"sass:meta\";\n@include meta.load-css(\"other\");\n",
),
]);
assert_module_parity(&[
("_other.scss", "$color: blue !default;\na { color: $color; }\n"),
(
"input.scss",
"@use \"sass:meta\";\n@include meta.load-css(\"other\", $with: (\"color\": green));\n",
),
]);
// A module loaded for its CSS may itself @use other modules.
assert_module_parity(&[
("_dep.scss", "@function two() { @return 2; }\n"),
("_mid.scss", "@use \"dep\";\nb { width: dep.two() * 1px; }\n"),
(
"input.scss",
"@use \"sass:meta\";\n@include meta.load-css(\"mid\");\n",
),
]);
}
#[test]
fn parity_selector_pseudo_is_superselector() {
// :is/:matches/:where/:any and :has/:host/:host-context consider their
// selector argument. :is(...) is a superselector when every branch of a
// same-name pseudo is covered, or (matches family) one branch supersedes the
// parents+target compound. :is(c) is NOT a superselector of :is(c, d).
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":is(c d, e f, g h)\", \"c d.i, e j f\")}\n",
);
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":is(c e)\", \"c d e\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":is(c)\", \":is(c, d)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":is(c, d)\", \":is(c)\")}\n");
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":has(c d, e f, g h)\", \":has(c d.i, e j f)\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":host(c d, e f, g h)\", \":host(c d.i, e j f)\")}\n",
);
}
#[test]
fn parity_extend_source_extender_not_trimmed() {
// A source extender (`:is(midstream)`) is never trimmed away by a broader
// generated selector (the transitive `:is(midstream, downstream)`).
assert_parity(":is(midstream) {@extend upstream}\ndownstream {@extend midstream}\nupstream {a: b}\n");
}
#[test]
fn parity_meta_exists_module_and_star() {
// The optional `$module` namespace arg looks the member up in that module.
assert_module_parity(&[
("_other.scss", "$d: 1;\n@function f() {@return 2}\n@mixin m {}\n"),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\" as o;\na {\n f: meta.function-exists(\"f\", \"o\");\n m: meta.mixin-exists(\"m\", \"o\");\n v: meta.global-variable-exists(\"d\", \"o\");\n n: meta.function-exists(\"nope\", \"o\");\n}\n",
),
]);
// The no-`$module` forms see members exposed via `@use … as *`.
assert_module_parity(&[
("_other.scss", "$d: 1;\n@function f() {@return 2}\n@mixin m {}\n"),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\" as *;\na {\n f: meta.function-exists(\"f\");\n m: meta.mixin-exists(\"m\");\n v: meta.variable-exists(\"d\");\n}\n",
),
]);
// A built-in module member via the namespace.
assert_module_parity(&[(
"input.scss",
"@use \"sass:meta\";\n@use \"sass:color\" as c;\na {r: meta.function-exists(\"red\", \"c\")}\n",
)]);
}
#[test]
fn parity_meta_module_members() {
// module-variables maps non-private members → value (private `-priv` omitted).
assert_module_parity(&[
("_other.scss", "$d: d value;\n$e: e value;\n$-priv: hidden;\n"),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\";\na {b: meta.inspect(meta.module-variables(\"other\"))}\n",
),
]);
// module-functions references are callable; an underscore key canonicalizes
// to a dash.
assert_module_parity(&[
(
"_other.scss",
"@function c-d() {@return cd}\n@function e_f() {@return ef}\n",
),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\";\n$m: meta.module-functions(\"other\");\na {x: meta.call(map-get($m, \"c-d\")); y: meta.call(map-get($m, \"e-f\"))}\n",
),
]);
}
#[test]
fn parity_meta_get_function_module() {
// get-function with a `$module` namespace resolves from that module.
assert_module_parity(&[
("_other.scss", "@function f() {@return ff}\n"),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\" as o;\na {b: meta.call(meta.get-function(\"f\", $module: \"o\"))}\n",
),
]);
// The bare form captures a function exposed via `@use … as *`.
assert_module_parity(&[
("_other.scss", "@function f() {@return ff}\n"),
(
"input.scss",
"@use \"sass:meta\";\n@use \"other\" as *;\na {b: meta.call(meta.get-function(\"f\"))}\n",
),
]);
// A built-in module member (math.round) via the namespace.
assert_module_parity(&[(
"input.scss",
"@use \"sass:meta\";\n@use \"sass:math\" as m;\na {b: meta.call(meta.get-function(\"round\", $module: \"m\"), 0.6)}\n",
)]);
}
#[test]
fn parity_meta_accepts_content() {
// accepts-content reflects whether the mixin body uses @content; the only
// built-in mixin that does is meta.apply.
assert_parity(
"@use \"sass:meta\";\n@mixin a {@content}\n@mixin b {x: y}\nz {\n c: meta.accepts-content(meta.get-mixin(\"a\"));\n d: meta.accepts-content(meta.get-mixin(\"b\"));\n e: meta.accepts-content(meta.get-mixin(\"apply\", \"meta\"));\n}\n",
);
}
#[test]
fn parity_list_join_append_slash_separator() {
// `$separator: slash` now builds a slash-separated list (ListSep::Slash).
assert_parity("@use \"sass:list\";\na {b: list.join(1 2, 3 4, $separator: slash)}\n");
assert_parity("@use \"sass:list\";\na {b: list.append((1, 2), 3, $separator: slash)}\n");
assert_parity("@use \"sass:list\";\na {b: list.separator(list.join((), 1, slash))}\n");
}
#[test]
fn parity_for_loop_unit() {
// The @for loop variable inherits the FROM bound's unit; the TO bound is
// converted to match (a unitless side defers).
assert_parity("a {\n @for $i from 1px through 5px {b: $i}\n}\n");
assert_parity("a {\n @for $i from 5mm through 1cm {b: $i}\n}\n");
assert_parity("a {\n @for $i from 1px through 5 {b: $i}\n}\n");
assert_parity("a {\n @for $i from 1 through 5px {b: $i}\n}\n");
}
#[test]
fn parity_color_equality_space_and_none() {
// `==` compares color space + missing channels, not just sRGB.
assert_parity("a {b: color(srgb 0 0 0) == color(srgb-linear 0 0 0)}\n");
assert_parity("a {b: hsl(0 0% 80%) == hsl(none 0% 80%)}\n");
assert_parity("a {b: hwb(0 0% 0%) == hwb(none 0% 0%)}\n");
assert_parity("a {b: hsl(0 0% 50%) == hsl(120 0% 50%)}\n");
// Different LEGACY spaces still compare via sRGB; non-legacy never crosses.
assert_parity("a {b: rgb(255 0 0) == hsl(0 100% 50%)}\n");
assert_parity("a {b: gray == hsl(none 0% 50.196078431373%)}\n");
assert_parity("a {b: rgb(none 0 0) == rgb(0 0 0)}\n");
}
#[test]
fn parity_reserved_function_names() {
// A user @function may not reuse a reserved operator/special-function name.
if enabled() {
for name in [
"and",
"or",
"not",
"url",
"expression",
"element",
"-a-element",
"type",
"TYPE",
] {
let scss = format!("@function {name}() {{@return 1}}\na {{b: 1}}\n");
assert!(
compile(&scss, &Options::default()).is_err(),
"expected error for @function {name}"
);
assert!(
dart_sass(&scss).is_none(),
"dart-sass unexpectedly accepted @function {name}"
);
}
}
// Vendor-prefixed / differently-cased / non-reserved names stay valid.
assert_parity("@function -a-and() {@return 1}\na {b: -a-and()}\n");
assert_parity("@function AND() {@return 1}\na {b: AND()}\n");
assert_parity("@function -a-url() {@return 1}\na {b: -a-url()}\n");
assert_parity("@function ELEMENT() {@return 1}\na {b: ELEMENT()}\n");
}
#[test]
fn parity_user_function_calc_override() {
// An empty `calc()` is a regular call, so a user `@function calc()` runs.
assert_parity("@function calc() {@return 1}\na {b: calc()}\n");
assert_parity("@function calc($x) {@return $x}\na {b: calc(2)}\n");
assert_parity("a {b: calc(1px + 2px)}\n");
// A bare `calc()` with no user override errors (CSS calc needs an argument).
if enabled() {
let scss = "a {b: calc()}\n";
assert!(compile(scss, &Options::default()).is_err());
assert!(dart_sass(scss).is_none());
}
}
#[test]
fn parity_content_using_clause() {
// A `@content(args)` call binds its arguments to the content block's
// `using (params)`, which become locally visible inside the block.
assert_parity(
"@mixin m {\n @content(1, 2);\n}\na {\n @include m using ($x, $y) {\n p: $x;\n q: $y;\n }\n}\n",
);
// Default values in the `using` list fill un-passed arguments.
assert_parity(
"@mixin m {\n @content(10);\n}\na {\n @include m using ($x, $y: 20) {\n p: $x;\n q: $y;\n }\n}\n",
);
// `@content` with no parens and no `using` clause is a plain content block.
assert_parity("@mixin m {\n @content;\n}\na {\n @include m {\n p: 1;\n }\n}\n");
// Whitespace and case quirks around `using` / the arglist.
assert_parity(
"a {\n @mixin m {\n @content(1, 2);\n }\n @include m()UsInG ($x, $y) {\n p: $x;\n q: $y;\n }\n}\n",
);
assert_parity(
"a {\n @mixin m {\n @content (1, 2);\n }\n @include m using($x, $y){\n p: $x;\n q: $y;\n }\n}\n",
);
}
#[test]
fn parity_content_using_errors() {
// Validation around `@content(args)` / `using (params)` matches dart-sass.
if !enabled() {
return;
}
let cases = [
// A content block with no `using` accepts no arguments.
"@mixin m {\n @content(1);\n}\na {\n @include m {}\n}\n",
// An unknown named argument to the content block.
"@mixin m {\n @content($bogus: 1);\n}\na {\n @include m using ($x) {}\n}\n",
// `using` without a content block.
"@mixin m {\n @content;\n}\na {\n @include m using ();\n}\n",
// `using` without a parenthesized parameter list.
"@mixin m {\n @content;\n}\na {\n @include m using {}\n}\n",
// An unknown named argument to a plain mixin call.
"@mixin m($a) {\n x: $a;\n}\na {\n @include m(1, $b: 2);\n}\n",
];
for scss in cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping content-using error parity case: dart-sass unavailable"),
}
}
}
#[test]
fn parity_color_arithmetic_removed() {
// dart-sass removed color arithmetic: `+`/`-` combining a color with
// another color or a number is "Undefined operation" (a color with a
// string still string-concatenates).
if !enabled() {
return;
}
let err_cases = [
"$v: #abc + #123;\na {b: $v}\n",
"$v: #abc + 1;\na {b: $v}\n",
"$v: 1 + #abc;\na {b: $v}\n",
"$v: #abc - #123;\na {b: $v}\n",
"$v: red - blue;\na {b: $v}\n",
"$v: 1 - red;\na {b: $v}\n",
"$v: rgb(1 2 3) + 1;\na {b: $v}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping color-arithmetic parity case: dart-sass unavailable"),
}
}
// A color combined with a string still concatenates (no error).
assert_parity("a {b: \"x\" + red}\n");
assert_parity("a {b: foo + red}\n");
assert_parity("a {b: red + foo}\n");
}
#[test]
fn parity_plus_quotes_from_right_string() {
// dart-sass's default `Value.plus` (used when the left operand is not a
// string) quotes the concatenated result iff the RIGHT operand is a quoted
// string: `1 + "x"` -> `"1x"`, `red + "x"` -> `"redx"`. An unquoted right
// keeps the result unquoted (`foo + "x"` is quoted, `1 + foo` is not).
assert_parity("a {b: 1 + \"x\"}\n");
assert_parity("a {b: red + \"x\"}\n");
assert_parity("a {b: true + \"x\"}\n");
assert_parity("a {b: 1px + \"x\"}\n");
assert_parity("a {b: (1 2) + \"x\"}\n");
assert_parity("a {b: null + \"x\"}\n");
assert_parity("a {b: calc(1px + 1px) + \"x\"}\n");
// Unquoted right operand → unquoted result.
assert_parity("a {b: 1 + foo}\n");
assert_parity("a {b: red + foo}\n");
}
#[test]
fn parity_builtin_argument_validation() {
// Fixed-arity builtins reject extra positional args, and several value
// constraints match dart-sass exactly.
if !enabled() {
return;
}
let err_cases = [
"@use \"sass:color\";\na {b: color.red(red, 2)}\n",
"@use \"sass:color\";\na {b: color.green(red, 2)}\n",
"@use \"sass:color\";\na {b: color.blue(red, 2)}\n",
"@use \"sass:color\";\na {b: color.space(red, srgb)}\n",
"@use \"sass:color\";\na {b: color.is-legacy(red, 1)}\n",
"@use \"sass:color\";\na {b: color.same(red, blue, green)}\n",
"@use \"sass:color\";\na {b: color.is-missing(black, \"red\", 1)}\n",
"@use \"sass:color\";\na {b: color.to-gamut(red, srgb, local-minde, x)}\n",
"@use \"sass:math\";\na {b: math.percentage(1, 2)}\n",
"@use \"sass:math\";\na {b: math.percentage(1%)}\n",
"@use \"sass:color\";\na {b: color.is-missing(black, \"RED\")}\n",
"@use \"sass:color\";\na {b: color.is-missing(black, \"hue\")}\n",
"@use \"sass:color\";\na {b: color.invert(red, 100.001%)}\n",
"@use \"sass:color\";\na {b: color.invert(red, -0.001%)}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping builtin-validation parity case: dart-sass unavailable"),
}
}
// Valid calls still succeed.
assert_module_parity(&[("input.scss", "@use \"sass:color\";\na {b: color.red(red)}\n")]);
assert_module_parity(&[("input.scss", "@use \"sass:math\";\na {b: math.percentage(0.5)}\n")]);
assert_module_parity(&[(
"input.scss",
"@use \"sass:color\";\na {b: color.invert(red, 50%)}\n",
)]);
}
#[test]
fn parity_calc_functions_are_calculations() {
// An unreduced `min`/`max`/`clamp`/`hypot` is a calculation value:
// `type-of` is `calculation`, `calc-name`/`calc-args` inspect it, a nested
// calculation stays a calculation, and `calc(min(…))` drops its wrapper.
// A reducible call still folds to a number.
assert_parity("a {b: min(1px, 2vw); c: max(1%, 2px); d: clamp(1%, 2px, 3px)}\n");
assert_parity("a {b: min(1px, 2px)}\n");
assert_parity("a {b: calc(min(1%, 2px))}\n");
assert_parity(
"@use \"sass:meta\";\na {b: meta.type-of(clamp(1%, 2px, 3px)); c: meta.calc-name(min(1%, 2px))}\n",
);
assert_parity("@use \"sass:list\";\n@use \"sass:meta\";\na {b: meta.inspect(meta.calc-args(clamp(1%, 2px, 3px)))}\n");
assert_parity("@use \"sass:list\";\n@use \"sass:meta\";\na {b: meta.type-of(list.nth(meta.calc-args(min(max(1%, 1px), 2px)), 1))}\n");
}
#[test]
fn parity_calc_name_and_args() {
// `meta.calc-name` returns the calculation function name (`"calc"`);
// `meta.calc-args` returns its arguments, where an operation/var becomes an
// unquoted string and a number stays a number.
assert_parity("@use \"sass:meta\";\na {b: meta.calc-name(calc(var(--c)))}\n");
assert_parity("@use \"sass:meta\";\na {b: meta.inspect(meta.calc-args(calc(1% + 1px)))}\n");
assert_parity(
"@use \"sass:list\";\n@use \"sass:meta\";\na {b: list.nth(meta.calc-args(calc(var(--c))), 1)}\n",
);
assert_parity("@use \"sass:list\";\n@use \"sass:meta\";\na {b: meta.type-of(list.nth(meta.calc-args(calc(1% + 1px)), 1))}\n");
// A non-calculation argument is rejected.
let scss = "@use \"sass:meta\";\na {b: meta.calc-args(1)}\n";
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
assert!(
ours.is_some_and(|m| m.contains("is not a calculation.")),
"expected calc-args on a number to error"
);
}
#[test]
fn parity_argument_list_keywords() {
// A `$args...` rest parameter captures positional args (a comma list) plus
// keyword args; `meta.keywords` returns the keyword map, `type-of` reports
// `arglist`, and a `$args...` splat forwards both positional and keyword
// arguments. Underscore arg names normalize to hyphens in the keyword map.
assert_parity(
"@use \"sass:meta\";\n@function f($args...) {@return meta.inspect((positional: $args, named: meta.keywords($args)))}\na {b: f(1, $x: 2, $y_z: 3); c: f(1, 2)}\n",
);
assert_parity(
"@use \"sass:meta\";\n@function t($args...) {@return meta.type-of($args)}\na {b: t(1, 2)}\n",
);
assert_parity(
"@use \"sass:meta\";\n@mixin fwd($args...) {@include inner($args...)}\n@mixin inner($a, $b: 0, $c: 0) {x: $a $b $c}\na {@include fwd(9, $c: 7)}\n",
);
// `meta.keywords` rejects a non-argument-list value.
let scss = "@use \"sass:meta\";\na {b: meta.keywords((a: 1))}\n";
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
assert!(
ours.is_some_and(|m| m.contains("is not an argument list.")),
"expected meta.keywords on a map to error"
);
}
#[test]
fn parity_selector_digit_start_errors() {
// An id/class whose name starts with a digit (`#2b`, `.3c`) is rejected;
// valid names — and keyframe-style `50%` stops — still compile.
if enabled() {
for scss in [
"#2b {x: y}\n",
"#2 {x: y}\n",
".3c {x: y}\n",
".3 {x: y}\n",
"#4 {x: y}\n",
] {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping selector-digit parity case: dart-sass unavailable"),
}
}
}
assert_parity("#a2 {x: y}\n.-baz {x: y}\n.foo2 {x: y}\n");
}
#[test]
fn parity_selector_nest_parent_in_pseudo() {
// `selector.nest` substitutes a `&` inside a selector-list pseudo
// (`:is`/`:where`/`:not`) with the parent, instead of nesting it as a
// descendant; a complex with no `&` anywhere still nests as a descendant.
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c\", \":is(&)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c d\", \":is(&)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c\", \":not(&)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c\", \":where(& .e)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c\", \"&:is(&)\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.nest(\"c\", \":is(&), x\")}\n");
}
#[test]
fn parity_private_use_char_escaping() {
// Private-use characters are serialized as `\<hex>` escapes in both quoted
// and unquoted strings (planes 15/16 too); a non-character or CJK-compat
// char stays raw. Control/DEL are escaped only inside a quoted string.
assert_parity("@use \"sass:string\";\na {b: string.split(\"\\E000\", \"\")}\n");
assert_parity("a {b: \"\\E000\"; c: \"\\F0000\"; d: \"\\10FFFD\"}\n");
assert_parity("a {b: \"\\FDD0\"; c: \"\\F900\"}\n");
assert_parity("@use \"sass:string\";\na {b: string.unquote(\"x\\E000 y\")}\n");
assert_parity("a {b: \"x\\7f y\"; c: \"x\\19 y\"}\n");
}
#[test]
fn parity_comment_newline_normalization() {
// CR, FF, and CRLF inside a loud comment are all normalized to LF.
assert_parity("/* foo\r * bar */\n");
assert_parity("/* foo\u{c} * bar */\n");
assert_parity("/* foo\r\n * bar */\n");
// A plain LF comment is unchanged.
assert_parity("/* a\n b\n c */\nx {y: z}\n");
}
#[test]
fn parity_empty_value_declaration_dropped() {
// A regular declaration whose value serializes to nothing (an empty
// unquoted string, an all-`null` list) is dropped like a `null` value; a
// quoted `""` or a space `" "` still emits.
assert_parity("@use \"sass:string\";\na {c: 1; b: string.unquote(\"\"); d: 2}\n");
assert_parity("a {c: 1; b: (null, null); d: 2}\n");
assert_parity("a {b: \"\"}\n");
assert_parity("a {b: \" \"}\n");
}
#[test]
fn parity_color_space_name_case_insensitive() {
// Color-space names are ASCII case-insensitive in `color()` and `$space`
// arguments; the canonical lower-case form is serialized.
assert_parity("a {b: color(sRGB 0.1 0.2 0.3)}\n");
assert_parity("a {b: color(Display-P3 0.1 0.2 0.3)}\n");
assert_parity("a {b: color(XYZ 0.1 0.2 0.3)}\n");
assert_parity("a {b: color(Display-P3-Linear 1 2 3)}\n");
assert_parity("@use \"sass:color\";\na {b: color.to-space(red, DISPLAY-p3)}\n");
assert_parity("@use \"sass:color\";\na {b: color.invert(lch(20% 80 50deg), $space: DISPLAY-p3)}\n");
}
#[test]
fn parity_list_undecided_separator() {
// An empty or single-element list with no explicit separator is
// "undecided": it defers to the other operand in join/append, while an
// explicit `$separator` (or `with-separator`) makes it stick even when the
// result is empty or single-element. A single-element bracketed `[1]` is
// also undecided.
assert_parity("@use \"sass:list\";\na {b: list.join([1], (2,))}\n");
assert_parity("@use \"sass:list\";\na {b: list.join([1], (2, 3, 4))}\n");
assert_parity("@use \"sass:list\";\na {b: list.separator(list.join((), (), comma))}\n");
assert_parity("@use \"sass:list\";\na {b: list.append((), 1, $separator: comma)}\n");
assert_parity("@use \"sass:list\";\na {b: list.separator(list.join((), (), $separator: space))}\n");
assert_parity("@use \"sass:list\";\na {b: list.separator([1])}\n");
assert_parity("@use \"sass:meta\";\na {b: meta.inspect(())}\n");
assert_parity("@use \"sass:meta\";\na {b: meta.inspect([])}\n");
// A deliberately space-separated empty list keeps its separator through a
// join with a comma list as the first operand's settled choice.
assert_parity("@use \"sass:list\";\na {b: list.join(list.join((), (), space), (1, 2))}\n");
}
#[test]
fn parity_empty_list_declaration_errors() {
// An empty unbracketed list (`()`, directly or via a variable/builtin)
// cannot be a declaration value; a bracketed `[]` and any non-empty list
// are fine.
if !enabled() {
return;
}
for scss in [
"a {b: ()}\n",
"a {b: (())}\n",
"$x: ();\na {b: $x}\n",
"@use \"sass:list\";\na {b: list.join((), ())}\n",
] {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping empty-list parity case: dart-sass unavailable"),
}
}
// A bracketed empty list is a valid CSS value.
assert_parity("a {b: []}\n");
}
#[test]
fn parity_raw_newline_in_string_errors() {
// A literal newline inside a quoted string is an error; a `\` line
// continuation or a `\a` escape is the valid way to span lines.
if !enabled() {
return;
}
for scss in ["a {b: \"x\ny\"}\n", "a {b: \"x\ry\"}\n"] {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping raw-newline parity case: dart-sass unavailable"),
}
}
// A `\` line continuation and a `\a` escape stay valid.
assert_parity("a {b: \"x\\\ny\"}\n");
assert_parity("a {b: \"x\\a y\"}\n");
}
#[test]
fn parity_stray_else_errors() {
// A `@else` / `@else if` that is not part of an `@if` chain is rejected
// ("This at-rule is not allowed here."). A valid chain still compiles.
if !enabled() {
return;
}
for scss in ["@else {a {b: c}}\n", "@else if true {a {b: c}}\n"] {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping stray-else parity case: dart-sass unavailable"),
}
}
assert_parity("@if false {a {b: c}} @else {d {e: f}}\n");
}
#[test]
fn parity_declaration_context_errors() {
// `@function`/`@mixin` declarations are rejected in control directives,
// function bodies, and mixin bodies (a compile-time restriction).
if !enabled() {
return;
}
let err_cases = [
"@each $i in (a, b) {\n @function foo() {@return 1}\n}\n",
"@for $i from 1 through 1 {\n @function foo() {@return 1}\n}\n",
"@if true {\n @function foo() {@return 1}\n}\n",
"@while false {\n @function foo() {@return 1}\n}\n",
"@each $i in (a, b) {\n @mixin foo() {}\n}\n",
"@if true {\n @mixin foo() {}\n}\n",
"@function foo() {\n @function bar() {@return 1}\n}\n",
"@function foo() {\n @mixin bar() {}\n}\n",
"@mixin m {\n @mixin n {}\n}\n",
"@mixin m {\n @function f() {@return 1}\n}\n",
// The control-directive restriction propagates through style rules.
"@if true {\n a {\n @function f() {@return 1}\n }\n}\n",
// A function body's scope sticks through a nested control directive.
"@function f() {\n @if true {\n @function g() {@return 1}\n }\n @return 1\n}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping declaration-context parity case: dart-sass unavailable"),
}
}
// Valid placements still compile.
assert_parity("a {\n @function foo() {@return 1}\n b: foo();\n}\n");
assert_parity("@media x {\n a {b: c}\n}\n");
}
#[test]
fn parity_sass_import_context_errors() {
// A Sass `@import` (inlining a partial) is forbidden inside a control
// directive or a function/mixin body ("This at-rule is not allowed
// here."); a plain-CSS `@import` is always allowed.
if !enabled() {
return;
}
let err_cases = [
"@if true {\n @import \"_include\";\n}\n",
"@if false {\n} @else {\n @import \"_include\";\n}\n",
"@each $i in (1) {\n @import \"_include\";\n}\n",
"@for $i from 1 through 2 {\n @import \"_include\";\n}\n",
"@while false {\n @import \"_include\";\n}\n",
"@mixin m {\n @import \"_include\";\n}\n@include m;\n",
"@function f() {\n @import \"_include\";\n @return 1\n}\na {b: f()}\n",
"@if true {\n a {\n @import \"_include\";\n }\n}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping import-context parity case: dart-sass unavailable"),
}
}
// A plain-CSS `@import` in a control directive is fine.
assert_parity("@if true {\n @import url(x);\n}\n");
}
#[test]
fn parity_callable_not_valid_css_value() {
// A first-class function/mixin reference cannot appear in `+`/`-`/`/`
// arithmetic ("<inspect> isn't a valid CSS value."); `*` still raises the
// "Undefined operation" message and `==` still compares.
if !enabled() {
return;
}
let err_cases = [
"@use \"sass:meta\";\n@mixin a() {}\n@mixin b() {}\nx {y: meta.get-mixin(a) + meta.get-mixin(b)}\n",
"@use \"sass:meta\";\n@mixin a() {}\n@mixin b() {}\nx {y: meta.get-mixin(a) - meta.get-mixin(b)}\n",
"@use \"sass:meta\";\n@mixin a() {}\n@mixin b() {}\nx {y: meta.get-mixin(a) / meta.get-mixin(b)}\n",
"@use \"sass:meta\";\n@function a() {@return 1}\nx {y: meta.get-function(\"a\") + 1}\n",
"@use \"sass:meta\";\n@mixin a() {}\n@mixin b() {}\nx {y: meta.get-mixin(a) * meta.get-mixin(b)}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping callable-CSS-value parity case: dart-sass unavailable"),
}
}
// Equality comparison of mixins is still valid.
assert_module_parity(&[(
"input.scss",
"@use \"sass:meta\";\n@mixin a() {}\nx {y: meta.get-mixin(a) == meta.get-mixin(a)}\n",
)]);
}
#[test]
fn parity_meta_exists_global_module_conflict() {
// A name exposed unprefixed by more than one `@use … as *` module is
// ambiguous: the existence predicates error (byte-verified against
// dart-sass via the spec ratchet) rather than returning true.
let other = "$member: 1;\n@function member() {@return 1}\n@mixin member() {}\n";
let cases = [
(
"meta.variable-exists(member)",
"This variable is available from multiple global modules.",
),
(
"meta.global-variable-exists(member)",
"This variable is available from multiple global modules.",
),
(
"meta.mixin-exists(member)",
"This mixin is available from multiple global modules.",
),
(
"meta.function-exists(member)",
"This function is available from multiple global modules.",
),
];
for (predicate, expected) in cases {
let dir = std::env::temp_dir().join(format!(
"sasso-conflict-{}-{}",
std::process::id(),
predicate.len()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("_other1.scss"), other).unwrap();
std::fs::write(dir.join("_other2.scss"), other).unwrap();
let main = format!(
"@use \"sass:meta\";\n@use \"other1\" as *;\n@use \"other2\" as *;\na {{b: {predicate}}}\n"
);
let importer = FsImporter::new(vec![dir.clone()]);
let err = compile(&main, &Options::default().with_importer(&importer))
.err()
.map(|e| e.to_string());
let _ = std::fs::remove_dir_all(&dir);
let err = err.unwrap_or_else(|| panic!("expected an ambiguity error for {predicate}"));
assert!(
err.trim_start_matches("Error: ").starts_with(expected),
"for {predicate}: got {err}"
);
}
// A single star module is unambiguous and resolves to true.
assert_module_parity(&[
(
"input.scss",
"@use \"sass:meta\";\n@use \"other1\" as *;\na {b: meta.function-exists(member)}\n",
),
("_other1.scss", other),
]);
}
#[test]
fn parity_color_module_grayscale_opacity_strict() {
// The module `color.grayscale`/`color.opacity` reject a CSS-special string
// argument ("$color: var(--c) is not a color."), while a number still
// passes through the deprecated filter overload and a color is computed.
if !enabled() {
return;
}
let err_cases = [
"@use \"sass:color\";\na {b: color.grayscale(var(--c))}\n",
"@use \"sass:color\";\na {b: color.opacity(var(--c))}\n",
"@use \"sass:color\";\na {b: color.grayscale($color: var(--c))}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping grayscale/opacity strict parity case: dart-sass unavailable"),
}
}
// A number passes through; a color is computed.
assert_parity("@use \"sass:color\";\na {b: color.grayscale(50%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.opacity(red)}\n");
}
#[test]
fn parity_legacy_color_functions_reject_non_legacy() {
// The legacy color modifiers darken/lighten/(de)saturate/fade-in/fade-out/
// adjust-hue reject a non-legacy color ("<fn>() is only supported for
// legacy colors. …"); a legacy color is still computed.
if !enabled() {
return;
}
let err_cases = [
"a {b: darken(color(srgb 1 1 1), 10%)}\n",
"a {b: lighten(color(srgb 0 0 0), 10%)}\n",
"a {b: desaturate(color(srgb 1 1 1), 10%)}\n",
"a {b: saturate(color(srgb 1 1 1), 10%)}\n",
"a {b: fade-in(color(srgb 1 1 1 / 0.1), 0.1)}\n",
"a {b: fade-out(color(srgb 1 1 1), 0.1)}\n",
"a {b: adjust-hue(color(srgb 1 1 1), 10deg)}\n",
];
for scss in err_cases {
let ours = compile(scss, &Options::default()).err().map(|e| e.to_string());
match dart_sass_error(scss) {
Some(theirs) => {
let ours = ours.unwrap_or_else(|| panic!("expected our compile to error:\n{scss}"));
let msg = ours.trim_start_matches("Error: ");
assert!(
msg.starts_with(&theirs),
"\n--- scss ---\n{scss}\n--- ours ---\n{ours}\n--- dart ---\n{theirs}\n"
);
}
None => eprintln!("skipping legacy-color parity case: dart-sass unavailable"),
}
}
// Legacy colors still compute.
assert_parity("a {b: darken(#888, 10%)}\n");
assert_parity("a {b: lighten(red, 10%)}\n");
assert_parity("a {b: fade-out(rgba(0, 0, 0, 0.5), 0.1)}\n");
}
#[test]
fn parity_loud_comment_interpolation() {
// `#{…}` interpolation inside a loud comment is resolved at eval time,
// and an undefined variable / unterminated interpolation errors.
assert_parity("/* a #{1 + 2} b */\nx {y: z}\n");
assert_parity("$v: hi;\n/* value: #{$v} */\nx {y: z}\n");
assert_parity("x {\n /* in #{1 + 1} rule */\n y: z;\n}\n");
assert_parity("/* no interp here */\nx {y: z}\n");
if enabled() {
for scss in ["/* #{$undefined} */\n", "/* #{broken */\n"] {
assert!(
compile(scss, &Options::default()).is_err(),
"expected comment-interpolation error: {scss}"
);
assert!(
dart_sass(scss).is_none(),
"dart-sass unexpectedly accepted: {scss}"
);
}
}
}
#[test]
fn parity_operator_without_whitespace() {
// dart-sass: `+`/`-` is a binary operator unless it has whitespace before
// but not after (then it is a unary sign starting a new space-list term).
assert_parity("a {b: 1+1}\n");
assert_parity("a {b: 1+ 1}\n");
assert_parity("a {b: 5-2}\n");
assert_parity("a {b: 5- 2}\n");
assert_parity("a {b: 10px+5px}\n");
assert_parity("a {b: 1-a}\n");
assert_parity("a {b: (1)-2}\n");
// Space-before-not-after stays a list of signed terms.
assert_parity("a {b: 1 -2}\n");
assert_parity("a {b: 5 -2 3}\n");
// Identifiers keep their hyphens (the `-` never reaches the operator parser).
assert_parity("a {b: a-b}\n");
assert_parity("a {b: foo-bar}\n");
assert_parity("a {b: a-1}\n");
assert_parity("a {b: red-1}\n");
// Unicode ranges: a `?`-wildcard followed by `-name` splits, by `-digit`
// subtracts.
assert_parity("a {b: U+A?-BCDE}\n");
assert_parity("a {b: U+A?-1234}\n");
assert_parity("a {b: U+0-7F}\n");
// calc() still requires whitespace around `+`/`-`.
if enabled() {
assert!(compile("a {b: calc(1+1)}\n", &Options::default()).is_err());
assert!(dart_sass("a {b: calc(1+1)}\n").is_none());
}
}
#[test]
fn parity_adjust_hsl_saturation_clamp() {
// `color.adjust` clamps an hsl saturation at its lower bound (0) but not the
// upper, so over-desaturating yields grey rather than a hue-flip. `change`
// sets the raw value (no clamp) and the serializer flips negative saturation.
assert_parity("@use \"sass:color\";\na {b: color.adjust(plum, $saturation: -100%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(plum, $saturation: -200%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(plum, $saturation: -48%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(red, $saturation: 50%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.scale(plum, $saturation: -100%)}\n");
// change does not clamp.
assert_parity("@use \"sass:color\";\na {b: color.change(hsl(120, 50%, 50%), $saturation: -100%)}\n");
}
#[test]
fn parity_legacy_modify_gamut_serialization() {
// A legacy color modified without $space serializes in the original format
// when the result is in the sRGB gamut (rgb/hex/named), but in the working
// space (keeping canonical channels) when it falls out of gamut — so an
// hsl-channel adjustment doesn't round-trip through rgb into a flipped
// negative-saturation form.
assert_parity("@use \"sass:color\";\na {b: color.adjust(red, $lightness: 100%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(red, $lightness: 200%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.change(red, $lightness: 150%)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(plum, $lightness: -200%)}\n");
// In-gamut results keep the rgb/named form.
assert_parity("@use \"sass:color\";\na {b: color.change(red, $hue: 120)}\n");
assert_parity("@use \"sass:color\";\na {b: color.adjust(red, $green: 10)}\n");
// A constructed hsl color keeps its hsl format when modified in gamut.
assert_parity("@use \"sass:color\";\na {b: color.adjust(hsl(120, 100%, 50%), $lightness: 0%)}\n");
}
#[test]
fn parity_modify_color_named_first_arg() {
// `$color` passed by name (e.g. with a non-legacy color and an explicit
// working space) must not be treated as a channel.
assert_parity(
"@use \"sass:color\";\na {b: color.scale($color: color(a98-rgb 0.2 0.5 0.7), $red: 12%, $green: 24%, $blue: 48%)}\n",
);
assert_parity("@use \"sass:color\";\na {b: color.adjust($color: red, $red: 10)}\n");
assert_parity("@use \"sass:color\";\na {b: color.change($color: red, $hue: 120)}\n");
assert_parity("@use \"sass:color\";\na {b: color.scale(color(display-p3 0.2 0.5 0.7), $red: 12%)}\n");
}
#[test]
fn parity_selector_unify_leading_combinator() {
// selector.unify preserves a leading combinator (dart-sass's
// `leadingCombinators`); two different leading combinators can't unify.
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"> .c\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\".c\", \"~ .d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"+ .c\", \"+ .d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"> .c .d\", \".e\")}\n");
// No leading combinator: unchanged.
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\".a .b\", \".c .d\")}\n");
}
#[test]
fn parity_selector_empty_namespace() {
// The selector-string parser accepts the empty namespace (`|c`, `|*`),
// which feeds the namespace-aware unify/extend/superselector logic.
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"|c\", \"|c\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"*|c\", \"|c\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\"*|c\", \"|*\")}\n");
assert_parity(
"@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"c\", \"|c\"))}\n",
);
assert_parity(
"@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"|c\", \"|d\"))}\n",
);
// As actual style-rule selectors.
assert_parity("|c {x: y}\n|* {x: y}\n*|c {x: y}\n");
}
#[test]
fn parity_selector_unify_host() {
// A `:host`/`:host-context` pseudo can't unify into a compound with an
// incompatible simple (class/type/universal/ordinary pseudo-class) → null;
// it may coexist with selector-list pseudos (`:is`/`:where`).
if !enabled() {
return;
}
let null_cases = [
("\":host(.c)\"", "\".d\""),
("\".c\"", "\":host(.d)\""),
("\":host\"", "\".c\""),
("\":host\"", "\":hover\""),
("\":host\"", "\"*\""),
("\"*\"", "\":host\""),
("\":host\"", "\":host.c\""),
("\":host-context(.c)\"", "\".d\""),
];
for (a, b) in null_cases {
let scss = format!(
"@use \"sass:selector\";\n@use \"sass:meta\";\na {{b: meta.inspect(selector.unify({a}, {b}))}}\n"
);
let ours = compile(&scss, &Options::default()).ok();
match dart_sass(&scss) {
Some(theirs) => assert_eq!(ours.as_deref(), Some(theirs.as_str()), "\nunify({a}, {b})"),
None => eprintln!("skipping :host unify case: dart-sass unavailable"),
}
}
}
#[test]
fn parity_selector_extend_into_pseudos() {
// selector.extend / selector.replace recurse into selector-list pseudo
// arguments (`:is`/`:where`/`:matches`/`:not`), and a self-referential
// extender converges (no unbounded growth).
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\".x\", \".x\", \".x .y\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":is(.c)\", \".c\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":is(.c)\", \".c\", \".d, .e\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":where(.x)\", \".x\", \".x .y\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":matches(.c)\", \".c\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":not(.c)\", \".c\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":not(.c.d)\", \".c\", \".e\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":is(.c) .e\", \".c\", \".d\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.replace(\":is(.c)\", \".c\", \".d\")}\n");
}
#[test]
fn parity_selector_nth_child_anb() {
// `:nth-child`/`:nth-last-child` An+B arguments are canonicalized
// (`2n + 1` → `2n+1`, `2N + 1` → `2n+1`); other nth pseudos and uppercased
// names stay verbatim.
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\":nth-child(2n + 1)\", \"a\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\":nth-last-child(3n - 2)\", \"a\")}\n");
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":nth-child(2n+1)\", \":nth-child(2n + 1)\")}\n",
);
assert_parity("@use \"sass:selector\";\na {b: selector.unify(\":nth-of-type(3n - 2)\", \"a\")}\n");
}
#[test]
fn parity_selector_extend_nth_of() {
// `:nth-child(An+B of <selector>)` extends only its `of` selector; a nested
// same-An+B nth pseudo in the extender merges (deduped), a different-An+B
// one is dropped.
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":nth-child(2n+1 of .c)\", \".c\", \".d\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":nth-child(2n+1 of .c)\", \".c\", \".d, .e\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":nth-child(2n+1 of .c)\", \".c\", \":nth-child(2n+1 of .d, .e)\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":nth-child(2n+1 of .c)\", \".c\", \":nth-child(2n+2 of .d, .e)\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.replace(\":nth-last-child(2n+1 of .c)\", \".c\", \".d\")}\n",
);
}
#[test]
fn parity_selector_vendor_prefixed_pseudos() {
// A vendor prefix on a selector pseudo (`:-pfx-is`, `:-ms-matches`) is
// stripped (dart-sass `unvendor`) for the selector-pseudo family check, so
// unify/extend/is-superselector treat it as its base pseudo — but the full
// (prefixed) name must match to merge a nested one.
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":-pfx-is(c d, e f, g h)\", \"c d\")}\n",
);
assert_parity("@use \"sass:selector\";\na {b: selector.extend(\":-ms-matches(.c)\", \".c\", \".d\")}\n");
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":-ms-matches(.c)\", \".c\", \":-ms-matches(.d, .e)\")}\n",
);
// Different vendor prefix does not merge.
assert_parity(
"@use \"sass:selector\";\na {b: selector.extend(\":-ms-matches(.c)\", \".c\", \":-moz-matches(.d, .e)\")}\n",
);
}
#[test]
fn parity_selector_is_superselector_not() {
// dart-sass `:not(S1)` superselector rule (contravariant): `:not(c.d)` is a
// superselector of `e` (a different type can never match `c.d`), and a
// same-name `:not` whose argument supersedes each complex covers it.
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":not(c.d)\", \"e\")}\n");
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":not(#c.d)\", \"#e\")}\n");
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":not(c d.i, e j f)\", \":not(c d, e f, g h)\")}\n",
);
assert_parity(
"@use \"sass:selector\";\na {b: selector.is-superselector(\":not(c d.i):not(e j f)\", \":not(c d, e f, g h)\")}\n",
);
// Not a superselector when the type can still match.
assert_parity("@use \"sass:selector\";\na {b: selector.is-superselector(\":not(c.d)\", \"c\")}\n");
}
#[test]
fn parity_selector_combinator_runs() {
// A "bogus" combinator run — a leading run (`~ ~ c`) or a run between
// compounds (`c > > d`) — is preserved through parse/nest/append, and two
// different leading runs can't unify.
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"c > > d\", \"e\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"~ ~ c\", \"d\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"c\", \"+ > d\"))}\n");
assert_parity(
"@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"+ ~ > .c\", \"+ > ~ ~ .d\"))}\n",
);
}
#[test]
fn parity_selector_trailing_combinators() {
// Trailing combinator runs (`c >`, `d + >`) and combinator-only selectors
// (`>`) round-trip through nest; append rejects them, and a bogus
// trailing-combinator @extend extender is dropped.
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"c\", \"d + >\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"c\", \">\", \"d\"))}\n");
assert_parity(
"@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.nest(\"c ~\", \"d\"))}\n",
);
if enabled() {
// append rejects a combinator-only / trailing-combinator selector.
for scss in [
"@use \"sass:selector\";\na {b: selector.append(\".c\", \">\", \".d\")}\n",
"@use \"sass:selector\";\na {b: selector.append(\".c ~\", \".d\")}\n",
] {
assert!(compile(scss, &Options::default()).is_err());
assert!(dart_sass(scss).is_none());
}
}
// A bogus trailing-combinator @extend extender is a no-op.
assert_parity("a {b: c}\nd + {@extend a}\n");
}
#[test]
fn parity_selector_unify_legacy_pseudo_element() {
// A legacy single-colon pseudo-element unifies with its double-colon form
// (`:after` ≡ `::after`), keeping the base's spelling; two different
// pseudo-elements still can't share a compound.
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\":after\", \"::after\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"::after\", \":after\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"a:after\", \"a::after\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\"::before\", \"::after\"))}\n");
}
#[test]
fn parity_selector_unify_host_is_order() {
// Unifying a `:host`/`:host-context` with selector-list pseudos orders the
// host after the first wrapper: `:host(.c)` + `:is(.d)` → `:is(.d):host(.c)`,
// and `:host` + `:is(.c):is(.d)` → `:is(.c):host:is(.d)`.
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\":host(.c)\", \":is(.d)\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\":host\", \":is(.c):is(.d)\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\":host\", \":is(.c)\"))}\n");
assert_parity("@use \"sass:selector\";\n@use \"sass:meta\";\na {b: meta.inspect(selector.unify(\":host-context(.c)\", \":is(.d)\"))}\n");
}
#[test]
fn parity_url_whitespace_normalization() {
// dart-sass normalizes whitespace inside a plain `url(...)`: leading and
// trailing whitespace is trimmed and each internal run collapses to a
// single space (matching `_tryUrlContents`). Interpolation and a literal
// `/* */` (not whitespace) are preserved verbatim.
assert_parity("a {b: url( c)}\n");
assert_parity("a {b: url(c )}\n");
assert_parity("a {b: url( a b )}\n");
assert_parity("a {b: url( )}\n");
assert_parity("a {b: url( #{1 + 1} x )}\n");
assert_parity("a {b: url(a/**/b)}\n");
assert_parity("a {b: url( \"x y\" )}\n");
}
#[test]
fn parity_strip_sourcemap_comment() {
// dart-sass strips a `/*# sourceMappingURL=… */` / `/*# sourceURL=… */`
// loud comment; the `# ` space is required, so a no-space or `/*!` form and
// other names are kept. (dart leaves a trailing blank line where the
// stripped comment was — spec-normalized away — so compare trim-trailing.)
if !enabled() {
return;
}
for scss in [
"a {b: c}\n/*# sourceMappingURL=x */\n",
"a {b: c}\n/*# sourceURL=x */\n",
"a {b: c}\n/* normal */\n",
"a {b: c}\n/*#sourceMappingURL=x*/\n",
"a {b: c}\n/*! sourceMappingURL=x */\n",
] {
let ours = compile(scss, &Options::default()).expect("compile failed");
if let Some(theirs) = dart_sass(scss) {
assert_eq!(ours.trim_end(), theirs.trim_end(), "\n--- scss ---\n{scss}\n");
}
}
}
#[test]
fn parity_selector_list_newlines() {
// dart-sass preserves a source newline before a comma-separated complex
// selector (`a,\nb` stays on two lines), but a part that references the
// parent with `&` takes the parent's line-break, not its own — so
// `&.x,\n&.y` under a single parent collapses back to one line.
assert_parity("a,\nb {\n x: y;\n}\n");
assert_parity(".a {\n b,\n c {\n x: y;\n }\n}\n");
assert_parity("a {\n &.div,\n &.span {\n display: block;\n }\n}\n");
assert_parity(".a {\n &,\n .b {\n x: y;\n }\n}\n");
assert_parity("a,\nb, {\n x: y;\n}\n");
// @extend through a `&,\n&` selector list still collapses to one line.
assert_parity("%h {\n &:hover,\n &:focus {\n o: 1;\n }\n}\n.l {\n @extend %h;\n}\n");
}
#[test]
fn parity_multi_unit_numbers() {
// Multi-unit numbers (dart-sass SassNumber numerator/denominator lists):
// multiplication/division cancel convertible units (scaling the value) and
// keep the rest; the result serializes in calc form everywhere
// (`calc(1px * 1em)`, `calc(1 / 1px)`), `math.unit()` reports the
// unitString (`px*em`, `px^-1`, `px*em/(rad*s)`), and a slash value
// carries the true quotient for forced arithmetic.
assert_parity(
"@use \"sass:math\";\n@use \"sass:meta\";\na {\n m1: 1px * 1em;\n d1: math.div(1px, 1s);\n d2: math.div(1, 1px);\n u1: math.unit(1px * 1em * 1rad);\n u3: math.unit(math.div(math.div(1px, 1em), math.div(1rad, 1s)));\n u5: math.unit(math.div(1px, 1em));\n c1: meta.inspect(math.div(1px, 1s));\n abs: math.abs(math.div(-1px, 1s));\n i1: math.div(1, 0px);\n i2: math.div(1px * 1em, 0);\n i3: math.div(1px, 0em);\n cancel: math.div(math.div(1px, 1s), math.div(1px, 1s));\n}\n",
);
// Inside calc: products/quotients fold into one multi-unit number, with
// unit conversion on cancellation (`* 1s` against a `/ms` denominator
// scales by 1000); convertible-complex sums fold, incompatible ones error.
assert_parity(
"@use \"sass:math\";\n$number: math.div(1px * 1rad, 1ms * 1Hz);\na {\n c1: calc($number / (1px / 1ms));\n c2: calc($number / 1px);\n c3: calc($number * 1ms);\n c5: calc($number * 1s);\n c6: calc(1 / (1 / 1px / 1rad));\n c7: calc(1 / (1px * 1rad));\n m: calc(2px * 3px) / 4px;\n inv: calc(infinity / 1px);\n}\n",
);
// clamp/min with EQUAL complex units computes (`calc(4px * 1px)`).
assert_parity("a {b: clamp(1px*1px, 2px*2px, 3px*3px)}\n");
assert_error_parity("a {b: (1px * 1em) + 1px}\n");
assert_error_parity("a {b: calc(min(1px*1px, 2em*2em))}\n");
}
#[test]
fn parity_out_of_range_lightness_color_mix() {
// A lab/lch/oklab/oklch lightness outside [0, 100%] can't round-trip
// through the space's own syntax (the CSS parser clamps it), so it
// serializes as `color-mix(in <space>, color(xyz <unclamped>) 100%, black)`.
// Boundary values (exactly 0/100) and missing lightness stay in the
// own-space form; alpha rides inside the xyz color.
assert_parity(
"@use \"sass:color\";\na {\n b: color.change(lab(50% 1 1), $lightness: -1%);\n c: color.change(lab(50% 1 1), $lightness: 101%);\n d: color.change(lch(50% 10 10deg), $lightness: -1%);\n e: color.change(oklab(50% 0.1 0.1), $lightness: 101%);\n f: color.change(oklch(50% 0.1 10deg), $lightness: -1%);\n g: color.change(lab(50% 1 1 / 0.5), $lightness: -1%);\n}\n",
);
assert_parity(
"@use \"sass:color\";\na {\n zero: color.change(lab(50% 1 1), $lightness: 0%);\n hundred: color.change(lab(50% 1 1), $lightness: 100%);\n missing: lab(none 1 1);\n okboundary: color.change(oklch(50% 0.1 10deg), $lightness: 100%);\n}\n",
);
}
#[test]
fn parity_legacy_conversion_fills_missing() {
// Converting a color to a LEGACY space (to-space, or the round-trip inside
// scale/adjust/to-gamut with $space) zero-fills missing channels and a
// missing alpha, yielding the plain comma form — while authored and
// `color.change`d missing channels keep the modern `none` form (same-space
// conversion is the identity).
assert_parity(
"@use \"sass:color\";\na {\n conv: color.to-space(hsl(none 50% 50%), hwb);\n same: color.to-space(hsl(none 50% 50%), hsl);\n scaled: color.scale(hsl(none 50% 50%), $space: hwb);\n modern: color.to-space(color.change(oklch(50% 0.1 10deg), $lightness: none), hsl);\n alpha: color.to-space(color.change(oklch(50% 0.1 10deg / none), $lightness: none), hsl);\n authored: hsl(none 50% 50%);\n changed: color.change(hsl(10deg, 50%, 50%), $saturation: none);\n}\n",
);
}
#[test]
fn parity_pseudo_argument_whitespace() {
// dart-sass trims whitespace immediately inside a pseudo's argument parens.
// Leading whitespace is always dropped; trailing whitespace is dropped for a
// pseudo-CLASS or a selector-argument pseudo-element (`::slotted`), but KEPT
// for a text-argument pseudo-element (`::part`, `::highlight`).
for sel in [
":nth-of-type( 2n - 1 )",
":nth-of-type(2n- 1)",
":nth-of-type(2n -1)",
":not( .a )",
":lang( en )",
":has( > .a )",
"::slotted( .x )",
"::part( foo )",
"::part(foo )",
"::highlight( h )",
] {
assert_parity(&format!("{sel} {{ color: red; }}\n"));
}
}
#[test]
fn weave_combinator_runs_match_dart() {
// dart-sass's `_weaveParents` family: combinator runs (leading, trailing,
// multi) survive `selector.extend`/`selector.unify` losslessly, merge by
// dart's `_mergeLeadingCombinators`/`_mergeTrailingCombinators` rules, and
// bogus (multi-run) results are dropped as useless.
let go = |args: &str| {
ours(&format!(
"@use \"sass:selector\";\na {{b: selector.extend({args})}}\n"
))
};
// Leading combinator on the extender is preserved.
assert_eq!(go("\".c\", \".c\", \"+ .d\""), "a {\n b: .c, + .d;\n}\n");
// Two different leading combinators can't merge: extension fails.
assert_eq!(go("\"~ .c\", \".c\", \"+ .d\""), "a {\n b: ~ .c;\n}\n");
// A trailing run on the selector is preserved on the extension.
assert_eq!(go("\".c +\", \".c\", \".d\""), "a {\n b: .c +, .d +;\n}\n");
// The extender's trailing combinator becomes the join combinator.
assert_eq!(
go("\".c .d\", \".c\", \".e >\""),
"a {\n b: .c .d, .e > .d;\n}\n"
);
// Conflicting trailing combinators can't merge.
assert_eq!(go("\".c ~\", \".c\", \".d >\""), "a {\n b: .c ~;\n}\n");
// A multi-combinator run anywhere makes the extension useless.
assert_eq!(go("\".c ~ ~ .d\", \".c\", \".e\""), "a {\n b: .c ~ ~ .d;\n}\n");
assert_eq!(go("\".c\", \".c\", \".d ~ + .e\""), "a {\n b: .c;\n}\n");
assert_eq!(go("\"> + .c\", \".c\", \".d\""), "a {\n b: > + .c;\n}\n");
// A combinator-only extender replaces the compound wholesale.
assert_eq!(go("\".c\", \".c\", \">\""), "a {\n b: .c, >;\n}\n");
assert_eq!(go("\".c .d\", \".c\", \"~\""), "a {\n b: .c .d, ~ .d;\n}\n");
// `selector.unify` rejects any multi-combinator run (dart `isUseless`).
assert_eq!(
ours("@use \"sass:selector\";\na {b: selector.unify(\".c + ~ > .d\", \".e + ~ > .f\")}\n"),
""
);
// `is-superselector` is false either way for a bogus trailing run.
assert_eq!(
ours(concat!(
"@use \"sass:meta\";\n@use \"sass:selector\";\n",
"a {b: meta.inspect(selector.is-superselector(\".c\", \".c >\"))}\n",
"c {d: meta.inspect(selector.is-superselector(\".c >\", \".c\"))}\n"
)),
"a {\n b: false;\n}\n\nc {\n d: false;\n}\n"
);
}
#[test]
fn selector_arg_pseudo_superselector_rules() {
// dart-sass `_selectorPseudoIsSuperselector` for `:nth-child(An+B of S)` /
// `:nth-last-child` (same name + An+B, `of` lists compared as selectors),
// the `::slotted` argument rule, and `SimpleSelector.isSuperselector`'s
// subselector-pseudos rule (`c` covers `:nth-child(n+1 of c)`).
let go = |s1: &str, s2: &str| {
ours(&format!(
"@use \"sass:selector\";\na {{b: selector.is-superselector(\"{s1}\", \"{s2}\")}}\n"
))
};
let yes = "a {\n b: true;\n}\n";
let no = "a {\n b: false;\n}\n";
assert_eq!(go(":nth-child(2n of c, d)", ":nth-child(2n of c)"), yes);
assert_eq!(go(":nth-child(2n of c, d)", "e:nth-child(2n of c)"), yes);
assert_eq!(go(":nth-child(2n of c)", ":nth-child(3n of c)"), no);
assert_eq!(go(":nth-last-child(2n of c, d)", ":nth-last-child(2n of c)"), yes);
assert_eq!(
go(
":-pfx-nth-child(n+1 of c d, e f)",
":-pfx-nth-child(n+1 of c d.i)"
),
yes
);
assert_eq!(go("::slotted(c, d)", "::slotted(c)"), yes);
assert_eq!(go("e::slotted(c, d)", "e::slotted(c)"), yes);
assert_eq!(go("::-pfx-slotted(c d, e f)", "::-pfx-slotted(c d.i)"), yes);
assert_eq!(go("c", ":nth-child(n+1 of c)"), yes);
assert_eq!(go("c", ":nth-child(n+1 of d)"), no);
}
#[test]
fn slotted_and_current_pseudo_arg_extend() {
// dart-sass `_extendPseudo` runs for every selector-bearing pseudo,
// including the pseudo-element `::slotted`; a nested same-name `:current`
// is unwrapped and merged, and re-extension settles (deduped argument).
let go = |args: &str| {
ours(&format!(
"@use \"sass:selector\";\na {{b: selector.extend({args})}}\n"
))
};
assert_eq!(
go("\"::slotted(.c)\", \".c\", \".d\""),
"a {\n b: ::slotted(.c, .d);\n}\n"
);
assert_eq!(
go("\"::slotted(.c)\", \".c\", \"::slotted(.d)\""),
"a {\n b: ::slotted(.c, ::slotted(.d));\n}\n"
);
assert_eq!(
go("\":current(.c)\", \".c\", \":current(.d, .e)\""),
"a {\n b: :current(.c, .d, .e);\n}\n"
);
}
#[test]
fn plain_css_at_rules() {
// Plain-CSS (.css) at-rules: top-level pass-through with canonical media
// serialization, first-level bubbling around a copy of the parent rule,
// and native (in-place) nesting below that (dart `_hasCssNesting`).
let dir = std::env::temp_dir().join(format!("sasso_plain_atrule_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let go = |css: &str| {
std::fs::write(dir.join("plain.css"), css).expect("write plain.css");
compile("@use \"plain\";\n", &opts).expect("plain css compile")
};
// Canonical media-logic serialization (mixed case normalized).
assert_eq!(
go("@media (a) AnD (b) {x {y: z}}\n"),
"@media (a) and (b) {\n x {\n y: z;\n }\n}\n"
);
assert_eq!(
go("@media a and not (b) {x {y: z}}\n"),
"@media a and not (b) {\n x {\n y: z;\n }\n}\n"
);
// First-level @media bubbles out around a copy of the parent rule.
assert_eq!(
go("a {@media b {c: d}}\n"),
"@media b {\n a {\n c: d;\n }\n}\n"
);
// Below the first nesting level it stays in place (native CSS nesting).
assert_eq!(
go("a { b {@media c {d: e}}}\n"),
"a {\n b {\n @media c {\n d: e;\n }\n }\n}\n"
);
// Childless rules are invisible, recursively.
assert_eq!(go("a {}\n"), "");
assert_eq!(go("a { b {} c: d}\n"), "a {\n c: d;\n}\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plain_css_value_semantics() {
// In a plain-CSS module no function is invoked: calls re-serialize with
// their arguments (dart-sass `plainCss`), keywords are plain identifiers,
// and CSS calculations still simplify.
let dir = std::env::temp_dir().join(format!("sasso_plain_value_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let go = |css: &str| {
std::fs::write(dir.join("plain.css"), css).expect("write plain.css");
compile("@use \"plain\";\n", &opts).expect("plain css compile")
};
assert_eq!(go("a {b: alpha(0.1)}\n"), "a {\n b: alpha(0.1);\n}\n");
assert_eq!(go("a {b: rgb(255 0 0)}\n"), "a {\n b: rgb(255 0 0);\n}\n");
assert_eq!(go("a {b: RGB(1,2,3)}\n"), "a {\n b: RGB(1, 2, 3);\n}\n");
assert_eq!(go("a {b: min(1px, 2px)}\n"), "a {\n b: 1px;\n}\n");
assert_eq!(go("a {x: null}\n"), "a {\n x: null;\n}\n");
assert_eq!(go("a {b: true}\n"), "a {\n b: true;\n}\n");
// The `:x: y` IE property hack parses as a declaration.
assert_eq!(go(".hacks {*x: y; :x: y}\n"), ".hacks {\n *x: y;\n :x: y;\n}\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn colon_property_hack_scss() {
// The leading-`:` property hack also parses in SCSS (dart-sass reads one
// leading punctuation character before the property identifier).
assert_eq!(ours("a {:x: y}\n"), "a {\n :x: y;\n}\n");
// A pseudo-selector block is still a nested rule, not a declaration.
assert_eq!(ours("b {:hover {c: d}}\n"), "b :hover {\n c: d;\n}\n");
}
#[test]
fn import_plain_css_file() {
// `@import "x"` resolves a plain `.css` file after the Sass candidates but
// before index files, loads it in plain-CSS mode, and nests its outermost
// rules under an enclosing Sass rule (inner nesting stays native).
let dir = std::env::temp_dir().join(format!("sasso_import_css_{}", std::process::id()));
std::fs::create_dir_all(dir.join("both")).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(dir.join("plain.css"), "a { b {c: d}}\n").unwrap();
assert_eq!(
compile("@import \"plain\";\n", &opts).expect("import css"),
"a {\n b {\n c: d;\n }\n}\n"
);
assert_eq!(
compile("x {@import \"plain\";}\n", &opts).expect("nested import css"),
"x a {\n b {\n c: d;\n }\n}\n"
);
// css beats index.
std::fs::write(dir.join("both.css"), "css {x: y}\n").unwrap();
std::fs::write(dir.join("both").join("index.scss"), "idx {x: y}\n").unwrap();
assert_eq!(
compile("@import \"both\";\n", &opts).expect("css before index"),
"css {\n x: y;\n}\n"
);
// An adjacent .scss still wins over .css.
std::fs::write(dir.join("plain.scss"), "scss {x: y}\n").unwrap();
assert_eq!(
compile("@import \"plain\";\n", &opts).expect("scss wins"),
"scss {\n x: y;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_implicit_configuration() {
// dart-sass: an `@import`ed file's `@forward`s see every variable visible
// at the import as an implicit configuration for `!default` variables.
let dir = std::env::temp_dir().join(format!("sasso_import_cfg_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(dir.join("_midstream.scss"), "@forward \"upstream\";\n").unwrap();
std::fs::write(dir.join("_upstream.scss"), "$a: original !default;\nb {c: $a}\n").unwrap();
assert_eq!(
compile("$a: configured;\n@import \"midstream\";\n", &opts).expect("import cfg"),
"b {\n c: configured;\n}\n"
);
// Without a matching variable the default applies; the unconsumed
// implicit entry is not an error.
assert_eq!(
compile("$other: x;\n@import \"midstream\";\n", &opts).expect("unrelated"),
"b {\n c: original;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn round_calculation_semantics() {
// CSS `round()` is a calculation: arguments fold as calc expressions
// (`1px + 4px` -> `5px`), unsimplifiable operands preserve the call with
// the folded subtrees, a leading strategy keyword participates as a
// keyword, and a SassScript-only operator falls back to the legacy
// one-argument math.round (arity error for more).
assert_eq!(
ours("a {b: round(3.4px + 10%, 1px + 4px)}\n"),
"a {\n b: round(3.4px + 10%, 5px);\n}\n"
);
assert_eq!(
ours("a {b: round(1.4px + var(--a))}\n"),
"a {\n b: round(1.4px + var(--a));\n}\n"
);
assert_eq!(
ours("a {b: round(1px + 0%, 1px + 0%)}\n"),
"a {\n b: round(1px + 0%, 1px + 0%);\n}\n"
);
assert_eq!(ours("a {b: round(#{\"up\"}, 3px, 9px)}\n"), "a {\n b: 9px;\n}\n");
assert_eq!(ours("a {b: round(2.5)}\n"), "a {\n b: 3;\n}\n");
assert!(compile("a {b: round(7 % 3, 1)}\n", &Options::default()).is_err());
}
#[test]
fn sign_and_minmax_calculation_semantics() {
// `sign()` keeps the operand's full units (also for zero), `±0` passes
// through (`1 / sign(-0.0)` is `-infinity`), and a `%` operand preserves
// the call. `min`/`max` arguments fold as calc expressions and preserve
// the call with folded subtrees when an operand doesn't simplify.
assert_eq!(ours("a {b: sign(0px)}\n"), "a {\n b: 0px;\n}\n");
assert_eq!(ours("a {b: sign(7%)}\n"), "a {\n b: sign(7%);\n}\n");
assert_eq!(ours("a {b: sign(-7px / 4em) * 1em}\n"), "a {\n b: -1px;\n}\n");
assert_eq!(
ours("@use \"sass:math\";\na {b: math.div(1, sign(-0.0))}\n"),
"a {\n b: calc(-infinity);\n}\n"
);
assert_eq!(
ours("a {b: min(1px + 2px - var(--c), 5px)}\n"),
"a {\n b: min(3px - var(--c), 5px);\n}\n"
);
assert_eq!(
ours("a {b: max(5px, 1px + var(--c))}\n"),
"a {\n b: max(5px, 1px + var(--c));\n}\n"
);
assert_eq!(ours("a {b: min(3px + 4px, 10px)}\n"), "a {\n b: 7px;\n}\n");
}
#[test]
fn calc_space_splice_and_minmax_compatibility() {
// A space-separated calc run splices a string-valued variable verbatim
// (`calc(1 $c)` with `$c: unquote("+ 2")` → `calc(1 + 2)`, unfolded);
// a number-valued variable still has no operator.
assert_eq!(
ours(concat!(
"@use \"sass:string\";\n$c: string.unquote(\"+ 2\");\n",
"a {b: calc(1 $c)}\nc {d: calc($c 3)}\n"
)),
"a {\n b: calc(1 + 2);\n}\n\nc {\n d: calc(+ 2 3);\n}\n"
);
assert!(compile("$n: 2;\na {b: calc(1 $n)}\n", &Options::default()).is_err());
// min/max fold sequentially with unitless comparable to anything; the
// preserve path rejects a unitless operand paired with any unit.
assert_eq!(ours("a {b: min(1c, 2)}\n"), "a {\n b: 1c;\n}\n");
assert_eq!(ours("a {b: max(2d, 3, 1c)}\n"), "a {\n b: 3;\n}\n");
assert_eq!(ours("a {b: min(1c, 2px)}\n"), "a {\n b: min(1c, 2px);\n}\n");
assert!(compile("a {b: min(3, 1c, 2d)}\n", &Options::default()).is_err());
assert!(compile("a {b: min(1px, 2em, 3)}\n", &Options::default()).is_err());
}
#[test]
fn strict_unary_plus_minus() {
// dart-sass strict-unary: in operator position with whitespace before but
// not after, `+` is ALWAYS binary (`c +d` is `c + d`); `-` starts a new
// space-list term only before a number or identifier.
assert_eq!(ours("a {b: c +d}\n"), "a {\n b: cd;\n}\n");
assert_eq!(ours("a {b: 1 +2}\n"), "a {\n b: 3;\n}\n");
assert_eq!(ours("a {b: 10 +5px}\n"), "a {\n b: 15px;\n}\n");
assert_eq!(ours("a {b: c -d}\n"), "a {\n b: c -d;\n}\n");
assert_eq!(ours("a {b: 1 -2}\n"), "a {\n b: 1 -2;\n}\n");
assert_eq!(ours("$d: 5;\na {b: c -$d}\n"), "a {\n b: c-5;\n}\n");
assert_eq!(ours("a {b: 10 -(2)}\n"), "a {\n b: 8;\n}\n");
assert_eq!(ours("a {b: c -\"x\"}\n"), "a {\n b: c-\"x\";\n}\n");
}
#[test]
fn interp_adjacency_and_unit_dash() {
// Interpolation adjacency (dart `identifierLike` + implicit list
// separators): `1#{0}` is the list `1 0`, `#{1}0` the single token `10`,
// `10-#{10}` the list `10 -10`; a quoted string followed by `-ident`
// starts a new term; a number's unit eats a trailing `-` unless a digit
// follows; a color divided by a string slash-joins.
assert_eq!(ours("a {b: 1#{0}}\n"), "a {\n b: 1 0;\n}\n");
assert_eq!(ours("a {b: #{1}0}\n"), "a {\n b: 10;\n}\n");
assert_eq!(ours("a {b: #{1}px}\n"), "a {\n b: 1px;\n}\n");
assert_eq!(ours("a {b: 10-#{10}}\n"), "a {\n b: 10 -10;\n}\n");
assert_eq!(ours("a {b: \"q\"-l}\n"), "a {\n b: \"q\" -l;\n}\n");
assert_eq!(ours("a {b: \"q\" - l}\n"), "a {\n b: \"q\"-l;\n}\n");
assert_eq!(ours("a {b: 10px- 10px}\n"), "a {\n b: 10px- 10px;\n}\n");
assert_eq!(ours("a {b: 10px-10px}\n"), "a {\n b: 0px;\n}\n");
assert_eq!(ours("a {b: 10em-1}\n"), "a {\n b: 9em;\n}\n");
assert_eq!(ours("a {b: #AAA/#{itpl}}\n"), "a {\n b: #AAA/itpl;\n}\n");
}
#[test]
fn not_precedence_and_leading_slash() {
// dart-sass: `not` is a unary operator over a single expression, binding
// tighter than every binary operator; a leading `/` begins a slash value
// with an empty left operand.
assert_eq!(ours("a {b: not 1 + 2}\n"), "a {\n b: false2;\n}\n");
assert_eq!(ours("a {b: 1 + not 2}\n"), "a {\n b: 1false;\n}\n");
assert_eq!(
ours("$a: false;\n$b: false;\na {b: not $a == $b}\n"),
"a {\n b: false;\n}\n"
);
assert_eq!(ours("a {b: not (1 == 1)}\n"), "a {\n b: false;\n}\n");
assert_eq!(ours("a {b: (1, / 2)}\n"), "a {\n b: 1, /2;\n}\n");
assert_eq!(ours("a {b: / 2}\n"), "a {\n b: /2;\n}\n");
}
#[test]
fn import_forward_module_semantics() {
// dart-sass @import-of-@forward: the module evaluates once (implicit
// configuration doesn't re-configure it), its CSS re-emits at every
// import site, forwarded assignments overwrite user globals but a
// forwarded global keeps an intervening assignment, and a rule-scoped
// import nests the module's CSS under the enclosing rule.
let dir = std::env::temp_dir().join(format!("sasso_imp_fwd_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(dir.join("_other.scss"), "$a: original !default;\nb {c: $a}\n").unwrap();
std::fs::write(dir.join("_other.import.scss"), "@forward \"other\";\n").unwrap();
// Module CSS re-emits per import; second implicit config is ignored.
assert_eq!(
compile(
"$a: configured;\n@import \"other\";\n$a: changed;\n@import \"other\";\n",
&opts
)
.expect("import twice"),
"b {\n c: configured;\n}\n\nb {\n c: configured;\n}\n"
);
// An intervening assignment to a forwarded global survives a re-import.
assert_eq!(
compile(
"@import \"other\";\n$a: changed;\n@import \"other\";\nd {e: $a}\n",
&opts
)
.expect("still changes"),
"b {\n c: original;\n}\n\nb {\n c: original;\n}\n\nd {\n e: changed;\n}\n"
);
// A rule-scoped import nests the module CSS under the rule.
std::fs::write(dir.join("_mid.scss"), "@forward \"other\";\n").unwrap();
assert_eq!(
compile("a {\n $a: configured;\n @import \"mid\";\n}\n", &opts).expect("nested"),
"a b {\n c: configured;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_css_reemits_cached_module() {
// `meta.load-css` re-emits a cached module's CSS at every call site,
// nested under the enclosing rule; an explicit second `$with` still
// errors ("already loaded").
let dir = std::env::temp_dir().join(format!("sasso_load_css_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(dir.join("_m.scss"), "b {c: d}\n").unwrap();
assert_eq!(
compile(
"@use \"sass:meta\";\n@include meta.load-css(\"m\");\nx {@include meta.load-css(\"m\");}\n",
&opts
)
.expect("load twice"),
"b {\n c: d;\n}\n\nx b {\n c: d;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn nested_global_decl_registers_module_slot() {
// A `$var: ... !global` nested in a never-evaluated branch still creates
// a module variable slot defaulting to null (dart-sass: a module exposes
// the same members regardless of how it's evaluated).
let dir = std::env::temp_dir().join(format!("sasso_global_slot_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(
dir.join("_other.scss"),
"x {\n @if false {\n $member: value !global;\n }\n}\n",
)
.unwrap();
assert_eq!(
compile(
"@use \"sass:meta\";\n@use \"other\";\na {b: meta.inspect(other.$member)}\n",
&opts
)
.expect("nested global slot"),
"a {\n b: null;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn nest_parent_list_and_cartesian() {
// dart-sass `resolveParentSelectors` semantics in selector.nest: a `&`
// inside a selector pseudo receives the WHOLE parent list, multiple
// top-level `&`s expand as a cartesian product (last varying fastest),
// and a lone `&` first argument stays literal.
let go = |args: &str| {
ours(&format!(
"@use \"sass:selector\";\na {{b: selector.nest({args})}}\n"
))
};
assert_eq!(go("\"c, d\", \":is(&)\""), "a {\n b: :is(c, d);\n}\n");
assert_eq!(
go("\"c, d\", \"&.e &.f\""),
"a {\n b: c.e c.f, c.e d.f, d.e c.f, d.e d.f;\n}\n"
);
assert_eq!(go("\"&\""), "a {\n b: &;\n}\n");
assert_eq!(go("\"&\", \".x\""), "a {\n b: & .x;\n}\n");
assert_eq!(go("\"&.suffix\""), "a {\n b: &.suffix;\n}\n");
assert_eq!(go("\"c, d\", \"e, f\""), "a {\n b: c e, c f, d e, d f;\n}\n");
assert!(compile(
"@use \"sass:selector\";\na {b: selector.nest(\"&c\")}\n",
&Options::default()
)
.is_err());
}
#[test]
fn module_scoped_extend() {
// dart-sass per-module ExtensionStores: an @extend affects the module's
// own CSS and its transitive upstreams — never siblings or downstream —
// and a chained extension only links when the outer extension's origin is
// visible to the inner one. Private placeholders are module-private.
let dir = std::env::temp_dir().join(format!("sasso_scoped_ext_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// Siblings don't see each other.
std::fs::write(
dir.join("_left.scss"),
"left-e {in: left}\nlx {@extend right-e !optional}\n",
)
.unwrap();
std::fs::write(
dir.join("_right.scss"),
"right-e {in: right}\nrx {@extend left-e !optional}\n",
)
.unwrap();
assert_eq!(
compile("@use \"left\";\n@use \"right\";\n", &opts).expect("sibling"),
"left-e {\n in: left;\n}\n\nright-e {\n in: right;\n}\n"
);
// Upstream extends work; downstream don't.
std::fs::write(dir.join("_up.scss"), "up-style {a: b}\n").unwrap();
assert_eq!(
compile("@use \"up\";\nme {@extend up-style}\n", &opts).expect("upstream"),
"up-style, me {\n a: b;\n}\n"
);
// Diamond: both sides extend shared, but don't chain through each other.
std::fs::write(dir.join("_shared.scss"), "in-shared {x: y}\n").unwrap();
std::fs::write(
dir.join("_dl.scss"),
"@use \"shared\";\nleft-e2 {@extend in-shared}\nl2 {@extend right-e2 !optional}\n",
)
.unwrap();
std::fs::write(
dir.join("_dr.scss"),
"@use \"shared\";\nright-e2 {@extend in-shared}\nr2 {@extend left-e2 !optional}\n",
)
.unwrap();
assert_eq!(
compile("@use \"dl\";\n@use \"dr\";\n", &opts).expect("diamond"),
"in-shared, right-e2, left-e2 {\n x: y;\n}\n"
);
// A private placeholder can't be extended from another module.
std::fs::write(dir.join("_po.scss"), "%-priv {x: y}\nin-other {@extend %-priv}\n").unwrap();
assert_eq!(
compile("@use \"po\";\nme {@extend %-priv !optional}\n", &opts).expect("private"),
"in-other {\n x: y;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn midstream_pseudo_extender_replaced_across_modules() {
// An extender added to an UPSTREAM module's CSS is not one of that
// store's originals, so an in-place pseudo rewrite replaces it; in the
// same file both forms survive (dart-sass _originals are store-wide).
let dir = std::env::temp_dir().join(format!("sasso_midstream_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
std::fs::write(dir.join("_up.scss"), "in-upstream {a: b}\n").unwrap();
assert_eq!(
compile(
"@use \"up\";\n:is(in-midstream) {@extend in-upstream}\n\nin-input {\n @extend in-midstream;\n y: z;\n}\n",
&opts
)
.expect("midstream"),
"in-upstream, :is(in-midstream, in-input) {\n a: b;\n}\n\nin-input {\n y: z;\n}\n"
);
// Same-file: both the added extender and its rewrite survive.
assert_eq!(
ours(":is(midstream) {@extend upstream}\n\ndownstream {@extend midstream}\n\nupstream {a: b}\n"),
"upstream, :is(midstream), :is(midstream, downstream) {\n a: b;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sass_whitespace_map_keys_and_decl_children() {
// A map key may be separated from its colon by whitespace: the space list
// ends at `:` so the paren handler still sees a key/value pair.
assert_eq!(
ours("$a: (b\n : c, d: e);\nx {y: map-get($a, b)}\n"),
"x {\n y: c;\n}\n"
);
assert_sass_parity("$a: (b\n : c, d: e)\nx\n y: map-get($a, b)\n");
// A declaration WITH a value whose child block contains a bare
// non-declaration line is dart's `expected ":".` error.
let err = compile(
"a\n b: c,\n d\n",
&Options::default().with_syntax(sasso::Syntax::Sass),
)
.expect_err("bare child line under a valued declaration must error");
assert!(
err.to_string().contains("expected \":\"."),
"unexpected error: {err}"
);
}
#[test]
fn selector_paren_validation_and_import_supports_collapse() {
// A top-level `(` in a selector is only valid as a pseudo argument list;
// a stray `)` is its own error (dart-sass "expected selector." /
// `Unexpected ")".`).
for bad in [
"a(b) {x: y}",
"a (b) {x: y}",
"(b) {x: y}",
"*(b) {x: y}",
"a[u](b) {x: y}",
"a#{\"(b)\"} {x: y}",
"a:nth-child (2n) {x: y}",
"a) {x: y}",
] {
assert!(compile(bad, &Options::default()).is_err(), "should reject: {bad}");
}
for good in [
":not(b) {x: y}",
"a:hover(b) {x: y}",
":#{\"not\"}(b) {x: y}",
":has(> a) {x: y}",
"::part(x) {a: b}",
":-webkit-any(a) {x: y}",
] {
assert!(
compile(good, &Options::default()).is_ok(),
"should accept: {good}"
);
assert_parity(good);
}
// An `@import` supports(...) condition is an expression part of the
// modifiers interpolation: its serialized text gets the unquoted-string
// newline collapse. A Raw function modifier is verbatim text — preserved.
assert_eq!(
ours("@import \"a.css\" supports(a(\n b));\n"),
"@import \"a.css\" supports(a( b));\n"
);
assert_eq!(
ours("@import \"a.css\" supports(a(b\n ));\n"),
"@import \"a.css\" supports(a(b ));\n"
);
assert_eq!(
ours("@import \"a.css\" foo(b\n c);\n"),
"@import \"a.css\" foo(b\n c);\n"
);
assert_parity("@import \"a.css\" supports(a(b\n\n c));\n");
}
#[test]
fn sass_bare_plus_selector_and_using_block() {
// A bare `+` line is the next-sibling combinator selector (like `+ a`),
// NOT the include shorthand — only `+name` includes a mixin.
assert_sass_parity("d\n +\n a\n x: y\n");
assert_eq!(ours_sass("@mixin a\n b: c\nd\n +\n a\n"), "");
assert_eq!(ours_sass("@mixin a\n b: c\nd\n +a\n"), "d {\n b: c;\n}\n");
// `=` alone still continues onto the next line as `@mixin`.
assert_eq!(ours_sass("=\n a\n\nd\n @include a\n"), "");
// An `@include … using (…)` with no child block takes an EMPTY content
// block (SCSS `@include a() using ();` is 'expected "{".').
assert_eq!(ours_sass("@mixin a\n @content\n@include a() using\n ()\n"), "");
}
#[test]
fn sass_comment_blocks_and_continuation_rules() {
// Multi-line loud comments keep each line's source column behind ` *`,
// preserve interior blank lines, and treat `\f` as a line separator.
assert_eq!(ours_sass("/*\n Multi\n line\n"), "/* Multi\n * line */\n");
assert_eq!(
ours_sass("/* Handles\n weird\n indentation\n"),
"/* Handles\n * weird\n * indentation */\n"
);
assert_eq!(ours_sass("/* Keeps\n\n empty\n"), "/* Keeps\n *\n * empty */\n");
assert_eq!(ours_sass("/*\n foo\u{c} bar\n"), "/* foo\n * bar */\n");
// An open interpolation joins continuation lines as expression text.
assert_eq!(ours_sass("/* #{a \n + b} */\n"), "/* ab */\n");
// A trailing comma does NOT continue a directive prelude (`@each $a in b,`
// iterates `(b,)`; the deeper lines are its body) — but a `@use`/`@forward`
// member list does continue.
assert_eq!(
ours_sass("@each $a in b,\n c\n .#{$a}\n d: $a\n"),
"c .b {\n d: b;\n}\n"
);
// `!` awaits `important` on the next line; anything else is dart's
// hard `Expected "important".` (also in SCSS: no silent backtrack).
assert_eq!(
ours_sass("a\n b: c!\n important\n"),
"a {\n b: c !important;\n}\n"
);
assert!(compile("a {b: c !ie;}", &Options::default()).is_err());
}
#[test]
fn sass_custom_props_spread_and_inline_comments() {
// Only an open bracket or interpolation continues a custom-property
// value; any other indented child is dart's error.
assert!(compile(
".x\n --foo: bar\n baz: qux\n",
&Options::default().with_syntax(sasso::Syntax::Sass)
)
.is_err());
assert_eq!(
ours_sass("a\n --b: (c\n d)\n"),
"a {\n --b: (c\n d);\n}\n"
);
// Whitespace (and newlines) may separate a splat value from its `...`.
assert_eq!(
ours("@function a($b, $c) {@return null}\n$d: e, f;\n$g: a($d\n ...);\nx {y: $g}\n"),
""
);
// A trailing loud comment is invisible to selector-comma continuation
// and tolerated (dropped) after an explicit `;`.
assert_eq!(ours_sass("a, /* c */\nb\n x: y\n"), "a,\nb {\n x: y;\n}\n");
assert_eq!(
ours_sass("a\n b: c; /* f */\n d: e;\n"),
"a {\n b: c;\n d: e;\n}\n"
);
// A selector list whose FIRST comma part is empty is "expected selector."
// (later empty parts are tolerated).
assert!(compile(",b {x: y}", &Options::default()).is_err());
assert_parity("a, ,b {x: y}\n");
}
#[test]
fn sass_statement_escape_and_nested_linebreaks() {
// A leading `\` escapes the statement into a style rule and is consumed
// (the legacy `\:hover` disambiguation; SCSS keeps the backslash).
assert_eq!(
ours_sass("\\:hover TD\n color: red\n"),
":hover TD {\n color: red;\n}\n"
);
assert_eq!(
ours_sass("\\:color red\n foo: bar\n"),
":color red {\n foo: bar;\n}\n"
);
// A nested complex selector starts a fresh output line when its own
// comma part did OR its parent did.
assert_eq!(
ours("a,\nb {\n c, d {x: y}\n}\n"),
"a c, a d,\nb c,\nb d {\n x: y;\n}\n"
);
assert_parity("a,\nb {\n c, d {x: y}\n}\n");
}
#[test]
fn sass_value_operator_continuation_and_unquoted_imports() {
// A declaration value continues on a trailing binary operator.
assert_eq!(ours_sass("a\n b: 3 %\n 2\n"), "a {\n b: 1;\n}\n");
assert_eq!(ours_sass("a\n b: 3 +\n 2\n"), "a {\n b: 5;\n}\n");
assert_eq!(ours_sass("a\n b: true and\n false\n"), "a {\n b: false;\n}\n");
// `3%` is a complete percent unit and `c-` ends an identifier — no join.
assert_eq!(ours_sass("a\n b: 3%\n"), "a {\n b: 3%;\n}\n");
// An unquoted indented-syntax `@import` URL is quoted for the SCSS
// grammar; a `.css` one stays a plain-CSS import.
assert_eq!(ours_sass("@import other.css\n"), "@import \"other.css\";\n");
}
#[test]
fn interpolated_function_name_is_plain_css_call() {
// An interpolated identifier directly followed by `(` is a plain-CSS
// call: the name resolves, the args evaluate, and the call serializes
// verbatim — never dispatched to a built-in or user function.
assert_eq!(
ours(".x {a: qu#{o}te(arg); b: foo#{1 + 1}bar(2 + 2); c: #{foo}(arg)}\n"),
".x {\n a: quote(arg);\n b: foo2bar(4);\n c: foo(arg);\n}\n"
);
assert_parity(".x {a: qu#{o}te(arg); b: #{1 + 1}foo(arg)}\n");
// Splats expand; keyword arguments are rejected.
assert_eq!(
ours("$l: 1, 2, 3;\n.x {a: f#{o}o($l...)}\n"),
".x {\n a: foo(1, 2, 3);\n}\n"
);
assert!(
ours_err(".x {a: f#{o}o($b: 1)}\n").contains("Plain CSS functions don't support keyword arguments.")
);
}
#[test]
fn splat_list_separator_survives_into_arglist() {
// A splatted list's separator survives into the callee's rest arglist:
// `foo(c d e...)` binds the rest param as a SPACE-separated arglist.
assert_eq!(
ours("@mixin m($x, $zs...) {z: $zs}\na {@include m(a, c d e...)}\n"),
"a {\n z: c d e;\n}\n"
);
assert_eq!(
ours("@mixin m($x, $zs...) {z: $zs}\na {@include m(a, (c, d, e)...)}\n"),
"a {\n z: c, d, e;\n}\n"
);
assert_parity("@mixin m($x, $zs...) {z: $zs}\na {@include m(a, c d e...)}\n");
}
#[test]
fn css_custom_callable_result_rules_and_string_line_continuation() {
// In a plain-CSS custom callable body, an interpolated property follows
// the nested-property rules (`#{re}sult: {b: c}` -> `result-b: c`)...
assert_eq!(
ours("@function --a() {#{re}sult: {b: c}}\n"),
"@function --a() {\n result-b: c;\n}\n"
);
// ...while a literal `result` keeps a braced value verbatim, and in the
// indented syntax may not have an indented child block.
assert_eq!(
ours("@function --a() {result: {b: c}}\n"),
"@function --a() {\n result: {b: c};\n}\n"
);
let err = compile(
"@function --a()\n result:\n b: c\n",
&Options::default().with_syntax(sasso::Syntax::Sass),
)
.expect_err("indented child beneath result must error");
assert!(err
.to_string()
.contains("Nothing may be indented beneath a @function result."));
// A `\`+newline inside an open quoted string is a CSS line continuation:
// it vanishes and the next line's indentation stays part of the string.
assert_eq!(
ours_sass("a \n b: 'line1 \\\n line2'\n"),
"a {\n b: \"line1 line2\";\n}\n"
);
}
#[test]
fn space_list_atom_adjacency() {
// dart-sass's space list doesn't require whitespace between atoms:
// a touching atom start begins a new term.
assert_eq!(ours("a {b: (x)y}\n"), "a {\n b: x y;\n}\n");
assert_eq!(ours("a {b: 5px(3)}\n"), "a {\n b: 5px 3;\n}\n");
assert_eq!(ours("a {b: x(y)z}\n"), "a {\n b: x(y) z;\n}\n");
assert_parity("a {b: (x)y; c: 5px(3); d: x(y)z}\n");
// Operators still bind tighter than adjacency.
assert_eq!(ours("a {b: 1+2}\n"), "a {\n b: 3;\n}\n");
}
#[test]
fn forwarded_members_bind_their_defining_module() {
let dir = std::env::temp_dir().join("sasso_parity_fwd_origin");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("_upstream.scss"),
"$a: old value;\n@function get-a() {@return $a}\n",
)
.unwrap();
std::fs::write(dir.join("_midstream.scss"), "@forward \"upstream\" as d-*;\n").unwrap();
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// A prefixed forwarded variable assignment writes through to the
// defining module, and the forwarded function executes there.
assert_eq!(
compile(
"@use \"midstream\";\nmidstream.$d-a: new value;\nb {c: midstream.d-get-a()}\n",
&opts
)
.unwrap(),
"b {\n c: new value;\n}\n"
);
// A module's own same-named variable shadows the forwarded one for
// READS, but a namespaced assignment still writes the forwarded module.
std::fs::write(
dir.join("_shadow.scss"),
"@forward \"upstream\";\n$a: shadow value;\n@function get-shadow-a() {@return $a}\n",
)
.unwrap();
assert_eq!(
compile("@use \"shadow\";\nshadow.$a: new value;\nb {c: shadow.$a; s: shadow.get-shadow-a(); u: shadow.get-a()}\n", &opts).unwrap(),
"b {\n c: shadow value;\n s: shadow value;\n u: new value;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn nested_import_forward_members_and_override() {
let dir = std::env::temp_dir().join("sasso_parity_itf");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
w(
"_upstream.scss",
"@mixin b() {c: d}\n$v: old;\n@function get-v() {@return $v}\n",
);
w("_midstream.scss", "@forward \"upstream\";\n");
w("_up1.scss", "$b: 1;\n");
w("_up2.scss", "$b: 2;\n");
w("_mid1.scss", "@forward \"up1\";\n");
w("_mid2.scss", "@forward \"up2\";\n");
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// A nested `@import`'s forwarded mixins/functions are callable inside
// the rule (and a local assignment writes the forwarded variable)...
assert_eq!(
compile(
"a {\n @import \"midstream\";\n @include b;\n $v: new;\n e: get-v();\n}\n",
&opts
)
.unwrap(),
"a {\n c: d;\n e: new;\n}\n"
);
// ...but they are NOT visible outside the rule.
assert!(compile("a {@import \"midstream\"}\nb {@include b}\n", &opts).is_err());
// A same-named variable forwarded from a DIFFERENT module overrides the
// previous import's binding (sass/dart-sass#888).
assert_eq!(
compile(
"@import \"mid1\";\nf {a: $b}\n@import \"mid2\";\ns {a: $b}\n",
&opts
)
.unwrap(),
"f {\n a: 1;\n}\n\ns {\n a: 2;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_css_copies_belong_to_their_caller() {
let dir = std::env::temp_dir().join("sasso_parity_lcx");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
w("_other.scss", "a {b: c}\n");
w(
"_left.scss",
"@use \"sass:meta\";\n@include meta.load-css(\"other\");\nleft {@extend a}\n",
);
w(
"_right.scss",
"@use \"sass:meta\";\n@include meta.load-css(\"other\");\nright {@extend a}\n",
);
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// Each load-css copy is spliced into ITS caller's tree: only that
// caller's extensions apply to it.
assert_eq!(
compile("@use \"left\";\n@use \"right\";\n", &opts).unwrap(),
"a, left {\n b: c;\n}\n\na, right {\n b: c;\n}\n"
);
// A built-in module loads as a no-op (no CSS, no error).
assert_eq!(
compile(
"@use \"sass:meta\";\n@include meta.load-css(\"sass:color\");\n",
&opts
)
.unwrap(),
""
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_css_subtree_clone_and_blank_gating() {
let dir = std::env::temp_dir().join("sasso_parity_scm");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
w("_upstream.scss", "@c;\n");
w("_midstream.scss", "@use 'upstream';\n");
w("_target.scss", "@use \"midstream\";\n\n.target {a: b}\n");
w(
"extender.scss",
"@use 'target';\n\n.extender {\n @extend .target;\n}\n",
);
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// load-css re-emits the module's whole subtree as a clone carrying that
// subtree's extensions; the main tree's copy stays untouched
// (sass/sass#3322).
assert_eq!(
compile(
"@use 'sass:meta';\n@use 'target';\n\n@include meta.load-css('extender');\n",
&opts
)
.unwrap(),
"@c;\n.target {\n a: b;\n}\n\n@c;\n.target, .extender {\n a: b;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
// A blank line only follows a completed style rule (dart isGroupEnd):
// consecutive @media blocks and at-rule-then-rule join tightly.
assert_eq!(
ours("@media a {x {p: q}}\n\n@media b {y {p: q}}\n"),
"@media a {\n x {\n p: q;\n }\n}\n@media b {\n y {\n p: q;\n }\n}\n"
);
assert_parity("a {x: y}\n\n@c;\n");
assert_parity("@media a {x {p: q}}\n\n@media b {y {p: q}}\n");
}
#[test]
fn keyframes_nested_at_rules_and_selector_normalization() {
// A keyframe block is not a style rule: a nested at-rule stays inside
// the frame instead of bubbling out.
assert_eq!(
ours("@keyframes a {\n to {@media screen {b: c}}\n}\n"),
"@keyframes a {\n to {\n @media screen {\n b: c;\n }\n }\n}\n"
);
// A percentage stop's scientific-notation marker lowercases; the digits
// stay verbatim, and `+` is not a combinator here.
assert_eq!(
ours("@keyframes a {\n 130E-1% {c: d}\n}\n"),
"@keyframes a {\n 130e-1% {\n c: d;\n }\n}\n"
);
assert_parity("@keyframes a {\n 13E+1% {c: d}\n}\n");
assert_parity("@keyframes a {\n from {c: d}\n 50.5% {e: f}\n}\n");
}
#[test]
fn nested_media_merge_bubbles_in_source_order() {
// A mergeable nested @media bubbles out of its enclosing media rule,
// slicing the outer rule's own children into segments around it at the
// source position (dart-sass#453).
assert_eq!(
ours("@media screen {\n a {b: c}\n @media (color) {x {y: z}}\n}\n"),
"@media screen {\n a {\n b: c;\n }\n}\n@media screen and (color) {\n x {\n y: z;\n }\n}\n"
);
assert_eq!(
ours("@media (r: after) {\n @media (a: b) {x {y: z}}\n a {b: c}\n}\n"),
"@media (r: after) and (a: b) {\n x {\n y: z;\n }\n}\n@media (r: after) {\n a {\n b: c;\n }\n}\n"
);
assert_parity("@media screen {\n a {b: c}\n @media (color) {x {y: z}}\n}\n");
assert_parity("@media (r: after) {\n @media (a: b) {x {y: z}}\n a {b: c}\n}\n");
// An unmergeable nested @media stays nested.
assert_parity("@media not screen {\n a {b: c}\n @media (color) {x {y: z}}\n}\n");
}
#[test]
fn media_interpolation_reparses_resolved_text() {
// Interpolation may span query boundaries: the RESOLVED prelude text is
// re-parsed (dart CssMediaQuery.parseList).
assert_eq!(
ours("@media scr#{\"een, pri\"}nt a#{\"nd (max-width: 300px)\"} {x {y: z}}\n"),
"@media screen, print and (max-width: 300px) {\n x {\n y: z;\n }\n}\n"
);
// A parenthesised condition survives verbatim through the re-parse
// (no `and` normalization inside raw parens).
assert_eq!(
ours("@media (#{\"(a) AnD (b)\"}) {x {y: z}}\n"),
"@media ((a) AnD (b)) {\n x {\n y: z;\n }\n}\n"
);
// A modifier keeps its original case, and `or` after interpolation is
// still a parse error (the stylesheet grammar stays strict).
assert_parity("@media ONLY screen {x {y: z}}\n");
assert!(compile("@media #{\"(a)\"} or (b) {x {y: z}}\n", &Options::default()).is_err());
assert_parity("@media bar#{12} {x {y: z}}\n");
}
#[test]
fn import_subtree_clones_and_extension_store_order() {
let dir = std::env::temp_dir().join("sasso_parity_impclone");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
w("_shared.scss", "shared {x: y}\n");
w("_used.scss", "@use \"shared\";\nin-used {@extend shared}\n");
w(
"_imported.scss",
"@use \"shared\";\nin-imported {@extend shared}\n",
);
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// A module-loading @import clones the whole import subtree at the import
// site: the used extend applies to the original, the imported extend to
// both (it is downstream of the used module). Upstream extension stores
// come first; same-store extenders show in reverse source order.
assert_eq!(
compile("@use \"used\";\n@import \"imported\";\n", &opts).unwrap(),
"shared, in-used, in-imported {\n x: y;\n}\n\nshared, in-imported {\n x: y;\n}\n"
);
// A module first loaded inside an import clone still emits its main-tree
// copy at the next plain @use.
w("_importer.scss", "@import \"imported\";\n");
assert_eq!(
compile("@use \"importer\";\n@use \"used\";\n", &opts).unwrap(),
"shared, in-imported {\n x: y;\n}\n\nshared, in-used {\n x: y;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
// Same-module extenders alone keep dart's reverse source order.
assert_eq!(
ours("s {x: y}\nfirst {@extend s}\nsecond {@extend s}\n"),
"s, second, first {\n x: y;\n}\n"
);
assert_parity("s {x: y}\nfirst {@extend s}\nsecond {@extend s}\n");
}
#[test]
fn relative_resolution_and_distributed_config() {
let dir = std::env::temp_dir().join("sasso_parity_relres");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("module/a")).unwrap();
std::fs::create_dir_all(dir.join("subdir")).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
// Relative URLs resolve against the containing FILE's directory.
w("module/_index.scss", "@forward './a/a1';\n@forward './a/a2';\n");
w("module/a/_variables.scss", "$a: default !default;\n");
w(
"module/a/a1.scss",
"@forward './variables';\n@use './variables' as *;\n.a1 {content: #{$a}}\n",
);
w(
"module/a/a2.scss",
"@forward './variables';\n@use './variables' as *;\n.a2 {content: #{$a}}\n",
);
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// A `with (...)` distributed through several forwards keeps one original
// identity: re-reaching the shared upstream is not "already loaded", and
// two forwards of the SAME member don't conflict.
assert_eq!(
compile("@use 'module' with ($a: 'a');\n", &opts).unwrap(),
".a1 {\n content: a;\n}\n\n.a2 {\n content: a;\n}\n"
);
// meta.load-css resolves relative to the DEFINING file of the mixin.
w("_upstream.scss", "a {b: in main}\n");
w("subdir/_upstream.scss", "a {b: in subdir}\n");
w(
"subdir/_midstream.scss",
"@use 'sass:meta';\n@mixin load-css($m) {@include meta.load-css($m)}\n",
);
assert_eq!(
compile(
"@use 'subdir/midstream';\n@include midstream.load-css('upstream');\n",
&opts
)
.unwrap(),
"a {\n b: in subdir;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn use_url_dotdot_and_nested_global_writethrough() {
let dir = std::env::temp_dir().join("sasso_parity_dotdot");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("foo/baz/qux")).unwrap();
let w = |n: &str, b: &str| std::fs::write(dir.join(n), b).unwrap();
w("foo/baz/qux/other.scss", "$variable: value;\n");
w(
"other.scss",
"$member: value;\n@function get-member() {@return $member}\n",
);
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
// `..` segments normalize lexically (foo/bar need not exist); the
// namespace is the URL basename.
assert_eq!(
compile(
"@use \"foo/bar/../baz/qux/other\";\na {b: other.$variable}\n",
&opts
)
.unwrap(),
"a {\n b: value;\n}\n"
);
// A nested un-namespaced `!global` assignment writes through to the one
// `as *` module that defines the variable.
assert_eq!(
compile(
"@use \"other\" as *;\na {\n $member: new value !global;\n b: get-member();\n}\n",
&opts
)
.unwrap(),
"a {\n b: new value;\n}\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn meta_builtin_module_introspection() {
// sass:meta's own members are enumerable; variables are none.
assert_eq!(
ours("@use \"sass:map\";\n@use \"sass:meta\";\n$m: meta.module-mixins(\"meta\");\na {x: map.has-key($m, \"load-css\"); t: meta.type-of($m)}\n"),
"a {\n x: true;\n t: map;\n}\n"
);
assert_eq!(
ours("@use \"sass:meta\";\na {b: meta.inspect(meta.module-variables(\"meta\"))}\n"),
"a {\n b: ();\n}\n"
);
// A captured builtin meta function dispatches through the evaluator
// (variable-exists needs scopes).
assert_eq!(
ours("@use \"sass:map\";\n@use \"sass:meta\";\n$functions: meta.module-functions(\"meta\");\na {x: meta.call(map.get($functions, \"variable-exists\"), \"functions\")}\n"),
"a {\n x: true;\n}\n"
);
// get-function with a builtin $module; its inspect form.
assert_eq!(
ours("@use \"sass:meta\";\na {v: meta.inspect(meta.get-function(\"get-function\", $module: \"meta\"))}\n"),
"a {\n v: get-function(\"get-function\");\n}\n"
);
// A bracketed single-element trailing-comma list keeps its separator.
assert_eq!(
ours("@use \"sass:meta\";\na {v: meta.inspect([1,])}\n"),
"a {\n v: [1,];\n}\n"
);
assert_parity(
"@use \"sass:meta\";\na {v: meta.inspect([1,]); w: meta.inspect([1, 2]); x: meta.inspect([])}\n",
);
}
#[test]
fn deep_media_chains_and_content_forwarding() {
// A three-level mergeable media chain re-bubbles every batch (the
// hoist queue is per-marker batches).
assert_parity("@media all {\n .bar {a: b}\n @media (min-width: 1px) {\n .baz {a: b}\n @media (max-width: 2em) {.foo {a: b}}\n }\n}\n");
// A bubbling media does NOT split the enclosing rule's own block: the
// declarations around it stay together and the merged rule follows.
assert_eq!(
ours("@media only screen {\n .foo {\n a: b;\n @media (min-width: 1px) {c: d}\n e: f;\n }\n}\n"),
"@media only screen {\n .foo {\n a: b;\n e: f;\n }\n}\n@media only screen and (min-width: 1px) {\n .foo {\n c: d;\n }\n}\n"
);
// A completed top-level style rule separates from the next group even
// when its output ended in a bubbled at-rule.
assert_parity(".foo {\n @media all {a: b}\n}\n.bar {c: d}\n");
// A recursive mixin that forwards @content terminates: the block runs
// with the content context of its DEFINITION site.
assert_eq!(
ours("@mixin m($l...) {\n @if length($l) == 0 {@content;}\n @else {@include m() {@content;}}\n}\na {@include m(x) {content: bar}}\n"),
"a {\n content: bar;\n}\n"
);
}
#[test]
fn interpolated_at_rule_names_and_forbidden_contexts() {
// An at-rule with an interpolated NAME is always generic (no Sass
// parse-time behavior) — except @keyframes, resolved at eval time.
assert_parity("@#{\"plain\"} value;\n@#{\"block\"} {x: y}\n");
assert_eq!(
ours("@#{\"media\"} ($var: value) {\n .x {y: z}\n}\n"),
"@media ($var: value) {\n .x {\n y: z;\n }\n}\n"
);
assert_eq!(
ours("@#{\"keyframes\"} name {\n 10% {x: y}\n}\n"),
"@keyframes name {\n 10% {\n x: y;\n }\n}\n"
);
assert_eq!(
ours("@#{\"error\"} not really an error;\n"),
"@error not really an error;\n"
);
// Unknown at-rules (interpolated or not) are parse errors in function
// bodies and nested property sets.
for bad in [
"@function f() {@asdf; @return null;}",
".x {y: {@asdf;}}",
"@function f() {@#{\"asdf\"}; @return null;}",
".x {y: {@#{\"asdf\"};}}",
] {
let err = ours_err(bad);
assert!(err.contains("This at-rule is not allowed here."), "{bad}: {err}");
}
}
#[test]
fn attribute_and_pseudo_arg_canonicalization() {
// Attribute selectors canonicalize: operator whitespace drops and a
// plain-identifier quoted value loses its quotes — so an interpolated
// extend target matches its rule.
assert_eq!(
ours("[baz^=\"blip12px\"] {a: b}\n.bar {@extend [baz^=\"blip#{12px}\"]}\n"),
"[baz^=blip12px], .bar {\n a: b;\n}\n"
);
assert_parity("a[b=\"c\"] {x: y}\na[d = e] {x: y}\na[f=\"g h\"] {x: y}\n");
// A selector-argument pseudo re-serializes canonically, so equal-modulo-
// whitespace `:not()` arguments unify into one.
assert_eq!(
ours("%-a :not([a=b]).baz {a: b}\n:not([a = b]) {@extend .baz} -a {@extend %-a}\n"),
"-a :not([a=b]) {\n a: b;\n}\n"
);
assert_parity(":not([a = b]) {x: y}\n:is( .a , .b ) {x: y}\n");
}
#[test]
fn color_named_serialization_rounding() {
// A converted channel a few ulps past 255 still rounds to a named color
// (dart fuzzyRound): oklch/oklab/lch/lab → rgb white.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(color.to-space(white, oklch), rgb)}\n"),
"a {\n b: white;\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(color.to-space(white, lab), rgb)}\n"),
"a {\n b: white;\n}\n"
);
}
#[test]
fn color_same_family_powerless_ab() {
// dart's lch → lab conversion marks a/b powerless when the lightness is
// missing or fuzzy-zero (LabColorSpace.convert dest=lab); oklab has NO
// such rule — `oklch(none ...)` keeps its computed a/b.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(lch(none 20% 30deg), lab)}\n"),
"a {\n b: lab(none none none);\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(lch(0% 20% 30deg), lab)}\n"),
"a {\n b: lab(0% none none);\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(oklch(none 20% 30deg), oklab)}\n"),
"a {\n b: oklab(none 0.0692820323 0.04);\n}\n"
);
}
#[test]
fn color_to_gamut_round_trips() {
// dart's to-gamut ALWAYS round-trips toSpace(space) → map → back, even
// in gamut: the back leg legacy-fills missing channels and the polar
// round trip marks a powerless hue `none`.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-gamut(rgb(none none none), $space: display-p3, $method: clip)}\n"),
"a {\n b: black;\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-gamut(hsl(none 50% 50%), $space: hwb, $method: clip)}\n"),
"a {\n b: hsl(0, 50%, 50%);\n}\n"
);
assert_eq!(
ours(
"@use \"sass:color\";\na {b: color.to-gamut(oklch(10% 0% 0deg), $space: srgb, $method: clip)}\n"
),
"a {\n b: oklch(10% 0 none);\n}\n"
);
// An unbounded target space returns the color untouched.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-gamut(red, $space: lab, $method: clip)}\n"),
"a {\n b: red;\n}\n"
);
}
#[test]
fn hwb_construction_normalization() {
// dart normalizes whiteness + blackness > 100 at CONSTRUCTION; every
// read path (legacy getters, channel, inspect) sees normalized storage.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.blackness(hwb(0 70% 70%))}\n"),
"a {\n b: 50%;\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.whiteness(hwb(0 70% 50%))}\n"),
"a {\n b: 58.3333333333%;\n}\n"
);
// The none-hue path normalizes too.
assert_eq!(
ours("@use \"sass:meta\";\na {b: meta.inspect(hwb(none 70% 70%))}\n"),
"a {\n b: hwb(none 50% 50%);\n}\n"
);
// `change` re-normalizes; `adjust`/`scale` results stay raw.
assert_eq!(
ours("@use \"sass:color\";\n@use \"sass:meta\";\na {b: meta.inspect(color.change(hwb(0 30% 30%), $whiteness: 90%))}\n"),
"a {\n b: hwb(0 75% 25%);\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\n@use \"sass:meta\";\na {b: meta.inspect(color.adjust(hwb(0 30% 30%), $whiteness: 60%))}\n"),
"a {\n b: hwb(0 90% 30%);\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.channel(color.adjust(hwb(0 30% 30%), $whiteness: 60%), \"whiteness\")}\n"),
"a {\n b: 90%;\n}\n"
);
// The legacy scale keyword path keeps the hwb space and raw channels.
assert_eq!(
ours("@use \"sass:color\";\n@use \"sass:meta\";\na {b: meta.inspect(color.scale(hwb(0 30% 30%), $whiteness: 90%))}\n"),
"a {\n b: hwb(0 93% 30%);\n}\n"
);
}
#[test]
fn color_inspect_forms() {
// inspect keeps hwb's own form (hue without `deg`, `/ alpha` tail) and
// prints an out-of-gamut rgb without the hsl reroute; CSS output of the
// same colors still uses the legacy route.
assert_eq!(
ours("@use \"sass:meta\";\na {b: meta.inspect(hwb(120 0% 0%))}\n"),
"a {\n b: hwb(120 0% 0%);\n}\n"
);
assert_eq!(
ours("@use \"sass:meta\";\na {b: meta.inspect(hwb(0 30% 40% / 0.5))}\n"),
"a {\n b: hwb(0 30% 40% / 0.5);\n}\n"
);
assert_eq!(
ours(
"@use \"sass:color\";\n@use \"sass:meta\";\na {b: meta.inspect(color.change(red, $red: 300))}\n"
),
"a {\n b: rgb(300, 0, 0);\n}\n"
);
assert_parity("a {b: hwb(120 0% 0%)}\n");
assert_parity("a {b: hwb(0 70% 70%)}\n");
}
#[test]
fn color_scale_past_bound_stays() {
// dart `_scaleChannel`: a channel already past the targeted bound stays
// put — scaling can't pull it back into range.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.scale(color(srgb 1.2 0.5 0.7), $red: 10%)}\n"),
"a {\n b: color(srgb 1.2 0.5 0.7);\n}\n"
);
assert_eq!(
ours("@use \"sass:color\";\na {b: color.scale(color(srgb -0.5 0.5 0.7), $red: -10%)}\n"),
"a {\n b: color(srgb -0.5 0.5 0.7);\n}\n"
);
// Still inside the bound: the normal lerp applies.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.scale(color(srgb 1.2 0.5 0.7), $red: -10%)}\n"),
"a {\n b: color(srgb 1.08 0.5 0.7);\n}\n"
);
}
#[test]
fn color_dart_vm_math_semantics() {
// dart's `math.pow(x, 3)` is the VM's `x*x*x` intrinsic, NOT libm pow —
// far-range oklab → xyz round-trips are bit-exact against the dart VM.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-gamut(color.change(oklab(50% 500 -999999), $lightness: 150%), $method: clip)}\n"),
"a {\n b: color-mix(in oklab, color(xyz 593644542057412224 -153762246556647904 3418717351297831936) 100%, black);\n}\n"
);
// dart `SassColor._normalizeHue` reduces every constructed polar hue via
// `(h % 360 + 360) % 360` — the fmod sequence perturbs the last ulp and
// the spec carries it (lab→lch hue here ends …024, not atan2's …008).
assert_eq!(
ours("@use \"sass:color\";\n@use \"sass:math\";\na {b: math.div(color.channel(color.to-space(lab(50% 1 2), lch), \"hue\"), 1deg) * 1e15}\n"),
"a {\n b: 63434948822922024;\n}\n"
);
// An infinite hue goes through the same fmod and lands on NaN.
assert_eq!(
ours("@use \"sass:meta\";\na {b: meta.inspect(lch(1% 2 calc(infinity)))}\n"),
"a {\n b: lch(1% 2 calc(NaN * 1deg));\n}\n"
);
// channel() builds a `%` number via `value * 100 / channel.max`; the
// round trip through ×100 ÷100 perturbs far-range values by one ulp.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.channel(color.to-space(color.change(black, $red: -999999), hwb), \"whiteness\") * 1e9}\n"),
"a {\n b: -392156470588235.4%;\n}\n"
);
}
#[test]
fn number_format_dart_tostring_semantics() {
// dart rounds the SHORTEST decimal spelling at the string level (11th
// digit, half-up): 2154.15598416745's true value is …44978 (below the
// half), but its shortest spelling ends in `5`, so dart rounds UP.
assert_eq!(
ours("a {b: 2154.15598416745}\n"),
"a {\n b: 2154.1559841675;\n}\n"
);
// ECMA toString tie-break: 657390374199289.25 has two equidistant
// shortest spellings; dart picks the EVEN final digit (…289.2).
assert_eq!(
ours("a {b: (657390374199289 + 0.25)}\n"),
"a {\n b: 657390374199289.2;\n}\n"
);
// Half-up at the 10-digit boundary still applies.
assert_eq!(ours("a {b: 0.00000000015}\n"), "a {\n b: 0.0000000002;\n}\n");
assert_eq!(ours("a {b: 1e-11}\n"), "a {\n b: 0;\n}\n");
}
#[test]
fn color_oklch_to_oklab_round_trips_lms() {
// dart's OklabColorSpace.convert has no same-space case: an oklch source
// bound for oklab cubes into LMS and cube-roots straight back.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(oklch(10% 999999 0deg), oklab)}\n"),
"a {\n b: oklab(9.9999999976% 999998.9999999992 0);\n}\n"
);
// A direct oklab→oklab conversion short-circuits at the toSpace level.
assert_eq!(
ours("@use \"sass:color\";\na {b: color.to-space(oklab(50% 0.1 0.1), oklab)}\n"),
"a {\n b: oklab(50% 0.1 0.1);\n}\n"
);
}
#[test]
fn unicode_identifiers() {
// Any non-ASCII code point is a valid identifier char (dart `isName`):
// variables, property values, and selectors carry them unescaped.
assert_eq!(
ours("$v\u{e4}r: foo;\nblat {a: $v\u{e4}r}\n"),
"blat {\n a: foo;\n}\n"
);
assert_eq!(
ours("@charset \"UTF-8\";\nfoo {\n bar: f\u{f6}\u{f6} b\u{e2}r; }\n"),
"@charset \"UTF-8\";\nfoo {\n bar: f\u{f6}\u{f6} b\u{e2}r;\n}\n"
);
}
#[test]
fn identifier_escape_decoding() {
// Identifier escapes decode at the lexer level: `@w\61rn` IS `@warn`,
// `@\69 f` is `@if`, and escaped function names normalize for dispatch.
assert_eq!(
ours("@function f\\6Fo-bar() {@return 1}\na {b: foo-b\\61r()}\n"),
"a {\n b: 1;\n}\n"
);
assert_eq!(ours("@\\69 f true {a {b: c}}\n"), "a {\n b: c;\n}\n");
// `@w\61rn` runs as @warn: output is just the rule.
assert_eq!(ours("@w\\61rn warning;\na {b: c}\n"), "a {\n b: c;\n}\n");
// A mid-identifier escape after an interpolation uses the name-CHAR
// rule (`#{foo}\-` is `foo-`), while a leading `\-` keeps its escape.
assert_eq!(
ours("a {b: #{foo}\\-; c: \\-#{foo}}\n"),
"a {\n b: foo-;\n c: \\-foo;\n}\n"
);
}
#[test]
fn selector_escape_terminating_space() {
// A hex escape's terminating space is part of the token: `\02e foo` is
// the single type selector `\.foo` (extendable), and a trailing
// `selector\9 ` keeps its space in the emitted selector.
assert_eq!(
ours(".foo {a: b}\n\\.foo {c: d}\n.bar {@extend \\02e foo}\n"),
".foo {\n a: b;\n}\n\n\\.foo, .bar {\n c: d;\n}\n"
);
assert_eq!(ours("selector\\9 { x: y; }\n"), "selector\\9 {\n x: y;\n}\n");
assert_eq!(
ours("@media screen\\9 { x {y: z} }\n"),
"@media screen\\9 {\n x {\n y: z;\n }\n}\n"
);
}
#[test]
fn at_root_parent_ref() {
// `&` inside @at-root resolves against the enclosing rule (dart
// _styleRuleIgnoringAtRoot) while plain selectors stay at the root
// (implicitParent: false), and deeper nesting re-enables the join.
assert_eq!(
ours(".foo {\n @at-root & {\n a: b;\n }\n}\n"),
".foo {\n a: b;\n}\n"
);
assert_eq!(
ours("foo {\n @at-root {\n & { color: blue; }\n &--modifier { color: red; }\n }\n}\n"),
"foo {\n color: blue;\n}\n\nfoo--modifier {\n color: red;\n}\n"
);
assert_eq!(
ours("test {\n @at-root {\n & {\n foo { bar: baz; }\n }\n }\n}\n"),
"test foo {\n bar: baz;\n}\n"
);
// @extend directly inside @at-root is outside any style rule.
assert!(compile(
".a { x: y }\n.b { @at-root { @extend .a } }\n",
&Options::default()
)
.is_err());
}
#[test]
fn at_root_queries() {
// dart AtRootQuery: `(without: media)` escapes the media layer but
// keeps the style rule (bare declarations re-wrap in the enclosing
// selector); `(without: all)` escapes everything; quoted names work.
assert_eq!(
ours("@media print {\n a {\n @at-root (without: media) {\n b: c;\n }\n }\n}\n"),
"a {\n b: c;\n}\n"
);
assert_eq!(
ours("@keyframes a {\n @at-root (without: all) {\n b {c: d}\n }\n}\n"),
"@keyframes a {}\nb {\n c: d;\n}\n"
);
// Kept layers re-wrap around the hoisted body at the root.
assert_eq!(
ours("@media screen {\n @supports (color: red) {\n @at-root (without: media) {\n .x {y: z}\n }\n }\n}\n"),
"@supports (color: red) {\n .x {\n y: z;\n }\n}\n"
);
// `(with: all)` excludes nothing and runs in place.
assert_eq!(
ours("@media screen {\n @at-root (with: \"all\") {\n .x {y: z}\n }\n}\n"),
"@media screen {\n .x {\n y: z;\n }\n}\n"
);
}
#[test]
fn extend_target_in_omitted_bogus_rule() {
// A bogus-combinator rule (`.a > + x`) is omitted from the CSS but
// still satisfies @extend target matching (dart keeps it in the extend
// graph); the extension result is bogus too, so the output is empty.
assert_eq!(ours(".a > + x {a: b}\n.b y {@extend x}\n"), "");
assert_eq!(ours(".a ~ > + .b > x {a: b}\n.c > + .d > y {@extend x}\n"), "");
// A genuinely missing target still errors.
assert!(compile(".a x {a: b}\n.b {@extend z}\n", &Options::default()).is_err());
}
#[test]
fn extend_trim_source_specificity() {
// dart _trim only drops a selector when a superselector ALSO has at
// least the victim's max SOURCE specificity: `.test-case` (1000) keeps
// `.test-case:active` whose extender weighed 2000.
assert_eq!(
ours(concat!(
"%default-color {color: red}\n%alt-color {color: green}\n",
"%default-style {\n@extend %default-color;\n&:hover {@extend %alt-color}\n",
"&:active {@extend %default-color}\n}\n",
".test-case {@extend %default-style}\n"
)),
".test-case:active, .test-case {\n color: red;\n}\n\n.test-case:hover {\n color: green;\n}\n"
);
}
#[test]
fn extend_transitive_multi_component_extender() {
// `c {@extend b}` inside `a` (extender `a c`) plus `d {@extend a}`
// yields `d c` — dart's _extendExistingExtensions re-extends the whole
// extender complex. (dart orders this `a b, d b, a c, d c`; our paths
// order differs pending the full _extendComplex order port.)
let out = ours("a {\nb {a: b}\nc {@extend b}\n}\nd {@extend a}\n");
let selectors = out.split(" {").next().unwrap();
for sel in ["a b", "d b", "a c", "d c"] {
assert!(
selectors.split(", ").any(|s| s == sel),
"missing {sel} in {selectors}"
);
}
}
#[test]
fn lone_percent_after_operand() {
// `%` is modulo only when an operand follows; a trailing `%` is a lone
// unquoted-string token (dart-sass css/percent).
assert_eq!(ours("a {b: c %}\n"), "a {\n b: c %;\n}\n");
assert_eq!(ours("d {e: f(g %)}\n"), "d {\n e: f(g %);\n}\n");
// Real modulo still works.
assert_eq!(ours("a {b: 7 % 3}\n"), "a {\n b: 1;\n}\n");
}
#[test]
fn selector_linebreaks_with_stray_commas() {
// A newline inside an empty comma part (stray commas) or BEFORE the
// comma still marks the next complex as line-broken.
assert_eq!(
ours("#foo #bar,,\n,#baz #boom, {a: b}\n"),
"#foo #bar,\n#baz #boom {\n a: b;\n}\n"
);
assert_eq!(
ours("a\n, b {\n z & {\n display: block;\n }\n}\n"),
"z a,\nz b {\n display: block;\n}\n"
);
}
#[test]
fn modulo_infinite_modulus() {
// dart moduloLikeSass: an infinite DIVISOR returns the dividend when
// signs agree and NaN otherwise; an infinite dividend is always NaN.
assert_eq!(ours("a {b: 1px % calc(infinity * 1px)}\n"), "a {\n b: 1px;\n}\n");
assert_eq!(
ours("a {b: -1px % calc(-infinity * 1px)}\n"),
"a {\n b: -1px;\n}\n"
);
assert_eq!(
ours("a {b: 1px % calc(-infinity * 1px)}\n"),
"a {\n b: calc(NaN * 1px);\n}\n"
);
assert_eq!(ours("a {b: calc(infinity) % 3}\n"), "a {\n b: calc(NaN);\n}\n");
}
#[test]
fn loud_comment_continuation_indent() {
// Continuation lines of a loud comment gain the CURRENT output
// indentation on top of their own source indentation.
assert_eq!(
ours(".foo {\n /* Foo\n Bar\nBaz */\n a: b; }\n"),
".foo {\n /* Foo\n Bar\n Baz */\n a: b;\n}\n"
);
}
#[test]
fn id_tokens_when_not_hex_colors() {
// `#` + identifier that isn't a valid color is an ID token (nav-up).
assert_eq!(
ours("a {b: #ab; c: #abcde; d: #abcg}\n"),
"a {\n b: #ab;\n c: #abcde;\n d: #abcg;\n}\n"
);
// Valid hex colors still parse as colors (color arithmetic errors).
assert!(compile("a {b: #abc + 1}\n", &Options::default()).is_err());
assert_eq!(ours("a {b: #abc}\n"), "a {\n b: #abc;\n}\n");
}
#[test]
fn url_quoted_string_normalizes() {
// A quoted url() is a normal function call whose string argument
// serializes canonically (double quotes preferred, single kept when the
// content has a double quote); unquoted urls stay plain tokens.
assert_eq!(ours("a {b: url('x.png')}\n"), "a {\n b: url(\"x.png\");\n}\n");
assert_eq!(
ours("e {f: url('it\"s.png')}\n"),
"e {\n f: url('it\"s.png');\n}\n"
);
assert_eq!(ours("e {f: url(plain.png)}\n"), "e {\n f: url(plain.png);\n}\n");
}
#[test]
fn nth_child_anb_canonicalizes_in_rules() {
// The An+B argument drops whitespace in plain rules too (not only
// through the extend machinery).
assert_eq!(
ours("li:nth-child(3n - 3) {a: b}\n"),
"li:nth-child(3n-3) {\n a: b;\n}\n"
);
assert_eq!(
ours("li:nth-child(-3n - 3) {a: b}\n"),
"li:nth-child(-3n-3) {\n a: b;\n}\n"
);
}
#[test]
fn duplicate_pseudo_preserved_through_extend() {
// `:baz:baz` is valid CSS; extension must not dedup the unchanged
// pseudos of the original compound.
assert_eq!(
ours(".foo:baz:baz {a: b}\n.bar {@extend .foo}\n"),
".foo:baz:baz, .bar:baz:baz {\n a: b;\n}\n"
);
}
#[test]
fn progid_string_interpolation() {
// `#{…}` inside a progid argument's quoted string still resolves.
assert_eq!(
ours(".foo {\n filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=\"#{foo}\", sizingMethod='scale');\n}\n"),
".foo {\n filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=\"foo\", sizingMethod='scale');\n}\n"
);
}
#[test]
fn blank_list_elements_vanish() {
// dart Value.isBlank: an empty unquoted string disappears from list
// serialization, separator included; whitespace-only stays.
assert_eq!(ours("a { a: foo #{\"\"}; }\n"), "a {\n a: foo;\n}\n");
assert_eq!(ours("b { b: foo #{\" \"}; }\n"), "b {\n b: foo ;\n}\n");
}
#[test]
fn media_feature_strings_unquote() {
// Media-feature names/values serialize in interpolation context.
assert_eq!(
ours("@media screen and (\"min-width:#{20px}\") { a { b: c } }\n"),
"@media screen and (min-width:20px) {\n a {\n b: c;\n }\n}\n"
);
assert_eq!(
ours("$s: \"20px\";\n@media (min-width: $s) { a { b: c } }\n"),
"@media (min-width: 20px) {\n a {\n b: c;\n }\n}\n"
);
}
#[test]
fn double_minus_starts_a_term() {
// `--` never subtracts: `1--em` is the space list `1 --em`, and
// `5--3` is `5 --3`; single-minus subtraction still works.
assert_eq!(
ours("foo {bar: 1--em-2--em; baz: 5--3; qux: 5-3}\n"),
"foo {\n bar: 1 --em-2--em;\n baz: 5 --3;\n qux: 2;\n}\n"
);
}
#[test]
fn at_rule_prelude_whitespace_collapses() {
// Whitespace runs in a generic at-rule prelude collapse to single
// spaces; quoted strings keep theirs.
assert_eq!(
ours(".foo {\n @apply ( --bar );\n}\n"),
".foo {\n @apply ( --bar );\n}\n"
);
}
#[test]
fn lexical_scoping_closures() {
// A mixin/function body runs against its DEFINITION environment (dart's
// Environment.closure), not the caller's stack: a caller-local variable
// is invisible inside the body.
assert!(compile(
"@mixin m() {\n a: $x;\n}\nfoo {\n $x: 1;\n @include m();\n}\n",
&Options::default()
)
.is_err());
// A local assignment inside a function stays local (issue_613): the
// caller's $var is untouched by the function's own $var.
assert_eq!(
ours("$v: 1;\n@function f() { $v: 3; @return 0; }\n.s { $v: 4; $d: f(); c: $v; }\n.o { c: $v; }\n"),
".s {\n c: 4;\n}\n\n.o {\n c: 1;\n}\n"
);
// Parameter defaults see already-bound parameters (progressive binding).
assert_eq!(
ours("@mixin m($a, $b: $a) { x: $a $b; }\nfoo { @include m(1); }\n"),
"foo {\n x: 1 1;\n}\n"
);
// A global write inside a mixin lands in the global scope and is visible
// through its closure afterwards.
assert_eq!(
ours("$g: old;\n@mixin set() { $g: new !global; }\nfoo { @include set(); v: $g; }\n"),
"foo {\n v: new;\n}\n"
);
}
#[test]
fn lexical_scoping_functions_and_mixins() {
// Functions and mixins are lexically scoped too (dart's parallel
// _functions/_mixins frames): a nested definition shadows an outer one
// only within its block (scss-tests 132/134).
assert_eq!(
ours("@mixin bar {a: b}\nfoo {\n @mixin bar {c: d}\n @include bar;\n}\nbaz {@include bar}\n"),
"foo {\n c: d;\n}\n\nbaz {\n a: b;\n}\n"
);
assert_eq!(
ours("@function foo() {@return 1}\nfoo {\n @function foo() {@return 2}\n a: foo();\n}\nbaz {b: foo()}\n"),
"foo {\n a: 2;\n}\n\nbaz {\n b: 1;\n}\n"
);
// A function defined inside a rule is invisible outside it: the outer
// call stays a plain CSS function (scss-tests 133).
assert_eq!(
ours("foo {\n @function foo() {@return 1}\n a: foo(); }\nbar {b: foo()}\n"),
"foo {\n a: 1;\n}\n\nbar {\n b: foo();\n}\n"
);
// A user function shadowing a built-in reverts to the built-in outside
// its block (functions-and-mixins).
assert_eq!(
ours("div {\n span {\n @function length($a, $b) { @return $a + $b; }\n w: length(1, 2);\n }\n h: length(a b c);\n}\n"),
"div span {\n w: 3;\n}\ndiv {\n h: 3;\n}\n"
);
}
#[test]
fn unit_conversion_is_case_sensitive_with_frequency() {
// dart's conversion table matches unit names exactly: `PX` and `px` are
// DIFFERENT units (addition errors), `1in + 1Q` errors while `1in + 1q`
// converts, and frequency `Hz`/`kHz` ARE convertible (canonical: Hz).
assert!(compile("a { v: 1PX + 1px; }", &Options::default()).is_err());
assert!(compile("a { v: 1in + 1Q; }", &Options::default()).is_err());
assert_eq!(ours("a { v: 1kHz + 1Hz; }"), "a {\n v: 1.001kHz;\n}\n");
assert_eq!(ours("a { v: 1PX + 2PX; }"), "a {\n v: 3PX;\n}\n");
// calc classification stays case-INSENSITIVE: same-class non-convertible
// pairs preserve, while the convertible pair folds.
assert_eq!(
ours("a { v: calc(1PX + 1px); }"),
"a {\n v: calc(1PX + 1px);\n}\n"
);
assert_eq!(ours("a { v: calc(1kHz + 1Hz); }"), "a {\n v: 1.001kHz;\n}\n");
}
#[test]
fn hyphens_and_underscores_interchange_in_names() {
// dart normalizes `_` to `-` in variable names at parse time and in
// function/mixin names at definition/lookup, so all four spellings
// resolve to the same member (52_interchangeable_hyphens_underscores).
assert_eq!(
ours("$my-var: 1;\n@mixin my-mix($p) { a: $p; b: $my_var; }\ndiv { @include my_mix(2); }\n"),
"div {\n a: 2;\n b: 1;\n}\n"
);
assert_eq!(
ours("@function blah_blah() { @return blah; }\ndiv { foo: blah-blah(); }\n"),
"div {\n foo: blah;\n}\n"
);
// A plain-CSS call keeps its original spelling (no user function).
assert_eq!(
ours("div { b: some_func(1); }\n"),
"div {\n b: some_func(1);\n}\n"
);
// `--`-prefixed calls are reserved for plain CSS: `--a()` never matches
// `@function __a` (even though both normalize to `--a`), and
// `@include --a` is a hard error.
assert_eq!(
ours("@function __a() {@return 1}\nb {c: --a(); d: __a()}\n"),
"b {\n c: --a();\n d: 1;\n}\n"
);
assert!(compile("@mixin __a() {b: c}\nd {@include --a}\n", &Options::default()).is_err());
}
#[test]
fn url_whitespace_falls_back_to_function_call() {
// A plain-URL token allows whitespace only directly before `)`: interior
// whitespace re-parses the call as a normal function, so SassScript
// evaluates (`url(foo + bar)` -> `url(foobar)`).
assert_eq!(
ours("div { c: url(foo + bar); b: url( foo ); a: url(foo bar); }\n"),
"div {\n c: url(foobar);\n b: url(foo);\n a: url(foo bar);\n}\n"
);
}
#[test]
fn call_results_are_slash_free_and_if_supports_splat() {
// dart applies withoutSlash() to every call result: extracting a
// slash-division value through a built-in resolves it.
assert_eq!(
ours("@use \"sass:list\";\na {b: list.nth(3 1/2 4, 2)}\n"),
"a {\n b: 0.5;\n}\n"
);
// if() accepts splat arguments (macro args evaluate the splat eagerly).
assert_eq!(ours("c {d: if(true, 1/2 null...)}\n"), "c {\n d: 0.5;\n}\n");
}
#[test]
fn important_is_a_value_term() {
// `!important` (any case, space after `!` allowed) is a value term, so it
// can appear mid-list and in mixin/function arguments; a stray `!default`
// after a declaration value still errors.
assert_eq!(
ours("@mixin foo($x) { style: $x; }\ndiv {\n @include foo(0px inset !important);\n fludge: foo bar ! Important hux;\n}\n"),
"div {\n style: 0px inset !important;\n fludge: foo bar !important hux;\n}\n"
);
assert!(compile("x { v: foo !default; }", &Options::default()).is_err());
}
#[test]
fn escaped_literal_space_survives_selector_normalization() {
// `sp\ ` (an escaped literal space) is part of the identifier: the
// whitespace collapse/trim in selector normalization must not eat it —
// at the end of a comma part, before a descendant space, or trailing.
assert_eq!(
ours("sp\\ , x {\n color: red;\n}\n"),
"sp\\ , x {\n color: red;\n}\n"
);
assert_eq!(
ours("div sp\\ p {\n color: red;\n}\n"),
"div sp\\ p {\n color: red;\n}\n"
);
assert_eq!(
ours("div sp\\ {\n color: red;\n}\n"),
"div sp\\ {\n color: red;\n}\n"
);
}
#[test]
fn plain_css_function_arg_validation() {
// dart: an unknown function is plain CSS — no keyword arguments, and an
// empty unbracketed list / a map has no CSS representation; `null`
// serializes to nothing and `[]` stays.
assert!(compile("foo { color: missing($a: b); }", &Options::default()).is_err());
assert!(compile("foo { foo: foo(()); }", &Options::default()).is_err());
assert!(compile("foo { foo: foo((a: b)); }", &Options::default()).is_err());
assert_eq!(
ours("x { v: foo(null); w: foo([]); }\n"),
"x {\n v: foo();\n w: foo([]);\n}\n"
);
// The BUILTIN min/max (via splat / meta.call) requires numbers.
assert!(compile(
"@use \"sass:meta\";\n$foo: 1 2 3 blah 4;\nfoo { bar: meta.call(min, $foo...); }",
&Options::default()
)
.is_err());
// A parent ending in a combinator can't substitute into a compound `&`.
assert!(compile(".a > { &.b { x: y; } }", &Options::default()).is_err());
assert_eq!(ours(".a > { & .b { x: y; } }\n"), ".a > .b {\n x: y;\n}\n");
}
#[test]
fn misc_error_validation_batch() {
// `@elseif` is a deprecated spelling of `@else if`.
assert_eq!(
ours("$x: 3px;\n@if $x == 2px { a { v: 1; } }\n@elseif $x == 3px { b { v: 2; } }\n"),
"b {\n v: 2;\n}\n"
);
// A declaration inside `@at-root` that no style rule wraps is an error.
assert!(compile(
"@mixin bar() { @at-root { @content; } }\n.test { @include bar() { color: yellow; } }",
&Options::default()
)
.is_err());
// `something\:` is a selector (the escape is never a declaration colon).
assert_eq!(
ours("something\\:{ padding: 2px; }\n"),
"something\\: {\n padding: 2px;\n}\n"
);
// `:nth-child()` requires an An+B argument.
assert!(compile("a:nth-child() { color: red; }", &Options::default()).is_err());
}
#[test]
fn extend_products_inherit_extender_linebreaks() {
// dart's ComplexSelector.lineBreak travels with the selector: when
// `@extend` adds the extenders to `.foo`'s rule, each added complex
// keeps its OWN source line break (issue_1574/2179).
assert_eq!(
ours(".foo { bar: baz; }\na,\nb,\nc { @extend .foo; }\n"),
".foo, a,\nb,\nc {\n bar: baz;\n}\n"
);
}
#[test]
fn pseudo_parent_ref_takes_whole_parent_list() {
// A `&` only inside a pseudo argument takes the WHOLE parent list in
// place — ONE complex, no cartesian expansion — with an optional ident
// suffix (libsass#2630).
assert_eq!(
ours(".a, .b {\n :not(&-c) {d: e}\n}\n"),
":not(.a-c, .b-c) {\n d: e;\n}\n"
);
assert_eq!(
ours(".a, .b {\n :not(&) {d: e}\n}\n"),
":not(.a, .b) {\n d: e;\n}\n"
);
// Mixed parts keep dart's order: the pseudo part resolves once, at its
// position in the FIRST parent round.
assert_eq!(
ours(".a, .b {\n x:hover &, :is(&) {d: e}\n}\n"),
"x:hover .a, :is(.a, .b), x:hover .b {\n d: e;\n}\n"
);
}
#[test]
fn unit_identifier_grammar() {
// dart `identifier(unit: true)`: a unit body may contain digits
// (`1a2b3c`), underscores, escapes (`1\65 m` is `em`), and may START
// with `-` when an identifier follows (`1-em` minus `2-em` is `-1-em`)
// — while `1--em` stays the list `1 --em` and `1- 2` subtracts.
assert_eq!(
ours("@use \"sass:meta\";\na { v: meta.type-of(1a2b3c); w: (1-em-2-em); x: (1\\65 _em); y: (1--em); z: (1- 2); }\n"),
"a {\n v: number;\n w: -1-em;\n x: 1e_em;\n y: 1 --em;\n z: -1;\n}\n"
);
}
#[test]
fn content_block_runs_in_child_scope() {
// A content block is a user-defined callable: a `$var:` first declared
// inside it stays local to the block, so a global-only variable is NOT
// overwritten (get_mixin content:scope/redeclare/vars).
assert_eq!(
ours("@mixin a { @content; }\n$g: global;\nfoo {\n $r: rule;\n @include a { $g: inner; $r: inner; }\n g: $g;\n r: $r;\n}\n"),
"foo {\n g: global;\n r: inner;\n}\n"
);
}
#[test]
fn trailing_comments_join_previous_line() {
// dart's trailing-comment serializer rule: a loud comment starting on the
// line the previous construct ended joins that line with a single space.
assert_eq!(ours("a {b: {c: d} /**/}\n"), "a {\n b-c: d; /**/\n}\n");
// A block whose ONLY child is a trailing comment stays on one line, with
// ` }` on the same line (the comment compares against the `{` line).
assert_eq!(ours("a {\n @font-face {/**/}\n}\n"), "@font-face { /**/ }\n");
assert_eq!(ours("a {\n @keyframes {/**/}\n}\n"), "@keyframes { /**/ }\n");
// A comment on its own source line keeps its own indented output line.
assert_eq!(
ours("a {\n b: c;\n /* own line */\n}\n"),
"a {\n b: c;\n /* own line */\n}\n"
);
}
#[test]
fn at_root_batches_graft_at_their_target_layer() {
// dart visitAtRootRule + _trimIncluded: an `@at-root (without: media)`
// batch re-enters the tree at the topmost EXCLUDED layer. Escaping the
// outermost at-rule appends the batch at the root AFTER the in-progress
// @media node (issue_1890) — never before it.
assert_eq!(
ours(".w {\n @media (min-width: 480px) {\n display: block;\n @at-root (without: media) {\n .box { display: inline-block; }\n }\n }\n}\n"),
"@media (min-width: 480px) {\n .w {\n display: block;\n }\n}\n.w .box {\n display: inline-block;\n}\n"
);
// Declarations before AND after the at-root join ONE style-rule copy in
// the original media (dart's entry copy has no following sibling inside
// the media node); a post-marker RULE splits into a media copy instead.
assert_eq!(
ours(".w {\n @media m {\n a: 1;\n @at-root (without: media) { .b { v: 1; } }\n .pm { c: 3; }\n d: 4;\n }\n}\n"),
"@media m {\n .w {\n a: 1;\n d: 4;\n }\n}\n.w .b {\n v: 1;\n}\n@media m {\n .w .pm {\n c: 3;\n }\n}\n"
);
// A kept layer ABOVE the excluded one is the graft root: the batch lands
// INSIDE the existing @supports node at its current end, and a later
// declaration copy follows it there.
assert_eq!(
ours(".w {\n @supports (display: flex) {\n @media m {\n a: 1;\n @at-root (without: media) { .box { b: 2; } }\n }\n c: 9;\n }\n}\n"),
"@supports (display: flex) {\n @media m {\n .w {\n a: 1;\n }\n }\n .w .box {\n b: 2;\n }\n .w {\n c: 9;\n }\n}\n"
);
// With no enclosing style rule dart marks the batch's last node as a
// group end: the next root node gets a blank line.
assert_eq!(
ours("@media a {\n .x { p: 1; }\n @at-root (without: media) { .b { v: 1; } }\n .z { r: 3; }\n}\n"),
"@media a {\n .x {\n p: 1;\n }\n}\n.b {\n v: 1;\n}\n\n@media a {\n .z {\n r: 3;\n }\n}\n"
);
}
#[test]
fn interpolation_skips_leading_whitespace_and_comments() {
// dart `singleInterpolation` runs whitespace() (which also consumes
// comments) BEFORE the expression: `#{ a }` parses everywhere, and a
// comment inside an interpolation ends at the first `*/` without being
// scanned for `#{` or quotes (issue_1798/3).
assert_eq!(
ours("a { content: \"#{ a /*#{\"}*/ }\"; }\n"),
"a {\n content: \"a\";\n}\n"
);
assert_eq!(ours("a { b: \"#{ 1 + 2}\"; }\n"), "a {\n b: \"3\";\n}\n");
// Selector templates and identifier templates share the rule.
assert_eq!(ours("a#{ \" b\"} { c: d; }\n"), "a b {\n c: d;\n}\n");
assert_eq!(ours("a { b: c#{ 1 + 2}; }\n"), "a {\n b: c3;\n}\n");
}
#[test]
fn custom_property_values_reindent_like_dart() {
// dart _writeReindentedValue: a multi-line custom value strips
// min(name column, least continuation indent) from each continuation
// line and prefixes the current output indentation; deeper relative
// indentation survives, blank-with-whitespace lines stay.
assert_eq!(
ours("a {\n --deep: {\n foo: bar;\n };\n}\n"),
"a {\n --deep: {\n foo: bar;\n };\n}\n"
);
// A continuation line ABOVE the base re-anchors the strip at its own
// indent: `--below: \n foo\n bar` strips 1 (min of col 2 and 1).
assert_eq!(
ours("a {\n --below:\n foo\n bar\n baz;\n}\n"),
"a {\n --below:\n foo\n bar\n baz;\n}\n"
);
// Hard tabs count as single characters in the strip.
assert_eq!(
ours("a {\n\t--tabs: {\n\t\tfoo: bar;\n\t};\n}\n"),
"a {\n --tabs: {\n \tfoo: bar;\n };\n}\n"
);
// .sass keeps the original name column through transpilation.
assert_eq!(
ours_sass("a\n --b: (c\n d)\n"),
"a {\n --b: (c\n d);\n}\n"
);
// Compressed output folds each newline + following whitespace run to a
// single space instead (dart _writeFoldedValue).
use sasso::OutputStyle;
let compressed = compile(
"a {\n --x: {\n foo: bar;\n };\n}\n",
&Options::default().with_style(OutputStyle::Compressed),
)
.expect("compile failed");
assert_eq!(compressed, "a{--x: { foo: bar; }}");
}
#[test]
fn var_empty_second_arg_only_after_first_positional() {
// dart's allowEmptySecondArg fires only when exactly one POSITIONAL
// argument precedes the trailing comma (`positional.length == 1 &&
// named.isEmpty`): `var(--c, )` keeps an empty second argument...
assert_eq!(ours("a {b: var(--c, )}\n"), "a {\n b: var(--c, );\n}\n");
// ...while a named first argument gets ordinary trailing-comma behavior
// and dispatches to a user-defined override with one argument.
assert_eq!(
ours("@function var($arg) {@return [$arg]}\na {b: var($arg: --c, )}\n"),
"a {\n b: [--c];\n}\n"
);
}
#[test]
fn interp_string_newlines_raw_at_top_level_collapse_inside_lists() {
// dart _performInterpolation: a directly interpolated string contributes
// its raw text (the outer quoted serializer re-escapes the newline)...
assert_eq!(
ours("$s: \"x\\a y\";\na { b: \"p#{$s}q\"; }\n"),
"a {\n b: \"px\\ayq\";\n}\n"
);
// ...while a string inside a composite value serializes quote-less, its
// newlines collapsing to single spaces (issue_1786).
assert_eq!(
ours("$s: \"x\\a y\";\na { b: \"#{q $s}\"; c: \"#{($s, $s)}\"; }\n"),
"a {\n b: \"q x y\";\n c: \"x y, x y\";\n}\n"
);
}
#[test]
fn loud_comment_glued_to_property_name_joins_it() {
// dart _declarationOrBuffer appends ONE rawText(loudComment) to the name
// when `/*` directly follows the identifier (issue_1422); a whitespace-
// separated comment is mid-trivia and drops.
assert_eq!(ours(".a { foo/*c*/: bar; }\n"), ".a {\n foo/*c*/: bar;\n}\n");
assert_eq!(ours(".a { foo /*c*/ : bar; }\n"), ".a {\n foo: bar;\n}\n");
// Only the first glued comment joins; later ones are trivia.
assert_eq!(
ours(".a { foo/*a*/ /*b*/: bar; }\n"),
".a {\n foo/*a*/: bar;\n}\n"
);
// An interpolated name glues the same way.
assert_eq!(ours(".a { #{f}oo/*c*/: bar; }\n"), ".a {\n foo/*c*/: bar;\n}\n");
}
#[test]
fn escaped_callable_names_decode_to_one_key() {
// dart decodes CSS escapes into identifier text at parse time, so the
// raw definition `foo\func` and the call site's canonical `foo\f unc`
// (and dash/underscore variants) name the same callable (issue_553).
assert_eq!(
ours("$foo\\bar: 1;\n@function foo\\func() { @return 1; }\n@mixin foo\\mixin() { m: 1; }\n.t {\n v: $foo\\bar;\n f: foo\\func();\n @include foo\\mixin();\n}\n"),
".t {\n v: 1;\n f: 1;\n m: 1;\n}\n"
);
// Equivalent escape spellings reach the same definition.
assert_eq!(
ours("@function f\\6fo() { @return 1; }\na { b: f\\6f o(); c: foo(); }\n"),
"a {\n b: 1;\n c: 1;\n}\n"
);
}
#[test]
fn has_pseudo_drops_placeholder_arguments_like_dart() {
// dart's serializer drops invisible (placeholder) complexes from every
// pseudo's argument list: after `@extend %not`, `div:has(%not)` renders
// `div:has(.not)` — the placeholder never reaches the output
// (issue_1797); a `:has` whose whole argument stays invisible can never
// match and its rule drops.
assert_eq!(
ours(
"%not { c: red; }\n.not { @extend %not; }\ndiv:has(%not) { x: y; }\nspan:has(%never) { z: w; }\n"
),
".not {\n c: red;\n}\n\ndiv:has(.not) {\n x: y;\n}\n"
);
}
#[test]
fn ampersand_in_attribute_strings_is_literal_and_unchanged_lists_skip_trim() {
// A `&` inside an attribute's quoted string is literal text, NOT a parent
// reference — the selector still gets the implicit parent join
// (issue_2291 `bar[baz="#{&}"][str="&"]` under `foo`).
assert_eq!(
ours("foo {\n bar[baz=\"#{&}\"][str=\"&\"] {\n a: q;\n }\n}\n"),
"foo bar[baz=foo][str=\"&\"] {\n a: q;\n}\n"
);
// When no selector in a rule is changed by any extension, dart returns
// the list untouched: duplicates from a reparsed interpolation survive
// even while @extend is active elsewhere.
assert_eq!(
ours("%p { x: y; }\n.q { @extend %p; }\nA, B {\n #{&}-z {\n c: d;\n }\n}\n"),
".q {\n x: y;\n}\n\nA A, A B-z, B A, B B-z {\n c: d;\n}\n"
);
}
#[test]
fn plain_css_parent_rules_nest_verbatim_under_importing_rule() {
// dart `nestWithin` with preserveParentSelectors: a plain-CSS top-level
// rule whose selector contains `&` keeps native CSS-nesting semantics —
// it nests VERBATIM inside one leading parent shell, while `&`-less
// rules get the descendant join (through_import:top_level_parent).
let dir = std::env::temp_dir().join("sasso_tip_parity");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plain.css"), "x {y: z}\n& {b {c: d}}\n").unwrap();
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let out = compile("a {@import \"plain\"}\n", &opts).expect("compile failed");
assert_eq!(
out,
"a {\n & {\n b {\n c: d;\n }\n }\n}\na x {\n y: z;\n}\n"
);
}
#[test]
fn load_css_parent_rules_nest_verbatim_too() {
// The meta.load-css path reparents the module's CSS into the caller's
// rule; an `&`-bearing top-level rule keeps native nesting there as well
// (through_load_css:top_level_parent).
let dir = std::env::temp_dir().join("sasso_tlc_parity");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plain.css"), "& {b {c: d}}\n").unwrap();
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let out = compile(
"@use \"sass:meta\";\na {@include meta.load-css(\"plain\")}\n",
&opts,
)
.expect("compile failed");
assert_eq!(out, "a {\n & {\n b {\n c: d;\n }\n }\n}\n");
}
#[test]
fn use_inside_imported_sheet_joins_importing_rule() {
// `outer {@import "imported"}` where the imported sheet `@use`s a module:
// the module evaluates in a clean context (its `&` is null) but its CSS
// joins the importing rule's selector (nested_import_into_use).
let dir = std::env::temp_dir().join("sasso_niu_parity");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("_imported.scss"),
"@use \"sass:meta\";\n@use \"used\";\n\nin-imported {parent: meta.inspect(&)}\n",
)
.unwrap();
std::fs::write(
dir.join("_used.scss"),
"@use \"sass:meta\";\nin-used {parent: meta.inspect(&)}\n",
)
.unwrap();
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let out = compile("outer {@import \"imported\"}\n", &opts).expect("compile failed");
assert_eq!(
out,
"outer in-used {\n parent: (in-used,);\n}\nouter in-imported {\n parent: (outer in-imported,);\n}\n"
);
}
#[test]
fn module_import_hoist_keeps_comment_runs_with_their_imports() {
// dart _combineCss visitModule: each module's leading comment+import run
// goes to the imports bucket (per-module _indexAfterImports), comments
// before a @use ride ahead of the used module's contribution, and an
// out-of-order plain import re-inserts at its module's import-run end.
let dir = std::env::temp_dir().join("sasso_order_parity");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("_midstream.scss"),
"/* before use in midstream */\n@use \"upstream\";\n/* after use in midstream */\n@import \"midstream.css\";\na {in: midstream}\n",
)
.unwrap();
std::fs::write(
dir.join("_upstream.scss"),
"/* before css in upstream */\n@import \"upstream.css\";\na {in: upstream}\n",
)
.unwrap();
let imp = FsImporter::new(vec![dir.clone()]);
let opts = Options::default().with_importer(&imp);
let out = compile(
"/* before use in input */\n@use \"midstream\";\n@import \"input.css\";\na {in: input}\n",
&opts,
)
.expect("compile failed");
assert_eq!(
out,
"/* before use in input */\n/* before use in midstream */\n/* before css in upstream */\n@import \"upstream.css\";\n/* after use in midstream */\n@import \"midstream.css\";\n@import \"input.css\";\na {\n in: upstream;\n}\n\na {\n in: midstream;\n}\n\na {\n in: input;\n}\n"
);
}
#[test]
fn at_in_selector_is_expected_selector_with_dual_span() {
// dart: `@` has no legal position in a selector. From literal text the
// error points at the source column (shifting across any preceding
// interpolations); from interpolated output it renders the dual-span
// "error in interpolated output" block (todo_single_escape).
let err = compile(".a@b { c: d; }\n", &Options::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected selector."), "{msg}");
assert!(msg.contains("1:3"), "{msg}");
let err = compile("$x: \"y\";\n.a#{$x}@b { c: d; }\n", &Options::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("2:8"), "{msg}");
// Without diagnostic source the dual-span block isn't rendered, but the
// position is still the interpolation expression's start.
let err = compile(".test31#{'\\@baz'} { content: '3.1'; }\n", &Options::default()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected selector."), "{msg}");
assert!(msg.contains("1:10"), "{msg}");
}
#[test]
fn user_calc_function_overrides_calculation() {
// dart resolves user functions before calculation semantics: a
// user-defined calc() shadows the CSS calculation, receiving the
// argument as an ordinarily-evaluated expression; vendor-prefixed
// -x-calc( stays a parse-time special function (issue_1706).
assert_eq!(
ours("@function calc($e) { @return custom; }\n@function -foo-calc($e) { @return custom; }\n.t {\n a: calc(1px * 1%);\n b: -foo-calc(2px * 2%);\n}\n"),
".t {\n a: custom;\n b: -foo-calc(2px * 2%);\n}\n"
);
assert_eq!(
ours("@function calc($e) { @return $e; }\na { b: calc(2px * 3); }\n"),
"a {\n b: 6px;\n}\n"
);
}
#[test]
fn media_feature_is_a_full_boolean_expression() {
// dart parses a media feature's value with _expressionUntilComparison —
// a full SassScript expression stopping only at range comparisons. So
// `(screen and (color))` is ONE boolean expression whose `and` returns
// its second operand (issue_485), while range syntax still parses.
assert_eq!(
ours("@media (not (screen and (color))) { a {b: c} }\n"),
"@media not (color) {\n a {\n b: c;\n }\n}\n"
);
assert_eq!(
ours("@media (width > 0) { a {b: c} }\n"),
"@media (width > 0) {\n a {\n b: c;\n }\n}\n"
);
}
#[test]
fn transitive_multi_simple_extender_chain_folds() {
// A multi-simple compound extender whose simple is itself extended by a
// DISJOINT multi-simple selector folds transitively: `%a` extended by
// `c:s`, and `c` extended by `d::e`, yields `d:s::e`
// (extend-tests/086.1).
assert_eq!(
ours("%a {\n x:y;\n}\nb:after:not(:first-child) {\n @extend %a;\n}\nc:s {\n @extend %a;\n}\nd::e {\n @extend c;\n}\n"),
"c:s, d:s::e, b:after:not(:first-child) {\n x: y;\n}\n"
);
// A self-overlapping multi-simple extender (`.a` extended by `.a.mod1`) is
// NOT folded here — that stays with dart's extension-graph fixpoint
// (regression guard for directives/extend/after_target:multiple_recursive).
assert_eq!(
ours(".a .b {\n c: d;\n}\n.a.mod1, .a.mod2 {\n @extend .a, .b;\n}\n"),
".a .b, .a .a.mod1, .a .a.mod2 {\n c: d;\n}\n"
);
}
#[test]
fn selector_replace_promotes_replaced_original() {
// dart promotes the first FULLY-replaced product of each input complex to
// an original, so it survives `_trim` even when a sibling product
// superselects it: `replace((c, d c), c, e)` keeps `d e` (the bare `e`
// from input `c` would otherwise trim it). (core_functions/selector/
// replace:format/input/non_string/selector.)
assert_eq!(
ours("@use \"sass:selector\";\na {b: selector.replace((c, d c), \"c\", \"e\")}\n"),
"a {\n b: e, d e;\n}\n"
);
// But an INTERMEDIATE (not fully replaced) product is still trimmed:
// multi-target replace collapses `c.d` to `.e`, not `.d.e, .e`.
assert_eq!(
ours("@use \"sass:selector\";\na {b: selector.replace(\"c.d\", \"c, .d\", \".e\")}\n"),
"a {\n b: .e;\n}\n"
);
}
#[test]
fn extend_registration_order_sequential() {
// dart applies each `@extend` in registration order, re-extending the
// accumulated selector list — NOT a one-shot cartesian. For case 229 the
// nested `c {@extend b}` registers first (rule `a b` becomes `a b, a c`),
// then `d {@extend a}` folds over that → `a b, d b, a c, d c`.
assert_eq!(
ours("a {\n b {a: b}\n c {@extend b}\n}\nd {@extend a}\n"),
"a b, d b, a c, d c {\n a: b;\n}\n"
);
// Cross-branch placeholder redundancy (extend-tests/234).
assert_eq!(
ours(".e %z {a: b}\n%x .c %y {@extend %z}\n.a, .b {@extend %x}\n.a .d {@extend %y}\n"),
".e .a .c .d, .e .b .c .a .d, .a .e .b .c .d, .a .c .e .d, .b .c .e .a .d {\n a: b;\n}\n"
);
// A three-level extend cycle settles to the full closure via the fixpoint
// re-fold, each rule keeping its own registration-order head.
assert_eq!(
ours(".foo {a: b; @extend .bar}\n.bar {c: d; @extend .baz}\n.baz {e: f; @extend .foo}\n"),
".foo, .baz, .bar {\n a: b;\n}\n\n.bar, .foo, .baz {\n c: d;\n}\n\n.baz, .bar, .foo {\n e: f;\n}\n"
);
}
#[test]
fn extend_selector_pseudo_one_shot() {
// A target inside a selector pseudo is extended by REWRITING the compound in
// place; dart applies such extensions simultaneously. Two `:not` extends
// unify into one compound (compound-unification-in-not)...
assert_eq!(
ours(".a {@extend .c}\n.b {@extend .d}\n:not(.c):not(.d) {x: y}\n"),
":not(.c):not(.a):not(.d):not(.b) {\n x: y;\n}\n"
);
// ...a `:not` chain keeps the pre-merge product (extend-result-of-extend)...
assert_eq!(
ours(".a {@extend :not(.b)}\n.b {@extend .c}\n:not(.c) {x: y}\n"),
":not(.c):not(.b), .a:not(.c) {\n x: y;\n}\n"
);
// ...and re-extending an EXTENDER's `:is()` keeps the pre-extension form
// (dart sass/dart-sass#1297).
assert_eq!(
ours(":is(midstream) {@extend upstream}\ndownstream {@extend midstream}\nupstream {a: b}\n"),
"upstream, :is(midstream), :is(midstream, downstream) {\n a: b;\n}\n"
);
// But a pseudo whose argument is NOT a target stays on the registration-order
// fold so its placeholder order is preserved (extend-tests/086.1).
assert_eq!(
ours("%a {\n x:y;\n}\nb:after:not(:first-child) {\n @extend %a;\n}\nc:s {\n @extend %a;\n}\nd::e {\n @extend c;\n}\n"),
"c:s, d:s::e, b:after:not(:first-child) {\n x: y;\n}\n"
);
}
#[test]
fn extend_one_shot_vs_incremental_order() {
// dart `addSelector` extends a rule registered AFTER all its `@extend`s by
// the whole store at ONCE, in `_extendComplex`'s `paths` order (the LAST
// unification choice varies slowest). The rule `.e.f` here comes after both
// extends, so its compound unifies to `.e.f, .a .f.b, .c .e.d, …`
// (nested-compound-unification) — NOT the incremental interleaving.
assert_eq!(
ours(".a .b {@extend .e}\n.c .d {@extend .f}\n.e.f {x: y}\n"),
".e.f, .a .f.b, .c .e.d, .a .c .b.d, .c .a .b.d {\n x: y;\n}\n"
);
// A rule registered BEFORE its `@extend`s is extended INCREMENTALLY in
// registration order: each `:not` is inserted right after the target, so a
// later `@extend` sorts ahead of an earlier one — `:not(.x)` extended by
// `.y` then `.z` gives `:not(.x):not(.z):not(.y)` (reverse), the opposite of
// the one-shot order.
assert_eq!(
ours(":not(.x) {a: b}\n.y {@extend .x}\n.z {@extend .x}\n"),
":not(.x):not(.z):not(.y) {\n a: b;\n}\n"
);
// The same rule registered AFTER its extends takes the one-shot order
// (forward): `:not(.x):not(.y):not(.z)`.
assert_eq!(
ours(".y {@extend .x}\n.z {@extend .x}\n:not(.x) {a: b}\n"),
":not(.x):not(.y):not(.z) {\n a: b;\n}\n"
);
}