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)]
171thread_local! {
172 static COMMENT_MASK_PASSES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
174 static LINE_LOOKUP_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
177}
178
179#[cfg(test)]
180fn note_line_lookup_bytes(bytes: usize) {
181 LINE_LOOKUP_BYTES.with(|read| read.set(read.get() + bytes));
182}
183
184thread_local! {
185 static COMMENT_MASKED_BYTES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
189}
190
191pub(crate) fn take_comment_masked_bytes() -> u64 {
194 COMMENT_MASKED_BYTES.with(|masked| masked.replace(0))
195}
196
197fn mask_css_comments(source: &str, is_scss: bool) -> String {
198 #[cfg(test)]
199 COMMENT_MASK_PASSES.with(|passes| passes.set(passes.get() + 1));
200 COMMENT_MASKED_BYTES.with(|masked| masked.set(masked.get() + source.len() as u64));
201 let mut masked = mask_with_whitespace(source, &CSS_COMMENT_RE);
202 if is_scss {
203 masked = mask_with_whitespace(&masked, &SCSS_LINE_COMMENT_RE);
204 }
205 masked
206}
207
208fn normalize_css_plugin_path(path: String) -> String {
214 path
215}
216
217#[must_use]
227pub fn extract_css_import_sources(source: &str, is_scss: bool) -> Vec<CssImportSource> {
228 import_sources_from_masked(&mask_css_comments(source, is_scss), is_scss)
229}
230
231fn import_sources_from_masked(stripped: &str, is_scss: bool) -> Vec<CssImportSource> {
234 let mut out = Vec::new();
235
236 for cap in CSS_IMPORT_RE.captures_iter(stripped) {
237 let raw = cap.get(1).or_else(|| cap.get(2)).or_else(|| cap.get(3));
238 if let Some(m) = raw {
239 let (src, span) = trimmed_match_with_span(m);
240 if !src.is_empty() && !is_css_url_import(&src) {
241 out.push(CssImportSource {
242 normalized: normalize_css_import_path(src.clone(), is_scss),
243 raw: src,
244 is_plugin: false,
245 span,
246 });
247 }
248 }
249 }
250
251 if is_scss {
252 for cap in SCSS_USE_RE.captures_iter(stripped) {
253 if let Some(m) = cap.get(1) {
254 let (raw, span) = trimmed_match_with_span(m);
255 out.push(CssImportSource {
256 normalized: normalize_css_import_path(raw.clone(), true),
257 raw,
258 is_plugin: false,
259 span,
260 });
261 }
262 }
263 }
264
265 for cap in CSS_PLUGIN_RE.captures_iter(stripped) {
266 if let Some(m) = cap.get(1) {
267 let (raw, span) = trimmed_match_with_span(m);
268 if !raw.is_empty() && !is_css_url_import(&raw) {
269 out.push(CssImportSource {
270 normalized: normalize_css_plugin_path(raw.clone()),
271 raw,
272 is_plugin: true,
273 span,
274 });
275 }
276 }
277 }
278
279 out
280}
281
282fn trimmed_match_with_span(m: regex::Match<'_>) -> (String, Span) {
283 let raw = m.as_str();
284 let trimmed_start = raw.len() - raw.trim_start().len();
285 let trimmed_end = raw.trim_end().len();
286 let start = m.start() + trimmed_start;
287 let end = m.start() + trimmed_end;
288 (raw.trim().to_string(), Span::new(start as u32, end as u32))
289}
290
291#[must_use]
298pub fn extract_css_imports(source: &str, is_scss: bool) -> Vec<String> {
299 extract_css_import_sources(source, is_scss)
300 .into_iter()
301 .map(|source| source.normalized)
302 .collect()
303}
304
305static CSS_THEME_OPEN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
309 crate::static_regex(r"@theme(?:\s+(?:inline|static|reference|default))*\s*\{")
310});
311
312static CSS_VAR_REF_RE: LazyLock<regex::Regex> =
316 LazyLock::new(|| crate::static_regex(r"var\(\s*--([A-Za-z0-9_-]+)"));
317
318#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct ThemeTokenDef {
322 pub name: String,
324 pub value: String,
327 pub line: u32,
329}
330
331#[derive(Debug, Clone, Default, PartialEq, Eq)]
333pub struct ThemeScan {
334 pub tokens: Vec<ThemeTokenDef>,
338 pub theme_var_reads: Vec<(String, u32)>,
345}
346
347#[must_use]
357pub fn scan_theme_blocks(source: &str) -> ThemeScan {
358 if !source.contains("@theme") {
360 return ThemeScan::default();
361 }
362 theme_scan_from_masked(source, &mask_theme_source(source))
363}
364
365fn theme_scan_from_masked(source: &str, masked: &str) -> ThemeScan {
367 let mut out = ThemeScan::default();
368 let mut seen: FxHashSet<String> = FxHashSet::default();
369 for open in CSS_THEME_OPEN_RE.find_iter(masked) {
370 let body_start = open.end();
371 let body_end = find_theme_body_end(masked, body_start);
372 collect_theme_declarations(&mut ThemeDeclarationScan {
373 source,
374 masked,
375 start: body_start,
376 end: body_end,
377 out: &mut out.tokens,
378 seen: &mut seen,
379 });
380 collect_theme_var_reads(
381 source,
382 masked,
383 body_start,
384 body_end,
385 &mut out.theme_var_reads,
386 );
387 }
388 out
389}
390
391#[must_use]
398pub fn extract_css_var_reads_located(source: &str) -> Vec<(String, u32)> {
399 if !source.contains("var(") {
400 return Vec::new();
401 }
402 css_var_reads_from_masked(source, &mask_theme_source(source))
403}
404
405fn css_var_reads_from_masked(source: &str, masked: &str) -> Vec<(String, u32)> {
408 let mut theme_bodies: Vec<(usize, usize)> = Vec::new();
411 if masked.contains("@theme") {
412 for open in CSS_THEME_OPEN_RE.find_iter(masked) {
413 let body_start = open.end();
414 let body_end = find_theme_body_end(masked, body_start);
415 theme_bodies.push((body_start, body_end));
416 }
417 }
418 let in_theme = |offset: usize| theme_bodies.iter().any(|&(s, e)| offset >= s && offset < e);
419 let mut out = Vec::new();
420 let mut last_pos = 0usize;
426 let mut last_line = 1u32;
427 for cap in CSS_VAR_REF_RE.captures_iter(masked) {
428 let (Some(whole), Some(name)) = (cap.get(0), cap.get(1)) else {
429 continue;
430 };
431 if in_theme(whole.start()) {
432 continue;
433 }
434 let offset = whole.start();
435 last_line = last_line.saturating_add(newlines_between(source, last_pos, offset));
436 last_pos = offset;
437 out.push((name.as_str().to_owned(), last_line));
438 }
439 out
440}
441
442fn mask_theme_source(source: &str) -> String {
445 mask_with_whitespace(&mask_css_comments(source, false), &CSS_NON_SELECTOR_RE)
446}
447
448fn find_theme_body_end(masked: &str, body_start: usize) -> usize {
450 let bytes = masked.as_bytes();
451 let mut depth = 1usize;
452 let mut i = body_start;
453 while i < bytes.len() {
454 match bytes[i] {
455 b'{' => depth += 1,
456 b'}' => {
457 depth -= 1;
458 if depth == 0 {
459 break;
460 }
461 }
462 _ => {}
463 }
464 i += 1;
465 }
466 i.min(bytes.len())
467}
468
469fn collect_theme_var_reads(
470 source: &str,
471 masked: &str,
472 body_start: usize,
473 body_end: usize,
474 out: &mut Vec<(String, u32)>,
475) {
476 let Some(body) = masked.get(body_start..body_end) else {
477 return;
478 };
479 let mut last_pos = 0usize;
484 let mut last_line = 1u32;
485 for cap in CSS_VAR_REF_RE.captures_iter(body) {
486 let (Some(whole), Some(name)) = (cap.get(0), cap.get(1)) else {
487 continue;
488 };
489 let offset = body_start + whole.start();
492 last_line = last_line.saturating_add(newlines_between(source, last_pos, offset));
493 last_pos = offset;
494 out.push((name.as_str().to_owned(), last_line));
495 }
496}
497
498#[cfg(test)]
502fn line_at_offset(source: &str, offset: usize) -> u32 {
503 note_line_lookup_bytes(offset);
504 let count = source
505 .get(..offset)
506 .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
507 u32::try_from(1 + count).unwrap_or(u32::MAX)
508}
509
510fn newlines_between(source: &str, from: usize, to: usize) -> u32 {
516 #[cfg(test)]
517 note_line_lookup_bytes(to.saturating_sub(from));
518 let count = source
519 .get(from..to)
520 .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
521 u32::try_from(count).unwrap_or(u32::MAX)
522}
523
524fn collect_theme_declarations(scan: &mut ThemeDeclarationScan<'_, '_>) {
530 let bytes = scan.masked.as_bytes();
531 let mut depth = 0usize;
532 let mut expect_decl = true;
533 let mut i = scan.start;
534 while i < scan.end {
535 let b = bytes[i];
536 match b {
537 b'{' => {
538 depth += 1;
539 expect_decl = false;
540 i += 1;
541 }
542 b'}' => {
543 depth = depth.saturating_sub(1);
544 if depth == 0 {
545 expect_decl = true;
546 }
547 i += 1;
548 }
549 b';' => {
550 if depth == 0 {
551 expect_decl = true;
552 }
553 i += 1;
554 }
555 _ if b.is_ascii_whitespace() => i += 1,
556 _ => {
557 if depth == 0 && expect_decl {
558 expect_decl = false;
559 i = scan_theme_declaration(scan, b, i);
560 } else {
561 i += 1;
562 }
563 }
564 }
565 }
566}
567
568struct ThemeDeclarationScan<'a, 'b> {
569 source: &'a str,
570 masked: &'a str,
571 start: usize,
572 end: usize,
573 out: &'b mut Vec<ThemeTokenDef>,
574 seen: &'b mut FxHashSet<String>,
575}
576
577fn scan_theme_declaration(scan: &mut ThemeDeclarationScan<'_, '_>, b: u8, i: usize) -> usize {
581 let bytes = scan.masked.as_bytes();
582 if !(b == b'-' && bytes.get(i + 1) == Some(&b'-')) {
583 return i + 1;
584 }
585 let id_start = i;
586 let mut j = i;
587 while j < scan.end {
588 let c = bytes[j];
589 if c == b'-' || c == b'_' || c.is_ascii_alphanumeric() {
590 j += 1;
591 } else {
592 break;
593 }
594 }
595 let mut k = j;
596 while k < scan.end && bytes[k].is_ascii_whitespace() {
597 k += 1;
598 }
599 if k < scan.end && bytes[k] == b':' {
601 let name = &scan.masked[id_start + 2..j];
602 if !name.is_empty() && scan.seen.insert(name.to_owned()) {
603 let value = theme_declaration_value(scan.source, scan.masked, k + 1, scan.end);
604 let line = 1 + scan
605 .source
606 .get(..id_start)
607 .map_or(0, |s| s.bytes().filter(|&x| x == b'\n').count());
608 scan.out.push(ThemeTokenDef {
609 name: name.to_owned(),
610 value,
611 line: u32::try_from(line).unwrap_or(u32::MAX),
612 });
613 }
614 }
615 j
616}
617
618fn theme_declaration_value(source: &str, masked: &str, start: usize, end: usize) -> String {
619 let bytes = masked.as_bytes();
620 let mut depth = 0usize;
621 let mut i = start;
622 while i < end {
623 match bytes[i] {
624 b'{' => depth += 1,
625 b'}' => {
626 if depth == 0 {
627 break;
628 }
629 depth -= 1;
630 }
631 b';' if depth == 0 => break,
632 _ => {}
633 }
634 i += 1;
635 }
636 source
637 .get(start..i)
638 .unwrap_or_default()
639 .split_whitespace()
640 .collect::<Vec<_>>()
641 .join(" ")
642}
643
644#[must_use]
650pub fn extract_apply_tokens(source: &str) -> Vec<String> {
651 extract_apply_tokens_located(source)
652 .into_iter()
653 .map(|(token, _line)| token)
654 .collect()
655}
656
657#[must_use]
662pub fn extract_apply_tokens_located(source: &str) -> Vec<(String, u32)> {
663 if !source.contains("@apply") {
665 return Vec::new();
666 }
667 apply_tokens_from_masked(source, &mask_theme_source(source))
668}
669
670fn apply_tokens_from_masked(source: &str, masked: &str) -> Vec<(String, u32)> {
673 let mut out = Vec::new();
674 let mut last_pos = 0usize;
677 let mut line = 1u32;
678 for m in CSS_APPLY_RE.find_iter(masked) {
679 line = line.saturating_add(newlines_between(source, last_pos, m.start()));
680 last_pos = m.start();
681 let body = m.as_str().trim_start_matches("@apply");
682 for token in body.split_whitespace() {
683 let token = token.trim_matches('!');
684 if token.is_empty() || token == "important" {
685 continue;
686 }
687 out.push((token.to_owned(), line));
688 }
689 }
690 out
691}
692
693#[derive(Debug, Clone, Default, PartialEq, Eq)]
696pub struct StylesheetTokens {
697 pub theme: ThemeScan,
699 pub apply_tokens_located: Vec<(String, u32)>,
701 pub css_var_reads_located: Vec<(String, u32)>,
703}
704
705#[must_use]
710pub fn scan_stylesheet_tokens(source: &str) -> StylesheetTokens {
711 let has_theme = source.contains("@theme");
712 let has_apply = source.contains("@apply");
713 let has_var = source.contains("var(");
714 if !(has_theme || has_apply || has_var) {
715 return StylesheetTokens::default();
716 }
717 let masked = mask_theme_source(source);
718 StylesheetTokens {
719 theme: if has_theme {
720 theme_scan_from_masked(source, &masked)
721 } else {
722 ThemeScan::default()
723 },
724 apply_tokens_located: if has_apply {
725 apply_tokens_from_masked(source, &masked)
726 } else {
727 Vec::new()
728 },
729 css_var_reads_located: if has_var {
730 css_var_reads_from_masked(source, &masked)
731 } else {
732 Vec::new()
733 },
734 }
735}
736
737fn mask_with_whitespace(src: &str, re: ®ex::Regex) -> String {
747 let mut out = String::with_capacity(src.len());
748 let mut cursor = 0;
749 for m in re.find_iter(src) {
750 out.push_str(&src[cursor..m.start()]);
751 for _ in m.start()..m.end() {
752 out.push(' ');
753 }
754 cursor = m.end();
755 }
756 out.push_str(&src[cursor..]);
757 out
758}
759
760fn lightningcss_class_set(source: &str) -> Option<FxHashSet<String>> {
777 let source = blank_bare_css_module_scope_keywords(source);
782 let options = ParserOptions {
783 error_recovery: true,
786 css_modules: Some(lightningcss::css_modules::Config::default()),
792 ..ParserOptions::default()
793 };
794 let stylesheet = StyleSheet::parse(&source, options).ok()?;
795 let mut classes = FxHashSet::default();
796 collect_classes_from_rules(&stylesheet.rules.0, &mut classes);
797 Some(classes)
798}
799
800fn collect_classes_from_rules(rules: &[CssRule<'_>], classes: &mut FxHashSet<String>) {
805 for rule in rules {
806 match rule {
807 CssRule::Style(style) => {
808 collect_classes_from_selector_list(&style.selectors, classes);
809 collect_classes_from_rules(&style.rules.0, classes);
810 }
811 CssRule::Media(rule) => collect_classes_from_rules(&rule.rules.0, classes),
812 CssRule::Supports(rule) => collect_classes_from_rules(&rule.rules.0, classes),
813 CssRule::Container(rule) => collect_classes_from_rules(&rule.rules.0, classes),
814 CssRule::LayerBlock(rule) => collect_classes_from_rules(&rule.rules.0, classes),
815 CssRule::MozDocument(rule) => collect_classes_from_rules(&rule.rules.0, classes),
816 CssRule::StartingStyle(rule) => collect_classes_from_rules(&rule.rules.0, classes),
817 CssRule::Nesting(rule) => {
818 collect_classes_from_selector_list(&rule.style.selectors, classes);
819 collect_classes_from_rules(&rule.style.rules.0, classes);
820 }
821 CssRule::Scope(rule) => {
822 if let Some(scope_start) = &rule.scope_start {
823 collect_classes_from_selector_list(scope_start, classes);
824 }
825 if let Some(scope_end) = &rule.scope_end {
826 collect_classes_from_selector_list(scope_end, classes);
827 }
828 collect_classes_from_rules(&rule.rules.0, classes);
829 }
830 _ => {}
831 }
832 }
833}
834
835fn collect_classes_from_selector_list(list: &SelectorList<'_>, classes: &mut FxHashSet<String>) {
836 for selector in &list.0 {
837 collect_classes_from_selector(selector, classes);
838 }
839}
840
841fn collect_classes_from_selector(selector: &Selector<'_>, classes: &mut FxHashSet<String>) {
842 for component in selector.iter_raw_match_order() {
843 match component {
844 Component::Class(name) => {
845 classes.insert(name.0.to_string());
846 }
847 Component::Is(list)
848 | Component::Where(list)
849 | Component::Has(list)
850 | Component::Negation(list)
851 | Component::Any(_, list) => {
852 for nested in list.as_ref() {
853 collect_classes_from_selector(nested, classes);
854 }
855 }
856 Component::Slotted(nested) | Component::Host(Some(nested)) => {
857 collect_classes_from_selector(nested, classes);
858 }
859 Component::NthOf(data) => {
860 for nested in data.selectors() {
861 collect_classes_from_selector(nested, classes);
862 }
863 }
864 Component::NonTSPseudoClass(PseudoClass::Local { selector }) => {
867 collect_classes_from_selector(selector, classes);
868 }
869 _ => {}
870 }
871 }
872}
873
874pub fn extract_css_module_exports(source: &str, is_scss: bool) -> Vec<ExportInfo> {
884 css_module_exports(source, &mask_css_comments(source, is_scss), is_scss)
885}
886
887fn css_module_exports(source: &str, comment_masked: &str, is_scss: bool) -> Vec<ExportInfo> {
891 if !is_scss && let Some(class_set) = lightningcss_class_set(source) {
892 return scan_css_module_exports(comment_masked, Some(&class_set));
893 }
894 scan_css_module_exports(comment_masked, None)
895}
896
897fn scan_css_module_exports(
908 comment_masked: &str,
909 class_filter: Option<&FxHashSet<String>>,
910) -> Vec<ExportInfo> {
911 let masked = mask_css_module_class_candidates(comment_masked, class_filter.is_some());
912 let mut seen = FxHashSet::default();
913 let mut exports = Vec::new();
914 for cap in CSS_CLASS_RE.captures_iter(&masked) {
915 if let Some(m) = cap.get(1) {
916 push_css_class_export(m, class_filter, &mut seen, &mut exports);
917 }
918 }
919 exports
920}
921
922fn mask_css_module_class_candidates(comment_masked: &str, has_class_filter: bool) -> String {
923 let mut masked = mask_with_whitespace(comment_masked, &CSS_NON_SELECTOR_RE);
924 if !has_class_filter {
925 masked = mask_with_whitespace(&masked, &CSS_AT_RULE_PRELUDE_RE);
926 }
927 mask_css_module_global_scopes(masked)
928}
929
930const GLOBAL_KEYWORD: &str = ":global";
931const LOCAL_KEYWORD: &str = ":local";
932
933#[derive(Clone, Copy)]
935struct ScopeKeyword {
936 is_global: bool,
937 len: usize,
938 has_argument: bool,
940}
941
942fn scope_keyword_at(bytes: &[u8], i: usize) -> Option<ScopeKeyword> {
945 let rest = &bytes[i..];
946 let (is_global, len) = if rest.starts_with(GLOBAL_KEYWORD.as_bytes()) {
947 (true, GLOBAL_KEYWORD.len())
948 } else if rest.starts_with(LOCAL_KEYWORD.as_bytes()) {
949 (false, LOCAL_KEYWORD.len())
950 } else {
951 return None;
952 };
953 let next = rest.get(len).copied();
954 if next.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
955 return None;
956 }
957 Some(ScopeKeyword {
958 is_global,
959 len,
960 has_argument: next == Some(b'('),
961 })
962}
963
964fn blank_bare_css_module_scope_keywords(source: &str) -> std::borrow::Cow<'_, str> {
967 if !source.contains(GLOBAL_KEYWORD) && !source.contains(LOCAL_KEYWORD) {
968 return std::borrow::Cow::Borrowed(source);
969 }
970 let bytes = source.as_bytes();
971 let mut out = source.to_string();
972 let mut i = 0;
973 while i < bytes.len() {
974 match scope_keyword_at(bytes, i) {
975 Some(keyword) => {
976 if !keyword.has_argument {
977 out.replace_range(i..i + keyword.len, &" ".repeat(keyword.len));
978 }
979 i += keyword.len;
980 }
981 None => i += 1,
982 }
983 }
984 std::borrow::Cow::Owned(out)
985}
986
987#[derive(Clone, Copy, PartialEq, Eq)]
989enum ParenScope {
990 Global,
991 Local,
992 Other,
993}
994
995struct GlobalScopeScan {
997 block_global: Vec<bool>,
999 selector_global: bool,
1001 parens: Vec<ParenScope>,
1002}
1003
1004impl GlobalScopeScan {
1005 fn inherited(&self) -> bool {
1006 self.block_global.last().copied().unwrap_or(false)
1007 }
1008
1009 fn reset_selector(&mut self) {
1012 self.selector_global = self.inherited();
1013 self.parens.clear();
1014 }
1015
1016 fn is_global(&self) -> bool {
1019 self.parens
1020 .iter()
1021 .rev()
1022 .find(|scope| **scope != ParenScope::Other)
1023 .map_or(self.selector_global, |scope| *scope == ParenScope::Global)
1024 }
1025}
1026
1027fn mask_css_module_global_scopes(masked: String) -> String {
1042 if !masked.contains(GLOBAL_KEYWORD) {
1043 return masked;
1044 }
1045 let indented = !masked
1046 .match_indices('{')
1047 .any(|(i, _)| i == 0 || masked.as_bytes()[i - 1] != b'#');
1048 let bytes = masked.as_bytes();
1049 let mut blank = Vec::new();
1050 let mut scan = GlobalScopeScan {
1051 block_global: Vec::new(),
1052 selector_global: false,
1053 parens: Vec::new(),
1054 };
1055 let mut i = 0;
1056 while i < bytes.len() {
1057 if let Some(keyword) = scope_keyword_at(bytes, i) {
1058 if keyword.has_argument {
1059 scan.parens.push(if keyword.is_global {
1060 ParenScope::Global
1061 } else {
1062 ParenScope::Local
1063 });
1064 i += keyword.len + 1;
1065 continue;
1066 }
1067 if scan.parens.is_empty() {
1068 scan.selector_global = keyword.is_global;
1069 }
1070 i += keyword.len;
1071 continue;
1072 }
1073 match bytes[i] {
1074 b'#' if bytes.get(i + 1) == Some(&b'{') => {
1075 i = scss_interpolation_end(bytes, i + 2);
1076 continue;
1077 }
1078 b'(' => scan.parens.push(ParenScope::Other),
1079 b')' => {
1080 scan.parens.pop();
1081 }
1082 b',' if scan.parens.is_empty() => scan.selector_global = scan.inherited(),
1083 b'{' => {
1084 scan.block_global.push(scan.selector_global);
1085 scan.reset_selector();
1086 }
1087 b'}' => {
1088 scan.block_global.pop();
1089 scan.reset_selector();
1090 }
1091 b';' => scan.reset_selector(),
1092 b'\n' if indented => scan.reset_selector(),
1093 b'.' if scan.is_global()
1094 && bytes
1095 .get(i + 1)
1096 .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') =>
1097 {
1098 blank.push(i);
1099 }
1100 _ => {}
1101 }
1102 i += 1;
1103 }
1104 let mut out = masked;
1105 for offset in blank {
1106 out.replace_range(offset..=offset, " ");
1107 }
1108 out
1109}
1110
1111fn scss_interpolation_end(bytes: &[u8], start: usize) -> usize {
1114 let mut depth = 1usize;
1115 let mut i = start;
1116 while i < bytes.len() {
1117 match bytes[i] {
1118 b'{' => depth += 1,
1119 b'}' => {
1120 depth -= 1;
1121 if depth == 0 {
1122 return i + 1;
1123 }
1124 }
1125 _ => {}
1126 }
1127 i += 1;
1128 }
1129 bytes.len()
1130}
1131
1132fn push_css_class_export(
1133 class_match: regex::Match<'_>,
1134 class_filter: Option<&FxHashSet<String>>,
1135 seen: &mut FxHashSet<String>,
1136 exports: &mut Vec<ExportInfo>,
1137) {
1138 let class_name = class_match.as_str().to_string();
1139 if class_filter.is_some_and(|filter| !filter.contains(&class_name)) {
1140 return;
1141 }
1142 if seen.insert(class_name.clone()) {
1143 exports.push(css_class_export(class_name, class_match));
1144 }
1145}
1146
1147fn css_class_export(class_name: String, class_match: regex::Match<'_>) -> ExportInfo {
1148 #[expect(
1149 clippy::cast_possible_truncation,
1150 reason = "CSS files exceeding u32::MAX bytes are not a realistic input"
1151 )]
1152 let span = Span::new(class_match.start() as u32, class_match.end() as u32);
1153 ExportInfo {
1154 name: ExportName::Named(class_name),
1155 local_name: None,
1156 is_type_only: false,
1157 visibility: VisibilityTag::None,
1158 expected_unused_reason: None,
1159 span,
1160 members: Vec::new(),
1161 is_side_effect_used: false,
1162 super_class: None,
1163 deprecated: false,
1164 deprecated_reason: None,
1165 }
1166}
1167
1168fn build_css_imports(stripped: &str, is_scss: bool) -> Vec<ImportInfo> {
1172 let mut imports = Vec::new();
1173
1174 for css_source in import_sources_from_masked(stripped, is_scss) {
1175 imports.push(ImportInfo {
1176 source: css_source.normalized,
1177 imported_name: if css_source.is_plugin {
1178 ImportedName::Default
1179 } else {
1180 ImportedName::SideEffect
1181 },
1182 local_name: String::new(),
1183 is_type_only: false,
1184 is_type_only_star: false,
1185 from_style: false,
1186 span: css_source.span,
1187 source_span: css_source.span,
1188 });
1189 }
1190
1191 let has_apply = CSS_APPLY_RE.is_match(stripped);
1192 let has_tailwind = CSS_TAILWIND_RE.is_match(stripped);
1193 if has_apply || has_tailwind {
1194 imports.push(ImportInfo {
1195 source: "tailwindcss".to_string(),
1196 imported_name: ImportedName::SideEffect,
1197 local_name: String::new(),
1198 is_type_only: false,
1199 is_type_only_star: false,
1200 from_style: false,
1201 span: Span::default(),
1202 source_span: Span::default(),
1203 });
1204 }
1205
1206 imports
1207}
1208
1209pub(crate) fn parse_css_to_module(
1211 file_id: FileId,
1212 path: &Path,
1213 source: &str,
1214 content_hash: u64,
1215) -> ModuleInfo {
1216 let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
1217 let is_scss = path
1218 .extension()
1219 .and_then(|e| e.to_str())
1220 .is_some_and(|ext| matches!(ext, "scss" | "sass" | "less"));
1221
1222 let stripped = mask_css_comments(source, is_scss);
1223 let imports = build_css_imports(&stripped, is_scss);
1224
1225 let exports = if is_css_module_file(path) {
1226 css_module_exports(source, &stripped, is_scss)
1227 } else {
1228 Vec::new()
1229 };
1230
1231 css_module_info(
1232 file_id,
1233 content_hash,
1234 source,
1235 parsed_suppressions,
1236 imports,
1237 exports,
1238 )
1239}
1240
1241fn css_module_info(
1245 file_id: FileId,
1246 content_hash: u64,
1247 source: &str,
1248 parsed_suppressions: crate::suppress::ParsedSuppressions,
1249 imports: Vec<ImportInfo>,
1250 exports: Vec<ExportInfo>,
1251) -> ModuleInfo {
1252 crate::module_info::non_js_module_info(crate::module_info::NonJsModuleInfoInput {
1253 file_id,
1254 content_hash,
1255 source,
1256 parsed_suppressions,
1257 imports,
1258 exports,
1259 })
1260}
1261
1262#[cfg(all(test, not(miri)))]
1263mod tests {
1264 use super::*;
1265
1266 fn export_names(source: &str) -> Vec<String> {
1268 extract_css_module_exports(source, false)
1269 .into_iter()
1270 .filter_map(|e| match e.name {
1271 ExportName::Named(n) => Some(n),
1272 ExportName::Default => None,
1273 })
1274 .collect()
1275 }
1276
1277 #[test]
1278 fn is_css_file_css() {
1279 assert!(is_css_file(Path::new("styles.css")));
1280 }
1281
1282 #[test]
1283 fn is_css_file_scss() {
1284 assert!(is_css_file(Path::new("styles.scss")));
1285 }
1286
1287 #[test]
1288 fn is_css_file_sass() {
1289 assert!(is_css_file(Path::new("styles.sass")));
1290 }
1291
1292 #[test]
1293 fn is_css_file_less() {
1294 assert!(is_css_file(Path::new("styles.less")));
1295 }
1296
1297 #[test]
1298 fn is_css_file_rejects_js() {
1299 assert!(!is_css_file(Path::new("app.js")));
1300 }
1301
1302 #[test]
1303 fn is_css_file_rejects_ts() {
1304 assert!(!is_css_file(Path::new("app.ts")));
1305 }
1306
1307 #[test]
1308 fn is_css_file_rejects_no_extension() {
1309 assert!(!is_css_file(Path::new("Makefile")));
1310 }
1311
1312 #[test]
1313 fn is_css_module_file_module_css() {
1314 assert!(is_css_module_file(Path::new("Component.module.css")));
1315 }
1316
1317 #[test]
1318 fn is_css_module_file_module_scss() {
1319 assert!(is_css_module_file(Path::new("Component.module.scss")));
1320 }
1321
1322 #[test]
1323 fn is_css_module_file_rejects_plain_css() {
1324 assert!(!is_css_module_file(Path::new("styles.css")));
1325 }
1326
1327 #[test]
1328 fn is_css_module_file_rejects_plain_scss() {
1329 assert!(!is_css_module_file(Path::new("styles.scss")));
1330 }
1331
1332 #[test]
1333 fn is_css_module_file_rejects_module_js() {
1334 assert!(!is_css_module_file(Path::new("utils.module.js")));
1335 }
1336
1337 #[test]
1338 fn extracts_single_class() {
1339 let names = export_names(".foo { color: red; }");
1340 assert_eq!(names, vec!["foo"]);
1341 }
1342
1343 #[test]
1344 fn extracts_multiple_classes() {
1345 let names = export_names(".foo { } .bar { }");
1346 assert_eq!(names, vec!["foo", "bar"]);
1347 }
1348
1349 #[test]
1350 fn extracts_nested_classes() {
1351 let names = export_names(".foo .bar { color: red; }");
1352 assert!(names.contains(&"foo".to_string()));
1353 assert!(names.contains(&"bar".to_string()));
1354 }
1355
1356 #[test]
1357 fn extracts_hyphenated_class() {
1358 let names = export_names(".my-class { }");
1359 assert_eq!(names, vec!["my-class"]);
1360 }
1361
1362 #[test]
1363 fn extracts_camel_case_class() {
1364 let names = export_names(".myClass { }");
1365 assert_eq!(names, vec!["myClass"]);
1366 }
1367
1368 fn scss_export_names(source: &str) -> Vec<String> {
1369 extract_css_module_exports(source, true)
1370 .into_iter()
1371 .filter_map(|e| match e.name {
1372 ExportName::Named(n) => Some(n),
1373 ExportName::Default => None,
1374 })
1375 .collect()
1376 }
1377
1378 #[test]
1379 fn skips_class_inside_global_pseudo() {
1380 let names = export_names(":global(.globalClass) { color: red; }");
1382 assert!(names.is_empty(), "got {names:?}");
1383 }
1384
1385 #[test]
1386 fn keeps_local_class_next_to_global_pseudo() {
1387 let names = export_names(".wrapper :global(.selector) { color: red; }");
1389 assert_eq!(names, vec!["wrapper"]);
1390 }
1391
1392 #[test]
1393 fn skips_every_class_inside_global_pseudo_with_descendants() {
1394 let names = export_names(".panel :global(.outer .inner.mod) { color: red; }");
1395 assert_eq!(names, vec!["panel"]);
1396 }
1397
1398 #[test]
1399 fn skips_classes_after_bare_global() {
1400 let names = export_names(".lead :global .first .second { color: red; }");
1401 assert_eq!(names, vec!["lead"]);
1402 }
1403
1404 #[test]
1405 fn bare_global_stops_at_local() {
1406 let names = export_names(":global .g1 :local(.l1) .g2 :local .l2 .l3, .l4 { color: red; }");
1407 assert_eq!(names, vec!["l1", "l2", "l3", "l4"]);
1408 }
1409
1410 #[test]
1411 fn bare_global_stops_at_comma() {
1412 let names = export_names(":global .g1, .l1 :is(.l2, .l3) { color: red; }");
1413 assert_eq!(names, vec!["l1", "l2", "l3"]);
1414 }
1415
1416 #[test]
1417 fn global_class_inside_media_query_is_skipped() {
1418 let names = export_names("@media (min-width: 1px) { .media :global(.g) { color: red; } }");
1419 assert_eq!(names, vec!["media"]);
1420 }
1421
1422 #[test]
1423 fn class_both_local_and_global_stays_exported_at_local_span() {
1424 let source = ":global(.both) { color: red; }\n.both { color: blue; }";
1425 let exports = extract_css_module_exports(source, false);
1426 assert_eq!(exports.len(), 1);
1427 let span = exports[0].span;
1428 assert_eq!(&source[span.start as usize..span.end as usize], "both");
1429 assert!(
1430 span.start > 20,
1431 "span must point at the local rule: {span:?}"
1432 );
1433 }
1434
1435 #[test]
1436 fn composes_keeps_local_classes() {
1437 let names = export_names(
1438 ".base { color: red; }\n.button { composes: base; composes: g from global; }",
1439 );
1440 assert_eq!(names, vec!["base", "button"]);
1441 }
1442
1443 #[test]
1444 fn global_in_strings_and_comments_has_no_effect() {
1445 let names =
1446 export_names("/* :global */ .a { content: \":global\"; }\n.b :global(.c) { }\n.d { }");
1447 assert_eq!(names, vec!["a", "b", "d"]);
1448 }
1449
1450 #[test]
1451 fn scss_skips_global_pseudo_and_global_block() {
1452 let names = scss_export_names(
1453 ".card {\n :global(.g1) { color: red; }\n .title { color: blue; }\n}\n\
1454 :global {\n .g2 { color: red; }\n .g3 { :local(.l1) { color: red; } }\n}\n\
1455 .after { color: blue; }",
1456 );
1457 assert_eq!(names, vec!["card", "title", "l1", "after"]);
1458 }
1459
1460 #[test]
1461 fn scss_bare_global_in_parent_reaches_nested_rules() {
1462 let names = scss_export_names(
1463 ".a :global .b {\n .c { color: red; }\n &:hover { color: blue; }\n}\n.d { }",
1464 );
1465 assert_eq!(names, vec!["a", "d"]);
1466 }
1467
1468 #[test]
1469 fn scss_interpolation_does_not_end_global_scope() {
1470 let names = scss_export_names("$n: x;\n.a :global .b-#{$n} .c { }\n.d { }");
1471 assert_eq!(names, vec!["a", "d"]);
1472 }
1473
1474 #[test]
1475 fn sass_indented_bare_global_ends_at_line_end() {
1476 let names = scss_export_names(".a :global .b\n color: red\n.c\n color: blue\n");
1477 assert_eq!(names, vec!["a", "c"]);
1478 }
1479
1480 #[test]
1481 fn sass_indented_with_interpolation_still_ends_global_at_line_end() {
1482 let names = scss_export_names(
1483 ".a :global .b\n color: red\n.c-#{$x}\n color: blue\n.d\n color: blue\n",
1484 );
1485 assert_eq!(names, vec!["a", "c-", "d"]);
1487 }
1488
1489 #[test]
1490 fn global_like_pseudo_names_are_not_global() {
1491 let names = export_names(".a:global-ish .b { }");
1492 assert!(names.contains(&"b".to_string()), "got {names:?}");
1493 }
1494
1495 #[test]
1496 fn extracts_class_inside_local_pseudo() {
1497 let names = export_names(":local(.localClass) { color: red; }");
1498 assert_eq!(names, vec!["localClass"]);
1499 }
1500
1501 #[test]
1502 fn extracts_classes_inside_negation() {
1503 let names = export_names(".btn:not(.disabled) { }");
1504 assert!(names.contains(&"btn".to_string()), "got {names:?}");
1505 assert!(names.contains(&"disabled".to_string()), "got {names:?}");
1506 }
1507
1508 #[test]
1509 fn extracts_classes_inside_is_and_where() {
1510 let names = export_names(":is(.a, .b) :where(.c) { }");
1511 for expected in ["a", "b", "c"] {
1512 assert!(
1513 names.contains(&expected.to_string()),
1514 "missing {expected} in {names:?}"
1515 );
1516 }
1517 }
1518
1519 #[test]
1520 fn extracts_underscore_class() {
1521 let names = export_names("._hidden { } .__wrapper { }");
1522 assert!(names.contains(&"_hidden".to_string()));
1523 assert!(names.contains(&"__wrapper".to_string()));
1524 }
1525
1526 #[test]
1527 fn pseudo_selector_hover() {
1528 let names = export_names(".foo:hover { color: blue; }");
1529 assert_eq!(names, vec!["foo"]);
1530 }
1531
1532 #[test]
1533 fn pseudo_selector_focus() {
1534 let names = export_names(".input:focus { outline: none; }");
1535 assert_eq!(names, vec!["input"]);
1536 }
1537
1538 #[test]
1539 fn pseudo_element_before() {
1540 let names = export_names(".icon::before { content: ''; }");
1541 assert_eq!(names, vec!["icon"]);
1542 }
1543
1544 #[test]
1545 fn combined_pseudo_selectors() {
1546 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
1547 assert_eq!(names, vec!["btn"]);
1548 }
1549
1550 #[test]
1551 fn classes_inside_media_query() {
1552 let names = export_names(
1553 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
1554 );
1555 assert!(names.contains(&"mobile-nav".to_string()));
1556 assert!(names.contains(&"desktop-nav".to_string()));
1557 }
1558
1559 #[test]
1560 fn classes_inside_multi_line_media_query() {
1561 let names =
1562 export_names("@media\n screen and (min-width: 600px)\n{\n .real { color: red; }\n}");
1563 assert_eq!(names, vec!["real"]);
1564 }
1565
1566 #[test]
1567 fn at_layer_statement_does_not_export() {
1568 let names = export_names("@layer foo.bar;");
1569 assert!(names.is_empty(), "got {names:?}");
1570 let names = export_names("@layer foo.bar, foo.baz;");
1571 assert!(names.is_empty(), "got {names:?}");
1572 }
1573
1574 #[test]
1575 fn at_layer_block_keeps_body_classes() {
1576 let names = export_names("@layer foo.bar { .root { color: red; } }");
1577 assert_eq!(names, vec!["root"]);
1578 }
1579
1580 #[test]
1581 fn at_layer_multiline_prelude_keeps_body_classes() {
1582 let names = export_names("@layer\n foo.bar\n{ .root { color: red; } }");
1583 assert_eq!(names, vec!["root"]);
1584 }
1585
1586 #[test]
1587 fn at_layer_with_nested_media_keeps_body() {
1588 let names =
1589 export_names("@layer foo.bar { @media (max-width: 768px) { .real { color: red; } } }");
1590 assert_eq!(names, vec!["real"]);
1591 }
1592
1593 #[test]
1594 fn at_import_with_layer_attribute_does_not_export() {
1595 let names = export_names(r#"@import url("x.css") layer(theme.button);"#);
1596 assert!(names.is_empty(), "got {names:?}");
1597 }
1598
1599 #[test]
1600 fn class_then_at_layer_does_not_leak_prelude() {
1601 let names =
1602 export_names(".outer { color: blue; } @layer foo.bar { .inner { color: red; } }");
1603 assert_eq!(names, vec!["outer", "inner"]);
1604 }
1605
1606 #[test]
1607 fn at_scope_keeps_selector_list_classes() {
1608 let names = export_names("@scope (.parent) to (.child) { .title { color: red; } }");
1609 assert!(names.contains(&"parent".to_string()), "got {names:?}");
1610 assert!(names.contains(&"child".to_string()), "got {names:?}");
1611 assert!(names.contains(&"title".to_string()), "got {names:?}");
1612 }
1613
1614 #[test]
1615 fn at_keyframes_numeric_step_is_not_class() {
1616 let names = export_names(
1617 "@keyframes slide { 0% { transform: scale(.5); } 100% { transform: scale(1); } }",
1618 );
1619 assert!(names.is_empty(), "got {names:?}");
1620 }
1621
1622 #[test]
1623 fn at_webkit_keyframes_keeps_body_classes() {
1624 let names = export_names("@-webkit-keyframes slide { 0% { } 100% { } } .real { }");
1625 assert_eq!(names, vec!["real"]);
1626 }
1627
1628 #[test]
1629 fn deduplicates_repeated_class() {
1630 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
1631 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
1632 }
1633
1634 #[test]
1635 fn empty_source() {
1636 let names = export_names("");
1637 assert!(names.is_empty());
1638 }
1639
1640 #[test]
1641 fn no_classes() {
1642 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
1643 assert!(names.is_empty());
1644 }
1645
1646 #[test]
1647 fn ignores_classes_in_block_comments() {
1648 let names = export_names("/* .fake { } */ .real { }");
1649 assert!(!names.contains(&"fake".to_string()));
1650 assert!(names.contains(&"real".to_string()));
1651 }
1652
1653 #[test]
1654 fn ignores_classes_in_scss_line_comments() {
1655 let exports = extract_css_module_exports("// .fake\n.real { }", true);
1656 let names: Vec<_> = exports
1657 .iter()
1658 .filter_map(|e| match &e.name {
1659 ExportName::Named(n) => Some(n.as_str()),
1660 ExportName::Default => None,
1661 })
1662 .collect();
1663 assert_eq!(names, vec!["real"]);
1664 }
1665
1666 #[test]
1667 fn ignores_classes_in_strings() {
1668 let names = export_names(r#".real { content: ".fake"; }"#);
1669 assert!(names.contains(&"real".to_string()));
1670 assert!(!names.contains(&"fake".to_string()));
1671 }
1672
1673 #[test]
1674 fn ignores_classes_in_url() {
1675 let names = export_names(".real { background: url(./images/hero.png); }");
1676 assert!(names.contains(&"real".to_string()));
1677 assert!(!names.contains(&"png".to_string()));
1678 }
1679
1680 #[test]
1681 fn mask_css_block_comment() {
1682 let source = "/* removed */ .kept { }";
1683 let result = mask_css_comments(source, false);
1684 assert!(!result.contains("removed"));
1685 assert_eq!(result.len(), source.len());
1686 assert_eq!(result.find(".kept"), source.find(".kept"));
1687 }
1688
1689 #[test]
1690 fn mask_scss_line_comment() {
1691 let source = "// removed\n.kept { }";
1692 let result = mask_css_comments(source, true);
1693 assert!(!result.contains("removed"));
1694 assert_eq!(result.len(), source.len());
1695 assert_eq!(result.find(".kept"), source.find(".kept"));
1696 }
1697
1698 #[test]
1699 fn mask_scss_preserves_css_outside_comments() {
1700 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
1701 let result = mask_css_comments(source, true);
1702 assert!(result.contains(".visible"));
1703 }
1704
1705 #[test]
1706 fn url_import_http() {
1707 assert!(is_css_url_import("http://example.com/style.css"));
1708 }
1709
1710 #[test]
1711 fn url_import_https() {
1712 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
1713 }
1714
1715 #[test]
1716 fn url_import_data() {
1717 assert!(is_css_url_import("data:text/css;base64,abc"));
1718 }
1719
1720 #[test]
1721 fn url_import_local_not_skipped() {
1722 assert!(!is_css_url_import("./local.css"));
1723 }
1724
1725 #[test]
1726 fn url_import_bare_specifier_not_skipped() {
1727 assert!(!is_css_url_import("tailwindcss"));
1728 }
1729
1730 #[test]
1731 fn normalize_relative_dot_path_unchanged() {
1732 assert_eq!(
1733 normalize_css_import_path("./reset.css".to_string(), false),
1734 "./reset.css"
1735 );
1736 }
1737
1738 #[test]
1739 fn normalize_parent_relative_path_unchanged() {
1740 assert_eq!(
1741 normalize_css_import_path("../shared.scss".to_string(), false),
1742 "../shared.scss"
1743 );
1744 }
1745
1746 #[test]
1747 fn normalize_absolute_path_unchanged() {
1748 assert_eq!(
1749 normalize_css_import_path("/styles/main.css".to_string(), false),
1750 "/styles/main.css"
1751 );
1752 }
1753
1754 #[test]
1755 fn normalize_url_unchanged() {
1756 assert_eq!(
1757 normalize_css_import_path("https://example.com/style.css".to_string(), false),
1758 "https://example.com/style.css"
1759 );
1760 }
1761
1762 #[test]
1763 fn normalize_bare_css_gets_dot_slash() {
1764 assert_eq!(
1765 normalize_css_import_path("app.css".to_string(), false),
1766 "./app.css"
1767 );
1768 }
1769
1770 #[test]
1771 fn normalize_css_package_subpath_stays_bare() {
1772 assert_eq!(
1773 normalize_css_import_path("tailwindcss/theme.css".to_string(), false),
1774 "tailwindcss/theme.css"
1775 );
1776 }
1777
1778 #[test]
1779 fn normalize_css_package_subpath_with_dotted_name_stays_bare() {
1780 assert_eq!(
1781 normalize_css_import_path("highlight.js/styles/github.css".to_string(), false),
1782 "highlight.js/styles/github.css"
1783 );
1784 }
1785
1786 #[test]
1787 fn normalize_bare_scss_gets_dot_slash() {
1788 assert_eq!(
1789 normalize_css_import_path("vars.scss".to_string(), false),
1790 "./vars.scss"
1791 );
1792 }
1793
1794 #[test]
1795 fn normalize_bare_sass_gets_dot_slash() {
1796 assert_eq!(
1797 normalize_css_import_path("main.sass".to_string(), false),
1798 "./main.sass"
1799 );
1800 }
1801
1802 #[test]
1803 fn normalize_bare_less_gets_dot_slash() {
1804 assert_eq!(
1805 normalize_css_import_path("theme.less".to_string(), false),
1806 "./theme.less"
1807 );
1808 }
1809
1810 #[test]
1811 fn normalize_bare_js_extension_stays_bare() {
1812 assert_eq!(
1813 normalize_css_import_path("module.js".to_string(), false),
1814 "module.js"
1815 );
1816 }
1817
1818 #[test]
1819 fn normalize_scss_bare_partial_gets_dot_slash() {
1820 assert_eq!(
1821 normalize_css_import_path("variables".to_string(), true),
1822 "./variables"
1823 );
1824 }
1825
1826 #[test]
1827 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
1828 assert_eq!(
1829 normalize_css_import_path("base/reset".to_string(), true),
1830 "./base/reset"
1831 );
1832 }
1833
1834 #[test]
1835 fn normalize_scss_builtin_stays_bare() {
1836 assert_eq!(
1837 normalize_css_import_path("sass:math".to_string(), true),
1838 "sass:math"
1839 );
1840 }
1841
1842 #[test]
1843 fn normalize_scss_relative_path_unchanged() {
1844 assert_eq!(
1845 normalize_css_import_path("../styles/variables".to_string(), true),
1846 "../styles/variables"
1847 );
1848 }
1849
1850 #[test]
1851 fn normalize_css_bare_extensionless_stays_bare() {
1852 assert_eq!(
1853 normalize_css_import_path("tailwindcss".to_string(), false),
1854 "tailwindcss"
1855 );
1856 }
1857
1858 #[test]
1859 fn normalize_scoped_package_with_css_extension_stays_bare() {
1860 assert_eq!(
1861 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
1862 "@fontsource/monaspace-neon/400.css"
1863 );
1864 }
1865
1866 #[test]
1867 fn normalize_scoped_package_with_scss_extension_stays_bare() {
1868 assert_eq!(
1869 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
1870 "@company/design-system/tokens.scss"
1871 );
1872 }
1873
1874 #[test]
1875 fn normalize_scoped_package_without_extension_stays_bare() {
1876 assert_eq!(
1877 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
1878 "@fallow/design-system/styles"
1879 );
1880 }
1881
1882 #[test]
1883 fn normalize_scoped_package_extensionless_scss_stays_bare() {
1884 assert_eq!(
1885 normalize_css_import_path("@company/tokens".to_string(), true),
1886 "@company/tokens"
1887 );
1888 }
1889
1890 #[test]
1891 fn normalize_path_alias_with_css_extension_stays_bare() {
1892 assert_eq!(
1893 normalize_css_import_path("@/components/Button.css".to_string(), false),
1894 "@/components/Button.css"
1895 );
1896 }
1897
1898 #[test]
1899 fn normalize_path_alias_extensionless_stays_bare() {
1900 assert_eq!(
1901 normalize_css_import_path("@/styles/variables".to_string(), false),
1902 "@/styles/variables"
1903 );
1904 }
1905
1906 #[test]
1907 fn mask_css_no_comments() {
1908 let source = ".foo { color: red; }";
1909 assert_eq!(mask_css_comments(source, false), source);
1910 }
1911
1912 #[test]
1913 fn mask_css_multiple_block_comments() {
1914 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
1915 let result = mask_css_comments(source, false);
1916 assert!(!result.contains("comment-one"));
1917 assert!(!result.contains("comment-two"));
1918 assert!(result.contains(".foo"));
1919 assert!(result.contains(".bar"));
1920 }
1921
1922 #[test]
1923 fn mask_scss_does_not_affect_non_scss() {
1924 let source = "// this stays\n.foo { }";
1925 let result = mask_css_comments(source, false);
1926 assert!(result.contains("// this stays"));
1927 }
1928
1929 #[test]
1930 fn css_module_parses_suppressions() {
1931 let info = parse_css_to_module(
1932 fallow_types::discover::FileId(0),
1933 Path::new("Component.module.css"),
1934 "/* fallow-ignore-file */\n.btn { color: red; }",
1935 0,
1936 );
1937 assert!(!info.suppressions.is_empty());
1938 assert_eq!(info.suppressions[0].line, 0);
1939 }
1940
1941 #[test]
1942 fn extracts_class_starting_with_underscore() {
1943 let names = export_names("._private { } .__dunder { }");
1944 assert!(names.contains(&"_private".to_string()));
1945 assert!(names.contains(&"__dunder".to_string()));
1946 }
1947
1948 #[test]
1949 fn ignores_id_selectors() {
1950 let names = export_names("#myId { color: red; }");
1951 assert!(!names.contains(&"myId".to_string()));
1952 }
1953
1954 #[test]
1955 fn ignores_element_selectors() {
1956 let names = export_names("div { color: red; } span { }");
1957 assert!(names.is_empty());
1958 }
1959
1960 #[test]
1961 fn extract_css_imports_at_import_quoted() {
1962 let imports = extract_css_imports(r#"@import "./reset.css";"#, false);
1963 assert_eq!(imports, vec!["./reset.css"]);
1964 }
1965
1966 #[test]
1967 fn extract_css_imports_package_subpath_stays_bare() {
1968 let imports =
1969 extract_css_imports(r#"@import "tailwindcss/theme.css" layer(theme);"#, false);
1970 assert_eq!(imports, vec!["tailwindcss/theme.css"]);
1971 }
1972
1973 #[test]
1974 fn extract_css_imports_at_import_url() {
1975 let imports = extract_css_imports(r#"@import url("./reset.css");"#, false);
1976 assert_eq!(imports, vec!["./reset.css"]);
1977 }
1978
1979 #[test]
1980 fn extract_css_imports_skips_remote_urls() {
1981 let imports =
1982 extract_css_imports(r#"@import "https://fonts.example.com/font.css";"#, false);
1983 assert!(imports.is_empty());
1984 }
1985
1986 #[test]
1987 fn extract_css_imports_scss_use_normalizes_partial() {
1988 let imports = extract_css_imports(r#"@use "variables";"#, true);
1989 assert_eq!(imports, vec!["./variables"]);
1990 }
1991
1992 #[test]
1993 fn extract_css_imports_scss_forward_normalizes_partial() {
1994 let imports = extract_css_imports(r#"@forward "tokens";"#, true);
1995 assert_eq!(imports, vec!["./tokens"]);
1996 }
1997
1998 #[test]
1999 fn extract_css_imports_skips_comments() {
2000 let imports = extract_css_imports(
2001 r#"/* @import "./hidden.scss"; */
2002@use "real";"#,
2003 true,
2004 );
2005 assert_eq!(imports, vec!["./real"]);
2006 }
2007
2008 #[test]
2009 fn extract_css_imports_at_plugin_keeps_package_bare() {
2010 let imports = extract_css_imports(r#"@plugin "daisyui";"#, true);
2011 assert_eq!(imports, vec!["daisyui"]);
2012 }
2013
2014 #[test]
2015 fn extract_css_imports_at_plugin_tracks_relative_file() {
2016 let imports = extract_css_imports(r#"@plugin "./tailwind-plugin.js";"#, false);
2017 assert_eq!(imports, vec!["./tailwind-plugin.js"]);
2018 }
2019
2020 #[test]
2021 fn extract_css_imports_scss_at_import_kept_relative() {
2022 let imports = extract_css_imports(r"@import 'Foo';", true);
2023 assert_eq!(imports, vec!["./Foo"]);
2024 }
2025
2026 #[test]
2027 fn extract_css_imports_additional_data_string_body() {
2028 let body = r#"@use "./src/styles/global.scss";"#;
2029 let imports = extract_css_imports(body, true);
2030 assert_eq!(imports, vec!["./src/styles/global.scss"]);
2031 }
2032
2033 #[test]
2034 fn mask_with_whitespace_preserves_byte_length() {
2035 let src = "/* hello */ .foo { }";
2036 let masked = mask_with_whitespace(src, &CSS_COMMENT_RE);
2037 assert_eq!(masked.len(), src.len());
2038 assert!(masked.is_char_boundary(src.len()));
2039 }
2040
2041 #[test]
2042 fn mask_with_whitespace_preserves_offsets_around_multibyte() {
2043 let src = "/* \u{2713} */ .foo { }";
2044 let foo_offset = src.find(".foo").expect("`.foo` present");
2045 let masked = mask_with_whitespace(src, &CSS_COMMENT_RE);
2046 assert_eq!(masked.len(), src.len());
2047 assert_eq!(masked.find(".foo"), Some(foo_offset));
2048 }
2049
2050 fn span_line_col(source: &str, start: u32) -> (u32, u32) {
2053 let offsets = fallow_types::extract::compute_line_offsets(source);
2054 fallow_types::extract::byte_offset_to_line_col(&offsets, start)
2055 }
2056
2057 #[test]
2058 fn span_points_at_real_class_declaration_line() {
2059 let source = "\n\n\n\n.foo { color: red; }\n";
2060 let exports = extract_css_module_exports(source, false);
2061 assert_eq!(exports.len(), 1);
2062 let span = exports[0].span;
2063 let (line, col) = span_line_col(source, span.start);
2064 assert_eq!(line, 5, "`.foo` on line 5 must produce line 5, not line 1");
2065 assert_eq!(
2066 col, 1,
2067 "column points at `f` in `.foo` (post-dot identifier)"
2068 );
2069 assert_eq!(
2070 &source[span.start as usize..span.end as usize],
2071 "foo",
2072 "span range must slice to the class identifier in the original source"
2073 );
2074 }
2075
2076 #[test]
2077 fn span_survives_multibyte_comment_prefix() {
2078 let source = "/* \u{2713} */\n.foo { }";
2079 let exports = extract_css_module_exports(source, false);
2080 assert_eq!(exports.len(), 1);
2081 let span = exports[0].span;
2082 assert!(
2083 source.is_char_boundary(span.start as usize),
2084 "span.start must lie on a UTF-8 char boundary"
2085 );
2086 assert_eq!(&source[span.start as usize..span.end as usize], "foo");
2087 }
2088
2089 #[test]
2090 fn span_skips_at_layer_prelude_dot_segments() {
2091 let source = "@layer foo.bar { }\n.root { }\n";
2092 let exports = extract_css_module_exports(source, false);
2093 let names: Vec<_> = exports
2094 .iter()
2095 .filter_map(|e| match &e.name {
2096 ExportName::Named(n) => Some(n.as_str()),
2097 ExportName::Default => None,
2098 })
2099 .collect();
2100 assert_eq!(names, vec!["root"], "@layer sub-segments must not export");
2101 let span = exports[0].span;
2102 let (line, _col) = span_line_col(source, span.start);
2103 assert_eq!(line, 2, "`.root` lives on line 2 of the original source");
2104 assert_eq!(&source[span.start as usize..span.end as usize], "root");
2105 }
2106
2107 #[test]
2108 fn span_skips_classes_in_strings() {
2109 let source = ".real { content: \".fake\"; }\n.also-real { }\n";
2110 let exports = extract_css_module_exports(source, false);
2111 let names: Vec<_> = exports
2112 .iter()
2113 .filter_map(|e| match &e.name {
2114 ExportName::Named(n) => Some(n.as_str()),
2115 ExportName::Default => None,
2116 })
2117 .collect();
2118 assert_eq!(names, vec!["real", "also-real"]);
2119 for export in &exports {
2120 let span = export.span;
2121 let slice = &source[span.start as usize..span.end as usize];
2122 match &export.name {
2123 ExportName::Named(n) => assert_eq!(slice, n.as_str()),
2124 ExportName::Default => unreachable!("CSS modules emit only named exports"),
2125 }
2126 }
2127 }
2128
2129 #[test]
2130 fn span_deduplicates_to_first_occurrence() {
2131 let source = ".btn { color: red; }\n.btn { color: blue; }\n";
2132 let exports = extract_css_module_exports(source, false);
2133 assert_eq!(exports.len(), 1);
2134 let (line, _col) = span_line_col(source, exports[0].span.start);
2135 assert_eq!(
2136 line, 1,
2137 "first occurrence wins for deduplicated class names"
2138 );
2139 }
2140
2141 #[test]
2142 fn span_inside_media_query() {
2143 let source =
2144 "@media (max-width: 768px) {\n .mobile { display: block; }\n .desktop { }\n}\n";
2145 let exports = extract_css_module_exports(source, false);
2146 let by_name: rustc_hash::FxHashMap<&str, oxc_span::Span> = exports
2147 .iter()
2148 .filter_map(|e| match &e.name {
2149 ExportName::Named(n) => Some((n.as_str(), e.span)),
2150 ExportName::Default => None,
2151 })
2152 .collect();
2153 let mobile_line = span_line_col(source, by_name["mobile"].start).0;
2154 let desktop_line = span_line_col(source, by_name["desktop"].start).0;
2155 assert_eq!(mobile_line, 2);
2156 assert_eq!(desktop_line, 3);
2157 }
2158
2159 #[test]
2160 fn at_layer_only_module_emits_no_exports() {
2161 let exports = extract_css_module_exports("@layer foo.bar, foo.baz;\n", false);
2162 assert!(exports.is_empty());
2163 }
2164
2165 #[test]
2166 fn parse_css_to_module_resolves_real_line_offsets() {
2167 let source = "\n\n\n\n.foo { color: red; }\n";
2168 let info = parse_css_to_module(
2169 fallow_types::discover::FileId(0),
2170 Path::new("Component.module.css"),
2171 source,
2172 0,
2173 );
2174 assert_eq!(info.exports.len(), 1);
2175 let (line, _col) = fallow_types::extract::byte_offset_to_line_col(
2176 &info.line_offsets,
2177 info.exports[0].span.start,
2178 );
2179 assert_eq!(line, 5, "downstream line must equal the source line");
2180 }
2181
2182 fn theme_token_names(source: &str) -> Vec<String> {
2183 scan_theme_blocks(source)
2184 .tokens
2185 .into_iter()
2186 .map(|t| t.name)
2187 .collect()
2188 }
2189
2190 #[test]
2191 fn theme_single_block_collects_tokens() {
2192 let names = theme_token_names("@theme { --color-brand: #f00; --radius-card: 8px; }");
2193 assert_eq!(names, vec!["color-brand", "radius-card"]);
2194 }
2195
2196 #[test]
2197 fn theme_token_values_are_normalized() {
2198 let scan = scan_theme_blocks("@theme {\n --color-brand: rgb( 255 0 0 );\n}");
2199 assert_eq!(scan.tokens[0].name, "color-brand");
2200 assert_eq!(scan.tokens[0].value, "rgb( 255 0 0 )");
2201 }
2202
2203 #[test]
2204 fn theme_dashed_multi_segment_names() {
2205 let names = theme_token_names(
2206 "@theme {\n --font-weight-heavy: 900;\n --inset-shadow-glow: 0 0 4px red;\n}",
2207 );
2208 assert_eq!(names, vec!["font-weight-heavy", "inset-shadow-glow"]);
2209 }
2210
2211 #[test]
2212 fn theme_inline_and_static_modifiers() {
2213 assert_eq!(
2214 theme_token_names("@theme inline { --color-a: red; }"),
2215 vec!["color-a"]
2216 );
2217 assert_eq!(
2218 theme_token_names("@theme static { --color-b: red; }"),
2219 vec!["color-b"]
2220 );
2221 }
2222
2223 #[test]
2224 fn theme_multiple_blocks_union() {
2225 let names = theme_token_names(
2226 "@theme { --color-a: red; }\n.x { color: blue; }\n@theme { --spacing-gutter: 1rem; }",
2227 );
2228 assert_eq!(names, vec!["color-a", "spacing-gutter"]);
2229 }
2230
2231 #[test]
2232 fn theme_reset_form_excluded() {
2233 let names = theme_token_names("@theme { --color-*: initial; --color-brand: red; }");
2235 assert_eq!(names, vec!["color-brand"]);
2236 }
2237
2238 #[test]
2239 fn theme_no_block_yields_nothing() {
2240 assert!(theme_token_names(".x { --color-brand: red; }").is_empty());
2241 }
2242
2243 #[test]
2244 fn theme_line_numbers() {
2245 let scan = scan_theme_blocks("@theme {\n --color-a: red;\n --radius-b: 4px;\n}");
2246 assert_eq!(scan.tokens[0].line, 2);
2247 assert_eq!(scan.tokens[1].line, 3);
2248 }
2249
2250 #[test]
2251 fn theme_token_backs_token_via_var() {
2252 let scan = scan_theme_blocks(
2253 "@theme {\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}",
2254 );
2255 assert!(
2256 scan.theme_var_reads
2257 .iter()
2258 .any(|(name, _)| name == "color-brand")
2259 );
2260 }
2261
2262 #[test]
2263 fn theme_var_read_carries_line() {
2264 let scan = scan_theme_blocks(
2267 "@theme {\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}",
2268 );
2269 assert_eq!(
2270 scan.theme_var_reads,
2271 vec![("color-brand".to_string(), 3u32)]
2272 );
2273 }
2274
2275 #[test]
2276 fn css_var_reads_locate_outside_theme_and_exclude_interior() {
2277 let source = "@theme {\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}\n\n.btn {\n color: var(--color-brand);\n}\n";
2281 assert_eq!(
2282 extract_css_var_reads_located(source),
2283 vec![("color-brand".to_string(), 7u32)],
2284 "only the .btn read (line 7) is a css-var; the @theme-interior read is excluded"
2285 );
2286
2287 assert!(
2289 extract_css_var_reads_located("@theme {\n --a: #fff;\n --b: var(--a);\n}",)
2290 .is_empty(),
2291 "a @theme-interior-only var() read is not a css-var consumer"
2292 );
2293 }
2294
2295 #[test]
2296 fn css_var_reads_line_match_naive_reference_on_dense_line() {
2297 use std::fmt::Write as _;
2303 let mut src = String::from(".x {");
2304 for i in 0..500 {
2305 let _ = write!(src, " color: var(--t{i});");
2306 }
2307 src.push_str(" }\n.y { color: var(--tail); }\n");
2308
2309 let got = extract_css_var_reads_located(&src);
2310
2311 let want: Vec<(String, u32)> = CSS_VAR_REF_RE
2313 .captures_iter(&src)
2314 .filter_map(|cap| cap.get(0).zip(cap.get(1)))
2315 .map(|(whole, name)| {
2316 (
2317 name.as_str().to_owned(),
2318 line_at_offset(&src, whole.start()),
2319 )
2320 })
2321 .collect();
2322
2323 assert_eq!(got, want);
2324 assert!(got.len() > 500, "expected the dense line plus the trailer");
2325 assert_eq!(got.last().map(|(_, l)| *l), Some(2));
2327 assert!(got[..got.len() - 1].iter().all(|(_, l)| *l == 1));
2328 }
2329
2330 #[test]
2331 fn apply_token_lines_match_naive_reference_on_dense_line() {
2332 use std::fmt::Write as _;
2333 let mut src = String::from("/* a\n b */\n.x {");
2334 for i in 0..300 {
2335 let _ = write!(src, " @apply p-{i};");
2336 }
2337 src.push_str(" }\n\n.y { @apply tail-a tail-b; }\r\n.z { @apply last; }\n");
2338
2339 let got = extract_apply_tokens_located(&src);
2340
2341 let masked = mask_theme_source(&src);
2342 let want: Vec<(String, u32)> = CSS_APPLY_RE
2343 .find_iter(&masked)
2344 .flat_map(|m| {
2345 let line = line_at_offset(&src, m.start());
2346 m.as_str()
2347 .trim_start_matches("@apply")
2348 .split_whitespace()
2349 .map(move |token| (token.to_owned(), line))
2350 })
2351 .collect();
2352
2353 assert_eq!(got, want);
2354 assert_eq!(got.len(), 303);
2355 assert!(got[..300].iter().all(|(_, line)| *line == 3));
2356 assert_eq!(
2357 &got[300..],
2358 &[
2359 ("tail-a".to_owned(), 5),
2360 ("tail-b".to_owned(), 5),
2361 ("last".to_owned(), 6),
2362 ]
2363 );
2364 }
2365
2366 #[test]
2367 fn theme_string_braces_do_not_truncate_block() {
2368 let scan = scan_theme_blocks(
2369 "@theme {\n --font-label: \"}\";\n --color-brand: #f00;\n --color-button: var(--color-brand);\n}",
2370 );
2371 assert_eq!(
2372 scan.tokens
2373 .iter()
2374 .map(|token| token.name.as_str())
2375 .collect::<Vec<_>>(),
2376 vec!["font-label", "color-brand", "color-button"]
2377 );
2378 assert!(
2379 scan.theme_var_reads
2380 .iter()
2381 .any(|(name, _)| name == "color-brand")
2382 );
2383 }
2384
2385 #[test]
2386 fn theme_nested_keyframes_body_not_collected() {
2387 let names = theme_token_names(
2390 "@theme {\n --animate-spin: spin 1s linear infinite;\n @keyframes spin { from { --x: 0; } to { --y: 1; } }\n}",
2391 );
2392 assert_eq!(names, vec!["animate-spin"]);
2393 }
2394
2395 #[test]
2396 fn theme_comment_block_ignored() {
2397 let names = theme_token_names("/* @theme { --color-fake: red; } */ .x { color: blue; }");
2398 assert!(names.is_empty(), "got {names:?}");
2399 }
2400
2401 #[test]
2402 fn theme_deduplicates_repeated_token() {
2403 let names = theme_token_names("@theme { --color-a: red; --color-a: blue; }");
2404 assert_eq!(names, vec!["color-a"]);
2405 }
2406
2407 #[test]
2408 fn apply_tokens_basic() {
2409 let tokens = extract_apply_tokens(".panel { @apply rounded-card font-bold; }");
2410 assert_eq!(tokens, vec!["rounded-card", "font-bold"]);
2411 }
2412
2413 #[test]
2414 fn apply_tokens_strips_important() {
2415 let tokens = extract_apply_tokens(".x { @apply text-brand! font-bold !important; }");
2416 assert_eq!(tokens, vec!["text-brand", "font-bold"]);
2417 }
2418
2419 fn line_lookup_bytes(run: impl FnOnce()) -> usize {
2420 LINE_LOOKUP_BYTES.with(|read| read.set(0));
2421 run();
2422 LINE_LOOKUP_BYTES.with(std::cell::Cell::get)
2423 }
2424
2425 fn dense_token_stylesheet(count: usize) -> String {
2428 use std::fmt::Write as _;
2429 let mut src = String::from("@theme { --color-brand: red; }\n.x {");
2430 for i in 0..count {
2431 let _ = write!(src, " @apply p-{i}; color: var(--color-{i});");
2432 }
2433 src.push_str(" }\n.y { @apply tail; color: var(--color-tail); }\n");
2434 src
2435 }
2436
2437 #[test]
2438 fn located_token_scans_read_each_byte_once_for_lines() {
2439 for count in [400, 800] {
2440 let src = dense_token_stylesheet(count);
2441 let apply = line_lookup_bytes(|| {
2442 assert_eq!(extract_apply_tokens_located(&src).len(), count + 1);
2443 });
2444 let var_reads = line_lookup_bytes(|| {
2445 assert_eq!(extract_css_var_reads_located(&src).len(), count + 1);
2446 });
2447 let combined = line_lookup_bytes(|| {
2448 let _ = scan_stylesheet_tokens(&src);
2449 });
2450 assert!(apply <= src.len(), "@apply read {apply} of {}", src.len());
2453 assert!(
2454 var_reads <= src.len(),
2455 "var() read {var_reads} of {}",
2456 src.len()
2457 );
2458 assert!(
2459 combined <= 2 * src.len(),
2460 "scan read {combined} of {}",
2461 src.len()
2462 );
2463 }
2464 }
2465
2466 fn comment_mask_passes(run: impl FnOnce()) -> u32 {
2467 COMMENT_MASK_PASSES.with(|passes| passes.set(0));
2468 run();
2469 COMMENT_MASK_PASSES.with(std::cell::Cell::get)
2470 }
2471
2472 #[test]
2473 fn a_css_module_parse_masks_comments_once() {
2474 let source = "/* a */ @import './b.css';\n.card { @apply rounded; } // c\n";
2475 for name in ["styles.module.css", "styles.module.scss", "styles.css"] {
2476 let passes = comment_mask_passes(|| {
2477 parse_css_to_module(FileId(0), Path::new(name), source, 0);
2478 });
2479 assert_eq!(passes, 1, "comment mask passes for {name}");
2480 }
2481 }
2482
2483 #[test]
2484 fn a_stylesheet_token_scan_masks_comments_once() {
2485 let source = "/* x */\n@theme {\n --color-brand: red;\n --color-alt: var(--color-brand);\n}\n.a { @apply bg-brand p-2; color: var(--color-alt); }\n";
2486 let mut scan = StylesheetTokens::default();
2487 let passes = comment_mask_passes(|| scan = scan_stylesheet_tokens(source));
2488 assert_eq!(passes, 1);
2489 assert_eq!(scan.theme, scan_theme_blocks(source));
2490 assert_eq!(
2491 scan.apply_tokens_located,
2492 extract_apply_tokens_located(source)
2493 );
2494 assert_eq!(
2495 scan.css_var_reads_located,
2496 extract_css_var_reads_located(source)
2497 );
2498 }
2499
2500 #[test]
2501 fn a_stylesheet_token_scan_skips_the_mask_without_directives() {
2502 let source = "/* plain */ .a { color: red; }";
2503 let mut scan = StylesheetTokens::default();
2504 let passes = comment_mask_passes(|| scan = scan_stylesheet_tokens(source));
2505 assert_eq!(passes, 0);
2506 assert_eq!(scan, StylesheetTokens::default());
2507 }
2508
2509 #[test]
2510 fn apply_tokens_ignored_in_comments() {
2511 let tokens = extract_apply_tokens("/* @apply hidden-token; */ .x { color: red; }");
2512 assert!(tokens.is_empty(), "got {tokens:?}");
2513 }
2514}