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::{
21    Declaration, ExportDefaultDeclarationKind, Expression, Statement as JsStatement,
22};
23use oxc_ast_visit::{Visit, walk};
24use oxc_parser::Parser;
25use oxc_span::{GetSpan, SourceType};
26use serde::Serialize;
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31
32mod go;
33mod jvm;
34mod ruby;
35mod rust_source;
36
37/// Statements starting on each line: their text and change (`None`: not
38/// assessed).
39type Starts = BTreeMap<usize, Vec<(String, Option<Change>)>>;
40
41pub const MODEL: &str = "jev-1.13.0";
42/// Tests asked about a statement before it may be called unchecked, then the
43/// wider set asked before it is (measured: more tests remove false "unchecked"
44/// flags, and 25 tests was no better than 15).
45pub const FIRST_TESTS: usize = 5;
46pub const MORE_TESTS: usize = 15;
47/// Asked last, when none of those is judged to fail: the tests most likely
48/// written for the statement.
49pub const LAST_TESTS: usize = 3;
50/// The most tests one statement is asked of.
51pub const MAX_TESTS: usize = MORE_TESTS + LAST_TESTS;
52pub const MAX_QUESTIONS: usize = 150;
53/// A statement is asserted when some test's answer is at least this.
54pub const THRESHOLD: f64 = 0.5;
55const HEAD_LINES: usize = 10;
56const HELPER_CHARS: usize = 3000;
57/// The budget for data files a test names (Ruby spec support fixtures),
58/// beside the helpers': the cases a data-driven test checks are its asserts.
59const FIXTURE_CHARS: usize = 12000;
60const CONTEXT_LINES: usize = 0;
61const WINDOW_LINES: usize = 4;
62/// How far either side of a statement that is in no function its file is
63/// shown.
64const OUTSIDE_A_FUNCTION: usize = 25;
65const CODE_CHARS: usize = 10000;
66const BODY_LINES: usize = 150;
67
68pub 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.";
69
70fn runtime(runner: &str) -> &'static str {
71    match runner {
72        "node:test" => {
73            "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."
74        }
75        "jest" => {
76            "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."
77        }
78        "ava" => {
79            "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."
80        }
81        "tap" => {
82            "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."
83        }
84        "mocha" => {
85            "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."
86        }
87        "pytest" => {
88            "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."
89        }
90        "rspec" => {
91            "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."
92        }
93        "minitest" => {
94            "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."
95        }
96        "test-unit" => {
97            "test-unit; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test."
98        }
99        "cucumber" => {
100            "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."
101        }
102        "go-test" => {
103            "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."
104        }
105        "rust-libtest" | "rust-nextest" | "nextest" => {
106            "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."
107        }
108        "rustdoc" => {
109            "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."
110        }
111        "junit-platform" => {
112            "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."
113        }
114        "testng" => {
115            "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."
116        }
117        "unittest" => {
118            "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."
119        }
120        _ => {
121            "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."
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "camelCase")]
128pub enum Change {
129    /// `if`: the condition inverted, each direction asked separately.
130    Invert,
131    /// `return x`: returns undefined without evaluating `x`.
132    ReturnUndefined,
133    /// A declaration with a value: the value becomes undefined.
134    ValueUndefined,
135    /// An expression point that is not a statement (a JSX expression).
136    ExpressionUndefined,
137    /// Anything else: the statement is skipped.
138    Skip,
139    /// A boolean literal returned or declared: it becomes the other one.
140    /// `false` becoming undefined changes nothing a truthiness check reads,
141    /// so that change could never be caught.
142    Flip,
143    /// A TypeScript `throw`: it throws a bare `new Error()` instead. Deleting
144    /// it seldom compiles: a throw usually guards the narrowing the next line
145    /// relies on (`if ('error' in result) throw ...; return result.result`
146    /// is TS2339 without it). Keeping the throw keeps the types, and asks
147    /// whether any test checks which error comes out.
148    BareError,
149}
150
151/// The languages whose statements and tests this module can read.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Language {
154    JavaScript,
155    Python,
156    Ruby,
157    Go,
158    Rust,
159    /// Java and Kotlin, told apart by file extension.
160    Jvm,
161}
162
163impl Language {
164    /// Whether a request shows, of the code the test ran, the function each
165    /// asked statement is in and nothing else, and leaves the test's helper
166    /// files out. Measured for these two on eight projects against the
167    /// outcome of applying each change (2026-10-13): 28 to 81% of the tokens,
168    /// and 451 wrong verdicts where the fuller request gave 457. The other
169    /// languages have not been measured and are asked as before.
170    fn asks_within_the_function(self) -> bool {
171        matches!(self, Language::JavaScript | Language::Python)
172    }
173
174    /// The run manifest's language name, when it is one of these.
175    pub fn from_manifest(name: &str) -> Option<Self> {
176        match name {
177            "javascript" => Some(Self::JavaScript),
178            "python" => Some(Self::Python),
179            "ruby" => Some(Self::Ruby),
180            "go" => Some(Self::Go),
181            "rust" => Some(Self::Rust),
182            "jvm" => Some(Self::Jvm),
183            _ => None,
184        }
185    }
186    fn comment(self, line: &str) -> bool {
187        let t = line.trim_start();
188        match self {
189            Self::Python | Self::Ruby => t.starts_with('#'),
190            Self::JavaScript | Self::Go | Self::Rust | Self::Jvm => {
191                t.starts_with("//")
192                    || t.starts_with("/*")
193                    || t.starts_with("*/")
194                    || t == "*"
195                    || t.starts_with("* ")
196                    || t.starts_with("*\t")
197            }
198        }
199    }
200    /// The value a removed value becomes, as the questions name it.
201    fn nothing(self) -> &'static str {
202        match self {
203            Self::JavaScript => "undefined",
204            Self::Python => "None",
205            Self::Ruby => "nil",
206            Self::Go => "the zero value",
207            Self::Rust => "`Default::default()`",
208            Self::Jvm => "the default value (null, or 0 or false for a primitive)",
209        }
210    }
211}
212
213/// What separates a test's name from the groups it sits in.
214fn separator(language: Language, runner: &str) -> &'static str {
215    match (language, runner) {
216        (Language::JavaScript, _) => " > ",
217        (Language::Python, _) => "::",
218        (_, "rspec") => ":",
219        (_, "minitest" | "test-unit" | "junit-platform" | "testng") => "#",
220        (_, "go-test") => "/",
221        (_, "rustdoc") => " - ",
222        _ => "::",
223    }
224}
225
226#[derive(Debug, Clone)]
227pub struct Statement {
228    pub file: String,
229    pub line: usize,
230    pub text: String,
231    pub change: Change,
232    /// The value that replaces the statement's where the language chooses one
233    /// by type (Rust), rather than a stand-in for nothing.
234    pub replacement: Option<String>,
235    /// Indexes into `Population::tests`: the passing tests that executed it.
236    pub tests: Vec<usize>,
237}
238
239#[derive(Debug, Clone)]
240pub struct TestRef {
241    pub id: String,
242    pub file: String,
243    pub name: String,
244    pub runner: String,
245}
246
247pub struct Population {
248    pub root: PathBuf,
249    pub language: Language,
250    pub statements: Vec<Statement>,
251    pub tests: Vec<TestRef>,
252    /// Per test: the statement lines it ran, by file.
253    ran: Vec<BTreeMap<String, BTreeSet<usize>>>,
254    /// Files holding assessed statements; a test's own helpers are the files
255    /// it imports that are not among these.
256    source_files: BTreeSet<String>,
257    /// The project's Java and Kotlin files, for JVM tests and their imports.
258    jvm_files: Vec<String>,
259    /// Files read once: every request and every cache key reads the same text.
260    files: std::sync::Mutex<BTreeMap<String, Option<std::sync::Arc<str>>>>,
261    /// Whether a test file contains a text fragment, by (file, fragment).
262    quotes: std::sync::Mutex<std::collections::HashMap<(String, String), bool>>,
263}
264
265/// The executed statements of a run and the tests that ran them. `sources` are
266/// the run's source files, verified against the run.
267/// What assessing a run reads from its analysis: the passing view's tests,
268/// and its measured statements and executed decisions with the tests that ran
269/// them. Publication stores it, so an assessment does not analyse the run's
270/// evidence again (4.4 s of supergateway's 7.6 s of planning).
271#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
272pub struct AssessmentInput {
273    pub tests: Vec<InputTest>,
274    pub points: Vec<InputPoint>,
275    pub decisions: Vec<InputPoint>,
276}
277
278impl AssessmentInput {
279    /// Only the tests `keep` picks, and the statements and decisions they ran:
280    /// what assessing some of a run's tests asks about.
281    pub fn only_tests(&self, keep: impl Fn(&InputTest) -> bool) -> AssessmentInput {
282        let mut renumbered = vec![None; self.tests.len()];
283        let mut tests = Vec::new();
284        for (index, test) in self.tests.iter().enumerate() {
285            if keep(test) {
286                renumbered[index] = Some(tests.len() as u32);
287                tests.push(test.clone());
288            }
289        }
290        let narrow = |points: &[InputPoint]| {
291            points
292                .iter()
293                .filter_map(|point| {
294                    let tests = point
295                        .tests
296                        .iter()
297                        .filter_map(|test| renumbered.get(*test as usize).copied().flatten())
298                        .collect::<Vec<_>>();
299                    (!tests.is_empty()).then(|| InputPoint {
300                        tests,
301                        ..point.clone()
302                    })
303                })
304                .collect()
305        };
306        AssessmentInput {
307            points: narrow(&self.points),
308            decisions: narrow(&self.decisions),
309            tests,
310        }
311    }
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
315pub struct InputTest {
316    pub id: String,
317    pub file: Option<String>,
318    pub name: String,
319    pub role: String,
320    pub runner: String,
321}
322
323/// A statement or decision, with the tests that ran it as indexes into
324/// `AssessmentInput::tests`.
325#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
326pub struct InputPoint {
327    pub file: String,
328    pub line: usize,
329    pub source: String,
330    pub tests: Vec<u32>,
331}
332
333pub fn assessment_input(report: &CoverageReport) -> AssessmentInput {
334    let view = &report.filters.passed;
335    let tests = view
336        .tests
337        .iter()
338        .map(|test| InputTest {
339            id: test.id.clone(),
340            file: test.file.clone(),
341            name: test.name.clone(),
342            role: test.role.clone(),
343            runner: test.provenance.runner.clone(),
344        })
345        .collect::<Vec<_>>();
346    let index = tests
347        .iter()
348        .enumerate()
349        .map(|(i, test)| (test.id.as_str(), i as u32))
350        .collect::<BTreeMap<_, _>>();
351    let indexes = |ids: &[crate::interned::Id]| {
352        ids.iter()
353            .filter_map(|id| index.get(id.as_str()).copied())
354            .collect::<Vec<_>>()
355    };
356    let points = view
357        .points
358        .iter()
359        .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
360        .map(|p| InputPoint {
361            file: p.meta.file.clone(),
362            line: p.meta.line,
363            source: p.meta.source.clone(),
364            tests: indexes(&p.tests),
365        })
366        .collect();
367    let decisions = view
368        .decisions
369        .iter()
370        .filter(|d| d.executed)
371        .map(|d| InputPoint {
372            file: d.meta.file.clone(),
373            line: d.meta.line,
374            source: d.meta.source.clone(),
375            tests: indexes(&d.tests),
376        })
377        .collect();
378    AssessmentInput {
379        tests,
380        points,
381        decisions,
382    }
383}
384
385pub fn population(
386    root: &Path,
387    report: &CoverageReport,
388    sources: &BTreeMap<String, String>,
389    language: Language,
390) -> Result<Population, String> {
391    population_from(root, &assessment_input(report), sources, language)
392}
393
394pub fn population_from(
395    root: &Path,
396    input: &AssessmentInput,
397    sources: &BTreeMap<String, String>,
398    language: Language,
399) -> Result<Population, String> {
400    let mut tests = Vec::new();
401    // JVM tests are named by class; their file is found by its path.
402    let jvm_files = if language == Language::Jvm {
403        project_files(root, &["java", "kt"])
404    } else {
405        Vec::new()
406    };
407    // Input test index -> population test index.
408    let mut index = BTreeMap::<u32, usize>::new();
409    for (position, test) in input.tests.iter().enumerate() {
410        let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
411            Some(file) => file.clone(),
412            None if language == Language::Jvm => {
413                let found = jvm::class_paths(&test.name)
414                    .iter()
415                    .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
416                match found {
417                    Some(file) => file.clone(),
418                    None => continue,
419                }
420            }
421            None => continue,
422        };
423        if test.role != "test" {
424            continue;
425        }
426        index.insert(position as u32, tests.len());
427        tests.push(TestRef {
428            id: test.id.clone(),
429            file,
430            name: test.name.clone(),
431            runner: test.runner.clone(),
432        });
433    }
434    let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
435    let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
436    let signatures = if language == Language::Rust {
437        rust_source::signatures(
438            sources
439                .iter()
440                .filter(|(file, _)| file.ends_with(".rs"))
441                .map(|(file, source)| (file.as_str(), source.as_str())),
442        )
443    } else {
444        rust_source::Signatures::default()
445    };
446    let mut statements = Vec::new();
447    let mut seen = BTreeSet::new();
448    // Python and Ruby record an `elif`/`elsif`, and Go, Rust and the JVM
449    // languages may record an `else if`, as a decision only: its condition is
450    // taken after every statement, where no statement starts on its line.
451    let executed = input
452        .points
453        .iter()
454        .map(|p| (&p.file, p.line, &p.source, &p.tests, false));
455    let elifs = input
456        .decisions
457        .iter()
458        .filter(|_| language != Language::JavaScript)
459        .map(|d| (&d.file, d.line, &d.source, &d.tests, true));
460    for (file, line, text, point_tests, decision) in executed.chain(elifs) {
461        let key = (file.clone(), line);
462        if !seen.insert(key) {
463            continue; // the first statement on a line, as the audit reads it
464        }
465        let ids = point_tests
466            .iter()
467            .filter_map(|position| index.get(position).copied())
468            .collect::<Vec<_>>();
469        if ids.is_empty() {
470            continue;
471        }
472        if language == Language::Rust && rust_source::test_path(file) {
473            continue;
474        }
475        let Some(source) = sources.get(file) else {
476            continue;
477        };
478        let lines = changes
479            .entry(file.clone())
480            .or_insert_with(|| match language {
481                Language::JavaScript => statement_starts(file, source),
482                Language::Python => python_statement_starts(source),
483                Language::Ruby => ruby::statement_starts(source),
484                Language::Go => go::statement_starts(source),
485                Language::Rust => rust_source::statement_starts(source),
486                Language::Jvm => jvm::statement_starts(file, source),
487            });
488        let Some(change) = change_at(lines, line, text, language) else {
489            continue;
490        };
491        if decision && change != Change::Invert {
492            continue;
493        }
494        let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
495        let replacement = match language {
496            Language::Rust if valued => {
497                let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
498                let first = first.trim_end_matches(';');
499                replacements
500                    .entry(file.clone())
501                    .or_insert_with(|| rust_source::replacements(file, source, &signatures))
502                    .get(&line)
503                    .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
504                    .map(|(_, r)| r.clone())
505            }
506            // Go: the line the change turns it into, zero values written out
507            // from the result types. (Writing the JVM's `null` out the same
508            // way measured no better on commons-cli and moved the share away
509            // from the truth, so the JVM keeps its description.)
510            Language::Go if valued => go::rewrite(source, line, change),
511            _ => None,
512        };
513        // A replacement that writes the statement out as it already is --
514        // `return 0, nil` with zero values, `count := 0` -- is no change.
515        if replacement
516            .as_deref()
517            .is_some_and(|written| same_code(written, text))
518        {
519            continue;
520        }
521        statements.push(Statement {
522            file: file.clone(),
523            line,
524            text: text.clone(),
525            change,
526            replacement,
527            tests: ids,
528        });
529    }
530    let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
531    for s in &statements {
532        for &t in &s.tests {
533            ran[t].entry(s.file.clone()).or_default().insert(s.line);
534        }
535    }
536    let source_files = statements.iter().map(|s| s.file.clone()).collect();
537    Ok(Population {
538        root: root.to_path_buf(),
539        language,
540        statements,
541        tests,
542        ran,
543        source_files,
544        jvm_files,
545        files: Default::default(),
546        quotes: Default::default(),
547    })
548}
549
550/// Whether two lines of code are the same but for spacing and a final `;`.
551fn same_code(a: &str, b: &str) -> bool {
552    let squash = |code: &str| {
553        code.trim()
554            .trim_end_matches(';')
555            .chars()
556            .filter(|c| !c.is_whitespace())
557            .collect::<String>()
558    };
559    squash(a) == squash(b)
560}
561
562/// The words of a name or a line of code: runs of letters and digits split at
563/// camelCase and at anything else, lowercased, short and filler words left out.
564fn name_words(text: &str) -> BTreeSet<String> {
565    const FILLER: [&str; 13] = [
566        "test", "tests", "the", "and", "for", "with", "get", "set", "self", "from", "not", "does",
567        "should",
568    ];
569    let mut words = BTreeSet::new();
570    let chars = text.chars().collect::<Vec<_>>();
571    let mut word = String::new();
572    let mut flush = |word: &mut String| {
573        let w = word.to_lowercase();
574        if w.len() > 2 && !FILLER.contains(&w.as_str()) {
575            words.insert(w);
576        }
577        word.clear();
578    };
579    for (i, &c) in chars.iter().enumerate() {
580        if !c.is_ascii_alphanumeric() {
581            flush(&mut word);
582            continue;
583        }
584        let previous = i.checked_sub(1).map(|p| chars[p]);
585        let next = chars.get(i + 1).copied();
586        // `getHTTPResponse`: a word starts at a capital after a lowercase
587        // letter, and at the last capital of a run before a lowercase one.
588        let starts = c.is_ascii_uppercase()
589            && (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
590                || (previous.is_some_and(|p| p.is_ascii_uppercase())
591                    && next.is_some_and(|n| n.is_ascii_lowercase())));
592        if starts {
593            flush(&mut word);
594        }
595        word.push(c);
596    }
597    flush(&mut word);
598    words
599}
600
601/// Every statement starting on each line, outermost first, with its text and
602/// the change the audit applies to it (`None`: not assessed).
603fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
604    let allocator = Allocator::default();
605    let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
606    let parsed = Parser::new(&allocator, source, source_type).parse();
607    let starts = line_starts(source);
608    struct Collector<'s> {
609        source: &'s str,
610        starts: &'s [usize],
611        typescript: bool,
612        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
613    }
614    impl<'a> Visit<'a> for Collector<'_> {
615        fn visit_statement(&mut self, statement: &JsStatement<'a>) {
616            let span = statement.span();
617            let line = line_of(self.starts, span.start as usize);
618            let text = self
619                .source
620                .get(span.start as usize..span.end as usize)
621                .unwrap_or("")
622                .to_owned();
623            self.out.entry(line).or_default().push((
624                text,
625                match statement {
626                    JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
627                    _ => change_of(statement),
628                },
629            ));
630            walk::walk_statement(self, statement);
631        }
632    }
633    let mut collector = Collector {
634        source,
635        starts: &starts,
636        typescript: source_type.is_typescript(),
637        out: BTreeMap::new(),
638    };
639    collector.visit_program(&parsed.program);
640    collector.out
641}
642
643/// `undefined` or `void 0`: returning undefined instead is the same code.
644/// What TypeScript wraps a value in to give it a type (`undefined as T`)
645/// leaves it the same value.
646fn is_undefined(expression: &Expression) -> bool {
647    match expression {
648        Expression::Identifier(identifier) => identifier.name == "undefined",
649        Expression::UnaryExpression(unary) => {
650            unary.operator == oxc_syntax::operator::UnaryOperator::Void
651        }
652        Expression::TSAsExpression(cast) => is_undefined(&cast.expression),
653        Expression::TSSatisfiesExpression(cast) => is_undefined(&cast.expression),
654        Expression::TSTypeAssertion(cast) => is_undefined(&cast.expression),
655        Expression::TSNonNullExpression(cast) => is_undefined(&cast.expression),
656        Expression::ParenthesizedExpression(inner) => is_undefined(&inner.expression),
657        _ => false,
658    }
659}
660
661fn change_of(statement: &JsStatement) -> Option<Change> {
662    Some(match statement {
663        JsStatement::IfStatement(_) => Change::Invert,
664        // `return undefined` returning undefined is the same code; skipping
665        // the return is the change that can show.
666        JsStatement::ReturnStatement(r) => match &r.argument {
667            Some(argument) if is_undefined(argument) => Change::Skip,
668            Some(Expression::BooleanLiteral(_)) => Change::Flip,
669            Some(_) => Change::ReturnUndefined,
670            None => Change::Skip,
671        },
672        JsStatement::VariableDeclaration(d) => {
673            if let [only] = d.declarations.as_slice()
674                && matches!(only.init, Some(Expression::BooleanLiteral(_)))
675            {
676                Change::Flip
677            } else if d.declarations.iter().any(|d| d.init.is_some()) {
678                Change::ValueUndefined
679            } else {
680                return None;
681            }
682        }
683        // A constructor cannot skip `super(...)`: the result does not compile
684        // (TS2377), and in JavaScript it throws before anything is observed.
685        JsStatement::ExpressionStatement(e)
686            if matches!(&e.expression, Expression::CallExpression(call)
687                if matches!(call.callee, Expression::Super(_))) =>
688        {
689            return None;
690        }
691        JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
692            Some(Declaration::VariableDeclaration(d))
693                if d.declarations.iter().any(|d| d.init.is_some()) =>
694            {
695                Change::ValueUndefined
696            }
697            _ => return None,
698        },
699        JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
700            ExportDefaultDeclarationKind::FunctionDeclaration(_)
701            | ExportDefaultDeclarationKind::ClassDeclaration(_)
702            | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
703            _ => Change::Skip,
704        },
705        JsStatement::ImportDeclaration(_)
706        | JsStatement::ExportAllDeclaration(_)
707        | JsStatement::FunctionDeclaration(_)
708        | JsStatement::ClassDeclaration(_)
709        | JsStatement::TSTypeAliasDeclaration(_)
710        | JsStatement::TSInterfaceDeclaration(_)
711        | JsStatement::TSEnumDeclaration(_)
712        | JsStatement::TSModuleDeclaration(_)
713        | JsStatement::TSGlobalDeclaration(_)
714        | JsStatement::TSImportEqualsDeclaration(_)
715        | JsStatement::TSExportAssignment(_)
716        | JsStatement::TSNamespaceExportDeclaration(_) => return None,
717        _ => Change::Skip,
718    })
719}
720
721/// The change for the measured statement on `line` whose text is `text`: the
722/// first statement starting there with that text, or an expression point when
723/// no statement starts there (a JSX expression is measured but not a statement).
724fn change_at(
725    starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
726    line: usize,
727    text: &str,
728    language: Language,
729) -> Option<Change> {
730    let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
731    let found = starts.get(&line).and_then(|all| {
732        all.iter().find(|(t, _)| {
733            let bare = t
734                .strip_prefix("export ")
735                .map(|t| t.strip_prefix("default ").unwrap_or(t))
736                .unwrap_or(t);
737            bare.starts_with(first) || t.starts_with(first)
738        })
739    });
740    match (found, language) {
741        (Some((_, change)), _) => *change,
742        (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
743        // Elsewhere every statement is its own point; one no statement
744        // matches is not assessed rather than guessed at.
745        (None, _) => None,
746    }
747}
748
749/// Every Python statement starting on each line, outermost first, with its
750/// text and change: `if` inverted, `return x` returning None, an assignment's
751/// value becoming None, anything else skipped; imports, definitions, `pass`,
752/// `global`/`nonlocal` and bare strings (docstrings) are not assessed.
753fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
754    use ruff_python_ast::{
755        Expr, Stmt,
756        visitor::{Visitor, walk_stmt},
757    };
758    use ruff_text_size::Ranged;
759    struct Collector<'s> {
760        source: &'s str,
761        starts: Vec<usize>,
762        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
763    }
764    impl<'a> Visitor<'a> for Collector<'_> {
765        fn visit_stmt(&mut self, stmt: &'a Stmt) {
766            let change = match stmt {
767                Stmt::If(_) => Some(Change::Invert),
768                Stmt::Return(r) => Some(match r.value.as_deref() {
769                    // `return None` returning None is the same code.
770                    Some(Expr::NoneLiteral(_)) | None => Change::Skip,
771                    Some(Expr::BooleanLiteral(_)) => Change::Flip,
772                    Some(_) => Change::ReturnUndefined,
773                }),
774                Stmt::Assign(a)
775                    if a.targets.len() == 1
776                        && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
777                {
778                    Some(Change::Flip)
779                }
780                Stmt::Assign(_) => Some(Change::ValueUndefined),
781                Stmt::AnnAssign(a)
782                    if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
783                {
784                    Some(Change::Flip)
785                }
786                Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
787                Stmt::Import(_)
788                | Stmt::ImportFrom(_)
789                | Stmt::FunctionDef(_)
790                | Stmt::ClassDef(_)
791                | Stmt::Pass(_)
792                | Stmt::Global(_)
793                | Stmt::Nonlocal(_)
794                | Stmt::TypeAlias(_)
795                | Stmt::IpyEscapeCommand(_) => None,
796                Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
797                _ => Some(Change::Skip),
798            };
799            let range = stmt.range();
800            let (start, end) = (range.start().to_usize(), range.end().to_usize());
801            let line = line_of(&self.starts, start);
802            let text = self.source.get(start..end).unwrap_or("").to_owned();
803            self.out.entry(line).or_default().push((text, change));
804            // An `elif` is an `if` of its own, as JavaScript's `else if` is.
805            if let Stmt::If(s) = stmt {
806                // Its point is its condition, as coverage records it.
807                for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
808                    let start = test.range().start().to_usize();
809                    let text = self.source.get(start..test.range().end().to_usize());
810                    self.out
811                        .entry(line_of(&self.starts, start))
812                        .or_default()
813                        .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
814                }
815            }
816            walk_stmt(self, stmt);
817        }
818    }
819    let Ok(parsed) = ruff_python_parser::parse_module(source) else {
820        return BTreeMap::new();
821    };
822    let mut collector = Collector {
823        source,
824        starts: line_starts(source),
825        out: BTreeMap::new(),
826    };
827    for stmt in &parsed.syntax().body {
828        collector.visit_stmt(stmt);
829    }
830    collector.out
831}
832
833/// The line a JavaScript test is declared on. Each group in its name
834/// (`cart > totals`) narrows the search to that group's block, since groups
835/// often hold tests of the same title; a title written as a string is
836/// preferred over the same words anywhere else.
837fn js_test_line(lines: &[&str], name: &str) -> Option<usize> {
838    let parts = name.split(" > ").collect::<Vec<_>>();
839    let (title, groups) = parts.split_last()?;
840    let quoted = |line: &str, text: &str| {
841        ['\'', '"', '`']
842            .iter()
843            .any(|quote| line.contains(&format!("{quote}{text}")))
844    };
845    let (mut from, mut to) = (0, lines.len());
846    for group in groups {
847        let needle = prefix(group, 40);
848        if needle.is_empty() {
849            continue;
850        }
851        if let Some(at) = (from..to).find(|&i| quoted(lines[i], needle)) {
852            (from, to) = (at, (block_end(lines, at) + 1).min(lines.len()));
853        }
854    }
855    let needle = prefix(title, 40);
856    if needle.is_empty() {
857        return None;
858    }
859    (from..to)
860        .find(|&i| quoted(lines[i], needle))
861        .or_else(|| (from..to).find(|&i| lines[i].contains(needle)))
862        .or_else(|| (0..lines.len()).find(|&i| lines[i].contains(needle)))
863}
864
865fn prefix(text: &str, chars: usize) -> &str {
866    match text.char_indices().nth(chars) {
867        Some((i, _)) => &text[..i],
868        None => text,
869    }
870}
871
872/// The lines of each function that lies in no other, with the statement that
873/// declares it (`export const seal = (key) => {`) and its decorators. A
874/// callback written inside a function belongs to it. First and last line,
875/// from one.
876fn outermost_functions(language: Language, file: &str, source: &str) -> Vec<(usize, usize)> {
877    let starts = line_starts(source);
878    let lines = |from: usize, to: usize| {
879        (
880            line_of(&starts, from),
881            line_of(&starts, to.saturating_sub(1).max(from)),
882        )
883    };
884    let mut spans = match language {
885        Language::JavaScript => {
886            let allocator = Allocator::default();
887            let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
888            let parsed = Parser::new(&allocator, source, source_type).parse();
889            #[derive(Default)]
890            struct Functions {
891                // The statement, or the member of a class, a function is
892                // written in: what stands directly in the file, a block or a
893                // class body.
894                within: Option<(u32, u32)>,
895                found: Vec<(u32, u32)>,
896            }
897            impl Functions {
898                fn inside(&mut self, span: (u32, u32), walk: impl FnOnce(&mut Self)) {
899                    let outer = self.within.replace(span);
900                    walk(self);
901                    self.within = outer;
902                }
903                fn function(&mut self, own: oxc_span::Span) {
904                    self.found.push(self.within.unwrap_or((own.start, own.end)));
905                }
906            }
907            fn from_decorators(
908                span: oxc_span::Span,
909                decorators: &[oxc_ast::ast::Decorator<'_>],
910            ) -> (u32, u32) {
911                (
912                    decorators
913                        .first()
914                        .map_or(span.start, |first| first.span.start.min(span.start)),
915                    span.end,
916                )
917            }
918            impl<'a> Visit<'a> for Functions {
919                fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) {
920                    for statement in &program.body {
921                        let span = statement.span();
922                        self.inside((span.start, span.end), |this| {
923                            this.visit_statement(statement)
924                        });
925                    }
926                }
927                fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) {
928                    for statement in &block.body {
929                        let span = statement.span();
930                        self.inside((span.start, span.end), |this| {
931                            this.visit_statement(statement)
932                        });
933                    }
934                }
935                fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) {
936                    for statement in &block.body {
937                        let span = statement.span();
938                        self.inside((span.start, span.end), |this| {
939                            this.visit_statement(statement)
940                        });
941                    }
942                }
943                fn visit_method_definition(&mut self, it: &oxc_ast::ast::MethodDefinition<'a>) {
944                    self.inside(from_decorators(it.span, &it.decorators), |this| {
945                        walk::walk_method_definition(this, it)
946                    });
947                }
948                fn visit_property_definition(&mut self, it: &oxc_ast::ast::PropertyDefinition<'a>) {
949                    self.inside(from_decorators(it.span, &it.decorators), |this| {
950                        walk::walk_property_definition(this, it)
951                    });
952                }
953                fn visit_accessor_property(&mut self, it: &oxc_ast::ast::AccessorProperty<'a>) {
954                    self.inside(from_decorators(it.span, &it.decorators), |this| {
955                        walk::walk_accessor_property(this, it)
956                    });
957                }
958                fn visit_static_block(&mut self, it: &oxc_ast::ast::StaticBlock<'a>) {
959                    self.inside((it.span.start, it.span.end), |this| {
960                        walk::walk_static_block(this, it)
961                    });
962                }
963                // Not walked into: what is inside a function is the function's.
964                fn visit_function(
965                    &mut self,
966                    it: &oxc_ast::ast::Function<'a>,
967                    _flags: oxc_syntax::scope::ScopeFlags,
968                ) {
969                    if it.body.is_some() {
970                        self.function(it.span);
971                    }
972                }
973                fn visit_arrow_function_expression(
974                    &mut self,
975                    it: &oxc_ast::ast::ArrowFunctionExpression<'a>,
976                ) {
977                    self.function(it.span);
978                }
979            }
980            let mut functions = Functions::default();
981            functions.visit_program(&parsed.program);
982            functions
983                .found
984                .into_iter()
985                .map(|(from, to)| lines(from as usize, to as usize))
986                .collect::<Vec<_>>()
987        }
988        Language::Python => {
989            use ruff_python_ast::{
990                Stmt,
991                visitor::{Visitor, walk_stmt},
992            };
993            use ruff_text_size::Ranged;
994            struct Functions(Vec<(usize, usize)>);
995            impl<'a> Visitor<'a> for Functions {
996                fn visit_stmt(&mut self, stmt: &'a Stmt) {
997                    match stmt {
998                        Stmt::FunctionDef(function) => {
999                            let from = function
1000                                .decorator_list
1001                                .first()
1002                                .map_or(function.start(), |first| {
1003                                    first.start().min(function.start())
1004                                });
1005                            self.0.push((from.to_usize(), function.end().to_usize()));
1006                        }
1007                        _ => walk_stmt(self, stmt),
1008                    }
1009                }
1010            }
1011            let Ok(parsed) = ruff_python_parser::parse_module(source) else {
1012                return Vec::new();
1013            };
1014            let mut functions = Functions(Vec::new());
1015            for stmt in &parsed.syntax().body {
1016                functions.visit_stmt(stmt);
1017            }
1018            functions
1019                .0
1020                .into_iter()
1021                .map(|(from, to)| lines(from, to))
1022                .collect()
1023        }
1024        _ => Vec::new(),
1025    };
1026    spans.sort_unstable();
1027    spans.dedup();
1028    spans
1029}
1030
1031/// How many lines a deleted statement is named by, when its question names
1032/// all of them ("all 4 lines of it (lines 133 to 136)").
1033fn whole_statement(statement: &Statement) -> Option<usize> {
1034    let lines = statement.text.lines().count();
1035    let word = statement
1036        .text
1037        .lines()
1038        .next()
1039        .unwrap_or("")
1040        .trim()
1041        .trim_end_matches(';')
1042        .trim();
1043    (statement.change == Change::Skip
1044        && lines > 1
1045        && !matches!(word, "break" | "continue" | "next"))
1046    .then_some(lines)
1047}
1048
1049fn line_starts(source: &str) -> Vec<usize> {
1050    std::iter::once(0)
1051        .chain(source.match_indices('\n').map(|(i, _)| i + 1))
1052        .collect()
1053}
1054
1055fn line_of(starts: &[usize], offset: usize) -> usize {
1056    starts.partition_point(|&s| s <= offset)
1057}
1058
1059fn numbered(lines: &[&str]) -> String {
1060    lines
1061        .iter()
1062        .enumerate()
1063        .map(|(i, l)| format!("{:5} {l}", i + 1))
1064        .collect::<Vec<_>>()
1065        .join("\n")
1066}
1067
1068/// The text a statement writes out: the fixed parts of its string literals,
1069/// six characters or longer with a letter in them (`- Headers:` from
1070/// `` `  - Headers: ${describe(headers)}` ``). A test that quotes one is the
1071/// likeliest to assert what the statement produces.
1072fn quoted_fragments(text: &str) -> Vec<String> {
1073    let mut out = Vec::new();
1074    let mut chars = text.char_indices().peekable();
1075    while let Some((start, quote)) = chars.next() {
1076        if !matches!(quote, '\'' | '"' | '`') {
1077            continue;
1078        }
1079        let mut end = None;
1080        while let Some((at, c)) = chars.next() {
1081            if c == '\\' {
1082                chars.next();
1083            } else if c == quote {
1084                end = Some(at);
1085                break;
1086            }
1087        }
1088        let Some(end) = end else { break };
1089        let body = &text[start + quote.len_utf8()..end];
1090        // The fixed text only: what sits inside `${...}`, `#{...}` or an
1091        // f-string's `{...}` is an expression, not output.
1092        let mut pieces = vec![String::new()];
1093        let mut depth = 0usize;
1094        for c in body.chars() {
1095            match c {
1096                '{' => {
1097                    depth += 1;
1098                    pieces.push(String::new());
1099                }
1100                '}' if depth > 0 => depth -= 1,
1101                '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
1102                _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
1103                _ => {}
1104            }
1105        }
1106        for piece in pieces {
1107            let piece = piece.trim();
1108            if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
1109                out.push(piece.to_owned());
1110            }
1111        }
1112    }
1113    out.sort();
1114    out.dedup();
1115    out
1116}
1117
1118fn stem(path: &str) -> String {
1119    Path::new(path)
1120        .file_name()
1121        .and_then(|n| n.to_str())
1122        .unwrap_or("")
1123        .split('.')
1124        .next()
1125        .unwrap_or("")
1126        .to_lowercase()
1127}
1128
1129fn describe(name: &str, separator: &str) -> String {
1130    let parts = name.split(separator).collect::<Vec<_>>();
1131    parts[..parts.len().saturating_sub(1)].join(separator)
1132}
1133
1134impl Population {
1135    /// Read these files from here rather than the checkout: a run's sources
1136    /// as they were assessed, once the checkout has moved on.
1137    pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
1138        {
1139            let mut cache = self
1140                .files
1141                .lock()
1142                .expect("the file cache is only read and filled");
1143            for (file, text) in files {
1144                cache.insert(file, Some(text.into()));
1145            }
1146        }
1147        self
1148    }
1149
1150    /// Every file read so far, as it was read.
1151    pub fn files_read(&self) -> BTreeMap<String, String> {
1152        self.files
1153            .lock()
1154            .expect("the file cache is only read and filled")
1155            .iter()
1156            .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
1157            .collect()
1158    }
1159
1160    fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
1161        let mut files = self
1162            .files
1163            .lock()
1164            .expect("the file cache is only read and filled");
1165        files
1166            .entry(file.to_owned())
1167            .or_insert_with(|| {
1168                std::fs::read_to_string(self.root.join(file))
1169                    .ok()
1170                    .map(Into::into)
1171            })
1172            .clone()
1173    }
1174
1175    /// Whether `test`'s file contains one of these fragments.
1176    fn quotes(&self, test: usize, fragments: &[String]) -> bool {
1177        let file = &self.tests[test].file;
1178        fragments.iter().any(|fragment| {
1179            let key = (file.clone(), fragment.clone());
1180            if let Some(&known) = self
1181                .quotes
1182                .lock()
1183                .expect("the quote cache is only read and filled")
1184                .get(&key)
1185            {
1186                return known;
1187            }
1188            let found = self
1189                .read(file)
1190                .is_some_and(|text| text.contains(fragment.as_str()));
1191            self.quotes
1192                .lock()
1193                .expect("the quote cache is only read and filled")
1194                .insert(key, found);
1195            found
1196        })
1197    }
1198
1199    /// The tests to ask about a statement, in order: tests whose file quotes
1200    /// text the statement produces first, one per file; then tests in a file
1201    /// named like the source; then one per describe block, round-robin over
1202    /// files. Without the first rule, the alphabetically first files filled
1203    /// the sample: on supergateway 15 of 179 tests were asked about a log
1204    /// line, none of them the test that compares the whole startup log.
1205    pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
1206        let s = &self.statements[statement];
1207        let base = stem(&s.file);
1208        let key = |&t: &usize| {
1209            let test = &self.tests[t];
1210            (
1211                !Path::new(&test.file)
1212                    .file_name()
1213                    .and_then(|n| n.to_str())
1214                    .unwrap_or("")
1215                    .to_lowercase()
1216                    .contains(&base),
1217                test.file.clone(),
1218                test.name.clone(),
1219            )
1220        };
1221        let mut all = s.tests.clone();
1222        all.sort_by_key(key);
1223        if all.len() <= k {
1224            return all;
1225        }
1226        let mut groups = Vec::<((String, String), Vec<usize>)>::new();
1227        for &t in &all {
1228            let g = (
1229                self.tests[t].file.clone(),
1230                describe(
1231                    &self.tests[t].name,
1232                    separator(self.language, &self.tests[t].runner),
1233                ),
1234            );
1235            match groups.iter_mut().find(|(k, _)| *k == g) {
1236                Some((_, v)) => v.push(t),
1237                None => groups.push((g, vec![t])),
1238            }
1239        }
1240        let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
1241        for ((file, _), members) in groups {
1242            match by_file.iter_mut().find(|(f, _)| *f == file) {
1243                Some((_, v)) => v.push(members),
1244                None => by_file.push((file, vec![members])),
1245            }
1246        }
1247        by_file.sort_by_key(|(f, _)| {
1248            (
1249                !Path::new(f)
1250                    .file_name()
1251                    .and_then(|n| n.to_str())
1252                    .unwrap_or("")
1253                    .to_lowercase()
1254                    .contains(&base),
1255                f.clone(),
1256            )
1257        });
1258        let mut picked = Vec::new();
1259        let fragments = quoted_fragments(&s.text);
1260        if !fragments.is_empty() {
1261            let quoting = all
1262                .iter()
1263                .copied()
1264                .filter(|&t| self.quotes(t, &fragments))
1265                .collect::<Vec<_>>();
1266            let mut files = BTreeSet::new();
1267            let (first, rest): (Vec<usize>, Vec<usize>) = quoting
1268                .iter()
1269                .partition(|&&t| files.insert(self.tests[t].file.clone()));
1270            for t in first.into_iter().chain(rest) {
1271                if picked.len() < k {
1272                    picked.push(t);
1273                }
1274            }
1275        }
1276        let mut depth = 0;
1277        while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
1278            for (_, g) in &by_file {
1279                if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
1280                    picked.push(g[depth][0]);
1281                }
1282            }
1283            depth += 1;
1284        }
1285        for t in all {
1286            if picked.len() >= k {
1287                break;
1288            }
1289            if !picked.contains(&t) {
1290                picked.push(t);
1291            }
1292        }
1293        picked
1294    }
1295
1296    /// The tests to ask last, when none of `asked` was judged to fail: of the
1297    /// rest, those whose name shares the most words with the statement's
1298    /// line, and among those the tests that ran the least code.
1299    ///
1300    /// A statement hundreds of tests run is mostly run in passing, by
1301    /// end-to-end tests on their way somewhere else. The tests written for it
1302    /// tend to say so in their name (`corsOrigin: a false value` for
1303    /// `return corsOrigin(...)`) and to run little besides it. Asking a few
1304    /// more in the usual order found few of them; replacing some of the usual
1305    /// tests with these lost statements the usual tests catch.
1306    pub fn last_order(&self, statement: usize, asked: &[usize], k: usize) -> Vec<usize> {
1307        let spread = self.order(statement, MORE_TESTS);
1308        let position = |t: &usize| spread.iter().position(|x| x == t).unwrap_or(usize::MAX);
1309        let s = &self.statements[statement];
1310        let line = name_words(s.text.lines().next().unwrap_or(""));
1311        let shared = |t: usize| {
1312            let name = &self.tests[t].name;
1313            let name = name.rsplit("::").next().unwrap_or(name);
1314            name_words(name).intersection(&line).count()
1315        };
1316        let mut rest = s
1317            .tests
1318            .iter()
1319            .copied()
1320            .filter(|t| !asked.contains(t))
1321            .collect::<Vec<_>>();
1322        rest.sort_by_cached_key(|t| {
1323            (
1324                std::cmp::Reverse(shared(*t)),
1325                self.breadth(*t),
1326                position(t),
1327                *t,
1328            )
1329        });
1330        rest.truncate(k);
1331        rest
1332    }
1333
1334    /// How many statement lines a test ran.
1335    fn breadth(&self, test: usize) -> usize {
1336        self.ran[test].values().map(BTreeSet::len).sum()
1337    }
1338
1339    /// The test's code: the file's first lines and the test's own body, found
1340    /// by its title, comment lines left out.
1341    pub fn test_code(&self, test: usize) -> String {
1342        match self.language {
1343            Language::Python => return self.python_test_code(test),
1344            Language::JavaScript => {}
1345            _ => return self.located_test_code(test),
1346        }
1347        let t = &self.tests[test];
1348        let text = self.read(&t.file).unwrap_or_default();
1349        let lines = text.split('\n').collect::<Vec<_>>();
1350        let start = js_test_line(&lines, &t.name);
1351        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1352        let head = (0..lines.len().min(HEAD_LINES))
1353            .filter(|&i| !self.language.comment(lines[i]))
1354            .map(fmt)
1355            .collect::<Vec<_>>();
1356        let Some(start) = start else {
1357            let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
1358            return head
1359                .into_iter()
1360                .chain(std::iter::once("  ...".to_owned()))
1361                .chain(rest)
1362                .collect::<Vec<_>>()
1363                .join("\n");
1364        };
1365        let indent = lines[start].len() - lines[start].trim_start().len();
1366        let mut body = Vec::new();
1367        for (i, l) in lines
1368            .iter()
1369            .copied()
1370            .enumerate()
1371            .take(start + BODY_LINES)
1372            .skip(start)
1373        {
1374            if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1375                break;
1376            }
1377            if !self.language.comment(l) {
1378                body.push(fmt(i));
1379            }
1380        }
1381        let mut out = head;
1382        if start > HEAD_LINES {
1383            out.push("  ...".into());
1384        }
1385        out.extend(body);
1386        out.join("\n")
1387    }
1388
1389    /// A Ruby, Go, Rust or JVM test's code: the file's first lines, the lines
1390    /// that frame it (its class or groups, its annotations), and its body.
1391    fn located_test_code(&self, test: usize) -> String {
1392        let t = &self.tests[test];
1393        let text = self.read(&t.file).unwrap_or_default();
1394        let lines = text.split('\n').collect::<Vec<_>>();
1395        let located = match self.language {
1396            Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1397            Language::Go => go::locate(&lines, &t.name),
1398            Language::Rust => rust_source::locate(&lines, &t.name),
1399            _ => jvm::locate(&lines, &t.name),
1400        };
1401        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1402        // Lines inside `/* ... */` (a license header, say) are comments too.
1403        let mut open = false;
1404        let commented = lines
1405            .iter()
1406            .map(|l| {
1407                let t = l.trim();
1408                let was = open;
1409                if self.language != Language::Ruby {
1410                    if !open && t.starts_with("/*") {
1411                        open = true;
1412                    }
1413                    if open && t.contains("*/") {
1414                        open = false;
1415                        return true;
1416                    }
1417                }
1418                was || open || self.language.comment(l)
1419            })
1420            .collect::<Vec<_>>();
1421        // The head: the file's first code lines (its package and imports).
1422        let head = (0..lines.len())
1423            .filter(|&i| !commented[i])
1424            .take(HEAD_LINES)
1425            .collect::<Vec<_>>();
1426        let head_end = head.last().map_or(0, |&i| i + 1);
1427        let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1428        let Some(located) = located else {
1429            out.push("  ...".into());
1430            out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1431            return out.join("\n");
1432        };
1433        let end = located
1434            .end
1435            .min(located.start + BODY_LINES)
1436            .min(lines.len().saturating_sub(1));
1437        let mut last = head_end;
1438        let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1439        shown.extend(located.start..=end);
1440        // What the test uses from its own file: a table it loops over, a
1441        // helper it calls, a `let` it reads.
1442        shown.extend(used_definitions(&lines, self.language, located.start, end));
1443        for i in shown {
1444            // A doctest lives in comments: its lines are the test.
1445            if i < last || (commented[i] && self.language != Language::Rust) {
1446                continue;
1447            }
1448            if i > last {
1449                out.push("  ...".into());
1450            }
1451            out.push(fmt(i));
1452            last = i + 1;
1453        }
1454        out.join("\n")
1455    }
1456
1457    /// A Python test's code: the file's first lines, the class holding the
1458    /// test, its decorators, and its body, found by the test function's name
1459    /// and ended by indentation.
1460    fn python_test_code(&self, test: usize) -> String {
1461        let t = &self.tests[test];
1462        let text = self.read(&t.file).unwrap_or_default();
1463        let lines = text.split('\n').collect::<Vec<_>>();
1464        let title = python_title(&t.name);
1465        let indent_of = |l: &str| l.len() - l.trim_start().len();
1466        // Test classes in one file often share method names, so the method
1467        // is looked for inside the class its id names, then anywhere.
1468        let (from, to, body) = python_class_lines(&lines, &python_classes(&t.name));
1469        let defines = |i: &usize| {
1470            let t = lines[*i].trim_start();
1471            t.starts_with(&format!("def {title}(")) || t.starts_with(&format!("async def {title}("))
1472        };
1473        // In the class's own body, not a class nested in it.
1474        let own = |i: &usize| defines(i) && body.is_none_or(|body| indent_of(lines[*i]) == body);
1475        let start = (!title.is_empty())
1476            .then(|| {
1477                (from..to)
1478                    .find(own)
1479                    .or_else(|| (0..lines.len()).find(defines))
1480            })
1481            .flatten();
1482        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1483        let mut out = (0..lines.len().min(HEAD_LINES))
1484            .filter(|&i| !self.language.comment(lines[i]))
1485            .map(fmt)
1486            .collect::<Vec<_>>();
1487        let Some(start) = start else {
1488            out.push("  ...".into());
1489            out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1490            return out.join("\n");
1491        };
1492        let indent = indent_of(lines[start]);
1493        let mut first = start;
1494        while first > 0
1495            && lines[first - 1].trim_start().starts_with('@')
1496            && indent_of(lines[first - 1]) == indent
1497        {
1498            first -= 1;
1499        }
1500        let class = (indent > 0)
1501            .then(|| {
1502                (0..first).rev().find(|&i| {
1503                    lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1504                })
1505            })
1506            .flatten();
1507        // `...` where lines are left out between the ones shown.
1508        let mut last = HEAD_LINES.min(lines.len()).checked_sub(1);
1509        let mut show = |out: &mut Vec<String>, i: usize| {
1510            if i >= HEAD_LINES {
1511                if last.is_some_and(|l| i > l + 1) {
1512                    out.push("  ...".into());
1513                }
1514                out.push(fmt(i));
1515                last = Some(i);
1516            }
1517        };
1518        for i in class.into_iter().chain(first..=start) {
1519            show(&mut out, i);
1520        }
1521        for (i, &l) in lines
1522            .iter()
1523            .enumerate()
1524            .skip(start + 1)
1525            .take(BODY_LINES - 1)
1526        {
1527            if !l.trim().is_empty() && indent_of(l) <= indent {
1528                break;
1529            }
1530            // A comment in the body is left out without a mark.
1531            if !self.language.comment(l) && i >= HEAD_LINES {
1532                out.push(fmt(i));
1533            }
1534        }
1535        out.join("\n")
1536    }
1537
1538    /// Test-side files the test imports (helpers, fixtures), cut to a budget
1539    /// that grows with the number of questions. None for JavaScript,
1540    /// TypeScript and Python (see `asks_within_the_function`): on
1541    /// supergateway they were a sixth of what was sent and changed 4 verdicts
1542    /// in 101, two each way.
1543    pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1544        if self.language.asks_within_the_function() {
1545            return BTreeMap::new();
1546        }
1547        let cap = HELPER_CHARS.min(1500 + 400 * questions);
1548        let t = &self.tests[test];
1549        let text = self.read(&t.file).unwrap_or_default();
1550        let mut out = BTreeMap::new();
1551        let mut size = 0;
1552        let files = match self.language {
1553            Language::JavaScript | Language::Python => Vec::new(),
1554            Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1555            Language::Go => go::helpers(&self.root, &t.file, &text),
1556            Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1557            Language::Jvm => jvm::imports(&text)
1558                .iter()
1559                .filter_map(|import| {
1560                    jvm::class_paths(&import.replace('/', "."))
1561                        .iter()
1562                        .find_map(|suffix| {
1563                            self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1564                        })
1565                        .cloned()
1566                })
1567                .collect(),
1568        };
1569        for file in files {
1570            if out.contains_key(&file) {
1571                continue;
1572            }
1573            if self.source_files.contains(&file) || file.contains("node_modules") {
1574                continue;
1575            }
1576            let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1577            if size + body.len() > cap {
1578                let keep = cap.saturating_sub(size);
1579                let cut = body
1580                    .char_indices()
1581                    .map(|(i, _)| i)
1582                    .take_while(|&i| i <= keep)
1583                    .last()
1584                    .unwrap_or(0);
1585                body = format!("{}\n... (cut)", &body[..cut]);
1586            }
1587            size += body.len();
1588            out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1589            if size >= cap {
1590                break;
1591            }
1592        }
1593        if self.language == Language::Ruby {
1594            let mut used = 0;
1595            for file in ruby::support_files(&self.root, &text) {
1596                if out.contains_key(&file) || used >= FIXTURE_CHARS {
1597                    continue;
1598                }
1599                let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1600                if used + body.len() > FIXTURE_CHARS {
1601                    let keep = FIXTURE_CHARS - used;
1602                    let cut = body
1603                        .char_indices()
1604                        .map(|(i, _)| i)
1605                        .take_while(|&i| i <= keep)
1606                        .last()
1607                        .unwrap_or(0);
1608                    body = format!("{}\n... (cut)", &body[..cut]);
1609                }
1610                used += body.len();
1611                out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1612            }
1613        }
1614        out
1615    }
1616
1617    /// The source this test ran: every statement it executed marked ▶, four
1618    /// code lines around each asked statement, comment lines left out, within
1619    /// a budget that grows with the number of questions. Two lines around
1620    /// every executed statement and eight around each asked one, in a budget
1621    /// four times this, cost 28% more over 3,532 checked statements in 14
1622    /// projects and were no more accurate.
1623    ///
1624    /// In JavaScript, TypeScript and Python that is cut to the function each
1625    /// asked statement is in (see `asks_within_the_function`): what the test
1626    /// ran elsewhere in the file, and in other files, is left out.
1627    pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1628        let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1629        let within = self.language.asks_within_the_function();
1630        let ran = &self.ran[test];
1631        let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1632        for &a in asked {
1633            want.entry(self.statements[a].file.clone())
1634                .or_default()
1635                .insert(self.statements[a].line);
1636        }
1637        let mut files = want.keys().cloned().collect::<Vec<_>>();
1638        let mut others = ran
1639            .iter()
1640            .filter(|(f, _)| !within && !want.contains_key(*f))
1641            .collect::<Vec<_>>();
1642        others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1643        files.extend(others.into_iter().map(|(f, _)| f.clone()));
1644        let empty = BTreeSet::new();
1645        let mut out = BTreeMap::new();
1646        let mut total = 0;
1647        for f in files {
1648            let Some(source) = self.read(&f) else {
1649                continue;
1650            };
1651            let text = source.split('\n').collect::<Vec<_>>();
1652            let r = ran.get(&f).unwrap_or(&empty);
1653            let code = (1..=text.len())
1654                .filter(|&i| !self.language.comment(text[i - 1]))
1655                .collect::<Vec<_>>();
1656            let pos = code
1657                .iter()
1658                .enumerate()
1659                .map(|(n, &i)| (i, n))
1660                .collect::<BTreeMap<_, _>>();
1661            let around = |l: usize, w: usize| -> Vec<usize> {
1662                match pos.get(&l) {
1663                    Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1664                    None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1665                }
1666            };
1667            let mut show = BTreeSet::new();
1668            for &l in r {
1669                show.extend(around(l, CONTEXT_LINES));
1670            }
1671            for &l in want.get(&f).unwrap_or(&empty) {
1672                show.extend(around(l, WINDOW_LINES));
1673            }
1674            // How far the file was going to be shown, for the line that says
1675            // how much of it is not.
1676            let end = show
1677                .iter()
1678                .rev()
1679                .copied()
1680                .find(|&i| i != 0 && i <= text.len() && !self.language.comment(text[i - 1]))
1681                .unwrap_or(0);
1682            if within {
1683                let functions = outermost_functions(self.language, &f, &source);
1684                let around =
1685                    |l: usize| (l.saturating_sub(OUTSIDE_A_FUNCTION), l + OUTSIDE_A_FUNCTION);
1686                let function = |l: usize| {
1687                    functions
1688                        .iter()
1689                        .copied()
1690                        .find(|&(from, to)| from <= l && l <= to)
1691                };
1692                // The lines an assessed statement of this file begins on.
1693                let begins = self
1694                    .statements
1695                    .iter()
1696                    .filter(|s| s.file == f)
1697                    .map(|s| s.line)
1698                    .collect::<BTreeSet<_>>();
1699                let mut kept = Vec::new();
1700                for statement in asked.iter().map(|&a| &self.statements[a]) {
1701                    if statement.file != f {
1702                        continue;
1703                    }
1704                    kept.push(function(statement.line).unwrap_or(around(statement.line)));
1705                    // A statement its question names by all its lines also
1706                    // keeps what lies near each line it goes on to, as the
1707                    // requests this was measured with did.
1708                    for l in (statement.line + 1)
1709                        ..statement.line + whole_statement(statement).unwrap_or(1)
1710                    {
1711                        kept.push(
1712                            begins
1713                                .contains(&l)
1714                                .then(|| function(l))
1715                                .flatten()
1716                                .unwrap_or(around(l)),
1717                        );
1718                    }
1719                }
1720                show.retain(|i| kept.iter().any(|&(from, to)| from <= *i && *i <= to));
1721            }
1722            let mut lines = Vec::new();
1723            let mut last = 0;
1724            for i in show {
1725                if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1726                    continue;
1727                }
1728                if i != last + 1 {
1729                    lines.push(format!("        ... {} lines not shown", i - last - 1));
1730                }
1731                lines.push(format!(
1732                    "{}{:5} {}",
1733                    if r.contains(&i) { '▶' } else { ' ' },
1734                    i,
1735                    text[i - 1]
1736                ));
1737                last = i;
1738            }
1739            if within && last != 0 && end > last {
1740                lines.push(format!("        ... {} lines not shown", end - last));
1741            }
1742            let body = lines.join("\n");
1743            if !want.contains_key(&f) && total + body.len() > budget {
1744                continue;
1745            }
1746            total += body.len();
1747            out.insert(f, body);
1748        }
1749        out
1750    }
1751
1752    /// The questions for one statement: one per change (two for an `if`).
1753    pub fn questions(&self, statement: usize) -> Vec<String> {
1754        let s = &self.statements[statement];
1755        let line = s.text.lines().next().unwrap_or("").trim();
1756        let changes = match (s.change, &s.replacement) {
1757            (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1758                if self.language == Language::Rust =>
1759            {
1760                vec![rust_question(line, s.change, r)]
1761            }
1762            // The value is replaced after it is computed, as Go's mutation
1763            // testers do.
1764            (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1765                vec![format!(
1766                    "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1767                )]
1768            }
1769            (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1770                vec![format!(
1771                    "`{line}` becomes `{r}` (the original value is still computed first)"
1772                )]
1773            }
1774            _ => self.default_questions(s, line),
1775        };
1776        changes
1777            .into_iter()
1778            .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1779            .collect()
1780    }
1781
1782    fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1783        match s.change {
1784            // Go and the JVM languages replace a value after it is computed,
1785            // as their mutation testers do: the expression still runs.
1786            Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1787                "`{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)"
1788            )],
1789            // A `when`, `switch` or `try` ending a function returns from its
1790            // branches.
1791            Change::ReturnUndefined
1792                if self.language == Language::Jvm
1793                    && s.text.contains("return")
1794                    && ["when", "switch", "try"].iter().any(|k| {
1795                        line.strip_prefix(k)
1796                            .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1797                    }) =>
1798            {
1799                vec![format!(
1800                    "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1801                    self.language.nothing()
1802                )]
1803            }
1804            Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1805                "`{line}` returns {} instead (its expression still runs)",
1806                self.language.nothing()
1807            )],
1808            Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1809                let nothing = match self.language {
1810                    Language::Rust
1811                        if ["Ok(", "Err(", "return Ok(", "return Err("]
1812                            .iter()
1813                            .any(|p| line.starts_with(p)) =>
1814                    {
1815                        "`Ok(Default::default())`"
1816                    }
1817                    other => other.nothing(),
1818                };
1819                vec![if line.starts_with("return") {
1820                    format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1821                } else {
1822                    // Often a call made for what it does, not what it gives
1823                    // back: saying only "the value becomes nil" hides that.
1824                    format!(
1825                        "`{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}"
1826                    )
1827                }]
1828            }
1829            Change::ValueUndefined if self.language == Language::Go => vec![format!(
1830                "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1831            )],
1832            Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1833                "`{line}`: after it runs, the variable it assigns holds {} instead",
1834                self.language.nothing()
1835            )],
1836            Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1837                let nothing = self.language.nothing();
1838                vec![match split_initializer(line) {
1839                    Some(head) => format!(
1840                        "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1841                    ),
1842                    None => format!("`{line}`: the assigned value becomes {nothing}"),
1843                }]
1844            }
1845            Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1846                "`{line}` becomes `return None` (the expression is not evaluated)"
1847            )],
1848            Change::ReturnUndefined => vec![format!(
1849                "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1850            )],
1851            Change::ValueUndefined if self.language == Language::Python => {
1852                vec![match split_initializer(line) {
1853                    Some(head) => {
1854                        format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1855                    }
1856                    None => format!("`{line}`: the assigned value becomes None"),
1857                }]
1858            }
1859            Change::ValueUndefined => vec![match split_initializer(line) {
1860                Some(head) => format!(
1861                    "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1862                ),
1863                None => format!("`{line}`: the declared value becomes undefined"),
1864            }],
1865            Change::Skip => {
1866                let word = line.trim_end_matches(';').trim();
1867                vec![match word {
1868                    "break" => format!(
1869                        "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1870                    ),
1871                    "continue" | "next" => format!(
1872                        "`{line}` is deleted: the rest of the loop body runs in this iteration"
1873                    ),
1874                    // A statement of several lines is shown by its first.
1875                    // Read as "`for x in items:` is deleted", only the header
1876                    // seemed to go: 22 of 100 such verdicts were wrong on six
1877                    // projects, 9 with all of it named.
1878                    _ if whole_statement(s).is_some() => {
1879                        let lines = s.text.lines().count();
1880                        format!(
1881                            "the whole statement starting `{line}` is deleted, all {lines} lines of it (lines {} to {}): none of it runs",
1882                            s.line,
1883                            s.line + lines - 1
1884                        )
1885                    }
1886                    // Python: what deleting a return or a raise lets happen.
1887                    // Of the skipped returns and raises of five projects, 61
1888                    // of 371 verdicts were wrong as "is deleted" and 44 so.
1889                    // (TypeScript returns were judged worse this way, 21 of
1890                    // 82 against 18, and keep the plain wording.)
1891                    _ if self.language == Language::Python
1892                        && (word == "return" || word.starts_with("return ")) =>
1893                    {
1894                        format!(
1895                            "`{line}` is deleted: the function does not return here, and execution carries on with whatever follows it"
1896                        )
1897                    }
1898                    _ if self.language == Language::Python
1899                        && (word == "raise" || word.starts_with("raise ")) =>
1900                    {
1901                        format!(
1902                            "`{line}` is deleted: no error is raised here, and execution carries on with whatever follows it"
1903                        )
1904                    }
1905                    _ => format!("`{line}` is deleted (it never runs)"),
1906                }]
1907            }
1908            Change::ExpressionUndefined => vec![format!(
1909                "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1910            )],
1911            Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1912            Change::BareError => vec![format!(
1913                "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1914            )],
1915            Change::Invert => {
1916                let c = match self.language {
1917                    Language::Python => python_condition(line),
1918                    Language::JavaScript => condition(line),
1919                    Language::Ruby => ruby::condition(line),
1920                    Language::Go => go::condition(line),
1921                    Language::Rust => rust_source::condition(line),
1922                    Language::Jvm => jvm::condition(line),
1923                };
1924                // A pattern match (`if let`) can be made to fail, not to
1925                // succeed: its bindings would have no value.
1926                if c.starts_with("let ") {
1927                    return vec![format!(
1928                        "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1929                    )];
1930                }
1931                vec![
1932                    format!(
1933                        "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1934                    ),
1935                    format!(
1936                        "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1937                    ),
1938                ]
1939            }
1940        }
1941    }
1942
1943    /// The request for one test and the statements asked about it.
1944    pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1945        let t = &self.tests[test];
1946        let mut questions = serde_json::Map::new();
1947        for (n, &a) in asked.iter().enumerate() {
1948            for (j, q) in self.questions(a).into_iter().enumerate() {
1949                questions.insert(
1950                    format!("q{n}_{j}"),
1951                    json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1952                );
1953            }
1954        }
1955        let mut state = json!({
1956            "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1957            "test": {"file": t.file, "name": t.name},
1958            "test_code": self.test_code(test),
1959            "code_run": self.code_run(test, asked)});
1960        if !self.language.asks_within_the_function() {
1961            state["helpers"] = json!(self.helpers(test, asked.len()));
1962        }
1963        json!({"model": model, "state": state, "questions": questions})
1964    }
1965
1966    /// What Jev sees for one statement asked of one test, line numbers aside:
1967    /// the same code renumbered is the same question. Equal keys, equal question.
1968    /// `salt` carries what changes every answer at once: the run's dependency
1969    /// and configuration fingerprints.
1970    pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1971        let t = &self.tests[test];
1972        let parts = json!([
1973            model,
1974            salt,
1975            INSTRUCTIONS,
1976            runtime(&t.runner),
1977            true,
1978            &t.file,
1979            &t.name,
1980            strip_numbers(&self.test_code(test)),
1981            self.helpers(test, 1)
1982                .into_iter()
1983                .map(|(f, b)| (f, strip_numbers(&b)))
1984                .collect::<BTreeMap<_, _>>(),
1985            self.code_run(test, &[statement])
1986                .into_iter()
1987                .map(|(f, b)| (f, strip_numbers(&b)))
1988                .collect::<BTreeMap<_, _>>(),
1989            self.questions(statement)
1990                .iter()
1991                .map(|q| strip_location(q))
1992                .collect::<Vec<_>>(),
1993        ]);
1994        format!(
1995            "{:x}",
1996            Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1997        )
1998    }
1999}
2000
2001/// The line with its boolean literal turned into the other one: the last
2002/// `true`/`false` (`True`/`False` in Python) that stands as a word.
2003fn flipped(line: &str) -> String {
2004    // A trailing comment is not code: `let ready = false // true once open`
2005    // flipped the comment's `true`.
2006    let cut = comment_start(line);
2007    let (code, comment) = line.split_at(cut);
2008    format!("{}{comment}", flipped_code(code))
2009}
2010
2011/// Where a trailing `//` or `# ` comment starts, outside string literals; the
2012/// line's length when it has none. A bare `#` is code (a private field).
2013fn comment_start(line: &str) -> usize {
2014    let bytes = line.as_bytes();
2015    let mut quote = None;
2016    let mut i = 0;
2017    while i < bytes.len() {
2018        let c = bytes[i];
2019        match quote {
2020            Some(_) if c == b'\\' => i += 1,
2021            Some(q) if c == q => quote = None,
2022            Some(_) => {}
2023            None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
2024            None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
2025            None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
2026            None => {}
2027        }
2028        i += 1;
2029    }
2030    line.len()
2031}
2032
2033fn flipped_code(line: &str) -> String {
2034    let words = [
2035        ("true", "false"),
2036        ("false", "true"),
2037        ("True", "False"),
2038        ("False", "True"),
2039    ];
2040    let found = words
2041        .iter()
2042        .filter_map(|(from, to)| {
2043            line.match_indices(from)
2044                .filter(|(at, _)| {
2045                    let before = line[..*at].chars().next_back();
2046                    let after = line[at + from.len()..].chars().next();
2047                    let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
2048                    !word(before) && !word(after)
2049                })
2050                .last()
2051                .map(|(at, _)| (at, *from, *to))
2052        })
2053        .max_by_key(|(at, _, _)| *at);
2054    match found {
2055        Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
2056        None => line.to_owned(),
2057    }
2058}
2059
2060/// A Rust return or assignment's change, as the line it becomes.
2061fn rust_question(line: &str, change: Change, replacement: &str) -> String {
2062    let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
2063    match change {
2064        Change::ReturnUndefined if line.starts_with("return") => {
2065            format!("`{line}` becomes `return {repl};`")
2066        }
2067        Change::ReturnUndefined => {
2068            format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
2069        }
2070        _ => match split_initializer(line) {
2071            Some(head) => format!("`{line}` becomes `{head} {repl};`"),
2072            None => format!("`{line}`: the value it assigns becomes `{repl}`"),
2073        },
2074    }
2075}
2076
2077/// Write files' text, compressed, to `path`.
2078pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
2079    use std::io::Write;
2080    let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
2081    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2082    encoder.write_all(&bytes).map_err(|e| e.to_string())?;
2083    let compressed = encoder.finish().map_err(|e| e.to_string())?;
2084    let temp = path.with_extension("tmp");
2085    std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
2086    std::fs::rename(&temp, path).map_err(|e| e.to_string())
2087}
2088
2089/// Read files written by [`save_snapshot`].
2090pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
2091    use std::io::Read;
2092    let compressed = std::fs::read(path).ok()?;
2093    let mut text = Vec::new();
2094    flate2::read::GzDecoder::new(compressed.as_slice())
2095        .read_to_end(&mut text)
2096        .ok()?;
2097    serde_json::from_slice(&text).ok()
2098}
2099
2100/// Where a test sits in its file: its first line, its last, and lines that
2101/// frame it (its class, groups or annotations), all zero-based.
2102struct Located {
2103    context: Vec<usize>,
2104    start: usize,
2105    end: usize,
2106}
2107
2108fn indent(line: &str) -> usize {
2109    line.len() - line.trim_start().len()
2110}
2111
2112/// The line closing the braces opened from `start` on (strings, characters
2113/// and line comments aside).
2114fn brace_end(lines: &[&str], start: usize) -> usize {
2115    brace_end_within(lines, start, BODY_LINES)
2116}
2117
2118/// Where the block opened at `start` closes, however long it is: the region
2119/// a class, module or group holds, which is searched rather than shown.
2120fn block_end(lines: &[&str], start: usize) -> usize {
2121    brace_end_within(lines, start, lines.len())
2122}
2123
2124fn brace_end_within(lines: &[&str], start: usize, limit: usize) -> usize {
2125    let mut depth = 0i64;
2126    let mut opened = false;
2127    for (i, line) in lines.iter().enumerate().skip(start).take(limit) {
2128        let mut chars = line.chars().peekable();
2129        let mut quote: Option<char> = None;
2130        while let Some(c) = chars.next() {
2131            match (quote, c) {
2132                (Some(_), '\\') => {
2133                    chars.next();
2134                }
2135                (Some(q), c) if c == q => quote = None,
2136                (Some(_), _) => {}
2137                (None, '"' | '`') => quote = Some(c),
2138                (None, '/') if chars.peek() == Some(&'/') => break,
2139                (None, '{') => {
2140                    depth += 1;
2141                    opened = true;
2142                }
2143                (None, '}') => depth -= 1,
2144                _ => {}
2145            }
2146        }
2147        if opened && depth <= 0 {
2148            return i;
2149        }
2150    }
2151    start
2152        .saturating_add(limit)
2153        .min(lines.len())
2154        .saturating_sub(1)
2155}
2156
2157/// The `end` (or `}`) closing a Ruby block or method opened on `start`.
2158fn keyword_end(lines: &[&str], start: usize) -> usize {
2159    let first = lines[start].trim_end();
2160    if first.ends_with(" end") || first.ends_with('}') {
2161        return start;
2162    }
2163    let depth = indent(lines[start]);
2164    (start + 1..lines.len().min(start + BODY_LINES))
2165        .find(|&i| {
2166            let t = lines[i].trim_start();
2167            indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
2168        })
2169        .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
2170}
2171
2172const DEFINITION_LINES: usize = 120;
2173
2174/// The lines of the test file's own definitions that the test at
2175/// `start..=end` names, and those they name in turn, up to a budget.
2176fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
2177    let words = |range: std::ops::RangeInclusive<usize>| {
2178        let mut out = BTreeSet::new();
2179        for i in range {
2180            let line = lines.get(i).copied().unwrap_or("");
2181            out.extend(
2182                line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2183                    .filter(|w| !w.is_empty())
2184                    .map(str::to_owned),
2185            );
2186        }
2187        out
2188    };
2189    let definitions = definitions(lines, language);
2190    let mut wanted = words(start..=end);
2191    let mut picked = BTreeSet::new();
2192    let mut budget = DEFINITION_LINES;
2193    for _ in 0..2 {
2194        let mut next = BTreeSet::new();
2195        for (name, a, b) in &definitions {
2196            let (a, b) = (*a, *b);
2197            if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
2198                continue;
2199            }
2200            let size = b - a + 1;
2201            if size > budget {
2202                continue;
2203            }
2204            budget -= size;
2205            picked.insert(a);
2206            next.extend(words(a..=b));
2207            picked.extend(a..=b);
2208        }
2209        wanted = next;
2210    }
2211    picked.into_iter().collect()
2212}
2213
2214/// A file's named definitions (functions, methods, table variables, RSpec
2215/// `let`/`subject`) with their first and last lines.
2216fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
2217    let ident = |s: &str| {
2218        s.chars()
2219            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
2220            .collect::<String>()
2221    };
2222    let mut out = Vec::new();
2223    for (i, line) in lines.iter().enumerate() {
2224        let t = line.trim_start();
2225        let name = match language {
2226            Language::Go => t
2227                .strip_prefix("func ")
2228                .map(|r| match r.strip_prefix('(') {
2229                    Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
2230                    None => r,
2231                })
2232                .or_else(|| t.strip_prefix("var "))
2233                .or_else(|| t.strip_prefix("type "))
2234                .filter(|_| indent(line) == 0)
2235                .map(ident),
2236            Language::Rust => {
2237                let r = t.strip_prefix("pub ").unwrap_or(t);
2238                let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
2239                ["fn ", "const ", "static ", "struct ", "enum "]
2240                    .iter()
2241                    .find_map(|k| r.strip_prefix(k))
2242                    .map(ident)
2243            }
2244            Language::Ruby => t
2245                .strip_prefix("def ")
2246                .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
2247                .or_else(|| {
2248                    ["let(:", "let!(:", "subject(:"]
2249                        .iter()
2250                        .find_map(|k| t.strip_prefix(k))
2251                        .map(ident)
2252                })
2253                .or_else(|| {
2254                    (t.starts_with("subject ") || t.starts_with("subject{"))
2255                        .then(|| "subject".into())
2256                }),
2257            Language::Jvm => {
2258                if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
2259                    Some(ident(r.1.trim_start_matches('`')))
2260                } else if t.contains('(')
2261                    && !t.ends_with(';')
2262                    && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
2263                    && ![
2264                        "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
2265                        "throw", "assert", "super", "this",
2266                    ]
2267                    .iter()
2268                    .any(|k| t.starts_with(k))
2269                {
2270                    let head = t.split('(').next().unwrap_or("");
2271                    let mut parts = head.split_whitespace().rev();
2272                    let name = parts.next().unwrap_or("");
2273                    (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
2274                } else {
2275                    None
2276                }
2277            }
2278            _ => None,
2279        };
2280        let Some(name) = name.filter(|n| !n.is_empty()) else {
2281            continue;
2282        };
2283        let end = match language {
2284            Language::Ruby => keyword_end(lines, i),
2285            _ if line.contains('{')
2286                || lines
2287                    .get(i + 1)
2288                    .is_some_and(|n| n.trim_start().starts_with('{')) =>
2289            {
2290                brace_end(lines, i)
2291            }
2292            _ => i,
2293        };
2294        out.push((name, i, end));
2295    }
2296    out
2297}
2298
2299/// Whether `path` is `suffix` or ends with `/suffix`.
2300fn ends_with_path(path: &str, suffix: &str) -> bool {
2301    path.strip_suffix(suffix)
2302        .is_some_and(|head| head.is_empty() || head.ends_with('/'))
2303}
2304
2305/// The project's files with these extensions, as project-relative paths
2306/// (ignored files, such as build output, left out).
2307fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
2308    let mut out = ignore::WalkBuilder::new(root)
2309        .build()
2310        .filter_map(Result::ok)
2311        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2312        .filter(|e| {
2313            e.path()
2314                .extension()
2315                .and_then(|x| x.to_str())
2316                .is_some_and(|x| extensions.contains(&x))
2317        })
2318        .filter_map(|e| {
2319            e.path()
2320                .strip_prefix(root)
2321                .ok()
2322                .map(|p| p.to_string_lossy().replace('\\', "/"))
2323        })
2324        .filter(|p| !p.starts_with(".supercov/"))
2325        .collect::<Vec<_>>();
2326    out.sort();
2327    out
2328}
2329
2330fn is_test_call(line: &str) -> bool {
2331    let t = line.trim_start();
2332    let rest = if let Some(r) = t.strip_prefix("it") {
2333        r
2334    } else if let Some(r) = t.strip_prefix("test") {
2335        r
2336    } else {
2337        return false;
2338    };
2339    let rest = if let Some(r) = rest.strip_prefix('.') {
2340        r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
2341    } else {
2342        rest
2343    };
2344    rest.trim_start().starts_with('(')
2345}
2346
2347/// `const x = value` -> `const x =`; `None` when there is no plain `=`.
2348fn split_initializer(line: &str) -> Option<String> {
2349    let b = line.as_bytes();
2350    for i in 1..b.len() {
2351        if b[i] == b'='
2352            && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
2353            && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
2354        {
2355            return Some(line[..=i].to_owned());
2356        }
2357    }
2358    None
2359}
2360
2361/// `if c:` -> `c`
2362/// A Python test's function name: pytest `file::Class::test[param]`,
2363/// unittest `module.Class.test`.
2364fn python_title(name: &str) -> &str {
2365    let last = name.rsplit("::").next().unwrap_or(name);
2366    let last = last.split('[').next().unwrap_or(last);
2367    last.rsplit('.').next().unwrap_or(last)
2368}
2369
2370/// The classes a Python test id names around its function: pytest's
2371/// `file::TestA::TestB::test[param]`, unittest's `module.TestA.test`.
2372fn python_classes(name: &str) -> Vec<&str> {
2373    let name = name.split('[').next().unwrap_or(name);
2374    if name.contains("::") {
2375        let parts = name.split("::").collect::<Vec<_>>();
2376        return parts[1..parts.len().saturating_sub(1)].to_vec();
2377    }
2378    let parts = name.split('.').collect::<Vec<_>>();
2379    parts
2380        .len()
2381        .checked_sub(2)
2382        .map(|at| parts[at])
2383        .filter(|class| class.starts_with(|c: char| c.is_ascii_uppercase()))
2384        .into_iter()
2385        .collect()
2386}
2387
2388/// The lines of the innermost of `classes`, each found inside the one before
2389/// and ended by indentation, with the indentation of that class's own body;
2390/// the whole file when one is not found.
2391fn python_class_lines(lines: &[&str], classes: &[&str]) -> (usize, usize, Option<usize>) {
2392    let indent_of = |l: &str| l.len() - l.trim_start().len();
2393    let (mut from, mut to, mut body) = (0, lines.len(), None);
2394    for class in classes {
2395        let declared = (from..to).find(|&i| {
2396            lines[i]
2397                .trim_start()
2398                .strip_prefix("class ")
2399                .and_then(|rest| rest.strip_prefix(class))
2400                .is_some_and(|rest| rest.starts_with(['(', ':']))
2401        });
2402        let Some(at) = declared else {
2403            return (0, lines.len(), None);
2404        };
2405        let indent = indent_of(lines[at]);
2406        let end = (at + 1..to)
2407            .find(|&i| !lines[i].trim().is_empty() && indent_of(lines[i]) <= indent)
2408            .unwrap_or(to);
2409        body = (at + 1..end)
2410            .find(|&i| !lines[i].trim().is_empty())
2411            .map(|i| indent_of(lines[i]));
2412        (from, to) = (at, end);
2413    }
2414    (from, to, body)
2415}
2416
2417fn python_condition(line: &str) -> String {
2418    let t = line.trim();
2419    let t = t
2420        .strip_prefix("elif")
2421        .or_else(|| t.strip_prefix("if"))
2422        .map(str::trim_start)
2423        .unwrap_or(t);
2424    t.strip_suffix(':')
2425        .map(str::trim_end)
2426        .unwrap_or(t)
2427        .to_owned()
2428}
2429
2430/// `if (c) {` -> `c`
2431fn condition(line: &str) -> String {
2432    let mut t = line.trim();
2433    if let Some(r) = t.strip_prefix('}') {
2434        let r = r.trim_start();
2435        if let Some(r) = r.strip_prefix("else") {
2436            t = r.trim_start();
2437        }
2438    }
2439    let Some(r) = t.strip_prefix("if") else {
2440        return line.to_owned();
2441    };
2442    let r = r.trim_start();
2443    let Some(r) = r.strip_prefix('(') else {
2444        return line.to_owned();
2445    };
2446    let r = r.trim_end();
2447    let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2448    match r.strip_suffix(')') {
2449        Some(c) => c.to_owned(),
2450        None => line.to_owned(),
2451    }
2452}
2453
2454fn strip_numbers(text: &str) -> String {
2455    text.split('\n')
2456        .map(|l| {
2457            let (mark, rest) = match l.chars().next() {
2458                Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2459                _ => (None, l),
2460            };
2461            let trimmed = rest.trim_start();
2462            let digits = trimmed.len()
2463                - trimmed
2464                    .trim_start_matches(|c: char| c.is_ascii_digit())
2465                    .len();
2466            if digits > 0 && trimmed[digits..].starts_with(' ') {
2467                format!(
2468                    "{}{}",
2469                    mark.map(String::from).unwrap_or_default(),
2470                    &trimmed[digits + 1..]
2471                )
2472            } else if let Some(n) = l
2473                .trim_start()
2474                .strip_prefix("... ")
2475                .and_then(|r| r.split_once(" lines not shown"))
2476            {
2477                let _ = n;
2478                "...".to_owned()
2479            } else {
2480                l.to_owned()
2481            }
2482        })
2483        .collect::<Vec<_>>()
2484        .join("\n")
2485}
2486
2487fn strip_location(question: &str) -> String {
2488    // `file:12: change` -> `file:: change`
2489    match question.split_once(": ") {
2490        Some((loc, rest)) => match loc.rsplit_once(':') {
2491            Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2492                format!("{file}:: {rest}")
2493            }
2494            _ => question.to_owned(),
2495        },
2496        None => question.to_owned(),
2497    }
2498}
2499
2500/// The project-relative name of `path`, `..` and `.` resolved, with `/`
2501/// between parts on every platform: a run names its files that way, and on
2502/// Windows a joined path had `\\` and named a helper no run had.
2503fn normalize(path: &Path) -> String {
2504    let mut out = Vec::new();
2505    for c in path.components() {
2506        match c {
2507            std::path::Component::ParentDir => {
2508                out.pop();
2509            }
2510            std::path::Component::CurDir => {}
2511            other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2512        }
2513    }
2514    out.join("/")
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519    #[test]
2520    fn a_replacement_that_is_the_statement_is_no_change() {
2521        assert!(super::same_code("return 0, nil", "return 0,  nil"));
2522        assert!(super::same_code("count := 0", "count := 0;"));
2523        assert!(!super::same_code("return 0, nil", "return n, nil"));
2524        let source = "package p\n\nfunc f(n int) (int, error) {\n\tif n > 0 {\n\t\treturn n, nil\n\t}\n\treturn 0, nil\n}\n";
2525        assert_eq!(
2526            super::go::rewrite(source, 7, Change::ReturnUndefined).as_deref(),
2527            Some("return 0, nil")
2528        );
2529    }
2530
2531    use super::*;
2532
2533    #[test]
2534    fn a_statement_many_tests_run_is_asked_of_the_tests_that_ran_little_else() {
2535        // Seventeen end-to-end tests pass through `total` and nine other
2536        // statements; three unit tests, in the file that sorts last, run
2537        // `total` alone. One test per file in file order never reaches the
2538        // unit tests within fifteen.
2539        let source = (0..10)
2540            .map(|n| format!("const v{n} = compute({n});"))
2541            .collect::<Vec<_>>()
2542            .join("\n");
2543        let test = |file: &str, name: &str| InputTest {
2544            id: format!("{file}::{name}"),
2545            file: Some(file.to_owned()),
2546            name: name.to_owned(),
2547            role: "test".into(),
2548            runner: "node:test".into(),
2549        };
2550        let mut tests = (0..17)
2551            .map(|n| {
2552                let name = if n == 16 {
2553                    "calls compute on start"
2554                } else {
2555                    "serves a request"
2556                };
2557                test(&format!("tests/e2e{n:02}.test.js"), name)
2558            })
2559            .collect::<Vec<_>>();
2560        tests.extend((0..3).map(|n| test("tests/zz_unit.test.js", &format!("total case {n}"))));
2561        let everyone = (0..20).collect::<Vec<u32>>();
2562        let end_to_end = (0..17).collect::<Vec<u32>>();
2563        let points = (0..10)
2564            .map(|n| InputPoint {
2565                file: "src/sum.js".into(),
2566                line: n + 1,
2567                source: format!("const v{n} = compute({n});"),
2568                tests: if n == 0 {
2569                    everyone.clone()
2570                } else {
2571                    end_to_end.clone()
2572                },
2573            })
2574            .collect();
2575        let input = AssessmentInput {
2576            tests,
2577            points,
2578            decisions: Vec::new(),
2579        };
2580        let sources = BTreeMap::from([("src/sum.js".to_owned(), source)]);
2581        let population = population_from(
2582            Path::new("/nonexistent"),
2583            &input,
2584            &sources,
2585            Language::JavaScript,
2586        )
2587        .unwrap();
2588        let target = population
2589            .statements
2590            .iter()
2591            .position(|s| s.line == 1)
2592            .unwrap();
2593        let unit = |order: &[usize]| {
2594            order
2595                .iter()
2596                .filter(|&&t| population.tests[t].file == "tests/zz_unit.test.js")
2597                .count()
2598        };
2599        let spread = population.order(target, MORE_TESTS);
2600        assert_eq!(
2601            unit(&spread),
2602            0,
2603            "file order alone leaves the unit tests out"
2604        );
2605        let last = population.last_order(target, &spread, LAST_TESTS);
2606        assert_eq!(last.len(), LAST_TESTS);
2607        assert!(last.iter().all(|t| !spread.contains(t)), "{last:?}");
2608        // The test naming what the line calls comes first, though it ran as
2609        // much as any; then the tests that ran the least.
2610        assert_eq!(population.tests[last[0]].name, "calls compute on start");
2611        assert_eq!(unit(&last[1..]), 2, "{last:?}");
2612        assert_eq!(
2613            name_words("const hostCount = getHTTPResponse2(send_data)"),
2614            [
2615                "const",
2616                "count",
2617                "data",
2618                "host",
2619                "http",
2620                "response2",
2621                "send"
2622            ]
2623            .map(String::from)
2624            .into()
2625        );
2626    }
2627
2628    #[test]
2629    fn a_statement_is_shown_with_the_function_it_is_in() {
2630        // The outermost one, with the statement that declares it and what
2631        // decorates it; a callback written inside it is part of it.
2632        let typescript = "import { log } from './log'\n\nexport const seal = (key: string) => {\n  const parts = key.split('-').map((part) => {\n    return part.trim()\n  })\n  return parts\n}\n\nclass Vault {\n  size = 0\n  @logged()\n  open(door: string) {\n    return () => door\n  }\n}\n\nserver.on('close', function () {\n  log('closed')\n})\n\nif (flag) {\n  function inner() {\n    return 1\n  }\n}\nconst limit = 4\n";
2633        assert_eq!(
2634            outermost_functions(Language::JavaScript, "src/vault.ts", typescript),
2635            [(3, 8), (12, 15), (18, 20), (23, 25)]
2636        );
2637        let python = "import os\n\nLIMIT = 4\n\n@cached\ndef seal(key):\n    def trim(part):\n        return part.strip()\n    return [trim(part) for part in key.split('-')]\n\nclass Vault:\n    size = 0\n\n    async def open(self, door):\n        return lambda: door\n\nif os.name == 'nt':\n    def separator():\n        return ';'\n";
2638        assert_eq!(
2639            outermost_functions(Language::Python, "vault.py", python),
2640            [(5, 9), (14, 15), (18, 19)]
2641        );
2642        // The other languages are asked as they were.
2643        assert!(outermost_functions(Language::Go, "vault.go", "package vault\n").is_empty());
2644        assert!(Language::Python.asks_within_the_function());
2645        assert!(!Language::Ruby.asks_within_the_function());
2646    }
2647
2648    #[test]
2649    fn changes_follow_the_audit() {
2650        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";
2651        let starts = statement_starts("x.tsx", source);
2652        let js = Language::JavaScript;
2653        assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2654        assert_eq!(
2655            change_at(&starts, 2, "const a = 1", js),
2656            Some(Change::ValueUndefined)
2657        );
2658        assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2659        assert_eq!(
2660            change_at(&starts, 5, "if (a) return 2", js),
2661            Some(Change::Invert)
2662        );
2663        assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2664        assert_eq!(
2665            change_at(&starts, 8, "a && b", js),
2666            Some(Change::ExpressionUndefined)
2667        );
2668    }
2669
2670    #[test]
2671    fn a_change_that_cannot_change_anything_is_not_asked() {
2672        // supergateway's audit: `return undefined` "returning undefined" and
2673        // `let answered = false` "becoming undefined" (read only as `!answered`)
2674        // could never be caught, and skipping `super(...)` does not compile.
2675        let source = "class H extends Map {\n  constructor() {\n    super([])\n  }\n}\nfunction f(a) {\n  if (a) return undefined\n  if (!a) return void 0\n  let answered = false\n  const done = true, more = 1\n  return false\n}\n";
2676        let starts = statement_starts("x.ts", source);
2677        let js = Language::JavaScript;
2678        assert_eq!(change_at(&starts, 3, "super([])", js), None);
2679        assert_eq!(
2680            change_at(&starts, 7, "return undefined", js),
2681            Some(Change::Skip)
2682        );
2683        assert_eq!(
2684            change_at(&starts, 8, "return void 0", js),
2685            Some(Change::Skip)
2686        );
2687        // destr: `return undefined as T` was asked as "returns undefined", which no
2688        // test can catch, and read as the one statement not asserted.
2689        let typed = "function f<T>(a: number): T {\n  if (a === 1) return undefined as T\n  if (a === 2) return (undefined as unknown) as T\n  if (a === 3) return <T>undefined\n  return undefined!\n}\n";
2690        let starts = statement_starts("x.ts", typed);
2691        for (line, text) in [
2692            (2, "return undefined as T"),
2693            (3, "return (undefined as unknown) as T"),
2694            (4, "return <T>undefined"),
2695            (5, "return undefined!"),
2696        ] {
2697            assert_eq!(
2698                change_at(&starts, line, text, js),
2699                Some(Change::Skip),
2700                "{text}"
2701            );
2702        }
2703        let starts = statement_starts("x.ts", source);
2704        assert_eq!(
2705            change_at(&starts, 9, "let answered = false", js),
2706            Some(Change::Flip)
2707        );
2708        // Two declarators: the value change still applies to both.
2709        assert_eq!(
2710            change_at(&starts, 10, "const done = true, more = 1", js),
2711            Some(Change::ValueUndefined)
2712        );
2713        assert_eq!(
2714            change_at(&starts, 11, "return false", js),
2715            Some(Change::Flip)
2716        );
2717        let python = python_statement_starts(
2718            "def f(a):\n    if a:\n        return None\n    ready = False\n    return True\n",
2719        );
2720        let py = Language::Python;
2721        assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2722        assert_eq!(
2723            change_at(&python, 4, "ready = False", py),
2724            Some(Change::Flip)
2725        );
2726        assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2727        assert_eq!(flipped("let answered = false"), "let answered = true");
2728        assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2729        assert_eq!(flipped("ready = False"), "ready = True");
2730        assert_eq!(
2731            flipped("let ready = false // true once open"),
2732            "let ready = true // true once open"
2733        );
2734        assert_eq!(
2735            flipped("ready = False  # True later"),
2736            "ready = True  # True later"
2737        );
2738        assert_eq!(flipped("this.#open = false"), "this.#open = true");
2739        assert_eq!(
2740            flipped("const s = 'a // b', on = false"),
2741            "const s = 'a // b', on = true"
2742        );
2743    }
2744
2745    #[test]
2746    fn a_typescript_throw_keeps_its_types() {
2747        // supergateway's modernHttp.ts:294: deleting the guard's throw made
2748        // `result.result` fail to type-check on the next line (TS2339).
2749        let source = "async function list(reply) {\n  const result = await reply\n  if ('error' in result)\n    throw new Error(`tools/list failed: ${result.error.message}`)\n  return result.result\n}\n";
2750        let typed = statement_starts("x.ts", source);
2751        let js = Language::JavaScript;
2752        assert_eq!(
2753            change_at(
2754                &typed,
2755                4,
2756                "throw new Error(`tools/list failed: ${result.error.message}`)",
2757                js
2758            ),
2759            Some(Change::BareError)
2760        );
2761        let plain = statement_starts("x.js", source);
2762        assert_eq!(
2763            change_at(
2764                &plain,
2765                4,
2766                "throw new Error(`tools/list failed: ${result.error.message}`)",
2767                js
2768            ),
2769            Some(Change::Skip)
2770        );
2771    }
2772
2773    #[test]
2774    fn a_deleted_statement_says_what_goes_and_what_then_runs() {
2775        let source = "def f(items, n):\n    if n < 0:\n        raise ValueError(\"negative\")\n    if not items:\n        return None\n    for item in items:\n        use(item)\n    log(n)\n    return n\n";
2776        let point = |line: usize, text: &str| InputPoint {
2777            file: "pkg/f.py".into(),
2778            line,
2779            source: text.into(),
2780            tests: vec![0],
2781        };
2782        let input = AssessmentInput {
2783            tests: vec![InputTest {
2784                id: "tests/test_f.py::test_f".into(),
2785                file: Some("tests/test_f.py".into()),
2786                name: "tests/test_f.py::test_f".into(),
2787                role: "test".into(),
2788                runner: "pytest".into(),
2789            }],
2790            points: vec![
2791                point(3, "raise ValueError(\"negative\")"),
2792                point(5, "return None"),
2793                point(6, "for item in items:\n        use(item)"),
2794                point(8, "log(n)"),
2795            ],
2796            decisions: Vec::new(),
2797        };
2798        let sources = BTreeMap::from([("pkg/f.py".to_owned(), source.to_owned())]);
2799        let python = population_from(
2800            Path::new("/nonexistent"),
2801            &input,
2802            &sources,
2803            Language::Python,
2804        )
2805        .unwrap();
2806        let question = |population: &Population, line: usize| {
2807            let at = population
2808                .statements
2809                .iter()
2810                .position(|s| s.line == line)
2811                .unwrap();
2812            population.questions(at).join(" | ")
2813        };
2814        assert_eq!(
2815            question(&python, 3),
2816            "pkg/f.py:3: `raise ValueError(\"negative\")` is deleted: no error is raised here, and execution carries on with whatever follows it."
2817        );
2818        assert_eq!(
2819            question(&python, 5),
2820            "pkg/f.py:5: `return None` is deleted: the function does not return here, and execution carries on with whatever follows it."
2821        );
2822        assert_eq!(
2823            question(&python, 6),
2824            "pkg/f.py:6: the whole statement starting `for item in items:` is deleted, all 2 lines of it (lines 6 to 7): none of it runs."
2825        );
2826        assert_eq!(
2827            question(&python, 8),
2828            "pkg/f.py:8: `log(n)` is deleted (it never runs)."
2829        );
2830        // A JavaScript return keeps the plain wording: it was judged worse
2831        // the other way.
2832        let js = AssessmentInput {
2833            tests: vec![InputTest {
2834                id: "t".into(),
2835                file: Some("test/g.test.js".into()),
2836                name: "g".into(),
2837                role: "test".into(),
2838                runner: "node:test".into(),
2839            }],
2840            points: vec![InputPoint {
2841                file: "src/g.js".into(),
2842                line: 3,
2843                source: "return;".into(),
2844                tests: vec![0],
2845            }],
2846            decisions: Vec::new(),
2847        };
2848        let sources = BTreeMap::from([(
2849            "src/g.js".to_owned(),
2850            "function g(a) {\n  if (a) {\n    return;\n  }\n  use(a);\n}\n".to_owned(),
2851        )]);
2852        let javascript = population_from(
2853            Path::new("/nonexistent"),
2854            &js,
2855            &sources,
2856            Language::JavaScript,
2857        )
2858        .unwrap();
2859        assert_eq!(
2860            question(&javascript, 3),
2861            "src/g.js:3: `return;` is deleted (it never runs)."
2862        );
2863    }
2864
2865    #[test]
2866    fn a_statement_s_text_is_what_a_test_would_quote() {
2867        assert_eq!(
2868            quoted_fragments("logger.info(`  - Headers: ${describeHeaders(headers)}`)"),
2869            vec!["- Headers:"]
2870        );
2871        assert_eq!(
2872            quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2873            vec![", signal=", "Child exited: code="]
2874        );
2875        assert_eq!(
2876            quoted_fragments("log(f\"Serving {path} on {port}\")"),
2877            vec!["Serving"]
2878        );
2879        // Short or letterless pieces say nothing about a test.
2880        assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2881        assert!(quoted_fragments("return a + b").is_empty());
2882    }
2883
2884    /// A pytest method far into its file: the file's first lines, then the
2885    /// class around it, its decorators and its body, each once.
2886    #[test]
2887    fn a_python_test_deep_in_its_file_is_shown_with_its_class_and_decorators() {
2888        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2889        let root = std::env::temp_dir().join(format!(
2890            "supercov-python-test-code-{}-{}",
2891            std::process::id(),
2892            COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2893        ));
2894        std::fs::create_dir_all(root.join("tests")).unwrap();
2895        let mut file =
2896            String::from("# The shop's tests.\nimport pytest\n\nfrom shop import total\n");
2897        for i in 0..10 {
2898            file.push_str(&format!("\n\ndef helper_{i}():\n    return {i}\n"));
2899        }
2900        file.push_str("\n\nclass TestTotal:\n    def test_first(self):\n        assert total([]) == 0\n\n    @pytest.mark.slow\n    @pytest.mark.parametrize(\"n\", [1])\n    def test_deep(self, n):\n        # one item\n        assert total([n]) == n\n\n    def test_after(self):\n        pass\n");
2901        std::fs::write(root.join("tests/test_total.py"), &file).unwrap();
2902        let input = AssessmentInput {
2903            tests: vec![InputTest {
2904                id: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2905                file: Some("tests/test_total.py".into()),
2906                name: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2907                role: "test".into(),
2908                runner: "pytest".into(),
2909            }],
2910            points: vec![InputPoint {
2911                file: "shop.py".into(),
2912                line: 1,
2913                source: "return 0".into(),
2914                tests: vec![0],
2915            }],
2916            decisions: Vec::new(),
2917        };
2918        let sources = BTreeMap::from([("shop.py".to_owned(), "return 0\n".to_owned())]);
2919        let population = population_from(&root, &input, &sources, Language::Python).unwrap();
2920        let code = population.test_code(0);
2921        std::fs::remove_dir_all(&root).unwrap();
2922        let shown = code
2923            .lines()
2924            .map(|line| {
2925                let line = line.trim_start();
2926                line.split_once(' ').map_or(line, |(_, l)| l.trim_start())
2927            })
2928            .collect::<Vec<_>>();
2929        assert_eq!(
2930            shown,
2931            [
2932                "import pytest",
2933                "",
2934                "from shop import total",
2935                "",
2936                "",
2937                "def helper_0():",
2938                "return 0",
2939                "",
2940                "",
2941                "...",
2942                "class TestTotal:",
2943                "...",
2944                "@pytest.mark.slow",
2945                "@pytest.mark.parametrize(\"n\", [1])",
2946                "def test_deep(self, n):",
2947                "assert total([n]) == n",
2948                "",
2949            ],
2950            "{code}"
2951        );
2952    }
2953
2954    /// The small readers a question is built with: a statement's condition,
2955    /// the part of a line before its value, a test call, and the numbers and
2956    /// locations a cache key leaves out.
2957    #[test]
2958    fn statement_text_is_read_the_way_each_language_writes_it() {
2959        assert_eq!(condition("if (a && b) {"), "a && b");
2960        assert_eq!(condition("} else if (ready) {"), "ready");
2961        assert_eq!(condition("if (a) return;"), "if (a) return;");
2962        assert_eq!(condition("while (a) {"), "while (a) {");
2963        assert_eq!(python_condition("elif x is None:"), "x is None");
2964        assert_eq!(python_condition("if ready:"), "ready");
2965        assert_eq!(
2966            split_initializer("const x = f(1);").as_deref(),
2967            Some("const x =")
2968        );
2969        assert_eq!(
2970            split_initializer("if (a <= b && c != d) total = 1").as_deref(),
2971            Some("if (a <= b && c != d) total =")
2972        );
2973        assert_eq!(split_initializer("a === b"), None);
2974        assert_eq!(split_initializer("items.map((x) => x)"), None);
2975        assert!(is_test_call("  it('works', () => {"));
2976        assert!(is_test_call("test.only('works', () => {"));
2977        assert!(is_test_call("test ('spaced', () => {"));
2978        assert!(!is_test_call("items.push(1)"));
2979        assert!(!is_test_call("testing(1)"));
2980        assert_eq!(
2981            strip_numbers("▶   12 total(1)\n    13 next\n  ... 4 lines not shown\nplain"),
2982            "▶total(1)\n next\n...\nplain"
2983        );
2984        assert_eq!(
2985            strip_location("src/a.js:12: `x` is deleted"),
2986            "src/a.js:: `x` is deleted"
2987        );
2988        assert_eq!(strip_location("src/a.js:main: kept"), "src/a.js:main: kept");
2989        assert_eq!(strip_location("no location"), "no location");
2990    }
2991
2992    /// A helper file's named definitions, so a test is sent only the helpers
2993    /// it names: methods with their bodies (a brace on its own line too),
2994    /// Kotlin functions, RSpec `let` and `subject`, Rust items.
2995    #[test]
2996    fn helper_definitions_are_read_the_way_each_language_declares_them() {
2997        let names = |language: Language, text: &str| {
2998            let lines = text.split('\n').collect::<Vec<_>>();
2999            definitions(&lines, language)
3000                .into_iter()
3001                .map(|(name, start, end)| format!("{name} {}-{}", start + 1, end + 1))
3002                .collect::<Vec<_>>()
3003        };
3004        assert_eq!(
3005            names(
3006                Language::Jvm,
3007                "class Helpers {\n  static int total(int a) {\n    return a;\n  }\n  private static final Map<String, Integer> PRICES = Map.of(\"a\", 1);\n  public <T> List<T> wrap(T value)\n  {\n    return List.of(value);\n  }\n  void call() { helper(1); }\n}\n"
3008            ),
3009            ["total 2-4", "wrap 6-9", "call 10-10"]
3010        );
3011        assert_eq!(
3012            names(
3013                Language::Jvm,
3014                "object Fixtures {\n    private fun price(x: Int) = x * 2\n    val list = listOf(1).map { it }\n    suspend fun later() {\n    }\n}\n"
3015            ),
3016            ["price 2-2", "later 4-5"]
3017        );
3018        assert_eq!(
3019            names(
3020                Language::Ruby,
3021                "module Helpers\n  def self.make(x)\n    x\n  end\n  let(:order) { 1 }\n  let!(:user) { 2 }\n  subject(:cart) { 3 }\n  subject { 4 }\n  def valid?\n    true\n  end\nend\n"
3022            ),
3023            [
3024                "make 2-4",
3025                "order 5-5",
3026                "user 6-6",
3027                "cart 7-7",
3028                "subject 8-8",
3029                "valid? 9-11"
3030            ]
3031        );
3032        assert_eq!(
3033            names(
3034                Language::Rust,
3035                "pub fn make() -> u8 {\n    1\n}\npub(crate) const LIMIT: u8 = 2;\nstruct Point {\n    x: u8,\n}\n"
3036            ),
3037            ["make 1-3", "LIMIT 4-4", "Point 5-7"]
3038        );
3039    }
3040
3041    #[test]
3042    fn a_python_test_is_found_in_the_class_its_id_names() {
3043        let text = "import pytest\n\n\nclass TestCart:\n    def test_total(self):\n        assert cart() == 0\n\n\nclass TestOrder:\n    class TestRefund:\n        def test_total(self):\n            assert refund() == 1\n\n    def test_total(self):\n        assert order() == 2\n";
3044        let lines = text.split('\n').collect::<Vec<_>>();
3045        let at = |name: &str| {
3046            let (from, to, body) = python_class_lines(&lines, &python_classes(name));
3047            (from..to)
3048                .find(|&i| {
3049                    lines[i].trim_start().starts_with("def test_total(")
3050                        && body
3051                            .is_none_or(|body| lines[i].len() - lines[i].trim_start().len() == body)
3052                })
3053                .map(|i| i + 1)
3054        };
3055        assert_eq!(at("tests/test_a.py::TestCart::test_total"), Some(5));
3056        assert_eq!(
3057            at("tests/test_a.py::TestOrder::TestRefund::test_total[1]"),
3058            Some(11)
3059        );
3060        // The class's own method, not the one of a class nested in it.
3061        assert_eq!(at("tests/test_a.py::TestOrder::test_total"), Some(14));
3062        assert_eq!(at("tests.test_a.TestCart.test_total"), Some(5));
3063        assert_eq!(
3064            python_classes("tests/test_a.py::test_plain"),
3065            Vec::<&str>::new()
3066        );
3067        assert_eq!(
3068            python_classes("tests.test_a.test_plain"),
3069            Vec::<&str>::new()
3070        );
3071    }
3072
3073    #[test]
3074    fn a_javascript_test_is_found_in_the_group_its_name_gives() {
3075        let text = "import { totals } from '../src/cart.js';\n\ndescribe('cart', () => {\n  it('totals', () => {\n    expect(totals([])).toBe(0);\n  });\n});\n\ndescribe('order', () => {\n  describe('refund', () => {\n    it('totals', () => {});\n  });\n  it('totals', () => {\n    expect(1).toBe(1);\n  });\n});\n";
3076        let lines = text.split('\n').collect::<Vec<_>>();
3077        let line = |name: &str| js_test_line(&lines, name).map(|i| i + 1);
3078        assert_eq!(
3079            line("cart > totals"),
3080            Some(4),
3081            "not the import that names it"
3082        );
3083        assert_eq!(line("order > refund > totals"), Some(11));
3084        assert_eq!(line("totals"), Some(4));
3085        // A group longer than a body is shown is still searched to its end.
3086        let long = format!(
3087            "describe('a', () => {{\n  it('x', () => {{}});\n}});\ndescribe('b', () => {{\n{}  it('x', () => {{}});\n}});\n",
3088            "  const y = 1;\n".repeat(200)
3089        );
3090        let lines = long.split('\n').collect::<Vec<_>>();
3091        assert_eq!(js_test_line(&lines, "b > x").map(|i| i + 1), Some(205));
3092    }
3093
3094    #[test]
3095    fn python_changes_follow_the_audit() {
3096        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";
3097        let starts = python_statement_starts(source);
3098        let py = Language::Python;
3099        assert_eq!(change_at(&starts, 1, "import os", py), None);
3100        assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
3101        assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
3102        assert_eq!(
3103            change_at(&starts, 6, "y = x + 1", py),
3104            Some(Change::ValueUndefined)
3105        );
3106        assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
3107        assert_eq!(
3108            change_at(&starts, 8, "return y", py),
3109            Some(Change::ReturnUndefined)
3110        );
3111        assert_eq!(change_at(&starts, 9, "z: int", py), None);
3112        assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
3113        assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
3114        assert_eq!(
3115            change_at(&starts, 15, "v is None", py),
3116            Some(Change::Invert)
3117        );
3118        assert_eq!(
3119            python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
3120            "test_b"
3121        );
3122        assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
3123        assert_eq!(python_condition("if y > 2:"), "y > 2");
3124        assert_eq!(python_condition("elif v is None:"), "v is None");
3125    }
3126
3127    #[test]
3128    fn change_texts() {
3129        assert_eq!(
3130            split_initializer("const x = a === b").as_deref(),
3131            Some("const x =")
3132        );
3133        assert_eq!(
3134            split_initializer("const f = () => 1").as_deref(),
3135            Some("const f =")
3136        );
3137        assert_eq!(condition("} else if (x > 1) {"), "x > 1");
3138        assert_eq!(
3139            condition("if (!body) return null"),
3140            "if (!body) return null"
3141        );
3142        assert!(is_test_call("  it(\"works\", () => {"));
3143        assert!(is_test_call("test.each([1])('x', () => {"));
3144        assert!(!is_test_call("items.push(1)"));
3145    }
3146
3147    #[test]
3148    fn keys_ignore_line_numbers() {
3149        assert_eq!(
3150            strip_numbers("▶   12   return x\n        ... 3 lines not shown\n    15 }"),
3151            "▶  return x\n...\n }"
3152        );
3153        assert_eq!(
3154            strip_location("src/a.ts:41: `x` is deleted."),
3155            "src/a.ts:: `x` is deleted."
3156        );
3157    }
3158}