1use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_span::Span;
10
11use crate::{ExportInfo, ExportName, ImportInfo, ImportedName, ModuleInfo, VisibilityTag};
12use fallow_types::discover::FileId;
13
14static CSS_IMPORT_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
17 regex::Regex::new(r#"@import\s+(?:url\(\s*(?:["']([^"']+)["']|([^)]+))\s*\)|["']([^"']+)["'])"#)
18 .expect("valid regex")
19});
20
21static SCSS_USE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
24 regex::Regex::new(r#"@(?:use|forward)\s+["']([^"']+)["']"#).expect("valid regex")
25});
26
27static CSS_APPLY_RE: LazyLock<regex::Regex> =
30 LazyLock::new(|| regex::Regex::new(r"@apply\s+[^;}\n]+").expect("valid regex"));
31
32static CSS_TAILWIND_RE: LazyLock<regex::Regex> =
35 LazyLock::new(|| regex::Regex::new(r"@tailwind\s+\w+").expect("valid regex"));
36
37static CSS_COMMENT_RE: LazyLock<regex::Regex> =
39 LazyLock::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").expect("valid regex"));
40
41static SCSS_LINE_COMMENT_RE: LazyLock<regex::Regex> =
43 LazyLock::new(|| regex::Regex::new(r"//[^\n]*").expect("valid regex"));
44
45static CSS_CLASS_RE: LazyLock<regex::Regex> =
48 LazyLock::new(|| regex::Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").expect("valid regex"));
49
50static CSS_NON_SELECTOR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
53 regex::Regex::new(r#"(?s)"[^"]*"|'[^']*'|url\([^)]*\)"#).expect("valid regex")
54});
55
56pub(crate) fn is_css_file(path: &Path) -> bool {
57 path.extension()
58 .and_then(|e| e.to_str())
59 .is_some_and(|ext| ext == "css" || ext == "scss")
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CssImportSource {
65 pub raw: String,
67 pub normalized: String,
69}
70
71fn is_css_module_file(path: &Path) -> bool {
72 is_css_file(path)
73 && path
74 .file_stem()
75 .and_then(|s| s.to_str())
76 .is_some_and(|stem| stem.ends_with(".module"))
77}
78
79fn is_css_url_import(source: &str) -> bool {
81 source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
82}
83
84fn normalize_css_import_path(path: String, is_scss: bool) -> String {
96 if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
97 return path;
98 }
99 if path.starts_with('@') && path.contains('/') {
102 return path;
103 }
104 let ext = std::path::Path::new(&path)
106 .extension()
107 .and_then(|e| e.to_str());
108 match ext {
109 Some(e)
110 if e.eq_ignore_ascii_case("css")
111 || e.eq_ignore_ascii_case("scss")
112 || e.eq_ignore_ascii_case("sass")
113 || e.eq_ignore_ascii_case("less") =>
114 {
115 format!("./{path}")
116 }
117 _ => {
118 if is_scss && !path.contains(':') {
122 format!("./{path}")
123 } else {
124 path
125 }
126 }
127 }
128}
129
130fn strip_css_comments(source: &str, is_scss: bool) -> String {
132 let stripped = CSS_COMMENT_RE.replace_all(source, "");
133 if is_scss {
134 SCSS_LINE_COMMENT_RE.replace_all(&stripped, "").into_owned()
135 } else {
136 stripped.into_owned()
137 }
138}
139
140#[must_use]
146pub fn extract_css_import_sources(source: &str, is_scss: bool) -> Vec<CssImportSource> {
147 let stripped = strip_css_comments(source, is_scss);
148 let mut out = Vec::new();
149
150 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
151 let raw = cap
152 .get(1)
153 .or_else(|| cap.get(2))
154 .or_else(|| cap.get(3))
155 .map(|m| m.as_str().trim().to_string());
156 if let Some(src) = raw
157 && !src.is_empty()
158 && !is_css_url_import(&src)
159 {
160 out.push(CssImportSource {
161 normalized: normalize_css_import_path(src.clone(), is_scss),
162 raw: src,
163 });
164 }
165 }
166
167 if is_scss {
168 for cap in SCSS_USE_RE.captures_iter(&stripped) {
169 if let Some(m) = cap.get(1) {
170 let raw = m.as_str().to_string();
171 out.push(CssImportSource {
172 normalized: normalize_css_import_path(raw.clone(), true),
173 raw,
174 });
175 }
176 }
177 }
178
179 out
180}
181
182#[must_use]
189pub fn extract_css_imports(source: &str, is_scss: bool) -> Vec<String> {
190 extract_css_import_sources(source, is_scss)
191 .into_iter()
192 .map(|source| source.normalized)
193 .collect()
194}
195
196pub fn extract_css_module_exports(source: &str) -> Vec<ExportInfo> {
198 let cleaned = CSS_NON_SELECTOR_RE.replace_all(source, "");
199 let mut seen = rustc_hash::FxHashSet::default();
200 let mut exports = Vec::new();
201 for cap in CSS_CLASS_RE.captures_iter(&cleaned) {
202 if let Some(m) = cap.get(1) {
203 let class_name = m.as_str().to_string();
204 if seen.insert(class_name.clone()) {
205 exports.push(ExportInfo {
206 name: ExportName::Named(class_name),
207 local_name: None,
208 is_type_only: false,
209 visibility: VisibilityTag::None,
210 span: Span::default(),
211 members: Vec::new(),
212 super_class: None,
213 });
214 }
215 }
216 }
217 exports
218}
219
220pub(crate) fn parse_css_to_module(
222 file_id: FileId,
223 path: &Path,
224 source: &str,
225 content_hash: u64,
226) -> ModuleInfo {
227 let suppressions = crate::suppress::parse_suppressions_from_source(source);
228 let is_scss = path
229 .extension()
230 .and_then(|e| e.to_str())
231 .is_some_and(|ext| ext == "scss");
232
233 let stripped = strip_css_comments(source, is_scss);
235
236 let mut imports = Vec::new();
237
238 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
240 let source_path = cap
241 .get(1)
242 .or_else(|| cap.get(2))
243 .or_else(|| cap.get(3))
244 .map(|m| m.as_str().trim().to_string());
245 if let Some(src) = source_path
246 && !src.is_empty()
247 && !is_css_url_import(&src)
248 {
249 let src = normalize_css_import_path(src, is_scss);
252 imports.push(ImportInfo {
253 source: src,
254 imported_name: ImportedName::SideEffect,
255 local_name: String::new(),
256 is_type_only: false,
257 from_style: false,
258 span: Span::default(),
259 source_span: Span::default(),
260 });
261 }
262 }
263
264 if is_scss {
266 for cap in SCSS_USE_RE.captures_iter(&stripped) {
267 if let Some(m) = cap.get(1) {
268 imports.push(ImportInfo {
269 source: normalize_css_import_path(m.as_str().to_string(), true),
270 imported_name: ImportedName::SideEffect,
271 local_name: String::new(),
272 is_type_only: false,
273 from_style: false,
274 span: Span::default(),
275 source_span: Span::default(),
276 });
277 }
278 }
279 }
280
281 let has_apply = CSS_APPLY_RE.is_match(&stripped);
284 let has_tailwind = CSS_TAILWIND_RE.is_match(&stripped);
285 if has_apply || has_tailwind {
286 imports.push(ImportInfo {
287 source: "tailwindcss".to_string(),
288 imported_name: ImportedName::SideEffect,
289 local_name: String::new(),
290 is_type_only: false,
291 from_style: false,
292 span: Span::default(),
293 source_span: Span::default(),
294 });
295 }
296
297 let exports = if is_css_module_file(path) {
299 extract_css_module_exports(&stripped)
300 } else {
301 Vec::new()
302 };
303
304 ModuleInfo {
305 file_id,
306 exports,
307 imports,
308 re_exports: Vec::new(),
309 dynamic_imports: Vec::new(),
310 dynamic_import_patterns: Vec::new(),
311 require_calls: Vec::new(),
312 member_accesses: Vec::new(),
313 whole_object_uses: Vec::new(),
314 has_cjs_exports: false,
315 content_hash,
316 suppressions,
317 unused_import_bindings: Vec::new(),
318 type_referenced_import_bindings: Vec::new(),
319 value_referenced_import_bindings: Vec::new(),
320 line_offsets: fallow_types::extract::compute_line_offsets(source),
321 complexity: Vec::new(),
322 flag_uses: Vec::new(),
323 class_heritage: vec![],
324 local_type_declarations: Vec::new(),
325 public_signature_type_references: Vec::new(),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn export_names(source: &str) -> Vec<String> {
335 extract_css_module_exports(source)
336 .into_iter()
337 .filter_map(|e| match e.name {
338 ExportName::Named(n) => Some(n),
339 ExportName::Default => None,
340 })
341 .collect()
342 }
343
344 #[test]
347 fn is_css_file_css() {
348 assert!(is_css_file(Path::new("styles.css")));
349 }
350
351 #[test]
352 fn is_css_file_scss() {
353 assert!(is_css_file(Path::new("styles.scss")));
354 }
355
356 #[test]
357 fn is_css_file_rejects_js() {
358 assert!(!is_css_file(Path::new("app.js")));
359 }
360
361 #[test]
362 fn is_css_file_rejects_ts() {
363 assert!(!is_css_file(Path::new("app.ts")));
364 }
365
366 #[test]
367 fn is_css_file_rejects_less() {
368 assert!(!is_css_file(Path::new("styles.less")));
369 }
370
371 #[test]
372 fn is_css_file_rejects_no_extension() {
373 assert!(!is_css_file(Path::new("Makefile")));
374 }
375
376 #[test]
379 fn is_css_module_file_module_css() {
380 assert!(is_css_module_file(Path::new("Component.module.css")));
381 }
382
383 #[test]
384 fn is_css_module_file_module_scss() {
385 assert!(is_css_module_file(Path::new("Component.module.scss")));
386 }
387
388 #[test]
389 fn is_css_module_file_rejects_plain_css() {
390 assert!(!is_css_module_file(Path::new("styles.css")));
391 }
392
393 #[test]
394 fn is_css_module_file_rejects_plain_scss() {
395 assert!(!is_css_module_file(Path::new("styles.scss")));
396 }
397
398 #[test]
399 fn is_css_module_file_rejects_module_js() {
400 assert!(!is_css_module_file(Path::new("utils.module.js")));
401 }
402
403 #[test]
406 fn extracts_single_class() {
407 let names = export_names(".foo { color: red; }");
408 assert_eq!(names, vec!["foo"]);
409 }
410
411 #[test]
412 fn extracts_multiple_classes() {
413 let names = export_names(".foo { } .bar { }");
414 assert_eq!(names, vec!["foo", "bar"]);
415 }
416
417 #[test]
418 fn extracts_nested_classes() {
419 let names = export_names(".foo .bar { color: red; }");
420 assert!(names.contains(&"foo".to_string()));
421 assert!(names.contains(&"bar".to_string()));
422 }
423
424 #[test]
425 fn extracts_hyphenated_class() {
426 let names = export_names(".my-class { }");
427 assert_eq!(names, vec!["my-class"]);
428 }
429
430 #[test]
431 fn extracts_camel_case_class() {
432 let names = export_names(".myClass { }");
433 assert_eq!(names, vec!["myClass"]);
434 }
435
436 #[test]
437 fn extracts_underscore_class() {
438 let names = export_names("._hidden { } .__wrapper { }");
439 assert!(names.contains(&"_hidden".to_string()));
440 assert!(names.contains(&"__wrapper".to_string()));
441 }
442
443 #[test]
446 fn pseudo_selector_hover() {
447 let names = export_names(".foo:hover { color: blue; }");
448 assert_eq!(names, vec!["foo"]);
449 }
450
451 #[test]
452 fn pseudo_selector_focus() {
453 let names = export_names(".input:focus { outline: none; }");
454 assert_eq!(names, vec!["input"]);
455 }
456
457 #[test]
458 fn pseudo_element_before() {
459 let names = export_names(".icon::before { content: ''; }");
460 assert_eq!(names, vec!["icon"]);
461 }
462
463 #[test]
464 fn combined_pseudo_selectors() {
465 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
466 assert_eq!(names, vec!["btn"]);
468 }
469
470 #[test]
473 fn classes_inside_media_query() {
474 let names = export_names(
475 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
476 );
477 assert!(names.contains(&"mobile-nav".to_string()));
478 assert!(names.contains(&"desktop-nav".to_string()));
479 }
480
481 #[test]
484 fn deduplicates_repeated_class() {
485 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
486 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
487 }
488
489 #[test]
492 fn empty_source() {
493 let names = export_names("");
494 assert!(names.is_empty());
495 }
496
497 #[test]
498 fn no_classes() {
499 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
500 assert!(names.is_empty());
501 }
502
503 #[test]
504 fn ignores_classes_in_block_comments() {
505 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
510 let names = export_names(&stripped);
511 assert!(!names.contains(&"fake".to_string()));
512 assert!(names.contains(&"real".to_string()));
513 }
514
515 #[test]
516 fn ignores_classes_in_strings() {
517 let names = export_names(r#".real { content: ".fake"; }"#);
518 assert!(names.contains(&"real".to_string()));
519 assert!(!names.contains(&"fake".to_string()));
520 }
521
522 #[test]
523 fn ignores_classes_in_url() {
524 let names = export_names(".real { background: url(./images/hero.png); }");
525 assert!(names.contains(&"real".to_string()));
526 assert!(!names.contains(&"png".to_string()));
528 }
529
530 #[test]
533 fn strip_css_block_comment() {
534 let result = strip_css_comments("/* removed */ .kept { }", false);
535 assert!(!result.contains("removed"));
536 assert!(result.contains(".kept"));
537 }
538
539 #[test]
540 fn strip_scss_line_comment() {
541 let result = strip_css_comments("// removed\n.kept { }", true);
542 assert!(!result.contains("removed"));
543 assert!(result.contains(".kept"));
544 }
545
546 #[test]
547 fn strip_scss_preserves_css_outside_comments() {
548 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
549 let result = strip_css_comments(source, true);
550 assert!(result.contains(".visible"));
551 }
552
553 #[test]
556 fn url_import_http() {
557 assert!(is_css_url_import("http://example.com/style.css"));
558 }
559
560 #[test]
561 fn url_import_https() {
562 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
563 }
564
565 #[test]
566 fn url_import_data() {
567 assert!(is_css_url_import("data:text/css;base64,abc"));
568 }
569
570 #[test]
571 fn url_import_local_not_skipped() {
572 assert!(!is_css_url_import("./local.css"));
573 }
574
575 #[test]
576 fn url_import_bare_specifier_not_skipped() {
577 assert!(!is_css_url_import("tailwindcss"));
578 }
579
580 #[test]
583 fn normalize_relative_dot_path_unchanged() {
584 assert_eq!(
585 normalize_css_import_path("./reset.css".to_string(), false),
586 "./reset.css"
587 );
588 }
589
590 #[test]
591 fn normalize_parent_relative_path_unchanged() {
592 assert_eq!(
593 normalize_css_import_path("../shared.scss".to_string(), false),
594 "../shared.scss"
595 );
596 }
597
598 #[test]
599 fn normalize_absolute_path_unchanged() {
600 assert_eq!(
601 normalize_css_import_path("/styles/main.css".to_string(), false),
602 "/styles/main.css"
603 );
604 }
605
606 #[test]
607 fn normalize_url_unchanged() {
608 assert_eq!(
609 normalize_css_import_path("https://example.com/style.css".to_string(), false),
610 "https://example.com/style.css"
611 );
612 }
613
614 #[test]
615 fn normalize_bare_css_gets_dot_slash() {
616 assert_eq!(
617 normalize_css_import_path("app.css".to_string(), false),
618 "./app.css"
619 );
620 }
621
622 #[test]
623 fn normalize_bare_scss_gets_dot_slash() {
624 assert_eq!(
625 normalize_css_import_path("vars.scss".to_string(), false),
626 "./vars.scss"
627 );
628 }
629
630 #[test]
631 fn normalize_bare_sass_gets_dot_slash() {
632 assert_eq!(
633 normalize_css_import_path("main.sass".to_string(), false),
634 "./main.sass"
635 );
636 }
637
638 #[test]
639 fn normalize_bare_less_gets_dot_slash() {
640 assert_eq!(
641 normalize_css_import_path("theme.less".to_string(), false),
642 "./theme.less"
643 );
644 }
645
646 #[test]
647 fn normalize_bare_js_extension_stays_bare() {
648 assert_eq!(
649 normalize_css_import_path("module.js".to_string(), false),
650 "module.js"
651 );
652 }
653
654 #[test]
657 fn normalize_scss_bare_partial_gets_dot_slash() {
658 assert_eq!(
659 normalize_css_import_path("variables".to_string(), true),
660 "./variables"
661 );
662 }
663
664 #[test]
665 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
666 assert_eq!(
667 normalize_css_import_path("base/reset".to_string(), true),
668 "./base/reset"
669 );
670 }
671
672 #[test]
673 fn normalize_scss_builtin_stays_bare() {
674 assert_eq!(
675 normalize_css_import_path("sass:math".to_string(), true),
676 "sass:math"
677 );
678 }
679
680 #[test]
681 fn normalize_scss_relative_path_unchanged() {
682 assert_eq!(
683 normalize_css_import_path("../styles/variables".to_string(), true),
684 "../styles/variables"
685 );
686 }
687
688 #[test]
689 fn normalize_css_bare_extensionless_stays_bare() {
690 assert_eq!(
692 normalize_css_import_path("tailwindcss".to_string(), false),
693 "tailwindcss"
694 );
695 }
696
697 #[test]
700 fn normalize_scoped_package_with_css_extension_stays_bare() {
701 assert_eq!(
702 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
703 "@fontsource/monaspace-neon/400.css"
704 );
705 }
706
707 #[test]
708 fn normalize_scoped_package_with_scss_extension_stays_bare() {
709 assert_eq!(
710 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
711 "@company/design-system/tokens.scss"
712 );
713 }
714
715 #[test]
716 fn normalize_scoped_package_without_extension_stays_bare() {
717 assert_eq!(
718 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
719 "@fallow/design-system/styles"
720 );
721 }
722
723 #[test]
724 fn normalize_scoped_package_extensionless_scss_stays_bare() {
725 assert_eq!(
726 normalize_css_import_path("@company/tokens".to_string(), true),
727 "@company/tokens"
728 );
729 }
730
731 #[test]
732 fn normalize_path_alias_with_css_extension_stays_bare() {
733 assert_eq!(
738 normalize_css_import_path("@/components/Button.css".to_string(), false),
739 "@/components/Button.css"
740 );
741 }
742
743 #[test]
744 fn normalize_path_alias_extensionless_stays_bare() {
745 assert_eq!(
746 normalize_css_import_path("@/styles/variables".to_string(), false),
747 "@/styles/variables"
748 );
749 }
750
751 #[test]
754 fn strip_css_no_comments() {
755 let source = ".foo { color: red; }";
756 assert_eq!(strip_css_comments(source, false), source);
757 }
758
759 #[test]
760 fn strip_css_multiple_block_comments() {
761 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
762 let result = strip_css_comments(source, false);
763 assert!(!result.contains("comment-one"));
764 assert!(!result.contains("comment-two"));
765 assert!(result.contains(".foo"));
766 assert!(result.contains(".bar"));
767 }
768
769 #[test]
770 fn strip_scss_does_not_affect_non_scss() {
771 let source = "// this stays\n.foo { }";
773 let result = strip_css_comments(source, false);
774 assert!(result.contains("// this stays"));
775 }
776
777 #[test]
780 fn css_module_parses_suppressions() {
781 let info = parse_css_to_module(
782 fallow_types::discover::FileId(0),
783 Path::new("Component.module.css"),
784 "/* fallow-ignore-file */\n.btn { color: red; }",
785 0,
786 );
787 assert!(!info.suppressions.is_empty());
788 assert_eq!(info.suppressions[0].line, 0);
789 }
790
791 #[test]
794 fn extracts_class_starting_with_underscore() {
795 let names = export_names("._private { } .__dunder { }");
796 assert!(names.contains(&"_private".to_string()));
797 assert!(names.contains(&"__dunder".to_string()));
798 }
799
800 #[test]
801 fn ignores_id_selectors() {
802 let names = export_names("#myId { color: red; }");
803 assert!(!names.contains(&"myId".to_string()));
804 }
805
806 #[test]
807 fn ignores_element_selectors() {
808 let names = export_names("div { color: red; } span { }");
809 assert!(names.is_empty());
810 }
811
812 #[test]
815 fn extract_css_imports_at_import_quoted() {
816 let imports = extract_css_imports(r#"@import "./reset.css";"#, false);
817 assert_eq!(imports, vec!["./reset.css"]);
818 }
819
820 #[test]
821 fn extract_css_imports_at_import_url() {
822 let imports = extract_css_imports(r#"@import url("./reset.css");"#, false);
823 assert_eq!(imports, vec!["./reset.css"]);
824 }
825
826 #[test]
827 fn extract_css_imports_skips_remote_urls() {
828 let imports =
829 extract_css_imports(r#"@import "https://fonts.example.com/font.css";"#, false);
830 assert!(imports.is_empty());
831 }
832
833 #[test]
834 fn extract_css_imports_scss_use_normalizes_partial() {
835 let imports = extract_css_imports(r#"@use "variables";"#, true);
836 assert_eq!(imports, vec!["./variables"]);
837 }
838
839 #[test]
840 fn extract_css_imports_scss_forward_normalizes_partial() {
841 let imports = extract_css_imports(r#"@forward "tokens";"#, true);
842 assert_eq!(imports, vec!["./tokens"]);
843 }
844
845 #[test]
846 fn extract_css_imports_skips_comments() {
847 let imports = extract_css_imports(
848 r#"/* @import "./hidden.scss"; */
849@use "real";"#,
850 true,
851 );
852 assert_eq!(imports, vec!["./real"]);
853 }
854
855 #[test]
856 fn extract_css_imports_scss_at_import_kept_relative() {
857 let imports = extract_css_imports(r"@import 'Foo';", true);
858 assert_eq!(imports, vec!["./Foo"]);
860 }
861
862 #[test]
863 fn extract_css_imports_additional_data_string_body() {
864 let body = r#"@use "./src/styles/global.scss";"#;
866 let imports = extract_css_imports(body, true);
867 assert_eq!(imports, vec!["./src/styles/global.scss"]);
868 }
869}