1use 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#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ScopedModule {
37 pub suffix: String,
39 pub css: String,
41 pub classes: BTreeMap<String, String>,
43 pub dependencies: Vec<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ScopedSassOptions {
50 pub compressed: bool,
52 pub suffix: Option<String>,
54 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
82pub 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 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}