1#![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
25pub 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
32pub 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
40pub 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
69fn 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
79fn 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 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 let source = std::fs::read_to_string(&file.path).ok()?;
110 let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
111
112 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 Some(parse_source_to_module(
123 file.id,
124 &file.path,
125 &source,
126 content_hash,
127 ))
128}
129
130pub 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
142pub 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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}