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