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(25%, 50%, 75%);\n}\n.a:hover {\n color: rgb(60%, 70%, 80%);\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(8.9993518068%, 33.8251498947%, 66.0986874088%);\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 a_units_identity_is_its_decoded_spelling() {
assert_eq!(css("a { b: 1\\65 m + 1em; }"), "a {\n b: 2em;\n}\n");
assert_eq!(css("a { b: 1rem + 1\\72 em; }"), "a {\n b: 2rem;\n}\n");
assert_eq!(css("a { b: 1PX; c: 1px; }"), "a {\n b: 1PX;\n c: 1px;\n}\n");
let err = compile("a { b: 1PX + 1px; }", &Options::default()).unwrap_err();
assert!(err.message.contains("incompatible units"), "{}", err.message);
assert_eq!(
css("@use \"sass:math\"; a { b: math.unit(1\\72 em); c: math.unit(1\\50 X); }"),
"a {\n b: \"rem\";\n c: \"PX\";\n}\n"
);
assert_eq!(css("a { b: 1e3; c: 1E3; }"), "a {\n b: 1000;\n c: 1000;\n}\n");
assert_eq!(
css("@for $i from 1 through 3 { .c-#{$i} { w: 2px * $i; } }"),
".c-1 {\n w: 2px;\n}\n\n.c-2 {\n w: 4px;\n}\n\n.c-3 {\n w: 6px;\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}");
}
struct CountingImporter {
inner: DirImporter,
canonicalized: std::cell::RefCell<HashMap<String, usize>>,
loaded: std::cell::RefCell<HashMap<String, usize>>,
poison_reload: bool,
}
impl CountingImporter {
fn new(files: HashMap<String, String>, poison_reload: bool) -> Self {
CountingImporter {
inner: DirImporter(files),
canonicalized: std::cell::RefCell::new(HashMap::new()),
loaded: std::cell::RefCell::new(HashMap::new()),
poison_reload,
}
}
fn count(map: &std::cell::RefCell<HashMap<String, usize>>, key: &str) -> usize {
*map.borrow().get(key).unwrap_or(&0)
}
}
impl Importer for CountingImporter {
fn canonicalize(
&self,
url: &str,
ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
*self
.canonicalized
.borrow_mut()
.entry(url.to_string())
.or_insert(0) += 1;
self.inner.canonicalize(url, ctx)
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
let key = canonical.as_str().to_string();
let seen = {
let mut counts = self.loaded.borrow_mut();
let n = counts.entry(key.clone()).or_insert(0);
*n += 1;
*n
};
if self.poison_reload && seen > 1 {
return Err(ImporterError {
message: format!("load #{seen} of {key}"),
});
}
self.inner.load(canonical)
}
}
fn diamond_files() -> HashMap<String, String> {
let mut files = HashMap::new();
files.insert(
"/_shared".to_string(),
"$w: 4px;\n.shared { x: 1; }\n".to_string(),
);
files.insert(
"/_a".to_string(),
"@use \"shared\";\n.a { width: shared.$w; }\n".to_string(),
);
files.insert(
"/_b".to_string(),
"@use \"shared\";\n.b { width: shared.$w; }\n".to_string(),
);
files
}
const DIAMOND_ENTRY: &str = "@use \"shared\";\n@use \"a\";\n@use \"b\";\n.entry { width: shared.$w; }\n";
const DIAMOND_CSS: &str =
".shared {\n x: 1;\n}\n\n.a {\n width: 4px;\n}\n\n.b {\n width: 4px;\n}\n\n.entry {\n width: 4px;\n}";
#[test]
fn a_repeat_use_edge_canonicalizes_again_but_does_not_reload() {
let importer = CountingImporter::new(diamond_files(), false);
let out = compile(
DIAMOND_ENTRY,
&Options::default().with_url("/entry").with_importer(&importer),
)
.expect("compile");
assert_eq!(out, DIAMOND_CSS);
assert_eq!(
CountingImporter::count(&importer.canonicalized, "shared"),
3,
"canonicalize runs per edge: the entry, /_a and /_b each `@use \"shared\"`"
);
assert_eq!(
CountingImporter::count(&importer.loaded, "/_shared"),
1,
"load runs once per canonical url"
);
}
#[test]
fn a_module_that_becomes_unreadable_after_its_first_load_still_compiles() {
let importer = CountingImporter::new(diamond_files(), true);
let out = compile(
DIAMOND_ENTRY,
&Options::default().with_url("/entry").with_importer(&importer),
)
.expect("a cached module is never re-read, so the poisoned reload is never reached");
assert_eq!(out, DIAMOND_CSS);
}
#[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:rgb(15%,30%,45%)}");
assert_eq!(case("lighten(#336699,10%)"), "a{x:rgb(25%,50%,75%)}");
assert_eq!(case("saturate(#336699,10%)"), "a{x:rgb(16%,40%,64%)}");
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:rgb(100%,82%,70%)}"
);
assert_eq!(
css_compressed("@use 'sass:color';a{x:color.adjust(#336699,$lightness:-10%)}"),
"a{x:rgb(15%,30%,45%)}"
);
assert_eq!(case("rgba(darken(#336699,10%),.5)"), "a{x:rgba(15%,30%,45%,.5)}");
assert_eq!(case("saturate(#888,20%)"), "a{x:rgb(62.6666666667%,44%,44%)}");
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:rgb(15%,30%,45%)}");
assert_eq!(case("hsl(30,100%,50%)"), "a{x:rgb(100%,50%,0%)}");
assert_eq!(case("hsl(30,0%,50%)"), "a{x:hsl(0,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_keeps_the_zero_on_a_negative_decimal() {
let v = |scss: &str| css_compressed(&format!("a{{x:{scss}}}"));
assert_eq!(v("0.5px"), "a{x:.5px}");
assert_eq!(v("-0.5px"), "a{x:-0.5px}");
assert_eq!(v("-0.25%"), "a{x:-0.25%}");
assert_eq!(v("-0.5"), "a{x:-0.5}");
assert_eq!(v("-1px * 0.1"), "a{x:-0.1px}");
assert_eq!(v("1px -0.5px"), "a{x:1px -0.5px}");
assert_eq!(v("0px"), "a{x:0px}");
assert_eq!(v("-0.0px"), "a{x:-0px}");
assert_eq!(v("-0"), "a{x:-0}");
}
#[test]
fn compressed_keeps_loud_comments() {
assert_eq!(css_compressed("/*! head */\n.a { b: 1; }"), "/*! head */.a{b:1}");
assert_eq!(
css_compressed("/*!\n * line\n */\n.a { b: 1; }"),
"/*!\n * line\n */.a{b:1}"
);
assert_eq!(
css_compressed("/*! one */\n/*! two */\n.a { b: 1; }"),
"/*! one *//*! two */.a{b:1}"
);
assert_eq!(css_compressed(".a { b: 1; }\n/*! tail */"), ".a{b:1}/*! tail */");
assert_eq!(
css_compressed(".a { b: 1; }\n/*! mid */\n.c { d: 1; }"),
".a{b:1}/*! mid */.c{d:1}"
);
assert_eq!(
css_compressed(".a { b: 1; /*! c */ d: 2; }"),
".a{b:1;/*! c */d:2}"
);
assert_eq!(css_compressed(".a { /*! c */ b: 1; }"), ".a{/*! c */b:1}");
assert_eq!(css_compressed(".a { b: 1; /*! c */ }"), ".a{b:1;/*! c */}");
assert_eq!(css_compressed(".a { /*! c */ }"), ".a{/*! c */}");
assert_eq!(css_compressed(".a { /* c */ }"), "");
assert_eq!(
css_compressed(".a { /*! c */ .b { d: 1; } }"),
".a{/*! c */}.a .b{d:1}"
);
assert_eq!(
css_compressed("@media (a: 1) { /*! c */ .a { b: 1; } }"),
"@media(a: 1){/*! c */.a{b:1}}"
);
assert_eq!(
css_compressed("$x: 1;\n/*! v#{$x} */\n.a { b: 1; }"),
"/*! v1 */.a{b:1}"
);
}
#[test]
fn compressed_selectors_keep_an_escapes_terminator() {
let sel = |scss: &str| css_compressed(&format!("{scss}{{a:1}}"));
assert_eq!(sel(".\\31 > .b"), ".\\31 >.b{a:1}");
assert_eq!(sel(".\\31 .b"), ".\\31 .b{a:1}");
assert_eq!(sel(".\\31 .b"), ".\\31 .b{a:1}");
assert_eq!(sel(".\\31 "), ".\\31 {a:1}");
assert_eq!(sel(":not(.\\31 , .b)"), ":not(.\\31 ,.b){a:1}");
assert_eq!(sel(":not(.a, .\\31 )"), ":not(.a,.\\31 ){a:1}");
assert_eq!(sel(":is(.\\31 , .b) > .c"), ":is(.\\31 ,.b)>.c{a:1}");
assert_eq!(sel(".a\\ b > .c"), ".a\\ b>.c{a:1}");
assert_eq!(sel(".a\\9 b .c"), ".a\\9 b .c{a:1}");
assert_eq!(sel(".\\31 a .b"), ".\\31 a .b{a:1}");
}
#[test]
fn compressed_selector_pseudo_dispatch_matches_dart() {
let sel = |scss: &str| css_compressed(&format!("{scss}{{a:1}}"));
assert_eq!(sel("::slotted(.b, .c)"), "::slotted(.b,.c){a:1}");
assert_eq!(sel(".x:-moz-any(.b, .c)"), ".x:-moz-any(.b,.c){a:1}");
assert_eq!(sel(".x:NOT(.b, .c)"), ".x:NOT(.b, .c){a:1}");
assert_eq!(sel(".x:Where(.b, .c)"), ".x:Where(.b, .c){a:1}");
assert_eq!(sel(".x:LANG(en, fr)"), ".x:LANG(en, fr){a:1}");
}
#[test]
fn compressed_loudness_is_decided_after_interpolation() {
assert_eq!(
css_compressed("/*#{\"!\"} normal */\n.a { b: 1; }"),
"/*! normal */.a{b:1}"
);
assert_eq!(css_compressed("/* !late */\n.a { b: 1; }"), ".a{b:1}");
}
#[test]
fn compressed_css_import_loses_its_prelude_space() {
assert_eq!(
css_compressed("@import \"x.css\";\n.a { b: 1; }"),
"@import\"x.css\";.a{b:1}"
);
assert_eq!(
css_compressed("@import url(x.css);\n.a { b: 1; }"),
"@import\"x.css\";.a{b:1}"
);
assert_eq!(
css_compressed("@import url(\"x.css\");\n.a { b: 1; }"),
"@import\"x.css\";.a{b:1}"
);
assert_eq!(
css_compressed("@import 'x.css';\n.a { b: 1; }"),
"@import'x.css';.a{b:1}"
);
assert_eq!(
css_compressed("@import \"x.css\" screen, print;\n.a { b: 1; }"),
"@import\"x.css\"screen, print;.a{b:1}"
);
assert_eq!(
css_compressed("@import \"x.css\" layer(a) supports(display: grid) screen;\n.a { b: 1; }"),
"@import\"x.css\"layer(a) supports(display: grid) screen;.a{b:1}"
);
assert_eq!(
css_compressed(".a { @import url(x.css); }"),
".a{@import\"x.css\"}"
);
assert_eq!(
css_compressed("@media screen { @import \"x.css\"; }"),
"@media screen{@import\"x.css\"}"
);
let expanded = |scss: &str| compile(scss, &Options::default()).expect("compile");
assert_eq!(
expanded("@import url(x.css);\n.a { b: 1; }"),
"@import url(x.css);\n.a {\n b: 1;\n}"
);
}
#[test]
fn compressed_supports_declarations_keep_their_calculation_spaces() {
for (scss, want) in [
(
"@supports (width: calc-size(auto, var(--y))) { .a { b: 1; } }",
"@supports(width: calc-size(auto, var(--y))){.a{b:1}}",
),
(
"@supports (width: clamp(1px, var(--y), 2px)) { .a { b: 1; } }",
"@supports(width: clamp(1px, var(--y), 2px)){.a{b:1}}",
),
(
"@supports (width: min(1px, var(--y))) { .a { b: 1; } }",
"@supports(width: min(1px, var(--y))){.a{b:1}}",
),
] {
assert_eq!(css_compressed(scss), want, "{scss}");
}
assert_eq!(
css_compressed(".a { b: calc-size(auto, var(--y)); }"),
".a{b:calc-size(auto,var(--y))}"
);
}
#[test]
fn compressed_finds_a_modules_last_visible_child() {
let dir = std::env::temp_dir().join(format!("sasso_tail_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let imp = sasso::FsImporter::new(vec![dir.clone()]);
let run = |name: &str, module: &str, entry: &str| -> String {
std::fs::write(dir.join(format!("_{name}.scss")), module).unwrap();
compile(
entry,
&Options::default()
.with_importer(&imp)
.with_style(OutputStyle::Compressed),
)
.expect("compile")
};
for (name, module) in [
("tail_plain", "@namespace \"x\";\n"),
("tail_blank", "@namespace \"x\";\n\n\n"),
("tail_quiet", "@namespace \"x\";\n/* quiet */\n"),
("tail_map", "@namespace \"x\";\n/*# sourceMappingURL=x.map */\n"),
(
"tail_empty_rule",
"@namespace \"x\";\n.e { /* only a comment */ }\n",
),
("tail_empty_at", "@namespace \"x\";\n@media a {}\n"),
] {
assert_eq!(
run(name, module, &format!("@use \"{name}\";")),
"@namespace \"x\"",
"{name}"
);
}
assert_eq!(
run(
"tail_loud",
"@namespace \"x\";\n/*! loud */\n",
"@use \"tail_loud\";"
),
"@namespace \"x\";/*! loud */"
);
assert_eq!(
run(
"tail_after",
"@namespace \"x\";\n",
"@use \"tail_after\";\n.z { y: 1; }"
),
"@namespace \"x\";.z{y:1}"
);
assert_eq!(
run(
"tail_import",
"@import \"z.css\";\n/* quiet */\n",
"@use \"tail_import\";"
),
"@import\"z.css\""
);
std::fs::write(dir.join("_all_comments.scss"), "/* quiet */\n").unwrap();
std::fs::write(dir.join("_nothing.scss"), "\n").unwrap();
for loaded in ["all_comments", "nothing"] {
assert_eq!(
run(
"tail_load",
&format!("@use \"sass:meta\";\n@namespace \"x\";\n@include meta.load-css(\"{loaded}\");\n"),
"@use \"tail_load\";",
),
"@namespace \"x\"",
"{loaded}"
);
}
std::fs::write(dir.join("_writes.scss"), ".w { v: 1; }\n").unwrap();
assert_eq!(
run(
"tail_load2",
"@use \"sass:meta\";\n@namespace \"x\";\n@include meta.load-css(\"writes\");\n",
"@use \"tail_load2\";",
),
"@namespace \"x\";.w{v:1}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn compressed_nested_plain_css_import_loses_its_gap() {
let dir = std::env::temp_dir().join(format!("sasso_nested_import_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let imp = sasso::FsImporter::new(vec![dir.clone()]);
let run = |name: &str, css: &str| -> String {
std::fs::write(dir.join(format!("{name}.css")), css).unwrap();
compile(
&format!("@use \"{name}\";"),
&Options::default()
.with_importer(&imp)
.with_style(OutputStyle::Compressed),
)
.expect("compile")
};
assert_eq!(
run("v1", ".a {\n @import url(x.css);\n}\n"),
".a{@import\"x.css\"}"
);
assert_eq!(
run("v2", ".a {\n @import \"x.css\";\n}\n"),
".a{@import\"x.css\"}"
);
assert_eq!(
run("v3", "@import url(x.css);\n.a { b: 1; }\n"),
"@import\"x.css\";.a{b:1}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn compressed_plain_css_calls_keep_their_arguments_default_style() {
let v = |scss: &str| css_compressed(&format!("$o: o;\na{{x:{scss}}}"));
assert_eq!(v("f#{$o}o(0.5)"), "a{x:foo(0.5)}");
assert_eq!(v("f#{$o}o(0.5, -0.5px)"), "a{x:foo(0.5, -0.5px)}");
assert_eq!(v("#{\"fo\" + $o}(0.5)"), "a{x:foo(0.5)}");
assert_eq!(v("1px f#{$o}o(0.5)"), "a{x:1px foo(0.5)}");
assert_eq!(v("f#{$o}o(inner(0.5))"), "a{x:foo(inner(0.5))}");
assert_eq!(v("foo(0.5)"), "a{x:foo(0.5)}");
assert_eq!(v("translate(0.5px)"), "a{x:translate(0.5px)}");
let e = |scss: &str| compile(&format!("$o: o;\na{{x:{scss}}}"), &Options::default()).expect("compile");
assert_eq!(e("f#{$o}o(0.5)"), "a {\n x: foo(0.5);\n}");
assert_eq!(e("foo(0.5)"), "a {\n x: foo(0.5);\n}");
}
#[test]
fn an_escaped_delimiter_is_not_selector_structure() {
let css = |scss: &str| compile(scss, &Options::default()).expect("compile");
assert_eq!(css(".a\\,b { c: 1; }"), ".a\\,b {\n c: 1;\n}");
assert_eq!(css(".\\, { a: 1; }"), ".\\, {\n a: 1;\n}");
assert_eq!(css("#a\\,b { c: 1; }"), "#a\\,b {\n c: 1;\n}");
assert_eq!(css("a\\,b { c: 1; }"), "a\\,b {\n c: 1;\n}");
assert_eq!(css(".a\\,b, .c { d: 1; }"), ".a\\,b, .c {\n d: 1;\n}");
assert_eq!(css(".x { .a\\,b { c: 1; } }"), ".x .a\\,b {\n c: 1;\n}");
assert_eq!(css(".a\\,b { .c { d: 1; } }"), ".a\\,b .c {\n d: 1;\n}");
assert_eq!(css(".x { &\\,y { c: 1; } }"), ".x\\,y {\n c: 1;\n}");
assert_eq!(css(".x { .a\\&b { c: 1; } }"), ".x .a\\&b {\n c: 1;\n}");
assert_eq!(css("\\&a { b: 1; }"), "\\&a {\n b: 1;\n}");
assert_eq!(
css("%p { a: 1; }\n.x\\,y { @extend %p; }"),
".x\\,y {\n a: 1;\n}"
);
assert_eq!(
css(".a\\,b { c: 1; }\n.d { @extend .a\\,b; }"),
".a\\,b, .d {\n c: 1;\n}"
);
assert_eq!(css(".a\\2c b { c: 1; }"), ".a\\,b {\n c: 1;\n}");
assert_eq!(css(".a\\2c b, .c { d: 1; }"), ".a\\,b, .c {\n d: 1;\n}");
assert_eq!(css(".p { & .a\\,b & { c: 1; } }"), ".p .a\\,b .p {\n c: 1;\n}");
assert_eq!(
css(".p { & .a\\,b & .c { d: 1; } }"),
".p .a\\,b .p .c {\n d: 1;\n}"
);
assert_eq!(
css(".p, .q { & .a\\,b & { c: 1; } }"),
".p .a\\,b .p, .p .a\\,b .q, .q .a\\,b .p, .q .a\\,b .q {\n c: 1;\n}"
);
assert_eq!(css(".p { & .a\\&b & { c: 1; } }"), ".p .a\\&b .p {\n c: 1;\n}");
assert_eq!(css(".p { & & .a\\, { c: 1; } }"), ".p .p .a\\, {\n c: 1;\n}");
assert_eq!(
css(".p, .q { & [data-x=\"a\\\"b'c\"] & { d: 1; } }"),
".p [data-x=\"a\\\"b'c\"] .p, .p [data-x=\"a\\\"b'c\"] .q, \
.q [data-x=\"a\\\"b'c\"] .p, .q [data-x=\"a\\\"b'c\"] .q {\n d: 1;\n}"
);
assert_eq!(
css(".p, .q { & [data-x=\"a\\\"&b'c\"] & { d: 1; } }"),
".p [data-x=\"a\\\"&b'c\"] .p, .p [data-x=\"a\\\"&b'c\"] .q, \
.q [data-x=\"a\\\"&b'c\"] .p, .q [data-x=\"a\\\"&b'c\"] .q {\n d: 1;\n}"
);
assert_eq!(
css(".p, .q { [data-x=\"a&b\"] { c: 1; } }"),
".p [data-x=\"a&b\"], .q [data-x=\"a&b\"] {\n c: 1;\n}"
);
assert_eq!(
css("p > { & [data-x=\"&.x\"] { d: 1; } }"),
"p > [data-x=\"&.x\"] {\n d: 1;\n}"
);
assert_eq!(
css(".p, .q { :not([data=\"&\"]) { c: 1; } }"),
".p :not([data=\"&\"]), .q :not([data=\"&\"]) {\n c: 1;\n}"
);
assert_eq!(
css(".p, .q { :not([data=\"&-c\"]) { c: 1; } }"),
".p :not([data=\"&-c\"]), .q :not([data=\"&-c\"]) {\n c: 1;\n}"
);
assert_eq!(
css(".p, .q { :is([data=\"&\"]) { c: 1; } }"),
".p :is([data=\"&\"]), .q :is([data=\"&\"]) {\n c: 1;\n}"
);
assert_eq!(
css(".p, .q { :not(&-c) { d: 1; } }"),
":not(.p-c, .q-c) {\n d: 1;\n}"
);
assert!(compile("p > { &.x { d: 1; } }", &Options::default()).is_err());
for esc in ["\\[", "\\(", "\\]", "\\)", "\\,"] {
assert_eq!(
css(&format!(".a{esc}b, .c {{ & .d {{ e: 1; }} }}")),
format!(".a{esc}b .d, .c .d {{\n e: 1;\n}}"),
"{esc}"
);
}
assert_eq!(
css(".a\\]b[x=\",\"], .c { d: 1; }"),
".a\\]b[x=\",\"], .c {\n d: 1;\n}"
);
assert_eq!(
css(".a\\)b:not(.x, .y), .c { d: 1; }"),
".a\\)b:not(.x, .y), .c {\n d: 1;\n}"
);
for esc in ["\\[", "\\(", "\\]", "\\)"] {
assert_eq!(
css(&format!(".p, .q {{ & .a{esc}b & {{ c: 1; }} }}")),
format!(".p .a{esc}b .p, .p .a{esc}b .q, .q .a{esc}b .p, .q .a{esc}b .q {{\n c: 1;\n}}"),
"{esc}"
);
}
assert_eq!(
css(".p, .q { & .a\\[b[x=\"y\"] & { c: 1; } }"),
".p .a\\[b[x=y] .p, .p .a\\[b[x=y] .q, \
.q .a\\[b[x=y] .p, .q .a\\[b[x=y] .q {\n c: 1;\n}"
);
assert_eq!(css(".p { .a\\[b & { c: 1; } }"), ".a\\[b .p {\n c: 1;\n}");
assert_eq!(css_compressed(".a\\,b, .c { d: 1; }"), ".a\\,b,.c{d:1}");
assert_eq!(
css_compressed(".x { .a\\&b > .c { d: 1; } }"),
".x .a\\&b>.c{d:1}"
);
}
#[test]
fn a_cartesian_part_is_cut_on_character_boundaries() {
let sel = |scss: &str| -> String {
let out = compile(scss, &Options::default()).expect("compile");
out.strip_prefix("@charset \"UTF-8\";\n")
.expect("non-ASCII output opens with the charset prologue")
.to_string()
};
assert_eq!(
sel(".p, .q { & .ü & { c: 1; } }"),
".p .ü .p, .p .ü .q, .q .ü .p, .q .ü .q {\n c: 1;\n}"
);
assert_eq!(
sel(".p { & .日本語-クラス & { c: 2; } }"),
".p .日本語-クラス .p {\n c: 2;\n}"
);
assert_eq!(
sel(".p, .q { & .a\\,ü & { c: 3; } }"),
".p .a\\,ü .p, .p .a\\,ü .q, .q .a\\,ü .p, .q .a\\,ü .q {\n c: 3;\n}"
);
assert_eq!(
sel(".p, .q { & [data-x=\"ü&é\"] & { c: 4; } }"),
".p [data-x=\"ü&é\"] .p, .p [data-x=\"ü&é\"] .q, \
.q [data-x=\"ü&é\"] .p, .q [data-x=\"ü&é\"] .q {\n c: 4;\n}"
);
assert_eq!(sel(".p { & .🎉 & .é { c: 5; } }"), ".p .🎉 .p .é {\n c: 5;\n}");
assert_eq!(
sel(".ü, .é { & & { c: 6; } }"),
".ü .ü, .ü .é, .é .ü, .é .é {\n c: 6;\n}"
);
assert_eq!(sel(".ü { &-é { c: 7; } }"), ".ü-é {\n c: 7;\n}");
}
#[test]
fn compressed_drops_what_writes_nothing() {
assert_eq!(css_compressed("@media x { /* c */ }"), "");
assert_eq!(css_compressed("@media x { .a { /* c */ } }"), "");
assert_eq!(css_compressed("@supports (a: 1) { /* c */ }"), "");
assert_eq!(css_compressed(".a { /* c */ }"), "");
assert_eq!(css_compressed(".a { .b { /* c */ } }"), "");
assert_eq!(css_compressed(".a { c: 1; @media x { /* d */ } }"), ".a{c:1}");
assert_eq!(
css_compressed("@media x { .a { b: 1; } .c { /* d */ } }"),
"@media x{.a{b:1}}"
);
assert_eq!(css_compressed("@media x { /*! c */ }"), "@media x{/*! c */}");
assert_eq!(css_compressed("@media x { .a { b: 1; } }"), "@media x{.a{b:1}}");
let expanded = |scss: &str| compile(scss, &Options::default()).expect("compile");
assert_eq!(expanded("@media x { /* c */ }"), "@media x { /* c */ }");
assert_eq!(expanded(".a { /* c */ }"), ".a { /* c */ }");
assert_eq!(expanded("@media x {\n /* c */\n}"), "@media x {\n /* c */\n}");
assert_eq!(expanded(".a {\n /* c */\n}"), ".a {\n /* c */\n}");
}
#[test]
fn compressed_keeps_a_semicolon_that_belongs_to_a_value() {
let v = "$v: \";\";\n";
assert_eq!(
css_compressed(&format!("{v}.a {{ --x: #{{$v}}; }}")),
".a{--x: ;}"
);
assert_eq!(
css_compressed(&format!("{v}@font-face {{ --x: #{{$v}}; }}")),
"@font-face{--x: ;}"
);
assert_eq!(
css_compressed(&format!("{v}@font-face {{ src: #{{$v}}; }}")),
"@font-face{src:;}"
);
assert_eq!(
css_compressed(&format!("{v}@font-face {{ a: 1; --x: #{{$v}}; }}")),
"@font-face{a:1;--x: ;}"
);
assert_eq!(
css_compressed(&format!("{v}@media a {{ @font-face {{ --x: #{{$v}}; }} }}")),
"@media a{@font-face{--x: ;}}"
);
}
#[test]
fn compressed_interpolated_at_rule_names_keep_their_gap() {
assert_eq!(
css_compressed("@#{\"import\"} \"x.css\";\n.a { b: 1; }"),
"@import \"x.css\";.a{b:1}"
);
assert_eq!(
css_compressed("@import \"x.css\";\n.a { b: 1; }"),
"@import\"x.css\";.a{b:1}"
);
assert_eq!(
css_compressed(".a { @import url(x.css); }"),
".a{@import\"x.css\"}"
);
assert_eq!(
css_compressed(".a { @#{\"import\"} \"x.css\"; }"),
".a{@import \"x.css\"}"
);
}
#[test]
fn compressed_leaves_the_pseudo_elements_dart_leaves() {
let sel = |scss: &str| css_compressed(&format!("{scss}{{a:1}}"));
assert_eq!(sel("::cue(.b, .c)"), "::cue(.b, .c){a:1}");
assert_eq!(sel("::cue-region(.b, .c)"), "::cue-region(.b, .c){a:1}");
assert_eq!(sel("::part(b, c)"), "::part(b, c){a:1}");
}
#[test]
fn compressed_output_never_ends_with_a_semicolon() {
assert_eq!(css_compressed("@import \"x.css\";"), "@import\"x.css\"");
assert_eq!(
css_compressed("@import \"x.css\" screen;"),
"@import\"x.css\"screen"
);
assert_eq!(css_compressed("@namespace \"x\";"), "@namespace \"x\"");
assert_eq!(css_compressed("@unknown foo;"), "@unknown foo");
assert_eq!(
css_compressed("@media a { @unknown foo; }"),
"@media a{@unknown foo}"
);
assert_eq!(css_compressed(".a { b: 1; }"), ".a{b:1}");
assert_eq!(
css_compressed("@font-face { src: url(x); }"),
"@font-face{src:url(x)}"
);
}
#[test]
fn compressed_writes_private_use_characters_raw() {
let expanded = |scss: &str| compile(scss, &Options::default()).expect("compile");
assert_eq!(
expanded(".a::before { content: \"\\e028\"; }"),
".a::before {\n content: \"\\e028\";\n}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\e028\"; }"),
"\u{feff}.a::before{content:\"\u{e028}\"}"
);
assert_eq!(
expanded(".a::before { content: \"\u{e028}\"; }"),
".a::before {\n content: \"\\e028\";\n}"
);
assert_eq!(
css_compressed(".a::before { content: \"\u{e028}\"; }"),
"\u{feff}.a::before{content:\"\u{e028}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\e000\"; }"),
"\u{feff}.a::before{content:\"\u{e000}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\f8ff\"; }"),
"\u{feff}.a::before{content:\"\u{f8ff}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\f0000\"; }"),
"\u{feff}.a::before{content:\"\u{f0000}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\10fffd\"; }"),
"\u{feff}.a::before{content:\"\u{10fffd}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\4e2d\"; }"),
"\u{feff}.a::before{content:\"\u{4e2d}\"}"
);
assert_eq!(
css_compressed(".a::before { content: \"\\1\"; }"),
".a::before{content:\"\\1\"}"
);
assert_eq!(
css_compressed(".a { b: inspect(\"\\e028\"); }"),
".a{b:\"\\e028\"}"
);
}
#[test]
fn compressed_selectors_lose_only_structural_whitespace() {
let sel = |scss: &str| css_compressed(&format!("{scss}{{a:1}}"));
assert_eq!(sel(".a > .b"), ".a>.b{a:1}");
assert_eq!(sel(".a + .b"), ".a+.b{a:1}");
assert_eq!(sel(".a ~ .b"), ".a~.b{a:1}");
assert_eq!(sel(".a .b > .c + .d ~ .e"), ".a .b>.c+.d~.e{a:1}");
assert_eq!(sel("* > *"), "*>*{a:1}");
assert_eq!(sel(".a .b"), ".a .b{a:1}");
assert_eq!(sel(":has(+ .b)"), ":has(+.b){a:1}");
assert_eq!(sel(":has(> .a, + .b)"), ":has(>.a,+.b){a:1}");
assert_eq!(sel(":not(.b, .c)"), ":not(.b,.c){a:1}");
assert_eq!(sel(":where(.a, .b) .c"), ":where(.a,.b) .c{a:1}");
assert_eq!(sel(":is(:not(.a, .b), .c) > .d"), ":is(:not(.a,.b),.c)>.d{a:1}");
assert_eq!(sel(":host-context(.a, .b)"), ":host-context(.a,.b){a:1}");
assert_eq!(sel(":lang(en, fr)"), ":lang(en, fr){a:1}");
assert_eq!(sel(":nth-child(2n + 1)"), ":nth-child(2n+1){a:1}");
assert_eq!(
sel(":nth-child(2n + 1 of .a, .b)"),
":nth-child(2n+1 of .a,.b){a:1}"
);
assert_eq!(sel("[a=\"x > y\"]"), "[a=\"x > y\"]{a:1}");
assert_eq!(sel(":not([a=\"x, y\"], .b)"), ":not([a=\"x, y\"],.b){a:1}");
assert_eq!(sel(".a\\+b"), ".a\\+b{a:1}");
assert_eq!(sel(".a\\:b > .c"), ".a\\:b>.c{a:1}");
assert_eq!(css_compressed(".a { > .b { c: 1; } }"), ".a>.b{c:1}");
assert_eq!(
css_compressed("%p { a: 1; }\n.x > .y { @extend %p; }"),
".x>.y{a:1}"
);
}
#[test]
fn compressed_preserved_calculations_drop_the_argument_space() {
let v = |scss: &str| css_compressed(&format!("a{{x:{scss}}}"));
assert_eq!(v("clamp(0.5px, var(--y), 2px)"), "a{x:clamp(.5px,var(--y),2px)}");
assert_eq!(
v("clamp(1px, env(safe-area), 2px)"),
"a{x:clamp(1px,env(safe-area),2px)}"
);
assert_eq!(v("min(1px, var(--y))"), "a{x:min(1px,var(--y))}");
assert_eq!(v("mod(var(--y), 2px)"), "a{x:mod(var(--y),2px)}");
assert_eq!(v("pow(var(--y), 2)"), "a{x:pow(var(--y),2)}");
assert_eq!(v("calc-size(auto, var(--y))"), "a{x:calc-size(auto,var(--y))}");
assert_eq!(
v("calc(1px + clamp(1px, var(--y), 2px))"),
"a{x:calc(1px + clamp(1px,var(--y),2px))}"
);
assert_eq!(
css_compressed("@supports (width: clamp(1px, var(--y), 2px)) { .a { b: 1; } }"),
"@supports(width: clamp(1px, var(--y), 2px)){.a{b:1}}"
);
}
#[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 a_nested_declaration_sees_its_blocks_later_siblings() {
assert_eq!(
css(".a {\n @function first($n) { @return second($n) + 1; }\n @function second($n) { @return $n * 2; }\n order: first(3);\n}"),
".a {\n order: 7;\n}\n"
);
assert_eq!(
css(".b {\n @mixin ping($n) { @if $n > 0 { @include pong($n - 1); } @else { done: true; } }\n @mixin pong($n) { @include ping($n); }\n @include ping(3);\n}"),
".b {\n done: true;\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 unknown_channel_errors_render_the_color_with_inspect() {
let err = |src: &str| compile(src, &Options::default()).unwrap_err().message;
let call = |src: &str| format!("@use \"sass:color\";a{{b:{src}}}");
assert_eq!(
err(&call("color.is-missing(hwb(200 20% 30%), \"red\")")),
"$channel: Color hwb(200 20% 30%) doesn't have a channel named \"red\"."
);
assert_eq!(
err(&call("color.is-powerless(hwb(200 20% 30%), \"red\")")),
"$channel: Color hwb(200 20% 30%) doesn't have a channel named \"red\"."
);
assert_eq!(
err(&call("color.channel(hwb(200 20% 30% / 0.5), \"zzz\")")),
"$channel: Color hwb(200 20% 30% / 0.5) has no channel named zzz."
);
assert_eq!(
err(&call("color.is-missing(oklch(0.5 0.1 200), \"red\")")),
"$channel: Color oklch(50% 0.1 200deg) doesn't have a channel named \"red\"."
);
assert_eq!(
err(&call("color.is-missing(#336699, \"zzz\")")),
"$channel: Color #336699 doesn't have a channel named \"zzz\"."
);
}
#[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"
);
}
#[test]
fn a_custom_property_value_reads_an_escape_as_one_token() {
assert_eq!(
css(".a { --x: \\{; }\n.b { c: d; }\n"),
".a {\n --x: \\{;\n}\n\n.b {\n c: d;\n}\n"
);
assert_eq!(css(".a { --x: \\\"; }\n"), ".a {\n --x: \\\";\n}\n");
assert_eq!(css(".a { --x: a\\;b; }\n"), ".a {\n --x: a\\;b;\n}\n");
assert_eq!(css(".a { --x: \\61 b; }\n"), ".a {\n --x: ab;\n}\n");
assert_eq!(css(".a { --x: \\7b; }\n"), ".a {\n --x: \\{;\n}\n");
assert_eq!(css(".a { --x: \\30 z; }\n"), ".a {\n --x: \\30 z;\n}\n");
assert_eq!(css(".a { --x: \\9 z; }\n"), ".a {\n --x: \\9 z;\n}\n");
assert_eq!(css(".a { --x: \\2d z; }\n"), ".a {\n --x: \\-z;\n}\n");
assert_eq!(
css(".a { --x: \\d800 z; }\n"),
"@charset \"UTF-8\";\n.a {\n --x: \u{fffd}z;\n}\n"
);
assert_eq!(
css("@supports (--x: \\61 b) { a { b: c } }\n"),
"@supports (--x: ab) {\n a {\n b: c;\n }\n}\n"
);
assert_eq!(
css("@function --f() { result: \\{; }\n"),
"@function --f() {\n result: \\{;\n}\n"
);
}
#[test]
fn a_custom_property_value_matches_its_brackets() {
let err = |src: &str| {
let e = compile(src, &Options::default()).expect_err("expected a compile error");
(e.to_string(), e.line, e.col)
};
let (msg, line, col) = err(".a { --x: (] ; }\n");
assert!(msg.contains("expected \")\"."), "{msg}");
assert_eq!((line, col), (1, 12));
let (msg, line, col) = err(".a { --x: ]; }\n");
assert!(msg.contains("expected \";\"."), "{msg}");
assert_eq!((line, col), (1, 11));
let (msg, line, col) = err(".a { --x: a\\\n b; }\n");
assert!(msg.contains("Expected escape sequence."), "{msg}");
assert_eq!((line, col), (1, 13));
}
#[test]
fn a_plain_css_custom_at_rule_body_matches_its_brackets() {
let err = |src: &str| {
let e = compile(src, &Options::default()).expect_err("expected a compile error");
(e.to_string(), e.line, e.col)
};
let (msg, line, col) = err("@function --f() { result: (]; }\n");
assert!(msg.contains("expected \")\"."), "{msg}");
assert_eq!((line, col), (1, 28));
let (msg, line, col) = err("@function --f() { result: (; }\n");
assert!(msg.contains("expected \")\"."), "{msg}");
assert_eq!((line, col), (1, 30));
let (msg, line, col) = err("@function --f() { result: ]; }\n");
assert!(msg.contains("expected \";\"."), "{msg}");
assert_eq!((line, col), (1, 27));
assert_eq!(
css("@function --f() { result: (a; b); other: c; }\n"),
"@function --f() {\n result: (a; b);\n other: c;\n}\n"
);
}
#[test]
fn an_import_url_token_drops_its_padding_and_decodes_escapes() {
assert_eq!(css("@import url( x.css );\n"), "@import url(x.css);\n");
assert_eq!(
css("@import url(\n http://x/y.css\n);\n"),
"@import url(http://x/y.css);\n"
);
assert_eq!(css("@import url(\\61 b.css);\n"), "@import url(ab.css);\n");
assert_eq!(css("@import url(\\2d x.css);\n"), "@import url(-x.css);\n");
assert_eq!(css("@import url(\\30 x.css);\n"), "@import url(0x.css);\n");
assert_eq!(css("@import url(\\9 x.css);\n"), "@import url(\\9 x.css);\n");
assert_eq!(css("@import url(\\ x.css);\n"), "@import url(\\ x.css);\n");
assert_eq!(
css("@import url(foo\\)bar.css);\n"),
"@import url(foo\\)bar.css);\n"
);
assert_eq!(
css("@import url(\" x.css \");\n"),
"@import url(\" x.css \");\n"
);
assert_eq!(
css("@import url(x.css ), url(y.css);\n"),
"@import url(x.css);\n@import url(y.css);\n"
);
}
#[test]
fn a_backslash_before_a_newline_is_only_an_escape_inside_a_string() {
let err = |src: &str| {
let e = compile(src, &Options::default()).expect_err("expected a compile error");
(e.to_string(), e.line, e.col)
};
for (src, line, col) in [
(".a { b: c\\\n d; }\n", 1, 11),
(".a,\\\n.b { c: d; }\n", 1, 5),
(".a { b\\\nc: d; }\n", 1, 8),
("@media screen\\\nand (min-width: 0) { .a { b: c } }\n", 1, 15),
(".a { b: url(foo\\\nbar.css); }\n", 1, 17),
] {
let (msg, l, c) = err(src);
assert!(msg.contains("Expected escape sequence."), "{src:?}: {msg}");
assert_eq!((l, c), (line, col), "for {src:?}");
}
assert_eq!(css(".a { b: \"x\\\ny\"; }\n"), ".a {\n b: \"xy\";\n}\n");
assert_eq!(css("[a=\"x\\\ny\"] { c: d; }\n"), "[a=xy] {\n c: d;\n}\n");
assert_eq!(
css("@media (min-width: 0) and (x: \"a\\\nb\") { .a { b: c } }\n"),
"@media (min-width: 0) and (x: ab) {\n .a {\n b: c;\n }\n}\n"
);
}
#[test]
fn an_import_url_that_is_not_a_url_token_is_a_function_call() {
assert_eq!(css("@import url(foo + bar);\n"), "@import url(foobar);\n");
assert_eq!(
css("$v: x;\n@import url($v + \".css\");\n"),
"@import url(x.css);\n"
);
assert_eq!(
css("@import url(\" x.css \");\n"),
"@import url(\" x.css \");\n"
);
assert_eq!(
css("$p: http;\n@import url(\"#{$p}://x/y.css\");\n"),
"@import url(\"http://x/y.css\");\n"
);
assert_eq!(css("@import url( x.css );\n"), "@import url(x.css);\n");
}
#[test]
fn interpolation_resolves_inside_a_quoted_verbatim_value() {
assert_eq!(
css("$v: x;\n@supports (--a: \"#{$v}\") { .a { b: c } }\n"),
"@supports (--a: \"x\") {\n .a {\n b: c;\n }\n}\n"
);
assert_eq!(
css("$v: x;\n@function --f() { result: \"#{$v}\"; }\n"),
"@function --f() {\n result: \"x\";\n}\n"
);
assert_eq!(
css("$v: x;\n.a { --x: \"#{$v}\"; }\n"),
".a {\n --x: \"x\";\n}\n"
);
assert_eq!(
css(".a { --x: \"a\\\nb\"; }\n"),
".a {\n --x: \"a\\\n b\";\n}\n"
);
assert_eq!(
css("@supports (--a: \"x\\\ny\") { .a { b: c } }\n"),
"@supports (--a: \"x\\ y\") {\n .a {\n b: c;\n }\n}\n"
);
}
#[test]
fn a_form_feed_is_a_newline_to_the_escape_reader() {
let err = |src: &str| {
let e = compile(src, &Options::default()).expect_err("expected a compile error");
(e.to_string(), e.line, e.col)
};
let (msg, line, col) = err(".a { --x: c\\\u{c}d; }\n");
assert!(msg.contains("Expected escape sequence."), "{msg}");
assert_eq!((line, col), (1, 13));
let (msg, _, _) = err(".a { b: c\\\u{c}d; }\n");
assert!(msg.contains("Expected escape sequence."), "{msg}");
assert_eq!(css(".a { b: \"c\\\u{c}d\"; }\n"), ".a {\n b: \"cd\";\n}\n");
}
#[test]
fn a_hex_escape_terminator_is_one_line_break() {
assert_eq!(css(".a { --x: \\61\r\nb; }\n"), ".a {\n --x: ab;\n}\n");
assert_eq!(css(".a { b: \\61\r\nb; }\n"), ".a {\n b: a b;\n}\n");
assert_eq!(css(".a { --x: \\61\nb; }\n"), ".a {\n --x: ab;\n}\n");
assert_eq!(css(".a { --x: \\61\rb; }\n"), ".a {\n --x: ab;\n}\n");
assert_eq!(css(".a { b: \\61\nb; }\n"), ".a {\n b: ab;\n}\n");
assert_eq!(css(".a { b: \\61\rb; }\n"), ".a {\n b: ab;\n}\n");
}
#[test]
fn a_quoted_string_is_serialized_with_the_quote_it_needs() {
assert_eq!(
css("@use \"sass:meta\";\n.a { b: meta.inspect(\"a\\\"b\"); }\n"),
".a {\n b: 'a\"b';\n}\n"
);
assert_eq!(
css("@use \"sass:meta\";\n.a { b: meta.inspect(\"a\\\\b\"); }\n"),
".a {\n b: \"a\\\\b\";\n}\n"
);
assert_eq!(
css("@use \"sass:meta\";\n.a { b: meta.inspect(\"it \\\"broke\\\"\"); }\n"),
".a {\n b: 'it \"broke\"';\n}\n"
);
assert_eq!(
css("@use \"sass:meta\";\n.a { b: meta.inspect((x: \"a\\\"b\")); }\n"),
".a {\n b: (x: 'a\"b');\n}\n"
);
let e = compile("@error \"a\\\"b\";\n", &Options::default()).expect_err("an @error");
assert!(e.to_string().contains("'a\"b'"), "{e}");
let e = compile("@error 'it \"broke\"';\n", &Options::default()).expect_err("an @error");
assert!(e.to_string().contains("'it \"broke\"'"), "{e}");
}
#[test]
fn an_unquoted_import_url_written_back_is_quoted_like_a_string() {
let sass = |src: &str| compile(src, &Options::default().with_syntax(Syntax::Sass)).expect("compile");
assert_eq!(
sass("@import h\\74 tps://x/y.css\n"),
"@import \"h\\\\74 tps://x/y.css\";"
);
assert_eq!(sass("@import foo\\\"bar.css\n"), "@import 'foo\\\\\"bar.css';");
assert_eq!(sass("@import \\\\x.css\n"), "@import \"\\\\\\\\x.css\";");
assert_eq!(sass("@import foo.css\n"), "@import \"foo.css\";");
}
#[test]
fn a_builtin_answers_to_its_underscore_spelling() {
assert_eq!(css("a { b: str_index(\"abc\", \"b\"); }"), "a {\n b: 2;\n}\n");
assert_eq!(css("a { b: to_upper_case(\"abc\"); }"), "a {\n b: \"ABC\";\n}\n");
assert_eq!(css("a { b: type_of(1); }"), "a {\n b: number;\n}\n");
assert_eq!(css("a { b: map_get((x: 1), x); }"), "a {\n b: 1;\n}\n");
assert_eq!(
css("$x: 1; a { b: variable_exists(\"x\"); }"),
"a {\n b: true;\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; a { b: meta.function_exists(\"rgb\"); }"),
"a {\n b: true;\n}\n"
);
assert_eq!(css("a { b: my_own_fn(1); }"), "a {\n b: my_own_fn(1);\n}\n");
assert_eq!(
css("@use \"sass:meta\"; a { b: meta.inspect(meta.get-function(\"map_get\")); }"),
"a {\n b: get-function(\"map-get\");\n}\n"
);
}
#[test]
fn a_module_member_reference_is_not_its_global_alias() {
assert_eq!(
css("@use \"sass:meta\"; @use \"sass:map\";\na { b: meta.inspect(meta.get-function(\"get\", $module: \"map\")); }"),
"a {\n b: get-function(\"get\");\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; @use \"sass:map\";\na { b: meta.get-function(\"get\", $module: \"map\") == meta.get-function(\"map-get\"); }"),
"a {\n b: false;\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; @use \"sass:map\";\na { b: meta.call(meta.get-function(\"get\", $module: \"map\"), (x: 1), x); }"),
"a {\n b: 1;\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; @use \"sass:color\";\na { b: meta.inspect(meta.get-function(\"scale\", $module: \"color\")); }"),
"a {\n b: get-function(\"scale\");\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; @use \"sass:color\";\na { b: meta.call(meta.get-function(\"scale\", $module: \"color\"), #abcdef, $lightness: 50%); }"),
"a {\n b: #d5e6f7;\n}\n"
);
assert_eq!(
css(
"@use \"sass:map\" as *; @use \"sass:meta\";\na { b: meta.inspect(meta.get-function(\"get\")); }"
),
"a {\n b: get-function(\"get\");\n}\n"
);
}
#[test]
fn every_global_is_referenceable_by_name() {
assert_eq!(
css(
"$x: 1;\n@use \"sass:meta\";\na { b: meta.call(meta.get-function(\"variable-exists\"), \"x\"); }"
),
"a {\n b: true;\n}\n"
);
assert_eq!(
css("@use \"sass:meta\";\na { b: meta.call(meta.get-function(\"call\"), meta.get-function(\"rgb\"), 1, 2, 3); }"),
"a {\n b: rgb(1, 2, 3);\n}\n"
);
for name in ["keywords", "content-exists", "get-function", "mixin-exists"] {
assert_eq!(
css(&format!(
"@use \"sass:meta\";\na {{ b: meta.inspect(meta.get-function(\"{name}\")); }}"
)),
format!("a {{\n b: get-function(\"{name}\");\n}}\n")
);
}
let err = compile(
"@use \"sass:meta\"; a { b: meta.get-function(\"a\\\"b\"); }",
&Options::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("Function not found: 'a\"b'"), "{err}");
}
#[test]
fn a_starred_builtin_member_shadows_the_global_of_that_name() {
assert_eq!(
css("@use \"sass:string\" as *; a { b: index(\"abc\", \"b\"); }"),
"a {\n b: 2;\n}\n"
);
assert_eq!(
css("@use \"sass:list\" as *; a { b: index(\"abc\" \"b\", \"b\"); }"),
"a {\n b: 2;\n}\n"
);
assert!(compile(
"@use \"sass:string\" as *; a { b: index(\"abc\" \"b\", \"b\"); }",
&Options::default()
)
.unwrap_err()
.to_string()
.contains("is not a string."));
for src in [
"@use \"sass:list\" as *; @use \"sass:string\" as *; a { b: index(\"abc\", \"b\"); }",
"@use \"sass:list\" as *; @use \"sass:string\" as *; @use \"sass:meta\";\na { b: meta.get-function(\"index\"); }",
"@use \"sass:list\" as *; @use \"sass:string\" as *; @use \"sass:meta\";\na { b: meta.function-exists(\"index\"); }",
] {
let err = compile(src, &Options::default()).unwrap_err().to_string();
assert!(
err.contains("This function is available from multiple global modules."),
"{src}: {err}"
);
}
assert_eq!(
css("@use \"sass:map\" as *; @use \"sass:meta\";\na { b: meta.function-exists(\"get\"); }"),
"a {\n b: true;\n}\n"
);
}
#[test]
fn a_reference_invoked_by_name_is_looked_up_canonically() {
assert_eq!(
css("$x: 1; a { b: call(\"variable_exists\", \"x\"); }"),
"a {\n b: true;\n}\n"
);
assert_eq!(
css("a { b: call(\"str_index\", \"abc\", \"b\"); }"),
"a {\n b: 2;\n}\n"
);
assert_eq!(
css("@use \"sass:meta\"; $x: 1; a { b: meta.call(\"variable_exists\", \"x\"); }"),
"a {\n b: true;\n}\n"
);
assert_eq!(
css("@use \"sass:string\" as *; @use \"sass:meta\";\na { b: meta.inspect(meta.get-function(\"to_upper_case\")); }"),
"a {\n b: get-function(\"to-upper-case\");\n}\n"
);
}