Skip to main content

scoped_sass_core/
lib.rs

1//! Core compilation primitives for scoped Sass processing.
2//!
3//! This crate compiles a Sass module, rewrites local class selectors with a stable suffix,
4//! tracks Sass dependencies, and caches compilation output between builds.
5//!
6//! Most application code should use the higher-level `scoped-sass` proc-macro crate.
7//! This crate is useful when you want direct programmatic access to the compiler.
8//!
9//! # Example
10//!
11//! ```no_run
12//! use scoped_sass_core::{compile_module_file, ScopedSassOptions};
13//!
14//! let module = compile_module_file(
15//!     "src/components/button.scss",
16//!     ScopedSassOptions::default(),
17//! )?;
18//!
19//! assert!(!module.css.is_empty());
20//! # Ok::<(), String>(())
21//! ```
22
23use sass_rs::{Options, OutputStyle, compile_file};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::collections::BTreeSet;
27use std::collections::HashSet;
28use std::collections::hash_map::DefaultHasher;
29use std::env;
30use std::fs;
31use std::hash::{Hash, Hasher};
32use std::path::{Path, PathBuf};
33
34/// The result of compiling a Sass module with scoped class rewriting.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ScopedModule {
37    /// The deterministic suffix appended to local class selectors.
38    pub suffix: String,
39    /// The transformed CSS emitted for the compiled module.
40    pub css: String,
41    /// A mapping from source class names to their scoped output names.
42    pub classes: BTreeMap<String, String>,
43    /// Canonical dependency paths that contributed to the compilation result.
44    pub dependencies: Vec<String>,
45}
46
47/// Options that control how a Sass module is compiled and scoped.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ScopedSassOptions {
50    /// Emits compressed CSS when `true`, or expanded CSS when `false`.
51    pub compressed: bool,
52    /// Overrides the generated scope suffix with a fixed value when provided.
53    pub suffix: Option<String>,
54    /// Controls the generated suffix length and is clamped to `4..=16`.
55    pub suffix_len: usize,
56}
57
58impl Default for ScopedSassOptions {
59    fn default() -> Self {
60        Self {
61            compressed: true,
62            suffix: None,
63            suffix_len: 7,
64        }
65    }
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69struct CacheEntry {
70    suffix: String,
71    css: String,
72    classes: BTreeMap<String, String>,
73    dependencies: Vec<String>,
74}
75
76#[derive(Debug)]
77struct DependencyGraph {
78    files: Vec<PathBuf>,
79    fingerprint: u64,
80}
81
82/// Compiles a Sass file into a [`ScopedModule`].
83///
84/// This function:
85///
86/// - resolves the input path
87/// - tracks transitive Sass dependencies from `@import`, `@use`, and `@forward`
88/// - caches results in `target/scoped_sass_cache` when possible
89/// - rewrites local class selectors with a deterministic scoped suffix
90///
91/// The parent directory of `path` is added to the Sass include path before compilation.
92///
93/// # Errors
94///
95/// Returns an error string when the input cannot be resolved, a dependency cannot be read,
96/// Sass compilation fails, or the generated CSS cannot be transformed safely.
97///
98/// # Example
99///
100/// ```no_run
101/// use scoped_sass_core::{compile_module_file, ScopedSassOptions};
102///
103/// let module = compile_module_file(
104///     "src/components/button.scss",
105///     ScopedSassOptions::default(),
106/// )?;
107///
108/// println!("{}", module.css);
109/// # Ok::<(), String>(())
110/// ```
111pub fn compile_module_file(
112    path: impl AsRef<Path>,
113    options: ScopedSassOptions,
114) -> Result<ScopedModule, String> {
115    let root = canonicalize_lossy(path.as_ref())?;
116    let dependency_graph = collect_dependency_graph(&root)?;
117
118    let cache_key = cache_key_for(&root, &dependency_graph, &options);
119    let cache_file = cache_dir().join(format!("{cache_key}.json"));
120    if let Some(entry) = read_cache_entry(&cache_file) {
121        return Ok(ScopedModule {
122            suffix: entry.suffix,
123            css: entry.css,
124            classes: entry.classes,
125            dependencies: entry.dependencies,
126        });
127    }
128
129    let suffix = options.suffix.clone().unwrap_or_else(|| {
130        generate_suffix(&root, dependency_graph.fingerprint, options.suffix_len)
131    });
132
133    let mut sass_options = Options {
134        output_style: if options.compressed {
135            OutputStyle::Compressed
136        } else {
137            OutputStyle::Expanded
138        },
139        ..Options::default()
140    };
141    if let Some(parent) = root.parent() {
142        sass_options
143            .include_paths
144            .push(parent.to_string_lossy().to_string());
145    }
146
147    let compiled = compile_file(&root, sass_options)
148        .map_err(|e| format!("sass compilation failed for {}: {e}", root.display()))?;
149    let (css, mut classes) = transform_css(&compiled, &suffix)?;
150    merge_declared_source_classes(&dependency_graph.files, &suffix, &mut classes)?;
151
152    let dependencies = dependency_graph
153        .files
154        .iter()
155        .map(|p| p.to_string_lossy().to_string())
156        .collect::<Vec<_>>();
157
158    let entry = CacheEntry {
159        suffix: suffix.clone(),
160        css: css.clone(),
161        classes: classes.clone(),
162        dependencies: dependencies.clone(),
163    };
164    write_cache_entry(&cache_file, &entry);
165
166    Ok(ScopedModule {
167        suffix,
168        css,
169        classes,
170        dependencies,
171    })
172}
173
174fn canonicalize_lossy(path: &Path) -> Result<PathBuf, String> {
175    fs::canonicalize(path)
176        .or_else(|_| {
177            if path.is_absolute() {
178                Ok(path.to_path_buf())
179            } else {
180                env::current_dir().map(|cwd| cwd.join(path))
181            }
182        })
183        .map_err(|e| format!("failed to canonicalize '{}': {e}", path.display()))
184}
185
186fn cache_dir() -> PathBuf {
187    if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
188        return PathBuf::from(target_dir).join("scoped_sass_cache");
189    }
190    if let Ok(out_dir) = env::var("OUT_DIR")
191        && let Some(target_root) = target_root_from_out_dir(Path::new(&out_dir))
192    {
193        return target_root.join("scoped_sass_cache");
194    }
195    if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
196        let manifest_dir = PathBuf::from(manifest_dir);
197        if let Some(workspace_root) = find_workspace_root(&manifest_dir) {
198            return workspace_root.join("target/scoped_sass_cache");
199        }
200        return manifest_dir.join("target/scoped_sass_cache");
201    }
202    env::temp_dir().join("scoped_sass_cache")
203}
204
205fn target_root_from_out_dir(out_dir: &Path) -> Option<PathBuf> {
206    for ancestor in out_dir.ancestors() {
207        if ancestor.file_name().map(|n| n == "target").unwrap_or(false) {
208            return Some(ancestor.to_path_buf());
209        }
210    }
211    None
212}
213
214fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
215    for dir in start_dir.ancestors() {
216        let manifest = dir.join("Cargo.toml");
217        let Ok(contents) = fs::read_to_string(&manifest) else {
218            continue;
219        };
220        if contents.contains("[workspace]") {
221            return Some(dir.to_path_buf());
222        }
223    }
224    None
225}
226
227fn cache_key_for(path: &Path, graph: &DependencyGraph, options: &ScopedSassOptions) -> String {
228    let mut hasher = DefaultHasher::new();
229    "scoped_sass_cache_v2".hash(&mut hasher);
230    path.hash(&mut hasher);
231    graph.fingerprint.hash(&mut hasher);
232    options.compressed.hash(&mut hasher);
233    options.suffix_len.hash(&mut hasher);
234    options.suffix.hash(&mut hasher);
235    format!("{:016x}", hasher.finish())
236}
237
238fn read_cache_entry(path: &Path) -> Option<CacheEntry> {
239    let contents = fs::read_to_string(path).ok()?;
240    serde_json::from_str::<CacheEntry>(&contents).ok()
241}
242
243fn write_cache_entry(path: &Path, entry: &CacheEntry) {
244    if let Some(parent) = path.parent() {
245        let _ = fs::create_dir_all(parent);
246    }
247    let Ok(contents) = serde_json::to_string(entry) else {
248        return;
249    };
250    let _ = fs::write(path, contents);
251}
252
253fn generate_suffix(path: &Path, dependency_fingerprint: u64, suffix_len: usize) -> String {
254    let mut hasher = DefaultHasher::new();
255    path.hash(&mut hasher);
256    dependency_fingerprint.hash(&mut hasher);
257    let full = format!("{:016x}", hasher.finish());
258    let len = suffix_len.clamp(4, 16);
259    full[..len].to_string()
260}
261
262fn collect_dependency_graph(root: &Path) -> Result<DependencyGraph, String> {
263    let mut visited = HashSet::<PathBuf>::new();
264    let mut files = Vec::<PathBuf>::new();
265    collect_dependencies_recursive(root, &mut visited, &mut files)?;
266    files.sort();
267
268    let mut hasher = DefaultHasher::new();
269    for file in &files {
270        file.hash(&mut hasher);
271        let content = fs::read_to_string(file)
272            .map_err(|e| format!("failed to read dependency '{}': {e}", file.display()))?;
273        content.hash(&mut hasher);
274    }
275
276    Ok(DependencyGraph {
277        files,
278        fingerprint: hasher.finish(),
279    })
280}
281
282fn collect_dependencies_recursive(
283    file: &Path,
284    visited: &mut HashSet<PathBuf>,
285    out: &mut Vec<PathBuf>,
286) -> Result<(), String> {
287    let canonical = canonicalize_lossy(file)?;
288    if !visited.insert(canonical.clone()) {
289        return Ok(());
290    }
291
292    out.push(canonical.clone());
293
294    let content = fs::read_to_string(&canonical)
295        .map_err(|e| format!("failed to read dependency '{}': {e}", canonical.display()))?;
296    let imports = parse_sass_dependencies(&content);
297
298    for import in imports {
299        if let Some(resolved) = resolve_dependency(&canonical, &import)? {
300            collect_dependencies_recursive(&resolved, visited, out)?;
301        }
302    }
303
304    Ok(())
305}
306
307fn parse_sass_dependencies(content: &str) -> Vec<String> {
308    let stripped = strip_comments(content);
309    let mut dependencies = Vec::new();
310
311    for statement in stripped.split(';') {
312        let trimmed = statement.trim_start();
313        if !(trimmed.starts_with("@import")
314            || trimmed.starts_with("@use")
315            || trimmed.starts_with("@forward"))
316        {
317            continue;
318        }
319
320        let mut i = 0usize;
321        let bytes = statement.as_bytes();
322        while i < bytes.len() {
323            let c = bytes[i] as char;
324            if c == '\'' || c == '"' {
325                let quote = c;
326                let start = i + 1;
327                i += 1;
328                while i < bytes.len() {
329                    let c2 = bytes[i] as char;
330                    if c2 == quote && !is_escaped(bytes, i) {
331                        if i > start {
332                            dependencies.push(statement[start..i].trim().to_string());
333                        }
334                        break;
335                    }
336                    i += 1;
337                }
338            }
339            i += 1;
340        }
341    }
342
343    dependencies
344}
345
346fn strip_comments(input: &str) -> String {
347    let bytes = input.as_bytes();
348    let mut out = String::with_capacity(input.len());
349    let mut i = 0usize;
350    let mut in_single = false;
351    let mut in_double = false;
352
353    while i < bytes.len() {
354        let c = bytes[i] as char;
355
356        if in_single {
357            out.push(c);
358            if c == '\'' && !is_escaped(bytes, i) {
359                in_single = false;
360            }
361            i += 1;
362            continue;
363        }
364        if in_double {
365            out.push(c);
366            if c == '"' && !is_escaped(bytes, i) {
367                in_double = false;
368            }
369            i += 1;
370            continue;
371        }
372
373        if c == '\'' {
374            in_single = true;
375            out.push(c);
376            i += 1;
377            continue;
378        }
379        if c == '"' {
380            in_double = true;
381            out.push(c);
382            i += 1;
383            continue;
384        }
385
386        if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
387            i += 2;
388            while i < bytes.len() && bytes[i] != b'\n' {
389                i += 1;
390            }
391            continue;
392        }
393
394        if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
395            i += 2;
396            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
397                i += 1;
398            }
399            if i + 1 < bytes.len() {
400                i += 2;
401            }
402            continue;
403        }
404
405        out.push(c);
406        i += 1;
407    }
408
409    out
410}
411
412fn resolve_dependency(from_file: &Path, import: &str) -> Result<Option<PathBuf>, String> {
413    let import = import.trim();
414    if import.is_empty()
415        || import.starts_with("http://")
416        || import.starts_with("https://")
417        || import.starts_with("sass:")
418        || import.starts_with("url(")
419        || import.contains("#{")
420    {
421        return Ok(None);
422    }
423
424    let from_dir = from_file
425        .parent()
426        .ok_or_else(|| format!("file has no parent directory: {}", from_file.display()))?;
427
428    let import_path = Path::new(import);
429    let mut candidates = Vec::new();
430
431    if import_path.extension().is_some() {
432        candidates.push(from_dir.join(import_path));
433        if let Some(partial) = as_partial_path(import_path) {
434            candidates.push(from_dir.join(partial));
435        }
436    } else {
437        for ext in ["scss", "sass"] {
438            let with_ext = with_extension(import_path, ext);
439            candidates.push(from_dir.join(&with_ext));
440            if let Some(partial) = as_partial_path(&with_ext) {
441                candidates.push(from_dir.join(partial));
442            }
443
444            let index = import_path.join(format!("index.{ext}"));
445            candidates.push(from_dir.join(&index));
446
447            let partial_index = import_path.join(format!("_index.{ext}"));
448            candidates.push(from_dir.join(partial_index));
449        }
450    }
451
452    for candidate in candidates {
453        if candidate.exists() {
454            return canonicalize_lossy(&candidate).map(Some);
455        }
456    }
457
458    Ok(None)
459}
460
461fn with_extension(path: &Path, ext: &str) -> PathBuf {
462    let mut out = path.to_path_buf();
463    out.set_extension(ext);
464    out
465}
466
467fn as_partial_path(path: &Path) -> Option<PathBuf> {
468    let file_name = path.file_name()?.to_string_lossy();
469    if file_name.starts_with('_') {
470        return None;
471    }
472
473    let mut out = path.to_path_buf();
474    out.set_file_name(format!("_{file_name}"));
475    Some(out)
476}
477
478fn transform_css(css: &str, suffix: &str) -> Result<(String, BTreeMap<String, String>), String> {
479    let mut classes = BTreeMap::new();
480    let transformed = transform_block(css, suffix, false, 0, &mut classes)?;
481    Ok((transformed, classes))
482}
483
484fn merge_declared_source_classes(
485    files: &[PathBuf],
486    suffix: &str,
487    classes: &mut BTreeMap<String, String>,
488) -> Result<(), String> {
489    for file in files {
490        let source = fs::read_to_string(file)
491            .map_err(|e| format!("failed to read source '{}': {e}", file.display()))?;
492        let mut declared = BTreeSet::new();
493        collect_classes_from_block(&source, false, 0, &mut declared)?;
494        for class_name in declared {
495            classes
496                .entry(class_name.clone())
497                .or_insert_with(|| format!("{class_name}_{suffix}"));
498        }
499    }
500    Ok(())
501}
502
503fn collect_classes_from_block(
504    input: &str,
505    in_keyframes: bool,
506    mut i: usize,
507    classes: &mut BTreeSet<String>,
508) -> Result<(), String> {
509    let bytes = input.as_bytes();
510    while i < bytes.len() {
511        skip_ws_and_comments(input, &mut i);
512        if i >= bytes.len() {
513            break;
514        }
515        if bytes[i] == b'}' {
516            break;
517        }
518
519        let prelude_start = i;
520        let Some(delim) = find_next_delim(input, i) else {
521            break;
522        };
523        i = delim;
524
525        if bytes[delim] == b';' {
526            i += 1;
527            continue;
528        }
529
530        let header_raw = &input[prelude_start..delim];
531        let open_brace = delim;
532        let close_brace = find_matching_brace(input, open_brace)
533            .ok_or_else(|| "malformed scss: unmatched '{'".to_string())?;
534        let body = &input[(open_brace + 1)..close_brace];
535        let header = header_raw.trim();
536
537        if header.starts_with('@') {
538            let keyframes_here = is_keyframes_at_rule(header);
539            collect_classes_from_block(body, keyframes_here, 0, classes)?;
540        } else {
541            if !in_keyframes {
542                collect_selector_classes(header_raw, classes);
543            }
544            collect_classes_from_block(body, in_keyframes, 0, classes)?;
545        }
546
547        i = close_brace + 1;
548    }
549
550    Ok(())
551}
552
553fn collect_selector_classes(selector_list: &str, classes: &mut BTreeSet<String>) {
554    for selector in split_top_level(selector_list, ',') {
555        collect_classes_in_selector(selector, classes);
556    }
557}
558
559fn collect_classes_in_selector(selector: &str, classes: &mut BTreeSet<String>) {
560    let bytes = selector.as_bytes();
561    let mut i = 0usize;
562    let mut bracket_depth = 0usize;
563    let mut in_single = false;
564    let mut in_double = false;
565
566    while i < bytes.len() {
567        let c = bytes[i] as char;
568
569        if in_single {
570            if c == '\'' && !is_escaped(bytes, i) {
571                in_single = false;
572            }
573            i += 1;
574            continue;
575        }
576        if in_double {
577            if c == '"' && !is_escaped(bytes, i) {
578                in_double = false;
579            }
580            i += 1;
581            continue;
582        }
583
584        if c == '\'' {
585            in_single = true;
586            i += 1;
587            continue;
588        }
589        if c == '"' {
590            in_double = true;
591            i += 1;
592            continue;
593        }
594
595        if c == '[' {
596            bracket_depth += 1;
597            i += 1;
598            continue;
599        }
600        if c == ']' {
601            bracket_depth = bracket_depth.saturating_sub(1);
602            i += 1;
603            continue;
604        }
605
606        if bracket_depth == 0 && starts_with_global(selector, i) {
607            let open_paren = i + 7;
608            if let Some(close_paren) = find_matching_paren(selector, open_paren) {
609                i = close_paren + 1;
610                continue;
611            }
612        }
613
614        if c == '.' && bracket_depth == 0 {
615            let class_start = i + 1;
616            if class_start < bytes.len() && is_class_start(bytes[class_start] as char) {
617                let mut j = class_start + 1;
618                while j < bytes.len() && is_class_char(bytes[j] as char) {
619                    j += 1;
620                }
621                classes.insert(selector[class_start..j].to_string());
622                i = j;
623                continue;
624            }
625        }
626
627        i += 1;
628    }
629}
630
631fn transform_block(
632    input: &str,
633    suffix: &str,
634    in_keyframes: bool,
635    mut i: usize,
636    classes: &mut BTreeMap<String, String>,
637) -> Result<String, String> {
638    let bytes = input.as_bytes();
639    let mut out = String::with_capacity(input.len() + 64);
640
641    while i < bytes.len() {
642        skip_ws_and_comments(input, &mut i);
643        if i >= bytes.len() {
644            break;
645        }
646        if bytes[i] == b'}' {
647            break;
648        }
649
650        let prelude_start = i;
651        let delim = if let Some(delim) = find_next_delim(input, i) {
652            delim
653        } else {
654            // Compressed CSS can omit the final semicolon in a declaration block.
655            out.push_str(&input[prelude_start..]);
656            break;
657        };
658        i = delim;
659        let delim_ch = bytes[delim];
660        if delim_ch == b';' {
661            out.push_str(&input[prelude_start..=delim]);
662            i += 1;
663            continue;
664        }
665
666        let header_raw = &input[prelude_start..delim];
667        let open_brace = delim;
668        let close_brace = find_matching_brace(input, open_brace)
669            .ok_or_else(|| "malformed css: unmatched '{'".to_string())?;
670        let body = &input[(open_brace + 1)..close_brace];
671
672        let header = header_raw.trim();
673        if header.starts_with('@') {
674            let keyframes_here = is_keyframes_at_rule(header);
675            let transformed_body = transform_block(body, suffix, keyframes_here, 0, classes)?;
676            out.push_str(header_raw);
677            out.push('{');
678            out.push_str(&transformed_body);
679            out.push('}');
680        } else {
681            let transformed_header = if in_keyframes {
682                header_raw.to_string()
683            } else {
684                transform_selector_list(header_raw, suffix, classes)
685            };
686            let transformed_body = transform_block(body, suffix, in_keyframes, 0, classes)?;
687            out.push_str(&transformed_header);
688            out.push('{');
689            out.push_str(&transformed_body);
690            out.push('}');
691        }
692
693        i = close_brace + 1;
694    }
695
696    Ok(out)
697}
698
699fn is_keyframes_at_rule(header: &str) -> bool {
700    let lowered = header.to_ascii_lowercase();
701    lowered.starts_with("@keyframes") || lowered.starts_with("@-webkit-keyframes")
702}
703
704fn transform_selector_list(
705    selector_list: &str,
706    suffix: &str,
707    classes: &mut BTreeMap<String, String>,
708) -> String {
709    split_top_level(selector_list, ',')
710        .into_iter()
711        .map(|selector| transform_selector(selector, suffix, classes))
712        .collect::<Vec<_>>()
713        .join(",")
714}
715
716fn transform_selector(
717    selector: &str,
718    suffix: &str,
719    classes: &mut BTreeMap<String, String>,
720) -> String {
721    let mut out = String::with_capacity(selector.len() + 16);
722    let bytes = selector.as_bytes();
723    let mut i = 0usize;
724    let mut bracket_depth = 0usize;
725    let mut in_single = false;
726    let mut in_double = false;
727
728    while i < bytes.len() {
729        let c = bytes[i] as char;
730
731        if in_single {
732            out.push(c);
733            if c == '\'' && !is_escaped(bytes, i) {
734                in_single = false;
735            }
736            i += 1;
737            continue;
738        }
739        if in_double {
740            out.push(c);
741            if c == '"' && !is_escaped(bytes, i) {
742                in_double = false;
743            }
744            i += 1;
745            continue;
746        }
747
748        if c == '\'' {
749            in_single = true;
750            out.push(c);
751            i += 1;
752            continue;
753        }
754        if c == '"' {
755            in_double = true;
756            out.push(c);
757            i += 1;
758            continue;
759        }
760
761        if c == '[' {
762            bracket_depth += 1;
763            out.push(c);
764            i += 1;
765            continue;
766        }
767        if c == ']' {
768            bracket_depth = bracket_depth.saturating_sub(1);
769            out.push(c);
770            i += 1;
771            continue;
772        }
773
774        if bracket_depth == 0 && starts_with_global(selector, i) {
775            let open_paren = i + 7;
776            if let Some(close_paren) = find_matching_paren(selector, open_paren) {
777                out.push_str(&selector[(open_paren + 1)..close_paren]);
778                i = close_paren + 1;
779                continue;
780            }
781        }
782
783        if c == '.' && bracket_depth == 0 {
784            let class_start = i + 1;
785            if class_start < bytes.len() && is_class_start(bytes[class_start] as char) {
786                let mut j = class_start + 1;
787                while j < bytes.len() && is_class_char(bytes[j] as char) {
788                    j += 1;
789                }
790                let class_name = &selector[class_start..j];
791                let scoped_class = format!("{class_name}_{suffix}");
792                classes
793                    .entry(class_name.to_string())
794                    .or_insert_with(|| scoped_class.clone());
795                out.push('.');
796                out.push_str(&scoped_class);
797                i = j;
798                continue;
799            }
800        }
801
802        out.push(c);
803        i += 1;
804    }
805
806    out
807}
808
809fn starts_with_global(input: &str, idx: usize) -> bool {
810    input[idx..].starts_with(":global(")
811}
812
813fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
814    let bytes = input.as_bytes();
815    if bytes.get(open_paren).copied() != Some(b'(') {
816        return None;
817    }
818
819    let mut depth = 1usize;
820    let mut i = open_paren + 1;
821    let mut in_single = false;
822    let mut in_double = false;
823
824    while i < bytes.len() {
825        let c = bytes[i] as char;
826        if in_single {
827            if c == '\'' && !is_escaped(bytes, i) {
828                in_single = false;
829            }
830            i += 1;
831            continue;
832        }
833        if in_double {
834            if c == '"' && !is_escaped(bytes, i) {
835                in_double = false;
836            }
837            i += 1;
838            continue;
839        }
840
841        match c {
842            '\'' => in_single = true,
843            '"' => in_double = true,
844            '(' => depth += 1,
845            ')' => {
846                depth -= 1;
847                if depth == 0 {
848                    return Some(i);
849                }
850            }
851            _ => {}
852        }
853        i += 1;
854    }
855
856    None
857}
858
859fn is_class_start(c: char) -> bool {
860    c.is_ascii_alphabetic() || c == '_' || c == '-'
861}
862
863fn is_class_char(c: char) -> bool {
864    c.is_ascii_alphanumeric() || c == '_' || c == '-'
865}
866
867fn split_top_level(input: &str, separator: char) -> Vec<&str> {
868    let mut parts = Vec::new();
869    let mut start = 0usize;
870    let mut i = 0usize;
871    let bytes = input.as_bytes();
872    let mut paren = 0usize;
873    let mut bracket = 0usize;
874    let mut in_single = false;
875    let mut in_double = false;
876
877    while i < bytes.len() {
878        let c = bytes[i] as char;
879        if in_single {
880            if c == '\'' && !is_escaped(bytes, i) {
881                in_single = false;
882            }
883            i += 1;
884            continue;
885        }
886        if in_double {
887            if c == '"' && !is_escaped(bytes, i) {
888                in_double = false;
889            }
890            i += 1;
891            continue;
892        }
893        if c == '\'' {
894            in_single = true;
895            i += 1;
896            continue;
897        }
898        if c == '"' {
899            in_double = true;
900            i += 1;
901            continue;
902        }
903        match c {
904            '(' => paren += 1,
905            ')' => paren = paren.saturating_sub(1),
906            '[' => bracket += 1,
907            ']' => bracket = bracket.saturating_sub(1),
908            _ => {}
909        }
910        if c == separator && paren == 0 && bracket == 0 {
911            parts.push(&input[start..i]);
912            start = i + 1;
913        }
914        i += 1;
915    }
916    parts.push(&input[start..]);
917    parts
918}
919
920fn skip_ws_and_comments(input: &str, i: &mut usize) {
921    let bytes = input.as_bytes();
922    while *i < bytes.len() {
923        if bytes[*i].is_ascii_whitespace() {
924            *i += 1;
925            continue;
926        }
927        if *i + 1 < bytes.len() && bytes[*i] == b'/' && bytes[*i + 1] == b'*' {
928            *i += 2;
929            while *i + 1 < bytes.len() && !(bytes[*i] == b'*' && bytes[*i + 1] == b'/') {
930                *i += 1;
931            }
932            if *i + 1 < bytes.len() {
933                *i += 2;
934            }
935            continue;
936        }
937        break;
938    }
939}
940
941fn find_next_delim(input: &str, mut i: usize) -> Option<usize> {
942    let bytes = input.as_bytes();
943    let mut paren = 0usize;
944    let mut bracket = 0usize;
945    let mut in_single = false;
946    let mut in_double = false;
947
948    while i < bytes.len() {
949        let c = bytes[i] as char;
950        if in_single {
951            if c == '\'' && !is_escaped(bytes, i) {
952                in_single = false;
953            }
954            i += 1;
955            continue;
956        }
957        if in_double {
958            if c == '"' && !is_escaped(bytes, i) {
959                in_double = false;
960            }
961            i += 1;
962            continue;
963        }
964
965        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
966            i += 2;
967            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
968                i += 1;
969            }
970            if i + 1 < bytes.len() {
971                i += 2;
972            }
973            continue;
974        }
975
976        if c == '\'' {
977            in_single = true;
978            i += 1;
979            continue;
980        }
981        if c == '"' {
982            in_double = true;
983            i += 1;
984            continue;
985        }
986
987        match c {
988            '(' => paren += 1,
989            ')' => paren = paren.saturating_sub(1),
990            '[' => bracket += 1,
991            ']' => bracket = bracket.saturating_sub(1),
992            '{' | ';' if paren == 0 && bracket == 0 => return Some(i),
993            _ => {}
994        }
995        i += 1;
996    }
997    None
998}
999
1000fn find_matching_brace(input: &str, open_brace: usize) -> Option<usize> {
1001    let bytes = input.as_bytes();
1002    if bytes.get(open_brace).copied() != Some(b'{') {
1003        return None;
1004    }
1005    let mut i = open_brace + 1;
1006    let mut depth = 1usize;
1007    let mut in_single = false;
1008    let mut in_double = false;
1009    while i < bytes.len() {
1010        let c = bytes[i] as char;
1011        if in_single {
1012            if c == '\'' && !is_escaped(bytes, i) {
1013                in_single = false;
1014            }
1015            i += 1;
1016            continue;
1017        }
1018        if in_double {
1019            if c == '"' && !is_escaped(bytes, i) {
1020                in_double = false;
1021            }
1022            i += 1;
1023            continue;
1024        }
1025        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
1026            i += 2;
1027            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1028                i += 1;
1029            }
1030            if i + 1 < bytes.len() {
1031                i += 2;
1032            }
1033            continue;
1034        }
1035        match c {
1036            '\'' => in_single = true,
1037            '"' => in_double = true,
1038            '{' => depth += 1,
1039            '}' => {
1040                depth -= 1;
1041                if depth == 0 {
1042                    return Some(i);
1043                }
1044            }
1045            _ => {}
1046        }
1047        i += 1;
1048    }
1049    None
1050}
1051
1052fn is_escaped(bytes: &[u8], i: usize) -> bool {
1053    if i == 0 {
1054        return false;
1055    }
1056    let mut backslashes = 0usize;
1057    let mut j = i;
1058    while j > 0 {
1059        j -= 1;
1060        if bytes[j] == b'\\' {
1061            backslashes += 1;
1062        } else {
1063            break;
1064        }
1065    }
1066    backslashes % 2 == 1
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    #[test]
1074    fn renames_local_classes_with_suffix() {
1075        let (css, classes) = transform_css(".card{color:red}", "f45126d").expect("should compile");
1076        assert_eq!(css, ".card_f45126d{color:red}");
1077        assert_eq!(
1078            classes.get("card").map(String::as_str),
1079            Some("card_f45126d")
1080        );
1081    }
1082
1083    #[test]
1084    fn keeps_global_selector_unscoped() {
1085        let (css, classes) =
1086            transform_css(".my_scoped_class :global(.paragraph){color:red}", "f45126d")
1087                .expect("should compile");
1088        assert_eq!(css, ".my_scoped_class_f45126d .paragraph{color:red}");
1089        assert_eq!(
1090            classes.get("my_scoped_class").map(String::as_str),
1091            Some("my_scoped_class_f45126d")
1092        );
1093        assert!(!classes.contains_key("paragraph"));
1094    }
1095
1096    #[test]
1097    fn scopes_selectors_inside_media() {
1098        let css = "@media screen and (max-width: 600px) {.card{color:red;}}";
1099        let (out, classes) = transform_css(css, "f45126d").expect("scope should work");
1100        assert!(out.contains("@media screen and (max-width: 600px)"));
1101        assert!(out.contains(".card_f45126d{color:red;}"));
1102        assert_eq!(
1103            classes.get("card").map(String::as_str),
1104            Some("card_f45126d")
1105        );
1106    }
1107
1108    #[test]
1109    fn does_not_scope_keyframe_steps() {
1110        let css = "@keyframes spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}";
1111        let (out, _) = transform_css(css, "f45126d").expect("scope should work");
1112        assert!(out.contains(
1113            "@keyframes spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}"
1114        ));
1115        assert!(!out.contains("from_f45126d"));
1116    }
1117
1118    #[test]
1119    fn supports_compressed_declarations_without_trailing_semicolon() {
1120        let (out, _) = transform_css(".card{color:red}", "f45126d").expect("scope should work");
1121        assert_eq!(out, ".card_f45126d{color:red}");
1122    }
1123
1124    #[test]
1125    fn collects_declared_empty_classes_from_source() {
1126        let source = r#"
1127            .container {}
1128            .title { color: red; }
1129            .scope :global(.external) { color: blue; }
1130        "#;
1131        let mut classes = BTreeSet::new();
1132        collect_classes_from_block(source, false, 0, &mut classes).expect("should parse");
1133
1134        assert!(classes.contains("container"));
1135        assert!(classes.contains("title"));
1136        assert!(classes.contains("scope"));
1137        assert!(!classes.contains("external"));
1138    }
1139
1140    #[test]
1141    fn parses_import_use_and_forward_dependencies() {
1142        let deps = parse_sass_dependencies(
1143            r#"
1144            @import "a", 'b';
1145            @use "c" as *;
1146            @forward 'd';
1147            "#,
1148        );
1149        assert_eq!(deps, vec!["a", "b", "c", "d"]);
1150    }
1151}