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