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 file.ends_with(".py")
45 || file.ends_with(".rb")
46 || EXTENSIONS[..8]
47 .iter()
48 .any(|extension| file.ends_with(extension))
49}
50
51pub struct Imports {
53 root: PathBuf,
54 packages: BTreeSet<String>,
55 aliases: Vec<(String, Vec<PathBuf>)>,
57 subpaths: BTreeMap<String, Vec<String>>,
59 parsed: RefCell<BTreeMap<String, Option<Vec<Specifier>>>>,
60 python: RefCell<BTreeMap<String, Option<Vec<PythonImport>>>>,
61 ruby: RefCell<BTreeMap<String, Option<Vec<RubyRequire>>>>,
62}
63
64impl Imports {
65 pub fn new(root: &Path) -> Self {
66 let manifest = read_json(&root.join("package.json"));
67 let mut packages = BTreeSet::new();
68 let mut subpaths = BTreeMap::new();
69 if let Some(manifest) = &manifest {
70 for field in [
71 "dependencies",
72 "devDependencies",
73 "peerDependencies",
74 "optionalDependencies",
75 ] {
76 if let Some(names) = manifest.get(field).and_then(Value::as_object) {
77 packages.extend(names.keys().cloned());
78 }
79 }
80 if let Some(name) = manifest.get("name").and_then(Value::as_str) {
81 packages.insert(name.to_owned());
82 }
83 if let Some(imports) = manifest.get("imports").and_then(Value::as_object) {
84 for (name, target) in imports {
85 let mut targets = Vec::new();
86 collect_strings(target, &mut targets);
87 subpaths.insert(name.clone(), targets);
88 }
89 }
90 }
91 Self {
92 root: root.to_owned(),
93 packages,
94 aliases: aliases(root),
95 subpaths,
96 parsed: RefCell::new(BTreeMap::new()),
97 python: RefCell::new(BTreeMap::new()),
98 ruby: RefCell::new(BTreeMap::new()),
99 }
100 }
101
102 pub fn loads<'e>(&self, entries: impl IntoIterator<Item = &'e str>) -> Loads {
104 let mut loads = Loads::default();
105 let mut queue = entries
106 .into_iter()
107 .map(str::to_owned)
108 .collect::<VecDeque<_>>();
109 while let Some(file) = queue.pop_front() {
110 if !loads.files.insert(file.clone()) || !reads_imports(&file) {
111 continue;
112 }
113 if file.ends_with(".py") {
114 self.python_loads(&file, &mut loads, &mut queue);
115 continue;
116 }
117 if file.ends_with(".rb") {
118 self.ruby_loads(&file, &mut loads, &mut queue);
119 continue;
120 }
121 let Some(specifiers) = self.specifiers(&file) else {
122 loads
123 .open
124 .get_or_insert_with(|| format!("{file} could not be read"));
125 continue;
126 };
127 for specifier in specifiers {
128 match specifier {
129 Specifier::Computed => {
130 loads
131 .open
132 .get_or_insert_with(|| format!("{file} imports a computed path"));
133 }
134 Specifier::Literal(specifier) => match self.resolve(&file, &specifier) {
135 Resolved::File(next) => queue.push_back(next),
136 Resolved::Outside => {}
137 Resolved::Unknown => {
138 loads.open.get_or_insert_with(|| {
139 format!("{file} imports '{specifier}', which Supercov cannot find")
140 });
141 }
142 },
143 }
144 }
145 }
146 loads
147 }
148
149 pub fn named_by(&self, configuration: &str) -> Vec<String> {
153 let Ok(source) = fs::read_to_string(self.root.join(configuration)) else {
154 return Vec::new();
155 };
156 let directory = Path::new(configuration).parent().unwrap_or(Path::new(""));
157 let mut named = Vec::new();
158 for text in strings(configuration, &source) {
159 if !(text.starts_with('.') || text.contains('/')) || text.contains(['*', '{', '?']) {
160 continue;
161 }
162 for candidate in [directory.join(&text), PathBuf::from(&text)] {
163 if let Resolved::File(file) = self.file(&candidate)
164 && !named.contains(&file)
165 {
166 named.push(file);
167 break;
168 }
169 }
170 }
171 named
172 }
173
174 fn python_loads(&self, file: &str, loads: &mut Loads, queue: &mut VecDeque<String>) {
181 for directory in Path::new(file).ancestors().skip(1) {
182 if let Some(conftest) = normal(&directory.join("conftest.py"))
183 && self.root.join(&conftest).is_file()
184 {
185 queue.push_back(conftest);
186 }
187 }
188 let known = self.python.borrow().get(file).cloned();
189 let imports = known.unwrap_or_else(|| {
190 let parsed = fs::read_to_string(self.root.join(file))
191 .ok()
192 .and_then(|source| python_imports(&source));
193 self.python
194 .borrow_mut()
195 .insert(file.to_owned(), parsed.clone());
196 parsed
197 });
198 let Some(imports) = imports else {
199 loads
200 .open
201 .get_or_insert_with(|| format!("{file} could not be read"));
202 return;
203 };
204 for import in imports {
205 match import {
206 PythonImport::Computed(why) => {
207 loads.open.get_or_insert_with(|| format!("{file} {why}"));
208 }
209 PythonImport::Module {
210 module,
211 level,
212 names,
213 } => {
214 queue.extend(self.python_resolve(file, &module, level, &names));
215 }
216 }
217 }
218 }
219
220 fn ruby_loads(&self, file: &str, loads: &mut Loads, queue: &mut VecDeque<String>) {
227 let exists = |path: &Path| normal(path).filter(|path| self.root.join(path).is_file());
228 let feature = |name: &str| {
229 let name = name.strip_suffix(".rb").unwrap_or(name);
230 ["lib", "spec", "test", ""]
231 .iter()
232 .find_map(|base| exists(&Path::new(base).join(format!("{name}.rb"))))
233 };
234 if file.ends_with("_spec.rb")
235 && let Ok(options) = fs::read_to_string(self.root.join(".rspec"))
236 {
237 let words = options.split_whitespace().collect::<Vec<_>>();
238 for pair in words.windows(2) {
239 if matches!(pair[0], "--require" | "-r")
240 && let Some(required) = feature(pair[1])
241 {
242 queue.push_back(required);
243 }
244 }
245 for word in &words {
246 if let Some(name) = word.strip_prefix("--require=")
247 && let Some(required) = feature(name)
248 {
249 queue.push_back(required);
250 }
251 }
252 }
253 let known = self.ruby.borrow().get(file).cloned();
254 let requires = known.unwrap_or_else(|| {
255 let parsed = fs::read_to_string(self.root.join(file))
256 .ok()
257 .and_then(|source| ruby_requires(&source));
258 self.ruby
259 .borrow_mut()
260 .insert(file.to_owned(), parsed.clone());
261 parsed
262 });
263 let Some(requires) = requires else {
264 loads
265 .open
266 .get_or_insert_with(|| format!("{file} could not be read"));
267 return;
268 };
269 let directory = Path::new(file).parent().unwrap_or(Path::new(""));
270 for required in requires {
271 match required {
272 RubyRequire::Relative(path) => {
273 let path = path.strip_suffix(".rb").unwrap_or(&path).to_owned();
274 match exists(&directory.join(format!("{path}.rb"))) {
275 Some(next) => queue.push_back(next),
276 None => {
277 loads.open.get_or_insert_with(|| {
278 format!("{file} requires '{path}', which Supercov cannot find")
279 });
280 }
281 }
282 }
283 RubyRequire::Feature(name) => {
284 if let Some(next) = feature(&name) {
285 queue.push_back(next);
286 }
287 }
288 RubyRequire::Computed(why) => {
289 loads.open.get_or_insert_with(|| format!("{file} {why}"));
290 }
291 }
292 }
293 }
294
295 fn python_resolve(
297 &self,
298 file: &str,
299 module: &str,
300 level: u32,
301 names: &[String],
302 ) -> Vec<String> {
303 let exists = |path: &Path| normal(path).filter(|path| self.root.join(path).is_file());
304 let parts = module
305 .split('.')
306 .filter(|part| !part.is_empty())
307 .collect::<Vec<_>>();
308 let directory = Path::new(file).parent().unwrap_or(Path::new(""));
309 let bases = if level > 0 {
310 let mut base = directory.to_owned();
311 for _ in 1..level {
312 base = base.parent().unwrap_or(Path::new("")).to_owned();
313 }
314 vec![base]
315 } else {
316 let mut bases = directory
319 .ancestors()
320 .map(Path::to_owned)
321 .collect::<Vec<_>>();
322 bases.push(PathBuf::from("src"));
323 bases
324 };
325 for base in bases {
326 let mut found = Vec::new();
327 let mut package = base.clone();
328 let mut resolved = parts.is_empty();
329 for (index, part) in parts.iter().enumerate() {
330 package = package.join(part);
331 if let Some(init) = exists(&package.join("__init__.py")) {
332 found.push(init);
333 resolved = index + 1 == parts.len();
334 } else if index + 1 == parts.len()
335 && let Some(module) = exists(&package.with_extension("py"))
336 {
337 found.push(module);
338 resolved = true;
339 } else {
340 break;
341 }
342 }
343 if !resolved {
344 continue;
345 }
346 for name in names {
348 let submodule = package.join(name);
349 if let Some(file) = exists(&submodule.with_extension("py"))
350 .or_else(|| exists(&submodule.join("__init__.py")))
351 {
352 found.push(file);
353 }
354 }
355 return found;
356 }
357 Vec::new()
358 }
359
360 fn specifiers(&self, file: &str) -> Option<Vec<Specifier>> {
361 if let Some(known) = self.parsed.borrow().get(file) {
362 return known.clone();
363 }
364 let parsed = fs::read_to_string(self.root.join(file))
365 .ok()
366 .and_then(|source| parse(file, &source));
367 self.parsed
368 .borrow_mut()
369 .insert(file.to_owned(), parsed.clone());
370 parsed
371 }
372
373 fn resolve(&self, from: &str, specifier: &str) -> Resolved {
374 let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
375 if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
376 let base = Path::new(from).parent().unwrap_or(Path::new(""));
377 return self.file(&base.join(specifier));
378 }
379 if specifier.starts_with('/') {
380 return Resolved::Unknown;
381 }
382 if let Some(name) = specifier.strip_prefix("node:") {
383 let _ = name;
384 return Resolved::Outside;
385 }
386 if specifier.starts_with('#') {
387 return self.subpath(specifier);
388 }
389 for (pattern, targets) in &self.aliases {
390 let rest = match pattern.split_once('*') {
391 Some((prefix, suffix)) => specifier
392 .strip_prefix(prefix)
393 .and_then(|rest| rest.strip_suffix(suffix)),
394 None => (specifier == pattern).then_some(""),
395 };
396 let Some(rest) = rest else { continue };
397 for target in targets {
398 let target = target.to_string_lossy().replacen('*', rest, 1);
399 if let Resolved::File(file) = self.file(Path::new(&target)) {
400 return Resolved::File(file);
401 }
402 }
403 }
404 let package = package_name(specifier);
405 if BUILTINS.contains(&package)
407 || self.packages.contains(package)
408 || self
409 .root
410 .ancestors()
411 .any(|directory| directory.join("node_modules").join(package).exists())
412 {
413 return Resolved::Outside;
414 }
415 Resolved::Unknown
416 }
417
418 fn subpath(&self, specifier: &str) -> Resolved {
419 let targets = self.subpaths.get(specifier).cloned().or_else(|| {
420 self.subpaths.iter().find_map(|(pattern, targets)| {
421 let (prefix, suffix) = pattern.split_once('*')?;
422 let rest = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
423 Some(
424 targets
425 .iter()
426 .map(|target| target.replacen('*', rest, 1))
427 .collect(),
428 )
429 })
430 });
431 let Some(targets) = targets else {
432 return Resolved::Unknown;
433 };
434 for target in targets {
435 if !target.starts_with("./") {
436 return Resolved::Outside;
437 }
438 if let Resolved::File(file) = self.file(Path::new(&target)) {
439 return Resolved::File(file);
440 }
441 }
442 Resolved::Unknown
443 }
444
445 fn file(&self, path: &Path) -> Resolved {
449 let Some(path) = normal(path) else {
450 return Resolved::Unknown;
451 };
452 if path.split('/').any(|part| part == "node_modules") {
453 return Resolved::Outside;
454 }
455 let exists = |candidate: &str| self.root.join(candidate).is_file();
456 let mut candidates = vec![path.clone()];
457 for (javascript, typescript) in [
458 (".js", [".ts", ".tsx"]),
459 (".jsx", [".tsx", ".tsx"]),
460 (".mjs", [".mts", ".mts"]),
461 (".cjs", [".cts", ".cts"]),
462 ] {
463 if let Some(stem) = path.strip_suffix(javascript) {
464 candidates.extend(typescript.iter().map(|ts| format!("{stem}{ts}")));
465 }
466 }
467 candidates.extend(
468 EXTENSIONS
469 .iter()
470 .map(|extension| format!("{path}{extension}")),
471 );
472 candidates.extend(
473 EXTENSIONS
474 .iter()
475 .map(|extension| format!("{path}/index{extension}")),
476 );
477 match candidates.into_iter().find(|candidate| exists(candidate)) {
478 Some(found) => Resolved::File(found),
479 None => Resolved::Unknown,
480 }
481 }
482}
483
484enum Resolved {
485 File(String),
486 Outside,
488 Unknown,
489}
490
491const BUILTINS: [&str; 40] = [
492 "assert",
493 "async_hooks",
494 "buffer",
495 "child_process",
496 "cluster",
497 "console",
498 "constants",
499 "crypto",
500 "dgram",
501 "diagnostics_channel",
502 "dns",
503 "domain",
504 "events",
505 "fs",
506 "http",
507 "http2",
508 "https",
509 "inspector",
510 "module",
511 "net",
512 "os",
513 "path",
514 "perf_hooks",
515 "process",
516 "punycode",
517 "querystring",
518 "readline",
519 "repl",
520 "stream",
521 "string_decoder",
522 "timers",
523 "tls",
524 "tty",
525 "url",
526 "util",
527 "v8",
528 "vm",
529 "worker_threads",
530 "zlib",
531 "test",
532];
533
534fn package_name(specifier: &str) -> &str {
535 let mut parts = specifier.splitn(3, '/');
536 let first = parts.next().unwrap_or(specifier);
537 if first.starts_with('@')
538 && let Some(second) = parts.next()
539 {
540 return &specifier[..first.len() + 1 + second.len()];
541 }
542 first
543}
544
545fn normal(path: &Path) -> Option<String> {
547 let mut parts = Vec::new();
548 for component in path.components() {
549 match component {
550 Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
551 Component::CurDir => {}
552 Component::ParentDir => {
553 parts.pop()?;
554 }
555 _ => return None,
556 }
557 }
558 Some(parts.join("/"))
559}
560
561fn read_json(path: &Path) -> Option<Value> {
562 let text = fs::read_to_string(path).ok()?;
563 serde_json::from_str(&crate::source_discovery::strip_trailing_commas(
564 &crate::source_discovery::strip_jsonc_comments(&text),
565 ))
566 .ok()
567}
568
569fn collect_strings(value: &Value, into: &mut Vec<String>) {
570 match value {
571 Value::String(text) => into.push(text.clone()),
572 Value::Object(map) => map.values().for_each(|value| collect_strings(value, into)),
573 Value::Array(items) => items.iter().for_each(|value| collect_strings(value, into)),
574 _ => {}
575 }
576}
577
578fn aliases(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
581 let mut found = Vec::new();
582 let mut config = root.join("tsconfig.json");
583 let mut seen = BTreeSet::new();
584 while seen.insert(config.clone()) && seen.len() < 16 {
585 let Some(json) = read_json(&config) else {
586 break;
587 };
588 let directory = config.parent().unwrap_or(root).to_owned();
589 let options = json.get("compilerOptions");
590 let base = options
591 .and_then(|options| options.get("baseUrl"))
592 .and_then(Value::as_str)
593 .map_or(directory.clone(), |base| directory.join(base));
594 if found.is_empty()
595 && let Some(paths) = options
596 .and_then(|options| options.get("paths"))
597 .and_then(Value::as_object)
598 {
599 for (pattern, targets) in paths {
600 let targets = targets
601 .as_array()
602 .into_iter()
603 .flatten()
604 .filter_map(Value::as_str)
605 .filter_map(|target| {
606 base.join(target)
607 .strip_prefix(root)
608 .ok()
609 .map(Path::to_owned)
610 })
611 .collect();
612 found.push((pattern.clone(), targets));
613 }
614 }
615 match json.get("extends").and_then(Value::as_str) {
616 Some(parent) if parent.starts_with('.') => {
617 config = directory.join(parent);
618 if config.extension().is_none() {
619 config.set_extension("json");
620 }
621 }
622 _ => break,
623 }
624 }
625 found
626}
627
628fn parse(file: &str, source: &str) -> Option<Vec<Specifier>> {
632 use oxc_ast::ast::*;
633 use oxc_ast_visit::{Visit, walk};
634
635 let source_type = crate::js_instrumenter::project_source_type(Path::new(file)).ok()?;
636 let allocator = oxc_allocator::Allocator::default();
637 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
638 if parsed.panicked {
639 return None;
640 }
641 struct Collector {
642 found: Vec<Specifier>,
643 }
644 fn literal(expression: &Expression<'_>) -> Specifier {
645 match expression {
646 Expression::StringLiteral(text) => Specifier::Literal(text.value.to_string()),
647 Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
648 .quasis
649 .first()
650 .and_then(|quasi| quasi.value.cooked.as_ref())
651 .map_or(Specifier::Computed, |text| {
652 Specifier::Literal(text.to_string())
653 }),
654 _ => Specifier::Computed,
655 }
656 }
657 impl<'a> Visit<'a> for Collector {
658 fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
659 if !it.import_kind.is_type() {
660 self.found
661 .push(Specifier::Literal(it.source.value.to_string()));
662 }
663 }
664 fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) {
665 if !it.export_kind.is_type() {
666 self.found
667 .push(Specifier::Literal(it.source.value.to_string()));
668 }
669 }
670 fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) {
671 if let Some(source) = &it.source
672 && !it.export_kind.is_type()
673 {
674 self.found
675 .push(Specifier::Literal(source.value.to_string()));
676 }
677 walk::walk_export_named_declaration(self, it);
678 }
679 fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) {
680 if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference
681 && !it.import_kind.is_type()
682 {
683 self.found
684 .push(Specifier::Literal(reference.expression.value.to_string()));
685 }
686 }
687 fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
688 self.found.push(literal(&it.source));
689 walk::walk_import_expression(self, it);
690 }
691 fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
692 let loads = match &it.callee {
693 Expression::Identifier(name) => name.name == "require",
694 Expression::StaticMemberExpression(member) => {
695 let object = match &member.object {
696 Expression::Identifier(name) => name.name.as_str(),
697 _ => "",
698 };
699 let property = member.property.name.as_str();
700 matches!(
701 (object, property),
702 ("require", "resolve")
703 | ("jest", "mock" | "requireActual" | "doMock")
704 | ("vi", "mock" | "importActual" | "doMock")
705 )
706 }
707 _ => false,
708 };
709 if loads && let Some(first) = it.arguments.first() {
710 self.found.push(match first.as_expression() {
711 Some(expression) => literal(expression),
712 None => Specifier::Computed,
713 });
714 }
715 walk::walk_call_expression(self, it);
716 }
717 }
718 let mut collector = Collector { found: Vec::new() };
719 collector.visit_program(&parsed.program);
720 Some(collector.found)
721}
722
723#[derive(Debug, Clone)]
724enum PythonImport {
725 Module {
726 module: String,
727 level: u32,
729 names: Vec<String>,
730 },
731 Computed(&'static str),
733}
734
735fn python_imports(source: &str) -> Option<Vec<PythonImport>> {
738 use ruff_python_ast::{
739 Expr, Stmt,
740 visitor::{Visitor, walk_expr, walk_stmt},
741 };
742 let parsed = ruff_python_parser::parse_module(source).ok()?;
743 struct Collector(Vec<PythonImport>);
744 fn module(name: &str) -> PythonImport {
745 PythonImport::Module {
746 module: name.to_owned(),
747 level: 0,
748 names: Vec::new(),
749 }
750 }
751 fn strings(expression: &Expr) -> Option<Vec<String>> {
752 match expression {
753 Expr::StringLiteral(text) => Some(vec![text.value.to_str().to_owned()]),
754 Expr::List(list) => list
755 .elts
756 .iter()
757 .map(|item| strings(item).map(|mut s| s.remove(0)))
758 .collect(),
759 Expr::Tuple(tuple) => tuple
760 .elts
761 .iter()
762 .map(|item| strings(item).map(|mut s| s.remove(0)))
763 .collect(),
764 _ => None,
765 }
766 }
767 fn is_sys_path(expression: &Expr) -> bool {
768 matches!(expression, Expr::Attribute(attribute)
769 if attribute.attr.as_str() == "path"
770 && matches!(&*attribute.value, Expr::Name(name) if name.id.as_str() == "sys"))
771 }
772 impl<'a> Visitor<'a> for Collector {
773 fn visit_stmt(&mut self, stmt: &'a Stmt) {
774 match stmt {
775 Stmt::Import(import) => {
776 for alias in &import.names {
777 self.0.push(module(alias.name.as_str()));
778 }
779 }
780 Stmt::ImportFrom(import) => self.0.push(PythonImport::Module {
781 module: import
782 .module
783 .as_ref()
784 .map(|m| m.to_string())
785 .unwrap_or_default(),
786 level: import.level,
787 names: import
788 .names
789 .iter()
790 .map(|alias| alias.name.to_string())
791 .collect(),
792 }),
793 Stmt::Assign(assign) => {
794 for target in &assign.targets {
795 if matches!(target, Expr::Name(name) if name.id.as_str() == "pytest_plugins")
796 {
797 match strings(&assign.value) {
798 Some(plugins) => self.0.extend(plugins.iter().map(|p| module(p))),
799 None => self.0.push(PythonImport::Computed(
800 "names its pytest plugins by computation",
801 )),
802 }
803 }
804 if is_sys_path(target) {
805 self.0.push(PythonImport::Computed("changes sys.path"));
806 }
807 }
808 }
809 _ => {}
810 }
811 walk_stmt(self, stmt);
812 }
813 fn visit_expr(&mut self, expr: &'a Expr) {
814 if let Expr::Call(call) = expr {
815 let first = call.arguments.args.first();
816 match &*call.func {
817 Expr::Attribute(attribute)
818 if is_sys_path(&attribute.value)
819 && matches!(
820 attribute.attr.as_str(),
821 "insert" | "append" | "extend"
822 ) =>
823 {
824 self.0.push(PythonImport::Computed("changes sys.path"));
825 }
826 Expr::Attribute(attribute) if attribute.attr.as_str() == "import_module" => {
827 self.0.push(match first.and_then(strings) {
828 Some(mut names) => module(&names.remove(0)),
829 None => PythonImport::Computed("imports a module named by computation"),
830 });
831 }
832 Expr::Name(name)
833 if matches!(name.id.as_str(), "__import__" | "import_module") =>
834 {
835 self.0.push(match first.and_then(strings) {
836 Some(mut names) => module(&names.remove(0)),
837 None => PythonImport::Computed("imports a module named by computation"),
838 });
839 }
840 _ => {}
841 }
842 }
843 walk_expr(self, expr);
844 }
845 }
846 let mut collector = Collector(Vec::new());
847 collector.visit_body(&parsed.syntax().body);
848 Some(collector.0)
849}
850
851#[derive(Debug, Clone)]
854enum RubyRequire {
855 Relative(String),
857 Feature(String),
859 Computed(&'static str),
860}
861
862fn ruby_requires(source: &str) -> Option<Vec<RubyRequire>> {
864 use ruby_prism::{CallNode, GlobalVariableReadNode, Visit};
865 let parsed = ruby_prism::parse(source.as_bytes());
866 if parsed.errors().next().is_some() {
867 return None;
868 }
869 struct Requires(Vec<RubyRequire>);
870 impl<'pr> Visit<'pr> for Requires {
871 fn visit_call_node(&mut self, node: &CallNode<'pr>) {
872 let name = node.name();
873 let name = name.as_slice();
874 if node.receiver().is_none()
875 && matches!(
876 name,
877 b"require" | b"require_relative" | b"load" | b"autoload"
878 )
879 {
880 let arguments = node
881 .arguments()
882 .map(|arguments| arguments.arguments().iter().collect::<Vec<_>>())
883 .unwrap_or_default();
884 let path = if name == b"autoload" {
886 arguments.get(1)
887 } else {
888 arguments.first()
889 };
890 let literal = path
891 .and_then(|path| path.as_string_node())
892 .and_then(|path| String::from_utf8(path.unescaped().to_vec()).ok());
893 self.0.push(match (literal, name) {
894 (Some(path), b"require_relative") => RubyRequire::Relative(path),
895 (Some(path), _) => RubyRequire::Feature(path),
896 (None, _) => RubyRequire::Computed("requires a computed path"),
897 });
898 }
899 ruby_prism::visit_call_node(self, node);
900 }
901 fn visit_global_variable_read_node(&mut self, node: &GlobalVariableReadNode<'pr>) {
902 if matches!(node.name().as_slice(), b"$LOAD_PATH" | b"$:") {
903 self.0.push(RubyRequire::Computed("changes $LOAD_PATH"));
904 }
905 }
906 }
907 let mut requires = Requires(Vec::new());
908 requires.visit(&parsed.node());
909 Some(requires.0)
910}
911
912pub fn configuration_runner(file: &str) -> Option<&'static str> {
913 let name = file.rsplit('/').next()?.to_ascii_lowercase();
914 if !name.contains(".config.") && !name.contains(".workspace.") {
915 return None;
916 }
917 let stem = name.split('.').next()?;
918 match stem {
919 "playwright" | "playwright-ct" => Some("playwright"),
920 "vitest" => Some("vitest"),
921 "jest" => Some("jest"),
922 "ava" => Some("ava"),
923 _ => None,
924 }
925}
926
927fn strings(file: &str, source: &str) -> Vec<String> {
928 use oxc_ast::ast::*;
929 use oxc_ast_visit::Visit;
930 let Ok(source_type) = crate::js_instrumenter::project_source_type(Path::new(file)) else {
931 return Vec::new();
932 };
933 let allocator = oxc_allocator::Allocator::default();
934 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
935 struct Collector(Vec<String>);
936 impl<'a> Visit<'a> for Collector {
937 fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
938 self.0.push(it.value.to_string());
939 }
940 }
941 let mut collector = Collector(Vec::new());
942 collector.visit_program(&parsed.program);
943 collector.0
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 fn project(files: &[(&str, &str)]) -> PathBuf {
951 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
952 let root = std::env::temp_dir().join(format!(
953 "supercov-test-loads-{}-{}-{}",
954 std::process::id(),
955 std::time::SystemTime::now()
956 .duration_since(std::time::UNIX_EPOCH)
957 .unwrap()
958 .as_nanos(),
959 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
960 ));
961 fs::create_dir(&root).unwrap();
962 for (file, text) in files {
963 let path = root.join(file);
964 fs::create_dir_all(path.parent().unwrap()).unwrap();
965 fs::write(path, text).unwrap();
966 }
967 root
968 }
969
970 #[test]
971 fn a_test_loads_its_helpers_and_not_another_suites() {
972 let root = project(&[
973 (
974 "package.json",
975 r#"{"devDependencies":{"@playwright/test":"1"}}"#,
976 ),
977 (
978 "tsconfig.json",
979 r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
980 ),
981 (
982 "tests/e2e/helpers.ts",
983 "import { x } from '@/lib/x';\nexport const get = () => x;\n",
984 ),
985 (
986 "tests/e2e/app.spec.ts",
987 "import { test } from '@playwright/test';\nimport { get } from './helpers';\nimport type { T } from './types';\n",
988 ),
989 ("tests/e2e/types.ts", "export type T = 1;\n"),
990 (
991 "tests/unit/a.test.ts",
992 "import { y } from '../support/y.js';\n",
993 ),
994 ("tests/support/y.ts", "export const y = 1;\n"),
995 ("src/lib/x.ts", "export const x = 1;\n"),
996 ]);
997 let imports = Imports::new(&root);
998 let e2e = imports.loads(["tests/e2e/app.spec.ts"]);
999 assert_eq!(e2e.open, None);
1000 assert!(e2e.files.contains("tests/e2e/helpers.ts"));
1001 assert!(e2e.files.contains("src/lib/x.ts"), "{e2e:?}");
1002 assert!(
1003 !e2e.files.contains("tests/e2e/types.ts"),
1004 "a type import loads nothing"
1005 );
1006 assert!(!e2e.files.contains("tests/support/y.ts"));
1007 let unit = imports.loads(["tests/unit/a.test.ts"]);
1008 assert!(unit.files.contains("tests/support/y.ts"), "{unit:?}");
1009 assert!(!unit.files.contains("tests/e2e/helpers.ts"));
1010 fs::remove_dir_all(root).unwrap();
1011 }
1012
1013 #[test]
1014 fn what_cannot_be_followed_leaves_the_answer_open() {
1015 let root = project(&[
1016 ("package.json", "{}"),
1017 ("a.test.js", "const name = 'b';\nrequire(`./${name}`);\n"),
1018 ("c.test.js", "import 'unknown-alias/thing';\n"),
1019 (
1020 "d.test.js",
1021 "import fs from 'node:fs';\nimport path from 'path';\nimport './e.js';\n",
1022 ),
1023 ("e.js", "export {};\n"),
1024 ]);
1025 let imports = Imports::new(&root);
1026 assert!(imports.loads(["a.test.js"]).open.is_some());
1027 assert!(imports.loads(["c.test.js"]).open.is_some());
1028 let closed = imports.loads(["d.test.js"]);
1029 assert_eq!(closed.open, None);
1030 assert!(closed.files.contains("e.js"));
1031 fs::remove_dir_all(root).unwrap();
1032 }
1033
1034 #[test]
1035 fn a_python_test_loads_its_conftest_and_what_it_imports() {
1036 let root = project(&[
1037 (
1038 "tests/conftest.py",
1039 "import pytest\nfrom tests.support import factory\n",
1040 ),
1041 ("tests/__init__.py", ""),
1042 ("tests/support/__init__.py", ""),
1043 ("tests/support/factory.py", "import json\n"),
1044 (
1045 "tests/unit/test_a.py",
1046 "from .helpers import make\nfrom shop import cart\n",
1047 ),
1048 ("tests/unit/__init__.py", ""),
1049 ("tests/unit/helpers.py", "def make():\n return 1\n"),
1050 (
1051 "tests/e2e/test_b.py",
1052 "import requests\nfrom tests.e2e import pages\n",
1053 ),
1054 ("tests/e2e/__init__.py", ""),
1055 ("tests/e2e/pages.py", ""),
1056 ("src/shop/__init__.py", ""),
1057 ("src/shop/cart.py", ""),
1058 (
1059 "tests/odd/test_c.py",
1060 "import sys\nsys.path.insert(0, 'x')\n",
1061 ),
1062 ]);
1063 let imports = Imports::new(&root);
1064 let unit = imports.loads(["tests/unit/test_a.py"]);
1065 assert_eq!(unit.open, None, "{unit:?}");
1066 for file in [
1067 "tests/conftest.py",
1068 "tests/support/factory.py",
1069 "tests/unit/helpers.py",
1070 "src/shop/cart.py",
1071 ] {
1072 assert!(unit.files.contains(file), "{file}: {unit:?}");
1073 }
1074 assert!(!unit.files.contains("tests/e2e/pages.py"));
1075 let e2e = imports.loads(["tests/e2e/test_b.py"]);
1076 assert!(
1077 e2e.files.contains("tests/e2e/pages.py")
1078 && !e2e.files.contains("tests/unit/helpers.py"),
1079 "{e2e:?}"
1080 );
1081 assert!(imports.loads(["tests/odd/test_c.py"]).open.is_some());
1082 fs::remove_dir_all(root).unwrap();
1083 }
1084
1085 #[test]
1086 fn a_ruby_spec_loads_what_it_requires_and_what_rspec_requires() {
1087 let root = project(&[
1088 (".rspec", "--require spec_helper\n--format documentation\n"),
1089 (
1090 "spec/spec_helper.rb",
1091 "require \"json\"\nrequire_relative \"support/factories\"\n",
1092 ),
1093 ("spec/support/factories.rb", "module Factories; end\n"),
1094 ("spec/support/unused.rb", "module Unused; end\n"),
1095 (
1096 "spec/orders_spec.rb",
1097 "require \"orders\"\nRSpec.describe(Orders) { }\n",
1098 ),
1099 (
1100 "lib/orders.rb",
1101 "autoload :Refunds, \"orders/refunds\"\nmodule Orders; end\n",
1102 ),
1103 ("lib/orders/refunds.rb", "module Refunds; end\n"),
1104 (
1105 "spec/glob_spec.rb",
1106 "Dir[File.join(__dir__, \"support/*.rb\")].each { |f| require f }\n",
1107 ),
1108 ("spec/path_spec.rb", "$LOAD_PATH.unshift(\"vendor\")\n"),
1109 ]);
1110 let imports = Imports::new(&root);
1111 let loads = imports.loads(["spec/orders_spec.rb"]);
1112 assert_eq!(loads.open, None);
1113 assert_eq!(
1114 loads.files.into_iter().collect::<Vec<_>>(),
1115 [
1116 "lib/orders.rb",
1117 "lib/orders/refunds.rb",
1118 "spec/orders_spec.rb",
1119 "spec/spec_helper.rb",
1120 "spec/support/factories.rb",
1121 ]
1122 );
1123 assert!(imports.loads(["spec/glob_spec.rb"]).open.is_some());
1124 assert!(imports.loads(["spec/path_spec.rb"]).open.is_some());
1125 fs::remove_dir_all(root).unwrap();
1126 }
1127
1128 #[test]
1129 fn a_configuration_belongs_to_its_runner() {
1130 assert_eq!(
1131 configuration_runner("tests/e2e/playwright.config.ts"),
1132 Some("playwright")
1133 );
1134 assert_eq!(configuration_runner("vitest.config.mts"), Some("vitest"));
1135 assert_eq!(configuration_runner("jest.config.js"), Some("jest"));
1136 assert_eq!(
1137 configuration_runner("vite.config.ts"),
1138 None,
1139 "a build shared with the app"
1140 );
1141 assert_eq!(configuration_runner("tsconfig.json"), None);
1142 assert_eq!(configuration_runner("next.config.ts"), None);
1143 }
1144
1145 #[test]
1146 fn a_configuration_names_its_setup_files() {
1147 let root = project(&[
1148 (
1149 "vitest.config.ts",
1150 "export default { test: { setupFiles: ['tests/unit/setup.ts'], include: ['tests/**/*.test.ts'] } };\n",
1151 ),
1152 ("tests/unit/setup.ts", "export {};\n"),
1153 (
1154 "e2e/playwright.config.ts",
1155 "export default { globalSetup: './global-setup', testMatch: '*.spec.ts' };\n",
1156 ),
1157 ("e2e/global-setup.ts", "export default () => {};\n"),
1158 ]);
1159 let imports = Imports::new(&root);
1160 assert_eq!(
1161 imports.named_by("vitest.config.ts"),
1162 ["tests/unit/setup.ts"]
1163 );
1164 assert_eq!(
1165 imports.named_by("e2e/playwright.config.ts"),
1166 ["e2e/global-setup.ts"]
1167 );
1168 fs::remove_dir_all(root).unwrap();
1169 }
1170}