use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use sasso::{
compile, compile_with_source_map, CanonicalUrl, CanonicalizeContext, FsImporter, Importer, ImporterError,
ImporterResult, Options, Syntax, WarnEvent,
};
const DEP: &str = "@mixin m {\n x: 1;\n @content;\n z: 3;\n}\n@function f($v) {\n @warn \"in f\";\n @return $v + 1;\n}\n@mixin undef {\n q: $nope;\n}\n";
const IMP: &str = "@import \"dep\";\na {\n @include m {\n @warn \"in content\";\n y: f(1);\n }\n}\n";
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sasso_origin_{tag}_{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).expect("mkdir");
std::fs::write(dir.join("src/_dep.scss"), DEP).unwrap();
dir
}
fn canon_key(p: std::path::PathBuf) -> String {
let s = p.to_string_lossy().into_owned();
#[cfg(windows)]
let s = s.to_lowercase();
s
}
fn canon(dir: &std::path::Path, rel: &str) -> String {
canon_key(dir.join(rel))
}
#[test]
fn imported_callable_output_maps_to_its_file_and_content_to_the_includers() {
let dir = scratch("map");
let entry = dir.join("src/imp.scss");
std::fs::write(&entry, IMP).unwrap();
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default().with_importer(&imp).with_url(&url);
let r = compile_with_source_map(IMP, &opts).unwrap();
assert_eq!(r.css, "a {\n x: 1;\n y: 2;\n z: 3;\n}");
assert_eq!(r.source_map.sources, [url.clone(), canon(&dir, "src/_dep.scss")]);
assert_eq!(r.source_map.mappings, "AACA;ECAE;EDGE;ECDF");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn warn_events_inside_callables_carry_the_defining_file() {
let dir = scratch("warn");
let entry = dir.join("src/imp.scss");
std::fs::write(&entry, IMP).unwrap();
let url = entry.to_string_lossy().into_owned();
type Seen = Vec<(String, String, String, String)>;
let seen: Rc<RefCell<Seen>> = Rc::new(RefCell::new(Vec::new()));
let sink = Rc::clone(&seen);
let imp = FsImporter::new(Vec::new());
let opts = Options::default()
.with_importer(&imp)
.with_url(&url)
.with_warn_handler(Rc::new(move |ev: &WarnEvent<'_>| {
if !ev.deprecation {
sink.borrow_mut().push((
ev.message.to_string(),
ev.url.to_string(),
ev.path.to_string(),
ev.formatted.to_string(),
));
}
}));
compile(IMP, &opts).expect("compile");
let events = seen.borrow();
let dep = canon(&dir, "src/_dep.scss");
let content = events.iter().find(|e| e.0 == "in content").expect("content warn");
assert_eq!(content.2, url);
assert_eq!(content.1, url, "display url of the entry is its url as given");
assert!(
content.3.contains("src/imp.scss 4:5") && content.3.contains(" @content\n"),
"{}",
content.3
);
assert!(
content.3.contains("src/_dep.scss 3:3") && content.3.contains(" m()\n"),
"{}",
content.3
);
let f = events.iter().find(|e| e.0 == "in f").expect("f warn");
assert_eq!(f.2, dep);
assert!(
std::path::Path::new(&f.1).ends_with("src/_dep.scss"),
"url {:?}",
f.1
);
assert!(
f.3.contains("src/_dep.scss 7:3") && f.3.contains(" f()\n"),
"{}",
f.3
);
drop(events);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn error_inside_an_imported_mixin_renders_its_source() {
let dir = scratch("err");
let entry = dir.join("src/undef.scss");
let src = "@import \"dep\";\na {\n @include undef;\n}\n";
std::fs::write(&entry, src).unwrap();
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default().with_importer(&imp).with_url(&url);
let err = compile(src, &opts).expect_err("undefined variable").to_string();
assert!(err.starts_with("Error: Undefined variable.\n"), "{err}");
assert!(err.contains("11 │ q: $nope;\n"), "{err}");
assert!(
err.contains("src/_dep.scss 11:6") && err.contains(" undef()\n"),
"{err}"
);
assert!(
err.contains("src/undef.scss 3:3") && err.trim_end().ends_with(" root stylesheet"),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn meta_call_resolves_namespaces_at_the_definition_site() {
let dir = scratch("call_ns");
std::fs::write(
dir.join("src/_lib.scss"),
"@use \"sass:math\";\n@use \"sass:string\" as s;\n@function half($v) {\n @return math.div($v, 2) + s.length(\"ab\");\n}\n",
)
.unwrap();
let entry = dir.join("src/main.scss");
let src = "@use \"sass:meta\";\n@use \"lib\";\na {\n b: meta.call(meta.get-function(\"half\", $module: \"lib\"), 10px);\n}\n";
std::fs::write(&entry, src).unwrap();
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default().with_importer(&imp).with_url(&url);
assert_eq!(compile(src, &opts).expect("compile"), "a {\n b: 7px;\n}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn members_forwarded_through_an_import_keep_their_namespaces() {
let dir = scratch("fwd_ns");
std::fs::write(
dir.join("src/_helpers.scss"),
"$unit: 1px;\n@function twice($v) {\n @return $v * 2;\n}\n",
)
.unwrap();
std::fs::write(
dir.join("src/_lib.scss"),
"@use \"sass:math\";\n@use \"helpers\" as h;\n$scale: 3;\n@function half($v) {\n @return math.div($v, 2) + h.twice(h.$unit) * $scale;\n}\n@mixin pad($v) {\n padding: math.div($v, 2) h.twice(1px);\n}\n",
)
.unwrap();
std::fs::write(dir.join("src/_fwd.scss"), "@forward \"lib\";\n").unwrap();
let entry = dir.join("src/main.scss");
let src = "@import \"fwd\";\na {\n b: half(10px);\n @include pad(4px);\n}\n";
std::fs::write(&entry, src).unwrap();
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default()
.with_importer(&imp)
.with_url(&url)
.with_warn_handler(Rc::new(|_: &WarnEvent<'_>| {}));
assert_eq!(
compile(src, &opts).expect("compile"),
"a {\n b: 11px;\n padding: 2px 2px;\n}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn url_less_entry_keeps_its_identity_across_callables() {
let dir = scratch("nourl");
std::fs::write(
dir.join("src/_mod.scss"),
"@mixin m {\n x: 1;\n @content;\n}\n@mixin call-back {\n @include from-entry;\n}\n",
)
.unwrap();
let src = "@use \"mod\";\n@mixin from-entry {\n @warn \"entry mixin\";\n y: 2;\n}\na {\n @include mod.m {\n @warn \"entry content\";\n }\n}\n";
type Seen = Vec<(String, String, String)>;
let seen: Rc<RefCell<Seen>> = Rc::new(RefCell::new(Vec::new()));
let sink = Rc::clone(&seen);
let imp = FsImporter::new(vec![dir.join("src")]);
let opts =
Options::default()
.with_importer(&imp)
.with_warn_handler(Rc::new(move |ev: &WarnEvent<'_>| {
if !ev.deprecation {
sink.borrow_mut()
.push((ev.message.to_string(), ev.url.to_string(), ev.path.to_string()));
}
}));
let css = compile(src, &opts).expect("compile");
assert_eq!(css, "a {\n x: 1;\n}");
let events = seen.borrow();
let content = events
.iter()
.find(|e| e.0 == "entry content")
.expect("content warn");
assert_eq!(
(content.1.as_str(), content.2.as_str()),
("", ""),
"content block is the entry's"
);
drop(events);
std::fs::write(
dir.join("src/_imp.scss"),
"@mixin call-back {\n z: 3;\n @include from-entry;\n}\n",
)
.unwrap();
let src2 = "@import \"imp\";\n@mixin from-entry {\n @warn \"entry mixin\";\n y: 2;\n}\na {\n @include call-back;\n}\n";
seen.borrow_mut().clear();
let css = compile(src2, &opts).expect("compile");
assert_eq!(css, "a {\n z: 3;\n y: 2;\n}");
let events = seen.borrow();
let mixin = events
.iter()
.find(|e| e.0 == "entry mixin")
.expect("entry mixin warn");
assert_eq!(
(mixin.1.as_str(), mixin.2.as_str()),
("", ""),
"entry mixin is the entry's"
);
drop(events);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn module_callables_know_whether_they_are_a_mixin() {
let dir = scratch("in_mixin");
std::fs::write(
dir.join("src/_lib.scss"),
"@use \"sass:meta\";\n@mixin m {\n @if meta.content-exists() {\n has: content;\n @content;\n } @else {\n has: none;\n }\n}\n@function probe() {\n @return meta.content-exists();\n}\n",
)
.unwrap();
let entry = dir.join("src/main.scss");
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default().with_importer(&imp).with_url(&url);
let src = "@use \"lib\";\na {\n @include lib.m {\n b: 1;\n }\n}\nc {\n @include lib.m;\n}\n";
std::fs::write(&entry, src).unwrap();
assert_eq!(
compile(src, &opts).expect("compile"),
"a {\n has: content;\n b: 1;\n}\n\nc {\n has: none;\n}"
);
let src = "@use \"lib\";\n@mixin wrap {\n x: lib.probe();\n @content;\n}\na {\n @include wrap {\n b: 1;\n }\n}\n";
std::fs::write(&entry, src).unwrap();
let err = compile(src, &opts).expect_err("not in a mixin").to_string();
assert!(
err.starts_with("Error: content-exists() may only be called within a mixin.\n"),
"{err}"
);
assert!(err.contains("_lib.scss 11:11 probe()"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
struct SameNameImporter;
impl Importer for SameNameImporter {
fn canonicalize(
&self,
url: &str,
_ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
Ok(match url {
"a" => Some(CanonicalUrl::new("custom://a/foo")),
"b" => Some(CanonicalUrl::new("custom://b/foo")),
_ => None,
})
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
let contents = match canonical.as_str() {
"custom://a/foo" => {
"@mixin ma {\n p: $nope;\n}\n@mixin boom {\n @error \"boom\";\n}\n@mixin need($x) {\n p: $x;\n}\n"
}
"custom://b/foo" => {
"@use \"a\";\n@mixin mb {\n\n\n q: $nope;\n}\n@mixin via-a {\n\n\n\n\n @include a.boom;\n}\n@mixin via-ma {\n @include a.ma;\n}\n@mixin via-need {\n @include a.need;\n}\n"
}
_ => return Ok(None),
};
Ok(Some(ImporterResult {
contents: contents.to_string(),
syntax: Syntax::Scss,
source_map_url: None,
}))
}
}
#[test]
fn custom_importer_modules_sharing_a_display_name_render_their_own_source() {
let imp = SameNameImporter;
let opts = Options::default().with_importer(&imp).with_url("entry.scss");
let err = compile("@use \"a\";\n@use \"b\";\nx {\n @include a.ma;\n}\n", &opts)
.expect_err("undefined variable")
.to_string();
assert!(err.contains("2 │ p: $nope;\n"), "{err}");
assert!(err.contains("foo 2:6"), "{err}");
assert!(!err.contains("q: $nope"), "{err}");
let err = compile("@use \"b\";\nx {\n @include b.via-a;\n}\n", &opts)
.expect_err("boom")
.to_string();
assert!(err.starts_with("Error: \"boom\"\n"), "{err}");
assert!(err.contains("12 │ @include a.boom;\n"), "{err}");
assert!(err.contains("foo 12:3"), "{err}");
let opts = Options::default().with_importer(&imp).with_url("entry.scss");
let err = compile("@use \"b\";\nx {\n @include b.via-ma;\n}\n", &opts)
.expect_err("undefined variable")
.to_string();
assert!(err.contains("2 │ p: $nope;\n"), "{err}");
assert!(err.contains("foo 2:6"), "{err}");
assert!(!err.contains("@mixin mb"), "{err}");
}
#[test]
fn using_defaults_evaluate_where_the_block_was_written() {
let dir = scratch("using_defaults");
std::fs::write(
dir.join("src/_dep.scss"),
"$inner: 100;\n@mixin m {\n @content(1);\n}\n",
)
.unwrap();
let entry = dir.join("src/main.scss");
let url = entry.to_string_lossy().into_owned();
let imp = FsImporter::new(Vec::new());
let opts = Options::default().with_importer(&imp).with_url(&url);
let src = "@use \"dep\";\n$caller: 2;\na {\n @include dep.m using ($x, $y: $caller) {\n b: $x + $y;\n }\n}\n";
std::fs::write(&entry, src).unwrap();
assert_eq!(compile(src, &opts).expect("compile"), "a {\n b: 3;\n}");
let src = "@use \"dep\";\na {\n @include dep.m using ($x, $y: $inner) {\n b: $x + $y;\n }\n}\n";
std::fs::write(&entry, src).unwrap();
let err = compile(src, &opts).expect_err("undefined variable").to_string();
assert!(err.starts_with("Error: Undefined variable.\n"), "{err}");
assert!(
err.contains("3 │ @include dep.m using ($x, $y: $inner) {\n"),
"{err}"
);
assert!(
err.contains("main.scss 3:33") && err.contains(" @content\n"),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_cross_module_capture_renders_its_own_module_source() {
let imp = SameNameImporter;
let opts = Options::default().with_importer(&imp).with_url("entry.scss");
let src = "@use \"sass:meta\";\n@use \"a\";\n@use \"b\";\nx {\n @include meta.apply(meta.get-mixin(\"ma\", $module: \"a\"));\n}\n";
let err = compile(src, &opts).expect_err("undefined variable").to_string();
assert!(err.contains("2 │ p: $nope;\n"), "{err}");
assert!(!err.contains("@mixin mb"), "{err}");
assert!(err.contains("foo 2:6"), "{err}");
}
#[test]
fn a_two_span_error_keeps_files_that_share_a_display_name_apart() {
let imp = SameNameImporter;
let opts = Options::default().with_importer(&imp).with_url("entry.scss");
let err = compile("@use \"b\";\nx {\n @include b.via-need;\n}\n", &opts)
.expect_err("missing argument")
.to_string();
assert!(err.starts_with("Error: Missing argument $x.\n"), "{err}");
assert_eq!(err.matches("\u{250c}\u{2500}\u{2500}> foo\n").count(), 2, "{err}");
assert!(err.contains("18 \u{2502} @include a.need;\n"), "{err}");
assert!(err.contains("7 \u{2502} @mixin need($x) {\n"), "{err}");
assert!(!err.contains("@mixin via-need"), "{err}");
}