Skip to main content

supercov_engine/
assertion_coverage.rs

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