use std::collections::HashMap;
use sasso::{
compile, CanonicalUrl, CanonicalizeContext, Importer, ImporterError, ImporterResult, Options,
OutputStyle, Syntax,
};
fn css(input: &str) -> String {
let out = compile(input, &Options::default()).expect("compile should succeed");
if out.is_empty() {
out
} else {
out + "\n"
}
}
fn css_compressed(input: &str) -> String {
compile(input, &Options::default().with_style(OutputStyle::Compressed)).expect("compile should succeed")
}
#[test]
fn library_api_omits_trailing_newline() {
for input in [
".a { color: red; }",
".a { color: red; }\n",
"$c: blue;\n.a {\n color: $c;\n .b { width: 10px; }\n}\n",
".a { color: red; }\n.b { color: blue; }\n",
".a { content: \"café\"; }",
] {
let expanded = compile(input, &Options::default()).expect("compile");
assert!(
!expanded.ends_with('\n'),
"expanded library output must not end with a newline: {expanded:?}"
);
let compressed =
compile(input, &Options::default().with_style(OutputStyle::Compressed)).expect("compile");
assert!(
!compressed.ends_with('\n'),
"compressed library output must not end with a newline: {compressed:?}"
);
}
}
struct MemImporter(HashMap<String, String>);
impl Importer for MemImporter {
fn canonicalize(
&self,
url: &str,
_ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
Ok(self.0.contains_key(url).then(|| CanonicalUrl::new(url)))
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
Ok(self.0.get(canonical.as_str()).map(|c| ImporterResult {
contents: c.clone(),
syntax: Syntax::Scss,
source_map_url: None,
}))
}
}
#[test]
fn variables_nesting_and_colors() {
let out = css("$c: #336699;\n.a {\n color: $c;\n .b { color: lighten($c, 10%); }\n &:hover { color: mix($c, white, 50%); }\n}\n");
assert_eq!(
out,
".a {\n color: #336699;\n}\n.a .b {\n color: rgb(63.75, 127.5, 191.25);\n}\n.a:hover {\n color: rgb(153, 178.5, 204);\n}\n"
);
}
#[test]
fn color_function_set() {
let out = css("$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");
assert_eq!(
out,
".x {\n color: rgba(42, 122, 226, 0.5);\n background: rgb(22.9483471074, 86.2541322314, 168.5516528926);\n border-color: hsl(120, 50%, 40%);\n width: 25%;\n}\n"
);
}
#[test]
fn rgb_and_hsl_literals_preserve_form() {
assert_eq!(
css(".x { color: rgb(51, 153, 51); }"),
".x {\n color: rgb(51, 153, 51);\n}\n"
);
assert_eq!(
css(".x { color: hsl(120, 50%, 40%); }"),
".x {\n color: hsl(120, 50%, 40%);\n}\n"
);
}
#[test]
fn unknown_identifiers_pass_through() {
let out = css(".x { color: red; border-color: rebeccapurple; display: block; }");
assert_eq!(
out,
".x {\n color: red;\n border-color: rebeccapurple;\n display: block;\n}\n"
);
}
#[test]
fn nesting_combinators_and_parent_ref() {
let out = css(".a, .b {\n margin: 0;\n > .c { padding: 1px; }\n &.active { color: red; }\n .d & { color: blue; }\n}\n");
assert_eq!(
out,
".a, .b {\n margin: 0;\n}\n.a > .c, .b > .c {\n padding: 1px;\n}\n.a.active, .b.active {\n color: red;\n}\n.d .a, .d .b {\n color: blue;\n}\n"
);
}
#[test]
fn blank_line_between_top_level_groups() {
assert_eq!(
css(".a{color:red} .b{color:blue}"),
".a {\n color: red;\n}\n\n.b {\n color: blue;\n}\n"
);
assert_eq!(
css(".a{color:red; .b{color:blue}}"),
".a {\n color: red;\n}\n.a .b {\n color: blue;\n}\n"
);
}
#[test]
fn interpolation_in_selectors_and_values() {
let out = css("$name: warning;\n$i: 3;\n.icon-#{$name} { content: \"#{$name}-#{$i}\"; }\n.col-#{$i} { width: 10px * $i; }\n");
assert_eq!(
out,
".icon-warning {\n content: \"warning-3\";\n}\n\n.col-3 {\n width: 30px;\n}\n"
);
}
#[test]
fn unit_arithmetic() {
assert_eq!(css(".a { width: 8px * 2; }"), ".a {\n width: 16px;\n}\n");
assert_eq!(css(".a { width: 10px + 5px; }"), ".a {\n width: 15px;\n}\n");
assert_eq!(css(".a { margin: 2 * 3em; }"), ".a {\n margin: 6em;\n}\n");
}
#[test]
fn lists_round_trip() {
let out = css("$stack: \"Helvetica Neue\", Arial, sans-serif;\n.t { font-family: $stack; margin: 1px 2px 3px 4px; }\n");
assert_eq!(
out,
".t {\n font-family: \"Helvetica Neue\", Arial, sans-serif;\n margin: 1px 2px 3px 4px;\n}\n"
);
}
#[test]
fn default_and_important_flags() {
let out = css("$c: red;\n$c: blue !default;\n.a { color: $c; background: red !important; }");
assert_eq!(out, ".a {\n color: red;\n background: red !important;\n}\n");
}
#[test]
fn comments_loud_preserved_silent_dropped() {
let out = css("// silent\n.a { color: red; /* inline */ }\n/* trailing */");
assert_eq!(out, ".a {\n color: red; /* inline */\n}\n\n/* trailing */\n");
}
#[test]
fn null_value_omits_declaration() {
assert_eq!(css(".a { color: null; width: 1px; }"), ".a {\n width: 1px;\n}\n");
}
#[test]
fn import_inlining() {
let mut files = HashMap::new();
files.insert(
"parts/base".to_string(),
"$pad: 8px;\nbody { margin: 0; padding: $pad * 2; }".to_string(),
);
let importer = MemImporter(files);
let out = compile(
"@import \"parts/base\";\n.wrap { padding: 4px; }",
&Options::default().with_importer(&importer),
)
.expect("compile");
assert_eq!(
out,
"body {\n margin: 0;\n padding: 16px;\n}\n\n.wrap {\n padding: 4px;\n}"
);
}
#[test]
fn css_import_passes_through() {
let out = css("@import \"https://fonts.example/x.css\";\n.a { color: red; }");
assert_eq!(
out,
"@import \"https://fonts.example/x.css\";\n.a {\n color: red;\n}\n"
);
}
#[test]
fn preserves_css_functions_verbatim() {
let out = css(".a { width: calc(100% - 20px); transform: translateX(10px); }");
assert_eq!(
out,
".a {\n width: calc(100% - 20px);\n transform: translateX(10px);\n}\n"
);
}
struct DirImporter(HashMap<String, String>);
impl DirImporter {
fn dirname(p: &str) -> &str {
match p.rfind('/') {
Some(0) => "/",
Some(i) => &p[..i],
None => "",
}
}
fn as_partial(p: &str) -> String {
match p.rfind('/') {
Some(i) => format!("{}/_{}", &p[..i], &p[i + 1..]),
None => format!("_{p}"),
}
}
}
impl Importer for DirImporter {
fn canonicalize(
&self,
url: &str,
ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
let base = ctx
.containing_url
.map(|c| Self::dirname(c.as_str()))
.unwrap_or("");
let joined = format!("{}/{}", base.trim_end_matches('/'), url);
for cand in [joined.clone(), Self::as_partial(&joined)] {
if self.0.contains_key(&cand) {
return Ok(Some(CanonicalUrl::new(&cand)));
}
}
Ok(None)
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
Ok(self.0.get(canonical.as_str()).map(|c| ImporterResult {
contents: c.clone(),
syntax: Syntax::Scss,
source_map_url: None,
}))
}
}
#[test]
fn first_class_mixin_load_css_resolves_against_defining_module() {
let mut files = HashMap::new();
files.insert(
"/sub/_mod".to_string(),
"@use \"sass:meta\";\n@mixin go { @include meta.load-css(\"dep\"); }\n\
$m: meta.get-mixin(\"go\");\n"
.to_string(),
);
files.insert(
"/sub/_dep".to_string(),
".loaded { x: from-sub-dep; }\n".to_string(),
);
files.insert(
"/_dep".to_string(),
".loaded { x: from-ROOT-WRONG; }\n".to_string(),
);
let importer = DirImporter(files);
let out = compile(
"@use \"sub/mod\";\n@use \"sass:meta\";\n.out { @include meta.apply(mod.$m); }\n",
&Options::default().with_url("/entry").with_importer(&importer),
)
.expect("compile");
assert_eq!(out, ".out .loaded {\n x: from-sub-dep;\n}");
}
#[test]
fn compressed_output() {
let out = css_compressed(".a { color: #336699; width: 10px; .b { color: #2a7ae2; } }");
assert_eq!(out, ".a{color:#369;width:10px}.a .b{color:#2a7ae2}");
}
#[test]
fn compressed_color_picks_shortest_form() {
let case = |scss: &str| css_compressed(&format!("a{{x:{scss}}}"));
assert_eq!(case("darken(#336699,10%)"), "a{x:hsl(210,50%,30%)}");
assert_eq!(case("lighten(#336699,10%)"), "a{x:hsl(210,50%,50%)}");
assert_eq!(case("saturate(#336699,10%)"), "a{x:hsl(210,60%,40%)}");
assert_eq!(case("grayscale(#ff6600)"), "a{x:hsl(0,0%,50%)}");
assert_eq!(
css_compressed("@use 'sass:color';a{x:color.mix(#ff6600,#fff,30%)}"),
"a{x:hsl(24,100%,85%)}"
);
assert_eq!(
css_compressed("@use 'sass:color';a{x:color.adjust(#336699,$lightness:-10%)}"),
"a{x:hsl(210,50%,30%)}"
);
assert_eq!(case("rgba(darken(#336699,10%),.5)"), "a{x:hsla(210,50%,30%,.5)}");
assert_eq!(case("saturate(#888,20%)"), "a{x:rgb(159.8,112.2,112.2)}");
assert_eq!(case("adjust-hue(#336699,90deg)"), "a{x:#939}");
assert_eq!(case("hsl(210,50%,40%)"), "a{x:#369}");
assert_eq!(case("hsl(210,50%,30%)"), "a{x:hsl(210,50%,30%)}");
assert_eq!(case("hsl(30,100%,50%)"), "a{x:rgb(255,127.5,0)}");
assert_eq!(case("hsl(30,0%,50%)"), "a{x:hsl(30,0%,50%)}");
assert_eq!(case("#336699"), "a{x:#369}");
assert_eq!(case("red"), "a{x:red}");
assert_eq!(case("rgba(0,0,0,.5)"), "a{x:rgba(0,0,0,.5)}");
assert_eq!(case("hsl(0,0%,50%)"), "a{x:hsl(0,0%,50%)}");
}
#[test]
fn compressed_at_rule_prelude_spacing() {
assert_eq!(
css_compressed("@media (min-width: 1px) { a { x: 1 } }"),
"@media(min-width: 1px){a{x:1}}"
);
assert_eq!(
css_compressed("@media (a: 1) and (b: 2) { a { x: 1 } }"),
"@media(a: 1)and (b: 2){a{x:1}}"
);
assert_eq!(
css_compressed("@media (a: 1), (b: 2) { a { x: 1 } }"),
"@media(a: 1),(b: 2){a{x:1}}"
);
assert_eq!(
css_compressed("@media screen and (a: 1) { a { x: 1 } }"),
"@media screen and (a: 1){a{x:1}}"
);
assert_eq!(
css_compressed("@supports (display: grid) { a { x: 1 } }"),
"@supports(display: grid){a{x:1}}"
);
assert_eq!(
css_compressed("@supports (a: 1) and (b: 2) { a { x: 1 } }"),
"@supports(a: 1) and (b: 2){a{x:1}}"
);
assert_eq!(
css_compressed("@container (min-width: 1px) { a { x: 1 } }"),
"@container (min-width: 1px){a{x:1}}"
);
}
#[test]
fn comparison_and_logical_operators() {
assert_eq!(css(".a { x: if(3 > 2, big, small); }"), ".a {\n x: big;\n}\n");
assert_eq!(css(".a { x: 1 + 2 == 3; }"), ".a {\n x: true;\n}\n");
assert_eq!(css(".a { x: not false; }"), ".a {\n x: true;\n}\n");
assert_eq!(css(".a { x: 1 == 1px; }"), ".a {\n x: false;\n}\n");
assert_eq!(css(".a { x: if(true and false, y, n); }"), ".a {\n x: n;\n}\n");
assert_eq!(css(".a { x: if(2 <= 2 or false, y, n); }"), ".a {\n x: y;\n}\n");
}
#[test]
fn if_function_is_lazy() {
assert_eq!(css(".a { x: if(true, ok, $undefined); }"), ".a {\n x: ok;\n}\n");
assert_eq!(
css(".a { x: if($condition: false, $if-true: a, $if-false: b); }"),
".a {\n x: b;\n}\n"
);
}
#[test]
fn at_if_else_chain() {
assert_eq!(
css("$t: dark;\n.a { @if $t == dark { color: white; } @else { color: black; } padding: 1px; }"),
".a {\n color: white;\n padding: 1px;\n}\n"
);
assert_eq!(
css("$n: 2;\n.a { @if $n == 1 { x: a; } @else if $n == 2 { x: b; } @else { x: c; } }"),
".a {\n x: b;\n}\n"
);
assert_eq!(css("@if 2 > 1 { .b { y: 1; } }"), ".b {\n y: 1;\n}\n");
assert_eq!(
css(".a { @if false { x: 1; } color: red; }"),
".a {\n color: red;\n}\n"
);
}
#[test]
fn at_for_loop() {
assert_eq!(
css("@for $i from 1 through 3 { .c#{$i} { w: $i * 10px; } }"),
".c1 {\n w: 10px;\n}\n\n.c2 {\n w: 20px;\n}\n\n.c3 {\n w: 30px;\n}\n"
);
assert_eq!(
css("@for $i from 1 to 3 { .c#{$i} { x: $i; } }"),
".c1 {\n x: 1;\n}\n\n.c2 {\n x: 2;\n}\n"
);
}
#[test]
fn at_each_loop() {
assert_eq!(
css("@each $n in a, b { .i-#{$n} { content: \"#{$n}\"; } }"),
".i-a {\n content: \"a\";\n}\n\n.i-b {\n content: \"b\";\n}\n"
);
assert_eq!(
css("@each $k, $v in (a 1), (b 2) { .#{$k} { order: $v; } }"),
".a {\n order: 1;\n}\n\n.b {\n order: 2;\n}\n"
);
}
#[test]
fn at_while_loop() {
assert_eq!(
css(".x { $i: 0; @while $i < 3 { p-#{$i}: $i; $i: $i + 1; } }"),
".x {\n p-0: 0;\n p-1: 1;\n p-2: 2;\n}\n"
);
}
#[test]
fn at_function_and_return() {
assert_eq!(
css("@function double($n) { @return $n * 2; }\n.a { width: double(8px); }"),
".a {\n width: 16px;\n}\n"
);
assert_eq!(
css("@function cap($v, $max: 100) { @if $v > $max { @return $max; } @return $v; }\n.a { x: cap(150); y: cap(50, $max: 60); }"),
".a {\n x: 100;\n y: 50;\n}\n"
);
assert_eq!(
css("@function sum($n...) { $t: 0; @each $x in $n { $t: $t + $x; } @return $t; }\n.a { order: sum(1, 2, 3, 4); }"),
".a {\n order: 10;\n}\n"
);
}
#[test]
fn at_mixin_include_content() {
assert_eq!(
css("@mixin box($pad, $color: blue) { padding: $pad; color: $color; }\n.a { @include box(4px); }\n.b { @include box(8px, red); }"),
".a {\n padding: 4px;\n color: blue;\n}\n\n.b {\n padding: 8px;\n color: red;\n}\n"
);
assert_eq!(
css("@mixin surround { border: 1px; @content; margin: 0; }\n.a { @include surround { background: yellow; } }"),
".a {\n border: 1px;\n background: yellow;\n margin: 0;\n}\n"
);
}
#[test]
fn undefined_variable_is_an_error() {
let err = compile(".a { color: $missing; }", &Options::default()).unwrap_err();
assert!(err.message.contains("Undefined variable"));
}
#[test]
fn incompatible_units_error() {
let err = compile(".a { width: 1px + 1em; }", &Options::default()).unwrap_err();
assert!(err.message.contains("incompatible units"));
}
#[test]
fn hex_color_validation_matches_dart() {
assert_eq!(css("a{color:#000}"), "a {\n color: #000;\n}\n");
assert_eq!(css("a{color:#abc}"), "a {\n color: #abc;\n}\n");
assert_eq!(css("a{color:#000000}"), "a {\n color: #000000;\n}\n");
assert_eq!(css("a{color:#abcd12}"), "a {\n color: #abcd12;\n}\n");
assert_eq!(css("a{color:#0000}"), "a {\n color: rgba(0, 0, 0, 0);\n}\n");
assert_eq!(css("a{color:#00000000}"), "a {\n color: rgba(0, 0, 0, 0);\n}\n");
for bad in [
"a{color:#0}",
"a{color:#00}",
"a{color:#00000}",
"a{color:#0000000}",
"a{color:#0g}",
"a{color:#00g}",
"a{color:#12g}",
] {
let err = compile(bad, &Options::default()).unwrap_err();
assert!(
err.message.contains("Expected hex digit"),
"{bad} should error, got {}",
err.message
);
}
assert_eq!(css("a{color:#000g}"), "a {\n color: #000 g;\n}\n");
assert_eq!(css("a{color:#000000g}"), "a {\n color: #000000 g;\n}\n");
assert_eq!(css("a{color:#abcde}"), "a {\n color: #abcde;\n}\n");
assert_eq!(css("a{color:#abcg}"), "a {\n color: #abcg;\n}\n");
assert_eq!(css("a{color:#xyz}"), "a {\n color: #xyz;\n}\n");
}
#[test]
fn rejects_lenient_parser_forms_like_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(
err("@mixin m($a,$a){x:$a}a{@include m(1,2)}"),
"Duplicate parameter."
);
assert_eq!(
err("@function f($a,$a){@return $a}a{x:f(1,2)}"),
"Duplicate parameter."
);
assert_eq!(
err("@mixin m($a-b,$a_b){x:$a-b}c{@include m(1,2)}"),
"Duplicate parameter."
);
assert!(compile("@mixin ok($a,$b){x:$a}a{@include ok(1,2)}", &Options::default()).is_ok());
for bad in ["a{b:1e-}", "a{b:1e-x}", "a{b:1e++5}", "a{b:1e--5}"] {
assert_eq!(err(bad), "Expected digit.", "{bad}");
}
assert_eq!(css("a{b:1e5}"), "a {\n b: 100000;\n}\n");
assert_eq!(css("a{b:1e+2}"), "a {\n b: 100;\n}\n");
assert_eq!(css("a{b:1em}"), "a {\n b: 1em;\n}\n");
assert_eq!(err("@use \"sass:math\" as 0;a{b:1}"), "Expected identifier.");
assert_eq!(err("@forward \"sass:math\" as 9-*;"), "Expected identifier.");
}
#[test]
fn rgb_hsl_argument_validation_matches_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(
err("a{b:rgb(1px,2,3)}"),
"$red: Expected 1px to have unit \"%\" or no units."
);
assert_eq!(
err("a{b:rgb(2,1px,3)}"),
"$green: Expected 1px to have unit \"%\" or no units."
);
assert_eq!(
err("a{b:rgb(1,2,3px)}"),
"$blue: Expected 3px to have unit \"%\" or no units."
);
assert_eq!(err("a{color:rgb(1 2 3, 0.5)}"), "$color: (1 2 3) is not a color.");
assert_eq!(err("a{color:hsl(1 2% 3%, 0.5)}"), "Missing argument $lightness.");
for ok in [
"a{color:rgb(255 0 0)}",
"a{color:rgb(1,2,3)}",
"a{color:rgb(1 2 3 / 0.5)}",
"a{color:hsl(120, 50%, 40%)}",
"a{color:rgb(1, var(--foo))}",
] {
assert!(compile(ok, &Options::default()).is_ok(), "{ok}");
}
}
#[test]
fn static_placement_and_serialization_match_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(
err("@content;"),
"@content is only allowed within mixin declarations."
);
assert_eq!(
err("@if true{@content}"),
"@content is only allowed within mixin declarations."
);
assert!(compile("@mixin m{@content}\na{@include m{x:y}}", &Options::default()).is_ok());
assert_eq!(
err("@function f(){ @if false { a { color:red } } @return 1 } x{y:f()}"),
"@function rules may not contain style rules."
);
assert_eq!(
err("@function f(){ @if false { color: red } @return 1 } x{y:f()}"),
"@function rules may not contain declarations."
);
assert_eq!(
err("@if false { @extend .foo; }"),
"@extend may only be used within style rules."
);
assert!(compile("a{x:1}b{@extend a}", &Options::default()).is_ok());
assert_eq!(err("a{b: -(a:1)}"), "(a: 1) isn't a valid CSS value.");
assert_eq!(err("a{b: #{(a:1)}}"), "(a: 1) isn't a valid CSS value.");
assert_eq!(err("a{b: #{()}}"), "() isn't a valid CSS value.");
assert_eq!(err("a{b: 1 + ()}"), "() isn't a valid CSS value.");
assert_eq!(css("a{b: #{1 2 3}}"), "a {\n b: 1 2 3;\n}\n"); }
#[test]
fn selector_pseudo_grammar_matches_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(err("a:not(){x:y}"), "expected selector.");
assert_eq!(err("a:nth-child(2n+3 foo){x:y}"), "Expected \"of\".");
assert_eq!(err("a:::before{x:y}"), "Expected identifier.");
for bad in [
"a:nth-child(2n+){x:y}",
"a:nth-child(of){x:y}",
"a:nth-child(2x){x:y}",
] {
assert!(compile(bad, &Options::default()).is_err(), "{bad}");
}
for ok in [
"a:nth-child(2n+1){x:y}",
"a:nth-child(odd){x:y}",
"a:nth-child(-n+3){x:y}",
"a:nth-child(2n of .a){x:y}",
"a:not(.a, .b){x:y}",
"a:is(h1, h2){x:y}",
"a:has(> .x){x:y}",
"a::before{x:y}",
"a:lang(en){x:y}",
"$n: 3;\na:nth-child(#{$n}){x:y}",
"a:nth-of-type(){x:y}", ] {
assert!(compile(ok, &Options::default()).is_ok(), "{ok}");
}
}
#[test]
fn selector_bang_and_extend_leading_comma_match_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(err("a !important {b:c}"), "expected \"{\".");
assert_eq!(err("div !default {color:red}"), "expected \"{\".");
assert!(compile("[data-x=\"a!b\"]{c:d}", &Options::default()).is_ok());
assert_eq!(
css("a{color:red !important}"),
"a {\n color: red !important;\n}\n"
);
assert_eq!(err("a{x:1}.x{@extend ,a}"), "expected selector.");
assert!(compile("a{x:1}.x{@extend a,}", &Options::default()).is_ok());
}
#[test]
fn at_charset_and_at_root_query_match_dart() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
assert_eq!(err("@charset utf-8;a{b:1}"), "Expected string.");
assert_eq!(err("@charset;a{b:1}"), "Expected string.");
assert!(compile("@charset \"utf-8\";a{b:1}", &Options::default()).is_ok());
assert!(compile("@charset \"utf-8\" \"extra\";a{b:1}", &Options::default()).is_err());
assert_eq!(
err("@at-root (foo) {a{b:c}}"),
"Expected \"with\" or \"without\"."
);
assert_eq!(err("@at-root (with) {a{b:c}}"), "expected \":\".");
assert_eq!(err("@at-root (with:) {a{b:c}}"), "Expected expression.");
assert_eq!(err("@at-root (with: rule) junk {a{b:c}}"), "expected \"{\".");
for ok in [
"@at-root (with: rule) {a{b:c}}",
"@at-root (without: media) {a{b:c}}",
"@at-root (with: a b) {a{b:c}}",
"@at-root {a{b:c}}",
"@at-root .x {a{b:c}}",
] {
assert!(compile(ok, &Options::default()).is_ok(), "{ok}");
}
}
#[test]
fn at_root_group_separation_matches_dart() {
assert_eq!(
css(".a {\n x: 1;\n @at-root .b {\n y: 2;\n }\n z: 3;\n}\n"),
".a {\n x: 1;\n}\n.b {\n y: 2;\n}\n\n.a {\n z: 3;\n}\n"
);
assert_eq!(
css(".a {\n x: 1;\n @at-root {\n .b { y: 2; }\n .c { w: 4; }\n }\n z: 3;\n}\n"),
".a {\n x: 1;\n}\n.b {\n y: 2;\n}\n\n.c {\n w: 4;\n}\n\n.a {\n z: 3;\n}\n"
);
assert_eq!(
css(".a {\n x: 1;\n @at-root .b {\n y: 2;\n @at-root .c { w: 4; }\n }\n z: 3;\n}\n"),
".a {\n x: 1;\n}\n.b {\n y: 2;\n}\n.c {\n w: 4;\n}\n\n.a {\n z: 3;\n}\n"
);
assert_eq!(
css(".parent {\n color: red;\n @at-root .top {\n color: green;\n @media screen { color: blue; }\n }\n}\n"),
".parent {\n color: red;\n}\n.top {\n color: green;\n}\n@media screen {\n .top {\n color: blue;\n }\n}\n"
);
assert_eq!(
css(".a {\n x: 1;\n @at-root .b { y: 2; }\n}\n"),
".a {\n x: 1;\n}\n.b {\n y: 2;\n}\n"
);
}
use std::cell::RefCell;
struct CachingImporter {
files: HashMap<String, String>,
requested: RefCell<Vec<String>>,
}
impl Importer for CachingImporter {
fn canonicalize(
&self,
url: &str,
_ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
self.requested.borrow_mut().push(url.to_string());
Ok(self.files.contains_key(url).then(|| CanonicalUrl::new(url)))
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
Ok(self.files.get(canonical.as_str()).map(|src| ImporterResult {
contents: src.clone(),
syntax: Syntax::Scss,
source_map_url: None,
}))
}
}
#[test]
fn importer_cached_strings_survive_compile_reset() {
let mut files = HashMap::new();
files.insert(
"partial".to_string(),
"$pad: 8px;\nbody { padding: $pad; }".to_string(),
);
files.insert("mod".to_string(), "$gap: 4px;".to_string());
let importer = CachingImporter {
files,
requested: RefCell::new(Vec::new()),
};
let out = compile(
"@use \"mod\";\n@import \"partial\";\n.a { margin: mod.$gap; }",
&Options::default().with_importer(&importer),
)
.expect("compile should succeed");
assert_eq!(out, "body {\n padding: 8px;\n}\n\n.a {\n margin: 4px;\n}");
let requested = importer.requested.borrow();
assert!(
requested.iter().any(|p| p == "mod"),
"expected `mod` to have been requested; got {requested:?}"
);
assert!(
requested.iter().any(|p| p == "partial"),
"expected `partial` to have been requested; got {requested:?}"
);
for p in requested.iter() {
assert!(!p.is_empty());
assert!(p == "mod" || p == "partial", "unexpected cached path {p:?}");
}
}
#[test]
fn default_assignment_does_not_eval_rhs_when_already_set() {
let out = css(concat!(
"$w: 1rem !default;\n",
"$p: 1.5rem !default;\n",
"$w: 1em !default;\n",
"$p: $w + .5em !default;\n",
".a { width: $w; padding: $p; }\n",
));
assert_eq!(out, ".a {\n width: 1rem;\n padding: 1.5rem;\n}\n");
}
#[test]
fn rgba_hsla_special_value_passthrough_keeps_name() {
assert_eq!(
css(".a { color: rgba(var(--bs-body-color-rgb), 0.65); }\n"),
".a {\n color: rgba(var(--bs-body-color-rgb), 0.65);\n}\n"
);
assert_eq!(
css(".a { color: hsla(var(--h), 50%, 50%, 0.5); }\n"),
".a {\n color: hsla(var(--h), 50%, 50%, 0.5);\n}\n"
);
assert_eq!(
css(".a { color: rgb(var(--y), 0.5); }\n"),
".a {\n color: rgb(var(--y), 0.5);\n}\n"
);
assert_eq!(
css(".a { color: rgba(none none none); }\n"),
".a {\n color: rgb(none none none);\n}\n"
);
}