use sass_rs::{Options, OutputStyle, compile_file};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedModule {
pub suffix: String,
pub css: String,
pub classes: BTreeMap<String, String>,
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedSassOptions {
pub compressed: bool,
pub suffix: Option<String>,
pub suffix_len: usize,
}
impl Default for ScopedSassOptions {
fn default() -> Self {
Self {
compressed: true,
suffix: None,
suffix_len: 7,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
suffix: String,
css: String,
classes: BTreeMap<String, String>,
dependencies: Vec<String>,
}
#[derive(Debug)]
struct DependencyGraph {
files: Vec<PathBuf>,
fingerprint: u64,
}
pub fn compile_module_file(
path: impl AsRef<Path>,
options: ScopedSassOptions,
) -> Result<ScopedModule, String> {
let root = canonicalize_lossy(path.as_ref())?;
let dependency_graph = collect_dependency_graph(&root)?;
let cache_key = cache_key_for(&root, &dependency_graph, &options);
let cache_file = cache_dir().join(format!("{cache_key}.json"));
if let Some(entry) = read_cache_entry(&cache_file) {
return Ok(ScopedModule {
suffix: entry.suffix,
css: entry.css,
classes: entry.classes,
dependencies: entry.dependencies,
});
}
let suffix = options.suffix.clone().unwrap_or_else(|| {
generate_suffix(&root, dependency_graph.fingerprint, options.suffix_len)
});
let mut sass_options = Options {
output_style: if options.compressed {
OutputStyle::Compressed
} else {
OutputStyle::Expanded
},
..Options::default()
};
if let Some(parent) = root.parent() {
sass_options
.include_paths
.push(parent.to_string_lossy().to_string());
}
let compiled = compile_file(&root, sass_options)
.map_err(|e| format!("sass compilation failed for {}: {e}", root.display()))?;
let (css, mut classes) = transform_css(&compiled, &suffix)?;
merge_declared_source_classes(&dependency_graph.files, &suffix, &mut classes)?;
let dependencies = dependency_graph
.files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<_>>();
let entry = CacheEntry {
suffix: suffix.clone(),
css: css.clone(),
classes: classes.clone(),
dependencies: dependencies.clone(),
};
write_cache_entry(&cache_file, &entry);
Ok(ScopedModule {
suffix,
css,
classes,
dependencies,
})
}
fn canonicalize_lossy(path: &Path) -> Result<PathBuf, String> {
fs::canonicalize(path)
.or_else(|_| {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
env::current_dir().map(|cwd| cwd.join(path))
}
})
.map_err(|e| format!("failed to canonicalize '{}': {e}", path.display()))
}
fn cache_dir() -> PathBuf {
if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
return PathBuf::from(target_dir).join("scoped_sass_cache");
}
if let Ok(out_dir) = env::var("OUT_DIR")
&& let Some(target_root) = target_root_from_out_dir(Path::new(&out_dir))
{
return target_root.join("scoped_sass_cache");
}
if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
let manifest_dir = PathBuf::from(manifest_dir);
if let Some(workspace_root) = find_workspace_root(&manifest_dir) {
return workspace_root.join("target/scoped_sass_cache");
}
return manifest_dir.join("target/scoped_sass_cache");
}
env::temp_dir().join("scoped_sass_cache")
}
fn target_root_from_out_dir(out_dir: &Path) -> Option<PathBuf> {
for ancestor in out_dir.ancestors() {
if ancestor.file_name().map(|n| n == "target").unwrap_or(false) {
return Some(ancestor.to_path_buf());
}
}
None
}
fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
for dir in start_dir.ancestors() {
let manifest = dir.join("Cargo.toml");
let Ok(contents) = fs::read_to_string(&manifest) else {
continue;
};
if contents.contains("[workspace]") {
return Some(dir.to_path_buf());
}
}
None
}
fn cache_key_for(path: &Path, graph: &DependencyGraph, options: &ScopedSassOptions) -> String {
let mut hasher = DefaultHasher::new();
"scoped_sass_cache_v2".hash(&mut hasher);
path.hash(&mut hasher);
graph.fingerprint.hash(&mut hasher);
options.compressed.hash(&mut hasher);
options.suffix_len.hash(&mut hasher);
options.suffix.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn read_cache_entry(path: &Path) -> Option<CacheEntry> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str::<CacheEntry>(&contents).ok()
}
fn write_cache_entry(path: &Path, entry: &CacheEntry) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let Ok(contents) = serde_json::to_string(entry) else {
return;
};
let _ = fs::write(path, contents);
}
fn generate_suffix(path: &Path, dependency_fingerprint: u64, suffix_len: usize) -> String {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
dependency_fingerprint.hash(&mut hasher);
let full = format!("{:016x}", hasher.finish());
let len = suffix_len.clamp(4, 16);
full[..len].to_string()
}
fn collect_dependency_graph(root: &Path) -> Result<DependencyGraph, String> {
let mut visited = HashSet::<PathBuf>::new();
let mut files = Vec::<PathBuf>::new();
collect_dependencies_recursive(root, &mut visited, &mut files)?;
files.sort();
let mut hasher = DefaultHasher::new();
for file in &files {
file.hash(&mut hasher);
let content = fs::read_to_string(file)
.map_err(|e| format!("failed to read dependency '{}': {e}", file.display()))?;
content.hash(&mut hasher);
}
Ok(DependencyGraph {
files,
fingerprint: hasher.finish(),
})
}
fn collect_dependencies_recursive(
file: &Path,
visited: &mut HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
) -> Result<(), String> {
let canonical = canonicalize_lossy(file)?;
if !visited.insert(canonical.clone()) {
return Ok(());
}
out.push(canonical.clone());
let content = fs::read_to_string(&canonical)
.map_err(|e| format!("failed to read dependency '{}': {e}", canonical.display()))?;
let imports = parse_sass_dependencies(&content);
for import in imports {
if let Some(resolved) = resolve_dependency(&canonical, &import)? {
collect_dependencies_recursive(&resolved, visited, out)?;
}
}
Ok(())
}
fn parse_sass_dependencies(content: &str) -> Vec<String> {
let stripped = strip_comments(content);
let mut dependencies = Vec::new();
for statement in stripped.split(';') {
let trimmed = statement.trim_start();
if !(trimmed.starts_with("@import")
|| trimmed.starts_with("@use")
|| trimmed.starts_with("@forward"))
{
continue;
}
let mut i = 0usize;
let bytes = statement.as_bytes();
while i < bytes.len() {
let c = bytes[i] as char;
if c == '\'' || c == '"' {
let quote = c;
let start = i + 1;
i += 1;
while i < bytes.len() {
let c2 = bytes[i] as char;
if c2 == quote && !is_escaped(bytes, i) {
if i > start {
dependencies.push(statement[start..i].trim().to_string());
}
break;
}
i += 1;
}
}
i += 1;
}
}
dependencies
}
fn strip_comments(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut i = 0usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
out.push(c);
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
out.push(c);
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if c == '\'' {
in_single = true;
out.push(c);
i += 1;
continue;
}
if c == '"' {
in_double = true;
out.push(c);
i += 1;
continue;
}
if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
if i + 1 < bytes.len() {
i += 2;
}
continue;
}
out.push(c);
i += 1;
}
out
}
fn resolve_dependency(from_file: &Path, import: &str) -> Result<Option<PathBuf>, String> {
let import = import.trim();
if import.is_empty()
|| import.starts_with("http://")
|| import.starts_with("https://")
|| import.starts_with("sass:")
|| import.starts_with("url(")
|| import.contains("#{")
{
return Ok(None);
}
let from_dir = from_file
.parent()
.ok_or_else(|| format!("file has no parent directory: {}", from_file.display()))?;
let import_path = Path::new(import);
let mut candidates = Vec::new();
if import_path.extension().is_some() {
candidates.push(from_dir.join(import_path));
if let Some(partial) = as_partial_path(import_path) {
candidates.push(from_dir.join(partial));
}
} else {
for ext in ["scss", "sass"] {
let with_ext = with_extension(import_path, ext);
candidates.push(from_dir.join(&with_ext));
if let Some(partial) = as_partial_path(&with_ext) {
candidates.push(from_dir.join(partial));
}
let index = import_path.join(format!("index.{ext}"));
candidates.push(from_dir.join(&index));
let partial_index = import_path.join(format!("_index.{ext}"));
candidates.push(from_dir.join(partial_index));
}
}
for candidate in candidates {
if candidate.exists() {
return canonicalize_lossy(&candidate).map(Some);
}
}
Ok(None)
}
fn with_extension(path: &Path, ext: &str) -> PathBuf {
let mut out = path.to_path_buf();
out.set_extension(ext);
out
}
fn as_partial_path(path: &Path) -> Option<PathBuf> {
let file_name = path.file_name()?.to_string_lossy();
if file_name.starts_with('_') {
return None;
}
let mut out = path.to_path_buf();
out.set_file_name(format!("_{file_name}"));
Some(out)
}
fn transform_css(css: &str, suffix: &str) -> Result<(String, BTreeMap<String, String>), String> {
let mut classes = BTreeMap::new();
let transformed = transform_block(css, suffix, false, 0, &mut classes)?;
Ok((transformed, classes))
}
fn merge_declared_source_classes(
files: &[PathBuf],
suffix: &str,
classes: &mut BTreeMap<String, String>,
) -> Result<(), String> {
for file in files {
let source = fs::read_to_string(file)
.map_err(|e| format!("failed to read source '{}': {e}", file.display()))?;
let mut declared = BTreeSet::new();
collect_classes_from_block(&source, false, 0, &mut declared)?;
for class_name in declared {
classes
.entry(class_name.clone())
.or_insert_with(|| format!("{class_name}_{suffix}"));
}
}
Ok(())
}
fn collect_classes_from_block(
input: &str,
in_keyframes: bool,
mut i: usize,
classes: &mut BTreeSet<String>,
) -> Result<(), String> {
let bytes = input.as_bytes();
while i < bytes.len() {
skip_ws_and_comments(input, &mut i);
if i >= bytes.len() {
break;
}
if bytes[i] == b'}' {
break;
}
let prelude_start = i;
let Some(delim) = find_next_delim(input, i) else {
break;
};
i = delim;
if bytes[delim] == b';' {
i += 1;
continue;
}
let header_raw = &input[prelude_start..delim];
let open_brace = delim;
let close_brace = find_matching_brace(input, open_brace)
.ok_or_else(|| "malformed scss: unmatched '{'".to_string())?;
let body = &input[(open_brace + 1)..close_brace];
let header = header_raw.trim();
if header.starts_with('@') {
let keyframes_here = is_keyframes_at_rule(header);
collect_classes_from_block(body, keyframes_here, 0, classes)?;
} else {
if !in_keyframes {
collect_selector_classes(header_raw, classes);
}
collect_classes_from_block(body, in_keyframes, 0, classes)?;
}
i = close_brace + 1;
}
Ok(())
}
fn collect_selector_classes(selector_list: &str, classes: &mut BTreeSet<String>) {
for selector in split_top_level(selector_list, ',') {
collect_classes_in_selector(selector, classes);
}
}
fn collect_classes_in_selector(selector: &str, classes: &mut BTreeSet<String>) {
let bytes = selector.as_bytes();
let mut i = 0usize;
let mut bracket_depth = 0usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if c == '\'' {
in_single = true;
i += 1;
continue;
}
if c == '"' {
in_double = true;
i += 1;
continue;
}
if c == '[' {
bracket_depth += 1;
i += 1;
continue;
}
if c == ']' {
bracket_depth = bracket_depth.saturating_sub(1);
i += 1;
continue;
}
if bracket_depth == 0 && starts_with_global(selector, i) {
let open_paren = i + 7;
if let Some(close_paren) = find_matching_paren(selector, open_paren) {
i = close_paren + 1;
continue;
}
}
if c == '.' && bracket_depth == 0 {
let class_start = i + 1;
if class_start < bytes.len() && is_class_start(bytes[class_start] as char) {
let mut j = class_start + 1;
while j < bytes.len() && is_class_char(bytes[j] as char) {
j += 1;
}
classes.insert(selector[class_start..j].to_string());
i = j;
continue;
}
}
i += 1;
}
}
fn transform_block(
input: &str,
suffix: &str,
in_keyframes: bool,
mut i: usize,
classes: &mut BTreeMap<String, String>,
) -> Result<String, String> {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len() + 64);
while i < bytes.len() {
skip_ws_and_comments(input, &mut i);
if i >= bytes.len() {
break;
}
if bytes[i] == b'}' {
break;
}
let prelude_start = i;
let delim = if let Some(delim) = find_next_delim(input, i) {
delim
} else {
out.push_str(&input[prelude_start..]);
break;
};
i = delim;
let delim_ch = bytes[delim];
if delim_ch == b';' {
out.push_str(&input[prelude_start..=delim]);
i += 1;
continue;
}
let header_raw = &input[prelude_start..delim];
let open_brace = delim;
let close_brace = find_matching_brace(input, open_brace)
.ok_or_else(|| "malformed css: unmatched '{'".to_string())?;
let body = &input[(open_brace + 1)..close_brace];
let header = header_raw.trim();
if header.starts_with('@') {
let keyframes_here = is_keyframes_at_rule(header);
let transformed_body = transform_block(body, suffix, keyframes_here, 0, classes)?;
out.push_str(header_raw);
out.push('{');
out.push_str(&transformed_body);
out.push('}');
} else {
let transformed_header = if in_keyframes {
header_raw.to_string()
} else {
transform_selector_list(header_raw, suffix, classes)
};
let transformed_body = transform_block(body, suffix, in_keyframes, 0, classes)?;
out.push_str(&transformed_header);
out.push('{');
out.push_str(&transformed_body);
out.push('}');
}
i = close_brace + 1;
}
Ok(out)
}
fn is_keyframes_at_rule(header: &str) -> bool {
let lowered = header.to_ascii_lowercase();
lowered.starts_with("@keyframes") || lowered.starts_with("@-webkit-keyframes")
}
fn transform_selector_list(
selector_list: &str,
suffix: &str,
classes: &mut BTreeMap<String, String>,
) -> String {
split_top_level(selector_list, ',')
.into_iter()
.map(|selector| transform_selector(selector, suffix, classes))
.collect::<Vec<_>>()
.join(",")
}
fn transform_selector(
selector: &str,
suffix: &str,
classes: &mut BTreeMap<String, String>,
) -> String {
let mut out = String::with_capacity(selector.len() + 16);
let bytes = selector.as_bytes();
let mut i = 0usize;
let mut bracket_depth = 0usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
out.push(c);
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
out.push(c);
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if c == '\'' {
in_single = true;
out.push(c);
i += 1;
continue;
}
if c == '"' {
in_double = true;
out.push(c);
i += 1;
continue;
}
if c == '[' {
bracket_depth += 1;
out.push(c);
i += 1;
continue;
}
if c == ']' {
bracket_depth = bracket_depth.saturating_sub(1);
out.push(c);
i += 1;
continue;
}
if bracket_depth == 0 && starts_with_global(selector, i) {
let open_paren = i + 7;
if let Some(close_paren) = find_matching_paren(selector, open_paren) {
out.push_str(&selector[(open_paren + 1)..close_paren]);
i = close_paren + 1;
continue;
}
}
if c == '.' && bracket_depth == 0 {
let class_start = i + 1;
if class_start < bytes.len() && is_class_start(bytes[class_start] as char) {
let mut j = class_start + 1;
while j < bytes.len() && is_class_char(bytes[j] as char) {
j += 1;
}
let class_name = &selector[class_start..j];
let scoped_class = format!("{class_name}_{suffix}");
classes
.entry(class_name.to_string())
.or_insert_with(|| scoped_class.clone());
out.push('.');
out.push_str(&scoped_class);
i = j;
continue;
}
}
out.push(c);
i += 1;
}
out
}
fn starts_with_global(input: &str, idx: usize) -> bool {
input[idx..].starts_with(":global(")
}
fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
let bytes = input.as_bytes();
if bytes.get(open_paren).copied() != Some(b'(') {
return None;
}
let mut depth = 1usize;
let mut i = open_paren + 1;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
match c {
'\'' => in_single = true,
'"' => in_double = true,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn is_class_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_' || c == '-'
}
fn is_class_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn split_top_level(input: &str, separator: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
let bytes = input.as_bytes();
let mut paren = 0usize;
let mut bracket = 0usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if c == '\'' {
in_single = true;
i += 1;
continue;
}
if c == '"' {
in_double = true;
i += 1;
continue;
}
match c {
'(' => paren += 1,
')' => paren = paren.saturating_sub(1),
'[' => bracket += 1,
']' => bracket = bracket.saturating_sub(1),
_ => {}
}
if c == separator && paren == 0 && bracket == 0 {
parts.push(&input[start..i]);
start = i + 1;
}
i += 1;
}
parts.push(&input[start..]);
parts
}
fn skip_ws_and_comments(input: &str, i: &mut usize) {
let bytes = input.as_bytes();
while *i < bytes.len() {
if bytes[*i].is_ascii_whitespace() {
*i += 1;
continue;
}
if *i + 1 < bytes.len() && bytes[*i] == b'/' && bytes[*i + 1] == b'*' {
*i += 2;
while *i + 1 < bytes.len() && !(bytes[*i] == b'*' && bytes[*i + 1] == b'/') {
*i += 1;
}
if *i + 1 < bytes.len() {
*i += 2;
}
continue;
}
break;
}
}
fn find_next_delim(input: &str, mut i: usize) -> Option<usize> {
let bytes = input.as_bytes();
let mut paren = 0usize;
let mut bracket = 0usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
if i + 1 < bytes.len() {
i += 2;
}
continue;
}
if c == '\'' {
in_single = true;
i += 1;
continue;
}
if c == '"' {
in_double = true;
i += 1;
continue;
}
match c {
'(' => paren += 1,
')' => paren = paren.saturating_sub(1),
'[' => bracket += 1,
']' => bracket = bracket.saturating_sub(1),
'{' | ';' if paren == 0 && bracket == 0 => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn find_matching_brace(input: &str, open_brace: usize) -> Option<usize> {
let bytes = input.as_bytes();
if bytes.get(open_brace).copied() != Some(b'{') {
return None;
}
let mut i = open_brace + 1;
let mut depth = 1usize;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_single {
if c == '\'' && !is_escaped(bytes, i) {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == '"' && !is_escaped(bytes, i) {
in_double = false;
}
i += 1;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
if i + 1 < bytes.len() {
i += 2;
}
continue;
}
match c {
'\'' => in_single = true,
'"' => in_double = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn is_escaped(bytes: &[u8], i: usize) -> bool {
if i == 0 {
return false;
}
let mut backslashes = 0usize;
let mut j = i;
while j > 0 {
j -= 1;
if bytes[j] == b'\\' {
backslashes += 1;
} else {
break;
}
}
backslashes % 2 == 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renames_local_classes_with_suffix() {
let (css, classes) = transform_css(".card{color:red}", "f45126d").expect("should compile");
assert_eq!(css, ".card_f45126d{color:red}");
assert_eq!(
classes.get("card").map(String::as_str),
Some("card_f45126d")
);
}
#[test]
fn keeps_global_selector_unscoped() {
let (css, classes) =
transform_css(".my_scoped_class :global(.paragraph){color:red}", "f45126d")
.expect("should compile");
assert_eq!(css, ".my_scoped_class_f45126d .paragraph{color:red}");
assert_eq!(
classes.get("my_scoped_class").map(String::as_str),
Some("my_scoped_class_f45126d")
);
assert!(!classes.contains_key("paragraph"));
}
#[test]
fn scopes_selectors_inside_media() {
let css = "@media screen and (max-width: 600px) {.card{color:red;}}";
let (out, classes) = transform_css(css, "f45126d").expect("scope should work");
assert!(out.contains("@media screen and (max-width: 600px)"));
assert!(out.contains(".card_f45126d{color:red;}"));
assert_eq!(
classes.get("card").map(String::as_str),
Some("card_f45126d")
);
}
#[test]
fn does_not_scope_keyframe_steps() {
let css = "@keyframes spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}";
let (out, _) = transform_css(css, "f45126d").expect("scope should work");
assert!(out.contains(
"@keyframes spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}"
));
assert!(!out.contains("from_f45126d"));
}
#[test]
fn supports_compressed_declarations_without_trailing_semicolon() {
let (out, _) = transform_css(".card{color:red}", "f45126d").expect("scope should work");
assert_eq!(out, ".card_f45126d{color:red}");
}
#[test]
fn collects_declared_empty_classes_from_source() {
let source = r#"
.container {}
.title { color: red; }
.scope :global(.external) { color: blue; }
"#;
let mut classes = BTreeSet::new();
collect_classes_from_block(source, false, 0, &mut classes).expect("should parse");
assert!(classes.contains("container"));
assert!(classes.contains("title"));
assert!(classes.contains("scope"));
assert!(!classes.contains("external"));
}
#[test]
fn parses_import_use_and_forward_dependencies() {
let deps = parse_sass_dependencies(
r#"
@import "a", 'b';
@use "c" as *;
@forward 'd';
"#,
);
assert_eq!(deps, vec!["a", "b", "c", "d"]);
}
}