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