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