1use std::path::Path;
15use std::sync::LazyLock;
16
17use lightningcss::rules::CssRule;
18use lightningcss::selector::{Component, PseudoClass, Selector, SelectorList};
19use lightningcss::stylesheet::{ParserOptions, StyleSheet};
20use oxc_span::Span;
21use rustc_hash::FxHashSet;
22
23use crate::{ExportInfo, ExportName, ImportInfo, ImportedName, ModuleInfo, VisibilityTag};
24use fallow_types::discover::FileId;
25
26static CSS_IMPORT_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
29 crate::static_regex(
30 r#"@import\s+(?:url\(\s*(?:["']([^"']+)["']|([^)]+))\s*\)|["']([^"']+)["'])"#,
31 )
32});
33
34static SCSS_USE_RE: LazyLock<regex::Regex> =
37 LazyLock::new(|| crate::static_regex(r#"@(?:use|forward)\s+["']([^"']+)["']"#));
38
39static CSS_PLUGIN_RE: LazyLock<regex::Regex> =
42 LazyLock::new(|| crate::static_regex(r#"@plugin\s+["']([^"']+)["']"#));
43
44static CSS_APPLY_RE: LazyLock<regex::Regex> =
47 LazyLock::new(|| crate::static_regex(r"@apply\s+[^;}\n]+"));
48
49static CSS_TAILWIND_RE: LazyLock<regex::Regex> =
52 LazyLock::new(|| crate::static_regex(r"@tailwind\s+\w+"));
53
54static CSS_COMMENT_RE: LazyLock<regex::Regex> =
56 LazyLock::new(|| crate::static_regex(r"(?s)/\*.*?\*/"));
57
58static SCSS_LINE_COMMENT_RE: LazyLock<regex::Regex> =
60 LazyLock::new(|| crate::static_regex(r"//[^\n]*"));
61
62static CSS_CLASS_RE: LazyLock<regex::Regex> =
65 LazyLock::new(|| crate::static_regex(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)"));
66
67static CSS_NON_SELECTOR_RE: LazyLock<regex::Regex> =
70 LazyLock::new(|| crate::static_regex(r#"(?s)"[^"]*"|'[^']*'|url\([^)]*\)"#));
71
72static CSS_AT_RULE_PRELUDE_RE: LazyLock<regex::Regex> =
84 LazyLock::new(|| crate::static_regex(r"@(?:layer|import)\b[^;{]*"));
85
86pub(crate) fn is_css_file(path: &Path) -> bool {
87 path.extension()
88 .and_then(|e| e.to_str())
89 .is_some_and(|ext| matches!(ext, "css" | "scss" | "sass" | "less"))
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct CssImportSource {
95 pub raw: String,
97 pub normalized: String,
99 pub is_plugin: bool,
101 pub span: Span,
103}
104
105fn is_css_module_file(path: &Path) -> bool {
106 is_css_file(path)
107 && path
108 .file_stem()
109 .and_then(|s| s.to_str())
110 .is_some_and(|stem| stem.ends_with(".module"))
111}
112
113fn is_css_url_import(source: &str) -> bool {
115 source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
116}
117
118fn normalize_css_import_path(path: String, is_scss: bool) -> String {
131 if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
132 return path;
133 }
134 if path.starts_with('@') && path.contains('/') {
135 return path;
136 }
137 let path_ref = std::path::Path::new(&path);
138 if !is_scss
139 && path.contains('/')
140 && path_ref
141 .extension()
142 .and_then(|e| e.to_str())
143 .is_some_and(is_style_extension)
144 {
145 return path;
146 }
147 let ext = std::path::Path::new(&path)
148 .extension()
149 .and_then(|e| e.to_str());
150 match ext {
151 Some(e) if is_style_extension(e) => format!("./{path}"),
152 _ => {
153 if is_scss && !path.contains(':') {
154 format!("./{path}")
155 } else {
156 path
157 }
158 }
159 }
160}
161
162fn is_style_extension(ext: &str) -> bool {
163 ext.eq_ignore_ascii_case("css")
164 || ext.eq_ignore_ascii_case("scss")
165 || ext.eq_ignore_ascii_case("sass")
166 || ext.eq_ignore_ascii_case("less")
167}
168
169#[cfg(test)]
171fn strip_css_comments(source: &str, is_scss: bool) -> String {
172 let stripped = CSS_COMMENT_RE.replace_all(source, "");
173 if is_scss {
174 SCSS_LINE_COMMENT_RE.replace_all(&stripped, "").into_owned()
175 } else {
176 stripped.into_owned()
177 }
178}
179
180fn mask_css_comments(source: &str, is_scss: bool) -> String {
181 let mut masked = mask_with_whitespace(source, &CSS_COMMENT_RE);
182 if is_scss {
183 masked = mask_with_whitespace(&masked, &SCSS_LINE_COMMENT_RE);
184 }
185 masked
186}
187
188fn normalize_css_plugin_path(path: String) -> String {
194 path
195}
196
197#[must_use]
207pub fn extract_css_import_sources(source: &str, is_scss: bool) -> Vec<CssImportSource> {
208 let stripped = mask_css_comments(source, is_scss);
209 let mut out = Vec::new();
210
211 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
212 let raw = cap.get(1).or_else(|| cap.get(2)).or_else(|| cap.get(3));
213 if let Some(m) = raw {
214 let (src, span) = trimmed_match_with_span(m);
215 if !src.is_empty() && !is_css_url_import(&src) {
216 out.push(CssImportSource {
217 normalized: normalize_css_import_path(src.clone(), is_scss),
218 raw: src,
219 is_plugin: false,
220 span,
221 });
222 }
223 }
224 }
225
226 if is_scss {
227 for cap in SCSS_USE_RE.captures_iter(&stripped) {
228 if let Some(m) = cap.get(1) {
229 let (raw, span) = trimmed_match_with_span(m);
230 out.push(CssImportSource {
231 normalized: normalize_css_import_path(raw.clone(), true),
232 raw,
233 is_plugin: false,
234 span,
235 });
236 }
237 }
238 }
239
240 for cap in CSS_PLUGIN_RE.captures_iter(&stripped) {
241 if let Some(m) = cap.get(1) {
242 let (raw, span) = trimmed_match_with_span(m);
243 if !raw.is_empty() && !is_css_url_import(&raw) {
244 out.push(CssImportSource {
245 normalized: normalize_css_plugin_path(raw.clone()),
246 raw,
247 is_plugin: true,
248 span,
249 });
250 }
251 }
252 }
253
254 out
255}
256
257fn trimmed_match_with_span(m: regex::Match<'_>) -> (String, Span) {
258 let raw = m.as_str();
259 let trimmed_start = raw.len() - raw.trim_start().len();
260 let trimmed_end = raw.trim_end().len();
261 let start = m.start() + trimmed_start;
262 let end = m.start() + trimmed_end;
263 (raw.trim().to_string(), Span::new(start as u32, end as u32))
264}
265
266#[must_use]
273pub fn extract_css_imports(source: &str, is_scss: bool) -> Vec<String> {
274 extract_css_import_sources(source, is_scss)
275 .into_iter()
276 .map(|source| source.normalized)
277 .collect()
278}
279
280static CSS_THEME_OPEN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
284 crate::static_regex(r"@theme(?:\s+(?:inline|static|reference|default))*\s*\{")
285});
286
287static CSS_VAR_REF_RE: LazyLock<regex::Regex> =
291 LazyLock::new(|| crate::static_regex(r"var\(\s*--([A-Za-z0-9_-]+)"));
292
293#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct ThemeTokenDef {
297 pub name: String,
299 pub line: u32,
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct ThemeScan {
306 pub tokens: Vec<ThemeTokenDef>,
310 pub theme_var_reads: Vec<String>,
316}
317
318#[must_use]
328pub fn scan_theme_blocks(source: &str) -> ThemeScan {
329 if !source.contains("@theme") {
331 return ThemeScan::default();
332 }
333 let masked = mask_theme_source(source);
334 let mut out = ThemeScan::default();
335 let mut seen: FxHashSet<String> = FxHashSet::default();
336 for open in CSS_THEME_OPEN_RE.find_iter(&masked) {
337 let body_start = open.end();
338 let body_end = find_theme_body_end(&masked, body_start);
339 collect_theme_declarations(
340 source,
341 &masked,
342 body_start,
343 body_end,
344 &mut out.tokens,
345 &mut seen,
346 );
347 collect_theme_var_reads(&masked, body_start, body_end, &mut out.theme_var_reads);
348 }
349 out
350}
351
352fn mask_theme_source(source: &str) -> String {
355 mask_with_whitespace(&mask_css_comments(source, false), &CSS_NON_SELECTOR_RE)
356}
357
358fn find_theme_body_end(masked: &str, body_start: usize) -> usize {
360 let bytes = masked.as_bytes();
361 let mut depth = 1usize;
362 let mut i = body_start;
363 while i < bytes.len() {
364 match bytes[i] {
365 b'{' => depth += 1,
366 b'}' => {
367 depth -= 1;
368 if depth == 0 {
369 break;
370 }
371 }
372 _ => {}
373 }
374 i += 1;
375 }
376 i.min(bytes.len())
377}
378
379fn collect_theme_var_reads(
380 masked: &str,
381 body_start: usize,
382 body_end: usize,
383 out: &mut Vec<String>,
384) {
385 let Some(body) = masked.get(body_start..body_end) else {
386 return;
387 };
388 for cap in CSS_VAR_REF_RE.captures_iter(body) {
389 if let Some(name) = cap.get(1) {
390 out.push(name.as_str().to_owned());
391 }
392 }
393}
394
395fn collect_theme_declarations(
401 source: &str,
402 masked: &str,
403 start: usize,
404 end: usize,
405 out: &mut Vec<ThemeTokenDef>,
406 seen: &mut FxHashSet<String>,
407) {
408 let bytes = masked.as_bytes();
409 let mut depth = 0usize;
410 let mut expect_decl = true;
411 let mut i = start;
412 while i < end {
413 let b = bytes[i];
414 match b {
415 b'{' => {
416 depth += 1;
417 expect_decl = false;
418 i += 1;
419 }
420 b'}' => {
421 depth = depth.saturating_sub(1);
422 if depth == 0 {
423 expect_decl = true;
424 }
425 i += 1;
426 }
427 b';' => {
428 if depth == 0 {
429 expect_decl = true;
430 }
431 i += 1;
432 }
433 _ if b.is_ascii_whitespace() => i += 1,
434 _ => {
435 if depth == 0 && expect_decl {
436 expect_decl = false;
437 i = scan_theme_declaration(
438 &mut ThemeDeclarationScan {
439 source,
440 masked,
441 end,
442 out,
443 seen,
444 },
445 b,
446 i,
447 );
448 } else {
449 i += 1;
450 }
451 }
452 }
453 }
454}
455
456struct ThemeDeclarationScan<'a, 'b> {
457 source: &'a str,
458 masked: &'a str,
459 end: usize,
460 out: &'b mut Vec<ThemeTokenDef>,
461 seen: &'b mut FxHashSet<String>,
462}
463
464fn scan_theme_declaration(scan: &mut ThemeDeclarationScan<'_, '_>, b: u8, i: usize) -> usize {
468 let bytes = scan.masked.as_bytes();
469 if !(b == b'-' && bytes.get(i + 1) == Some(&b'-')) {
470 return i + 1;
471 }
472 let id_start = i;
473 let mut j = i;
474 while j < scan.end {
475 let c = bytes[j];
476 if c == b'-' || c == b'_' || c.is_ascii_alphanumeric() {
477 j += 1;
478 } else {
479 break;
480 }
481 }
482 let mut k = j;
483 while k < scan.end && bytes[k].is_ascii_whitespace() {
484 k += 1;
485 }
486 if k < scan.end && bytes[k] == b':' {
488 let name = &scan.masked[id_start + 2..j];
489 if !name.is_empty() && scan.seen.insert(name.to_owned()) {
490 let line = 1 + scan
491 .source
492 .get(..id_start)
493 .map_or(0, |s| s.bytes().filter(|&x| x == b'\n').count());
494 scan.out.push(ThemeTokenDef {
495 name: name.to_owned(),
496 line: u32::try_from(line).unwrap_or(u32::MAX),
497 });
498 }
499 }
500 j
501}
502
503#[must_use]
509pub fn extract_apply_tokens(source: &str) -> Vec<String> {
510 if !source.contains("@apply") {
512 return Vec::new();
513 }
514 let masked = mask_with_whitespace(&mask_css_comments(source, false), &CSS_NON_SELECTOR_RE);
515 let mut out = Vec::new();
516 for m in CSS_APPLY_RE.find_iter(&masked) {
517 let body = m.as_str().trim_start_matches("@apply");
518 for token in body.split_whitespace() {
519 let token = token.trim_matches('!');
520 if token.is_empty() || token == "important" {
521 continue;
522 }
523 out.push(token.to_owned());
524 }
525 }
526 out
527}
528
529fn mask_with_whitespace(src: &str, re: ®ex::Regex) -> String {
539 let mut out = String::with_capacity(src.len());
540 let mut cursor = 0;
541 for m in re.find_iter(src) {
542 out.push_str(&src[cursor..m.start()]);
543 for _ in m.start()..m.end() {
544 out.push(' ');
545 }
546 cursor = m.end();
547 }
548 out.push_str(&src[cursor..]);
549 out
550}
551
552fn lightningcss_class_set(source: &str) -> Option<FxHashSet<String>> {
569 let options = ParserOptions {
570 error_recovery: true,
573 css_modules: Some(lightningcss::css_modules::Config::default()),
579 ..ParserOptions::default()
580 };
581 let stylesheet = StyleSheet::parse(source, options).ok()?;
582 let mut classes = FxHashSet::default();
583 collect_classes_from_rules(&stylesheet.rules.0, &mut classes);
584 Some(classes)
585}
586
587fn collect_classes_from_rules(rules: &[CssRule<'_>], classes: &mut FxHashSet<String>) {
592 for rule in rules {
593 match rule {
594 CssRule::Style(style) => {
595 collect_classes_from_selector_list(&style.selectors, classes);
596 collect_classes_from_rules(&style.rules.0, classes);
597 }
598 CssRule::Media(rule) => collect_classes_from_rules(&rule.rules.0, classes),
599 CssRule::Supports(rule) => collect_classes_from_rules(&rule.rules.0, classes),
600 CssRule::Container(rule) => collect_classes_from_rules(&rule.rules.0, classes),
601 CssRule::LayerBlock(rule) => collect_classes_from_rules(&rule.rules.0, classes),
602 CssRule::MozDocument(rule) => collect_classes_from_rules(&rule.rules.0, classes),
603 CssRule::StartingStyle(rule) => collect_classes_from_rules(&rule.rules.0, classes),
604 CssRule::Nesting(rule) => {
605 collect_classes_from_selector_list(&rule.style.selectors, classes);
606 collect_classes_from_rules(&rule.style.rules.0, classes);
607 }
608 CssRule::Scope(rule) => {
609 if let Some(scope_start) = &rule.scope_start {
610 collect_classes_from_selector_list(scope_start, classes);
611 }
612 if let Some(scope_end) = &rule.scope_end {
613 collect_classes_from_selector_list(scope_end, classes);
614 }
615 collect_classes_from_rules(&rule.rules.0, classes);
616 }
617 _ => {}
618 }
619 }
620}
621
622fn collect_classes_from_selector_list(list: &SelectorList<'_>, classes: &mut FxHashSet<String>) {
623 for selector in &list.0 {
624 collect_classes_from_selector(selector, classes);
625 }
626}
627
628fn collect_classes_from_selector(selector: &Selector<'_>, classes: &mut FxHashSet<String>) {
629 for component in selector.iter_raw_match_order() {
630 match component {
631 Component::Class(name) => {
632 classes.insert(name.0.to_string());
633 }
634 Component::Is(list)
635 | Component::Where(list)
636 | Component::Has(list)
637 | Component::Negation(list)
638 | Component::Any(_, list) => {
639 for nested in list.as_ref() {
640 collect_classes_from_selector(nested, classes);
641 }
642 }
643 Component::Slotted(nested) | Component::Host(Some(nested)) => {
644 collect_classes_from_selector(nested, classes);
645 }
646 Component::NthOf(data) => {
647 for nested in data.selectors() {
648 collect_classes_from_selector(nested, classes);
649 }
650 }
651 Component::NonTSPseudoClass(
653 PseudoClass::Local { selector } | PseudoClass::Global { selector },
654 ) => collect_classes_from_selector(selector, classes),
655 _ => {}
656 }
657 }
658}
659
660pub fn extract_css_module_exports(source: &str, is_scss: bool) -> Vec<ExportInfo> {
670 if !is_scss && let Some(class_set) = lightningcss_class_set(source) {
671 return scan_css_module_exports(source, is_scss, Some(&class_set));
672 }
673 scan_css_module_exports(source, is_scss, None)
674}
675
676fn scan_css_module_exports(
686 source: &str,
687 is_scss: bool,
688 class_filter: Option<&FxHashSet<String>>,
689) -> Vec<ExportInfo> {
690 let masked = mask_css_module_class_candidates(source, is_scss, class_filter.is_some());
691 let mut seen = FxHashSet::default();
692 let mut exports = Vec::new();
693 for cap in CSS_CLASS_RE.captures_iter(&masked) {
694 if let Some(m) = cap.get(1) {
695 push_css_class_export(m, class_filter, &mut seen, &mut exports);
696 }
697 }
698 exports
699}
700
701fn mask_css_module_class_candidates(source: &str, is_scss: bool, has_class_filter: bool) -> String {
702 let mut masked = mask_with_whitespace(source, &CSS_COMMENT_RE);
703 if is_scss {
704 masked = mask_with_whitespace(&masked, &SCSS_LINE_COMMENT_RE);
705 }
706 masked = mask_with_whitespace(&masked, &CSS_NON_SELECTOR_RE);
707 if !has_class_filter {
708 masked = mask_with_whitespace(&masked, &CSS_AT_RULE_PRELUDE_RE);
709 }
710 masked
711}
712
713fn push_css_class_export(
714 class_match: regex::Match<'_>,
715 class_filter: Option<&FxHashSet<String>>,
716 seen: &mut FxHashSet<String>,
717 exports: &mut Vec<ExportInfo>,
718) {
719 let class_name = class_match.as_str().to_string();
720 if class_filter.is_some_and(|filter| !filter.contains(&class_name)) {
721 return;
722 }
723 if seen.insert(class_name.clone()) {
724 exports.push(css_class_export(class_name, class_match));
725 }
726}
727
728fn css_class_export(class_name: String, class_match: regex::Match<'_>) -> ExportInfo {
729 #[expect(
730 clippy::cast_possible_truncation,
731 reason = "CSS files exceeding u32::MAX bytes are not a realistic input"
732 )]
733 let span = Span::new(class_match.start() as u32, class_match.end() as u32);
734 ExportInfo {
735 name: ExportName::Named(class_name),
736 local_name: None,
737 is_type_only: false,
738 visibility: VisibilityTag::None,
739 expected_unused_reason: None,
740 span,
741 members: Vec::new(),
742 is_side_effect_used: false,
743 super_class: None,
744 }
745}
746
747fn build_css_imports(source: &str, stripped: &str, is_scss: bool) -> Vec<ImportInfo> {
751 let mut imports = Vec::new();
752
753 for css_source in extract_css_import_sources(source, is_scss) {
754 imports.push(ImportInfo {
755 source: css_source.normalized,
756 imported_name: if css_source.is_plugin {
757 ImportedName::Default
758 } else {
759 ImportedName::SideEffect
760 },
761 local_name: String::new(),
762 is_type_only: false,
763 from_style: false,
764 span: css_source.span,
765 source_span: css_source.span,
766 });
767 }
768
769 let has_apply = CSS_APPLY_RE.is_match(stripped);
770 let has_tailwind = CSS_TAILWIND_RE.is_match(stripped);
771 if has_apply || has_tailwind {
772 imports.push(ImportInfo {
773 source: "tailwindcss".to_string(),
774 imported_name: ImportedName::SideEffect,
775 local_name: String::new(),
776 is_type_only: false,
777 from_style: false,
778 span: Span::default(),
779 source_span: Span::default(),
780 });
781 }
782
783 imports
784}
785
786pub(crate) fn parse_css_to_module(
788 file_id: FileId,
789 path: &Path,
790 source: &str,
791 content_hash: u64,
792) -> ModuleInfo {
793 let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
794 let is_scss = path
795 .extension()
796 .and_then(|e| e.to_str())
797 .is_some_and(|ext| matches!(ext, "scss" | "sass" | "less"));
798
799 let stripped = mask_css_comments(source, is_scss);
800 let imports = build_css_imports(source, &stripped, is_scss);
801
802 let exports = if is_css_module_file(path) {
803 extract_css_module_exports(source, is_scss)
804 } else {
805 Vec::new()
806 };
807
808 css_module_info(
809 file_id,
810 content_hash,
811 source,
812 parsed_suppressions,
813 imports,
814 exports,
815 )
816}
817
818fn css_module_info(
822 file_id: FileId,
823 content_hash: u64,
824 source: &str,
825 parsed_suppressions: crate::suppress::ParsedSuppressions,
826 imports: Vec<ImportInfo>,
827 exports: Vec<ExportInfo>,
828) -> ModuleInfo {
829 crate::module_info::non_js_module_info(
830 file_id,
831 content_hash,
832 source,
833 parsed_suppressions,
834 imports,
835 exports,
836 )
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 fn export_names(source: &str) -> Vec<String> {
845 extract_css_module_exports(source, false)
846 .into_iter()
847 .filter_map(|e| match e.name {
848 ExportName::Named(n) => Some(n),
849 ExportName::Default => None,
850 })
851 .collect()
852 }
853
854 #[test]
855 fn is_css_file_css() {
856 assert!(is_css_file(Path::new("styles.css")));
857 }
858
859 #[test]
860 fn is_css_file_scss() {
861 assert!(is_css_file(Path::new("styles.scss")));
862 }
863
864 #[test]
865 fn is_css_file_sass() {
866 assert!(is_css_file(Path::new("styles.sass")));
867 }
868
869 #[test]
870 fn is_css_file_less() {
871 assert!(is_css_file(Path::new("styles.less")));
872 }
873
874 #[test]
875 fn is_css_file_rejects_js() {
876 assert!(!is_css_file(Path::new("app.js")));
877 }
878
879 #[test]
880 fn is_css_file_rejects_ts() {
881 assert!(!is_css_file(Path::new("app.ts")));
882 }
883
884 #[test]
885 fn is_css_file_rejects_no_extension() {
886 assert!(!is_css_file(Path::new("Makefile")));
887 }
888
889 #[test]
890 fn is_css_module_file_module_css() {
891 assert!(is_css_module_file(Path::new("Component.module.css")));
892 }
893
894 #[test]
895 fn is_css_module_file_module_scss() {
896 assert!(is_css_module_file(Path::new("Component.module.scss")));
897 }
898
899 #[test]
900 fn is_css_module_file_rejects_plain_css() {
901 assert!(!is_css_module_file(Path::new("styles.css")));
902 }
903
904 #[test]
905 fn is_css_module_file_rejects_plain_scss() {
906 assert!(!is_css_module_file(Path::new("styles.scss")));
907 }
908
909 #[test]
910 fn is_css_module_file_rejects_module_js() {
911 assert!(!is_css_module_file(Path::new("utils.module.js")));
912 }
913
914 #[test]
915 fn extracts_single_class() {
916 let names = export_names(".foo { color: red; }");
917 assert_eq!(names, vec!["foo"]);
918 }
919
920 #[test]
921 fn extracts_multiple_classes() {
922 let names = export_names(".foo { } .bar { }");
923 assert_eq!(names, vec!["foo", "bar"]);
924 }
925
926 #[test]
927 fn extracts_nested_classes() {
928 let names = export_names(".foo .bar { color: red; }");
929 assert!(names.contains(&"foo".to_string()));
930 assert!(names.contains(&"bar".to_string()));
931 }
932
933 #[test]
934 fn extracts_hyphenated_class() {
935 let names = export_names(".my-class { }");
936 assert_eq!(names, vec!["my-class"]);
937 }
938
939 #[test]
940 fn extracts_camel_case_class() {
941 let names = export_names(".myClass { }");
942 assert_eq!(names, vec!["myClass"]);
943 }
944
945 #[test]
946 fn extracts_class_inside_global_pseudo() {
947 let names = export_names(":global(.globalClass) { color: red; }");
950 assert_eq!(names, vec!["globalClass"]);
951 }
952
953 #[test]
954 fn extracts_class_inside_local_pseudo() {
955 let names = export_names(":local(.localClass) { color: red; }");
956 assert_eq!(names, vec!["localClass"]);
957 }
958
959 #[test]
960 fn extracts_classes_inside_negation() {
961 let names = export_names(".btn:not(.disabled) { }");
962 assert!(names.contains(&"btn".to_string()), "got {names:?}");
963 assert!(names.contains(&"disabled".to_string()), "got {names:?}");
964 }
965
966 #[test]
967 fn extracts_classes_inside_is_and_where() {
968 let names = export_names(":is(.a, .b) :where(.c) { }");
969 for expected in ["a", "b", "c"] {
970 assert!(
971 names.contains(&expected.to_string()),
972 "missing {expected} in {names:?}"
973 );
974 }
975 }
976
977 #[test]
978 fn extracts_underscore_class() {
979 let names = export_names("._hidden { } .__wrapper { }");
980 assert!(names.contains(&"_hidden".to_string()));
981 assert!(names.contains(&"__wrapper".to_string()));
982 }
983
984 #[test]
985 fn pseudo_selector_hover() {
986 let names = export_names(".foo:hover { color: blue; }");
987 assert_eq!(names, vec!["foo"]);
988 }
989
990 #[test]
991 fn pseudo_selector_focus() {
992 let names = export_names(".input:focus { outline: none; }");
993 assert_eq!(names, vec!["input"]);
994 }
995
996 #[test]
997 fn pseudo_element_before() {
998 let names = export_names(".icon::before { content: ''; }");
999 assert_eq!(names, vec!["icon"]);
1000 }
1001
1002 #[test]
1003 fn combined_pseudo_selectors() {
1004 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
1005 assert_eq!(names, vec!["btn"]);
1006 }
1007
1008 #[test]
1009 fn classes_inside_media_query() {
1010 let names = export_names(
1011 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
1012 );
1013 assert!(names.contains(&"mobile-nav".to_string()));
1014 assert!(names.contains(&"desktop-nav".to_string()));
1015 }
1016
1017 #[test]
1018 fn classes_inside_multi_line_media_query() {
1019 let names =
1020 export_names("@media\n screen and (min-width: 600px)\n{\n .real { color: red; }\n}");
1021 assert_eq!(names, vec!["real"]);
1022 }
1023
1024 #[test]
1025 fn at_layer_statement_does_not_export() {
1026 let names = export_names("@layer foo.bar;");
1027 assert!(names.is_empty(), "got {names:?}");
1028 let names = export_names("@layer foo.bar, foo.baz;");
1029 assert!(names.is_empty(), "got {names:?}");
1030 }
1031
1032 #[test]
1033 fn at_layer_block_keeps_body_classes() {
1034 let names = export_names("@layer foo.bar { .root { color: red; } }");
1035 assert_eq!(names, vec!["root"]);
1036 }
1037
1038 #[test]
1039 fn at_layer_multiline_prelude_keeps_body_classes() {
1040 let names = export_names("@layer\n foo.bar\n{ .root { color: red; } }");
1041 assert_eq!(names, vec!["root"]);
1042 }
1043
1044 #[test]
1045 fn at_layer_with_nested_media_keeps_body() {
1046 let names =
1047 export_names("@layer foo.bar { @media (max-width: 768px) { .real { color: red; } } }");
1048 assert_eq!(names, vec!["real"]);
1049 }
1050
1051 #[test]
1052 fn at_import_with_layer_attribute_does_not_export() {
1053 let names = export_names(r#"@import url("x.css") layer(theme.button);"#);
1054 assert!(names.is_empty(), "got {names:?}");
1055 }
1056
1057 #[test]
1058 fn class_then_at_layer_does_not_leak_prelude() {
1059 let names =
1060 export_names(".outer { color: blue; } @layer foo.bar { .inner { color: red; } }");
1061 assert_eq!(names, vec!["outer", "inner"]);
1062 }
1063
1064 #[test]
1065 fn at_scope_keeps_selector_list_classes() {
1066 let names = export_names("@scope (.parent) to (.child) { .title { color: red; } }");
1067 assert!(names.contains(&"parent".to_string()), "got {names:?}");
1068 assert!(names.contains(&"child".to_string()), "got {names:?}");
1069 assert!(names.contains(&"title".to_string()), "got {names:?}");
1070 }
1071
1072 #[test]
1073 fn at_keyframes_numeric_step_is_not_class() {
1074 let names = export_names(
1075 "@keyframes slide { 0% { transform: scale(.5); } 100% { transform: scale(1); } }",
1076 );
1077 assert!(names.is_empty(), "got {names:?}");
1078 }
1079
1080 #[test]
1081 fn at_webkit_keyframes_keeps_body_classes() {
1082 let names = export_names("@-webkit-keyframes slide { 0% { } 100% { } } .real { }");
1083 assert_eq!(names, vec!["real"]);
1084 }
1085
1086 #[test]
1087 fn deduplicates_repeated_class() {
1088 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
1089 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
1090 }
1091
1092 #[test]
1093 fn empty_source() {
1094 let names = export_names("");
1095 assert!(names.is_empty());
1096 }
1097
1098 #[test]
1099 fn no_classes() {
1100 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
1101 assert!(names.is_empty());
1102 }
1103
1104 #[test]
1105 fn ignores_classes_in_block_comments() {
1106 let names = export_names("/* .fake { } */ .real { }");
1107 assert!(!names.contains(&"fake".to_string()));
1108 assert!(names.contains(&"real".to_string()));
1109 }
1110
1111 #[test]
1112 fn ignores_classes_in_scss_line_comments() {
1113 let exports = extract_css_module_exports("// .fake\n.real { }", true);
1114 let names: Vec<_> = exports
1115 .iter()
1116 .filter_map(|e| match &e.name {
1117 ExportName::Named(n) => Some(n.as_str()),
1118 ExportName::Default => None,
1119 })
1120 .collect();
1121 assert_eq!(names, vec!["real"]);
1122 }
1123
1124 #[test]
1125 fn ignores_classes_in_strings() {
1126 let names = export_names(r#".real { content: ".fake"; }"#);
1127 assert!(names.contains(&"real".to_string()));
1128 assert!(!names.contains(&"fake".to_string()));
1129 }
1130
1131 #[test]
1132 fn ignores_classes_in_url() {
1133 let names = export_names(".real { background: url(./images/hero.png); }");
1134 assert!(names.contains(&"real".to_string()));
1135 assert!(!names.contains(&"png".to_string()));
1136 }
1137
1138 #[test]
1139 fn strip_css_block_comment() {
1140 let result = strip_css_comments("/* removed */ .kept { }", false);
1141 assert!(!result.contains("removed"));
1142 assert!(result.contains(".kept"));
1143 }
1144
1145 #[test]
1146 fn strip_scss_line_comment() {
1147 let result = strip_css_comments("// removed\n.kept { }", true);
1148 assert!(!result.contains("removed"));
1149 assert!(result.contains(".kept"));
1150 }
1151
1152 #[test]
1153 fn strip_scss_preserves_css_outside_comments() {
1154 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
1155 let result = strip_css_comments(source, true);
1156 assert!(result.contains(".visible"));
1157 }
1158
1159 #[test]
1160 fn url_import_http() {
1161 assert!(is_css_url_import("http://example.com/style.css"));
1162 }
1163
1164 #[test]
1165 fn url_import_https() {
1166 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
1167 }
1168
1169 #[test]
1170 fn url_import_data() {
1171 assert!(is_css_url_import("data:text/css;base64,abc"));
1172 }
1173
1174 #[test]
1175 fn url_import_local_not_skipped() {
1176 assert!(!is_css_url_import("./local.css"));
1177 }
1178
1179 #[test]
1180 fn url_import_bare_specifier_not_skipped() {
1181 assert!(!is_css_url_import("tailwindcss"));
1182 }
1183
1184 #[test]
1185 fn normalize_relative_dot_path_unchanged() {
1186 assert_eq!(
1187 normalize_css_import_path("./reset.css".to_string(), false),
1188 "./reset.css"
1189 );
1190 }
1191
1192 #[test]
1193 fn normalize_parent_relative_path_unchanged() {
1194 assert_eq!(
1195 normalize_css_import_path("../shared.scss".to_string(), false),
1196 "../shared.scss"
1197 );
1198 }
1199
1200 #[test]
1201 fn normalize_absolute_path_unchanged() {
1202 assert_eq!(
1203 normalize_css_import_path("/styles/main.css".to_string(), false),
1204 "/styles/main.css"
1205 );
1206 }
1207
1208 #[test]
1209 fn normalize_url_unchanged() {
1210 assert_eq!(
1211 normalize_css_import_path("https://example.com/style.css".to_string(), false),
1212 "https://example.com/style.css"
1213 );
1214 }
1215
1216 #[test]
1217 fn normalize_bare_css_gets_dot_slash() {
1218 assert_eq!(
1219 normalize_css_import_path("app.css".to_string(), false),
1220 "./app.css"
1221 );
1222 }
1223
1224 #[test]
1225 fn normalize_css_package_subpath_stays_bare() {
1226 assert_eq!(
1227 normalize_css_import_path("tailwindcss/theme.css".to_string(), false),
1228 "tailwindcss/theme.css"
1229 );
1230 }
1231
1232 #[test]
1233 fn normalize_css_package_subpath_with_dotted_name_stays_bare() {
1234 assert_eq!(
1235 normalize_css_import_path("highlight.js/styles/github.css".to_string(), false),
1236 "highlight.js/styles/github.css"
1237 );
1238 }
1239
1240 #[test]
1241 fn normalize_bare_scss_gets_dot_slash() {
1242 assert_eq!(
1243 normalize_css_import_path("vars.scss".to_string(), false),
1244 "./vars.scss"
1245 );
1246 }
1247
1248 #[test]
1249 fn normalize_bare_sass_gets_dot_slash() {
1250 assert_eq!(
1251 normalize_css_import_path("main.sass".to_string(), false),
1252 "./main.sass"
1253 );
1254 }
1255
1256 #[test]
1257 fn normalize_bare_less_gets_dot_slash() {
1258 assert_eq!(
1259 normalize_css_import_path("theme.less".to_string(), false),
1260 "./theme.less"
1261 );
1262 }
1263
1264 #[test]
1265 fn normalize_bare_js_extension_stays_bare() {
1266 assert_eq!(
1267 normalize_css_import_path("module.js".to_string(), false),
1268 "module.js"
1269 );
1270 }
1271
1272 #[test]
1273 fn normalize_scss_bare_partial_gets_dot_slash() {
1274 assert_eq!(
1275 normalize_css_import_path("variables".to_string(), true),
1276 "./variables"
1277 );
1278 }
1279
1280 #[test]
1281 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
1282 assert_eq!(
1283 normalize_css_import_path("base/reset".to_string(), true),
1284 "./base/reset"
1285 );
1286 }
1287
1288 #[test]
1289 fn normalize_scss_builtin_stays_bare() {
1290 assert_eq!(
1291 normalize_css_import_path("sass:math".to_string(), true),
1292 "sass:math"
1293 );
1294 }
1295
1296 #[test]
1297 fn normalize_scss_relative_path_unchanged() {
1298 assert_eq!(
1299 normalize_css_import_path("../styles/variables".to_string(), true),
1300 "../styles/variables"
1301 );
1302 }
1303
1304 #[test]
1305 fn normalize_css_bare_extensionless_stays_bare() {
1306 assert_eq!(
1307 normalize_css_import_path("tailwindcss".to_string(), false),
1308 "tailwindcss"
1309 );
1310 }
1311
1312 #[test]
1313 fn normalize_scoped_package_with_css_extension_stays_bare() {
1314 assert_eq!(
1315 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
1316 "@fontsource/monaspace-neon/400.css"
1317 );
1318 }
1319
1320 #[test]
1321 fn normalize_scoped_package_with_scss_extension_stays_bare() {
1322 assert_eq!(
1323 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
1324 "@company/design-system/tokens.scss"
1325 );
1326 }
1327
1328 #[test]
1329 fn normalize_scoped_package_without_extension_stays_bare() {
1330 assert_eq!(
1331 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
1332 "@fallow/design-system/styles"
1333 );
1334 }
1335
1336 #[test]
1337 fn normalize_scoped_package_extensionless_scss_stays_bare() {
1338 assert_eq!(
1339 normalize_css_import_path("@company/tokens".to_string(), true),
1340 "@company/tokens"
1341 );
1342 }
1343
1344 #[test]
1345 fn normalize_path_alias_with_css_extension_stays_bare() {
1346 assert_eq!(
1347 normalize_css_import_path("@/components/Button.css".to_string(), false),
1348 "@/components/Button.css"
1349 );
1350 }
1351
1352 #[test]
1353 fn normalize_path_alias_extensionless_stays_bare() {
1354 assert_eq!(
1355 normalize_css_import_path("@/styles/variables".to_string(), false),
1356 "@/styles/variables"
1357 );
1358 }
1359
1360 #[test]
1361 fn strip_css_no_comments() {
1362 let source = ".foo { color: red; }";
1363 assert_eq!(strip_css_comments(source, false), source);
1364 }
1365
1366 #[test]
1367 fn strip_css_multiple_block_comments() {
1368 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
1369 let result = strip_css_comments(source, false);
1370 assert!(!result.contains("comment-one"));
1371 assert!(!result.contains("comment-two"));
1372 assert!(result.contains(".foo"));
1373 assert!(result.contains(".bar"));
1374 }
1375
1376 #[test]
1377 fn strip_scss_does_not_affect_non_scss() {
1378 let source = "// this stays\n.foo { }";
1379 let result = strip_css_comments(source, false);
1380 assert!(result.contains("// this stays"));
1381 }
1382
1383 #[test]
1384 fn css_module_parses_suppressions() {
1385 let info = parse_css_to_module(
1386 fallow_types::discover::FileId(0),
1387 Path::new("Component.module.css"),
1388 "/* fallow-ignore-file */\n.btn { color: red; }",
1389 0,
1390 );
1391 assert!(!info.suppressions.is_empty());
1392 assert_eq!(info.suppressions[0].line, 0);
1393 }
1394
1395 #[test]
1396 fn extracts_class_starting_with_underscore() {
1397 let names = export_names("._private { } .__dunder { }");
1398 assert!(names.contains(&"_private".to_string()));
1399 assert!(names.contains(&"__dunder".to_string()));
1400 }
1401
1402 #[test]
1403 fn ignores_id_selectors() {
1404 let names = export_names("#myId { color: red; }");
1405 assert!(!names.contains(&"myId".to_string()));
1406 }
1407
1408 #[test]
1409 fn ignores_element_selectors() {
1410 let names = export_names("div { color: red; } span { }");
1411 assert!(names.is_empty());
1412 }
1413
1414 #[test]
1415 fn extract_css_imports_at_import_quoted() {
1416 let imports = extract_css_imports(r#"@import "./reset.css";"#, false);
1417 assert_eq!(imports, vec!["./reset.css"]);
1418 }
1419
1420 #[test]
1421 fn extract_css_imports_package_subpath_stays_bare() {
1422 let imports =
1423 extract_css_imports(r#"@import "tailwindcss/theme.css" layer(theme);"#, false);
1424 assert_eq!(imports, vec!["tailwindcss/theme.css"]);
1425 }
1426
1427 #[test]
1428 fn extract_css_imports_at_import_url() {
1429 let imports = extract_css_imports(r#"@import url("./reset.css");"#, false);
1430 assert_eq!(imports, vec!["./reset.css"]);
1431 }
1432
1433 #[test]
1434 fn extract_css_imports_skips_remote_urls() {
1435 let imports =
1436 extract_css_imports(r#"@import "https://fonts.example.com/font.css";"#, false);
1437 assert!(imports.is_empty());
1438 }
1439
1440 #[test]
1441 fn extract_css_imports_scss_use_normalizes_partial() {
1442 let imports = extract_css_imports(r#"@use "variables";"#, true);
1443 assert_eq!(imports, vec!["./variables"]);
1444 }
1445
1446 #[test]
1447 fn extract_css_imports_scss_forward_normalizes_partial() {
1448 let imports = extract_css_imports(r#"@forward "tokens";"#, true);
1449 assert_eq!(imports, vec!["./tokens"]);
1450 }
1451
1452 #[test]
1453 fn extract_css_imports_skips_comments() {
1454 let imports = extract_css_imports(
1455 r#"/* @import "./hidden.scss"; */
1456@use "real";"#,
1457 true,
1458 );
1459 assert_eq!(imports, vec!["./real"]);
1460 }
1461
1462 #[test]
1463 fn extract_css_imports_at_plugin_keeps_package_bare() {
1464 let imports = extract_css_imports(r#"@plugin "daisyui";"#, true);
1465 assert_eq!(imports, vec!["daisyui"]);
1466 }
1467
1468 #[test]
1469 fn extract_css_imports_at_plugin_tracks_relative_file() {
1470 let imports = extract_css_imports(r#"@plugin "./tailwind-plugin.js";"#, false);
1471 assert_eq!(imports, vec!["./tailwind-plugin.js"]);
1472 }
1473
1474 #[test]
1475 fn extract_css_imports_scss_at_import_kept_relative() {
1476 let imports = extract_css_imports(r"@import 'Foo';", true);
1477 assert_eq!(imports, vec!["./Foo"]);
1478 }
1479
1480 #[test]
1481 fn extract_css_imports_additional_data_string_body() {
1482 let body = r#"@use "./src/styles/global.scss";"#;
1483 let imports = extract_css_imports(body, true);
1484 assert_eq!(imports, vec!["./src/styles/global.scss"]);
1485 }
1486
1487 #[test]
1488 fn mask_with_whitespace_preserves_byte_length() {
1489 let src = "/* hello */ .foo { }";
1490 let masked = mask_with_whitespace(src, &CSS_COMMENT_RE);
1491 assert_eq!(masked.len(), src.len());
1492 assert!(masked.is_char_boundary(src.len()));
1493 }
1494
1495 #[test]
1496 fn mask_with_whitespace_preserves_offsets_around_multibyte() {
1497 let src = "/* \u{2713} */ .foo { }";
1498 let foo_offset = src.find(".foo").expect("`.foo` present");
1499 let masked = mask_with_whitespace(src, &CSS_COMMENT_RE);
1500 assert_eq!(masked.len(), src.len());
1501 assert_eq!(masked.find(".foo"), Some(foo_offset));
1502 }
1503
1504 fn span_line_col(source: &str, start: u32) -> (u32, u32) {
1507 let offsets = fallow_types::extract::compute_line_offsets(source);
1508 fallow_types::extract::byte_offset_to_line_col(&offsets, start)
1509 }
1510
1511 #[test]
1512 fn span_points_at_real_class_declaration_line() {
1513 let source = "\n\n\n\n.foo { color: red; }\n";
1514 let exports = extract_css_module_exports(source, false);
1515 assert_eq!(exports.len(), 1);
1516 let span = exports[0].span;
1517 let (line, col) = span_line_col(source, span.start);
1518 assert_eq!(line, 5, "`.foo` on line 5 must produce line 5, not line 1");
1519 assert_eq!(
1520 col, 1,
1521 "column points at `f` in `.foo` (post-dot identifier)"
1522 );
1523 assert_eq!(
1524 &source[span.start as usize..span.end as usize],
1525 "foo",
1526 "span range must slice to the class identifier in the original source"
1527 );
1528 }
1529
1530 #[test]
1531 fn span_survives_multibyte_comment_prefix() {
1532 let source = "/* \u{2713} */\n.foo { }";
1533 let exports = extract_css_module_exports(source, false);
1534 assert_eq!(exports.len(), 1);
1535 let span = exports[0].span;
1536 assert!(
1537 source.is_char_boundary(span.start as usize),
1538 "span.start must lie on a UTF-8 char boundary"
1539 );
1540 assert_eq!(&source[span.start as usize..span.end as usize], "foo");
1541 }
1542
1543 #[test]
1544 fn span_skips_at_layer_prelude_dot_segments() {
1545 let source = "@layer foo.bar { }\n.root { }\n";
1546 let exports = extract_css_module_exports(source, false);
1547 let names: Vec<_> = exports
1548 .iter()
1549 .filter_map(|e| match &e.name {
1550 ExportName::Named(n) => Some(n.as_str()),
1551 ExportName::Default => None,
1552 })
1553 .collect();
1554 assert_eq!(names, vec!["root"], "@layer sub-segments must not export");
1555 let span = exports[0].span;
1556 let (line, _col) = span_line_col(source, span.start);
1557 assert_eq!(line, 2, "`.root` lives on line 2 of the original source");
1558 assert_eq!(&source[span.start as usize..span.end as usize], "root");
1559 }
1560
1561 #[test]
1562 fn span_skips_classes_in_strings() {
1563 let source = ".real { content: \".fake\"; }\n.also-real { }\n";
1564 let exports = extract_css_module_exports(source, false);
1565 let names: Vec<_> = exports
1566 .iter()
1567 .filter_map(|e| match &e.name {
1568 ExportName::Named(n) => Some(n.as_str()),
1569 ExportName::Default => None,
1570 })
1571 .collect();
1572 assert_eq!(names, vec!["real", "also-real"]);
1573 for export in &exports {
1574 let span = export.span;
1575 let slice = &source[span.start as usize..span.end as usize];
1576 match &export.name {
1577 ExportName::Named(n) => assert_eq!(slice, n.as_str()),
1578 ExportName::Default => unreachable!("CSS modules emit only named exports"),
1579 }
1580 }
1581 }
1582
1583 #[test]
1584 fn span_deduplicates_to_first_occurrence() {
1585 let source = ".btn { color: red; }\n.btn { color: blue; }\n";
1586 let exports = extract_css_module_exports(source, false);
1587 assert_eq!(exports.len(), 1);
1588 let (line, _col) = span_line_col(source, exports[0].span.start);
1589 assert_eq!(
1590 line, 1,
1591 "first occurrence wins for deduplicated class names"
1592 );
1593 }
1594
1595 #[test]
1596 fn span_inside_media_query() {
1597 let source =
1598 "@media (max-width: 768px) {\n .mobile { display: block; }\n .desktop { }\n}\n";
1599 let exports = extract_css_module_exports(source, false);
1600 let by_name: rustc_hash::FxHashMap<&str, oxc_span::Span> = exports
1601 .iter()
1602 .filter_map(|e| match &e.name {
1603 ExportName::Named(n) => Some((n.as_str(), e.span)),
1604 ExportName::Default => None,
1605 })
1606 .collect();
1607 let mobile_line = span_line_col(source, by_name["mobile"].start).0;
1608 let desktop_line = span_line_col(source, by_name["desktop"].start).0;
1609 assert_eq!(mobile_line, 2);
1610 assert_eq!(desktop_line, 3);
1611 }
1612
1613 #[test]
1614 fn at_layer_only_module_emits_no_exports() {
1615 let exports = extract_css_module_exports("@layer foo.bar, foo.baz;\n", false);
1616 assert!(exports.is_empty());
1617 }
1618
1619 #[test]
1620 fn parse_css_to_module_resolves_real_line_offsets() {
1621 let source = "\n\n\n\n.foo { color: red; }\n";
1622 let info = parse_css_to_module(
1623 fallow_types::discover::FileId(0),
1624 Path::new("Component.module.css"),
1625 source,
1626 0,
1627 );
1628 assert_eq!(info.exports.len(), 1);
1629 let (line, _col) = fallow_types::extract::byte_offset_to_line_col(
1630 &info.line_offsets,
1631 info.exports[0].span.start,
1632 );
1633 assert_eq!(line, 5, "downstream line must equal the source line");
1634 }
1635
1636 fn theme_token_names(source: &str) -> Vec<String> {
1637 scan_theme_blocks(source)
1638 .tokens
1639 .into_iter()
1640 .map(|t| t.name)
1641 .collect()
1642 }
1643
1644 #[test]
1645 fn theme_single_block_collects_tokens() {
1646 let names = theme_token_names("@theme { --color-brand: #f00; --radius-card: 8px; }");
1647 assert_eq!(names, vec!["color-brand", "radius-card"]);
1648 }
1649
1650 #[test]
1651 fn theme_dashed_multi_segment_names() {
1652 let names = theme_token_names(
1653 "@theme {\n --font-weight-heavy: 900;\n --inset-shadow-glow: 0 0 4px red;\n}",
1654 );
1655 assert_eq!(names, vec!["font-weight-heavy", "inset-shadow-glow"]);
1656 }
1657
1658 #[test]
1659 fn theme_inline_and_static_modifiers() {
1660 assert_eq!(
1661 theme_token_names("@theme inline { --color-a: red; }"),
1662 vec!["color-a"]
1663 );
1664 assert_eq!(
1665 theme_token_names("@theme static { --color-b: red; }"),
1666 vec!["color-b"]
1667 );
1668 }
1669
1670 #[test]
1671 fn theme_multiple_blocks_union() {
1672 let names = theme_token_names(
1673 "@theme { --color-a: red; }\n.x { color: blue; }\n@theme { --spacing-gutter: 1rem; }",
1674 );
1675 assert_eq!(names, vec!["color-a", "spacing-gutter"]);
1676 }
1677
1678 #[test]
1679 fn theme_reset_form_excluded() {
1680 let names = theme_token_names("@theme { --color-*: initial; --color-brand: red; }");
1682 assert_eq!(names, vec!["color-brand"]);
1683 }
1684
1685 #[test]
1686 fn theme_no_block_yields_nothing() {
1687 assert!(theme_token_names(".x { --color-brand: red; }").is_empty());
1688 }
1689
1690 #[test]
1691 fn theme_line_numbers() {
1692 let scan = scan_theme_blocks("@theme {\n --color-a: red;\n --radius-b: 4px;\n}");
1693 assert_eq!(scan.tokens[0].line, 2);
1694 assert_eq!(scan.tokens[1].line, 3);
1695 }
1696
1697 #[test]
1698 fn theme_token_backs_token_via_var() {
1699 let scan = scan_theme_blocks(
1700 "@theme {\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}",
1701 );
1702 assert!(scan.theme_var_reads.contains(&"color-brand".to_string()));
1703 }
1704
1705 #[test]
1706 fn theme_string_braces_do_not_truncate_block() {
1707 let scan = scan_theme_blocks(
1708 "@theme {\n --font-label: \"}\";\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}",
1709 );
1710 assert_eq!(
1711 scan.tokens
1712 .iter()
1713 .map(|token| token.name.as_str())
1714 .collect::<Vec<_>>(),
1715 vec!["font-label", "color-brand", "color-button"]
1716 );
1717 assert!(scan.theme_var_reads.contains(&"color-brand".to_string()));
1718 }
1719
1720 #[test]
1721 fn theme_nested_keyframes_body_not_collected() {
1722 let names = theme_token_names(
1725 "@theme {\n --animate-spin: spin 1s linear infinite;\n @keyframes spin { from { --x: 0; } to { --y: 1; } }\n}",
1726 );
1727 assert_eq!(names, vec!["animate-spin"]);
1728 }
1729
1730 #[test]
1731 fn theme_comment_block_ignored() {
1732 let names = theme_token_names("/* @theme { --color-fake: red; } */ .x { color: blue; }");
1733 assert!(names.is_empty(), "got {names:?}");
1734 }
1735
1736 #[test]
1737 fn theme_deduplicates_repeated_token() {
1738 let names = theme_token_names("@theme { --color-a: red; --color-a: blue; }");
1739 assert_eq!(names, vec!["color-a"]);
1740 }
1741
1742 #[test]
1743 fn apply_tokens_basic() {
1744 let tokens = extract_apply_tokens(".panel { @apply rounded-card font-bold; }");
1745 assert_eq!(tokens, vec!["rounded-card", "font-bold"]);
1746 }
1747
1748 #[test]
1749 fn apply_tokens_strips_important() {
1750 let tokens = extract_apply_tokens(".x { @apply text-brand! font-bold !important; }");
1751 assert_eq!(tokens, vec!["text-brand", "font-bold"]);
1752 }
1753
1754 #[test]
1755 fn apply_tokens_ignored_in_comments() {
1756 let tokens = extract_apply_tokens("/* @apply hidden-token; */ .x { color: red; }");
1757 assert!(tokens.is_empty(), "got {tokens:?}");
1758 }
1759}