Skip to main content

supercov_engine/
assertion_coverage.rs

1//! Assertions coverage: which executed statements a test would catch breaking.
2//!
3//! A statement counts as asserted when changing it makes at least one passing
4//! test that executes it fail. The change follows the statement: an `if` has its
5//! condition inverted (each direction), `return x` returns undefined, a
6//! declaration's value becomes undefined, an expression point evaluates to
7//! undefined, and any other statement is skipped. Imports, declarations without
8//! a value, and function, class and type declarations are not assessed.
9//!
10//! Supercov does not decide this itself. For each test it renders what the test
11//! ran (its code, its helpers, and the source it executed) and asks Jev, one
12//! yes/no question per statement, whether the test would fail. This module owns
13//! the population, the change rules and the rendering; the CLI owns the requests,
14//! the answer cache and the output. What Jev sees for one statement and one test
15//! is also the cache key, so an answer is reused only when the question would be
16//! asked with exactly the same text.
17use crate::coverage_analysis::PointKind;
18use crate::coverage_report::CoverageReport;
19use oxc_allocator::Allocator;
20use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement as JsStatement};
21use oxc_ast_visit::{Visit, walk};
22use oxc_parser::Parser;
23use oxc_span::{GetSpan, SourceType};
24use serde::Serialize;
25use serde_json::{Value, json};
26use sha2::{Digest, Sha256};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30mod go;
31mod jvm;
32mod ruby;
33mod rust_source;
34
35/// Statements starting on each line: their text and change (`None`: not
36/// assessed).
37type Starts = BTreeMap<usize, Vec<(String, Option<Change>)>>;
38
39pub const MODEL: &str = "jev-1.13.0";
40/// Tests asked about a statement before it may be called unchecked, then the
41/// wider set asked before it is (measured: more tests remove false "unchecked"
42/// flags, and 25 tests was no better than 15).
43pub const FIRST_TESTS: usize = 5;
44pub const MORE_TESTS: usize = 15;
45pub const MAX_QUESTIONS: usize = 150;
46/// A statement is asserted when some test's answer is at least this.
47pub const THRESHOLD: f64 = 0.5;
48const HEAD_LINES: usize = 10;
49const HELPER_CHARS: usize = 3000;
50/// The budget for data files a test names (Ruby spec support fixtures),
51/// beside the helpers': the cases a data-driven test checks are its asserts.
52const FIXTURE_CHARS: usize = 12000;
53const CONTEXT_LINES: usize = 0;
54const WINDOW_LINES: usize = 4;
55const CODE_CHARS: usize = 10000;
56const BODY_LINES: usize = 150;
57
58pub const INSTRUCTIONS: &str = "Each question describes exactly one change to the program, made alone. The test passes on the code as written and executes the changed line. Question: with that change, does this test fail (an assertion fails, test or helper code throws, the test times out, or the process crashes)? Follow what the changed line computes to what the test checks; do not guess from names. In `code_run`, lines marked ▶ start statements this test ran.";
59
60fn runtime(runner: &str) -> &'static str {
61    match runner {
62        "node:test" => {
63            "node:test; a test past its timeout fails; after hooks still run. The tests spawn the program as child processes; an unhandled promise rejection ends a Node process."
64        }
65        "jest" => {
66            "Jest; a test past its timeout (default 5000 ms) fails; an unhandled error during the run fails it; jest.fn() returns undefined unless given an implementation."
67        }
68        "ava" => {
69            "AVA; a failed t assertion, an uncaught error or a rejected promise the test returns fails it; a test past its timeout (default 10 s) fails; a test with no assertion fails unless failWithoutAssertions is off; a file's tests run concurrently unless declared with test.serial."
70        }
71        "tap" => {
72            "node-tap; a failed t assertion, an uncaught error or a rejected promise a subtest returns fails it; a subtest past its timeout (default 30 s) fails; a parent fails when a subtest does; each test file runs as a process of its own."
73        }
74        "mocha" => {
75            "Mocha; a test past its timeout (default 2000 ms) fails; an uncaught error, a rejected promise it returns or an error passed to done fails it; beforeEach hooks run before each test."
76        }
77        "pytest" => {
78            "pytest; a failing assert or an uncaught exception fails the test; there is no timeout unless pytest-timeout is configured; fixtures from conftest.py run before the test; a Mock or MagicMock returns another mock unless given a return value."
79        }
80        "rspec" => {
81            "RSpec; a failed expectation or an uncaught exception fails the example; there is no timeout; before hooks and let blocks run for each example; a double raises on messages it was not told to expect, and allow(...).to receive returns nil unless given a value."
82        }
83        "minitest" => {
84            "Minitest; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test; a stubbed method returns the value it was given."
85        }
86        "test-unit" => {
87            "test-unit; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test."
88        }
89        "cucumber" => {
90            "Cucumber; a step that raises (a failed expectation included) fails the scenario; a step with no definition leaves it undefined, not passed; Before hooks run for each scenario."
91        }
92        "go-test" => {
93            "go test; t.Error, t.Fatal and a panic fail the test; a test past the -timeout (default 10 minutes) fails; subtests run inside their parent; a nil pointer dereference panics. A test catches a changed value only where one of its checks compares it, directly or through what it feeds: a byte, field or result that no check reads can change while the test still passes."
94        }
95        "rust-libtest" | "rust-nextest" | "nextest" => {
96            "Rust tests; a panic fails the test (a failed assert!, assert_eq!, unwrap or expect included) unless it is marked #[should_panic], which then fails when nothing panics; there is no timeout."
97        }
98        "rustdoc" => {
99            "Rust doctests; the code block is compiled and run as its own program, and a panic fails it; a block marked no_run is only compiled and should_panic fails when nothing panics."
100        }
101        "junit-platform" => {
102            "JUnit; a failed assertion or an uncaught exception fails the test (an expected one inside assertThrows aside); @BeforeEach runs before each test; there is no timeout unless one is set; a Mockito mock returns null, 0, false or an empty collection unless stubbed."
103        }
104        "testng" => {
105            "TestNG; a failed assertion or an uncaught exception fails the test; @BeforeMethod runs before each test; there is no timeout unless one is set; a Mockito mock returns null, 0, false or an empty collection unless stubbed."
106        }
107        "unittest" => {
108            "unittest; a failing assert* method or an uncaught exception fails the test; there is no timeout; setUp runs before each test; a Mock or MagicMock returns another mock unless given a return value."
109        }
110        _ => {
111            "Vitest; a test past its timeout (default 5000 ms) fails; an unhandled error during the run fails it; vi.fn() returns undefined unless given an implementation."
112        }
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub enum Change {
119    /// `if`: the condition inverted, each direction asked separately.
120    Invert,
121    /// `return x`: returns undefined without evaluating `x`.
122    ReturnUndefined,
123    /// A declaration with a value: the value becomes undefined.
124    ValueUndefined,
125    /// An expression point that is not a statement (a JSX expression).
126    ExpressionUndefined,
127    /// Anything else: the statement is skipped.
128    Skip,
129}
130
131/// The languages whose statements and tests this module can read.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Language {
134    JavaScript,
135    Python,
136    Ruby,
137    Go,
138    Rust,
139    /// Java and Kotlin, told apart by file extension.
140    Jvm,
141}
142
143impl Language {
144    /// The run manifest's language name, when it is one of these.
145    pub fn from_manifest(name: &str) -> Option<Self> {
146        match name {
147            "javascript" => Some(Self::JavaScript),
148            "python" => Some(Self::Python),
149            "ruby" => Some(Self::Ruby),
150            "go" => Some(Self::Go),
151            "rust" => Some(Self::Rust),
152            "jvm" => Some(Self::Jvm),
153            _ => None,
154        }
155    }
156    fn comment(self, line: &str) -> bool {
157        let t = line.trim_start();
158        match self {
159            Self::Python | Self::Ruby => t.starts_with('#'),
160            Self::JavaScript | Self::Go | Self::Rust | Self::Jvm => {
161                t.starts_with("//")
162                    || t.starts_with("/*")
163                    || t.starts_with("*/")
164                    || t == "*"
165                    || t.starts_with("* ")
166                    || t.starts_with("*\t")
167            }
168        }
169    }
170    /// The value a removed value becomes, as the questions name it.
171    fn nothing(self) -> &'static str {
172        match self {
173            Self::JavaScript => "undefined",
174            Self::Python => "None",
175            Self::Ruby => "nil",
176            Self::Go => "the zero value",
177            Self::Rust => "`Default::default()`",
178            Self::Jvm => "the default value (null, or 0 or false for a primitive)",
179        }
180    }
181}
182
183/// What separates a test's name from the groups it sits in.
184fn separator(language: Language, runner: &str) -> &'static str {
185    match (language, runner) {
186        (Language::JavaScript, _) => " > ",
187        (Language::Python, _) => "::",
188        (_, "rspec") => ":",
189        (_, "minitest" | "test-unit" | "junit-platform" | "testng") => "#",
190        (_, "go-test") => "/",
191        (_, "rustdoc") => " - ",
192        _ => "::",
193    }
194}
195
196#[derive(Debug, Clone)]
197pub struct Statement {
198    pub file: String,
199    pub line: usize,
200    pub text: String,
201    pub change: Change,
202    /// The value that replaces the statement's where the language chooses one
203    /// by type (Rust), rather than a stand-in for nothing.
204    pub replacement: Option<String>,
205    /// Indexes into `Population::tests`: the passing tests that executed it.
206    pub tests: Vec<usize>,
207}
208
209#[derive(Debug, Clone)]
210pub struct TestRef {
211    pub id: String,
212    pub file: String,
213    pub name: String,
214    pub runner: String,
215}
216
217pub struct Population {
218    pub root: PathBuf,
219    pub language: Language,
220    pub statements: Vec<Statement>,
221    pub tests: Vec<TestRef>,
222    /// Per test: the statement lines it ran, by file.
223    ran: Vec<BTreeMap<String, BTreeSet<usize>>>,
224    /// Files holding assessed statements; a test's own helpers are the files
225    /// it imports that are not among these.
226    source_files: BTreeSet<String>,
227    /// The project's Java and Kotlin files, for JVM tests and their imports.
228    jvm_files: Vec<String>,
229    aliases: Vec<(String, String)>,
230    /// Files read once: every request and every cache key reads the same text.
231    files: std::sync::Mutex<BTreeMap<String, Option<std::sync::Arc<str>>>>,
232}
233
234/// The executed statements of a run and the tests that ran them. `sources` are
235/// the run's source files, verified against the run.
236pub fn population(
237    root: &Path,
238    report: &CoverageReport,
239    sources: &BTreeMap<String, String>,
240    language: Language,
241) -> Result<Population, String> {
242    let view = &report.filters.passed;
243    let mut tests = Vec::new();
244    let mut index = BTreeMap::new();
245    // JVM tests are named by class; their file is found by its path.
246    let jvm_files = if language == Language::Jvm {
247        project_files(root, &["java", "kt"])
248    } else {
249        Vec::new()
250    };
251    for test in &view.tests {
252        let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
253            Some(file) => file.clone(),
254            None if language == Language::Jvm => {
255                let found = jvm::class_paths(&test.name)
256                    .iter()
257                    .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
258                match found {
259                    Some(file) => file.clone(),
260                    None => continue,
261                }
262            }
263            None => continue,
264        };
265        if test.role != "test" {
266            continue;
267        }
268        index.insert(test.id.as_str(), tests.len());
269        tests.push(TestRef {
270            id: test.id.clone(),
271            file,
272            name: test.name.clone(),
273            runner: test.provenance.runner.clone(),
274        });
275    }
276    let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
277    let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
278    let signatures = if language == Language::Rust {
279        rust_source::signatures(
280            sources
281                .iter()
282                .filter(|(file, _)| file.ends_with(".rs"))
283                .map(|(file, source)| (file.as_str(), source.as_str())),
284        )
285    } else {
286        rust_source::Signatures::default()
287    };
288    let mut statements = Vec::new();
289    let mut seen = BTreeSet::new();
290    // Python and Ruby record an `elif`/`elsif`, and Go, Rust and the JVM
291    // languages may record an `else if`, as a decision only: its condition is
292    // taken after every statement, where no statement starts on its line.
293    let executed = view
294        .points
295        .iter()
296        .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
297        .map(|p| (&p.meta.file, p.meta.line, &p.meta.source, &p.tests, false));
298    let elifs = view
299        .decisions
300        .iter()
301        .filter(|d| d.executed && language != Language::JavaScript)
302        .map(|d| (&d.meta.file, d.meta.line, &d.meta.source, &d.tests, true));
303    for (file, line, text, point_tests, decision) in executed.chain(elifs) {
304        let key = (file.clone(), line);
305        if !seen.insert(key) {
306            continue; // the first statement on a line, as the audit reads it
307        }
308        let ids = point_tests
309            .iter()
310            .filter_map(|id| index.get(id.as_str()).copied())
311            .collect::<Vec<_>>();
312        if ids.is_empty() {
313            continue;
314        }
315        if language == Language::Rust && rust_source::test_path(file) {
316            continue;
317        }
318        let Some(source) = sources.get(file) else {
319            continue;
320        };
321        let lines = changes
322            .entry(file.clone())
323            .or_insert_with(|| match language {
324                Language::JavaScript => statement_starts(file, source),
325                Language::Python => python_statement_starts(source),
326                Language::Ruby => ruby::statement_starts(source),
327                Language::Go => go::statement_starts(source),
328                Language::Rust => rust_source::statement_starts(source),
329                Language::Jvm => jvm::statement_starts(file, source),
330            });
331        let Some(change) = change_at(lines, line, text, language) else {
332            continue;
333        };
334        if decision && change != Change::Invert {
335            continue;
336        }
337        let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
338        let replacement = match language {
339            Language::Rust if valued => {
340                let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
341                let first = first.trim_end_matches(';');
342                replacements
343                    .entry(file.clone())
344                    .or_insert_with(|| rust_source::replacements(file, source, &signatures))
345                    .get(&line)
346                    .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
347                    .map(|(_, r)| r.clone())
348            }
349            // Go: the line the change turns it into, zero values written out
350            // from the result types. (Writing the JVM's `null` out the same
351            // way measured no better on commons-cli and moved the share away
352            // from the truth, so the JVM keeps its description.)
353            Language::Go if valued => go::rewrite(source, line, change),
354            _ => None,
355        };
356        statements.push(Statement {
357            file: file.clone(),
358            line,
359            text: text.clone(),
360            change,
361            replacement,
362            tests: ids,
363        });
364    }
365    let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
366    for s in &statements {
367        for &t in &s.tests {
368            ran[t].entry(s.file.clone()).or_default().insert(s.line);
369        }
370    }
371    let source_files = statements.iter().map(|s| s.file.clone()).collect();
372    Ok(Population {
373        root: root.to_path_buf(),
374        language,
375        statements,
376        tests,
377        ran,
378        source_files,
379        jvm_files,
380        aliases: tsconfig_aliases(root),
381        files: Default::default(),
382    })
383}
384
385/// Every statement starting on each line, outermost first, with its text and
386/// the change the audit applies to it (`None`: not assessed).
387fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
388    let allocator = Allocator::default();
389    let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
390    let parsed = Parser::new(&allocator, source, source_type).parse();
391    let starts = line_starts(source);
392    struct Collector<'s> {
393        source: &'s str,
394        starts: &'s [usize],
395        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
396    }
397    impl<'a> Visit<'a> for Collector<'_> {
398        fn visit_statement(&mut self, statement: &JsStatement<'a>) {
399            let span = statement.span();
400            let line = line_of(self.starts, span.start as usize);
401            let text = self
402                .source
403                .get(span.start as usize..span.end as usize)
404                .unwrap_or("")
405                .to_owned();
406            self.out
407                .entry(line)
408                .or_default()
409                .push((text, change_of(statement)));
410            walk::walk_statement(self, statement);
411        }
412    }
413    let mut collector = Collector {
414        source,
415        starts: &starts,
416        out: BTreeMap::new(),
417    };
418    collector.visit_program(&parsed.program);
419    collector.out
420}
421
422fn change_of(statement: &JsStatement) -> Option<Change> {
423    Some(match statement {
424        JsStatement::IfStatement(_) => Change::Invert,
425        JsStatement::ReturnStatement(r) if r.argument.is_some() => Change::ReturnUndefined,
426        JsStatement::VariableDeclaration(d) => {
427            if d.declarations.iter().any(|d| d.init.is_some()) {
428                Change::ValueUndefined
429            } else {
430                return None;
431            }
432        }
433        JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
434            Some(Declaration::VariableDeclaration(d))
435                if d.declarations.iter().any(|d| d.init.is_some()) =>
436            {
437                Change::ValueUndefined
438            }
439            _ => return None,
440        },
441        JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
442            ExportDefaultDeclarationKind::FunctionDeclaration(_)
443            | ExportDefaultDeclarationKind::ClassDeclaration(_)
444            | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
445            _ => Change::Skip,
446        },
447        JsStatement::ImportDeclaration(_)
448        | JsStatement::ExportAllDeclaration(_)
449        | JsStatement::FunctionDeclaration(_)
450        | JsStatement::ClassDeclaration(_)
451        | JsStatement::TSTypeAliasDeclaration(_)
452        | JsStatement::TSInterfaceDeclaration(_)
453        | JsStatement::TSEnumDeclaration(_)
454        | JsStatement::TSModuleDeclaration(_)
455        | JsStatement::TSGlobalDeclaration(_)
456        | JsStatement::TSImportEqualsDeclaration(_)
457        | JsStatement::TSExportAssignment(_)
458        | JsStatement::TSNamespaceExportDeclaration(_) => return None,
459        _ => Change::Skip,
460    })
461}
462
463/// The change for the measured statement on `line` whose text is `text`: the
464/// first statement starting there with that text, or an expression point when
465/// no statement starts there (a JSX expression is measured but not a statement).
466fn change_at(
467    starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
468    line: usize,
469    text: &str,
470    language: Language,
471) -> Option<Change> {
472    let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
473    let found = starts.get(&line).and_then(|all| {
474        all.iter().find(|(t, _)| {
475            let bare = t
476                .strip_prefix("export ")
477                .map(|t| t.strip_prefix("default ").unwrap_or(t))
478                .unwrap_or(t);
479            bare.starts_with(first) || t.starts_with(first)
480        })
481    });
482    match (found, language) {
483        (Some((_, change)), _) => *change,
484        (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
485        // Elsewhere every statement is its own point; one no statement
486        // matches is not assessed rather than guessed at.
487        (None, _) => None,
488    }
489}
490
491/// Every Python statement starting on each line, outermost first, with its
492/// text and change: `if` inverted, `return x` returning None, an assignment's
493/// value becoming None, anything else skipped; imports, definitions, `pass`,
494/// `global`/`nonlocal` and bare strings (docstrings) are not assessed.
495fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
496    use ruff_python_ast::{
497        Expr, Stmt,
498        visitor::{Visitor, walk_stmt},
499    };
500    use ruff_text_size::Ranged;
501    struct Collector<'s> {
502        source: &'s str,
503        starts: Vec<usize>,
504        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
505    }
506    impl<'a> Visitor<'a> for Collector<'_> {
507        fn visit_stmt(&mut self, stmt: &'a Stmt) {
508            let change = match stmt {
509                Stmt::If(_) => Some(Change::Invert),
510                Stmt::Return(r) => Some(if r.value.is_some() {
511                    Change::ReturnUndefined
512                } else {
513                    Change::Skip
514                }),
515                Stmt::Assign(_) => Some(Change::ValueUndefined),
516                Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
517                Stmt::Import(_)
518                | Stmt::ImportFrom(_)
519                | Stmt::FunctionDef(_)
520                | Stmt::ClassDef(_)
521                | Stmt::Pass(_)
522                | Stmt::Global(_)
523                | Stmt::Nonlocal(_)
524                | Stmt::TypeAlias(_)
525                | Stmt::IpyEscapeCommand(_) => None,
526                Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
527                _ => Some(Change::Skip),
528            };
529            let range = stmt.range();
530            let (start, end) = (range.start().to_usize(), range.end().to_usize());
531            let line = line_of(&self.starts, start);
532            let text = self.source.get(start..end).unwrap_or("").to_owned();
533            self.out.entry(line).or_default().push((text, change));
534            // An `elif` is an `if` of its own, as JavaScript's `else if` is.
535            if let Stmt::If(s) = stmt {
536                // Its point is its condition, as coverage records it.
537                for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
538                    let start = test.range().start().to_usize();
539                    let text = self.source.get(start..test.range().end().to_usize());
540                    self.out
541                        .entry(line_of(&self.starts, start))
542                        .or_default()
543                        .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
544                }
545            }
546            walk_stmt(self, stmt);
547        }
548    }
549    let Ok(parsed) = ruff_python_parser::parse_module(source) else {
550        return BTreeMap::new();
551    };
552    let mut collector = Collector {
553        source,
554        starts: line_starts(source),
555        out: BTreeMap::new(),
556    };
557    for stmt in &parsed.syntax().body {
558        collector.visit_stmt(stmt);
559    }
560    collector.out
561}
562
563fn prefix(text: &str, chars: usize) -> &str {
564    match text.char_indices().nth(chars) {
565        Some((i, _)) => &text[..i],
566        None => text,
567    }
568}
569
570fn line_starts(source: &str) -> Vec<usize> {
571    std::iter::once(0)
572        .chain(source.match_indices('\n').map(|(i, _)| i + 1))
573        .collect()
574}
575
576fn line_of(starts: &[usize], offset: usize) -> usize {
577    starts.partition_point(|&s| s <= offset)
578}
579
580fn numbered(lines: &[&str]) -> String {
581    lines
582        .iter()
583        .enumerate()
584        .map(|(i, l)| format!("{:5} {l}", i + 1))
585        .collect::<Vec<_>>()
586        .join("\n")
587}
588
589fn stem(path: &str) -> String {
590    Path::new(path)
591        .file_name()
592        .and_then(|n| n.to_str())
593        .unwrap_or("")
594        .split('.')
595        .next()
596        .unwrap_or("")
597        .to_lowercase()
598}
599
600fn describe(name: &str, separator: &str) -> String {
601    let parts = name.split(separator).collect::<Vec<_>>();
602    parts[..parts.len().saturating_sub(1)].join(separator)
603}
604
605impl Population {
606    /// Read these files from here rather than the checkout: a run's sources
607    /// as they were assessed, once the checkout has moved on.
608    pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
609        {
610            let mut cache = self
611                .files
612                .lock()
613                .expect("the file cache is only read and filled");
614            for (file, text) in files {
615                cache.insert(file, Some(text.into()));
616            }
617        }
618        self
619    }
620
621    /// Every file read so far, as it was read.
622    pub fn files_read(&self) -> BTreeMap<String, String> {
623        self.files
624            .lock()
625            .expect("the file cache is only read and filled")
626            .iter()
627            .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
628            .collect()
629    }
630
631    fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
632        let mut files = self
633            .files
634            .lock()
635            .expect("the file cache is only read and filled");
636        files
637            .entry(file.to_owned())
638            .or_insert_with(|| {
639                std::fs::read_to_string(self.root.join(file))
640                    .ok()
641                    .map(Into::into)
642            })
643            .clone()
644    }
645
646    /// The tests to ask about a statement, in order: tests in a file named like
647    /// the source first, then one per describe block, round-robin over files.
648    pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
649        let s = &self.statements[statement];
650        let base = stem(&s.file);
651        let key = |&t: &usize| {
652            let test = &self.tests[t];
653            (
654                !Path::new(&test.file)
655                    .file_name()
656                    .and_then(|n| n.to_str())
657                    .unwrap_or("")
658                    .to_lowercase()
659                    .contains(&base),
660                test.file.clone(),
661                test.name.clone(),
662            )
663        };
664        let mut all = s.tests.clone();
665        all.sort_by_key(key);
666        if all.len() <= k {
667            return all;
668        }
669        let mut groups = Vec::<((String, String), Vec<usize>)>::new();
670        for &t in &all {
671            let g = (
672                self.tests[t].file.clone(),
673                describe(
674                    &self.tests[t].name,
675                    separator(self.language, &self.tests[t].runner),
676                ),
677            );
678            match groups.iter_mut().find(|(k, _)| *k == g) {
679                Some((_, v)) => v.push(t),
680                None => groups.push((g, vec![t])),
681            }
682        }
683        let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
684        for ((file, _), members) in groups {
685            match by_file.iter_mut().find(|(f, _)| *f == file) {
686                Some((_, v)) => v.push(members),
687                None => by_file.push((file, vec![members])),
688            }
689        }
690        by_file.sort_by_key(|(f, _)| {
691            (
692                !Path::new(f)
693                    .file_name()
694                    .and_then(|n| n.to_str())
695                    .unwrap_or("")
696                    .to_lowercase()
697                    .contains(&base),
698                f.clone(),
699            )
700        });
701        let mut picked = Vec::new();
702        let mut depth = 0;
703        while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
704            for (_, g) in &by_file {
705                if depth < g.len() && picked.len() < k {
706                    picked.push(g[depth][0]);
707                }
708            }
709            depth += 1;
710        }
711        for t in all {
712            if picked.len() >= k {
713                break;
714            }
715            if !picked.contains(&t) {
716                picked.push(t);
717            }
718        }
719        picked
720    }
721
722    /// The test's code: the file's first lines and the test's own body, found
723    /// by its title, comment lines left out.
724    pub fn test_code(&self, test: usize) -> String {
725        match self.language {
726            Language::Python => return self.python_test_code(test),
727            Language::JavaScript => {}
728            _ => return self.located_test_code(test),
729        }
730        let t = &self.tests[test];
731        let text = self.read(&t.file).unwrap_or_default();
732        let lines = text.split('\n').collect::<Vec<_>>();
733        let title = t.name.rsplit(" > ").next().unwrap_or(&t.name);
734        let needle = prefix(title, 40);
735        let start = (!needle.is_empty())
736            .then(|| lines.iter().position(|l| l.contains(needle)))
737            .flatten();
738        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
739        let head = (0..lines.len().min(HEAD_LINES))
740            .filter(|&i| !self.language.comment(lines[i]))
741            .map(fmt)
742            .collect::<Vec<_>>();
743        let Some(start) = start else {
744            let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
745            return head
746                .into_iter()
747                .chain(std::iter::once("  ...".to_owned()))
748                .chain(rest)
749                .collect::<Vec<_>>()
750                .join("\n");
751        };
752        let indent = lines[start].len() - lines[start].trim_start().len();
753        let mut body = Vec::new();
754        for (i, l) in lines
755            .iter()
756            .copied()
757            .enumerate()
758            .take(start + BODY_LINES)
759            .skip(start)
760        {
761            if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
762                break;
763            }
764            if !self.language.comment(l) {
765                body.push(fmt(i));
766            }
767        }
768        let mut out = head;
769        if start > HEAD_LINES {
770            out.push("  ...".into());
771        }
772        out.extend(body);
773        out.join("\n")
774    }
775
776    /// A Ruby, Go, Rust or JVM test's code: the file's first lines, the lines
777    /// that frame it (its class or groups, its annotations), and its body.
778    fn located_test_code(&self, test: usize) -> String {
779        let t = &self.tests[test];
780        let text = self.read(&t.file).unwrap_or_default();
781        let lines = text.split('\n').collect::<Vec<_>>();
782        let located = match self.language {
783            Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
784            Language::Go => go::locate(&lines, &t.name),
785            Language::Rust => rust_source::locate(&lines, &t.name),
786            _ => jvm::locate(&lines, &t.name),
787        };
788        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
789        // Lines inside `/* ... */` (a license header, say) are comments too.
790        let mut open = false;
791        let commented = lines
792            .iter()
793            .map(|l| {
794                let t = l.trim();
795                let was = open;
796                if self.language != Language::Ruby {
797                    if !open && t.starts_with("/*") {
798                        open = true;
799                    }
800                    if open && t.contains("*/") {
801                        open = false;
802                        return true;
803                    }
804                }
805                was || open || self.language.comment(l)
806            })
807            .collect::<Vec<_>>();
808        // The head: the file's first code lines (its package and imports).
809        let head = (0..lines.len())
810            .filter(|&i| !commented[i])
811            .take(HEAD_LINES)
812            .collect::<Vec<_>>();
813        let head_end = head.last().map_or(0, |&i| i + 1);
814        let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
815        let Some(located) = located else {
816            out.push("  ...".into());
817            out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
818            return out.join("\n");
819        };
820        let end = located
821            .end
822            .min(located.start + BODY_LINES)
823            .min(lines.len().saturating_sub(1));
824        let mut last = head_end;
825        let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
826        shown.extend(located.start..=end);
827        // What the test uses from its own file: a table it loops over, a
828        // helper it calls, a `let` it reads.
829        shown.extend(used_definitions(&lines, self.language, located.start, end));
830        for i in shown {
831            // A doctest lives in comments: its lines are the test.
832            if i < last || (commented[i] && self.language != Language::Rust) {
833                continue;
834            }
835            if i > last {
836                out.push("  ...".into());
837            }
838            out.push(fmt(i));
839            last = i + 1;
840        }
841        out.join("\n")
842    }
843
844    /// A Python test's code: the file's first lines, the class holding the
845    /// test, its decorators, and its body, found by the test function's name
846    /// and ended by indentation.
847    fn python_test_code(&self, test: usize) -> String {
848        let t = &self.tests[test];
849        let text = self.read(&t.file).unwrap_or_default();
850        let lines = text.split('\n').collect::<Vec<_>>();
851        let title = python_title(&t.name);
852        let indent_of = |l: &str| l.len() - l.trim_start().len();
853        let start = (!title.is_empty())
854            .then(|| {
855                lines.iter().position(|l| {
856                    let t = l.trim_start();
857                    t.starts_with(&format!("def {title}("))
858                        || t.starts_with(&format!("async def {title}("))
859                })
860            })
861            .flatten();
862        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
863        let mut out = (0..lines.len().min(HEAD_LINES))
864            .filter(|&i| !self.language.comment(lines[i]))
865            .map(fmt)
866            .collect::<Vec<_>>();
867        let Some(start) = start else {
868            out.push("  ...".into());
869            out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
870            return out.join("\n");
871        };
872        let indent = indent_of(lines[start]);
873        let mut first = start;
874        while first > 0
875            && lines[first - 1].trim_start().starts_with('@')
876            && indent_of(lines[first - 1]) == indent
877        {
878            first -= 1;
879        }
880        let class = (indent > 0)
881            .then(|| {
882                (0..first).rev().find(|&i| {
883                    lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
884                })
885            })
886            .flatten();
887        for i in class.into_iter().chain(first..start) {
888            if i >= HEAD_LINES {
889                if out.last().is_some_and(|l| l != "  ...") {
890                    out.push("  ...".into());
891                }
892                out.push(fmt(i));
893            }
894        }
895        if start >= HEAD_LINES
896            && out.last().is_some_and(|l| l != "  ...")
897            && first == start
898            && class.is_none()
899        {
900            out.push("  ...".into());
901        }
902        for (i, &l) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
903            if i > start && !l.trim().is_empty() && indent_of(l) <= indent {
904                break;
905            }
906            if !self.language.comment(l) && i >= HEAD_LINES {
907                out.push(fmt(i));
908            }
909        }
910        out.join("\n")
911    }
912
913    /// Test-side files the test imports (helpers, fixtures), cut to a budget
914    /// that grows with the number of questions.
915    pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
916        let cap = HELPER_CHARS.min(1500 + 400 * questions);
917        let t = &self.tests[test];
918        let text = self.read(&t.file).unwrap_or_default();
919        let mut out = BTreeMap::new();
920        let mut size = 0;
921        let files = match self.language {
922            Language::JavaScript => import_specifiers(&text)
923                .iter()
924                .filter_map(|spec| self.resolve(&t.file, spec))
925                .collect::<Vec<_>>(),
926            Language::Python => self.python_helpers(&t.file, &text),
927            Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
928            Language::Go => go::helpers(&self.root, &t.file, &text),
929            Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
930            Language::Jvm => jvm::imports(&text)
931                .iter()
932                .filter_map(|import| {
933                    jvm::class_paths(&import.replace('/', "."))
934                        .iter()
935                        .find_map(|suffix| {
936                            self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
937                        })
938                        .cloned()
939                })
940                .collect(),
941        };
942        for file in files {
943            if out.contains_key(&file) {
944                continue;
945            }
946            if self.source_files.contains(&file) || file.contains("node_modules") {
947                continue;
948            }
949            let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
950            if size + body.len() > cap {
951                let keep = cap.saturating_sub(size);
952                let cut = body
953                    .char_indices()
954                    .map(|(i, _)| i)
955                    .take_while(|&i| i <= keep)
956                    .last()
957                    .unwrap_or(0);
958                body = format!("{}\n... (cut)", &body[..cut]);
959            }
960            size += body.len();
961            out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
962            if size >= cap {
963                break;
964            }
965        }
966        if self.language == Language::Ruby {
967            let mut used = 0;
968            for file in ruby::support_files(&self.root, &text) {
969                if out.contains_key(&file) || used >= FIXTURE_CHARS {
970                    continue;
971                }
972                let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
973                if used + body.len() > FIXTURE_CHARS {
974                    let keep = FIXTURE_CHARS - used;
975                    let cut = body
976                        .char_indices()
977                        .map(|(i, _)| i)
978                        .take_while(|&i| i <= keep)
979                        .last()
980                        .unwrap_or(0);
981                    body = format!("{}\n... (cut)", &body[..cut]);
982                }
983                used += body.len();
984                out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
985            }
986        }
987        out
988    }
989
990    /// A Python test's helpers: the `conftest.py` files from its directory up
991    /// (where pytest fixtures live, closest first), then the project modules it
992    /// imports.
993    fn python_helpers(&self, from: &str, text: &str) -> Vec<String> {
994        let mut out = Vec::new();
995        let mut dir = Path::new(from).parent();
996        while let Some(d) = dir {
997            let conftest = d.join("conftest.py").to_string_lossy().replace('\\', "/");
998            if self.root.join(&conftest).is_file() {
999                out.push(conftest);
1000            }
1001            dir = d.parent();
1002        }
1003        let here = Path::new(from).parent().unwrap_or(Path::new(""));
1004        for module in python_imports(text) {
1005            let dots = module.chars().take_while(|c| *c == '.').count();
1006            let rest = module[dots..].replace('.', "/");
1007            let bases = if dots > 0 {
1008                let mut base = here.to_path_buf();
1009                for _ in 1..dots {
1010                    base.pop();
1011                }
1012                vec![base]
1013            } else {
1014                vec![PathBuf::new(), here.to_path_buf(), PathBuf::from("src")]
1015            };
1016            for base in bases {
1017                let path = base.join(&rest);
1018                let path = path.to_string_lossy().replace('\\', "/");
1019                let found = [format!("{path}.py"), format!("{path}/__init__.py")]
1020                    .into_iter()
1021                    .find(|c| !c.starts_with('/') && self.root.join(c).is_file());
1022                if let Some(found) = found {
1023                    out.push(found);
1024                    break;
1025                }
1026            }
1027        }
1028        out
1029    }
1030
1031    fn resolve(&self, from: &str, spec: &str) -> Option<String> {
1032        let base = if let Some((alias, target)) = self
1033            .aliases
1034            .iter()
1035            .find(|(a, _)| spec.starts_with(a.as_str()))
1036        {
1037            PathBuf::from(format!("{target}{}", &spec[alias.len()..]))
1038        } else if spec.starts_with('.') {
1039            Path::new(from).parent().unwrap_or(Path::new("")).join(spec)
1040        } else {
1041            return None;
1042        };
1043        let s = normalize(&base);
1044        let candidates = [
1045            s.clone(),
1046            format!("{s}.ts"),
1047            format!("{s}.tsx"),
1048            format!("{s}.js"),
1049            format!("{s}.mjs"),
1050            s.replace(".js", ".ts"),
1051            format!("{s}/index.ts"),
1052            format!("{s}/index.tsx"),
1053            format!("{s}/index.js"),
1054        ];
1055        candidates.into_iter().find(|c| self.root.join(c).is_file())
1056    }
1057
1058    /// The source this test ran: every statement it executed marked ▶, four
1059    /// code lines around each asked statement, comment lines left out, within
1060    /// a budget that grows with the number of questions. Two lines around
1061    /// every executed statement and eight around each asked one, in a budget
1062    /// four times this, cost 28% more over 3,532 checked statements in 14
1063    /// projects and were no more accurate.
1064    pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1065        let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1066        let ran = &self.ran[test];
1067        let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1068        for &a in asked {
1069            want.entry(self.statements[a].file.clone())
1070                .or_default()
1071                .insert(self.statements[a].line);
1072        }
1073        let mut files = want.keys().cloned().collect::<Vec<_>>();
1074        let mut others = ran
1075            .iter()
1076            .filter(|(f, _)| !want.contains_key(*f))
1077            .collect::<Vec<_>>();
1078        others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1079        files.extend(others.into_iter().map(|(f, _)| f.clone()));
1080        let empty = BTreeSet::new();
1081        let mut out = BTreeMap::new();
1082        let mut total = 0;
1083        for f in files {
1084            let Some(source) = self.read(&f) else {
1085                continue;
1086            };
1087            let text = source.split('\n').collect::<Vec<_>>();
1088            let r = ran.get(&f).unwrap_or(&empty);
1089            let code = (1..=text.len())
1090                .filter(|&i| !self.language.comment(text[i - 1]))
1091                .collect::<Vec<_>>();
1092            let pos = code
1093                .iter()
1094                .enumerate()
1095                .map(|(n, &i)| (i, n))
1096                .collect::<BTreeMap<_, _>>();
1097            let around = |l: usize, w: usize| -> Vec<usize> {
1098                match pos.get(&l) {
1099                    Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1100                    None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1101                }
1102            };
1103            let mut show = BTreeSet::new();
1104            for &l in r {
1105                show.extend(around(l, CONTEXT_LINES));
1106            }
1107            for &l in want.get(&f).unwrap_or(&empty) {
1108                show.extend(around(l, WINDOW_LINES));
1109            }
1110            let mut lines = Vec::new();
1111            let mut last = 0;
1112            for i in show {
1113                if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1114                    continue;
1115                }
1116                if i != last + 1 {
1117                    lines.push(format!("        ... {} lines not shown", i - last - 1));
1118                }
1119                lines.push(format!(
1120                    "{}{:5} {}",
1121                    if r.contains(&i) { '▶' } else { ' ' },
1122                    i,
1123                    text[i - 1]
1124                ));
1125                last = i;
1126            }
1127            let body = lines.join("\n");
1128            if !want.contains_key(&f) && total + body.len() > budget {
1129                continue;
1130            }
1131            total += body.len();
1132            out.insert(f, body);
1133        }
1134        out
1135    }
1136
1137    /// The questions for one statement: one per change (two for an `if`).
1138    pub fn questions(&self, statement: usize) -> Vec<String> {
1139        let s = &self.statements[statement];
1140        let line = s.text.lines().next().unwrap_or("").trim();
1141        let changes = match (s.change, &s.replacement) {
1142            (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1143                if self.language == Language::Rust =>
1144            {
1145                vec![rust_question(line, s.change, r)]
1146            }
1147            // The value is replaced after it is computed, as Go's mutation
1148            // testers do.
1149            (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1150                vec![format!(
1151                    "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1152                )]
1153            }
1154            (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1155                vec![format!(
1156                    "`{line}` becomes `{r}` (the original value is still computed first)"
1157                )]
1158            }
1159            _ => self.default_questions(s, line),
1160        };
1161        changes
1162            .into_iter()
1163            .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1164            .collect()
1165    }
1166
1167    fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1168        match s.change {
1169            // Go and the JVM languages replace a value after it is computed,
1170            // as their mutation testers do: the expression still runs.
1171            Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1172                "`{line}` returns the zero value of each result instead: nil for an error, pointer, slice, map or interface, 0, \"\" or false, or an empty struct (its expressions still run)"
1173            )],
1174            // A `when`, `switch` or `try` ending a function returns from its
1175            // branches.
1176            Change::ReturnUndefined
1177                if self.language == Language::Jvm
1178                    && s.text.contains("return")
1179                    && ["when", "switch", "try"].iter().any(|k| {
1180                        line.strip_prefix(k)
1181                            .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1182                    }) =>
1183            {
1184                vec![format!(
1185                    "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1186                    self.language.nothing()
1187                )]
1188            }
1189            Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1190                "`{line}` returns {} instead (its expression still runs)",
1191                self.language.nothing()
1192            )],
1193            Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1194                let nothing = match self.language {
1195                    Language::Rust
1196                        if ["Ok(", "Err(", "return Ok(", "return Err("]
1197                            .iter()
1198                            .any(|p| line.starts_with(p)) =>
1199                    {
1200                        "`Ok(Default::default())`"
1201                    }
1202                    other => other.nothing(),
1203                };
1204                vec![if line.starts_with("return") {
1205                    format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1206                } else {
1207                    // Often a call made for what it does, not what it gives
1208                    // back: saying only "the value becomes nil" hides that.
1209                    format!(
1210                        "`{line}` becomes {nothing}: it is not evaluated, so nothing it does happens (no call it makes, no value it stores), and the enclosing block or method produces {nothing}"
1211                    )
1212                }]
1213            }
1214            Change::ValueUndefined if self.language == Language::Go => vec![format!(
1215                "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1216            )],
1217            Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1218                "`{line}`: after it runs, the variable it assigns holds {} instead",
1219                self.language.nothing()
1220            )],
1221            Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1222                let nothing = self.language.nothing();
1223                vec![match split_initializer(line) {
1224                    Some(head) => format!(
1225                        "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1226                    ),
1227                    None => format!("`{line}`: the assigned value becomes {nothing}"),
1228                }]
1229            }
1230            Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1231                "`{line}` becomes `return None` (the expression is not evaluated)"
1232            )],
1233            Change::ReturnUndefined => vec![format!(
1234                "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1235            )],
1236            Change::ValueUndefined if self.language == Language::Python => {
1237                vec![match split_initializer(line) {
1238                    Some(head) => {
1239                        format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1240                    }
1241                    None => format!("`{line}`: the assigned value becomes None"),
1242                }]
1243            }
1244            Change::ValueUndefined => vec![match split_initializer(line) {
1245                Some(head) => format!(
1246                    "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1247                ),
1248                None => format!("`{line}`: the declared value becomes undefined"),
1249            }],
1250            Change::Skip => {
1251                let word = line.trim_end_matches(';').trim();
1252                vec![match word {
1253                    "break" => format!(
1254                        "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1255                    ),
1256                    "continue" | "next" => format!(
1257                        "`{line}` is deleted: the rest of the loop body runs in this iteration"
1258                    ),
1259                    _ => format!("`{line}` is deleted (it never runs)"),
1260                }]
1261            }
1262            Change::ExpressionUndefined => vec![format!(
1263                "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1264            )],
1265            Change::Invert => {
1266                let c = match self.language {
1267                    Language::Python => python_condition(line),
1268                    Language::JavaScript => condition(line),
1269                    Language::Ruby => ruby::condition(line),
1270                    Language::Go => go::condition(line),
1271                    Language::Rust => rust_source::condition(line),
1272                    Language::Jvm => jvm::condition(line),
1273                };
1274                // A pattern match (`if let`) can be made to fail, not to
1275                // succeed: its bindings would have no value.
1276                if c.starts_with("let ") {
1277                    return vec![format!(
1278                        "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1279                    )];
1280                }
1281                vec![
1282                    format!(
1283                        "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1284                    ),
1285                    format!(
1286                        "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1287                    ),
1288                ]
1289            }
1290        }
1291    }
1292
1293    /// The request for one test and the statements asked about it.
1294    pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1295        let t = &self.tests[test];
1296        let mut questions = serde_json::Map::new();
1297        for (n, &a) in asked.iter().enumerate() {
1298            for (j, q) in self.questions(a).into_iter().enumerate() {
1299                questions.insert(
1300                    format!("q{n}_{j}"),
1301                    json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1302                );
1303            }
1304        }
1305        json!({"model": model, "state": {
1306            "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1307            "test": {"file": t.file, "name": t.name},
1308            "test_code": self.test_code(test), "helpers": self.helpers(test, asked.len()),
1309            "code_run": self.code_run(test, asked)}, "questions": questions})
1310    }
1311
1312    /// What Jev sees for one statement asked of one test, line numbers aside:
1313    /// the same code renumbered is the same question. Equal keys, equal question.
1314    /// `salt` carries what changes every answer at once: the run's dependency
1315    /// and configuration fingerprints.
1316    pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1317        let t = &self.tests[test];
1318        let parts = json!([
1319            model,
1320            salt,
1321            INSTRUCTIONS,
1322            runtime(&t.runner),
1323            true,
1324            &t.file,
1325            &t.name,
1326            strip_numbers(&self.test_code(test)),
1327            self.helpers(test, 1)
1328                .into_iter()
1329                .map(|(f, b)| (f, strip_numbers(&b)))
1330                .collect::<BTreeMap<_, _>>(),
1331            self.code_run(test, &[statement])
1332                .into_iter()
1333                .map(|(f, b)| (f, strip_numbers(&b)))
1334                .collect::<BTreeMap<_, _>>(),
1335            self.questions(statement)
1336                .iter()
1337                .map(|q| strip_location(q))
1338                .collect::<Vec<_>>(),
1339        ]);
1340        format!(
1341            "{:x}",
1342            Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1343        )
1344    }
1345}
1346
1347/// A Rust return or assignment's change, as the line it becomes.
1348fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1349    let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1350    match change {
1351        Change::ReturnUndefined if line.starts_with("return") => {
1352            format!("`{line}` becomes `return {repl};`")
1353        }
1354        Change::ReturnUndefined => {
1355            format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1356        }
1357        _ => match split_initializer(line) {
1358            Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1359            None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1360        },
1361    }
1362}
1363
1364/// Write files' text, compressed, to `path`.
1365pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
1366    use std::io::Write;
1367    let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
1368    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1369    encoder.write_all(&bytes).map_err(|e| e.to_string())?;
1370    let compressed = encoder.finish().map_err(|e| e.to_string())?;
1371    let temp = path.with_extension("tmp");
1372    std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
1373    std::fs::rename(&temp, path).map_err(|e| e.to_string())
1374}
1375
1376/// Read files written by [`save_snapshot`].
1377pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
1378    use std::io::Read;
1379    let compressed = std::fs::read(path).ok()?;
1380    let mut text = Vec::new();
1381    flate2::read::GzDecoder::new(compressed.as_slice())
1382        .read_to_end(&mut text)
1383        .ok()?;
1384    serde_json::from_slice(&text).ok()
1385}
1386
1387/// Where a test sits in its file: its first line, its last, and lines that
1388/// frame it (its class, groups or annotations), all zero-based.
1389struct Located {
1390    context: Vec<usize>,
1391    start: usize,
1392    end: usize,
1393}
1394
1395fn indent(line: &str) -> usize {
1396    line.len() - line.trim_start().len()
1397}
1398
1399/// The line closing the braces opened from `start` on (strings, characters
1400/// and line comments aside).
1401fn brace_end(lines: &[&str], start: usize) -> usize {
1402    let mut depth = 0i64;
1403    let mut opened = false;
1404    for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1405        let mut chars = line.chars().peekable();
1406        let mut quote: Option<char> = None;
1407        while let Some(c) = chars.next() {
1408            match (quote, c) {
1409                (Some(_), '\\') => {
1410                    chars.next();
1411                }
1412                (Some(q), c) if c == q => quote = None,
1413                (Some(_), _) => {}
1414                (None, '"' | '`') => quote = Some(c),
1415                (None, '/') if chars.peek() == Some(&'/') => break,
1416                (None, '{') => {
1417                    depth += 1;
1418                    opened = true;
1419                }
1420                (None, '}') => depth -= 1,
1421                _ => {}
1422            }
1423        }
1424        if opened && depth <= 0 {
1425            return i;
1426        }
1427    }
1428    (start + BODY_LINES).min(lines.len()).saturating_sub(1)
1429}
1430
1431/// The `end` (or `}`) closing a Ruby block or method opened on `start`.
1432fn keyword_end(lines: &[&str], start: usize) -> usize {
1433    let first = lines[start].trim_end();
1434    if first.ends_with(" end") || first.ends_with('}') {
1435        return start;
1436    }
1437    let depth = indent(lines[start]);
1438    (start + 1..lines.len().min(start + BODY_LINES))
1439        .find(|&i| {
1440            let t = lines[i].trim_start();
1441            indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
1442        })
1443        .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
1444}
1445
1446const DEFINITION_LINES: usize = 120;
1447
1448/// The lines of the test file's own definitions that the test at
1449/// `start..=end` names, and those they name in turn, up to a budget.
1450fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
1451    let words = |range: std::ops::RangeInclusive<usize>| {
1452        let mut out = BTreeSet::new();
1453        for i in range {
1454            let line = lines.get(i).copied().unwrap_or("");
1455            out.extend(
1456                line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1457                    .filter(|w| !w.is_empty())
1458                    .map(str::to_owned),
1459            );
1460        }
1461        out
1462    };
1463    let definitions = definitions(lines, language);
1464    let mut wanted = words(start..=end);
1465    let mut picked = BTreeSet::new();
1466    let mut budget = DEFINITION_LINES;
1467    for _ in 0..2 {
1468        let mut next = BTreeSet::new();
1469        for (name, a, b) in &definitions {
1470            let (a, b) = (*a, *b);
1471            if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
1472                continue;
1473            }
1474            let size = b - a + 1;
1475            if size > budget {
1476                continue;
1477            }
1478            budget -= size;
1479            picked.insert(a);
1480            next.extend(words(a..=b));
1481            picked.extend(a..=b);
1482        }
1483        wanted = next;
1484    }
1485    picked.into_iter().collect()
1486}
1487
1488/// A file's named definitions (functions, methods, table variables, RSpec
1489/// `let`/`subject`) with their first and last lines.
1490fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
1491    let ident = |s: &str| {
1492        s.chars()
1493            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
1494            .collect::<String>()
1495    };
1496    let mut out = Vec::new();
1497    for (i, line) in lines.iter().enumerate() {
1498        let t = line.trim_start();
1499        let name = match language {
1500            Language::Go => t
1501                .strip_prefix("func ")
1502                .map(|r| match r.strip_prefix('(') {
1503                    Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
1504                    None => r,
1505                })
1506                .or_else(|| t.strip_prefix("var "))
1507                .or_else(|| t.strip_prefix("type "))
1508                .filter(|_| indent(line) == 0)
1509                .map(ident),
1510            Language::Rust => {
1511                let r = t.strip_prefix("pub ").unwrap_or(t);
1512                let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
1513                ["fn ", "const ", "static ", "struct ", "enum "]
1514                    .iter()
1515                    .find_map(|k| r.strip_prefix(k))
1516                    .map(ident)
1517            }
1518            Language::Ruby => t
1519                .strip_prefix("def ")
1520                .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
1521                .or_else(|| {
1522                    ["let(:", "let!(:", "subject(:"]
1523                        .iter()
1524                        .find_map(|k| t.strip_prefix(k))
1525                        .map(ident)
1526                })
1527                .or_else(|| {
1528                    (t.starts_with("subject ") || t.starts_with("subject{"))
1529                        .then(|| "subject".into())
1530                }),
1531            Language::Jvm => {
1532                if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
1533                    Some(ident(r.1.trim_start_matches('`')))
1534                } else if t.contains('(')
1535                    && !t.ends_with(';')
1536                    && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
1537                    && ![
1538                        "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
1539                        "throw", "assert", "super", "this",
1540                    ]
1541                    .iter()
1542                    .any(|k| t.starts_with(k))
1543                {
1544                    let head = t.split('(').next().unwrap_or("");
1545                    let mut parts = head.split_whitespace().rev();
1546                    let name = parts.next().unwrap_or("");
1547                    (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
1548                } else {
1549                    None
1550                }
1551            }
1552            _ => None,
1553        };
1554        let Some(name) = name.filter(|n| !n.is_empty()) else {
1555            continue;
1556        };
1557        let end = match language {
1558            Language::Ruby => keyword_end(lines, i),
1559            _ if line.contains('{')
1560                || lines
1561                    .get(i + 1)
1562                    .is_some_and(|n| n.trim_start().starts_with('{')) =>
1563            {
1564                brace_end(lines, i)
1565            }
1566            _ => i,
1567        };
1568        out.push((name, i, end));
1569    }
1570    out
1571}
1572
1573/// Whether `path` is `suffix` or ends with `/suffix`.
1574fn ends_with_path(path: &str, suffix: &str) -> bool {
1575    path.strip_suffix(suffix)
1576        .is_some_and(|head| head.is_empty() || head.ends_with('/'))
1577}
1578
1579/// The project's files with these extensions, as project-relative paths
1580/// (ignored files, such as build output, left out).
1581fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
1582    let mut out = ignore::WalkBuilder::new(root)
1583        .build()
1584        .filter_map(Result::ok)
1585        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1586        .filter(|e| {
1587            e.path()
1588                .extension()
1589                .and_then(|x| x.to_str())
1590                .is_some_and(|x| extensions.contains(&x))
1591        })
1592        .filter_map(|e| {
1593            e.path()
1594                .strip_prefix(root)
1595                .ok()
1596                .map(|p| p.to_string_lossy().replace('\\', "/"))
1597        })
1598        .filter(|p| !p.starts_with(".supercov/"))
1599        .collect::<Vec<_>>();
1600    out.sort();
1601    out
1602}
1603
1604fn is_test_call(line: &str) -> bool {
1605    let t = line.trim_start();
1606    let rest = if let Some(r) = t.strip_prefix("it") {
1607        r
1608    } else if let Some(r) = t.strip_prefix("test") {
1609        r
1610    } else {
1611        return false;
1612    };
1613    let rest = if let Some(r) = rest.strip_prefix('.') {
1614        r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
1615    } else {
1616        rest
1617    };
1618    rest.trim_start().starts_with('(')
1619}
1620
1621/// `const x = value` -> `const x =`; `None` when there is no plain `=`.
1622fn split_initializer(line: &str) -> Option<String> {
1623    let b = line.as_bytes();
1624    for i in 1..b.len() {
1625        if b[i] == b'='
1626            && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
1627            && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
1628        {
1629            return Some(line[..=i].to_owned());
1630        }
1631    }
1632    None
1633}
1634
1635/// `if c:` -> `c`
1636/// A Python test's function name: pytest `file::Class::test[param]`,
1637/// unittest `module.Class.test`.
1638fn python_title(name: &str) -> &str {
1639    let last = name.rsplit("::").next().unwrap_or(name);
1640    let last = last.split('[').next().unwrap_or(last);
1641    last.rsplit('.').next().unwrap_or(last)
1642}
1643
1644fn python_condition(line: &str) -> String {
1645    let t = line.trim();
1646    let t = t
1647        .strip_prefix("elif")
1648        .or_else(|| t.strip_prefix("if"))
1649        .map(str::trim_start)
1650        .unwrap_or(t);
1651    t.strip_suffix(':')
1652        .map(str::trim_end)
1653        .unwrap_or(t)
1654        .to_owned()
1655}
1656
1657/// The modules a Python file imports: `from a.b import c` -> `a.b`,
1658/// `import a, b as x` -> `a`, `b`, `from . import c` -> `.c`.
1659fn python_imports(text: &str) -> Vec<String> {
1660    let mut out = Vec::new();
1661    for line in text.split('\n') {
1662        let t = line.trim_start();
1663        if let Some(rest) = t.strip_prefix("from ") {
1664            let Some((module, names)) = rest.split_once(" import ") else {
1665                continue;
1666            };
1667            let module = module.trim();
1668            if module.chars().all(|c| c == '.') {
1669                for name in names.trim_matches(|c| c == '(' || c == ')').split(',') {
1670                    let name = name.split(" as ").next().unwrap_or("").trim();
1671                    if !name.is_empty() && name != "*" {
1672                        out.push(format!("{module}{name}"));
1673                    }
1674                }
1675            } else {
1676                out.push(module.to_owned());
1677            }
1678        } else if let Some(rest) = t.strip_prefix("import ") {
1679            for part in rest.split(',') {
1680                let module = part.split(" as ").next().unwrap_or("").trim();
1681                if !module.is_empty() {
1682                    out.push(module.to_owned());
1683                }
1684            }
1685        }
1686    }
1687    out
1688}
1689
1690/// `if (c) {` -> `c`
1691fn condition(line: &str) -> String {
1692    let mut t = line.trim();
1693    if let Some(r) = t.strip_prefix('}') {
1694        let r = r.trim_start();
1695        if let Some(r) = r.strip_prefix("else") {
1696            t = r.trim_start();
1697        }
1698    }
1699    let Some(r) = t.strip_prefix("if") else {
1700        return line.to_owned();
1701    };
1702    let r = r.trim_start();
1703    let Some(r) = r.strip_prefix('(') else {
1704        return line.to_owned();
1705    };
1706    let r = r.trim_end();
1707    let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
1708    match r.strip_suffix(')') {
1709        Some(c) => c.to_owned(),
1710        None => line.to_owned(),
1711    }
1712}
1713
1714fn strip_numbers(text: &str) -> String {
1715    text.split('\n')
1716        .map(|l| {
1717            let (mark, rest) = match l.chars().next() {
1718                Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
1719                _ => (None, l),
1720            };
1721            let trimmed = rest.trim_start();
1722            let digits = trimmed.len()
1723                - trimmed
1724                    .trim_start_matches(|c: char| c.is_ascii_digit())
1725                    .len();
1726            if digits > 0 && trimmed[digits..].starts_with(' ') {
1727                format!(
1728                    "{}{}",
1729                    mark.map(String::from).unwrap_or_default(),
1730                    &trimmed[digits + 1..]
1731                )
1732            } else if let Some(n) = l
1733                .trim_start()
1734                .strip_prefix("... ")
1735                .and_then(|r| r.split_once(" lines not shown"))
1736            {
1737                let _ = n;
1738                "...".to_owned()
1739            } else {
1740                l.to_owned()
1741            }
1742        })
1743        .collect::<Vec<_>>()
1744        .join("\n")
1745}
1746
1747fn strip_location(question: &str) -> String {
1748    // `file:12: change` -> `file:: change`
1749    match question.split_once(": ") {
1750        Some((loc, rest)) => match loc.rsplit_once(':') {
1751            Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
1752                format!("{file}:: {rest}")
1753            }
1754            _ => question.to_owned(),
1755        },
1756        None => question.to_owned(),
1757    }
1758}
1759
1760fn import_specifiers(text: &str) -> Vec<String> {
1761    let mut out = Vec::new();
1762    for line in text.split('\n') {
1763        let t = line.trim_start();
1764        let quoted = |s: &str| -> Option<String> {
1765            let s = s.trim_start();
1766            let q = s.chars().next().filter(|c| *c == '\'' || *c == '"')?;
1767            let rest = &s[1..];
1768            rest.find(q).map(|end| rest[..end].to_owned())
1769        };
1770        if (t.starts_with("import ") || t.starts_with("export "))
1771            && let Some(i) = t.find(" from ")
1772        {
1773            out.extend(quoted(&t[i + 6..]));
1774        } else if let Some(i) = t.find("import(") {
1775            out.extend(quoted(&t[i + 7..]));
1776        } else if let Some(i) = t.find("require(") {
1777            out.extend(quoted(&t[i + 8..]));
1778        } else if let Some(r) = t.strip_prefix("} from ") {
1779            out.extend(quoted(r));
1780        }
1781    }
1782    out
1783}
1784
1785/// The project-relative name of `path`, `..` and `.` resolved, with `/`
1786/// between parts on every platform: a run names its files that way, and on
1787/// Windows a joined path had `\\` and named a helper no run had.
1788fn normalize(path: &Path) -> String {
1789    let mut out = Vec::new();
1790    for c in path.components() {
1791        match c {
1792            std::path::Component::ParentDir => {
1793                out.pop();
1794            }
1795            std::path::Component::CurDir => {}
1796            other => out.push(other.as_os_str().to_string_lossy().into_owned()),
1797        }
1798    }
1799    out.join("/")
1800}
1801
1802/// `compilerOptions.paths` of the project's tsconfig.json: `"~/*": ["./app/*"]`
1803/// becomes (`~/`, `app/`).
1804fn tsconfig_aliases(root: &Path) -> Vec<(String, String)> {
1805    let Ok(text) = std::fs::read_to_string(root.join("tsconfig.json")) else {
1806        return vec![];
1807    };
1808    let cleaned = text
1809        .lines()
1810        .filter(|l| !l.trim_start().starts_with("//"))
1811        .collect::<Vec<_>>()
1812        .join("\n");
1813    let Ok(config) = serde_json::from_str::<Value>(&cleaned) else {
1814        return vec![];
1815    };
1816    let mut out = Vec::new();
1817    if let Some(paths) = config["compilerOptions"]["paths"].as_object() {
1818        for (alias, targets) in paths {
1819            let Some(target) = targets
1820                .as_array()
1821                .and_then(|t| t.first())
1822                .and_then(Value::as_str)
1823            else {
1824                continue;
1825            };
1826            let (Some(a), Some(t)) = (alias.strip_suffix('*'), target.strip_suffix('*')) else {
1827                continue;
1828            };
1829            out.push((a.to_owned(), t.trim_start_matches("./").to_owned()));
1830        }
1831    }
1832    out
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837    use super::*;
1838
1839    #[test]
1840    fn changes_follow_the_audit() {
1841        let source = "import x from 'y'\nexport const a = 1\nlet b: number\nfunction f() {\n  if (a) return 2\n  return;\n}\nconst el = <div>{a && b}</div>\n";
1842        let starts = statement_starts("x.tsx", source);
1843        let js = Language::JavaScript;
1844        assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
1845        assert_eq!(
1846            change_at(&starts, 2, "const a = 1", js),
1847            Some(Change::ValueUndefined)
1848        );
1849        assert_eq!(change_at(&starts, 3, "let b: number", js), None);
1850        assert_eq!(
1851            change_at(&starts, 5, "if (a) return 2", js),
1852            Some(Change::Invert)
1853        );
1854        assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
1855        assert_eq!(
1856            change_at(&starts, 8, "a && b", js),
1857            Some(Change::ExpressionUndefined)
1858        );
1859    }
1860
1861    #[test]
1862    fn python_changes_follow_the_audit() {
1863        let source = "import os\n\n\ndef f(x):\n    \"\"\"Docs.\"\"\"\n    y = x + 1\n    if y > 2:\n        return y\n    z: int\n    count += 1\n    return\ndef g(v):\n    if v:\n        pass\n    elif v is None:\n        pass\n";
1864        let starts = python_statement_starts(source);
1865        let py = Language::Python;
1866        assert_eq!(change_at(&starts, 1, "import os", py), None);
1867        assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
1868        assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
1869        assert_eq!(
1870            change_at(&starts, 6, "y = x + 1", py),
1871            Some(Change::ValueUndefined)
1872        );
1873        assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
1874        assert_eq!(
1875            change_at(&starts, 8, "return y", py),
1876            Some(Change::ReturnUndefined)
1877        );
1878        assert_eq!(change_at(&starts, 9, "z: int", py), None);
1879        assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
1880        assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
1881        assert_eq!(
1882            change_at(&starts, 15, "v is None", py),
1883            Some(Change::Invert)
1884        );
1885        assert_eq!(
1886            python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
1887            "test_b"
1888        );
1889        assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
1890        assert_eq!(python_condition("if y > 2:"), "y > 2");
1891        assert_eq!(python_condition("elif v is None:"), "v is None");
1892        assert_eq!(
1893            python_imports(
1894                "from .helpers import make\nfrom . import fixtures, util as u\nimport tests.support, json as j\n"
1895            ),
1896            vec![".helpers", ".fixtures", ".util", "tests.support", "json"]
1897        );
1898    }
1899
1900    #[test]
1901    fn change_texts() {
1902        assert_eq!(
1903            split_initializer("const x = a === b").as_deref(),
1904            Some("const x =")
1905        );
1906        assert_eq!(
1907            split_initializer("const f = () => 1").as_deref(),
1908            Some("const f =")
1909        );
1910        assert_eq!(condition("} else if (x > 1) {"), "x > 1");
1911        assert_eq!(
1912            condition("if (!body) return null"),
1913            "if (!body) return null"
1914        );
1915        assert!(is_test_call("  it(\"works\", () => {"));
1916        assert!(is_test_call("test.each([1])('x', () => {"));
1917        assert!(!is_test_call("items.push(1)"));
1918    }
1919
1920    #[test]
1921    fn keys_ignore_line_numbers() {
1922        assert_eq!(
1923            strip_numbers("▶   12   return x\n        ... 3 lines not shown\n    15 }"),
1924            "▶  return x\n...\n }"
1925        );
1926        assert_eq!(
1927            strip_location("src/a.ts:41: `x` is deleted."),
1928            "src/a.ts:: `x` is deleted."
1929        );
1930    }
1931}