Skip to main content

fallow_extract/
lib.rs

1//! Parsing and extraction engine for the fallow dead code analyzer.
2//!
3//! This crate handles all file parsing: JS/TS via Oxc, Vue/Svelte SFC extraction,
4//! Astro frontmatter, MDX import/export extraction, CSS Module class name extraction,
5//! and incremental caching of parse results.
6
7#![warn(missing_docs)]
8
9pub mod astro;
10pub mod cache;
11pub mod css;
12pub mod mdx;
13mod parse;
14pub mod sfc;
15pub mod suppress;
16pub mod visitor;
17
18use std::path::Path;
19
20use rayon::prelude::*;
21
22use cache::CacheStore;
23use fallow_types::discover::{DiscoveredFile, FileId};
24
25// Re-export all extract types from fallow-types
26pub use fallow_types::extract::{
27    DynamicImportInfo, DynamicImportPattern, ExportInfo, ExportName, ImportInfo, ImportedName,
28    MemberAccess, MemberInfo, MemberKind, ModuleInfo, ParseResult, ReExportInfo, RequireCallInfo,
29    compute_line_offsets,
30};
31
32// Re-export extraction functions for internal use and fuzzing
33pub use astro::extract_astro_frontmatter;
34pub use css::extract_css_module_exports;
35pub use mdx::extract_mdx_statements;
36pub use sfc::{extract_sfc_scripts, is_sfc_file};
37
38use parse::parse_source_to_module;
39
40/// Parse all files in parallel, extracting imports and exports.
41/// Uses the cache to skip reparsing files whose content hasn't changed.
42pub fn parse_all_files(files: &[DiscoveredFile], cache: Option<&CacheStore>) -> ParseResult {
43    use std::sync::atomic::{AtomicUsize, Ordering};
44    let cache_hits = AtomicUsize::new(0);
45    let cache_misses = AtomicUsize::new(0);
46
47    let modules: Vec<ModuleInfo> = files
48        .par_iter()
49        .filter_map(|file| parse_single_file_cached(file, cache, &cache_hits, &cache_misses))
50        .collect();
51
52    let hits = cache_hits.load(Ordering::Relaxed);
53    let misses = cache_misses.load(Ordering::Relaxed);
54    if hits > 0 || misses > 0 {
55        tracing::info!(
56            cache_hits = hits,
57            cache_misses = misses,
58            "incremental cache stats"
59        );
60    }
61
62    ParseResult {
63        modules,
64        cache_hits: hits,
65        cache_misses: misses,
66    }
67}
68
69/// Extract mtime (seconds since epoch) from file metadata.
70/// Returns 0 if mtime cannot be determined (pre-epoch, unsupported OS, etc.).
71fn mtime_secs(metadata: &std::fs::Metadata) -> u64 {
72    metadata
73        .modified()
74        .ok()
75        .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
76        .map_or(0, |d| d.as_secs())
77}
78
79/// Parse a single file, consulting the cache first.
80///
81/// Cache validation strategy (fast path -> slow path):
82/// 1. `stat()` the file to get mtime + size (single syscall, no file read)
83/// 2. If mtime+size match the cached entry -> cache hit, return immediately
84/// 3. If mtime+size differ -> read file, compute content hash
85/// 4. If content hash matches cached entry -> cache hit (file was `touch`ed but unchanged)
86/// 5. Otherwise -> cache miss, full parse
87fn parse_single_file_cached(
88    file: &DiscoveredFile,
89    cache: Option<&CacheStore>,
90    cache_hits: &std::sync::atomic::AtomicUsize,
91    cache_misses: &std::sync::atomic::AtomicUsize,
92) -> Option<ModuleInfo> {
93    use std::sync::atomic::Ordering;
94
95    // Fast path: check mtime+size before reading file content.
96    // A single stat() syscall is ~100x cheaper than read()+hash().
97    if let Some(store) = cache
98        && let Ok(metadata) = std::fs::metadata(&file.path)
99    {
100        let mt = mtime_secs(&metadata);
101        let sz = metadata.len();
102        if let Some(cached) = store.get_by_metadata(&file.path, mt, sz) {
103            cache_hits.fetch_add(1, Ordering::Relaxed);
104            return Some(cache::cached_to_module(cached, file.id));
105        }
106    }
107
108    // Slow path: read file content and compute content hash.
109    let source = std::fs::read_to_string(&file.path).ok()?;
110    let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
111
112    // Check cache by content hash (handles touch/save-without-change)
113    if let Some(store) = cache
114        && let Some(cached) = store.get(&file.path, content_hash)
115    {
116        cache_hits.fetch_add(1, Ordering::Relaxed);
117        return Some(cache::cached_to_module(cached, file.id));
118    }
119    cache_misses.fetch_add(1, Ordering::Relaxed);
120
121    // Cache miss — do a full parse
122    Some(parse_source_to_module(
123        file.id,
124        &file.path,
125        &source,
126        content_hash,
127    ))
128}
129
130/// Parse a single file and extract module information.
131pub fn parse_single_file(file: &DiscoveredFile) -> Option<ModuleInfo> {
132    let source = std::fs::read_to_string(&file.path).ok()?;
133    let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
134    Some(parse_source_to_module(
135        file.id,
136        &file.path,
137        &source,
138        content_hash,
139    ))
140}
141
142/// Parse from in-memory content (for LSP).
143pub fn parse_from_content(file_id: FileId, path: &Path, content: &str) -> ModuleInfo {
144    let content_hash = xxhash_rust::xxh3::xxh3_64(content.as_bytes());
145    parse_source_to_module(file_id, path, content, content_hash)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn parse_source(source: &str) -> ModuleInfo {
153        parse_source_to_module(FileId(0), Path::new("test.ts"), source, 0)
154    }
155
156    #[test]
157    fn extracts_named_exports() {
158        let info = parse_source("export const foo = 1; export function bar() {}");
159        assert_eq!(info.exports.len(), 2);
160        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
161        assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
162    }
163
164    #[test]
165    fn extracts_default_export() {
166        let info = parse_source("export default function main() {}");
167        assert_eq!(info.exports.len(), 1);
168        assert_eq!(info.exports[0].name, ExportName::Default);
169    }
170
171    #[test]
172    fn extracts_named_imports() {
173        let info = parse_source("import { foo, bar } from './utils';");
174        assert_eq!(info.imports.len(), 2);
175        assert_eq!(
176            info.imports[0].imported_name,
177            ImportedName::Named("foo".to_string())
178        );
179        assert_eq!(info.imports[0].source, "./utils");
180    }
181
182    #[test]
183    fn extracts_namespace_import() {
184        let info = parse_source("import * as utils from './utils';");
185        assert_eq!(info.imports.len(), 1);
186        assert_eq!(info.imports[0].imported_name, ImportedName::Namespace);
187    }
188
189    #[test]
190    fn extracts_side_effect_import() {
191        let info = parse_source("import './styles.css';");
192        assert_eq!(info.imports.len(), 1);
193        assert_eq!(info.imports[0].imported_name, ImportedName::SideEffect);
194    }
195
196    #[test]
197    fn extracts_re_exports() {
198        let info = parse_source("export { foo, bar as baz } from './module';");
199        assert_eq!(info.re_exports.len(), 2);
200        assert_eq!(info.re_exports[0].imported_name, "foo");
201        assert_eq!(info.re_exports[0].exported_name, "foo");
202        assert_eq!(info.re_exports[1].imported_name, "bar");
203        assert_eq!(info.re_exports[1].exported_name, "baz");
204    }
205
206    #[test]
207    fn extracts_star_re_export() {
208        let info = parse_source("export * from './module';");
209        assert_eq!(info.re_exports.len(), 1);
210        assert_eq!(info.re_exports[0].imported_name, "*");
211        assert_eq!(info.re_exports[0].exported_name, "*");
212    }
213
214    #[test]
215    fn extracts_dynamic_import() {
216        let info = parse_source("const mod = import('./lazy');");
217        assert_eq!(info.dynamic_imports.len(), 1);
218        assert_eq!(info.dynamic_imports[0].source, "./lazy");
219    }
220
221    #[test]
222    fn extracts_require_call() {
223        let info = parse_source("const fs = require('fs');");
224        assert_eq!(info.require_calls.len(), 1);
225        assert_eq!(info.require_calls[0].source, "fs");
226    }
227
228    #[test]
229    fn extracts_type_exports() {
230        let info = parse_source("export type Foo = string; export interface Bar { x: number; }");
231        assert_eq!(info.exports.len(), 2);
232        assert!(info.exports[0].is_type_only);
233        assert!(info.exports[1].is_type_only);
234    }
235
236    #[test]
237    fn extracts_type_only_imports() {
238        let info = parse_source("import type { Foo } from './types';");
239        assert_eq!(info.imports.len(), 1);
240        assert!(info.imports[0].is_type_only);
241    }
242
243    #[test]
244    fn detects_cjs_module_exports() {
245        let info = parse_source("module.exports = { foo: 1 };");
246        assert!(info.has_cjs_exports);
247    }
248
249    #[test]
250    fn detects_cjs_exports_property() {
251        let info = parse_source("exports.foo = 42;");
252        assert!(info.has_cjs_exports);
253        assert_eq!(info.exports.len(), 1);
254        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
255    }
256
257    #[test]
258    fn extracts_static_member_accesses() {
259        let info = parse_source(
260            "import { Status, MyClass } from './types';\nconsole.log(Status.Active);\nMyClass.create();",
261        );
262        assert!(info.member_accesses.len() >= 2);
263        let has_status_active = info
264            .member_accesses
265            .iter()
266            .any(|a| a.object == "Status" && a.member == "Active");
267        let has_myclass_create = info
268            .member_accesses
269            .iter()
270            .any(|a| a.object == "MyClass" && a.member == "create");
271        assert!(has_status_active, "Should capture Status.Active");
272        assert!(has_myclass_create, "Should capture MyClass.create");
273    }
274
275    #[test]
276    fn extracts_default_import() {
277        let info = parse_source("import React from 'react';");
278        assert_eq!(info.imports.len(), 1);
279        assert_eq!(info.imports[0].imported_name, ImportedName::Default);
280        assert_eq!(info.imports[0].local_name, "React");
281        assert_eq!(info.imports[0].source, "react");
282    }
283
284    #[test]
285    fn extracts_mixed_import_default_and_named() {
286        let info = parse_source("import React, { useState, useEffect } from 'react';");
287        assert_eq!(info.imports.len(), 3);
288        assert_eq!(info.imports[0].imported_name, ImportedName::Default);
289        assert_eq!(info.imports[0].local_name, "React");
290        assert_eq!(
291            info.imports[1].imported_name,
292            ImportedName::Named("useState".to_string())
293        );
294        assert_eq!(
295            info.imports[2].imported_name,
296            ImportedName::Named("useEffect".to_string())
297        );
298    }
299
300    #[test]
301    fn extracts_import_with_alias() {
302        let info = parse_source("import { foo as bar } from './utils';");
303        assert_eq!(info.imports.len(), 1);
304        assert_eq!(
305            info.imports[0].imported_name,
306            ImportedName::Named("foo".to_string())
307        );
308        assert_eq!(info.imports[0].local_name, "bar");
309    }
310
311    #[test]
312    fn extracts_export_specifier_list() {
313        let info = parse_source("const foo = 1; const bar = 2; export { foo, bar };");
314        assert_eq!(info.exports.len(), 2);
315        assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
316        assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
317    }
318
319    #[test]
320    fn extracts_export_with_alias() {
321        let info = parse_source("const foo = 1; export { foo as myFoo };");
322        assert_eq!(info.exports.len(), 1);
323        assert_eq!(info.exports[0].name, ExportName::Named("myFoo".to_string()));
324    }
325
326    #[test]
327    fn extracts_star_re_export_with_alias() {
328        let info = parse_source("export * as utils from './utils';");
329        assert_eq!(info.re_exports.len(), 1);
330        assert_eq!(info.re_exports[0].imported_name, "*");
331        assert_eq!(info.re_exports[0].exported_name, "utils");
332    }
333
334    #[test]
335    fn extracts_export_class_declaration() {
336        let info = parse_source("export class MyService { name: string = ''; }");
337        assert_eq!(info.exports.len(), 1);
338        assert_eq!(
339            info.exports[0].name,
340            ExportName::Named("MyService".to_string())
341        );
342    }
343
344    #[test]
345    fn class_constructor_is_excluded() {
346        let info = parse_source("export class Foo { constructor() {} greet() {} }");
347        assert_eq!(info.exports.len(), 1);
348        let members: Vec<&str> = info.exports[0]
349            .members
350            .iter()
351            .map(|m| m.name.as_str())
352            .collect();
353        assert!(
354            !members.contains(&"constructor"),
355            "constructor should be excluded from members"
356        );
357        assert!(members.contains(&"greet"), "greet should be included");
358    }
359
360    #[test]
361    fn extracts_ts_enum_declaration() {
362        let info = parse_source("export enum Direction { Up, Down, Left, Right }");
363        assert_eq!(info.exports.len(), 1);
364        assert_eq!(
365            info.exports[0].name,
366            ExportName::Named("Direction".to_string())
367        );
368        assert_eq!(info.exports[0].members.len(), 4);
369        assert_eq!(info.exports[0].members[0].kind, MemberKind::EnumMember);
370    }
371
372    #[test]
373    fn extracts_ts_module_declaration() {
374        let info = parse_source("export declare module 'my-module' {}");
375        assert_eq!(info.exports.len(), 1);
376        assert!(info.exports[0].is_type_only);
377    }
378
379    #[test]
380    fn extracts_type_only_named_import() {
381        let info = parse_source("import { type Foo, Bar } from './types';");
382        assert_eq!(info.imports.len(), 2);
383        assert!(info.imports[0].is_type_only);
384        assert!(!info.imports[1].is_type_only);
385    }
386
387    #[test]
388    fn extracts_type_re_export() {
389        let info = parse_source("export type { Foo } from './types';");
390        assert_eq!(info.re_exports.len(), 1);
391        assert!(info.re_exports[0].is_type_only);
392    }
393
394    #[test]
395    fn extracts_destructured_array_export() {
396        let info = parse_source("export const [first, second] = [1, 2];");
397        assert_eq!(info.exports.len(), 2);
398        assert_eq!(info.exports[0].name, ExportName::Named("first".to_string()));
399        assert_eq!(
400            info.exports[1].name,
401            ExportName::Named("second".to_string())
402        );
403    }
404
405    #[test]
406    fn extracts_nested_destructured_export() {
407        let info = parse_source("export const { a, b: { c } } = obj;");
408        assert_eq!(info.exports.len(), 2);
409        assert_eq!(info.exports[0].name, ExportName::Named("a".to_string()));
410        assert_eq!(info.exports[1].name, ExportName::Named("c".to_string()));
411    }
412
413    #[test]
414    fn extracts_default_export_function_expression() {
415        let info = parse_source("export default function() { return 42; }");
416        assert_eq!(info.exports.len(), 1);
417        assert_eq!(info.exports[0].name, ExportName::Default);
418    }
419
420    #[test]
421    fn export_name_display() {
422        assert_eq!(ExportName::Named("foo".to_string()).to_string(), "foo");
423        assert_eq!(ExportName::Default.to_string(), "default");
424    }
425
426    #[test]
427    fn no_exports_no_imports() {
428        let info = parse_source("const x = 1; console.log(x);");
429        assert!(info.exports.is_empty());
430        assert!(info.imports.is_empty());
431        assert!(info.re_exports.is_empty());
432        assert!(!info.has_cjs_exports);
433    }
434
435    #[test]
436    fn dynamic_import_non_string_ignored() {
437        let info = parse_source("const mod = import(variable);");
438        assert_eq!(info.dynamic_imports.len(), 0);
439    }
440
441    #[test]
442    fn multiple_require_calls() {
443        let info =
444            parse_source("const a = require('a'); const b = require('b'); const c = require('c');");
445        assert_eq!(info.require_calls.len(), 3);
446    }
447
448    #[test]
449    fn extracts_ts_interface() {
450        let info = parse_source("export interface Props { name: string; age: number; }");
451        assert_eq!(info.exports.len(), 1);
452        assert_eq!(info.exports[0].name, ExportName::Named("Props".to_string()));
453        assert!(info.exports[0].is_type_only);
454    }
455
456    #[test]
457    fn extracts_ts_type_alias() {
458        let info = parse_source("export type ID = string | number;");
459        assert_eq!(info.exports.len(), 1);
460        assert_eq!(info.exports[0].name, ExportName::Named("ID".to_string()));
461        assert!(info.exports[0].is_type_only);
462    }
463
464    #[test]
465    fn extracts_member_accesses_inside_exported_functions() {
466        let info = parse_source(
467            "import { Color } from './types';\nexport const isRed = (c: Color) => c === Color.Red;",
468        );
469        let has_color_red = info
470            .member_accesses
471            .iter()
472            .any(|a| a.object == "Color" && a.member == "Red");
473        assert!(
474            has_color_red,
475            "Should capture Color.Red inside exported function body"
476        );
477    }
478
479    // ── Whole-object use detection ──────────────────────────────
480
481    #[test]
482    fn detects_object_values_whole_use() {
483        let info = parse_source("import { Status } from './types';\nObject.values(Status);");
484        assert!(info.whole_object_uses.contains(&"Status".to_string()));
485    }
486
487    #[test]
488    fn detects_object_keys_whole_use() {
489        let info = parse_source("import { Dir } from './types';\nObject.keys(Dir);");
490        assert!(info.whole_object_uses.contains(&"Dir".to_string()));
491    }
492
493    #[test]
494    fn detects_object_entries_whole_use() {
495        let info = parse_source("import { E } from './types';\nObject.entries(E);");
496        assert!(info.whole_object_uses.contains(&"E".to_string()));
497    }
498
499    #[test]
500    fn detects_for_in_whole_use() {
501        let info = parse_source("import { Color } from './types';\nfor (const k in Color) {}");
502        assert!(info.whole_object_uses.contains(&"Color".to_string()));
503    }
504
505    #[test]
506    fn detects_spread_whole_use() {
507        let info = parse_source("import { X } from './types';\nconst y = { ...X };");
508        assert!(info.whole_object_uses.contains(&"X".to_string()));
509    }
510
511    #[test]
512    fn computed_member_string_literal_resolves() {
513        let info = parse_source("import { Status } from './types';\nStatus[\"Active\"];");
514        let has_access = info
515            .member_accesses
516            .iter()
517            .any(|a| a.object == "Status" && a.member == "Active");
518        assert!(
519            has_access,
520            "Status[\"Active\"] should resolve to a static member access"
521        );
522    }
523
524    #[test]
525    fn computed_member_variable_marks_whole_use() {
526        let info = parse_source("import { Status } from './types';\nconst k = 'foo';\nStatus[k];");
527        assert!(info.whole_object_uses.contains(&"Status".to_string()));
528    }
529
530    // ── Dynamic import pattern extraction ───────────────────────
531
532    #[test]
533    fn extracts_template_literal_dynamic_import_pattern() {
534        let info = parse_source("const m = import(`./locales/${lang}.json`);");
535        assert_eq!(info.dynamic_import_patterns.len(), 1);
536        assert_eq!(info.dynamic_import_patterns[0].prefix, "./locales/");
537        assert_eq!(
538            info.dynamic_import_patterns[0].suffix,
539            Some(".json".to_string())
540        );
541    }
542
543    #[test]
544    fn extracts_concat_dynamic_import_pattern() {
545        let info = parse_source("const m = import('./pages/' + name);");
546        assert_eq!(info.dynamic_import_patterns.len(), 1);
547        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
548        assert!(info.dynamic_import_patterns[0].suffix.is_none());
549    }
550
551    #[test]
552    fn extracts_concat_with_suffix() {
553        let info = parse_source("const m = import('./pages/' + name + '.tsx');");
554        assert_eq!(info.dynamic_import_patterns.len(), 1);
555        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
556        assert_eq!(
557            info.dynamic_import_patterns[0].suffix,
558            Some(".tsx".to_string())
559        );
560    }
561
562    #[test]
563    fn no_substitution_template_treated_as_exact() {
564        let info = parse_source("const m = import(`./exact-module`);");
565        assert_eq!(info.dynamic_imports.len(), 1);
566        assert_eq!(info.dynamic_imports[0].source, "./exact-module");
567        assert!(info.dynamic_import_patterns.is_empty());
568    }
569
570    #[test]
571    fn fully_dynamic_import_still_ignored() {
572        let info = parse_source("const m = import(variable);");
573        assert!(info.dynamic_imports.is_empty());
574        assert!(info.dynamic_import_patterns.is_empty());
575    }
576
577    #[test]
578    fn non_relative_template_ignored() {
579        let info = parse_source("const m = import(`lodash/${fn}`);");
580        assert!(info.dynamic_import_patterns.is_empty());
581    }
582
583    #[test]
584    fn multi_expression_template_uses_globstar() {
585        let info = parse_source("const m = import(`./plugins/${cat}/${name}.js`);");
586        assert_eq!(info.dynamic_import_patterns.len(), 1);
587        assert_eq!(info.dynamic_import_patterns[0].prefix, "./plugins/**/");
588        assert_eq!(
589            info.dynamic_import_patterns[0].suffix,
590            Some(".js".to_string())
591        );
592    }
593
594    // ── Vue/Svelte SFC parsing ──────────────────────────────────
595
596    fn parse_sfc(source: &str, filename: &str) -> ModuleInfo {
597        parse_source_to_module(FileId(0), Path::new(filename), source, 0)
598    }
599
600    #[test]
601    fn extracts_vue_script_imports() {
602        let info = parse_sfc(
603            r#"
604<script lang="ts">
605import { ref } from 'vue';
606import { helper } from './utils';
607export default {};
608</script>
609<template><div></div></template>
610"#,
611            "App.vue",
612        );
613        assert_eq!(info.imports.len(), 2);
614        assert!(info.imports.iter().any(|i| i.source == "vue"));
615        assert!(info.imports.iter().any(|i| i.source == "./utils"));
616    }
617
618    #[test]
619    fn extracts_vue_script_setup_imports() {
620        let info = parse_sfc(
621            r#"
622<script setup lang="ts">
623import { ref } from 'vue';
624const count = ref(0);
625</script>
626"#,
627            "Comp.vue",
628        );
629        assert_eq!(info.imports.len(), 1);
630        assert_eq!(info.imports[0].source, "vue");
631    }
632
633    #[test]
634    fn extracts_vue_both_scripts() {
635        let info = parse_sfc(
636            r#"
637<script lang="ts">
638import { defineComponent } from 'vue';
639export default defineComponent({});
640</script>
641<script setup lang="ts">
642import { ref } from 'vue';
643const count = ref(0);
644</script>
645"#,
646            "Dual.vue",
647        );
648        assert!(info.imports.len() >= 2);
649    }
650
651    #[test]
652    fn extracts_svelte_script_imports() {
653        let info = parse_sfc(
654            r#"
655<script lang="ts">
656import { onMount } from 'svelte';
657import { helper } from './utils';
658</script>
659<p>Hello</p>
660"#,
661            "App.svelte",
662        );
663        assert_eq!(info.imports.len(), 2);
664        assert!(info.imports.iter().any(|i| i.source == "svelte"));
665        assert!(info.imports.iter().any(|i| i.source == "./utils"));
666    }
667
668    #[test]
669    fn vue_no_script_returns_empty() {
670        let info = parse_sfc(
671            "<template><div></div></template><style>div {}</style>",
672            "NoScript.vue",
673        );
674        assert!(info.imports.is_empty());
675        assert!(info.exports.is_empty());
676    }
677
678    #[test]
679    fn vue_js_default_lang() {
680        let info = parse_sfc(
681            r#"
682<script>
683import { createApp } from 'vue';
684export default {};
685</script>
686"#,
687            "JsVue.vue",
688        );
689        assert_eq!(info.imports.len(), 1);
690    }
691
692    #[test]
693    fn vue_script_lang_tsx() {
694        let info = parse_sfc(
695            r#"
696<script lang="tsx">
697import { defineComponent } from 'vue';
698export default defineComponent({
699    render() { return <div>Hello</div>; }
700});
701</script>
702"#,
703            "TsxVue.vue",
704        );
705        assert_eq!(info.imports.len(), 1);
706        assert_eq!(info.imports[0].source, "vue");
707    }
708
709    #[test]
710    fn svelte_context_module_script() {
711        let info = parse_sfc(
712            r#"
713<script context="module" lang="ts">
714export const preload = () => {};
715</script>
716<script lang="ts">
717import { onMount } from 'svelte';
718let count = 0;
719</script>
720"#,
721            "Module.svelte",
722        );
723        assert!(info.imports.iter().any(|i| i.source == "svelte"));
724        assert!(!info.exports.is_empty());
725    }
726
727    #[test]
728    fn vue_script_with_generic_attr() {
729        let info = parse_sfc(
730            r#"
731<script setup lang="ts" generic="T extends Record<string, unknown>">
732import { ref } from 'vue';
733const items = ref<T[]>([]);
734</script>
735"#,
736            "Generic.vue",
737        );
738        assert_eq!(info.imports.len(), 1);
739        assert_eq!(info.imports[0].source, "vue");
740    }
741
742    #[test]
743    fn vue_empty_script_block() {
744        let info = parse_sfc(
745            r#"<script lang="ts"></script><template><div/></template>"#,
746            "Empty.vue",
747        );
748        assert!(info.imports.is_empty());
749        assert!(info.exports.is_empty());
750    }
751
752    #[test]
753    fn vue_whitespace_only_script() {
754        let info = parse_sfc(
755            "<script lang=\"ts\">\n  \n</script>\n<template><div/></template>",
756            "Whitespace.vue",
757        );
758        assert!(info.imports.is_empty());
759    }
760
761    #[test]
762    fn vue_script_src_attribute() {
763        let info = parse_sfc(
764            r#"<script src="./component.ts" lang="ts"></script><template><div/></template>"#,
765            "External.vue",
766        );
767        assert_eq!(info.imports.len(), 1);
768        assert_eq!(info.imports[0].source, "./component.ts");
769    }
770
771    #[test]
772    fn vue_script_inside_html_comment() {
773        let info = parse_sfc(
774            r#"
775<!-- <script lang="ts">
776import { bad } from 'should-not-be-found';
777</script> -->
778<script lang="ts">
779import { good } from 'vue';
780</script>
781<template><div/></template>
782"#,
783            "Commented.vue",
784        );
785        assert_eq!(info.imports.len(), 1);
786        assert_eq!(info.imports[0].source, "vue");
787    }
788
789    #[test]
790    fn vue_script_setup_with_compiler_macros() {
791        let info = parse_sfc(
792            r#"
793<script setup lang="ts">
794import { ref } from 'vue';
795const props = defineProps<{ msg: string }>();
796const emit = defineEmits<{ change: [value: string] }>();
797const count = ref(0);
798</script>
799"#,
800            "Macros.vue",
801        );
802        assert_eq!(info.imports.len(), 1);
803        assert_eq!(info.imports[0].source, "vue");
804    }
805
806    #[test]
807    fn vue_script_with_single_quoted_lang() {
808        let info = parse_sfc(
809            "<script lang='ts'>\nimport { ref } from 'vue';\n</script>",
810            "SingleQuote.vue",
811        );
812        assert_eq!(info.imports.len(), 1);
813        assert_eq!(info.imports[0].source, "vue");
814    }
815
816    #[test]
817    fn svelte_generics_attribute() {
818        let info = parse_sfc(
819            r#"
820<script lang="ts" generics="T extends Record<string, unknown>">
821import { onMount } from 'svelte';
822export let items: T[] = [];
823</script>
824"#,
825            "Generic.svelte",
826        );
827        assert_eq!(info.imports.len(), 1);
828        assert_eq!(info.imports[0].source, "svelte");
829    }
830
831    #[test]
832    fn vue_script_with_extra_attributes() {
833        let info = parse_sfc(
834            r#"
835<script lang="ts" id="app-script" type="module" data-custom="value">
836import { ref } from 'vue';
837</script>
838"#,
839            "ExtraAttrs.vue",
840        );
841        assert_eq!(info.imports.len(), 1);
842    }
843
844    #[test]
845    fn vue_multiple_script_setup_invalid() {
846        let info = parse_sfc(
847            r#"
848<script setup lang="ts">
849import { ref } from 'vue';
850</script>
851<script setup lang="ts">
852import { computed } from 'vue';
853</script>
854"#,
855            "DuplicateSetup.vue",
856        );
857        assert!(info.imports.len() >= 2);
858    }
859
860    #[test]
861    fn vue_script_case_insensitive() {
862        let info = parse_sfc(
863            "<SCRIPT lang=\"ts\">\nimport { ref } from 'vue';\n</SCRIPT>",
864            "Upper.vue",
865        );
866        assert_eq!(info.imports.len(), 1);
867    }
868
869    #[test]
870    fn svelte_script_with_context_and_generics() {
871        let info = parse_sfc(
872            r#"
873<script context="module" lang="ts">
874export function preload() { return {}; }
875</script>
876<script lang="ts" generics="T">
877import { onMount } from 'svelte';
878export let value: T;
879</script>
880"#,
881            "ContextGenerics.svelte",
882        );
883        assert!(info.imports.iter().any(|i| i.source == "svelte"));
884        assert!(!info.exports.is_empty());
885    }
886
887    #[test]
888    fn vue_script_with_nested_generics() {
889        let info = parse_sfc(
890            r#"
891<script setup lang="ts" generic="T extends Map<string, Set<number>>">
892import { ref } from 'vue';
893const items = ref<T>();
894</script>
895"#,
896            "NestedGeneric.vue",
897        );
898        assert_eq!(info.imports.len(), 1);
899        assert_eq!(info.imports[0].source, "vue");
900    }
901
902    #[test]
903    fn vue_script_src_with_body_ignored() {
904        let info = parse_sfc(
905            r#"<script src="./external.ts" lang="ts">
906import { unused } from 'should-not-matter';
907</script>"#,
908            "SrcWithBody.vue",
909        );
910        assert!(info.imports.iter().any(|i| i.source == "./external.ts"));
911    }
912
913    #[test]
914    fn vue_data_src_not_treated_as_src() {
915        let info = parse_sfc(
916            r#"<script lang="ts" data-src="./not-a-module.ts">
917import { ref } from 'vue';
918</script>"#,
919            "DataSrc.vue",
920        );
921        assert_eq!(info.imports.len(), 1);
922        assert_eq!(info.imports[0].source, "vue");
923    }
924
925    #[test]
926    fn vue_html_comment_string_not_corrupted() {
927        let info = parse_sfc(
928            r#"
929<script setup lang="ts">
930const htmlComment = "<!-- this is not a comment -->";
931import { ref } from 'vue';
932</script>
933"#,
934            "CommentString.vue",
935        );
936        assert_eq!(info.imports.len(), 1);
937        assert_eq!(info.imports[0].source, "vue");
938    }
939
940    #[test]
941    fn vue_script_spanning_html_comment() {
942        let info = parse_sfc(
943            r#"
944<!-- disabled:
945<script lang="ts">
946import { bad } from 'should-not-be-found';
947</script>
948-->
949<script lang="ts">
950import { good } from 'vue';
951</script>
952"#,
953            "SpanningComment.vue",
954        );
955        assert_eq!(info.imports.len(), 1);
956        assert_eq!(info.imports[0].source, "vue");
957    }
958
959    // ── Astro frontmatter parsing ──────────────────────────────
960
961    #[test]
962    fn extracts_astro_frontmatter_imports() {
963        let info = parse_source_to_module(
964            FileId(0),
965            Path::new("Layout.astro"),
966            r#"---
967import Layout from '../layouts/Layout.astro';
968import { Card } from '../components/Card';
969const title = "Hello";
970---
971<Layout title={title}>
972  <Card />
973</Layout>
974"#,
975            0,
976        );
977        assert_eq!(info.imports.len(), 2);
978        assert!(
979            info.imports
980                .iter()
981                .any(|i| i.source == "../layouts/Layout.astro")
982        );
983        assert!(
984            info.imports
985                .iter()
986                .any(|i| i.source == "../components/Card")
987        );
988    }
989
990    #[test]
991    fn astro_no_frontmatter_returns_empty() {
992        let info = parse_source_to_module(
993            FileId(0),
994            Path::new("Simple.astro"),
995            "<div>No frontmatter here</div>",
996            0,
997        );
998        assert!(info.imports.is_empty());
999        assert!(info.exports.is_empty());
1000    }
1001
1002    #[test]
1003    fn astro_empty_frontmatter() {
1004        let info = parse_source_to_module(
1005            FileId(0),
1006            Path::new("Empty.astro"),
1007            "---\n---\n<div>Content</div>",
1008            0,
1009        );
1010        assert!(info.imports.is_empty());
1011    }
1012
1013    #[test]
1014    fn astro_frontmatter_with_dynamic_import() {
1015        let info = parse_source_to_module(
1016            FileId(0),
1017            Path::new("Dynamic.astro"),
1018            r#"---
1019const mod = await import('../utils/helper');
1020---
1021<div>{mod.value}</div>
1022"#,
1023            0,
1024        );
1025        assert_eq!(info.dynamic_imports.len(), 1);
1026        assert_eq!(info.dynamic_imports[0].source, "../utils/helper");
1027    }
1028
1029    #[test]
1030    fn astro_frontmatter_with_reexport() {
1031        let info = parse_source_to_module(
1032            FileId(0),
1033            Path::new("ReExport.astro"),
1034            r#"---
1035export { default as Layout } from '../layouts/Layout.astro';
1036---
1037<div>Content</div>
1038"#,
1039            0,
1040        );
1041        assert_eq!(info.re_exports.len(), 1);
1042    }
1043
1044    // ── MDX import extraction ──────────────────────────────────
1045
1046    #[test]
1047    fn extracts_mdx_imports() {
1048        let info = parse_source_to_module(
1049            FileId(0),
1050            Path::new("post.mdx"),
1051            r#"import { Chart } from './Chart'
1052import Button from './Button'
1053
1054# My Post
1055
1056Some markdown content here.
1057
1058<Chart data={[1, 2, 3]} />
1059<Button>Click me</Button>
1060"#,
1061            0,
1062        );
1063        assert_eq!(info.imports.len(), 2);
1064        assert!(info.imports.iter().any(|i| i.source == "./Chart"));
1065        assert!(info.imports.iter().any(|i| i.source == "./Button"));
1066    }
1067
1068    #[test]
1069    fn extracts_mdx_exports() {
1070        let info = parse_source_to_module(
1071            FileId(0),
1072            Path::new("post.mdx"),
1073            r#"export const meta = { title: 'Hello' }
1074
1075# My Post
1076
1077Content here.
1078"#,
1079            0,
1080        );
1081        assert!(!info.exports.is_empty());
1082    }
1083
1084    #[test]
1085    fn mdx_no_imports_returns_empty() {
1086        let info = parse_source_to_module(
1087            FileId(0),
1088            Path::new("simple.mdx"),
1089            "# Just Markdown\n\nNo imports here.\n",
1090            0,
1091        );
1092        assert!(info.imports.is_empty());
1093        assert!(info.exports.is_empty());
1094    }
1095
1096    #[test]
1097    fn mdx_multiline_import() {
1098        let info = parse_source_to_module(
1099            FileId(0),
1100            Path::new("multi.mdx"),
1101            r#"import {
1102  Chart,
1103  Table,
1104  Graph
1105} from './components'
1106
1107# Dashboard
1108
1109<Chart />
1110"#,
1111            0,
1112        );
1113        assert_eq!(info.imports.len(), 3);
1114        assert!(info.imports.iter().all(|i| i.source == "./components"));
1115    }
1116
1117    #[test]
1118    fn mdx_imports_between_content() {
1119        let info = parse_source_to_module(
1120            FileId(0),
1121            Path::new("mixed.mdx"),
1122            r#"import { Header } from './Header'
1123
1124# Section 1
1125
1126Some content.
1127
1128import { Footer } from './Footer'
1129
1130## Section 2
1131
1132More content.
1133"#,
1134            0,
1135        );
1136        assert_eq!(info.imports.len(), 2);
1137        assert!(info.imports.iter().any(|i| i.source == "./Header"));
1138        assert!(info.imports.iter().any(|i| i.source == "./Footer"));
1139    }
1140
1141    // ── import.meta.glob / require.context ──────────────────────
1142
1143    #[test]
1144    fn extracts_import_meta_glob_pattern() {
1145        let info = parse_source("const mods = import.meta.glob('./components/*.tsx');");
1146        assert_eq!(info.dynamic_import_patterns.len(), 1);
1147        assert_eq!(info.dynamic_import_patterns[0].prefix, "./components/*.tsx");
1148    }
1149
1150    #[test]
1151    fn extracts_import_meta_glob_array() {
1152        let info =
1153            parse_source("const mods = import.meta.glob(['./pages/*.ts', './layouts/*.ts']);");
1154        assert_eq!(info.dynamic_import_patterns.len(), 2);
1155        assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/*.ts");
1156        assert_eq!(info.dynamic_import_patterns[1].prefix, "./layouts/*.ts");
1157    }
1158
1159    #[test]
1160    fn extracts_require_context_pattern() {
1161        let info = parse_source("const ctx = require.context('./icons', false);");
1162        assert_eq!(info.dynamic_import_patterns.len(), 1);
1163        assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/");
1164    }
1165
1166    #[test]
1167    fn extracts_require_context_recursive() {
1168        let info = parse_source("const ctx = require.context('./icons', true);");
1169        assert_eq!(info.dynamic_import_patterns.len(), 1);
1170        assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/**/");
1171    }
1172
1173    // ── Dynamic import namespace tracking ────────────────────────
1174
1175    #[test]
1176    fn dynamic_import_await_captures_local_name() {
1177        let info = parse_source(
1178            "async function f() { const mod = await import('./service'); mod.doStuff(); }",
1179        );
1180        assert_eq!(info.dynamic_imports.len(), 1);
1181        assert_eq!(info.dynamic_imports[0].source, "./service");
1182        assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1183        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1184    }
1185
1186    #[test]
1187    fn dynamic_import_without_await_captures_local_name() {
1188        let info = parse_source("const mod = import('./service');");
1189        assert_eq!(info.dynamic_imports.len(), 1);
1190        assert_eq!(info.dynamic_imports[0].source, "./service");
1191        assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1192    }
1193
1194    #[test]
1195    fn dynamic_import_destructured_captures_names() {
1196        let info =
1197            parse_source("async function f() { const { foo, bar } = await import('./module'); }");
1198        assert_eq!(info.dynamic_imports.len(), 1);
1199        assert_eq!(info.dynamic_imports[0].source, "./module");
1200        assert!(info.dynamic_imports[0].local_name.is_none());
1201        assert_eq!(
1202            info.dynamic_imports[0].destructured_names,
1203            vec!["foo", "bar"]
1204        );
1205    }
1206
1207    #[test]
1208    fn dynamic_import_destructured_with_rest_is_namespace() {
1209        let info = parse_source(
1210            "async function f() { const { foo, ...rest } = await import('./module'); }",
1211        );
1212        assert_eq!(info.dynamic_imports.len(), 1);
1213        assert_eq!(info.dynamic_imports[0].source, "./module");
1214        assert!(info.dynamic_imports[0].local_name.is_none());
1215        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1216    }
1217
1218    #[test]
1219    fn dynamic_import_side_effect_only() {
1220        let info = parse_source("async function f() { await import('./side-effect'); }");
1221        assert_eq!(info.dynamic_imports.len(), 1);
1222        assert_eq!(info.dynamic_imports[0].source, "./side-effect");
1223        assert!(info.dynamic_imports[0].local_name.is_none());
1224        assert!(info.dynamic_imports[0].destructured_names.is_empty());
1225    }
1226
1227    #[test]
1228    fn dynamic_import_no_duplicate_entries() {
1229        let info = parse_source("async function f() { const mod = await import('./service'); }");
1230        assert_eq!(info.dynamic_imports.len(), 1);
1231    }
1232
1233    // ---- CSS/SCSS extraction tests ----
1234
1235    fn parse_css(source: &str, filename: &str) -> ModuleInfo {
1236        parse_source_to_module(FileId(0), Path::new(filename), source, 0)
1237    }
1238
1239    #[test]
1240    fn extracts_css_import_quoted() {
1241        let info = parse_css(r#"@import "./reset.css";"#, "styles.css");
1242        assert_eq!(info.imports.len(), 1);
1243        assert_eq!(info.imports[0].source, "./reset.css");
1244        assert_eq!(info.imports[0].imported_name, ImportedName::SideEffect);
1245    }
1246
1247    #[test]
1248    fn extracts_css_import_single_quoted() {
1249        let info = parse_css("@import './variables.css';", "styles.css");
1250        assert_eq!(info.imports.len(), 1);
1251        assert_eq!(info.imports[0].source, "./variables.css");
1252    }
1253
1254    #[test]
1255    fn extracts_css_import_url() {
1256        let info = parse_css(r#"@import url("./base.css");"#, "styles.css");
1257        assert_eq!(info.imports.len(), 1);
1258        assert_eq!(info.imports[0].source, "./base.css");
1259    }
1260
1261    #[test]
1262    fn extracts_css_import_url_single_quoted() {
1263        let info = parse_css("@import url('./base.css');", "styles.css");
1264        assert_eq!(info.imports.len(), 1);
1265        assert_eq!(info.imports[0].source, "./base.css");
1266    }
1267
1268    #[test]
1269    fn extracts_css_import_url_unquoted() {
1270        let info = parse_css("@import url(./base.css);", "styles.css");
1271        assert_eq!(info.imports.len(), 1);
1272        assert_eq!(info.imports[0].source, "./base.css");
1273    }
1274
1275    #[test]
1276    fn extracts_multiple_css_imports() {
1277        let info = parse_css(
1278            r#"
1279@import "./reset.css";
1280@import "./variables.css";
1281@import url("./base.css");
1282"#,
1283            "styles.css",
1284        );
1285        assert_eq!(info.imports.len(), 3);
1286        assert_eq!(info.imports[0].source, "./reset.css");
1287        assert_eq!(info.imports[1].source, "./variables.css");
1288        assert_eq!(info.imports[2].source, "./base.css");
1289    }
1290
1291    #[test]
1292    fn extracts_css_import_tailwind_package() {
1293        let info = parse_css(r#"@import "tailwindcss";"#, "styles.css");
1294        assert_eq!(info.imports.len(), 1);
1295        assert_eq!(info.imports[0].source, "tailwindcss");
1296    }
1297
1298    #[test]
1299    fn scss_import_without_dot_slash_normalized() {
1300        let info = parse_css("@import 'app.scss';", "index.scss");
1301        assert_eq!(info.imports.len(), 1);
1302        assert_eq!(info.imports[0].source, "./app.scss");
1303    }
1304
1305    #[test]
1306    fn scss_import_bare_extensionless_stays_bare() {
1307        // Extensionless imports like Tailwind should stay bare
1308        let info = parse_css(r#"@import "some-package";"#, "styles.scss");
1309        assert_eq!(info.imports.len(), 1);
1310        assert_eq!(info.imports[0].source, "some-package");
1311    }
1312
1313    #[test]
1314    fn css_apply_creates_tailwind_dependency() {
1315        let info = parse_css(
1316            r#"
1317.btn {
1318    @apply px-4 py-2 bg-blue-500 text-white;
1319}
1320"#,
1321            "styles.css",
1322        );
1323        assert!(
1324            info.imports.iter().any(|i| i.source == "tailwindcss"),
1325            "should create synthetic tailwindcss import"
1326        );
1327    }
1328
1329    #[test]
1330    fn css_tailwind_directive_creates_dependency() {
1331        let info = parse_css(
1332            r#"
1333@tailwind base;
1334@tailwind components;
1335@tailwind utilities;
1336"#,
1337            "styles.css",
1338        );
1339        assert!(
1340            info.imports.iter().any(|i| i.source == "tailwindcss"),
1341            "should create synthetic tailwindcss import"
1342        );
1343    }
1344
1345    #[test]
1346    fn css_without_apply_no_tailwind_dependency() {
1347        let info = parse_css(
1348            r#"
1349.btn {
1350    padding: 4px;
1351    color: blue;
1352}
1353"#,
1354            "styles.css",
1355        );
1356        assert!(
1357            !info.imports.iter().any(|i| i.source == "tailwindcss"),
1358            "should NOT create tailwindcss import without @apply"
1359        );
1360    }
1361
1362    #[test]
1363    fn extracts_scss_use() {
1364        let info = parse_css(r#"@use "./variables";"#, "styles.scss");
1365        assert_eq!(info.imports.len(), 1);
1366        assert_eq!(info.imports[0].source, "./variables");
1367    }
1368
1369    #[test]
1370    fn extracts_scss_forward() {
1371        let info = parse_css(r#"@forward "./mixins";"#, "styles.scss");
1372        assert_eq!(info.imports.len(), 1);
1373        assert_eq!(info.imports[0].source, "./mixins");
1374    }
1375
1376    #[test]
1377    fn scss_use_not_extracted_from_css() {
1378        let info = parse_css(r#"@use "./variables";"#, "styles.css");
1379        assert_eq!(info.imports.len(), 0);
1380    }
1381
1382    #[test]
1383    fn css_apply_with_multiple_classes() {
1384        let info = parse_css(
1385            r#"
1386.card {
1387    @apply shadow-lg rounded-lg p-4;
1388}
1389.header {
1390    @apply text-xl font-bold;
1391}
1392"#,
1393            "styles.css",
1394        );
1395        let tw_imports: Vec<_> = info
1396            .imports
1397            .iter()
1398            .filter(|i| i.source == "tailwindcss")
1399            .collect();
1400        assert_eq!(tw_imports.len(), 1);
1401    }
1402
1403    #[test]
1404    fn css_file_has_no_exports() {
1405        let info = parse_css(
1406            r#"
1407@import "./reset.css";
1408.btn { @apply px-4 py-2; }
1409"#,
1410            "styles.css",
1411        );
1412        assert!(info.exports.is_empty(), "CSS files should not have exports");
1413        assert!(info.re_exports.is_empty());
1414    }
1415
1416    #[test]
1417    fn scss_combined_imports_and_apply() {
1418        let info = parse_css(
1419            r#"
1420@use "./variables";
1421@use "./mixins";
1422@import "./reset.css";
1423
1424.btn {
1425    @apply px-4 py-2;
1426}
1427"#,
1428            "app.scss",
1429        );
1430        assert_eq!(info.imports.len(), 4);
1431        assert!(info.imports.iter().any(|i| i.source == "./variables"));
1432        assert!(info.imports.iter().any(|i| i.source == "./mixins"));
1433        assert!(info.imports.iter().any(|i| i.source == "./reset.css"));
1434        assert!(info.imports.iter().any(|i| i.source == "tailwindcss"));
1435    }
1436
1437    #[test]
1438    fn css_import_with_media_query() {
1439        let info = parse_css(r#"@import "./print.css" print;"#, "styles.css");
1440        assert_eq!(info.imports.len(), 1);
1441        assert_eq!(info.imports[0].source, "./print.css");
1442    }
1443
1444    #[test]
1445    fn css_commented_apply_not_extracted() {
1446        let info = parse_css(
1447            r#"
1448/* @apply px-4 py-2; */
1449.btn {
1450    padding: 4px;
1451}
1452"#,
1453            "styles.css",
1454        );
1455        assert!(
1456            !info.imports.iter().any(|i| i.source == "tailwindcss"),
1457            "commented-out @apply should NOT create tailwindcss import"
1458        );
1459    }
1460
1461    #[test]
1462    fn css_commented_import_not_extracted() {
1463        let info = parse_css(
1464            r#"
1465/* @import "./old-reset.css"; */
1466.btn { color: red; }
1467"#,
1468            "styles.css",
1469        );
1470        assert!(info.imports.is_empty());
1471    }
1472
1473    #[test]
1474    fn css_commented_tailwind_not_extracted() {
1475        let info = parse_css(
1476            r#"
1477/*
1478@tailwind base;
1479@tailwind components;
1480@tailwind utilities;
1481*/
1482.btn { color: red; }
1483"#,
1484            "styles.css",
1485        );
1486        assert!(
1487            !info.imports.iter().any(|i| i.source == "tailwindcss"),
1488            "commented-out @tailwind should NOT create tailwindcss import"
1489        );
1490    }
1491
1492    #[test]
1493    fn scss_line_comment_not_extracted() {
1494        let info = parse_css(
1495            r#"
1496// @use "./old-variables";
1497// @apply px-4;
1498.btn { color: red; }
1499"#,
1500            "styles.scss",
1501        );
1502        assert!(info.imports.is_empty());
1503    }
1504
1505    #[test]
1506    fn css_url_import_skipped() {
1507        let info = parse_css(
1508            r#"
1509@import "https://fonts.googleapis.com/css?family=Roboto";
1510@import url("https://cdn.example.com/reset.css");
1511@import "./local.css";
1512"#,
1513            "styles.css",
1514        );
1515        assert_eq!(info.imports.len(), 1);
1516        assert_eq!(info.imports[0].source, "./local.css");
1517    }
1518
1519    #[test]
1520    fn css_data_uri_import_skipped() {
1521        let info = parse_css(
1522            r#"@import url("data:text/css;base64,Ym9keSB7fQ==");"#,
1523            "styles.css",
1524        );
1525        assert!(info.imports.is_empty());
1526    }
1527
1528    #[test]
1529    fn css_mixed_comments_and_real_directives() {
1530        let info = parse_css(
1531            r#"
1532/* @import "./commented-out.css"; */
1533@import "./real-import.css";
1534/* @apply hidden; */
1535.visible {
1536    @apply block text-lg;
1537}
1538"#,
1539            "styles.css",
1540        );
1541        assert_eq!(info.imports.len(), 2);
1542        assert!(info.imports.iter().any(|i| i.source == "./real-import.css"));
1543        assert!(info.imports.iter().any(|i| i.source == "tailwindcss"));
1544    }
1545
1546    // ── CSS Module extraction ─────────────────────────────────────
1547
1548    fn parse_css_module(source: &str) -> ModuleInfo {
1549        parse_source_to_module(FileId(0), Path::new("Component.module.css"), source, 0)
1550    }
1551
1552    fn parse_css_non_module(source: &str) -> ModuleInfo {
1553        parse_source_to_module(FileId(0), Path::new("styles.css"), source, 0)
1554    }
1555
1556    #[test]
1557    fn css_module_extracts_class_names_as_exports() {
1558        let info = parse_css_module(".header { color: red; } .footer { color: blue; }");
1559        let export_names: Vec<&ExportName> = info.exports.iter().map(|e| &e.name).collect();
1560        assert!(export_names.contains(&&ExportName::Named("header".to_string())));
1561        assert!(export_names.contains(&&ExportName::Named("footer".to_string())));
1562        assert!(!export_names.contains(&&ExportName::Default));
1563    }
1564
1565    #[test]
1566    fn css_module_extracts_kebab_case_class_names() {
1567        let info = parse_css_module(".nav-bar { display: flex; } .main-content { padding: 10px; }");
1568        let named: Vec<String> = info
1569            .exports
1570            .iter()
1571            .filter_map(|e| match &e.name {
1572                ExportName::Named(n) => Some(n.clone()),
1573                _ => None,
1574            })
1575            .collect();
1576        assert!(named.contains(&"nav-bar".to_string()));
1577        assert!(named.contains(&"main-content".to_string()));
1578    }
1579
1580    #[test]
1581    fn css_module_deduplicates_class_names() {
1582        let info = parse_css_module(".btn { color: red; } .btn { font-size: 14px; }");
1583        let named_count = info
1584            .exports
1585            .iter()
1586            .filter(|e| matches!(&e.name, ExportName::Named(n) if n == "btn"))
1587            .count();
1588        assert_eq!(
1589            named_count, 1,
1590            "Duplicate class names should be deduplicated"
1591        );
1592    }
1593
1594    #[test]
1595    fn css_module_no_default_export() {
1596        let info = parse_css_module(".foo { color: red; }");
1597        assert!(
1598            !info.exports.iter().any(|e| e.name == ExportName::Default),
1599            "CSS modules should not emit a default export (handled at graph level)"
1600        );
1601    }
1602
1603    #[test]
1604    fn non_module_css_has_no_exports() {
1605        let info = parse_css_non_module(".header { color: red; }");
1606        assert!(
1607            info.exports.is_empty(),
1608            "Non-module CSS should have no exports"
1609        );
1610    }
1611
1612    #[test]
1613    fn css_module_ignores_classes_in_comments() {
1614        let info = parse_css_module("/* .commented { color: red; } */ .active { color: green; }");
1615        let named: Vec<String> = info
1616            .exports
1617            .iter()
1618            .filter_map(|e| match &e.name {
1619                ExportName::Named(n) => Some(n.clone()),
1620                _ => None,
1621            })
1622            .collect();
1623        assert!(
1624            !named.contains(&"commented".to_string()),
1625            "Classes in comments should be ignored"
1626        );
1627        assert!(named.contains(&"active".to_string()));
1628    }
1629
1630    #[test]
1631    fn scss_module_extracts_class_names() {
1632        let info = parse_source_to_module(
1633            FileId(0),
1634            Path::new("Component.module.scss"),
1635            ".wrapper { .inner { color: red; } }",
1636            0,
1637        );
1638        let named: Vec<String> = info
1639            .exports
1640            .iter()
1641            .filter_map(|e| match &e.name {
1642                ExportName::Named(n) => Some(n.clone()),
1643                _ => None,
1644            })
1645            .collect();
1646        assert!(named.contains(&"wrapper".to_string()));
1647        assert!(named.contains(&"inner".to_string()));
1648    }
1649
1650    #[test]
1651    fn css_module_with_complex_selectors() {
1652        let info =
1653            parse_css_module(".btn:hover { color: red; } .btn.active { } .container > .child { }");
1654        let named: Vec<String> = info
1655            .exports
1656            .iter()
1657            .filter_map(|e| match &e.name {
1658                ExportName::Named(n) => Some(n.clone()),
1659                _ => None,
1660            })
1661            .collect();
1662        assert!(named.contains(&"btn".to_string()));
1663        assert!(named.contains(&"active".to_string()));
1664        assert!(named.contains(&"container".to_string()));
1665        assert!(named.contains(&"child".to_string()));
1666    }
1667
1668    #[test]
1669    fn css_module_ignores_classes_in_strings_and_urls() {
1670        let info = parse_css_module(
1671            r#".real { content: ".fake"; background: url(./img/hero.png); } .also-real { color: red; }"#,
1672        );
1673        let named: Vec<String> = info
1674            .exports
1675            .iter()
1676            .filter_map(|e| match &e.name {
1677                ExportName::Named(n) => Some(n.clone()),
1678                _ => None,
1679            })
1680            .collect();
1681        assert!(named.contains(&"real".to_string()));
1682        assert!(named.contains(&"also-real".to_string()));
1683        assert!(
1684            !named.contains(&"fake".to_string()),
1685            "Classes inside quoted strings should be ignored"
1686        );
1687        assert!(
1688            !named.contains(&"png".to_string()),
1689            "File extensions inside url() should be ignored"
1690        );
1691    }
1692}