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