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 line_offsets: fallow_types::extract::compute_line_offsets(source),
251 complexity: Vec::new(),
252 flag_uses: Vec::new(),
253 class_heritage: vec![],
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn export_names(source: &str) -> Vec<String> {
263 extract_css_module_exports(source)
264 .into_iter()
265 .filter_map(|e| match e.name {
266 ExportName::Named(n) => Some(n),
267 ExportName::Default => None,
268 })
269 .collect()
270 }
271
272 #[test]
275 fn is_css_file_css() {
276 assert!(is_css_file(Path::new("styles.css")));
277 }
278
279 #[test]
280 fn is_css_file_scss() {
281 assert!(is_css_file(Path::new("styles.scss")));
282 }
283
284 #[test]
285 fn is_css_file_rejects_js() {
286 assert!(!is_css_file(Path::new("app.js")));
287 }
288
289 #[test]
290 fn is_css_file_rejects_ts() {
291 assert!(!is_css_file(Path::new("app.ts")));
292 }
293
294 #[test]
295 fn is_css_file_rejects_less() {
296 assert!(!is_css_file(Path::new("styles.less")));
297 }
298
299 #[test]
300 fn is_css_file_rejects_no_extension() {
301 assert!(!is_css_file(Path::new("Makefile")));
302 }
303
304 #[test]
307 fn is_css_module_file_module_css() {
308 assert!(is_css_module_file(Path::new("Component.module.css")));
309 }
310
311 #[test]
312 fn is_css_module_file_module_scss() {
313 assert!(is_css_module_file(Path::new("Component.module.scss")));
314 }
315
316 #[test]
317 fn is_css_module_file_rejects_plain_css() {
318 assert!(!is_css_module_file(Path::new("styles.css")));
319 }
320
321 #[test]
322 fn is_css_module_file_rejects_plain_scss() {
323 assert!(!is_css_module_file(Path::new("styles.scss")));
324 }
325
326 #[test]
327 fn is_css_module_file_rejects_module_js() {
328 assert!(!is_css_module_file(Path::new("utils.module.js")));
329 }
330
331 #[test]
334 fn extracts_single_class() {
335 let names = export_names(".foo { color: red; }");
336 assert_eq!(names, vec!["foo"]);
337 }
338
339 #[test]
340 fn extracts_multiple_classes() {
341 let names = export_names(".foo { } .bar { }");
342 assert_eq!(names, vec!["foo", "bar"]);
343 }
344
345 #[test]
346 fn extracts_nested_classes() {
347 let names = export_names(".foo .bar { color: red; }");
348 assert!(names.contains(&"foo".to_string()));
349 assert!(names.contains(&"bar".to_string()));
350 }
351
352 #[test]
353 fn extracts_hyphenated_class() {
354 let names = export_names(".my-class { }");
355 assert_eq!(names, vec!["my-class"]);
356 }
357
358 #[test]
359 fn extracts_camel_case_class() {
360 let names = export_names(".myClass { }");
361 assert_eq!(names, vec!["myClass"]);
362 }
363
364 #[test]
365 fn extracts_underscore_class() {
366 let names = export_names("._hidden { } .__wrapper { }");
367 assert!(names.contains(&"_hidden".to_string()));
368 assert!(names.contains(&"__wrapper".to_string()));
369 }
370
371 #[test]
374 fn pseudo_selector_hover() {
375 let names = export_names(".foo:hover { color: blue; }");
376 assert_eq!(names, vec!["foo"]);
377 }
378
379 #[test]
380 fn pseudo_selector_focus() {
381 let names = export_names(".input:focus { outline: none; }");
382 assert_eq!(names, vec!["input"]);
383 }
384
385 #[test]
386 fn pseudo_element_before() {
387 let names = export_names(".icon::before { content: ''; }");
388 assert_eq!(names, vec!["icon"]);
389 }
390
391 #[test]
392 fn combined_pseudo_selectors() {
393 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
394 assert_eq!(names, vec!["btn"]);
396 }
397
398 #[test]
401 fn classes_inside_media_query() {
402 let names = export_names(
403 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
404 );
405 assert!(names.contains(&"mobile-nav".to_string()));
406 assert!(names.contains(&"desktop-nav".to_string()));
407 }
408
409 #[test]
412 fn deduplicates_repeated_class() {
413 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
414 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
415 }
416
417 #[test]
420 fn empty_source() {
421 let names = export_names("");
422 assert!(names.is_empty());
423 }
424
425 #[test]
426 fn no_classes() {
427 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
428 assert!(names.is_empty());
429 }
430
431 #[test]
432 fn ignores_classes_in_block_comments() {
433 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
438 let names = export_names(&stripped);
439 assert!(!names.contains(&"fake".to_string()));
440 assert!(names.contains(&"real".to_string()));
441 }
442
443 #[test]
444 fn ignores_classes_in_strings() {
445 let names = export_names(r#".real { content: ".fake"; }"#);
446 assert!(names.contains(&"real".to_string()));
447 assert!(!names.contains(&"fake".to_string()));
448 }
449
450 #[test]
451 fn ignores_classes_in_url() {
452 let names = export_names(".real { background: url(./images/hero.png); }");
453 assert!(names.contains(&"real".to_string()));
454 assert!(!names.contains(&"png".to_string()));
456 }
457
458 #[test]
461 fn strip_css_block_comment() {
462 let result = strip_css_comments("/* removed */ .kept { }", false);
463 assert!(!result.contains("removed"));
464 assert!(result.contains(".kept"));
465 }
466
467 #[test]
468 fn strip_scss_line_comment() {
469 let result = strip_css_comments("// removed\n.kept { }", true);
470 assert!(!result.contains("removed"));
471 assert!(result.contains(".kept"));
472 }
473
474 #[test]
475 fn strip_scss_preserves_css_outside_comments() {
476 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
477 let result = strip_css_comments(source, true);
478 assert!(result.contains(".visible"));
479 }
480
481 #[test]
484 fn url_import_http() {
485 assert!(is_css_url_import("http://example.com/style.css"));
486 }
487
488 #[test]
489 fn url_import_https() {
490 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
491 }
492
493 #[test]
494 fn url_import_data() {
495 assert!(is_css_url_import("data:text/css;base64,abc"));
496 }
497
498 #[test]
499 fn url_import_local_not_skipped() {
500 assert!(!is_css_url_import("./local.css"));
501 }
502
503 #[test]
504 fn url_import_bare_specifier_not_skipped() {
505 assert!(!is_css_url_import("tailwindcss"));
506 }
507
508 #[test]
511 fn normalize_relative_dot_path_unchanged() {
512 assert_eq!(
513 normalize_css_import_path("./reset.css".to_string(), false),
514 "./reset.css"
515 );
516 }
517
518 #[test]
519 fn normalize_parent_relative_path_unchanged() {
520 assert_eq!(
521 normalize_css_import_path("../shared.scss".to_string(), false),
522 "../shared.scss"
523 );
524 }
525
526 #[test]
527 fn normalize_absolute_path_unchanged() {
528 assert_eq!(
529 normalize_css_import_path("/styles/main.css".to_string(), false),
530 "/styles/main.css"
531 );
532 }
533
534 #[test]
535 fn normalize_url_unchanged() {
536 assert_eq!(
537 normalize_css_import_path("https://example.com/style.css".to_string(), false),
538 "https://example.com/style.css"
539 );
540 }
541
542 #[test]
543 fn normalize_bare_css_gets_dot_slash() {
544 assert_eq!(
545 normalize_css_import_path("app.css".to_string(), false),
546 "./app.css"
547 );
548 }
549
550 #[test]
551 fn normalize_bare_scss_gets_dot_slash() {
552 assert_eq!(
553 normalize_css_import_path("vars.scss".to_string(), false),
554 "./vars.scss"
555 );
556 }
557
558 #[test]
559 fn normalize_bare_sass_gets_dot_slash() {
560 assert_eq!(
561 normalize_css_import_path("main.sass".to_string(), false),
562 "./main.sass"
563 );
564 }
565
566 #[test]
567 fn normalize_bare_less_gets_dot_slash() {
568 assert_eq!(
569 normalize_css_import_path("theme.less".to_string(), false),
570 "./theme.less"
571 );
572 }
573
574 #[test]
575 fn normalize_bare_js_extension_stays_bare() {
576 assert_eq!(
577 normalize_css_import_path("module.js".to_string(), false),
578 "module.js"
579 );
580 }
581
582 #[test]
585 fn normalize_scss_bare_partial_gets_dot_slash() {
586 assert_eq!(
587 normalize_css_import_path("variables".to_string(), true),
588 "./variables"
589 );
590 }
591
592 #[test]
593 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
594 assert_eq!(
595 normalize_css_import_path("base/reset".to_string(), true),
596 "./base/reset"
597 );
598 }
599
600 #[test]
601 fn normalize_scss_builtin_stays_bare() {
602 assert_eq!(
603 normalize_css_import_path("sass:math".to_string(), true),
604 "sass:math"
605 );
606 }
607
608 #[test]
609 fn normalize_scss_relative_path_unchanged() {
610 assert_eq!(
611 normalize_css_import_path("../styles/variables".to_string(), true),
612 "../styles/variables"
613 );
614 }
615
616 #[test]
617 fn normalize_css_bare_extensionless_stays_bare() {
618 assert_eq!(
620 normalize_css_import_path("tailwindcss".to_string(), false),
621 "tailwindcss"
622 );
623 }
624
625 #[test]
628 fn normalize_scoped_package_with_css_extension_stays_bare() {
629 assert_eq!(
630 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
631 "@fontsource/monaspace-neon/400.css"
632 );
633 }
634
635 #[test]
636 fn normalize_scoped_package_with_scss_extension_stays_bare() {
637 assert_eq!(
638 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
639 "@company/design-system/tokens.scss"
640 );
641 }
642
643 #[test]
644 fn normalize_scoped_package_without_extension_stays_bare() {
645 assert_eq!(
646 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
647 "@fallow/design-system/styles"
648 );
649 }
650
651 #[test]
652 fn normalize_scoped_package_extensionless_scss_stays_bare() {
653 assert_eq!(
654 normalize_css_import_path("@company/tokens".to_string(), true),
655 "@company/tokens"
656 );
657 }
658
659 #[test]
660 fn normalize_path_alias_with_css_extension_stays_bare() {
661 assert_eq!(
666 normalize_css_import_path("@/components/Button.css".to_string(), false),
667 "@/components/Button.css"
668 );
669 }
670
671 #[test]
672 fn normalize_path_alias_extensionless_stays_bare() {
673 assert_eq!(
674 normalize_css_import_path("@/styles/variables".to_string(), false),
675 "@/styles/variables"
676 );
677 }
678
679 #[test]
682 fn strip_css_no_comments() {
683 let source = ".foo { color: red; }";
684 assert_eq!(strip_css_comments(source, false), source);
685 }
686
687 #[test]
688 fn strip_css_multiple_block_comments() {
689 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
690 let result = strip_css_comments(source, false);
691 assert!(!result.contains("comment-one"));
692 assert!(!result.contains("comment-two"));
693 assert!(result.contains(".foo"));
694 assert!(result.contains(".bar"));
695 }
696
697 #[test]
698 fn strip_scss_does_not_affect_non_scss() {
699 let source = "// this stays\n.foo { }";
701 let result = strip_css_comments(source, false);
702 assert!(result.contains("// this stays"));
703 }
704
705 #[test]
708 fn css_module_parses_suppressions() {
709 let info = parse_css_to_module(
710 fallow_types::discover::FileId(0),
711 Path::new("Component.module.css"),
712 "/* fallow-ignore-file */\n.btn { color: red; }",
713 0,
714 );
715 assert!(!info.suppressions.is_empty());
716 assert_eq!(info.suppressions[0].line, 0);
717 }
718
719 #[test]
722 fn extracts_class_starting_with_underscore() {
723 let names = export_names("._private { } .__dunder { }");
724 assert!(names.contains(&"_private".to_string()));
725 assert!(names.contains(&"__dunder".to_string()));
726 }
727
728 #[test]
729 fn ignores_id_selectors() {
730 let names = export_names("#myId { color: red; }");
731 assert!(!names.contains(&"myId".to_string()));
732 }
733
734 #[test]
735 fn ignores_element_selectors() {
736 let names = export_names("div { color: red; } span { }");
737 assert!(names.is_empty());
738 }
739}