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    let words = [
1602        ("true", "false"),
1603        ("false", "true"),
1604        ("True", "False"),
1605        ("False", "True"),
1606    ];
1607    let found = words
1608        .iter()
1609        .filter_map(|(from, to)| {
1610            line.match_indices(from)
1611                .filter(|(at, _)| {
1612                    let before = line[..*at].chars().next_back();
1613                    let after = line[at + from.len()..].chars().next();
1614                    let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1615                    !word(before) && !word(after)
1616                })
1617                .last()
1618                .map(|(at, _)| (at, *from, *to))
1619        })
1620        .max_by_key(|(at, _, _)| *at);
1621    match found {
1622        Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
1623        None => line.to_owned(),
1624    }
1625}
1626
1627/// A Rust return or assignment's change, as the line it becomes.
1628fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1629    let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1630    match change {
1631        Change::ReturnUndefined if line.starts_with("return") => {
1632            format!("`{line}` becomes `return {repl};`")
1633        }
1634        Change::ReturnUndefined => {
1635            format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1636        }
1637        _ => match split_initializer(line) {
1638            Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1639            None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1640        },
1641    }
1642}
1643
1644/// Write files' text, compressed, to `path`.
1645pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
1646    use std::io::Write;
1647    let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
1648    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1649    encoder.write_all(&bytes).map_err(|e| e.to_string())?;
1650    let compressed = encoder.finish().map_err(|e| e.to_string())?;
1651    let temp = path.with_extension("tmp");
1652    std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
1653    std::fs::rename(&temp, path).map_err(|e| e.to_string())
1654}
1655
1656/// Read files written by [`save_snapshot`].
1657pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
1658    use std::io::Read;
1659    let compressed = std::fs::read(path).ok()?;
1660    let mut text = Vec::new();
1661    flate2::read::GzDecoder::new(compressed.as_slice())
1662        .read_to_end(&mut text)
1663        .ok()?;
1664    serde_json::from_slice(&text).ok()
1665}
1666
1667/// Where a test sits in its file: its first line, its last, and lines that
1668/// frame it (its class, groups or annotations), all zero-based.
1669struct Located {
1670    context: Vec<usize>,
1671    start: usize,
1672    end: usize,
1673}
1674
1675fn indent(line: &str) -> usize {
1676    line.len() - line.trim_start().len()
1677}
1678
1679/// The line closing the braces opened from `start` on (strings, characters
1680/// and line comments aside).
1681fn brace_end(lines: &[&str], start: usize) -> usize {
1682    let mut depth = 0i64;
1683    let mut opened = false;
1684    for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1685        let mut chars = line.chars().peekable();
1686        let mut quote: Option<char> = None;
1687        while let Some(c) = chars.next() {
1688            match (quote, c) {
1689                (Some(_), '\\') => {
1690                    chars.next();
1691                }
1692                (Some(q), c) if c == q => quote = None,
1693                (Some(_), _) => {}
1694                (None, '"' | '`') => quote = Some(c),
1695                (None, '/') if chars.peek() == Some(&'/') => break,
1696                (None, '{') => {
1697                    depth += 1;
1698                    opened = true;
1699                }
1700                (None, '}') => depth -= 1,
1701                _ => {}
1702            }
1703        }
1704        if opened && depth <= 0 {
1705            return i;
1706        }
1707    }
1708    (start + BODY_LINES).min(lines.len()).saturating_sub(1)
1709}
1710
1711/// The `end` (or `}`) closing a Ruby block or method opened on `start`.
1712fn keyword_end(lines: &[&str], start: usize) -> usize {
1713    let first = lines[start].trim_end();
1714    if first.ends_with(" end") || first.ends_with('}') {
1715        return start;
1716    }
1717    let depth = indent(lines[start]);
1718    (start + 1..lines.len().min(start + BODY_LINES))
1719        .find(|&i| {
1720            let t = lines[i].trim_start();
1721            indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
1722        })
1723        .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
1724}
1725
1726const DEFINITION_LINES: usize = 120;
1727
1728/// The lines of the test file's own definitions that the test at
1729/// `start..=end` names, and those they name in turn, up to a budget.
1730fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
1731    let words = |range: std::ops::RangeInclusive<usize>| {
1732        let mut out = BTreeSet::new();
1733        for i in range {
1734            let line = lines.get(i).copied().unwrap_or("");
1735            out.extend(
1736                line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1737                    .filter(|w| !w.is_empty())
1738                    .map(str::to_owned),
1739            );
1740        }
1741        out
1742    };
1743    let definitions = definitions(lines, language);
1744    let mut wanted = words(start..=end);
1745    let mut picked = BTreeSet::new();
1746    let mut budget = DEFINITION_LINES;
1747    for _ in 0..2 {
1748        let mut next = BTreeSet::new();
1749        for (name, a, b) in &definitions {
1750            let (a, b) = (*a, *b);
1751            if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
1752                continue;
1753            }
1754            let size = b - a + 1;
1755            if size > budget {
1756                continue;
1757            }
1758            budget -= size;
1759            picked.insert(a);
1760            next.extend(words(a..=b));
1761            picked.extend(a..=b);
1762        }
1763        wanted = next;
1764    }
1765    picked.into_iter().collect()
1766}
1767
1768/// A file's named definitions (functions, methods, table variables, RSpec
1769/// `let`/`subject`) with their first and last lines.
1770fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
1771    let ident = |s: &str| {
1772        s.chars()
1773            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
1774            .collect::<String>()
1775    };
1776    let mut out = Vec::new();
1777    for (i, line) in lines.iter().enumerate() {
1778        let t = line.trim_start();
1779        let name = match language {
1780            Language::Go => t
1781                .strip_prefix("func ")
1782                .map(|r| match r.strip_prefix('(') {
1783                    Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
1784                    None => r,
1785                })
1786                .or_else(|| t.strip_prefix("var "))
1787                .or_else(|| t.strip_prefix("type "))
1788                .filter(|_| indent(line) == 0)
1789                .map(ident),
1790            Language::Rust => {
1791                let r = t.strip_prefix("pub ").unwrap_or(t);
1792                let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
1793                ["fn ", "const ", "static ", "struct ", "enum "]
1794                    .iter()
1795                    .find_map(|k| r.strip_prefix(k))
1796                    .map(ident)
1797            }
1798            Language::Ruby => t
1799                .strip_prefix("def ")
1800                .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
1801                .or_else(|| {
1802                    ["let(:", "let!(:", "subject(:"]
1803                        .iter()
1804                        .find_map(|k| t.strip_prefix(k))
1805                        .map(ident)
1806                })
1807                .or_else(|| {
1808                    (t.starts_with("subject ") || t.starts_with("subject{"))
1809                        .then(|| "subject".into())
1810                }),
1811            Language::Jvm => {
1812                if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
1813                    Some(ident(r.1.trim_start_matches('`')))
1814                } else if t.contains('(')
1815                    && !t.ends_with(';')
1816                    && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
1817                    && ![
1818                        "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
1819                        "throw", "assert", "super", "this",
1820                    ]
1821                    .iter()
1822                    .any(|k| t.starts_with(k))
1823                {
1824                    let head = t.split('(').next().unwrap_or("");
1825                    let mut parts = head.split_whitespace().rev();
1826                    let name = parts.next().unwrap_or("");
1827                    (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
1828                } else {
1829                    None
1830                }
1831            }
1832            _ => None,
1833        };
1834        let Some(name) = name.filter(|n| !n.is_empty()) else {
1835            continue;
1836        };
1837        let end = match language {
1838            Language::Ruby => keyword_end(lines, i),
1839            _ if line.contains('{')
1840                || lines
1841                    .get(i + 1)
1842                    .is_some_and(|n| n.trim_start().starts_with('{')) =>
1843            {
1844                brace_end(lines, i)
1845            }
1846            _ => i,
1847        };
1848        out.push((name, i, end));
1849    }
1850    out
1851}
1852
1853/// Whether `path` is `suffix` or ends with `/suffix`.
1854fn ends_with_path(path: &str, suffix: &str) -> bool {
1855    path.strip_suffix(suffix)
1856        .is_some_and(|head| head.is_empty() || head.ends_with('/'))
1857}
1858
1859/// The project's files with these extensions, as project-relative paths
1860/// (ignored files, such as build output, left out).
1861fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
1862    let mut out = ignore::WalkBuilder::new(root)
1863        .build()
1864        .filter_map(Result::ok)
1865        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1866        .filter(|e| {
1867            e.path()
1868                .extension()
1869                .and_then(|x| x.to_str())
1870                .is_some_and(|x| extensions.contains(&x))
1871        })
1872        .filter_map(|e| {
1873            e.path()
1874                .strip_prefix(root)
1875                .ok()
1876                .map(|p| p.to_string_lossy().replace('\\', "/"))
1877        })
1878        .filter(|p| !p.starts_with(".supercov/"))
1879        .collect::<Vec<_>>();
1880    out.sort();
1881    out
1882}
1883
1884fn is_test_call(line: &str) -> bool {
1885    let t = line.trim_start();
1886    let rest = if let Some(r) = t.strip_prefix("it") {
1887        r
1888    } else if let Some(r) = t.strip_prefix("test") {
1889        r
1890    } else {
1891        return false;
1892    };
1893    let rest = if let Some(r) = rest.strip_prefix('.') {
1894        r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
1895    } else {
1896        rest
1897    };
1898    rest.trim_start().starts_with('(')
1899}
1900
1901/// `const x = value` -> `const x =`; `None` when there is no plain `=`.
1902fn split_initializer(line: &str) -> Option<String> {
1903    let b = line.as_bytes();
1904    for i in 1..b.len() {
1905        if b[i] == b'='
1906            && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
1907            && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
1908        {
1909            return Some(line[..=i].to_owned());
1910        }
1911    }
1912    None
1913}
1914
1915/// `if c:` -> `c`
1916/// A Python test's function name: pytest `file::Class::test[param]`,
1917/// unittest `module.Class.test`.
1918fn python_title(name: &str) -> &str {
1919    let last = name.rsplit("::").next().unwrap_or(name);
1920    let last = last.split('[').next().unwrap_or(last);
1921    last.rsplit('.').next().unwrap_or(last)
1922}
1923
1924fn python_condition(line: &str) -> String {
1925    let t = line.trim();
1926    let t = t
1927        .strip_prefix("elif")
1928        .or_else(|| t.strip_prefix("if"))
1929        .map(str::trim_start)
1930        .unwrap_or(t);
1931    t.strip_suffix(':')
1932        .map(str::trim_end)
1933        .unwrap_or(t)
1934        .to_owned()
1935}
1936
1937/// The modules a Python file imports: `from a.b import c` -> `a.b`,
1938/// `import a, b as x` -> `a`, `b`, `from . import c` -> `.c`.
1939fn python_imports(text: &str) -> Vec<String> {
1940    let mut out = Vec::new();
1941    for line in text.split('\n') {
1942        let t = line.trim_start();
1943        if let Some(rest) = t.strip_prefix("from ") {
1944            let Some((module, names)) = rest.split_once(" import ") else {
1945                continue;
1946            };
1947            let module = module.trim();
1948            if module.chars().all(|c| c == '.') {
1949                for name in names.trim_matches(|c| c == '(' || c == ')').split(',') {
1950                    let name = name.split(" as ").next().unwrap_or("").trim();
1951                    if !name.is_empty() && name != "*" {
1952                        out.push(format!("{module}{name}"));
1953                    }
1954                }
1955            } else {
1956                out.push(module.to_owned());
1957            }
1958        } else if let Some(rest) = t.strip_prefix("import ") {
1959            for part in rest.split(',') {
1960                let module = part.split(" as ").next().unwrap_or("").trim();
1961                if !module.is_empty() {
1962                    out.push(module.to_owned());
1963                }
1964            }
1965        }
1966    }
1967    out
1968}
1969
1970/// `if (c) {` -> `c`
1971fn condition(line: &str) -> String {
1972    let mut t = line.trim();
1973    if let Some(r) = t.strip_prefix('}') {
1974        let r = r.trim_start();
1975        if let Some(r) = r.strip_prefix("else") {
1976            t = r.trim_start();
1977        }
1978    }
1979    let Some(r) = t.strip_prefix("if") else {
1980        return line.to_owned();
1981    };
1982    let r = r.trim_start();
1983    let Some(r) = r.strip_prefix('(') else {
1984        return line.to_owned();
1985    };
1986    let r = r.trim_end();
1987    let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
1988    match r.strip_suffix(')') {
1989        Some(c) => c.to_owned(),
1990        None => line.to_owned(),
1991    }
1992}
1993
1994fn strip_numbers(text: &str) -> String {
1995    text.split('\n')
1996        .map(|l| {
1997            let (mark, rest) = match l.chars().next() {
1998                Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
1999                _ => (None, l),
2000            };
2001            let trimmed = rest.trim_start();
2002            let digits = trimmed.len()
2003                - trimmed
2004                    .trim_start_matches(|c: char| c.is_ascii_digit())
2005                    .len();
2006            if digits > 0 && trimmed[digits..].starts_with(' ') {
2007                format!(
2008                    "{}{}",
2009                    mark.map(String::from).unwrap_or_default(),
2010                    &trimmed[digits + 1..]
2011                )
2012            } else if let Some(n) = l
2013                .trim_start()
2014                .strip_prefix("... ")
2015                .and_then(|r| r.split_once(" lines not shown"))
2016            {
2017                let _ = n;
2018                "...".to_owned()
2019            } else {
2020                l.to_owned()
2021            }
2022        })
2023        .collect::<Vec<_>>()
2024        .join("\n")
2025}
2026
2027fn strip_location(question: &str) -> String {
2028    // `file:12: change` -> `file:: change`
2029    match question.split_once(": ") {
2030        Some((loc, rest)) => match loc.rsplit_once(':') {
2031            Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2032                format!("{file}:: {rest}")
2033            }
2034            _ => question.to_owned(),
2035        },
2036        None => question.to_owned(),
2037    }
2038}
2039
2040fn import_specifiers(text: &str) -> Vec<String> {
2041    let mut out = Vec::new();
2042    for line in text.split('\n') {
2043        let t = line.trim_start();
2044        let quoted = |s: &str| -> Option<String> {
2045            let s = s.trim_start();
2046            let q = s.chars().next().filter(|c| *c == '\'' || *c == '"')?;
2047            let rest = &s[1..];
2048            rest.find(q).map(|end| rest[..end].to_owned())
2049        };
2050        if (t.starts_with("import ") || t.starts_with("export "))
2051            && let Some(i) = t.find(" from ")
2052        {
2053            out.extend(quoted(&t[i + 6..]));
2054        } else if let Some(i) = t.find("import(") {
2055            out.extend(quoted(&t[i + 7..]));
2056        } else if let Some(i) = t.find("require(") {
2057            out.extend(quoted(&t[i + 8..]));
2058        } else if let Some(r) = t.strip_prefix("} from ") {
2059            out.extend(quoted(r));
2060        }
2061    }
2062    out
2063}
2064
2065/// The project-relative name of `path`, `..` and `.` resolved, with `/`
2066/// between parts on every platform: a run names its files that way, and on
2067/// Windows a joined path had `\\` and named a helper no run had.
2068fn normalize(path: &Path) -> String {
2069    let mut out = Vec::new();
2070    for c in path.components() {
2071        match c {
2072            std::path::Component::ParentDir => {
2073                out.pop();
2074            }
2075            std::path::Component::CurDir => {}
2076            other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2077        }
2078    }
2079    out.join("/")
2080}
2081
2082/// `compilerOptions.paths` of the project's tsconfig.json: `"~/*": ["./app/*"]`
2083/// becomes (`~/`, `app/`).
2084fn tsconfig_aliases(root: &Path) -> Vec<(String, String)> {
2085    let Ok(text) = std::fs::read_to_string(root.join("tsconfig.json")) else {
2086        return vec![];
2087    };
2088    let cleaned = text
2089        .lines()
2090        .filter(|l| !l.trim_start().starts_with("//"))
2091        .collect::<Vec<_>>()
2092        .join("\n");
2093    let Ok(config) = serde_json::from_str::<Value>(&cleaned) else {
2094        return vec![];
2095    };
2096    let mut out = Vec::new();
2097    if let Some(paths) = config["compilerOptions"]["paths"].as_object() {
2098        for (alias, targets) in paths {
2099            let Some(target) = targets
2100                .as_array()
2101                .and_then(|t| t.first())
2102                .and_then(Value::as_str)
2103            else {
2104                continue;
2105            };
2106            let (Some(a), Some(t)) = (alias.strip_suffix('*'), target.strip_suffix('*')) else {
2107                continue;
2108            };
2109            out.push((a.to_owned(), t.trim_start_matches("./").to_owned()));
2110        }
2111    }
2112    out
2113}
2114
2115#[cfg(test)]
2116mod tests {
2117    use super::*;
2118
2119    #[test]
2120    fn changes_follow_the_audit() {
2121        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";
2122        let starts = statement_starts("x.tsx", source);
2123        let js = Language::JavaScript;
2124        assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2125        assert_eq!(
2126            change_at(&starts, 2, "const a = 1", js),
2127            Some(Change::ValueUndefined)
2128        );
2129        assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2130        assert_eq!(
2131            change_at(&starts, 5, "if (a) return 2", js),
2132            Some(Change::Invert)
2133        );
2134        assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2135        assert_eq!(
2136            change_at(&starts, 8, "a && b", js),
2137            Some(Change::ExpressionUndefined)
2138        );
2139    }
2140
2141    #[test]
2142    fn a_change_that_cannot_change_anything_is_not_asked() {
2143        // supergateway's audit: `return undefined` "returning undefined" and
2144        // `let answered = false` "becoming undefined" (read only as `!answered`)
2145        // could never be caught, and skipping `super(...)` does not compile.
2146        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";
2147        let starts = statement_starts("x.ts", source);
2148        let js = Language::JavaScript;
2149        assert_eq!(change_at(&starts, 3, "super([])", js), None);
2150        assert_eq!(
2151            change_at(&starts, 7, "return undefined", js),
2152            Some(Change::Skip)
2153        );
2154        assert_eq!(
2155            change_at(&starts, 8, "return void 0", js),
2156            Some(Change::Skip)
2157        );
2158        assert_eq!(
2159            change_at(&starts, 9, "let answered = false", js),
2160            Some(Change::Flip)
2161        );
2162        // Two declarators: the value change still applies to both.
2163        assert_eq!(
2164            change_at(&starts, 10, "const done = true, more = 1", js),
2165            Some(Change::ValueUndefined)
2166        );
2167        assert_eq!(
2168            change_at(&starts, 11, "return false", js),
2169            Some(Change::Flip)
2170        );
2171        let python = python_statement_starts(
2172            "def f(a):\n    if a:\n        return None\n    ready = False\n    return True\n",
2173        );
2174        let py = Language::Python;
2175        assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2176        assert_eq!(
2177            change_at(&python, 4, "ready = False", py),
2178            Some(Change::Flip)
2179        );
2180        assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2181        assert_eq!(flipped("let answered = false"), "let answered = true");
2182        assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2183        assert_eq!(flipped("ready = False"), "ready = True");
2184    }
2185
2186    #[test]
2187    fn a_typescript_throw_keeps_its_types() {
2188        // supergateway's modernHttp.ts:294: deleting the guard's throw made
2189        // `result.result` fail to type-check on the next line (TS2339).
2190        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";
2191        let typed = statement_starts("x.ts", source);
2192        let js = Language::JavaScript;
2193        assert_eq!(
2194            change_at(
2195                &typed,
2196                4,
2197                "throw new Error(`tools/list failed: ${result.error.message}`)",
2198                js
2199            ),
2200            Some(Change::BareError)
2201        );
2202        let plain = statement_starts("x.js", source);
2203        assert_eq!(
2204            change_at(
2205                &plain,
2206                4,
2207                "throw new Error(`tools/list failed: ${result.error.message}`)",
2208                js
2209            ),
2210            Some(Change::Skip)
2211        );
2212    }
2213
2214    #[test]
2215    fn a_statement_s_text_is_what_a_test_would_quote() {
2216        assert_eq!(
2217            quoted_fragments("logger.info(`  - Headers: ${describeHeaders(headers)}`)"),
2218            vec!["- Headers:"]
2219        );
2220        assert_eq!(
2221            quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2222            vec![", signal=", "Child exited: code="]
2223        );
2224        assert_eq!(
2225            quoted_fragments("log(f\"Serving {path} on {port}\")"),
2226            vec!["Serving"]
2227        );
2228        // Short or letterless pieces say nothing about a test.
2229        assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2230        assert!(quoted_fragments("return a + b").is_empty());
2231    }
2232
2233    #[test]
2234    fn python_changes_follow_the_audit() {
2235        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";
2236        let starts = python_statement_starts(source);
2237        let py = Language::Python;
2238        assert_eq!(change_at(&starts, 1, "import os", py), None);
2239        assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
2240        assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
2241        assert_eq!(
2242            change_at(&starts, 6, "y = x + 1", py),
2243            Some(Change::ValueUndefined)
2244        );
2245        assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
2246        assert_eq!(
2247            change_at(&starts, 8, "return y", py),
2248            Some(Change::ReturnUndefined)
2249        );
2250        assert_eq!(change_at(&starts, 9, "z: int", py), None);
2251        assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
2252        assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
2253        assert_eq!(
2254            change_at(&starts, 15, "v is None", py),
2255            Some(Change::Invert)
2256        );
2257        assert_eq!(
2258            python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
2259            "test_b"
2260        );
2261        assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
2262        assert_eq!(python_condition("if y > 2:"), "y > 2");
2263        assert_eq!(python_condition("elif v is None:"), "v is None");
2264        assert_eq!(
2265            python_imports(
2266                "from .helpers import make\nfrom . import fixtures, util as u\nimport tests.support, json as j\n"
2267            ),
2268            vec![".helpers", ".fixtures", ".util", "tests.support", "json"]
2269        );
2270    }
2271
2272    #[test]
2273    fn change_texts() {
2274        assert_eq!(
2275            split_initializer("const x = a === b").as_deref(),
2276            Some("const x =")
2277        );
2278        assert_eq!(
2279            split_initializer("const f = () => 1").as_deref(),
2280            Some("const f =")
2281        );
2282        assert_eq!(condition("} else if (x > 1) {"), "x > 1");
2283        assert_eq!(
2284            condition("if (!body) return null"),
2285            "if (!body) return null"
2286        );
2287        assert!(is_test_call("  it(\"works\", () => {"));
2288        assert!(is_test_call("test.each([1])('x', () => {"));
2289        assert!(!is_test_call("items.push(1)"));
2290    }
2291
2292    #[test]
2293    fn keys_ignore_line_numbers() {
2294        assert_eq!(
2295            strip_numbers("▶   12   return x\n        ... 3 lines not shown\n    15 }"),
2296            "▶  return x\n...\n }"
2297        );
2298        assert_eq!(
2299            strip_location("src/a.ts:41: `x` is deleted."),
2300            "src/a.ts:: `x` is deleted."
2301        );
2302    }
2303}