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, VisibilityTag};
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`.
82///
83/// Scoped npm packages (`@scope/pkg`) are always kept bare, even when they have
84/// CSS extensions (e.g., `@fontsource/monaspace-neon/400.css`). Bundlers like
85/// Vite resolve these from node_modules, not as relative paths.
86fn 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    // Scoped npm packages (`@scope/...`) are always bare specifiers resolved
91    // from node_modules, regardless of file extension.
92    if path.starts_with('@') && path.contains('/') {
93        return path;
94    }
95    // Paths with CSS/SCSS extensions are relative file imports
96    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            // In SCSS, extensionless bare specifiers like `@use 'variables'` are
110            // local partials, not npm packages. SCSS built-in modules (`sass:math`,
111            // `sass:color`) use a colon prefix and should stay bare.
112            if is_scss && !path.contains(':') {
113                format!("./{path}")
114            } else {
115                path
116            }
117        }
118    }
119}
120
121/// Strip comments from CSS/SCSS source to avoid matching directives inside comments.
122fn 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
131/// Extract class names from a CSS module file as named exports.
132pub 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                    visibility: VisibilityTag::None,
145                    span: Span::default(),
146                    members: Vec::new(),
147                    super_class: None,
148                });
149            }
150        }
151    }
152    exports
153}
154
155/// Parse a CSS/SCSS file, extracting @import, @use, @forward, @apply, and @tailwind directives.
156pub(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    // Strip comments before matching to avoid false positives from commented-out code.
169    let stripped = strip_css_comments(source, is_scss);
170
171    let mut imports = Vec::new();
172
173    // Extract @import statements
174    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            // CSS/SCSS @import resolves relative paths without ./ prefix,
185            // so normalize to ./ to avoid bare-specifier misclassification
186            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    // Extract SCSS @use/@forward statements
199    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    // If @apply or @tailwind directives exist, create a synthetic import to tailwindcss
215    // to mark the dependency as used
216    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    // For CSS module files, extract class names as named exports
230    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        type_referenced_import_bindings: Vec::new(),
251        value_referenced_import_bindings: Vec::new(),
252        line_offsets: fallow_types::extract::compute_line_offsets(source),
253        complexity: Vec::new(),
254        flag_uses: Vec::new(),
255        class_heritage: vec![],
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// Helper to collect export names as strings from `extract_css_module_exports`.
264    fn export_names(source: &str) -> Vec<String> {
265        extract_css_module_exports(source)
266            .into_iter()
267            .filter_map(|e| match e.name {
268                ExportName::Named(n) => Some(n),
269                ExportName::Default => None,
270            })
271            .collect()
272    }
273
274    // ── is_css_file ──────────────────────────────────────────────
275
276    #[test]
277    fn is_css_file_css() {
278        assert!(is_css_file(Path::new("styles.css")));
279    }
280
281    #[test]
282    fn is_css_file_scss() {
283        assert!(is_css_file(Path::new("styles.scss")));
284    }
285
286    #[test]
287    fn is_css_file_rejects_js() {
288        assert!(!is_css_file(Path::new("app.js")));
289    }
290
291    #[test]
292    fn is_css_file_rejects_ts() {
293        assert!(!is_css_file(Path::new("app.ts")));
294    }
295
296    #[test]
297    fn is_css_file_rejects_less() {
298        assert!(!is_css_file(Path::new("styles.less")));
299    }
300
301    #[test]
302    fn is_css_file_rejects_no_extension() {
303        assert!(!is_css_file(Path::new("Makefile")));
304    }
305
306    // ── is_css_module_file ───────────────────────────────────────
307
308    #[test]
309    fn is_css_module_file_module_css() {
310        assert!(is_css_module_file(Path::new("Component.module.css")));
311    }
312
313    #[test]
314    fn is_css_module_file_module_scss() {
315        assert!(is_css_module_file(Path::new("Component.module.scss")));
316    }
317
318    #[test]
319    fn is_css_module_file_rejects_plain_css() {
320        assert!(!is_css_module_file(Path::new("styles.css")));
321    }
322
323    #[test]
324    fn is_css_module_file_rejects_plain_scss() {
325        assert!(!is_css_module_file(Path::new("styles.scss")));
326    }
327
328    #[test]
329    fn is_css_module_file_rejects_module_js() {
330        assert!(!is_css_module_file(Path::new("utils.module.js")));
331    }
332
333    // ── extract_css_module_exports: basic class extraction ───────
334
335    #[test]
336    fn extracts_single_class() {
337        let names = export_names(".foo { color: red; }");
338        assert_eq!(names, vec!["foo"]);
339    }
340
341    #[test]
342    fn extracts_multiple_classes() {
343        let names = export_names(".foo { } .bar { }");
344        assert_eq!(names, vec!["foo", "bar"]);
345    }
346
347    #[test]
348    fn extracts_nested_classes() {
349        let names = export_names(".foo .bar { color: red; }");
350        assert!(names.contains(&"foo".to_string()));
351        assert!(names.contains(&"bar".to_string()));
352    }
353
354    #[test]
355    fn extracts_hyphenated_class() {
356        let names = export_names(".my-class { }");
357        assert_eq!(names, vec!["my-class"]);
358    }
359
360    #[test]
361    fn extracts_camel_case_class() {
362        let names = export_names(".myClass { }");
363        assert_eq!(names, vec!["myClass"]);
364    }
365
366    #[test]
367    fn extracts_underscore_class() {
368        let names = export_names("._hidden { } .__wrapper { }");
369        assert!(names.contains(&"_hidden".to_string()));
370        assert!(names.contains(&"__wrapper".to_string()));
371    }
372
373    // ── Pseudo-selectors ─────────────────────────────────────────
374
375    #[test]
376    fn pseudo_selector_hover() {
377        let names = export_names(".foo:hover { color: blue; }");
378        assert_eq!(names, vec!["foo"]);
379    }
380
381    #[test]
382    fn pseudo_selector_focus() {
383        let names = export_names(".input:focus { outline: none; }");
384        assert_eq!(names, vec!["input"]);
385    }
386
387    #[test]
388    fn pseudo_element_before() {
389        let names = export_names(".icon::before { content: ''; }");
390        assert_eq!(names, vec!["icon"]);
391    }
392
393    #[test]
394    fn combined_pseudo_selectors() {
395        let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
396        // "btn" should be deduplicated
397        assert_eq!(names, vec!["btn"]);
398    }
399
400    // ── Media queries ────────────────────────────────────────────
401
402    #[test]
403    fn classes_inside_media_query() {
404        let names = export_names(
405            "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
406        );
407        assert!(names.contains(&"mobile-nav".to_string()));
408        assert!(names.contains(&"desktop-nav".to_string()));
409    }
410
411    // ── Deduplication ────────────────────────────────────────────
412
413    #[test]
414    fn deduplicates_repeated_class() {
415        let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
416        assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
417    }
418
419    // ── Edge cases ───────────────────────────────────────────────
420
421    #[test]
422    fn empty_source() {
423        let names = export_names("");
424        assert!(names.is_empty());
425    }
426
427    #[test]
428    fn no_classes() {
429        let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
430        assert!(names.is_empty());
431    }
432
433    #[test]
434    fn ignores_classes_in_block_comments() {
435        // Note: extract_css_module_exports itself does NOT strip comments;
436        // comments are stripped in parse_css_to_module before calling it.
437        // But CSS_NON_SELECTOR_RE strips quoted strings. Testing the
438        // strip_css_comments + extract pipeline via the stripped source:
439        let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
440        let names = export_names(&stripped);
441        assert!(!names.contains(&"fake".to_string()));
442        assert!(names.contains(&"real".to_string()));
443    }
444
445    #[test]
446    fn ignores_classes_in_strings() {
447        let names = export_names(r#".real { content: ".fake"; }"#);
448        assert!(names.contains(&"real".to_string()));
449        assert!(!names.contains(&"fake".to_string()));
450    }
451
452    #[test]
453    fn ignores_classes_in_url() {
454        let names = export_names(".real { background: url(./images/hero.png); }");
455        assert!(names.contains(&"real".to_string()));
456        // "png" from "hero.png" should not be extracted
457        assert!(!names.contains(&"png".to_string()));
458    }
459
460    // ── strip_css_comments ───────────────────────────────────────
461
462    #[test]
463    fn strip_css_block_comment() {
464        let result = strip_css_comments("/* removed */ .kept { }", false);
465        assert!(!result.contains("removed"));
466        assert!(result.contains(".kept"));
467    }
468
469    #[test]
470    fn strip_scss_line_comment() {
471        let result = strip_css_comments("// removed\n.kept { }", true);
472        assert!(!result.contains("removed"));
473        assert!(result.contains(".kept"));
474    }
475
476    #[test]
477    fn strip_scss_preserves_css_outside_comments() {
478        let source = "// line comment\n/* block comment */\n.visible { color: red; }";
479        let result = strip_css_comments(source, true);
480        assert!(result.contains(".visible"));
481    }
482
483    // ── is_css_url_import ────────────────────────────────────────
484
485    #[test]
486    fn url_import_http() {
487        assert!(is_css_url_import("http://example.com/style.css"));
488    }
489
490    #[test]
491    fn url_import_https() {
492        assert!(is_css_url_import("https://fonts.googleapis.com/css"));
493    }
494
495    #[test]
496    fn url_import_data() {
497        assert!(is_css_url_import("data:text/css;base64,abc"));
498    }
499
500    #[test]
501    fn url_import_local_not_skipped() {
502        assert!(!is_css_url_import("./local.css"));
503    }
504
505    #[test]
506    fn url_import_bare_specifier_not_skipped() {
507        assert!(!is_css_url_import("tailwindcss"));
508    }
509
510    // ── normalize_css_import_path ─────────────────────────────────
511
512    #[test]
513    fn normalize_relative_dot_path_unchanged() {
514        assert_eq!(
515            normalize_css_import_path("./reset.css".to_string(), false),
516            "./reset.css"
517        );
518    }
519
520    #[test]
521    fn normalize_parent_relative_path_unchanged() {
522        assert_eq!(
523            normalize_css_import_path("../shared.scss".to_string(), false),
524            "../shared.scss"
525        );
526    }
527
528    #[test]
529    fn normalize_absolute_path_unchanged() {
530        assert_eq!(
531            normalize_css_import_path("/styles/main.css".to_string(), false),
532            "/styles/main.css"
533        );
534    }
535
536    #[test]
537    fn normalize_url_unchanged() {
538        assert_eq!(
539            normalize_css_import_path("https://example.com/style.css".to_string(), false),
540            "https://example.com/style.css"
541        );
542    }
543
544    #[test]
545    fn normalize_bare_css_gets_dot_slash() {
546        assert_eq!(
547            normalize_css_import_path("app.css".to_string(), false),
548            "./app.css"
549        );
550    }
551
552    #[test]
553    fn normalize_bare_scss_gets_dot_slash() {
554        assert_eq!(
555            normalize_css_import_path("vars.scss".to_string(), false),
556            "./vars.scss"
557        );
558    }
559
560    #[test]
561    fn normalize_bare_sass_gets_dot_slash() {
562        assert_eq!(
563            normalize_css_import_path("main.sass".to_string(), false),
564            "./main.sass"
565        );
566    }
567
568    #[test]
569    fn normalize_bare_less_gets_dot_slash() {
570        assert_eq!(
571            normalize_css_import_path("theme.less".to_string(), false),
572            "./theme.less"
573        );
574    }
575
576    #[test]
577    fn normalize_bare_js_extension_stays_bare() {
578        assert_eq!(
579            normalize_css_import_path("module.js".to_string(), false),
580            "module.js"
581        );
582    }
583
584    // ── SCSS partial normalization ───────────────────────────────
585
586    #[test]
587    fn normalize_scss_bare_partial_gets_dot_slash() {
588        assert_eq!(
589            normalize_css_import_path("variables".to_string(), true),
590            "./variables"
591        );
592    }
593
594    #[test]
595    fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
596        assert_eq!(
597            normalize_css_import_path("base/reset".to_string(), true),
598            "./base/reset"
599        );
600    }
601
602    #[test]
603    fn normalize_scss_builtin_stays_bare() {
604        assert_eq!(
605            normalize_css_import_path("sass:math".to_string(), true),
606            "sass:math"
607        );
608    }
609
610    #[test]
611    fn normalize_scss_relative_path_unchanged() {
612        assert_eq!(
613            normalize_css_import_path("../styles/variables".to_string(), true),
614            "../styles/variables"
615        );
616    }
617
618    #[test]
619    fn normalize_css_bare_extensionless_stays_bare() {
620        // In CSS context (not SCSS), extensionless imports are npm packages
621        assert_eq!(
622            normalize_css_import_path("tailwindcss".to_string(), false),
623            "tailwindcss"
624        );
625    }
626
627    // ── Scoped npm packages stay bare ───────────────────────────
628
629    #[test]
630    fn normalize_scoped_package_with_css_extension_stays_bare() {
631        assert_eq!(
632            normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
633            "@fontsource/monaspace-neon/400.css"
634        );
635    }
636
637    #[test]
638    fn normalize_scoped_package_with_scss_extension_stays_bare() {
639        assert_eq!(
640            normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
641            "@company/design-system/tokens.scss"
642        );
643    }
644
645    #[test]
646    fn normalize_scoped_package_without_extension_stays_bare() {
647        assert_eq!(
648            normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
649            "@fallow/design-system/styles"
650        );
651    }
652
653    #[test]
654    fn normalize_scoped_package_extensionless_scss_stays_bare() {
655        assert_eq!(
656            normalize_css_import_path("@company/tokens".to_string(), true),
657            "@company/tokens"
658        );
659    }
660
661    #[test]
662    fn normalize_path_alias_with_css_extension_stays_bare() {
663        // Path aliases like `@/components/Button.css` (configured via tsconfig paths
664        // or Vite alias) share the `@` prefix with scoped packages. They must stay
665        // bare so the resolver's path-alias path can handle them; prepending `./`
666        // would break resolution.
667        assert_eq!(
668            normalize_css_import_path("@/components/Button.css".to_string(), false),
669            "@/components/Button.css"
670        );
671    }
672
673    #[test]
674    fn normalize_path_alias_extensionless_stays_bare() {
675        assert_eq!(
676            normalize_css_import_path("@/styles/variables".to_string(), false),
677            "@/styles/variables"
678        );
679    }
680
681    // ── strip_css_comments edge cases ─────────────────────────────
682
683    #[test]
684    fn strip_css_no_comments() {
685        let source = ".foo { color: red; }";
686        assert_eq!(strip_css_comments(source, false), source);
687    }
688
689    #[test]
690    fn strip_css_multiple_block_comments() {
691        let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
692        let result = strip_css_comments(source, false);
693        assert!(!result.contains("comment-one"));
694        assert!(!result.contains("comment-two"));
695        assert!(result.contains(".foo"));
696        assert!(result.contains(".bar"));
697    }
698
699    #[test]
700    fn strip_scss_does_not_affect_non_scss() {
701        // When is_scss=false, line comments should NOT be stripped
702        let source = "// this stays\n.foo { }";
703        let result = strip_css_comments(source, false);
704        assert!(result.contains("// this stays"));
705    }
706
707    // ── parse_css_to_module: suppression integration ──────────────
708
709    #[test]
710    fn css_module_parses_suppressions() {
711        let info = parse_css_to_module(
712            fallow_types::discover::FileId(0),
713            Path::new("Component.module.css"),
714            "/* fallow-ignore-file */\n.btn { color: red; }",
715            0,
716        );
717        assert!(!info.suppressions.is_empty());
718        assert_eq!(info.suppressions[0].line, 0);
719    }
720
721    // ── CSS class name edge cases ─────────────────────────────────
722
723    #[test]
724    fn extracts_class_starting_with_underscore() {
725        let names = export_names("._private { } .__dunder { }");
726        assert!(names.contains(&"_private".to_string()));
727        assert!(names.contains(&"__dunder".to_string()));
728    }
729
730    #[test]
731    fn ignores_id_selectors() {
732        let names = export_names("#myId { color: red; }");
733        assert!(!names.contains(&"myId".to_string()));
734    }
735
736    #[test]
737    fn ignores_element_selectors() {
738        let names = export_names("div { color: red; } span { }");
739        assert!(names.is_empty());
740    }
741}