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/// Only applies to paths with CSS/SCSS extensions — extensionless imports
79/// like `@import "tailwindcss"` are actual npm package imports.
80fn normalize_css_import_path(path: String) -> String {
81    if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
82        return path;
83    }
84    // Paths with CSS/SCSS extensions are relative file imports
85    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
101/// Strip comments from CSS/SCSS source to avoid matching directives inside comments.
102fn 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
111/// Extract class names from a CSS module file as named exports.
112pub 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
134/// Parse a CSS/SCSS file, extracting @import, @use, @forward, @apply, and @tailwind directives.
135pub(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    // Strip comments before matching to avoid false positives from commented-out code.
148    let stripped = strip_css_comments(source, is_scss);
149
150    let mut imports = Vec::new();
151
152    // Extract @import statements
153    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            // CSS/SCSS @import resolves relative paths without ./ prefix,
164            // so normalize to ./ to avoid bare-specifier misclassification
165            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    // Extract SCSS @use/@forward statements
178    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    // If @apply or @tailwind directives exist, create a synthetic import to tailwindcss
194    // to mark the dependency as used
195    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    // For CSS module files, extract class names as named exports
209    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    /// Helper to collect export names as strings from `extract_css_module_exports`.
240    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    // ── is_css_file ──────────────────────────────────────────────
251
252    #[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    // ── is_css_module_file ───────────────────────────────────────
283
284    #[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    // ── extract_css_module_exports: basic class extraction ───────
310
311    #[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    // ── Pseudo-selectors ─────────────────────────────────────────
350
351    #[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        // "btn" should be deduplicated
373        assert_eq!(names, vec!["btn"]);
374    }
375
376    // ── Media queries ────────────────────────────────────────────
377
378    #[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    // ── Deduplication ────────────────────────────────────────────
388
389    #[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    // ── Edge cases ───────────────────────────────────────────────
396
397    #[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        // Note: extract_css_module_exports itself does NOT strip comments;
412        // comments are stripped in parse_css_to_module before calling it.
413        // But CSS_NON_SELECTOR_RE strips quoted strings. Testing the
414        // strip_css_comments + extract pipeline via the stripped source:
415        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        // "png" from "hero.png" should not be extracted
433        assert!(!names.contains(&"png".to_string()));
434    }
435
436    // ── strip_css_comments ───────────────────────────────────────
437
438    #[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    // ── is_css_url_import ────────────────────────────────────────
460
461    #[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    // ── normalize_css_import_path ─────────────────────────────────
487
488    #[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    // ── strip_css_comments edge cases ─────────────────────────────
569
570    #[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        // When is_scss=false, line comments should NOT be stripped
589        let source = "// this stays\n.foo { }";
590        let result = strip_css_comments(source, false);
591        assert!(result.contains("// this stays"));
592    }
593
594    // ── parse_css_to_module: suppression integration ──────────────
595
596    #[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    // ── CSS class name edge cases ─────────────────────────────────
609
610    #[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}