1use crate::coverage_analysis::PointKind;
18use crate::coverage_report::CoverageReport;
19use oxc_allocator::Allocator;
20use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement as JsStatement};
21use oxc_ast_visit::{Visit, walk};
22use oxc_parser::Parser;
23use oxc_span::{GetSpan, SourceType};
24use serde::Serialize;
25use serde_json::{Value, json};
26use sha2::{Digest, Sha256};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30mod go;
31mod jvm;
32mod ruby;
33mod rust_source;
34
35type Starts = BTreeMap<usize, Vec<(String, Option<Change>)>>;
38
39pub const MODEL: &str = "jev-1.13.0";
40pub const FIRST_TESTS: usize = 5;
44pub const MORE_TESTS: usize = 15;
45pub const MAX_QUESTIONS: usize = 150;
46pub const THRESHOLD: f64 = 0.5;
48const HEAD_LINES: usize = 10;
49const HELPER_CHARS: usize = 3000;
50const FIXTURE_CHARS: usize = 12000;
53const CONTEXT_LINES: usize = 0;
54const WINDOW_LINES: usize = 4;
55const CODE_CHARS: usize = 10000;
56const BODY_LINES: usize = 150;
57
58pub 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.";
59
60fn runtime(runner: &str) -> &'static str {
61 match runner {
62 "node:test" => {
63 "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."
64 }
65 "jest" => {
66 "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."
67 }
68 "ava" => {
69 "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."
70 }
71 "tap" => {
72 "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."
73 }
74 "mocha" => {
75 "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."
76 }
77 "pytest" => {
78 "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."
79 }
80 "rspec" => {
81 "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."
82 }
83 "minitest" => {
84 "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."
85 }
86 "test-unit" => {
87 "test-unit; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test."
88 }
89 "cucumber" => {
90 "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."
91 }
92 "go-test" => {
93 "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."
94 }
95 "rust-libtest" | "rust-nextest" | "nextest" => {
96 "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."
97 }
98 "rustdoc" => {
99 "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."
100 }
101 "junit-platform" => {
102 "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."
103 }
104 "testng" => {
105 "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."
106 }
107 "unittest" => {
108 "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."
109 }
110 _ => {
111 "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."
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub enum Change {
119 Invert,
121 ReturnUndefined,
123 ValueUndefined,
125 ExpressionUndefined,
127 Skip,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Language {
134 JavaScript,
135 Python,
136 Ruby,
137 Go,
138 Rust,
139 Jvm,
141}
142
143impl Language {
144 pub fn from_manifest(name: &str) -> Option<Self> {
146 match name {
147 "javascript" => Some(Self::JavaScript),
148 "python" => Some(Self::Python),
149 "ruby" => Some(Self::Ruby),
150 "go" => Some(Self::Go),
151 "rust" => Some(Self::Rust),
152 "jvm" => Some(Self::Jvm),
153 _ => None,
154 }
155 }
156 fn comment(self, line: &str) -> bool {
157 let t = line.trim_start();
158 match self {
159 Self::Python | Self::Ruby => t.starts_with('#'),
160 Self::JavaScript | Self::Go | Self::Rust | Self::Jvm => {
161 t.starts_with("//")
162 || t.starts_with("/*")
163 || t.starts_with("*/")
164 || t == "*"
165 || t.starts_with("* ")
166 || t.starts_with("*\t")
167 }
168 }
169 }
170 fn nothing(self) -> &'static str {
172 match self {
173 Self::JavaScript => "undefined",
174 Self::Python => "None",
175 Self::Ruby => "nil",
176 Self::Go => "the zero value",
177 Self::Rust => "`Default::default()`",
178 Self::Jvm => "the default value (null, or 0 or false for a primitive)",
179 }
180 }
181}
182
183fn separator(language: Language, runner: &str) -> &'static str {
185 match (language, runner) {
186 (Language::JavaScript, _) => " > ",
187 (Language::Python, _) => "::",
188 (_, "rspec") => ":",
189 (_, "minitest" | "test-unit" | "junit-platform" | "testng") => "#",
190 (_, "go-test") => "/",
191 (_, "rustdoc") => " - ",
192 _ => "::",
193 }
194}
195
196#[derive(Debug, Clone)]
197pub struct Statement {
198 pub file: String,
199 pub line: usize,
200 pub text: String,
201 pub change: Change,
202 pub replacement: Option<String>,
205 pub tests: Vec<usize>,
207}
208
209#[derive(Debug, Clone)]
210pub struct TestRef {
211 pub id: String,
212 pub file: String,
213 pub name: String,
214 pub runner: String,
215}
216
217pub struct Population {
218 pub root: PathBuf,
219 pub language: Language,
220 pub statements: Vec<Statement>,
221 pub tests: Vec<TestRef>,
222 ran: Vec<BTreeMap<String, BTreeSet<usize>>>,
224 source_files: BTreeSet<String>,
227 jvm_files: Vec<String>,
229 aliases: Vec<(String, String)>,
230 files: std::sync::Mutex<BTreeMap<String, Option<std::sync::Arc<str>>>>,
232}
233
234pub fn population(
237 root: &Path,
238 report: &CoverageReport,
239 sources: &BTreeMap<String, String>,
240 language: Language,
241) -> Result<Population, String> {
242 let view = &report.filters.passed;
243 let mut tests = Vec::new();
244 let mut index = BTreeMap::new();
245 let jvm_files = if language == Language::Jvm {
247 project_files(root, &["java", "kt"])
248 } else {
249 Vec::new()
250 };
251 for test in &view.tests {
252 let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
253 Some(file) => file.clone(),
254 None if language == Language::Jvm => {
255 let found = jvm::class_paths(&test.name)
256 .iter()
257 .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
258 match found {
259 Some(file) => file.clone(),
260 None => continue,
261 }
262 }
263 None => continue,
264 };
265 if test.role != "test" {
266 continue;
267 }
268 index.insert(test.id.as_str(), tests.len());
269 tests.push(TestRef {
270 id: test.id.clone(),
271 file,
272 name: test.name.clone(),
273 runner: test.provenance.runner.clone(),
274 });
275 }
276 let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
277 let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
278 let signatures = if language == Language::Rust {
279 rust_source::signatures(
280 sources
281 .iter()
282 .filter(|(file, _)| file.ends_with(".rs"))
283 .map(|(file, source)| (file.as_str(), source.as_str())),
284 )
285 } else {
286 rust_source::Signatures::default()
287 };
288 let mut statements = Vec::new();
289 let mut seen = BTreeSet::new();
290 let executed = view
294 .points
295 .iter()
296 .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
297 .map(|p| (&p.meta.file, p.meta.line, &p.meta.source, &p.tests, false));
298 let elifs = view
299 .decisions
300 .iter()
301 .filter(|d| d.executed && language != Language::JavaScript)
302 .map(|d| (&d.meta.file, d.meta.line, &d.meta.source, &d.tests, true));
303 for (file, line, text, point_tests, decision) in executed.chain(elifs) {
304 let key = (file.clone(), line);
305 if !seen.insert(key) {
306 continue; }
308 let ids = point_tests
309 .iter()
310 .filter_map(|id| index.get(id.as_str()).copied())
311 .collect::<Vec<_>>();
312 if ids.is_empty() {
313 continue;
314 }
315 if language == Language::Rust && rust_source::test_path(file) {
316 continue;
317 }
318 let Some(source) = sources.get(file) else {
319 continue;
320 };
321 let lines = changes
322 .entry(file.clone())
323 .or_insert_with(|| match language {
324 Language::JavaScript => statement_starts(file, source),
325 Language::Python => python_statement_starts(source),
326 Language::Ruby => ruby::statement_starts(source),
327 Language::Go => go::statement_starts(source),
328 Language::Rust => rust_source::statement_starts(source),
329 Language::Jvm => jvm::statement_starts(file, source),
330 });
331 let Some(change) = change_at(lines, line, text, language) else {
332 continue;
333 };
334 if decision && change != Change::Invert {
335 continue;
336 }
337 let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
338 let replacement = match language {
339 Language::Rust if valued => {
340 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
341 let first = first.trim_end_matches(';');
342 replacements
343 .entry(file.clone())
344 .or_insert_with(|| rust_source::replacements(file, source, &signatures))
345 .get(&line)
346 .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
347 .map(|(_, r)| r.clone())
348 }
349 Language::Go if valued => go::rewrite(source, line, change),
354 _ => None,
355 };
356 statements.push(Statement {
357 file: file.clone(),
358 line,
359 text: text.clone(),
360 change,
361 replacement,
362 tests: ids,
363 });
364 }
365 let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
366 for s in &statements {
367 for &t in &s.tests {
368 ran[t].entry(s.file.clone()).or_default().insert(s.line);
369 }
370 }
371 let source_files = statements.iter().map(|s| s.file.clone()).collect();
372 Ok(Population {
373 root: root.to_path_buf(),
374 language,
375 statements,
376 tests,
377 ran,
378 source_files,
379 jvm_files,
380 aliases: tsconfig_aliases(root),
381 files: Default::default(),
382 })
383}
384
385fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
388 let allocator = Allocator::default();
389 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
390 let parsed = Parser::new(&allocator, source, source_type).parse();
391 let starts = line_starts(source);
392 struct Collector<'s> {
393 source: &'s str,
394 starts: &'s [usize],
395 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
396 }
397 impl<'a> Visit<'a> for Collector<'_> {
398 fn visit_statement(&mut self, statement: &JsStatement<'a>) {
399 let span = statement.span();
400 let line = line_of(self.starts, span.start as usize);
401 let text = self
402 .source
403 .get(span.start as usize..span.end as usize)
404 .unwrap_or("")
405 .to_owned();
406 self.out
407 .entry(line)
408 .or_default()
409 .push((text, change_of(statement)));
410 walk::walk_statement(self, statement);
411 }
412 }
413 let mut collector = Collector {
414 source,
415 starts: &starts,
416 out: BTreeMap::new(),
417 };
418 collector.visit_program(&parsed.program);
419 collector.out
420}
421
422fn change_of(statement: &JsStatement) -> Option<Change> {
423 Some(match statement {
424 JsStatement::IfStatement(_) => Change::Invert,
425 JsStatement::ReturnStatement(r) if r.argument.is_some() => Change::ReturnUndefined,
426 JsStatement::VariableDeclaration(d) => {
427 if d.declarations.iter().any(|d| d.init.is_some()) {
428 Change::ValueUndefined
429 } else {
430 return None;
431 }
432 }
433 JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
434 Some(Declaration::VariableDeclaration(d))
435 if d.declarations.iter().any(|d| d.init.is_some()) =>
436 {
437 Change::ValueUndefined
438 }
439 _ => return None,
440 },
441 JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
442 ExportDefaultDeclarationKind::FunctionDeclaration(_)
443 | ExportDefaultDeclarationKind::ClassDeclaration(_)
444 | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
445 _ => Change::Skip,
446 },
447 JsStatement::ImportDeclaration(_)
448 | JsStatement::ExportAllDeclaration(_)
449 | JsStatement::FunctionDeclaration(_)
450 | JsStatement::ClassDeclaration(_)
451 | JsStatement::TSTypeAliasDeclaration(_)
452 | JsStatement::TSInterfaceDeclaration(_)
453 | JsStatement::TSEnumDeclaration(_)
454 | JsStatement::TSModuleDeclaration(_)
455 | JsStatement::TSGlobalDeclaration(_)
456 | JsStatement::TSImportEqualsDeclaration(_)
457 | JsStatement::TSExportAssignment(_)
458 | JsStatement::TSNamespaceExportDeclaration(_) => return None,
459 _ => Change::Skip,
460 })
461}
462
463fn change_at(
467 starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
468 line: usize,
469 text: &str,
470 language: Language,
471) -> Option<Change> {
472 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
473 let found = starts.get(&line).and_then(|all| {
474 all.iter().find(|(t, _)| {
475 let bare = t
476 .strip_prefix("export ")
477 .map(|t| t.strip_prefix("default ").unwrap_or(t))
478 .unwrap_or(t);
479 bare.starts_with(first) || t.starts_with(first)
480 })
481 });
482 match (found, language) {
483 (Some((_, change)), _) => *change,
484 (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
485 (None, _) => None,
488 }
489}
490
491fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
496 use ruff_python_ast::{
497 Expr, Stmt,
498 visitor::{Visitor, walk_stmt},
499 };
500 use ruff_text_size::Ranged;
501 struct Collector<'s> {
502 source: &'s str,
503 starts: Vec<usize>,
504 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
505 }
506 impl<'a> Visitor<'a> for Collector<'_> {
507 fn visit_stmt(&mut self, stmt: &'a Stmt) {
508 let change = match stmt {
509 Stmt::If(_) => Some(Change::Invert),
510 Stmt::Return(r) => Some(if r.value.is_some() {
511 Change::ReturnUndefined
512 } else {
513 Change::Skip
514 }),
515 Stmt::Assign(_) => Some(Change::ValueUndefined),
516 Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
517 Stmt::Import(_)
518 | Stmt::ImportFrom(_)
519 | Stmt::FunctionDef(_)
520 | Stmt::ClassDef(_)
521 | Stmt::Pass(_)
522 | Stmt::Global(_)
523 | Stmt::Nonlocal(_)
524 | Stmt::TypeAlias(_)
525 | Stmt::IpyEscapeCommand(_) => None,
526 Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
527 _ => Some(Change::Skip),
528 };
529 let range = stmt.range();
530 let (start, end) = (range.start().to_usize(), range.end().to_usize());
531 let line = line_of(&self.starts, start);
532 let text = self.source.get(start..end).unwrap_or("").to_owned();
533 self.out.entry(line).or_default().push((text, change));
534 if let Stmt::If(s) = stmt {
536 for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
538 let start = test.range().start().to_usize();
539 let text = self.source.get(start..test.range().end().to_usize());
540 self.out
541 .entry(line_of(&self.starts, start))
542 .or_default()
543 .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
544 }
545 }
546 walk_stmt(self, stmt);
547 }
548 }
549 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
550 return BTreeMap::new();
551 };
552 let mut collector = Collector {
553 source,
554 starts: line_starts(source),
555 out: BTreeMap::new(),
556 };
557 for stmt in &parsed.syntax().body {
558 collector.visit_stmt(stmt);
559 }
560 collector.out
561}
562
563fn prefix(text: &str, chars: usize) -> &str {
564 match text.char_indices().nth(chars) {
565 Some((i, _)) => &text[..i],
566 None => text,
567 }
568}
569
570fn line_starts(source: &str) -> Vec<usize> {
571 std::iter::once(0)
572 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
573 .collect()
574}
575
576fn line_of(starts: &[usize], offset: usize) -> usize {
577 starts.partition_point(|&s| s <= offset)
578}
579
580fn numbered(lines: &[&str]) -> String {
581 lines
582 .iter()
583 .enumerate()
584 .map(|(i, l)| format!("{:5} {l}", i + 1))
585 .collect::<Vec<_>>()
586 .join("\n")
587}
588
589fn stem(path: &str) -> String {
590 Path::new(path)
591 .file_name()
592 .and_then(|n| n.to_str())
593 .unwrap_or("")
594 .split('.')
595 .next()
596 .unwrap_or("")
597 .to_lowercase()
598}
599
600fn describe(name: &str, separator: &str) -> String {
601 let parts = name.split(separator).collect::<Vec<_>>();
602 parts[..parts.len().saturating_sub(1)].join(separator)
603}
604
605impl Population {
606 pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
609 {
610 let mut cache = self
611 .files
612 .lock()
613 .expect("the file cache is only read and filled");
614 for (file, text) in files {
615 cache.insert(file, Some(text.into()));
616 }
617 }
618 self
619 }
620
621 pub fn files_read(&self) -> BTreeMap<String, String> {
623 self.files
624 .lock()
625 .expect("the file cache is only read and filled")
626 .iter()
627 .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
628 .collect()
629 }
630
631 fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
632 let mut files = self
633 .files
634 .lock()
635 .expect("the file cache is only read and filled");
636 files
637 .entry(file.to_owned())
638 .or_insert_with(|| {
639 std::fs::read_to_string(self.root.join(file))
640 .ok()
641 .map(Into::into)
642 })
643 .clone()
644 }
645
646 pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
649 let s = &self.statements[statement];
650 let base = stem(&s.file);
651 let key = |&t: &usize| {
652 let test = &self.tests[t];
653 (
654 !Path::new(&test.file)
655 .file_name()
656 .and_then(|n| n.to_str())
657 .unwrap_or("")
658 .to_lowercase()
659 .contains(&base),
660 test.file.clone(),
661 test.name.clone(),
662 )
663 };
664 let mut all = s.tests.clone();
665 all.sort_by_key(key);
666 if all.len() <= k {
667 return all;
668 }
669 let mut groups = Vec::<((String, String), Vec<usize>)>::new();
670 for &t in &all {
671 let g = (
672 self.tests[t].file.clone(),
673 describe(
674 &self.tests[t].name,
675 separator(self.language, &self.tests[t].runner),
676 ),
677 );
678 match groups.iter_mut().find(|(k, _)| *k == g) {
679 Some((_, v)) => v.push(t),
680 None => groups.push((g, vec![t])),
681 }
682 }
683 let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
684 for ((file, _), members) in groups {
685 match by_file.iter_mut().find(|(f, _)| *f == file) {
686 Some((_, v)) => v.push(members),
687 None => by_file.push((file, vec![members])),
688 }
689 }
690 by_file.sort_by_key(|(f, _)| {
691 (
692 !Path::new(f)
693 .file_name()
694 .and_then(|n| n.to_str())
695 .unwrap_or("")
696 .to_lowercase()
697 .contains(&base),
698 f.clone(),
699 )
700 });
701 let mut picked = Vec::new();
702 let mut depth = 0;
703 while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
704 for (_, g) in &by_file {
705 if depth < g.len() && picked.len() < k {
706 picked.push(g[depth][0]);
707 }
708 }
709 depth += 1;
710 }
711 for t in all {
712 if picked.len() >= k {
713 break;
714 }
715 if !picked.contains(&t) {
716 picked.push(t);
717 }
718 }
719 picked
720 }
721
722 pub fn test_code(&self, test: usize) -> String {
725 match self.language {
726 Language::Python => return self.python_test_code(test),
727 Language::JavaScript => {}
728 _ => return self.located_test_code(test),
729 }
730 let t = &self.tests[test];
731 let text = self.read(&t.file).unwrap_or_default();
732 let lines = text.split('\n').collect::<Vec<_>>();
733 let title = t.name.rsplit(" > ").next().unwrap_or(&t.name);
734 let needle = prefix(title, 40);
735 let start = (!needle.is_empty())
736 .then(|| lines.iter().position(|l| l.contains(needle)))
737 .flatten();
738 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
739 let head = (0..lines.len().min(HEAD_LINES))
740 .filter(|&i| !self.language.comment(lines[i]))
741 .map(fmt)
742 .collect::<Vec<_>>();
743 let Some(start) = start else {
744 let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
745 return head
746 .into_iter()
747 .chain(std::iter::once(" ...".to_owned()))
748 .chain(rest)
749 .collect::<Vec<_>>()
750 .join("\n");
751 };
752 let indent = lines[start].len() - lines[start].trim_start().len();
753 let mut body = Vec::new();
754 for (i, l) in lines
755 .iter()
756 .copied()
757 .enumerate()
758 .take(start + BODY_LINES)
759 .skip(start)
760 {
761 if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
762 break;
763 }
764 if !self.language.comment(l) {
765 body.push(fmt(i));
766 }
767 }
768 let mut out = head;
769 if start > HEAD_LINES {
770 out.push(" ...".into());
771 }
772 out.extend(body);
773 out.join("\n")
774 }
775
776 fn located_test_code(&self, test: usize) -> String {
779 let t = &self.tests[test];
780 let text = self.read(&t.file).unwrap_or_default();
781 let lines = text.split('\n').collect::<Vec<_>>();
782 let located = match self.language {
783 Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
784 Language::Go => go::locate(&lines, &t.name),
785 Language::Rust => rust_source::locate(&lines, &t.name),
786 _ => jvm::locate(&lines, &t.name),
787 };
788 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
789 let mut open = false;
791 let commented = lines
792 .iter()
793 .map(|l| {
794 let t = l.trim();
795 let was = open;
796 if self.language != Language::Ruby {
797 if !open && t.starts_with("/*") {
798 open = true;
799 }
800 if open && t.contains("*/") {
801 open = false;
802 return true;
803 }
804 }
805 was || open || self.language.comment(l)
806 })
807 .collect::<Vec<_>>();
808 let head = (0..lines.len())
810 .filter(|&i| !commented[i])
811 .take(HEAD_LINES)
812 .collect::<Vec<_>>();
813 let head_end = head.last().map_or(0, |&i| i + 1);
814 let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
815 let Some(located) = located else {
816 out.push(" ...".into());
817 out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
818 return out.join("\n");
819 };
820 let end = located
821 .end
822 .min(located.start + BODY_LINES)
823 .min(lines.len().saturating_sub(1));
824 let mut last = head_end;
825 let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
826 shown.extend(located.start..=end);
827 shown.extend(used_definitions(&lines, self.language, located.start, end));
830 for i in shown {
831 if i < last || (commented[i] && self.language != Language::Rust) {
833 continue;
834 }
835 if i > last {
836 out.push(" ...".into());
837 }
838 out.push(fmt(i));
839 last = i + 1;
840 }
841 out.join("\n")
842 }
843
844 fn python_test_code(&self, test: usize) -> String {
848 let t = &self.tests[test];
849 let text = self.read(&t.file).unwrap_or_default();
850 let lines = text.split('\n').collect::<Vec<_>>();
851 let title = python_title(&t.name);
852 let indent_of = |l: &str| l.len() - l.trim_start().len();
853 let start = (!title.is_empty())
854 .then(|| {
855 lines.iter().position(|l| {
856 let t = l.trim_start();
857 t.starts_with(&format!("def {title}("))
858 || t.starts_with(&format!("async def {title}("))
859 })
860 })
861 .flatten();
862 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
863 let mut out = (0..lines.len().min(HEAD_LINES))
864 .filter(|&i| !self.language.comment(lines[i]))
865 .map(fmt)
866 .collect::<Vec<_>>();
867 let Some(start) = start else {
868 out.push(" ...".into());
869 out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
870 return out.join("\n");
871 };
872 let indent = indent_of(lines[start]);
873 let mut first = start;
874 while first > 0
875 && lines[first - 1].trim_start().starts_with('@')
876 && indent_of(lines[first - 1]) == indent
877 {
878 first -= 1;
879 }
880 let class = (indent > 0)
881 .then(|| {
882 (0..first).rev().find(|&i| {
883 lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
884 })
885 })
886 .flatten();
887 for i in class.into_iter().chain(first..start) {
888 if i >= HEAD_LINES {
889 if out.last().is_some_and(|l| l != " ...") {
890 out.push(" ...".into());
891 }
892 out.push(fmt(i));
893 }
894 }
895 if start >= HEAD_LINES
896 && out.last().is_some_and(|l| l != " ...")
897 && first == start
898 && class.is_none()
899 {
900 out.push(" ...".into());
901 }
902 for (i, &l) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
903 if i > start && !l.trim().is_empty() && indent_of(l) <= indent {
904 break;
905 }
906 if !self.language.comment(l) && i >= HEAD_LINES {
907 out.push(fmt(i));
908 }
909 }
910 out.join("\n")
911 }
912
913 pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
916 let cap = HELPER_CHARS.min(1500 + 400 * questions);
917 let t = &self.tests[test];
918 let text = self.read(&t.file).unwrap_or_default();
919 let mut out = BTreeMap::new();
920 let mut size = 0;
921 let files = match self.language {
922 Language::JavaScript => import_specifiers(&text)
923 .iter()
924 .filter_map(|spec| self.resolve(&t.file, spec))
925 .collect::<Vec<_>>(),
926 Language::Python => self.python_helpers(&t.file, &text),
927 Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
928 Language::Go => go::helpers(&self.root, &t.file, &text),
929 Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
930 Language::Jvm => jvm::imports(&text)
931 .iter()
932 .filter_map(|import| {
933 jvm::class_paths(&import.replace('/', "."))
934 .iter()
935 .find_map(|suffix| {
936 self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
937 })
938 .cloned()
939 })
940 .collect(),
941 };
942 for file in files {
943 if out.contains_key(&file) {
944 continue;
945 }
946 if self.source_files.contains(&file) || file.contains("node_modules") {
947 continue;
948 }
949 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
950 if size + body.len() > cap {
951 let keep = cap.saturating_sub(size);
952 let cut = body
953 .char_indices()
954 .map(|(i, _)| i)
955 .take_while(|&i| i <= keep)
956 .last()
957 .unwrap_or(0);
958 body = format!("{}\n... (cut)", &body[..cut]);
959 }
960 size += body.len();
961 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
962 if size >= cap {
963 break;
964 }
965 }
966 if self.language == Language::Ruby {
967 let mut used = 0;
968 for file in ruby::support_files(&self.root, &text) {
969 if out.contains_key(&file) || used >= FIXTURE_CHARS {
970 continue;
971 }
972 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
973 if used + body.len() > FIXTURE_CHARS {
974 let keep = FIXTURE_CHARS - used;
975 let cut = body
976 .char_indices()
977 .map(|(i, _)| i)
978 .take_while(|&i| i <= keep)
979 .last()
980 .unwrap_or(0);
981 body = format!("{}\n... (cut)", &body[..cut]);
982 }
983 used += body.len();
984 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
985 }
986 }
987 out
988 }
989
990 fn python_helpers(&self, from: &str, text: &str) -> Vec<String> {
994 let mut out = Vec::new();
995 let mut dir = Path::new(from).parent();
996 while let Some(d) = dir {
997 let conftest = d.join("conftest.py").to_string_lossy().replace('\\', "/");
998 if self.root.join(&conftest).is_file() {
999 out.push(conftest);
1000 }
1001 dir = d.parent();
1002 }
1003 let here = Path::new(from).parent().unwrap_or(Path::new(""));
1004 for module in python_imports(text) {
1005 let dots = module.chars().take_while(|c| *c == '.').count();
1006 let rest = module[dots..].replace('.', "/");
1007 let bases = if dots > 0 {
1008 let mut base = here.to_path_buf();
1009 for _ in 1..dots {
1010 base.pop();
1011 }
1012 vec![base]
1013 } else {
1014 vec![PathBuf::new(), here.to_path_buf(), PathBuf::from("src")]
1015 };
1016 for base in bases {
1017 let path = base.join(&rest);
1018 let path = path.to_string_lossy().replace('\\', "/");
1019 let found = [format!("{path}.py"), format!("{path}/__init__.py")]
1020 .into_iter()
1021 .find(|c| !c.starts_with('/') && self.root.join(c).is_file());
1022 if let Some(found) = found {
1023 out.push(found);
1024 break;
1025 }
1026 }
1027 }
1028 out
1029 }
1030
1031 fn resolve(&self, from: &str, spec: &str) -> Option<String> {
1032 let base = if let Some((alias, target)) = self
1033 .aliases
1034 .iter()
1035 .find(|(a, _)| spec.starts_with(a.as_str()))
1036 {
1037 PathBuf::from(format!("{target}{}", &spec[alias.len()..]))
1038 } else if spec.starts_with('.') {
1039 Path::new(from).parent().unwrap_or(Path::new("")).join(spec)
1040 } else {
1041 return None;
1042 };
1043 let s = normalize(&base);
1044 let candidates = [
1045 s.clone(),
1046 format!("{s}.ts"),
1047 format!("{s}.tsx"),
1048 format!("{s}.js"),
1049 format!("{s}.mjs"),
1050 s.replace(".js", ".ts"),
1051 format!("{s}/index.ts"),
1052 format!("{s}/index.tsx"),
1053 format!("{s}/index.js"),
1054 ];
1055 candidates.into_iter().find(|c| self.root.join(c).is_file())
1056 }
1057
1058 pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1065 let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1066 let ran = &self.ran[test];
1067 let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1068 for &a in asked {
1069 want.entry(self.statements[a].file.clone())
1070 .or_default()
1071 .insert(self.statements[a].line);
1072 }
1073 let mut files = want.keys().cloned().collect::<Vec<_>>();
1074 let mut others = ran
1075 .iter()
1076 .filter(|(f, _)| !want.contains_key(*f))
1077 .collect::<Vec<_>>();
1078 others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1079 files.extend(others.into_iter().map(|(f, _)| f.clone()));
1080 let empty = BTreeSet::new();
1081 let mut out = BTreeMap::new();
1082 let mut total = 0;
1083 for f in files {
1084 let Some(source) = self.read(&f) else {
1085 continue;
1086 };
1087 let text = source.split('\n').collect::<Vec<_>>();
1088 let r = ran.get(&f).unwrap_or(&empty);
1089 let code = (1..=text.len())
1090 .filter(|&i| !self.language.comment(text[i - 1]))
1091 .collect::<Vec<_>>();
1092 let pos = code
1093 .iter()
1094 .enumerate()
1095 .map(|(n, &i)| (i, n))
1096 .collect::<BTreeMap<_, _>>();
1097 let around = |l: usize, w: usize| -> Vec<usize> {
1098 match pos.get(&l) {
1099 Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1100 None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1101 }
1102 };
1103 let mut show = BTreeSet::new();
1104 for &l in r {
1105 show.extend(around(l, CONTEXT_LINES));
1106 }
1107 for &l in want.get(&f).unwrap_or(&empty) {
1108 show.extend(around(l, WINDOW_LINES));
1109 }
1110 let mut lines = Vec::new();
1111 let mut last = 0;
1112 for i in show {
1113 if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1114 continue;
1115 }
1116 if i != last + 1 {
1117 lines.push(format!(" ... {} lines not shown", i - last - 1));
1118 }
1119 lines.push(format!(
1120 "{}{:5} {}",
1121 if r.contains(&i) { '▶' } else { ' ' },
1122 i,
1123 text[i - 1]
1124 ));
1125 last = i;
1126 }
1127 let body = lines.join("\n");
1128 if !want.contains_key(&f) && total + body.len() > budget {
1129 continue;
1130 }
1131 total += body.len();
1132 out.insert(f, body);
1133 }
1134 out
1135 }
1136
1137 pub fn questions(&self, statement: usize) -> Vec<String> {
1139 let s = &self.statements[statement];
1140 let line = s.text.lines().next().unwrap_or("").trim();
1141 let changes = match (s.change, &s.replacement) {
1142 (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1143 if self.language == Language::Rust =>
1144 {
1145 vec![rust_question(line, s.change, r)]
1146 }
1147 (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1150 vec![format!(
1151 "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1152 )]
1153 }
1154 (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1155 vec![format!(
1156 "`{line}` becomes `{r}` (the original value is still computed first)"
1157 )]
1158 }
1159 _ => self.default_questions(s, line),
1160 };
1161 changes
1162 .into_iter()
1163 .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1164 .collect()
1165 }
1166
1167 fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1168 match s.change {
1169 Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1172 "`{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)"
1173 )],
1174 Change::ReturnUndefined
1177 if self.language == Language::Jvm
1178 && s.text.contains("return")
1179 && ["when", "switch", "try"].iter().any(|k| {
1180 line.strip_prefix(k)
1181 .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1182 }) =>
1183 {
1184 vec![format!(
1185 "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1186 self.language.nothing()
1187 )]
1188 }
1189 Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1190 "`{line}` returns {} instead (its expression still runs)",
1191 self.language.nothing()
1192 )],
1193 Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1194 let nothing = match self.language {
1195 Language::Rust
1196 if ["Ok(", "Err(", "return Ok(", "return Err("]
1197 .iter()
1198 .any(|p| line.starts_with(p)) =>
1199 {
1200 "`Ok(Default::default())`"
1201 }
1202 other => other.nothing(),
1203 };
1204 vec![if line.starts_with("return") {
1205 format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1206 } else {
1207 format!(
1210 "`{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}"
1211 )
1212 }]
1213 }
1214 Change::ValueUndefined if self.language == Language::Go => vec![format!(
1215 "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1216 )],
1217 Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1218 "`{line}`: after it runs, the variable it assigns holds {} instead",
1219 self.language.nothing()
1220 )],
1221 Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1222 let nothing = self.language.nothing();
1223 vec![match split_initializer(line) {
1224 Some(head) => format!(
1225 "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1226 ),
1227 None => format!("`{line}`: the assigned value becomes {nothing}"),
1228 }]
1229 }
1230 Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1231 "`{line}` becomes `return None` (the expression is not evaluated)"
1232 )],
1233 Change::ReturnUndefined => vec![format!(
1234 "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1235 )],
1236 Change::ValueUndefined if self.language == Language::Python => {
1237 vec![match split_initializer(line) {
1238 Some(head) => {
1239 format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1240 }
1241 None => format!("`{line}`: the assigned value becomes None"),
1242 }]
1243 }
1244 Change::ValueUndefined => vec![match split_initializer(line) {
1245 Some(head) => format!(
1246 "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1247 ),
1248 None => format!("`{line}`: the declared value becomes undefined"),
1249 }],
1250 Change::Skip => {
1251 let word = line.trim_end_matches(';').trim();
1252 vec![match word {
1253 "break" => format!(
1254 "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1255 ),
1256 "continue" | "next" => format!(
1257 "`{line}` is deleted: the rest of the loop body runs in this iteration"
1258 ),
1259 _ => format!("`{line}` is deleted (it never runs)"),
1260 }]
1261 }
1262 Change::ExpressionUndefined => vec![format!(
1263 "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1264 )],
1265 Change::Invert => {
1266 let c = match self.language {
1267 Language::Python => python_condition(line),
1268 Language::JavaScript => condition(line),
1269 Language::Ruby => ruby::condition(line),
1270 Language::Go => go::condition(line),
1271 Language::Rust => rust_source::condition(line),
1272 Language::Jvm => jvm::condition(line),
1273 };
1274 if c.starts_with("let ") {
1277 return vec![format!(
1278 "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1279 )];
1280 }
1281 vec![
1282 format!(
1283 "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1284 ),
1285 format!(
1286 "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1287 ),
1288 ]
1289 }
1290 }
1291 }
1292
1293 pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1295 let t = &self.tests[test];
1296 let mut questions = serde_json::Map::new();
1297 for (n, &a) in asked.iter().enumerate() {
1298 for (j, q) in self.questions(a).into_iter().enumerate() {
1299 questions.insert(
1300 format!("q{n}_{j}"),
1301 json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1302 );
1303 }
1304 }
1305 json!({"model": model, "state": {
1306 "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1307 "test": {"file": t.file, "name": t.name},
1308 "test_code": self.test_code(test), "helpers": self.helpers(test, asked.len()),
1309 "code_run": self.code_run(test, asked)}, "questions": questions})
1310 }
1311
1312 pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1317 let t = &self.tests[test];
1318 let parts = json!([
1319 model,
1320 salt,
1321 INSTRUCTIONS,
1322 runtime(&t.runner),
1323 true,
1324 &t.file,
1325 &t.name,
1326 strip_numbers(&self.test_code(test)),
1327 self.helpers(test, 1)
1328 .into_iter()
1329 .map(|(f, b)| (f, strip_numbers(&b)))
1330 .collect::<BTreeMap<_, _>>(),
1331 self.code_run(test, &[statement])
1332 .into_iter()
1333 .map(|(f, b)| (f, strip_numbers(&b)))
1334 .collect::<BTreeMap<_, _>>(),
1335 self.questions(statement)
1336 .iter()
1337 .map(|q| strip_location(q))
1338 .collect::<Vec<_>>(),
1339 ]);
1340 format!(
1341 "{:x}",
1342 Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1343 )
1344 }
1345}
1346
1347fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1349 let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1350 match change {
1351 Change::ReturnUndefined if line.starts_with("return") => {
1352 format!("`{line}` becomes `return {repl};`")
1353 }
1354 Change::ReturnUndefined => {
1355 format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1356 }
1357 _ => match split_initializer(line) {
1358 Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1359 None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1360 },
1361 }
1362}
1363
1364pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
1366 use std::io::Write;
1367 let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
1368 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1369 encoder.write_all(&bytes).map_err(|e| e.to_string())?;
1370 let compressed = encoder.finish().map_err(|e| e.to_string())?;
1371 let temp = path.with_extension("tmp");
1372 std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
1373 std::fs::rename(&temp, path).map_err(|e| e.to_string())
1374}
1375
1376pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
1378 use std::io::Read;
1379 let compressed = std::fs::read(path).ok()?;
1380 let mut text = Vec::new();
1381 flate2::read::GzDecoder::new(compressed.as_slice())
1382 .read_to_end(&mut text)
1383 .ok()?;
1384 serde_json::from_slice(&text).ok()
1385}
1386
1387struct Located {
1390 context: Vec<usize>,
1391 start: usize,
1392 end: usize,
1393}
1394
1395fn indent(line: &str) -> usize {
1396 line.len() - line.trim_start().len()
1397}
1398
1399fn brace_end(lines: &[&str], start: usize) -> usize {
1402 let mut depth = 0i64;
1403 let mut opened = false;
1404 for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1405 let mut chars = line.chars().peekable();
1406 let mut quote: Option<char> = None;
1407 while let Some(c) = chars.next() {
1408 match (quote, c) {
1409 (Some(_), '\\') => {
1410 chars.next();
1411 }
1412 (Some(q), c) if c == q => quote = None,
1413 (Some(_), _) => {}
1414 (None, '"' | '`') => quote = Some(c),
1415 (None, '/') if chars.peek() == Some(&'/') => break,
1416 (None, '{') => {
1417 depth += 1;
1418 opened = true;
1419 }
1420 (None, '}') => depth -= 1,
1421 _ => {}
1422 }
1423 }
1424 if opened && depth <= 0 {
1425 return i;
1426 }
1427 }
1428 (start + BODY_LINES).min(lines.len()).saturating_sub(1)
1429}
1430
1431fn keyword_end(lines: &[&str], start: usize) -> usize {
1433 let first = lines[start].trim_end();
1434 if first.ends_with(" end") || first.ends_with('}') {
1435 return start;
1436 }
1437 let depth = indent(lines[start]);
1438 (start + 1..lines.len().min(start + BODY_LINES))
1439 .find(|&i| {
1440 let t = lines[i].trim_start();
1441 indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
1442 })
1443 .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
1444}
1445
1446const DEFINITION_LINES: usize = 120;
1447
1448fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
1451 let words = |range: std::ops::RangeInclusive<usize>| {
1452 let mut out = BTreeSet::new();
1453 for i in range {
1454 let line = lines.get(i).copied().unwrap_or("");
1455 out.extend(
1456 line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1457 .filter(|w| !w.is_empty())
1458 .map(str::to_owned),
1459 );
1460 }
1461 out
1462 };
1463 let definitions = definitions(lines, language);
1464 let mut wanted = words(start..=end);
1465 let mut picked = BTreeSet::new();
1466 let mut budget = DEFINITION_LINES;
1467 for _ in 0..2 {
1468 let mut next = BTreeSet::new();
1469 for (name, a, b) in &definitions {
1470 let (a, b) = (*a, *b);
1471 if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
1472 continue;
1473 }
1474 let size = b - a + 1;
1475 if size > budget {
1476 continue;
1477 }
1478 budget -= size;
1479 picked.insert(a);
1480 next.extend(words(a..=b));
1481 picked.extend(a..=b);
1482 }
1483 wanted = next;
1484 }
1485 picked.into_iter().collect()
1486}
1487
1488fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
1491 let ident = |s: &str| {
1492 s.chars()
1493 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
1494 .collect::<String>()
1495 };
1496 let mut out = Vec::new();
1497 for (i, line) in lines.iter().enumerate() {
1498 let t = line.trim_start();
1499 let name = match language {
1500 Language::Go => t
1501 .strip_prefix("func ")
1502 .map(|r| match r.strip_prefix('(') {
1503 Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
1504 None => r,
1505 })
1506 .or_else(|| t.strip_prefix("var "))
1507 .or_else(|| t.strip_prefix("type "))
1508 .filter(|_| indent(line) == 0)
1509 .map(ident),
1510 Language::Rust => {
1511 let r = t.strip_prefix("pub ").unwrap_or(t);
1512 let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
1513 ["fn ", "const ", "static ", "struct ", "enum "]
1514 .iter()
1515 .find_map(|k| r.strip_prefix(k))
1516 .map(ident)
1517 }
1518 Language::Ruby => t
1519 .strip_prefix("def ")
1520 .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
1521 .or_else(|| {
1522 ["let(:", "let!(:", "subject(:"]
1523 .iter()
1524 .find_map(|k| t.strip_prefix(k))
1525 .map(ident)
1526 })
1527 .or_else(|| {
1528 (t.starts_with("subject ") || t.starts_with("subject{"))
1529 .then(|| "subject".into())
1530 }),
1531 Language::Jvm => {
1532 if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
1533 Some(ident(r.1.trim_start_matches('`')))
1534 } else if t.contains('(')
1535 && !t.ends_with(';')
1536 && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
1537 && ![
1538 "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
1539 "throw", "assert", "super", "this",
1540 ]
1541 .iter()
1542 .any(|k| t.starts_with(k))
1543 {
1544 let head = t.split('(').next().unwrap_or("");
1545 let mut parts = head.split_whitespace().rev();
1546 let name = parts.next().unwrap_or("");
1547 (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
1548 } else {
1549 None
1550 }
1551 }
1552 _ => None,
1553 };
1554 let Some(name) = name.filter(|n| !n.is_empty()) else {
1555 continue;
1556 };
1557 let end = match language {
1558 Language::Ruby => keyword_end(lines, i),
1559 _ if line.contains('{')
1560 || lines
1561 .get(i + 1)
1562 .is_some_and(|n| n.trim_start().starts_with('{')) =>
1563 {
1564 brace_end(lines, i)
1565 }
1566 _ => i,
1567 };
1568 out.push((name, i, end));
1569 }
1570 out
1571}
1572
1573fn ends_with_path(path: &str, suffix: &str) -> bool {
1575 path.strip_suffix(suffix)
1576 .is_some_and(|head| head.is_empty() || head.ends_with('/'))
1577}
1578
1579fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
1582 let mut out = ignore::WalkBuilder::new(root)
1583 .build()
1584 .filter_map(Result::ok)
1585 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1586 .filter(|e| {
1587 e.path()
1588 .extension()
1589 .and_then(|x| x.to_str())
1590 .is_some_and(|x| extensions.contains(&x))
1591 })
1592 .filter_map(|e| {
1593 e.path()
1594 .strip_prefix(root)
1595 .ok()
1596 .map(|p| p.to_string_lossy().replace('\\', "/"))
1597 })
1598 .filter(|p| !p.starts_with(".supercov/"))
1599 .collect::<Vec<_>>();
1600 out.sort();
1601 out
1602}
1603
1604fn is_test_call(line: &str) -> bool {
1605 let t = line.trim_start();
1606 let rest = if let Some(r) = t.strip_prefix("it") {
1607 r
1608 } else if let Some(r) = t.strip_prefix("test") {
1609 r
1610 } else {
1611 return false;
1612 };
1613 let rest = if let Some(r) = rest.strip_prefix('.') {
1614 r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
1615 } else {
1616 rest
1617 };
1618 rest.trim_start().starts_with('(')
1619}
1620
1621fn split_initializer(line: &str) -> Option<String> {
1623 let b = line.as_bytes();
1624 for i in 1..b.len() {
1625 if b[i] == b'='
1626 && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
1627 && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
1628 {
1629 return Some(line[..=i].to_owned());
1630 }
1631 }
1632 None
1633}
1634
1635fn python_title(name: &str) -> &str {
1639 let last = name.rsplit("::").next().unwrap_or(name);
1640 let last = last.split('[').next().unwrap_or(last);
1641 last.rsplit('.').next().unwrap_or(last)
1642}
1643
1644fn python_condition(line: &str) -> String {
1645 let t = line.trim();
1646 let t = t
1647 .strip_prefix("elif")
1648 .or_else(|| t.strip_prefix("if"))
1649 .map(str::trim_start)
1650 .unwrap_or(t);
1651 t.strip_suffix(':')
1652 .map(str::trim_end)
1653 .unwrap_or(t)
1654 .to_owned()
1655}
1656
1657fn python_imports(text: &str) -> Vec<String> {
1660 let mut out = Vec::new();
1661 for line in text.split('\n') {
1662 let t = line.trim_start();
1663 if let Some(rest) = t.strip_prefix("from ") {
1664 let Some((module, names)) = rest.split_once(" import ") else {
1665 continue;
1666 };
1667 let module = module.trim();
1668 if module.chars().all(|c| c == '.') {
1669 for name in names.trim_matches(|c| c == '(' || c == ')').split(',') {
1670 let name = name.split(" as ").next().unwrap_or("").trim();
1671 if !name.is_empty() && name != "*" {
1672 out.push(format!("{module}{name}"));
1673 }
1674 }
1675 } else {
1676 out.push(module.to_owned());
1677 }
1678 } else if let Some(rest) = t.strip_prefix("import ") {
1679 for part in rest.split(',') {
1680 let module = part.split(" as ").next().unwrap_or("").trim();
1681 if !module.is_empty() {
1682 out.push(module.to_owned());
1683 }
1684 }
1685 }
1686 }
1687 out
1688}
1689
1690fn condition(line: &str) -> String {
1692 let mut t = line.trim();
1693 if let Some(r) = t.strip_prefix('}') {
1694 let r = r.trim_start();
1695 if let Some(r) = r.strip_prefix("else") {
1696 t = r.trim_start();
1697 }
1698 }
1699 let Some(r) = t.strip_prefix("if") else {
1700 return line.to_owned();
1701 };
1702 let r = r.trim_start();
1703 let Some(r) = r.strip_prefix('(') else {
1704 return line.to_owned();
1705 };
1706 let r = r.trim_end();
1707 let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
1708 match r.strip_suffix(')') {
1709 Some(c) => c.to_owned(),
1710 None => line.to_owned(),
1711 }
1712}
1713
1714fn strip_numbers(text: &str) -> String {
1715 text.split('\n')
1716 .map(|l| {
1717 let (mark, rest) = match l.chars().next() {
1718 Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
1719 _ => (None, l),
1720 };
1721 let trimmed = rest.trim_start();
1722 let digits = trimmed.len()
1723 - trimmed
1724 .trim_start_matches(|c: char| c.is_ascii_digit())
1725 .len();
1726 if digits > 0 && trimmed[digits..].starts_with(' ') {
1727 format!(
1728 "{}{}",
1729 mark.map(String::from).unwrap_or_default(),
1730 &trimmed[digits + 1..]
1731 )
1732 } else if let Some(n) = l
1733 .trim_start()
1734 .strip_prefix("... ")
1735 .and_then(|r| r.split_once(" lines not shown"))
1736 {
1737 let _ = n;
1738 "...".to_owned()
1739 } else {
1740 l.to_owned()
1741 }
1742 })
1743 .collect::<Vec<_>>()
1744 .join("\n")
1745}
1746
1747fn strip_location(question: &str) -> String {
1748 match question.split_once(": ") {
1750 Some((loc, rest)) => match loc.rsplit_once(':') {
1751 Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
1752 format!("{file}:: {rest}")
1753 }
1754 _ => question.to_owned(),
1755 },
1756 None => question.to_owned(),
1757 }
1758}
1759
1760fn import_specifiers(text: &str) -> Vec<String> {
1761 let mut out = Vec::new();
1762 for line in text.split('\n') {
1763 let t = line.trim_start();
1764 let quoted = |s: &str| -> Option<String> {
1765 let s = s.trim_start();
1766 let q = s.chars().next().filter(|c| *c == '\'' || *c == '"')?;
1767 let rest = &s[1..];
1768 rest.find(q).map(|end| rest[..end].to_owned())
1769 };
1770 if (t.starts_with("import ") || t.starts_with("export "))
1771 && let Some(i) = t.find(" from ")
1772 {
1773 out.extend(quoted(&t[i + 6..]));
1774 } else if let Some(i) = t.find("import(") {
1775 out.extend(quoted(&t[i + 7..]));
1776 } else if let Some(i) = t.find("require(") {
1777 out.extend(quoted(&t[i + 8..]));
1778 } else if let Some(r) = t.strip_prefix("} from ") {
1779 out.extend(quoted(r));
1780 }
1781 }
1782 out
1783}
1784
1785fn normalize(path: &Path) -> String {
1789 let mut out = Vec::new();
1790 for c in path.components() {
1791 match c {
1792 std::path::Component::ParentDir => {
1793 out.pop();
1794 }
1795 std::path::Component::CurDir => {}
1796 other => out.push(other.as_os_str().to_string_lossy().into_owned()),
1797 }
1798 }
1799 out.join("/")
1800}
1801
1802fn tsconfig_aliases(root: &Path) -> Vec<(String, String)> {
1805 let Ok(text) = std::fs::read_to_string(root.join("tsconfig.json")) else {
1806 return vec![];
1807 };
1808 let cleaned = text
1809 .lines()
1810 .filter(|l| !l.trim_start().starts_with("//"))
1811 .collect::<Vec<_>>()
1812 .join("\n");
1813 let Ok(config) = serde_json::from_str::<Value>(&cleaned) else {
1814 return vec![];
1815 };
1816 let mut out = Vec::new();
1817 if let Some(paths) = config["compilerOptions"]["paths"].as_object() {
1818 for (alias, targets) in paths {
1819 let Some(target) = targets
1820 .as_array()
1821 .and_then(|t| t.first())
1822 .and_then(Value::as_str)
1823 else {
1824 continue;
1825 };
1826 let (Some(a), Some(t)) = (alias.strip_suffix('*'), target.strip_suffix('*')) else {
1827 continue;
1828 };
1829 out.push((a.to_owned(), t.trim_start_matches("./").to_owned()));
1830 }
1831 }
1832 out
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837 use super::*;
1838
1839 #[test]
1840 fn changes_follow_the_audit() {
1841 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";
1842 let starts = statement_starts("x.tsx", source);
1843 let js = Language::JavaScript;
1844 assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
1845 assert_eq!(
1846 change_at(&starts, 2, "const a = 1", js),
1847 Some(Change::ValueUndefined)
1848 );
1849 assert_eq!(change_at(&starts, 3, "let b: number", js), None);
1850 assert_eq!(
1851 change_at(&starts, 5, "if (a) return 2", js),
1852 Some(Change::Invert)
1853 );
1854 assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
1855 assert_eq!(
1856 change_at(&starts, 8, "a && b", js),
1857 Some(Change::ExpressionUndefined)
1858 );
1859 }
1860
1861 #[test]
1862 fn python_changes_follow_the_audit() {
1863 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";
1864 let starts = python_statement_starts(source);
1865 let py = Language::Python;
1866 assert_eq!(change_at(&starts, 1, "import os", py), None);
1867 assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
1868 assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
1869 assert_eq!(
1870 change_at(&starts, 6, "y = x + 1", py),
1871 Some(Change::ValueUndefined)
1872 );
1873 assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
1874 assert_eq!(
1875 change_at(&starts, 8, "return y", py),
1876 Some(Change::ReturnUndefined)
1877 );
1878 assert_eq!(change_at(&starts, 9, "z: int", py), None);
1879 assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
1880 assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
1881 assert_eq!(
1882 change_at(&starts, 15, "v is None", py),
1883 Some(Change::Invert)
1884 );
1885 assert_eq!(
1886 python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
1887 "test_b"
1888 );
1889 assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
1890 assert_eq!(python_condition("if y > 2:"), "y > 2");
1891 assert_eq!(python_condition("elif v is None:"), "v is None");
1892 assert_eq!(
1893 python_imports(
1894 "from .helpers import make\nfrom . import fixtures, util as u\nimport tests.support, json as j\n"
1895 ),
1896 vec![".helpers", ".fixtures", ".util", "tests.support", "json"]
1897 );
1898 }
1899
1900 #[test]
1901 fn change_texts() {
1902 assert_eq!(
1903 split_initializer("const x = a === b").as_deref(),
1904 Some("const x =")
1905 );
1906 assert_eq!(
1907 split_initializer("const f = () => 1").as_deref(),
1908 Some("const f =")
1909 );
1910 assert_eq!(condition("} else if (x > 1) {"), "x > 1");
1911 assert_eq!(
1912 condition("if (!body) return null"),
1913 "if (!body) return null"
1914 );
1915 assert!(is_test_call(" it(\"works\", () => {"));
1916 assert!(is_test_call("test.each([1])('x', () => {"));
1917 assert!(!is_test_call("items.push(1)"));
1918 }
1919
1920 #[test]
1921 fn keys_ignore_line_numbers() {
1922 assert_eq!(
1923 strip_numbers("▶ 12 return x\n ... 3 lines not shown\n 15 }"),
1924 "▶ return x\n...\n }"
1925 );
1926 assert_eq!(
1927 strip_location("src/a.ts:41: `x` is deleted."),
1928 "src/a.ts:: `x` is deleted."
1929 );
1930 }
1931}