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