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