Skip to main content

fallow_extract/
css.rs

1//! CSS/SCSS file parsing and CSS Module class name extraction.
2//!
3//! Handles `@import`, `@use`, `@forward`, `@apply`, `@tailwind` directives,
4//! and extracts class names as named exports from `.module.css`/`.module.scss` files.
5
6use 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
14/// Regex to extract CSS @import sources.
15/// Matches: @import "path"; @import 'path'; @import url("path"); @import url('path'); @import url(path);
16static CSS_IMPORT_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
17    regex::Regex::new(r#"@import\s+(?:url\(\s*(?:["']([^"']+)["']|([^)]+))\s*\)|["']([^"']+)["'])"#)
18        .expect("valid regex")
19});
20
21/// Regex to extract SCSS @use and @forward sources.
22/// Matches: @use "path"; @use 'path'; @forward "path"; @forward 'path';
23static SCSS_USE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
24    regex::Regex::new(r#"@(?:use|forward)\s+["']([^"']+)["']"#).expect("valid regex")
25});
26
27/// Regex to extract @apply class references.
28/// Matches: @apply class1 class2 class3;
29static CSS_APPLY_RE: LazyLock<regex::Regex> =
30    LazyLock::new(|| regex::Regex::new(r"@apply\s+[^;}\n]+").expect("valid regex"));
31
32/// Regex to extract @tailwind directives.
33/// Matches: @tailwind base; @tailwind components; @tailwind utilities;
34static CSS_TAILWIND_RE: LazyLock<regex::Regex> =
35    LazyLock::new(|| regex::Regex::new(r"@tailwind\s+\w+").expect("valid regex"));
36
37/// Regex to match CSS block comments (`/* ... */`) for stripping before extraction.
38static CSS_COMMENT_RE: LazyLock<regex::Regex> =
39    LazyLock::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").expect("valid regex"));
40
41/// Regex to match SCSS single-line comments (`// ...`) for stripping before extraction.
42static SCSS_LINE_COMMENT_RE: LazyLock<regex::Regex> =
43    LazyLock::new(|| regex::Regex::new(r"//[^\n]*").expect("valid regex"));
44
45/// Regex to extract CSS class names from selectors.
46/// Matches `.className` in selectors. Applied after stripping comments, strings, and URLs.
47static CSS_CLASS_RE: LazyLock<regex::Regex> =
48    LazyLock::new(|| regex::Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").expect("valid regex"));
49
50/// Regex to strip quoted strings and `url(...)` content from CSS before class extraction.
51/// Prevents false positives from `content: ".foo"` and `url(./path/file.ext)`.
52static 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
70/// Returns true if a CSS import source is a remote URL or data URI that should be skipped.
71fn is_css_url_import(source: &str) -> bool {
72    source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
73}
74
75/// Normalize a CSS/SCSS import path to use `./` prefix for relative paths.
76/// CSS/SCSS resolve imports without `./` prefix as relative by default,
77/// unlike JS where unprefixed specifiers are bare (npm) specifiers.
78///
79/// When `is_scss` is true, extensionless specifiers that are not SCSS built-in
80/// modules (`sass:*`) are treated as relative imports (SCSS partial convention).
81/// This handles `@use 'variables'` resolving to `./_variables.scss`.
82fn 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    // Paths with CSS/SCSS extensions are relative file imports
87    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            // In SCSS, extensionless bare specifiers like `@use 'variables'` are
101            // local partials, not npm packages. SCSS built-in modules (`sass:math`,
102            // `sass:color`) use a colon prefix and should stay bare.
103            if is_scss && !path.contains(':') {
104                format!("./{path}")
105            } else {
106                path
107            }
108        }
109    }
110}
111
112/// Strip comments from CSS/SCSS source to avoid matching directives inside comments.
113fn 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
122/// Extract class names from a CSS module file as named exports.
123pub 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
146/// Parse a CSS/SCSS file, extracting @import, @use, @forward, @apply, and @tailwind directives.
147pub(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    // Strip comments before matching to avoid false positives from commented-out code.
160    let stripped = strip_css_comments(source, is_scss);
161
162    let mut imports = Vec::new();
163
164    // Extract @import statements
165    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            // CSS/SCSS @import resolves relative paths without ./ prefix,
176            // so normalize to ./ to avoid bare-specifier misclassification
177            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    // Extract SCSS @use/@forward statements
190    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    // If @apply or @tailwind directives exist, create a synthetic import to tailwindcss
206    // to mark the dependency as used
207    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    // For CSS module files, extract class names as named exports
221    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    /// Helper to collect export names as strings from `extract_css_module_exports`.
252    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    // ── is_css_file ──────────────────────────────────────────────
263
264    #[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    // ── is_css_module_file ───────────────────────────────────────
295
296    #[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    // ── extract_css_module_exports: basic class extraction ───────
322
323    #[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    // ── Pseudo-selectors ─────────────────────────────────────────
362
363    #[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        // "btn" should be deduplicated
385        assert_eq!(names, vec!["btn"]);
386    }
387
388    // ── Media queries ────────────────────────────────────────────
389
390    #[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    // ── Deduplication ────────────────────────────────────────────
400
401    #[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    // ── Edge cases ───────────────────────────────────────────────
408
409    #[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        // Note: extract_css_module_exports itself does NOT strip comments;
424        // comments are stripped in parse_css_to_module before calling it.
425        // But CSS_NON_SELECTOR_RE strips quoted strings. Testing the
426        // strip_css_comments + extract pipeline via the stripped source:
427        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        // "png" from "hero.png" should not be extracted
445        assert!(!names.contains(&"png".to_string()));
446    }
447
448    // ── strip_css_comments ───────────────────────────────────────
449
450    #[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    // ── is_css_url_import ────────────────────────────────────────
472
473    #[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    // ── normalize_css_import_path ─────────────────────────────────
499
500    #[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    // ── SCSS partial normalization ───────────────────────────────
573
574    #[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        // In CSS context (not SCSS), extensionless imports are npm packages
609        assert_eq!(
610            normalize_css_import_path("tailwindcss".to_string(), false),
611            "tailwindcss"
612        );
613    }
614
615    // ── strip_css_comments edge cases ─────────────────────────────
616
617    #[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        // When is_scss=false, line comments should NOT be stripped
636        let source = "// this stays\n.foo { }";
637        let result = strip_css_comments(source, false);
638        assert!(result.contains("// this stays"));
639    }
640
641    // ── parse_css_to_module: suppression integration ──────────────
642
643    #[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    // ── CSS class name edge cases ─────────────────────────────────
656
657    #[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}