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 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn export_names(source: &str) -> Vec<String> {
333 extract_css_module_exports(source)
334 .into_iter()
335 .filter_map(|e| match e.name {
336 ExportName::Named(n) => Some(n),
337 ExportName::Default => None,
338 })
339 .collect()
340 }
341
342 #[test]
345 fn is_css_file_css() {
346 assert!(is_css_file(Path::new("styles.css")));
347 }
348
349 #[test]
350 fn is_css_file_scss() {
351 assert!(is_css_file(Path::new("styles.scss")));
352 }
353
354 #[test]
355 fn is_css_file_rejects_js() {
356 assert!(!is_css_file(Path::new("app.js")));
357 }
358
359 #[test]
360 fn is_css_file_rejects_ts() {
361 assert!(!is_css_file(Path::new("app.ts")));
362 }
363
364 #[test]
365 fn is_css_file_rejects_less() {
366 assert!(!is_css_file(Path::new("styles.less")));
367 }
368
369 #[test]
370 fn is_css_file_rejects_no_extension() {
371 assert!(!is_css_file(Path::new("Makefile")));
372 }
373
374 #[test]
377 fn is_css_module_file_module_css() {
378 assert!(is_css_module_file(Path::new("Component.module.css")));
379 }
380
381 #[test]
382 fn is_css_module_file_module_scss() {
383 assert!(is_css_module_file(Path::new("Component.module.scss")));
384 }
385
386 #[test]
387 fn is_css_module_file_rejects_plain_css() {
388 assert!(!is_css_module_file(Path::new("styles.css")));
389 }
390
391 #[test]
392 fn is_css_module_file_rejects_plain_scss() {
393 assert!(!is_css_module_file(Path::new("styles.scss")));
394 }
395
396 #[test]
397 fn is_css_module_file_rejects_module_js() {
398 assert!(!is_css_module_file(Path::new("utils.module.js")));
399 }
400
401 #[test]
404 fn extracts_single_class() {
405 let names = export_names(".foo { color: red; }");
406 assert_eq!(names, vec!["foo"]);
407 }
408
409 #[test]
410 fn extracts_multiple_classes() {
411 let names = export_names(".foo { } .bar { }");
412 assert_eq!(names, vec!["foo", "bar"]);
413 }
414
415 #[test]
416 fn extracts_nested_classes() {
417 let names = export_names(".foo .bar { color: red; }");
418 assert!(names.contains(&"foo".to_string()));
419 assert!(names.contains(&"bar".to_string()));
420 }
421
422 #[test]
423 fn extracts_hyphenated_class() {
424 let names = export_names(".my-class { }");
425 assert_eq!(names, vec!["my-class"]);
426 }
427
428 #[test]
429 fn extracts_camel_case_class() {
430 let names = export_names(".myClass { }");
431 assert_eq!(names, vec!["myClass"]);
432 }
433
434 #[test]
435 fn extracts_underscore_class() {
436 let names = export_names("._hidden { } .__wrapper { }");
437 assert!(names.contains(&"_hidden".to_string()));
438 assert!(names.contains(&"__wrapper".to_string()));
439 }
440
441 #[test]
444 fn pseudo_selector_hover() {
445 let names = export_names(".foo:hover { color: blue; }");
446 assert_eq!(names, vec!["foo"]);
447 }
448
449 #[test]
450 fn pseudo_selector_focus() {
451 let names = export_names(".input:focus { outline: none; }");
452 assert_eq!(names, vec!["input"]);
453 }
454
455 #[test]
456 fn pseudo_element_before() {
457 let names = export_names(".icon::before { content: ''; }");
458 assert_eq!(names, vec!["icon"]);
459 }
460
461 #[test]
462 fn combined_pseudo_selectors() {
463 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
464 assert_eq!(names, vec!["btn"]);
466 }
467
468 #[test]
471 fn classes_inside_media_query() {
472 let names = export_names(
473 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
474 );
475 assert!(names.contains(&"mobile-nav".to_string()));
476 assert!(names.contains(&"desktop-nav".to_string()));
477 }
478
479 #[test]
482 fn deduplicates_repeated_class() {
483 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
484 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
485 }
486
487 #[test]
490 fn empty_source() {
491 let names = export_names("");
492 assert!(names.is_empty());
493 }
494
495 #[test]
496 fn no_classes() {
497 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
498 assert!(names.is_empty());
499 }
500
501 #[test]
502 fn ignores_classes_in_block_comments() {
503 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
508 let names = export_names(&stripped);
509 assert!(!names.contains(&"fake".to_string()));
510 assert!(names.contains(&"real".to_string()));
511 }
512
513 #[test]
514 fn ignores_classes_in_strings() {
515 let names = export_names(r#".real { content: ".fake"; }"#);
516 assert!(names.contains(&"real".to_string()));
517 assert!(!names.contains(&"fake".to_string()));
518 }
519
520 #[test]
521 fn ignores_classes_in_url() {
522 let names = export_names(".real { background: url(./images/hero.png); }");
523 assert!(names.contains(&"real".to_string()));
524 assert!(!names.contains(&"png".to_string()));
526 }
527
528 #[test]
531 fn strip_css_block_comment() {
532 let result = strip_css_comments("/* removed */ .kept { }", false);
533 assert!(!result.contains("removed"));
534 assert!(result.contains(".kept"));
535 }
536
537 #[test]
538 fn strip_scss_line_comment() {
539 let result = strip_css_comments("// removed\n.kept { }", true);
540 assert!(!result.contains("removed"));
541 assert!(result.contains(".kept"));
542 }
543
544 #[test]
545 fn strip_scss_preserves_css_outside_comments() {
546 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
547 let result = strip_css_comments(source, true);
548 assert!(result.contains(".visible"));
549 }
550
551 #[test]
554 fn url_import_http() {
555 assert!(is_css_url_import("http://example.com/style.css"));
556 }
557
558 #[test]
559 fn url_import_https() {
560 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
561 }
562
563 #[test]
564 fn url_import_data() {
565 assert!(is_css_url_import("data:text/css;base64,abc"));
566 }
567
568 #[test]
569 fn url_import_local_not_skipped() {
570 assert!(!is_css_url_import("./local.css"));
571 }
572
573 #[test]
574 fn url_import_bare_specifier_not_skipped() {
575 assert!(!is_css_url_import("tailwindcss"));
576 }
577
578 #[test]
581 fn normalize_relative_dot_path_unchanged() {
582 assert_eq!(
583 normalize_css_import_path("./reset.css".to_string(), false),
584 "./reset.css"
585 );
586 }
587
588 #[test]
589 fn normalize_parent_relative_path_unchanged() {
590 assert_eq!(
591 normalize_css_import_path("../shared.scss".to_string(), false),
592 "../shared.scss"
593 );
594 }
595
596 #[test]
597 fn normalize_absolute_path_unchanged() {
598 assert_eq!(
599 normalize_css_import_path("/styles/main.css".to_string(), false),
600 "/styles/main.css"
601 );
602 }
603
604 #[test]
605 fn normalize_url_unchanged() {
606 assert_eq!(
607 normalize_css_import_path("https://example.com/style.css".to_string(), false),
608 "https://example.com/style.css"
609 );
610 }
611
612 #[test]
613 fn normalize_bare_css_gets_dot_slash() {
614 assert_eq!(
615 normalize_css_import_path("app.css".to_string(), false),
616 "./app.css"
617 );
618 }
619
620 #[test]
621 fn normalize_bare_scss_gets_dot_slash() {
622 assert_eq!(
623 normalize_css_import_path("vars.scss".to_string(), false),
624 "./vars.scss"
625 );
626 }
627
628 #[test]
629 fn normalize_bare_sass_gets_dot_slash() {
630 assert_eq!(
631 normalize_css_import_path("main.sass".to_string(), false),
632 "./main.sass"
633 );
634 }
635
636 #[test]
637 fn normalize_bare_less_gets_dot_slash() {
638 assert_eq!(
639 normalize_css_import_path("theme.less".to_string(), false),
640 "./theme.less"
641 );
642 }
643
644 #[test]
645 fn normalize_bare_js_extension_stays_bare() {
646 assert_eq!(
647 normalize_css_import_path("module.js".to_string(), false),
648 "module.js"
649 );
650 }
651
652 #[test]
655 fn normalize_scss_bare_partial_gets_dot_slash() {
656 assert_eq!(
657 normalize_css_import_path("variables".to_string(), true),
658 "./variables"
659 );
660 }
661
662 #[test]
663 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
664 assert_eq!(
665 normalize_css_import_path("base/reset".to_string(), true),
666 "./base/reset"
667 );
668 }
669
670 #[test]
671 fn normalize_scss_builtin_stays_bare() {
672 assert_eq!(
673 normalize_css_import_path("sass:math".to_string(), true),
674 "sass:math"
675 );
676 }
677
678 #[test]
679 fn normalize_scss_relative_path_unchanged() {
680 assert_eq!(
681 normalize_css_import_path("../styles/variables".to_string(), true),
682 "../styles/variables"
683 );
684 }
685
686 #[test]
687 fn normalize_css_bare_extensionless_stays_bare() {
688 assert_eq!(
690 normalize_css_import_path("tailwindcss".to_string(), false),
691 "tailwindcss"
692 );
693 }
694
695 #[test]
698 fn normalize_scoped_package_with_css_extension_stays_bare() {
699 assert_eq!(
700 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
701 "@fontsource/monaspace-neon/400.css"
702 );
703 }
704
705 #[test]
706 fn normalize_scoped_package_with_scss_extension_stays_bare() {
707 assert_eq!(
708 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
709 "@company/design-system/tokens.scss"
710 );
711 }
712
713 #[test]
714 fn normalize_scoped_package_without_extension_stays_bare() {
715 assert_eq!(
716 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
717 "@fallow/design-system/styles"
718 );
719 }
720
721 #[test]
722 fn normalize_scoped_package_extensionless_scss_stays_bare() {
723 assert_eq!(
724 normalize_css_import_path("@company/tokens".to_string(), true),
725 "@company/tokens"
726 );
727 }
728
729 #[test]
730 fn normalize_path_alias_with_css_extension_stays_bare() {
731 assert_eq!(
736 normalize_css_import_path("@/components/Button.css".to_string(), false),
737 "@/components/Button.css"
738 );
739 }
740
741 #[test]
742 fn normalize_path_alias_extensionless_stays_bare() {
743 assert_eq!(
744 normalize_css_import_path("@/styles/variables".to_string(), false),
745 "@/styles/variables"
746 );
747 }
748
749 #[test]
752 fn strip_css_no_comments() {
753 let source = ".foo { color: red; }";
754 assert_eq!(strip_css_comments(source, false), source);
755 }
756
757 #[test]
758 fn strip_css_multiple_block_comments() {
759 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
760 let result = strip_css_comments(source, false);
761 assert!(!result.contains("comment-one"));
762 assert!(!result.contains("comment-two"));
763 assert!(result.contains(".foo"));
764 assert!(result.contains(".bar"));
765 }
766
767 #[test]
768 fn strip_scss_does_not_affect_non_scss() {
769 let source = "// this stays\n.foo { }";
771 let result = strip_css_comments(source, false);
772 assert!(result.contains("// this stays"));
773 }
774
775 #[test]
778 fn css_module_parses_suppressions() {
779 let info = parse_css_to_module(
780 fallow_types::discover::FileId(0),
781 Path::new("Component.module.css"),
782 "/* fallow-ignore-file */\n.btn { color: red; }",
783 0,
784 );
785 assert!(!info.suppressions.is_empty());
786 assert_eq!(info.suppressions[0].line, 0);
787 }
788
789 #[test]
792 fn extracts_class_starting_with_underscore() {
793 let names = export_names("._private { } .__dunder { }");
794 assert!(names.contains(&"_private".to_string()));
795 assert!(names.contains(&"__dunder".to_string()));
796 }
797
798 #[test]
799 fn ignores_id_selectors() {
800 let names = export_names("#myId { color: red; }");
801 assert!(!names.contains(&"myId".to_string()));
802 }
803
804 #[test]
805 fn ignores_element_selectors() {
806 let names = export_names("div { color: red; } span { }");
807 assert!(names.is_empty());
808 }
809
810 #[test]
813 fn extract_css_imports_at_import_quoted() {
814 let imports = extract_css_imports(r#"@import "./reset.css";"#, false);
815 assert_eq!(imports, vec!["./reset.css"]);
816 }
817
818 #[test]
819 fn extract_css_imports_at_import_url() {
820 let imports = extract_css_imports(r#"@import url("./reset.css");"#, false);
821 assert_eq!(imports, vec!["./reset.css"]);
822 }
823
824 #[test]
825 fn extract_css_imports_skips_remote_urls() {
826 let imports =
827 extract_css_imports(r#"@import "https://fonts.example.com/font.css";"#, false);
828 assert!(imports.is_empty());
829 }
830
831 #[test]
832 fn extract_css_imports_scss_use_normalizes_partial() {
833 let imports = extract_css_imports(r#"@use "variables";"#, true);
834 assert_eq!(imports, vec!["./variables"]);
835 }
836
837 #[test]
838 fn extract_css_imports_scss_forward_normalizes_partial() {
839 let imports = extract_css_imports(r#"@forward "tokens";"#, true);
840 assert_eq!(imports, vec!["./tokens"]);
841 }
842
843 #[test]
844 fn extract_css_imports_skips_comments() {
845 let imports = extract_css_imports(
846 r#"/* @import "./hidden.scss"; */
847@use "real";"#,
848 true,
849 );
850 assert_eq!(imports, vec!["./real"]);
851 }
852
853 #[test]
854 fn extract_css_imports_scss_at_import_kept_relative() {
855 let imports = extract_css_imports(r"@import 'Foo';", true);
856 assert_eq!(imports, vec!["./Foo"]);
858 }
859
860 #[test]
861 fn extract_css_imports_additional_data_string_body() {
862 let body = r#"@use "./src/styles/global.scss";"#;
864 let imports = extract_css_imports(body, true);
865 assert_eq!(imports, vec!["./src/styles/global.scss"]);
866 }
867}