Skip to main content

supercov_engine/
test_loads.rs

1//! Which first-party files a JavaScript or TypeScript test file loads, read
2//! from its imports, and which test runner a configuration file belongs to.
3//!
4//! A test's record says which source it ran, never which helpers it loaded:
5//! helpers sit beside the tests, outside what is measured. Without this a
6//! helper of the end-to-end suite changing made every unit run out of date,
7//! and a helper a test did call changing left `tests affected` saying no test
8//! was affected.
9//!
10//! What cannot be read is said: an import whose path is computed, or one that
11//! resolves to nothing Supercov can name, leaves the answer open, and an open
12//! answer is "it may load anything".
13use std::{
14    cell::RefCell,
15    collections::{BTreeMap, BTreeSet, VecDeque},
16    fs,
17    path::{Component, Path, PathBuf},
18};
19
20use serde_json::Value;
21
22/// The files a set of entry files loads, themselves included, or why that
23/// cannot be known.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Loads {
26    pub files: BTreeSet<String>,
27    /// The first import that could not be followed, where one could not.
28    pub open: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32enum Specifier {
33    Literal(String),
34    /// `import(name)`, `require(path.join(...))`: anything at all.
35    Computed,
36}
37
38const EXTENSIONS: [&str; 9] = [
39    ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json",
40];
41
42/// A file Supercov can read imports from.
43pub 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
51/// Imports of the files of one checkout, read once each.
52pub struct Imports {
53    root: PathBuf,
54    packages: BTreeSet<String>,
55    /// tsconfig `paths`: a pattern with at most one `*`, and where it maps.
56    aliases: Vec<(String, Vec<PathBuf>)>,
57    /// package.json `imports`, the `#name` specifiers.
58    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    /// Everything `entries` load, followed through first-party files.
103    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    /// Files a runner's configuration names by path, such as a setup file or
150    /// a global setup, where one exists: they load before every test. A
151    /// pattern names test files, which each run's record already holds.
152    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    /// What a Python file loads: the modules it imports, the packages they
175    /// sit in (whose `__init__.py` runs first), pytest's `conftest.py` in its
176    /// directory and every directory above, and the plugins they name. A
177    /// module found nowhere in the project is the standard library's or an
178    /// installed package's; a computed import, or a change to `sys.path`,
179    /// leaves the answer open.
180    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    /// What a Ruby file loads: what it requires by a literal path, relative
221    /// to it or on the load path a runner sets up (`lib`, `spec`, `test`),
222    /// and, for a spec, what `.rspec` requires before every one. A feature
223    /// found nowhere in the project is a gem's or the standard library's; a
224    /// computed path, a glob of support files, or a change to `$LOAD_PATH`
225    /// leaves the answer open.
226    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    /// The project files an import runs, or none for a module outside it.
296    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            // Where a test's imports are found: beside it and above (pytest
317            // puts a test's root directory on the path), and a src layout.
318            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            // `from package import name` may name a submodule.
347            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        // Installed where Node looks: beside the project or above it.
406        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    /// A first-party file at `path` (relative to the root), the way Node,
446    /// TypeScript and bundlers find it: as named, with an extension, as a
447    /// directory's index, and a `.js` that names its TypeScript source.
448    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    /// A package or a built-in: not the project's own code.
487    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
545/// `a/./b/../c` as `a/c`, refusing a path that climbs out of the root.
546fn 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
578/// The root tsconfig's `paths`, through its `extends` chain, each target
579/// relative to the root.
580fn 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
628/// What a file imports: static imports and re-exports, `import()`, `require`
629/// and the module mocks of Jest and Vitest, which name a module by its path.
630/// Imports of types only are erased before anything runs.
631fn 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        /// Leading dots of a relative import.
728        level: u32,
729        names: Vec<String>,
730    },
731    /// Why what it loads cannot be read.
732    Computed(&'static str),
733}
734
735/// Every import in a Python file, at any depth, with `importlib` calls that
736/// name a module literally and pytest's `pytest_plugins`.
737fn 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/// The test runner a configuration file belongs to, by its name. A change to
852/// one runner's configuration changes nothing another runner does.
853#[derive(Debug, Clone)]
854enum RubyRequire {
855    /// `require_relative "x"`.
856    Relative(String),
857    /// `require "x"`, `load "x.rb"`, `autoload :X, "x"`: on the load path.
858    Feature(String),
859    Computed(&'static str),
860}
861
862/// What a Ruby file requires, or None when it does not parse.
863fn 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                // `autoload :Name, "path"` names its path second.
885                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}