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
62fn is_css_module_file(path: &Path) -> bool {
63 is_css_file(path)
64 && path
65 .file_stem()
66 .and_then(|s| s.to_str())
67 .is_some_and(|stem| stem.ends_with(".module"))
68}
69
70fn is_css_url_import(source: &str) -> bool {
72 source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
73}
74
75fn normalize_css_import_path(path: String, is_scss: bool) -> String {
87 if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
88 return path;
89 }
90 if path.starts_with('@') && path.contains('/') {
93 return path;
94 }
95 let ext = std::path::Path::new(&path)
97 .extension()
98 .and_then(|e| e.to_str());
99 match ext {
100 Some(e)
101 if e.eq_ignore_ascii_case("css")
102 || e.eq_ignore_ascii_case("scss")
103 || e.eq_ignore_ascii_case("sass")
104 || e.eq_ignore_ascii_case("less") =>
105 {
106 format!("./{path}")
107 }
108 _ => {
109 if is_scss && !path.contains(':') {
113 format!("./{path}")
114 } else {
115 path
116 }
117 }
118 }
119}
120
121fn strip_css_comments(source: &str, is_scss: bool) -> String {
123 let stripped = CSS_COMMENT_RE.replace_all(source, "");
124 if is_scss {
125 SCSS_LINE_COMMENT_RE.replace_all(&stripped, "").into_owned()
126 } else {
127 stripped.into_owned()
128 }
129}
130
131pub fn extract_css_module_exports(source: &str) -> Vec<ExportInfo> {
133 let cleaned = CSS_NON_SELECTOR_RE.replace_all(source, "");
134 let mut seen = rustc_hash::FxHashSet::default();
135 let mut exports = Vec::new();
136 for cap in CSS_CLASS_RE.captures_iter(&cleaned) {
137 if let Some(m) = cap.get(1) {
138 let class_name = m.as_str().to_string();
139 if seen.insert(class_name.clone()) {
140 exports.push(ExportInfo {
141 name: ExportName::Named(class_name),
142 local_name: None,
143 is_type_only: false,
144 visibility: VisibilityTag::None,
145 span: Span::default(),
146 members: Vec::new(),
147 super_class: None,
148 });
149 }
150 }
151 }
152 exports
153}
154
155pub(crate) fn parse_css_to_module(
157 file_id: FileId,
158 path: &Path,
159 source: &str,
160 content_hash: u64,
161) -> ModuleInfo {
162 let suppressions = crate::suppress::parse_suppressions_from_source(source);
163 let is_scss = path
164 .extension()
165 .and_then(|e| e.to_str())
166 .is_some_and(|ext| ext == "scss");
167
168 let stripped = strip_css_comments(source, is_scss);
170
171 let mut imports = Vec::new();
172
173 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
175 let source_path = cap
176 .get(1)
177 .or_else(|| cap.get(2))
178 .or_else(|| cap.get(3))
179 .map(|m| m.as_str().trim().to_string());
180 if let Some(src) = source_path
181 && !src.is_empty()
182 && !is_css_url_import(&src)
183 {
184 let src = normalize_css_import_path(src, is_scss);
187 imports.push(ImportInfo {
188 source: src,
189 imported_name: ImportedName::SideEffect,
190 local_name: String::new(),
191 is_type_only: false,
192 span: Span::default(),
193 source_span: Span::default(),
194 });
195 }
196 }
197
198 if is_scss {
200 for cap in SCSS_USE_RE.captures_iter(&stripped) {
201 if let Some(m) = cap.get(1) {
202 imports.push(ImportInfo {
203 source: normalize_css_import_path(m.as_str().to_string(), true),
204 imported_name: ImportedName::SideEffect,
205 local_name: String::new(),
206 is_type_only: false,
207 span: Span::default(),
208 source_span: Span::default(),
209 });
210 }
211 }
212 }
213
214 let has_apply = CSS_APPLY_RE.is_match(&stripped);
217 let has_tailwind = CSS_TAILWIND_RE.is_match(&stripped);
218 if has_apply || has_tailwind {
219 imports.push(ImportInfo {
220 source: "tailwindcss".to_string(),
221 imported_name: ImportedName::SideEffect,
222 local_name: String::new(),
223 is_type_only: false,
224 span: Span::default(),
225 source_span: Span::default(),
226 });
227 }
228
229 let exports = if is_css_module_file(path) {
231 extract_css_module_exports(&stripped)
232 } else {
233 Vec::new()
234 };
235
236 ModuleInfo {
237 file_id,
238 exports,
239 imports,
240 re_exports: Vec::new(),
241 dynamic_imports: Vec::new(),
242 dynamic_import_patterns: Vec::new(),
243 require_calls: Vec::new(),
244 member_accesses: Vec::new(),
245 whole_object_uses: Vec::new(),
246 has_cjs_exports: false,
247 content_hash,
248 suppressions,
249 unused_import_bindings: Vec::new(),
250 type_referenced_import_bindings: Vec::new(),
251 value_referenced_import_bindings: Vec::new(),
252 line_offsets: fallow_types::extract::compute_line_offsets(source),
253 complexity: Vec::new(),
254 flag_uses: Vec::new(),
255 class_heritage: vec![],
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn export_names(source: &str) -> Vec<String> {
265 extract_css_module_exports(source)
266 .into_iter()
267 .filter_map(|e| match e.name {
268 ExportName::Named(n) => Some(n),
269 ExportName::Default => None,
270 })
271 .collect()
272 }
273
274 #[test]
277 fn is_css_file_css() {
278 assert!(is_css_file(Path::new("styles.css")));
279 }
280
281 #[test]
282 fn is_css_file_scss() {
283 assert!(is_css_file(Path::new("styles.scss")));
284 }
285
286 #[test]
287 fn is_css_file_rejects_js() {
288 assert!(!is_css_file(Path::new("app.js")));
289 }
290
291 #[test]
292 fn is_css_file_rejects_ts() {
293 assert!(!is_css_file(Path::new("app.ts")));
294 }
295
296 #[test]
297 fn is_css_file_rejects_less() {
298 assert!(!is_css_file(Path::new("styles.less")));
299 }
300
301 #[test]
302 fn is_css_file_rejects_no_extension() {
303 assert!(!is_css_file(Path::new("Makefile")));
304 }
305
306 #[test]
309 fn is_css_module_file_module_css() {
310 assert!(is_css_module_file(Path::new("Component.module.css")));
311 }
312
313 #[test]
314 fn is_css_module_file_module_scss() {
315 assert!(is_css_module_file(Path::new("Component.module.scss")));
316 }
317
318 #[test]
319 fn is_css_module_file_rejects_plain_css() {
320 assert!(!is_css_module_file(Path::new("styles.css")));
321 }
322
323 #[test]
324 fn is_css_module_file_rejects_plain_scss() {
325 assert!(!is_css_module_file(Path::new("styles.scss")));
326 }
327
328 #[test]
329 fn is_css_module_file_rejects_module_js() {
330 assert!(!is_css_module_file(Path::new("utils.module.js")));
331 }
332
333 #[test]
336 fn extracts_single_class() {
337 let names = export_names(".foo { color: red; }");
338 assert_eq!(names, vec!["foo"]);
339 }
340
341 #[test]
342 fn extracts_multiple_classes() {
343 let names = export_names(".foo { } .bar { }");
344 assert_eq!(names, vec!["foo", "bar"]);
345 }
346
347 #[test]
348 fn extracts_nested_classes() {
349 let names = export_names(".foo .bar { color: red; }");
350 assert!(names.contains(&"foo".to_string()));
351 assert!(names.contains(&"bar".to_string()));
352 }
353
354 #[test]
355 fn extracts_hyphenated_class() {
356 let names = export_names(".my-class { }");
357 assert_eq!(names, vec!["my-class"]);
358 }
359
360 #[test]
361 fn extracts_camel_case_class() {
362 let names = export_names(".myClass { }");
363 assert_eq!(names, vec!["myClass"]);
364 }
365
366 #[test]
367 fn extracts_underscore_class() {
368 let names = export_names("._hidden { } .__wrapper { }");
369 assert!(names.contains(&"_hidden".to_string()));
370 assert!(names.contains(&"__wrapper".to_string()));
371 }
372
373 #[test]
376 fn pseudo_selector_hover() {
377 let names = export_names(".foo:hover { color: blue; }");
378 assert_eq!(names, vec!["foo"]);
379 }
380
381 #[test]
382 fn pseudo_selector_focus() {
383 let names = export_names(".input:focus { outline: none; }");
384 assert_eq!(names, vec!["input"]);
385 }
386
387 #[test]
388 fn pseudo_element_before() {
389 let names = export_names(".icon::before { content: ''; }");
390 assert_eq!(names, vec!["icon"]);
391 }
392
393 #[test]
394 fn combined_pseudo_selectors() {
395 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
396 assert_eq!(names, vec!["btn"]);
398 }
399
400 #[test]
403 fn classes_inside_media_query() {
404 let names = export_names(
405 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
406 );
407 assert!(names.contains(&"mobile-nav".to_string()));
408 assert!(names.contains(&"desktop-nav".to_string()));
409 }
410
411 #[test]
414 fn deduplicates_repeated_class() {
415 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
416 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
417 }
418
419 #[test]
422 fn empty_source() {
423 let names = export_names("");
424 assert!(names.is_empty());
425 }
426
427 #[test]
428 fn no_classes() {
429 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
430 assert!(names.is_empty());
431 }
432
433 #[test]
434 fn ignores_classes_in_block_comments() {
435 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
440 let names = export_names(&stripped);
441 assert!(!names.contains(&"fake".to_string()));
442 assert!(names.contains(&"real".to_string()));
443 }
444
445 #[test]
446 fn ignores_classes_in_strings() {
447 let names = export_names(r#".real { content: ".fake"; }"#);
448 assert!(names.contains(&"real".to_string()));
449 assert!(!names.contains(&"fake".to_string()));
450 }
451
452 #[test]
453 fn ignores_classes_in_url() {
454 let names = export_names(".real { background: url(./images/hero.png); }");
455 assert!(names.contains(&"real".to_string()));
456 assert!(!names.contains(&"png".to_string()));
458 }
459
460 #[test]
463 fn strip_css_block_comment() {
464 let result = strip_css_comments("/* removed */ .kept { }", false);
465 assert!(!result.contains("removed"));
466 assert!(result.contains(".kept"));
467 }
468
469 #[test]
470 fn strip_scss_line_comment() {
471 let result = strip_css_comments("// removed\n.kept { }", true);
472 assert!(!result.contains("removed"));
473 assert!(result.contains(".kept"));
474 }
475
476 #[test]
477 fn strip_scss_preserves_css_outside_comments() {
478 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
479 let result = strip_css_comments(source, true);
480 assert!(result.contains(".visible"));
481 }
482
483 #[test]
486 fn url_import_http() {
487 assert!(is_css_url_import("http://example.com/style.css"));
488 }
489
490 #[test]
491 fn url_import_https() {
492 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
493 }
494
495 #[test]
496 fn url_import_data() {
497 assert!(is_css_url_import("data:text/css;base64,abc"));
498 }
499
500 #[test]
501 fn url_import_local_not_skipped() {
502 assert!(!is_css_url_import("./local.css"));
503 }
504
505 #[test]
506 fn url_import_bare_specifier_not_skipped() {
507 assert!(!is_css_url_import("tailwindcss"));
508 }
509
510 #[test]
513 fn normalize_relative_dot_path_unchanged() {
514 assert_eq!(
515 normalize_css_import_path("./reset.css".to_string(), false),
516 "./reset.css"
517 );
518 }
519
520 #[test]
521 fn normalize_parent_relative_path_unchanged() {
522 assert_eq!(
523 normalize_css_import_path("../shared.scss".to_string(), false),
524 "../shared.scss"
525 );
526 }
527
528 #[test]
529 fn normalize_absolute_path_unchanged() {
530 assert_eq!(
531 normalize_css_import_path("/styles/main.css".to_string(), false),
532 "/styles/main.css"
533 );
534 }
535
536 #[test]
537 fn normalize_url_unchanged() {
538 assert_eq!(
539 normalize_css_import_path("https://example.com/style.css".to_string(), false),
540 "https://example.com/style.css"
541 );
542 }
543
544 #[test]
545 fn normalize_bare_css_gets_dot_slash() {
546 assert_eq!(
547 normalize_css_import_path("app.css".to_string(), false),
548 "./app.css"
549 );
550 }
551
552 #[test]
553 fn normalize_bare_scss_gets_dot_slash() {
554 assert_eq!(
555 normalize_css_import_path("vars.scss".to_string(), false),
556 "./vars.scss"
557 );
558 }
559
560 #[test]
561 fn normalize_bare_sass_gets_dot_slash() {
562 assert_eq!(
563 normalize_css_import_path("main.sass".to_string(), false),
564 "./main.sass"
565 );
566 }
567
568 #[test]
569 fn normalize_bare_less_gets_dot_slash() {
570 assert_eq!(
571 normalize_css_import_path("theme.less".to_string(), false),
572 "./theme.less"
573 );
574 }
575
576 #[test]
577 fn normalize_bare_js_extension_stays_bare() {
578 assert_eq!(
579 normalize_css_import_path("module.js".to_string(), false),
580 "module.js"
581 );
582 }
583
584 #[test]
587 fn normalize_scss_bare_partial_gets_dot_slash() {
588 assert_eq!(
589 normalize_css_import_path("variables".to_string(), true),
590 "./variables"
591 );
592 }
593
594 #[test]
595 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
596 assert_eq!(
597 normalize_css_import_path("base/reset".to_string(), true),
598 "./base/reset"
599 );
600 }
601
602 #[test]
603 fn normalize_scss_builtin_stays_bare() {
604 assert_eq!(
605 normalize_css_import_path("sass:math".to_string(), true),
606 "sass:math"
607 );
608 }
609
610 #[test]
611 fn normalize_scss_relative_path_unchanged() {
612 assert_eq!(
613 normalize_css_import_path("../styles/variables".to_string(), true),
614 "../styles/variables"
615 );
616 }
617
618 #[test]
619 fn normalize_css_bare_extensionless_stays_bare() {
620 assert_eq!(
622 normalize_css_import_path("tailwindcss".to_string(), false),
623 "tailwindcss"
624 );
625 }
626
627 #[test]
630 fn normalize_scoped_package_with_css_extension_stays_bare() {
631 assert_eq!(
632 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
633 "@fontsource/monaspace-neon/400.css"
634 );
635 }
636
637 #[test]
638 fn normalize_scoped_package_with_scss_extension_stays_bare() {
639 assert_eq!(
640 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
641 "@company/design-system/tokens.scss"
642 );
643 }
644
645 #[test]
646 fn normalize_scoped_package_without_extension_stays_bare() {
647 assert_eq!(
648 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
649 "@fallow/design-system/styles"
650 );
651 }
652
653 #[test]
654 fn normalize_scoped_package_extensionless_scss_stays_bare() {
655 assert_eq!(
656 normalize_css_import_path("@company/tokens".to_string(), true),
657 "@company/tokens"
658 );
659 }
660
661 #[test]
662 fn normalize_path_alias_with_css_extension_stays_bare() {
663 assert_eq!(
668 normalize_css_import_path("@/components/Button.css".to_string(), false),
669 "@/components/Button.css"
670 );
671 }
672
673 #[test]
674 fn normalize_path_alias_extensionless_stays_bare() {
675 assert_eq!(
676 normalize_css_import_path("@/styles/variables".to_string(), false),
677 "@/styles/variables"
678 );
679 }
680
681 #[test]
684 fn strip_css_no_comments() {
685 let source = ".foo { color: red; }";
686 assert_eq!(strip_css_comments(source, false), source);
687 }
688
689 #[test]
690 fn strip_css_multiple_block_comments() {
691 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
692 let result = strip_css_comments(source, false);
693 assert!(!result.contains("comment-one"));
694 assert!(!result.contains("comment-two"));
695 assert!(result.contains(".foo"));
696 assert!(result.contains(".bar"));
697 }
698
699 #[test]
700 fn strip_scss_does_not_affect_non_scss() {
701 let source = "// this stays\n.foo { }";
703 let result = strip_css_comments(source, false);
704 assert!(result.contains("// this stays"));
705 }
706
707 #[test]
710 fn css_module_parses_suppressions() {
711 let info = parse_css_to_module(
712 fallow_types::discover::FileId(0),
713 Path::new("Component.module.css"),
714 "/* fallow-ignore-file */\n.btn { color: red; }",
715 0,
716 );
717 assert!(!info.suppressions.is_empty());
718 assert_eq!(info.suppressions[0].line, 0);
719 }
720
721 #[test]
724 fn extracts_class_starting_with_underscore() {
725 let names = export_names("._private { } .__dunder { }");
726 assert!(names.contains(&"_private".to_string()));
727 assert!(names.contains(&"__dunder".to_string()));
728 }
729
730 #[test]
731 fn ignores_id_selectors() {
732 let names = export_names("#myId { color: red; }");
733 assert!(!names.contains(&"myId".to_string()));
734 }
735
736 #[test]
737 fn ignores_element_selectors() {
738 let names = export_names("div { color: red; } span { }");
739 assert!(names.is_empty());
740 }
741}