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