1use std::{
14 cell::RefCell,
15 collections::{BTreeMap, BTreeSet, VecDeque},
16 fs,
17 path::{Component, Path, PathBuf},
18};
19
20use serde_json::Value;
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Loads {
26 pub files: BTreeSet<String>,
27 pub open: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32enum Specifier {
33 Literal(String),
34 Computed,
36}
37
38const EXTENSIONS: [&str; 9] = [
39 ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json",
40];
41
42pub fn reads_imports(file: &str) -> bool {
44 EXTENSIONS[..8]
45 .iter()
46 .any(|extension| file.ends_with(extension))
47}
48
49pub struct Imports {
51 root: PathBuf,
52 packages: BTreeSet<String>,
53 aliases: Vec<(String, Vec<PathBuf>)>,
55 subpaths: BTreeMap<String, Vec<String>>,
57 parsed: RefCell<BTreeMap<String, Option<Vec<Specifier>>>>,
58}
59
60impl Imports {
61 pub fn new(root: &Path) -> Self {
62 let manifest = read_json(&root.join("package.json"));
63 let mut packages = BTreeSet::new();
64 let mut subpaths = BTreeMap::new();
65 if let Some(manifest) = &manifest {
66 for field in [
67 "dependencies",
68 "devDependencies",
69 "peerDependencies",
70 "optionalDependencies",
71 ] {
72 if let Some(names) = manifest.get(field).and_then(Value::as_object) {
73 packages.extend(names.keys().cloned());
74 }
75 }
76 if let Some(name) = manifest.get("name").and_then(Value::as_str) {
77 packages.insert(name.to_owned());
78 }
79 if let Some(imports) = manifest.get("imports").and_then(Value::as_object) {
80 for (name, target) in imports {
81 let mut targets = Vec::new();
82 collect_strings(target, &mut targets);
83 subpaths.insert(name.clone(), targets);
84 }
85 }
86 }
87 Self {
88 root: root.to_owned(),
89 packages,
90 aliases: aliases(root),
91 subpaths,
92 parsed: RefCell::new(BTreeMap::new()),
93 }
94 }
95
96 pub fn loads<'e>(&self, entries: impl IntoIterator<Item = &'e str>) -> Loads {
98 let mut loads = Loads::default();
99 let mut queue = entries
100 .into_iter()
101 .map(str::to_owned)
102 .collect::<VecDeque<_>>();
103 while let Some(file) = queue.pop_front() {
104 if !loads.files.insert(file.clone()) || !reads_imports(&file) {
105 continue;
106 }
107 let Some(specifiers) = self.specifiers(&file) else {
108 loads
109 .open
110 .get_or_insert_with(|| format!("{file} could not be read"));
111 continue;
112 };
113 for specifier in specifiers {
114 match specifier {
115 Specifier::Computed => {
116 loads
117 .open
118 .get_or_insert_with(|| format!("{file} imports a computed path"));
119 }
120 Specifier::Literal(specifier) => match self.resolve(&file, &specifier) {
121 Resolved::File(next) => queue.push_back(next),
122 Resolved::Outside => {}
123 Resolved::Unknown => {
124 loads.open.get_or_insert_with(|| {
125 format!("{file} imports '{specifier}', which Supercov cannot find")
126 });
127 }
128 },
129 }
130 }
131 }
132 loads
133 }
134
135 pub fn named_by(&self, configuration: &str) -> Vec<String> {
139 let Ok(source) = fs::read_to_string(self.root.join(configuration)) else {
140 return Vec::new();
141 };
142 let directory = Path::new(configuration).parent().unwrap_or(Path::new(""));
143 let mut named = Vec::new();
144 for text in strings(configuration, &source) {
145 if !(text.starts_with('.') || text.contains('/')) || text.contains(['*', '{', '?']) {
146 continue;
147 }
148 for candidate in [directory.join(&text), PathBuf::from(&text)] {
149 if let Resolved::File(file) = self.file(&candidate)
150 && !named.contains(&file)
151 {
152 named.push(file);
153 break;
154 }
155 }
156 }
157 named
158 }
159
160 fn specifiers(&self, file: &str) -> Option<Vec<Specifier>> {
161 if let Some(known) = self.parsed.borrow().get(file) {
162 return known.clone();
163 }
164 let parsed = fs::read_to_string(self.root.join(file))
165 .ok()
166 .and_then(|source| parse(file, &source));
167 self.parsed
168 .borrow_mut()
169 .insert(file.to_owned(), parsed.clone());
170 parsed
171 }
172
173 fn resolve(&self, from: &str, specifier: &str) -> Resolved {
174 let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
175 if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
176 let base = Path::new(from).parent().unwrap_or(Path::new(""));
177 return self.file(&base.join(specifier));
178 }
179 if specifier.starts_with('/') {
180 return Resolved::Unknown;
181 }
182 if let Some(name) = specifier.strip_prefix("node:") {
183 let _ = name;
184 return Resolved::Outside;
185 }
186 if specifier.starts_with('#') {
187 return self.subpath(specifier);
188 }
189 for (pattern, targets) in &self.aliases {
190 let rest = match pattern.split_once('*') {
191 Some((prefix, suffix)) => specifier
192 .strip_prefix(prefix)
193 .and_then(|rest| rest.strip_suffix(suffix)),
194 None => (specifier == pattern).then_some(""),
195 };
196 let Some(rest) = rest else { continue };
197 for target in targets {
198 let target = target.to_string_lossy().replacen('*', rest, 1);
199 if let Resolved::File(file) = self.file(Path::new(&target)) {
200 return Resolved::File(file);
201 }
202 }
203 }
204 let package = package_name(specifier);
205 if BUILTINS.contains(&package)
207 || self.packages.contains(package)
208 || self
209 .root
210 .ancestors()
211 .any(|directory| directory.join("node_modules").join(package).exists())
212 {
213 return Resolved::Outside;
214 }
215 Resolved::Unknown
216 }
217
218 fn subpath(&self, specifier: &str) -> Resolved {
219 let targets = self.subpaths.get(specifier).cloned().or_else(|| {
220 self.subpaths.iter().find_map(|(pattern, targets)| {
221 let (prefix, suffix) = pattern.split_once('*')?;
222 let rest = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
223 Some(
224 targets
225 .iter()
226 .map(|target| target.replacen('*', rest, 1))
227 .collect(),
228 )
229 })
230 });
231 let Some(targets) = targets else {
232 return Resolved::Unknown;
233 };
234 for target in targets {
235 if !target.starts_with("./") {
236 return Resolved::Outside;
237 }
238 if let Resolved::File(file) = self.file(Path::new(&target)) {
239 return Resolved::File(file);
240 }
241 }
242 Resolved::Unknown
243 }
244
245 fn file(&self, path: &Path) -> Resolved {
249 let Some(path) = normal(path) else {
250 return Resolved::Unknown;
251 };
252 if path.split('/').any(|part| part == "node_modules") {
253 return Resolved::Outside;
254 }
255 let exists = |candidate: &str| self.root.join(candidate).is_file();
256 let mut candidates = vec![path.clone()];
257 for (javascript, typescript) in [
258 (".js", [".ts", ".tsx"]),
259 (".jsx", [".tsx", ".tsx"]),
260 (".mjs", [".mts", ".mts"]),
261 (".cjs", [".cts", ".cts"]),
262 ] {
263 if let Some(stem) = path.strip_suffix(javascript) {
264 candidates.extend(typescript.iter().map(|ts| format!("{stem}{ts}")));
265 }
266 }
267 candidates.extend(
268 EXTENSIONS
269 .iter()
270 .map(|extension| format!("{path}{extension}")),
271 );
272 candidates.extend(
273 EXTENSIONS
274 .iter()
275 .map(|extension| format!("{path}/index{extension}")),
276 );
277 match candidates.into_iter().find(|candidate| exists(candidate)) {
278 Some(found) => Resolved::File(found),
279 None => Resolved::Unknown,
280 }
281 }
282}
283
284enum Resolved {
285 File(String),
286 Outside,
288 Unknown,
289}
290
291const BUILTINS: [&str; 40] = [
292 "assert",
293 "async_hooks",
294 "buffer",
295 "child_process",
296 "cluster",
297 "console",
298 "constants",
299 "crypto",
300 "dgram",
301 "diagnostics_channel",
302 "dns",
303 "domain",
304 "events",
305 "fs",
306 "http",
307 "http2",
308 "https",
309 "inspector",
310 "module",
311 "net",
312 "os",
313 "path",
314 "perf_hooks",
315 "process",
316 "punycode",
317 "querystring",
318 "readline",
319 "repl",
320 "stream",
321 "string_decoder",
322 "timers",
323 "tls",
324 "tty",
325 "url",
326 "util",
327 "v8",
328 "vm",
329 "worker_threads",
330 "zlib",
331 "test",
332];
333
334fn package_name(specifier: &str) -> &str {
335 let mut parts = specifier.splitn(3, '/');
336 let first = parts.next().unwrap_or(specifier);
337 if first.starts_with('@')
338 && let Some(second) = parts.next()
339 {
340 return &specifier[..first.len() + 1 + second.len()];
341 }
342 first
343}
344
345fn normal(path: &Path) -> Option<String> {
347 let mut parts = Vec::new();
348 for component in path.components() {
349 match component {
350 Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
351 Component::CurDir => {}
352 Component::ParentDir => {
353 parts.pop()?;
354 }
355 _ => return None,
356 }
357 }
358 Some(parts.join("/"))
359}
360
361fn read_json(path: &Path) -> Option<Value> {
362 let text = fs::read_to_string(path).ok()?;
363 serde_json::from_str(&crate::source_discovery::strip_trailing_commas(
364 &crate::source_discovery::strip_jsonc_comments(&text),
365 ))
366 .ok()
367}
368
369fn collect_strings(value: &Value, into: &mut Vec<String>) {
370 match value {
371 Value::String(text) => into.push(text.clone()),
372 Value::Object(map) => map.values().for_each(|value| collect_strings(value, into)),
373 Value::Array(items) => items.iter().for_each(|value| collect_strings(value, into)),
374 _ => {}
375 }
376}
377
378fn aliases(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
381 let mut found = Vec::new();
382 let mut config = root.join("tsconfig.json");
383 let mut seen = BTreeSet::new();
384 while seen.insert(config.clone()) && seen.len() < 16 {
385 let Some(json) = read_json(&config) else {
386 break;
387 };
388 let directory = config.parent().unwrap_or(root).to_owned();
389 let options = json.get("compilerOptions");
390 let base = options
391 .and_then(|options| options.get("baseUrl"))
392 .and_then(Value::as_str)
393 .map_or(directory.clone(), |base| directory.join(base));
394 if found.is_empty()
395 && let Some(paths) = options
396 .and_then(|options| options.get("paths"))
397 .and_then(Value::as_object)
398 {
399 for (pattern, targets) in paths {
400 let targets = targets
401 .as_array()
402 .into_iter()
403 .flatten()
404 .filter_map(Value::as_str)
405 .filter_map(|target| {
406 base.join(target)
407 .strip_prefix(root)
408 .ok()
409 .map(Path::to_owned)
410 })
411 .collect();
412 found.push((pattern.clone(), targets));
413 }
414 }
415 match json.get("extends").and_then(Value::as_str) {
416 Some(parent) if parent.starts_with('.') => {
417 config = directory.join(parent);
418 if config.extension().is_none() {
419 config.set_extension("json");
420 }
421 }
422 _ => break,
423 }
424 }
425 found
426}
427
428fn parse(file: &str, source: &str) -> Option<Vec<Specifier>> {
432 use oxc_ast::ast::*;
433 use oxc_ast_visit::{Visit, walk};
434
435 let source_type = crate::js_instrumenter::project_source_type(Path::new(file)).ok()?;
436 let allocator = oxc_allocator::Allocator::default();
437 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
438 if parsed.panicked {
439 return None;
440 }
441 struct Collector {
442 found: Vec<Specifier>,
443 }
444 fn literal(expression: &Expression<'_>) -> Specifier {
445 match expression {
446 Expression::StringLiteral(text) => Specifier::Literal(text.value.to_string()),
447 Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
448 .quasis
449 .first()
450 .and_then(|quasi| quasi.value.cooked.as_ref())
451 .map_or(Specifier::Computed, |text| {
452 Specifier::Literal(text.to_string())
453 }),
454 _ => Specifier::Computed,
455 }
456 }
457 impl<'a> Visit<'a> for Collector {
458 fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
459 if !it.import_kind.is_type() {
460 self.found
461 .push(Specifier::Literal(it.source.value.to_string()));
462 }
463 }
464 fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) {
465 if !it.export_kind.is_type() {
466 self.found
467 .push(Specifier::Literal(it.source.value.to_string()));
468 }
469 }
470 fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) {
471 if let Some(source) = &it.source
472 && !it.export_kind.is_type()
473 {
474 self.found
475 .push(Specifier::Literal(source.value.to_string()));
476 }
477 walk::walk_export_named_declaration(self, it);
478 }
479 fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) {
480 if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference
481 && !it.import_kind.is_type()
482 {
483 self.found
484 .push(Specifier::Literal(reference.expression.value.to_string()));
485 }
486 }
487 fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
488 self.found.push(literal(&it.source));
489 walk::walk_import_expression(self, it);
490 }
491 fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
492 let loads = match &it.callee {
493 Expression::Identifier(name) => name.name == "require",
494 Expression::StaticMemberExpression(member) => {
495 let object = match &member.object {
496 Expression::Identifier(name) => name.name.as_str(),
497 _ => "",
498 };
499 let property = member.property.name.as_str();
500 matches!(
501 (object, property),
502 ("require", "resolve")
503 | ("jest", "mock" | "requireActual" | "doMock")
504 | ("vi", "mock" | "importActual" | "doMock")
505 )
506 }
507 _ => false,
508 };
509 if loads && let Some(first) = it.arguments.first() {
510 self.found.push(match first.as_expression() {
511 Some(expression) => literal(expression),
512 None => Specifier::Computed,
513 });
514 }
515 walk::walk_call_expression(self, it);
516 }
517 }
518 let mut collector = Collector { found: Vec::new() };
519 collector.visit_program(&parsed.program);
520 Some(collector.found)
521}
522
523pub fn configuration_runner(file: &str) -> Option<&'static str> {
526 let name = file.rsplit('/').next()?.to_ascii_lowercase();
527 if !name.contains(".config.") && !name.contains(".workspace.") {
528 return None;
529 }
530 let stem = name.split('.').next()?;
531 match stem {
532 "playwright" | "playwright-ct" => Some("playwright"),
533 "vitest" => Some("vitest"),
534 "jest" => Some("jest"),
535 "ava" => Some("ava"),
536 _ => None,
537 }
538}
539
540fn strings(file: &str, source: &str) -> Vec<String> {
541 use oxc_ast::ast::*;
542 use oxc_ast_visit::Visit;
543 let Ok(source_type) = crate::js_instrumenter::project_source_type(Path::new(file)) else {
544 return Vec::new();
545 };
546 let allocator = oxc_allocator::Allocator::default();
547 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
548 struct Collector(Vec<String>);
549 impl<'a> Visit<'a> for Collector {
550 fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
551 self.0.push(it.value.to_string());
552 }
553 }
554 let mut collector = Collector(Vec::new());
555 collector.visit_program(&parsed.program);
556 collector.0
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn project(files: &[(&str, &str)]) -> PathBuf {
564 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
565 let root = std::env::temp_dir().join(format!(
566 "supercov-test-loads-{}-{}-{}",
567 std::process::id(),
568 std::time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH)
570 .unwrap()
571 .as_nanos(),
572 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
573 ));
574 fs::create_dir(&root).unwrap();
575 for (file, text) in files {
576 let path = root.join(file);
577 fs::create_dir_all(path.parent().unwrap()).unwrap();
578 fs::write(path, text).unwrap();
579 }
580 root
581 }
582
583 #[test]
584 fn a_test_loads_its_helpers_and_not_another_suites() {
585 let root = project(&[
586 (
587 "package.json",
588 r#"{"devDependencies":{"@playwright/test":"1"}}"#,
589 ),
590 (
591 "tsconfig.json",
592 r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
593 ),
594 (
595 "tests/e2e/helpers.ts",
596 "import { x } from '@/lib/x';\nexport const get = () => x;\n",
597 ),
598 (
599 "tests/e2e/app.spec.ts",
600 "import { test } from '@playwright/test';\nimport { get } from './helpers';\nimport type { T } from './types';\n",
601 ),
602 ("tests/e2e/types.ts", "export type T = 1;\n"),
603 (
604 "tests/unit/a.test.ts",
605 "import { y } from '../support/y.js';\n",
606 ),
607 ("tests/support/y.ts", "export const y = 1;\n"),
608 ("src/lib/x.ts", "export const x = 1;\n"),
609 ]);
610 let imports = Imports::new(&root);
611 let e2e = imports.loads(["tests/e2e/app.spec.ts"]);
612 assert_eq!(e2e.open, None);
613 assert!(e2e.files.contains("tests/e2e/helpers.ts"));
614 assert!(e2e.files.contains("src/lib/x.ts"), "{e2e:?}");
615 assert!(
616 !e2e.files.contains("tests/e2e/types.ts"),
617 "a type import loads nothing"
618 );
619 assert!(!e2e.files.contains("tests/support/y.ts"));
620 let unit = imports.loads(["tests/unit/a.test.ts"]);
621 assert!(unit.files.contains("tests/support/y.ts"), "{unit:?}");
622 assert!(!unit.files.contains("tests/e2e/helpers.ts"));
623 fs::remove_dir_all(root).unwrap();
624 }
625
626 #[test]
627 fn what_cannot_be_followed_leaves_the_answer_open() {
628 let root = project(&[
629 ("package.json", "{}"),
630 ("a.test.js", "const name = 'b';\nrequire(`./${name}`);\n"),
631 ("c.test.js", "import 'unknown-alias/thing';\n"),
632 (
633 "d.test.js",
634 "import fs from 'node:fs';\nimport path from 'path';\nimport './e.js';\n",
635 ),
636 ("e.js", "export {};\n"),
637 ]);
638 let imports = Imports::new(&root);
639 assert!(imports.loads(["a.test.js"]).open.is_some());
640 assert!(imports.loads(["c.test.js"]).open.is_some());
641 let closed = imports.loads(["d.test.js"]);
642 assert_eq!(closed.open, None);
643 assert!(closed.files.contains("e.js"));
644 fs::remove_dir_all(root).unwrap();
645 }
646
647 #[test]
648 fn a_configuration_belongs_to_its_runner() {
649 assert_eq!(
650 configuration_runner("tests/e2e/playwright.config.ts"),
651 Some("playwright")
652 );
653 assert_eq!(configuration_runner("vitest.config.mts"), Some("vitest"));
654 assert_eq!(configuration_runner("jest.config.js"), Some("jest"));
655 assert_eq!(
656 configuration_runner("vite.config.ts"),
657 None,
658 "a build shared with the app"
659 );
660 assert_eq!(configuration_runner("tsconfig.json"), None);
661 assert_eq!(configuration_runner("next.config.ts"), None);
662 }
663
664 #[test]
665 fn a_configuration_names_its_setup_files() {
666 let root = project(&[
667 (
668 "vitest.config.ts",
669 "export default { test: { setupFiles: ['tests/unit/setup.ts'], include: ['tests/**/*.test.ts'] } };\n",
670 ),
671 ("tests/unit/setup.ts", "export {};\n"),
672 (
673 "e2e/playwright.config.ts",
674 "export default { globalSetup: './global-setup', testMatch: '*.spec.ts' };\n",
675 ),
676 ("e2e/global-setup.ts", "export default () => {};\n"),
677 ]);
678 let imports = Imports::new(&root);
679 assert_eq!(
680 imports.named_by("vitest.config.ts"),
681 ["tests/unit/setup.ts"]
682 );
683 assert_eq!(
684 imports.named_by("e2e/playwright.config.ts"),
685 ["e2e/global-setup.ts"]
686 );
687 fs::remove_dir_all(root).unwrap();
688 }
689}