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 statements.push(Statement {
478 file: file.clone(),
479 line,
480 text: text.clone(),
481 change,
482 replacement,
483 tests: ids,
484 });
485 }
486 let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
487 for s in &statements {
488 for &t in &s.tests {
489 ran[t].entry(s.file.clone()).or_default().insert(s.line);
490 }
491 }
492 let source_files = statements.iter().map(|s| s.file.clone()).collect();
493 Ok(Population {
494 root: root.to_path_buf(),
495 language,
496 statements,
497 tests,
498 ran,
499 source_files,
500 jvm_files,
501 files: Default::default(),
502 quotes: Default::default(),
503 })
504}
505
506fn name_words(text: &str) -> BTreeSet<String> {
509 const FILLER: [&str; 13] = [
510 "test", "tests", "the", "and", "for", "with", "get", "set", "self", "from", "not", "does",
511 "should",
512 ];
513 let mut words = BTreeSet::new();
514 let chars = text.chars().collect::<Vec<_>>();
515 let mut word = String::new();
516 let mut flush = |word: &mut String| {
517 let w = word.to_lowercase();
518 if w.len() > 2 && !FILLER.contains(&w.as_str()) {
519 words.insert(w);
520 }
521 word.clear();
522 };
523 for (i, &c) in chars.iter().enumerate() {
524 if !c.is_ascii_alphanumeric() {
525 flush(&mut word);
526 continue;
527 }
528 let previous = i.checked_sub(1).map(|p| chars[p]);
529 let next = chars.get(i + 1).copied();
530 let starts = c.is_ascii_uppercase()
533 && (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
534 || (previous.is_some_and(|p| p.is_ascii_uppercase())
535 && next.is_some_and(|n| n.is_ascii_lowercase())));
536 if starts {
537 flush(&mut word);
538 }
539 word.push(c);
540 }
541 flush(&mut word);
542 words
543}
544
545fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
548 let allocator = Allocator::default();
549 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
550 let parsed = Parser::new(&allocator, source, source_type).parse();
551 let starts = line_starts(source);
552 struct Collector<'s> {
553 source: &'s str,
554 starts: &'s [usize],
555 typescript: bool,
556 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
557 }
558 impl<'a> Visit<'a> for Collector<'_> {
559 fn visit_statement(&mut self, statement: &JsStatement<'a>) {
560 let span = statement.span();
561 let line = line_of(self.starts, span.start as usize);
562 let text = self
563 .source
564 .get(span.start as usize..span.end as usize)
565 .unwrap_or("")
566 .to_owned();
567 self.out.entry(line).or_default().push((
568 text,
569 match statement {
570 JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
571 _ => change_of(statement),
572 },
573 ));
574 walk::walk_statement(self, statement);
575 }
576 }
577 let mut collector = Collector {
578 source,
579 starts: &starts,
580 typescript: source_type.is_typescript(),
581 out: BTreeMap::new(),
582 };
583 collector.visit_program(&parsed.program);
584 collector.out
585}
586
587fn is_undefined(expression: &Expression) -> bool {
589 match expression {
590 Expression::Identifier(identifier) => identifier.name == "undefined",
591 Expression::UnaryExpression(unary) => {
592 unary.operator == oxc_syntax::operator::UnaryOperator::Void
593 }
594 _ => false,
595 }
596}
597
598fn change_of(statement: &JsStatement) -> Option<Change> {
599 Some(match statement {
600 JsStatement::IfStatement(_) => Change::Invert,
601 JsStatement::ReturnStatement(r) => match &r.argument {
604 Some(argument) if is_undefined(argument) => Change::Skip,
605 Some(Expression::BooleanLiteral(_)) => Change::Flip,
606 Some(_) => Change::ReturnUndefined,
607 None => Change::Skip,
608 },
609 JsStatement::VariableDeclaration(d) => {
610 if let [only] = d.declarations.as_slice()
611 && matches!(only.init, Some(Expression::BooleanLiteral(_)))
612 {
613 Change::Flip
614 } else if d.declarations.iter().any(|d| d.init.is_some()) {
615 Change::ValueUndefined
616 } else {
617 return None;
618 }
619 }
620 JsStatement::ExpressionStatement(e)
623 if matches!(&e.expression, Expression::CallExpression(call)
624 if matches!(call.callee, Expression::Super(_))) =>
625 {
626 return None;
627 }
628 JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
629 Some(Declaration::VariableDeclaration(d))
630 if d.declarations.iter().any(|d| d.init.is_some()) =>
631 {
632 Change::ValueUndefined
633 }
634 _ => return None,
635 },
636 JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
637 ExportDefaultDeclarationKind::FunctionDeclaration(_)
638 | ExportDefaultDeclarationKind::ClassDeclaration(_)
639 | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
640 _ => Change::Skip,
641 },
642 JsStatement::ImportDeclaration(_)
643 | JsStatement::ExportAllDeclaration(_)
644 | JsStatement::FunctionDeclaration(_)
645 | JsStatement::ClassDeclaration(_)
646 | JsStatement::TSTypeAliasDeclaration(_)
647 | JsStatement::TSInterfaceDeclaration(_)
648 | JsStatement::TSEnumDeclaration(_)
649 | JsStatement::TSModuleDeclaration(_)
650 | JsStatement::TSGlobalDeclaration(_)
651 | JsStatement::TSImportEqualsDeclaration(_)
652 | JsStatement::TSExportAssignment(_)
653 | JsStatement::TSNamespaceExportDeclaration(_) => return None,
654 _ => Change::Skip,
655 })
656}
657
658fn change_at(
662 starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
663 line: usize,
664 text: &str,
665 language: Language,
666) -> Option<Change> {
667 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
668 let found = starts.get(&line).and_then(|all| {
669 all.iter().find(|(t, _)| {
670 let bare = t
671 .strip_prefix("export ")
672 .map(|t| t.strip_prefix("default ").unwrap_or(t))
673 .unwrap_or(t);
674 bare.starts_with(first) || t.starts_with(first)
675 })
676 });
677 match (found, language) {
678 (Some((_, change)), _) => *change,
679 (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
680 (None, _) => None,
683 }
684}
685
686fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
691 use ruff_python_ast::{
692 Expr, Stmt,
693 visitor::{Visitor, walk_stmt},
694 };
695 use ruff_text_size::Ranged;
696 struct Collector<'s> {
697 source: &'s str,
698 starts: Vec<usize>,
699 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
700 }
701 impl<'a> Visitor<'a> for Collector<'_> {
702 fn visit_stmt(&mut self, stmt: &'a Stmt) {
703 let change = match stmt {
704 Stmt::If(_) => Some(Change::Invert),
705 Stmt::Return(r) => Some(match r.value.as_deref() {
706 Some(Expr::NoneLiteral(_)) | None => Change::Skip,
708 Some(Expr::BooleanLiteral(_)) => Change::Flip,
709 Some(_) => Change::ReturnUndefined,
710 }),
711 Stmt::Assign(a)
712 if a.targets.len() == 1
713 && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
714 {
715 Some(Change::Flip)
716 }
717 Stmt::Assign(_) => Some(Change::ValueUndefined),
718 Stmt::AnnAssign(a)
719 if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
720 {
721 Some(Change::Flip)
722 }
723 Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
724 Stmt::Import(_)
725 | Stmt::ImportFrom(_)
726 | Stmt::FunctionDef(_)
727 | Stmt::ClassDef(_)
728 | Stmt::Pass(_)
729 | Stmt::Global(_)
730 | Stmt::Nonlocal(_)
731 | Stmt::TypeAlias(_)
732 | Stmt::IpyEscapeCommand(_) => None,
733 Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
734 _ => Some(Change::Skip),
735 };
736 let range = stmt.range();
737 let (start, end) = (range.start().to_usize(), range.end().to_usize());
738 let line = line_of(&self.starts, start);
739 let text = self.source.get(start..end).unwrap_or("").to_owned();
740 self.out.entry(line).or_default().push((text, change));
741 if let Stmt::If(s) = stmt {
743 for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
745 let start = test.range().start().to_usize();
746 let text = self.source.get(start..test.range().end().to_usize());
747 self.out
748 .entry(line_of(&self.starts, start))
749 .or_default()
750 .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
751 }
752 }
753 walk_stmt(self, stmt);
754 }
755 }
756 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
757 return BTreeMap::new();
758 };
759 let mut collector = Collector {
760 source,
761 starts: line_starts(source),
762 out: BTreeMap::new(),
763 };
764 for stmt in &parsed.syntax().body {
765 collector.visit_stmt(stmt);
766 }
767 collector.out
768}
769
770fn prefix(text: &str, chars: usize) -> &str {
771 match text.char_indices().nth(chars) {
772 Some((i, _)) => &text[..i],
773 None => text,
774 }
775}
776
777fn outermost_functions(language: Language, file: &str, source: &str) -> Vec<(usize, usize)> {
782 let starts = line_starts(source);
783 let lines = |from: usize, to: usize| {
784 (
785 line_of(&starts, from),
786 line_of(&starts, to.saturating_sub(1).max(from)),
787 )
788 };
789 let mut spans = match language {
790 Language::JavaScript => {
791 let allocator = Allocator::default();
792 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
793 let parsed = Parser::new(&allocator, source, source_type).parse();
794 #[derive(Default)]
795 struct Functions {
796 within: Option<(u32, u32)>,
800 found: Vec<(u32, u32)>,
801 }
802 impl Functions {
803 fn inside(&mut self, span: (u32, u32), walk: impl FnOnce(&mut Self)) {
804 let outer = self.within.replace(span);
805 walk(self);
806 self.within = outer;
807 }
808 fn function(&mut self, own: oxc_span::Span) {
809 self.found.push(self.within.unwrap_or((own.start, own.end)));
810 }
811 }
812 fn from_decorators(
813 span: oxc_span::Span,
814 decorators: &[oxc_ast::ast::Decorator<'_>],
815 ) -> (u32, u32) {
816 (
817 decorators
818 .first()
819 .map_or(span.start, |first| first.span.start.min(span.start)),
820 span.end,
821 )
822 }
823 impl<'a> Visit<'a> for Functions {
824 fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) {
825 for statement in &program.body {
826 let span = statement.span();
827 self.inside((span.start, span.end), |this| {
828 this.visit_statement(statement)
829 });
830 }
831 }
832 fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) {
833 for statement in &block.body {
834 let span = statement.span();
835 self.inside((span.start, span.end), |this| {
836 this.visit_statement(statement)
837 });
838 }
839 }
840 fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) {
841 for statement in &block.body {
842 let span = statement.span();
843 self.inside((span.start, span.end), |this| {
844 this.visit_statement(statement)
845 });
846 }
847 }
848 fn visit_method_definition(&mut self, it: &oxc_ast::ast::MethodDefinition<'a>) {
849 self.inside(from_decorators(it.span, &it.decorators), |this| {
850 walk::walk_method_definition(this, it)
851 });
852 }
853 fn visit_property_definition(&mut self, it: &oxc_ast::ast::PropertyDefinition<'a>) {
854 self.inside(from_decorators(it.span, &it.decorators), |this| {
855 walk::walk_property_definition(this, it)
856 });
857 }
858 fn visit_accessor_property(&mut self, it: &oxc_ast::ast::AccessorProperty<'a>) {
859 self.inside(from_decorators(it.span, &it.decorators), |this| {
860 walk::walk_accessor_property(this, it)
861 });
862 }
863 fn visit_static_block(&mut self, it: &oxc_ast::ast::StaticBlock<'a>) {
864 self.inside((it.span.start, it.span.end), |this| {
865 walk::walk_static_block(this, it)
866 });
867 }
868 fn visit_function(
870 &mut self,
871 it: &oxc_ast::ast::Function<'a>,
872 _flags: oxc_syntax::scope::ScopeFlags,
873 ) {
874 if it.body.is_some() {
875 self.function(it.span);
876 }
877 }
878 fn visit_arrow_function_expression(
879 &mut self,
880 it: &oxc_ast::ast::ArrowFunctionExpression<'a>,
881 ) {
882 self.function(it.span);
883 }
884 }
885 let mut functions = Functions::default();
886 functions.visit_program(&parsed.program);
887 functions
888 .found
889 .into_iter()
890 .map(|(from, to)| lines(from as usize, to as usize))
891 .collect::<Vec<_>>()
892 }
893 Language::Python => {
894 use ruff_python_ast::{
895 Stmt,
896 visitor::{Visitor, walk_stmt},
897 };
898 use ruff_text_size::Ranged;
899 struct Functions(Vec<(usize, usize)>);
900 impl<'a> Visitor<'a> for Functions {
901 fn visit_stmt(&mut self, stmt: &'a Stmt) {
902 match stmt {
903 Stmt::FunctionDef(function) => {
904 let from = function
905 .decorator_list
906 .first()
907 .map_or(function.start(), |first| {
908 first.start().min(function.start())
909 });
910 self.0.push((from.to_usize(), function.end().to_usize()));
911 }
912 _ => walk_stmt(self, stmt),
913 }
914 }
915 }
916 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
917 return Vec::new();
918 };
919 let mut functions = Functions(Vec::new());
920 for stmt in &parsed.syntax().body {
921 functions.visit_stmt(stmt);
922 }
923 functions
924 .0
925 .into_iter()
926 .map(|(from, to)| lines(from, to))
927 .collect()
928 }
929 _ => Vec::new(),
930 };
931 spans.sort_unstable();
932 spans.dedup();
933 spans
934}
935
936fn whole_statement(statement: &Statement) -> Option<usize> {
939 let lines = statement.text.lines().count();
940 let word = statement
941 .text
942 .lines()
943 .next()
944 .unwrap_or("")
945 .trim()
946 .trim_end_matches(';')
947 .trim();
948 (statement.change == Change::Skip
949 && lines > 1
950 && !matches!(word, "break" | "continue" | "next"))
951 .then_some(lines)
952}
953
954fn line_starts(source: &str) -> Vec<usize> {
955 std::iter::once(0)
956 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
957 .collect()
958}
959
960fn line_of(starts: &[usize], offset: usize) -> usize {
961 starts.partition_point(|&s| s <= offset)
962}
963
964fn numbered(lines: &[&str]) -> String {
965 lines
966 .iter()
967 .enumerate()
968 .map(|(i, l)| format!("{:5} {l}", i + 1))
969 .collect::<Vec<_>>()
970 .join("\n")
971}
972
973fn quoted_fragments(text: &str) -> Vec<String> {
978 let mut out = Vec::new();
979 let mut chars = text.char_indices().peekable();
980 while let Some((start, quote)) = chars.next() {
981 if !matches!(quote, '\'' | '"' | '`') {
982 continue;
983 }
984 let mut end = None;
985 while let Some((at, c)) = chars.next() {
986 if c == '\\' {
987 chars.next();
988 } else if c == quote {
989 end = Some(at);
990 break;
991 }
992 }
993 let Some(end) = end else { break };
994 let body = &text[start + quote.len_utf8()..end];
995 let mut pieces = vec![String::new()];
998 let mut depth = 0usize;
999 for c in body.chars() {
1000 match c {
1001 '{' => {
1002 depth += 1;
1003 pieces.push(String::new());
1004 }
1005 '}' if depth > 0 => depth -= 1,
1006 '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
1007 _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
1008 _ => {}
1009 }
1010 }
1011 for piece in pieces {
1012 let piece = piece.trim();
1013 if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
1014 out.push(piece.to_owned());
1015 }
1016 }
1017 }
1018 out.sort();
1019 out.dedup();
1020 out
1021}
1022
1023fn stem(path: &str) -> String {
1024 Path::new(path)
1025 .file_name()
1026 .and_then(|n| n.to_str())
1027 .unwrap_or("")
1028 .split('.')
1029 .next()
1030 .unwrap_or("")
1031 .to_lowercase()
1032}
1033
1034fn describe(name: &str, separator: &str) -> String {
1035 let parts = name.split(separator).collect::<Vec<_>>();
1036 parts[..parts.len().saturating_sub(1)].join(separator)
1037}
1038
1039impl Population {
1040 pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
1043 {
1044 let mut cache = self
1045 .files
1046 .lock()
1047 .expect("the file cache is only read and filled");
1048 for (file, text) in files {
1049 cache.insert(file, Some(text.into()));
1050 }
1051 }
1052 self
1053 }
1054
1055 pub fn files_read(&self) -> BTreeMap<String, String> {
1057 self.files
1058 .lock()
1059 .expect("the file cache is only read and filled")
1060 .iter()
1061 .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
1062 .collect()
1063 }
1064
1065 fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
1066 let mut files = self
1067 .files
1068 .lock()
1069 .expect("the file cache is only read and filled");
1070 files
1071 .entry(file.to_owned())
1072 .or_insert_with(|| {
1073 std::fs::read_to_string(self.root.join(file))
1074 .ok()
1075 .map(Into::into)
1076 })
1077 .clone()
1078 }
1079
1080 fn quotes(&self, test: usize, fragments: &[String]) -> bool {
1082 let file = &self.tests[test].file;
1083 fragments.iter().any(|fragment| {
1084 let key = (file.clone(), fragment.clone());
1085 if let Some(&known) = self
1086 .quotes
1087 .lock()
1088 .expect("the quote cache is only read and filled")
1089 .get(&key)
1090 {
1091 return known;
1092 }
1093 let found = self
1094 .read(file)
1095 .is_some_and(|text| text.contains(fragment.as_str()));
1096 self.quotes
1097 .lock()
1098 .expect("the quote cache is only read and filled")
1099 .insert(key, found);
1100 found
1101 })
1102 }
1103
1104 pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
1111 let s = &self.statements[statement];
1112 let base = stem(&s.file);
1113 let key = |&t: &usize| {
1114 let test = &self.tests[t];
1115 (
1116 !Path::new(&test.file)
1117 .file_name()
1118 .and_then(|n| n.to_str())
1119 .unwrap_or("")
1120 .to_lowercase()
1121 .contains(&base),
1122 test.file.clone(),
1123 test.name.clone(),
1124 )
1125 };
1126 let mut all = s.tests.clone();
1127 all.sort_by_key(key);
1128 if all.len() <= k {
1129 return all;
1130 }
1131 let mut groups = Vec::<((String, String), Vec<usize>)>::new();
1132 for &t in &all {
1133 let g = (
1134 self.tests[t].file.clone(),
1135 describe(
1136 &self.tests[t].name,
1137 separator(self.language, &self.tests[t].runner),
1138 ),
1139 );
1140 match groups.iter_mut().find(|(k, _)| *k == g) {
1141 Some((_, v)) => v.push(t),
1142 None => groups.push((g, vec![t])),
1143 }
1144 }
1145 let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
1146 for ((file, _), members) in groups {
1147 match by_file.iter_mut().find(|(f, _)| *f == file) {
1148 Some((_, v)) => v.push(members),
1149 None => by_file.push((file, vec![members])),
1150 }
1151 }
1152 by_file.sort_by_key(|(f, _)| {
1153 (
1154 !Path::new(f)
1155 .file_name()
1156 .and_then(|n| n.to_str())
1157 .unwrap_or("")
1158 .to_lowercase()
1159 .contains(&base),
1160 f.clone(),
1161 )
1162 });
1163 let mut picked = Vec::new();
1164 let fragments = quoted_fragments(&s.text);
1165 if !fragments.is_empty() {
1166 let quoting = all
1167 .iter()
1168 .copied()
1169 .filter(|&t| self.quotes(t, &fragments))
1170 .collect::<Vec<_>>();
1171 let mut files = BTreeSet::new();
1172 let (first, rest): (Vec<usize>, Vec<usize>) = quoting
1173 .iter()
1174 .partition(|&&t| files.insert(self.tests[t].file.clone()));
1175 for t in first.into_iter().chain(rest) {
1176 if picked.len() < k {
1177 picked.push(t);
1178 }
1179 }
1180 }
1181 let mut depth = 0;
1182 while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
1183 for (_, g) in &by_file {
1184 if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
1185 picked.push(g[depth][0]);
1186 }
1187 }
1188 depth += 1;
1189 }
1190 for t in all {
1191 if picked.len() >= k {
1192 break;
1193 }
1194 if !picked.contains(&t) {
1195 picked.push(t);
1196 }
1197 }
1198 picked
1199 }
1200
1201 pub fn last_order(&self, statement: usize, asked: &[usize], k: usize) -> Vec<usize> {
1212 let spread = self.order(statement, MORE_TESTS);
1213 let position = |t: &usize| spread.iter().position(|x| x == t).unwrap_or(usize::MAX);
1214 let s = &self.statements[statement];
1215 let line = name_words(s.text.lines().next().unwrap_or(""));
1216 let shared = |t: usize| {
1217 let name = &self.tests[t].name;
1218 let name = name.rsplit("::").next().unwrap_or(name);
1219 name_words(name).intersection(&line).count()
1220 };
1221 let mut rest = s
1222 .tests
1223 .iter()
1224 .copied()
1225 .filter(|t| !asked.contains(t))
1226 .collect::<Vec<_>>();
1227 rest.sort_by_cached_key(|t| {
1228 (
1229 std::cmp::Reverse(shared(*t)),
1230 self.breadth(*t),
1231 position(t),
1232 *t,
1233 )
1234 });
1235 rest.truncate(k);
1236 rest
1237 }
1238
1239 fn breadth(&self, test: usize) -> usize {
1241 self.ran[test].values().map(BTreeSet::len).sum()
1242 }
1243
1244 pub fn test_code(&self, test: usize) -> String {
1247 match self.language {
1248 Language::Python => return self.python_test_code(test),
1249 Language::JavaScript => {}
1250 _ => return self.located_test_code(test),
1251 }
1252 let t = &self.tests[test];
1253 let text = self.read(&t.file).unwrap_or_default();
1254 let lines = text.split('\n').collect::<Vec<_>>();
1255 let title = t.name.rsplit(" > ").next().unwrap_or(&t.name);
1256 let needle = prefix(title, 40);
1257 let start = (!needle.is_empty())
1258 .then(|| lines.iter().position(|l| l.contains(needle)))
1259 .flatten();
1260 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1261 let head = (0..lines.len().min(HEAD_LINES))
1262 .filter(|&i| !self.language.comment(lines[i]))
1263 .map(fmt)
1264 .collect::<Vec<_>>();
1265 let Some(start) = start else {
1266 let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
1267 return head
1268 .into_iter()
1269 .chain(std::iter::once(" ...".to_owned()))
1270 .chain(rest)
1271 .collect::<Vec<_>>()
1272 .join("\n");
1273 };
1274 let indent = lines[start].len() - lines[start].trim_start().len();
1275 let mut body = Vec::new();
1276 for (i, l) in lines
1277 .iter()
1278 .copied()
1279 .enumerate()
1280 .take(start + BODY_LINES)
1281 .skip(start)
1282 {
1283 if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1284 break;
1285 }
1286 if !self.language.comment(l) {
1287 body.push(fmt(i));
1288 }
1289 }
1290 let mut out = head;
1291 if start > HEAD_LINES {
1292 out.push(" ...".into());
1293 }
1294 out.extend(body);
1295 out.join("\n")
1296 }
1297
1298 fn located_test_code(&self, test: usize) -> String {
1301 let t = &self.tests[test];
1302 let text = self.read(&t.file).unwrap_or_default();
1303 let lines = text.split('\n').collect::<Vec<_>>();
1304 let located = match self.language {
1305 Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1306 Language::Go => go::locate(&lines, &t.name),
1307 Language::Rust => rust_source::locate(&lines, &t.name),
1308 _ => jvm::locate(&lines, &t.name),
1309 };
1310 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1311 let mut open = false;
1313 let commented = lines
1314 .iter()
1315 .map(|l| {
1316 let t = l.trim();
1317 let was = open;
1318 if self.language != Language::Ruby {
1319 if !open && t.starts_with("/*") {
1320 open = true;
1321 }
1322 if open && t.contains("*/") {
1323 open = false;
1324 return true;
1325 }
1326 }
1327 was || open || self.language.comment(l)
1328 })
1329 .collect::<Vec<_>>();
1330 let head = (0..lines.len())
1332 .filter(|&i| !commented[i])
1333 .take(HEAD_LINES)
1334 .collect::<Vec<_>>();
1335 let head_end = head.last().map_or(0, |&i| i + 1);
1336 let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1337 let Some(located) = located else {
1338 out.push(" ...".into());
1339 out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1340 return out.join("\n");
1341 };
1342 let end = located
1343 .end
1344 .min(located.start + BODY_LINES)
1345 .min(lines.len().saturating_sub(1));
1346 let mut last = head_end;
1347 let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1348 shown.extend(located.start..=end);
1349 shown.extend(used_definitions(&lines, self.language, located.start, end));
1352 for i in shown {
1353 if i < last || (commented[i] && self.language != Language::Rust) {
1355 continue;
1356 }
1357 if i > last {
1358 out.push(" ...".into());
1359 }
1360 out.push(fmt(i));
1361 last = i + 1;
1362 }
1363 out.join("\n")
1364 }
1365
1366 fn python_test_code(&self, test: usize) -> String {
1370 let t = &self.tests[test];
1371 let text = self.read(&t.file).unwrap_or_default();
1372 let lines = text.split('\n').collect::<Vec<_>>();
1373 let title = python_title(&t.name);
1374 let indent_of = |l: &str| l.len() - l.trim_start().len();
1375 let start = (!title.is_empty())
1376 .then(|| {
1377 lines.iter().position(|l| {
1378 let t = l.trim_start();
1379 t.starts_with(&format!("def {title}("))
1380 || t.starts_with(&format!("async def {title}("))
1381 })
1382 })
1383 .flatten();
1384 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1385 let mut out = (0..lines.len().min(HEAD_LINES))
1386 .filter(|&i| !self.language.comment(lines[i]))
1387 .map(fmt)
1388 .collect::<Vec<_>>();
1389 let Some(start) = start else {
1390 out.push(" ...".into());
1391 out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1392 return out.join("\n");
1393 };
1394 let indent = indent_of(lines[start]);
1395 let mut first = start;
1396 while first > 0
1397 && lines[first - 1].trim_start().starts_with('@')
1398 && indent_of(lines[first - 1]) == indent
1399 {
1400 first -= 1;
1401 }
1402 let class = (indent > 0)
1403 .then(|| {
1404 (0..first).rev().find(|&i| {
1405 lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1406 })
1407 })
1408 .flatten();
1409 for i in class.into_iter().chain(first..start) {
1410 if i >= HEAD_LINES {
1411 if out.last().is_some_and(|l| l != " ...") {
1412 out.push(" ...".into());
1413 }
1414 out.push(fmt(i));
1415 }
1416 }
1417 if start >= HEAD_LINES
1418 && out.last().is_some_and(|l| l != " ...")
1419 && first == start
1420 && class.is_none()
1421 {
1422 out.push(" ...".into());
1423 }
1424 for (i, &l) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1425 if i > start && !l.trim().is_empty() && indent_of(l) <= indent {
1426 break;
1427 }
1428 if !self.language.comment(l) && i >= HEAD_LINES {
1429 out.push(fmt(i));
1430 }
1431 }
1432 out.join("\n")
1433 }
1434
1435 pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1441 if self.language.asks_within_the_function() {
1442 return BTreeMap::new();
1443 }
1444 let cap = HELPER_CHARS.min(1500 + 400 * questions);
1445 let t = &self.tests[test];
1446 let text = self.read(&t.file).unwrap_or_default();
1447 let mut out = BTreeMap::new();
1448 let mut size = 0;
1449 let files = match self.language {
1450 Language::JavaScript | Language::Python => Vec::new(),
1451 Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1452 Language::Go => go::helpers(&self.root, &t.file, &text),
1453 Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1454 Language::Jvm => jvm::imports(&text)
1455 .iter()
1456 .filter_map(|import| {
1457 jvm::class_paths(&import.replace('/', "."))
1458 .iter()
1459 .find_map(|suffix| {
1460 self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1461 })
1462 .cloned()
1463 })
1464 .collect(),
1465 };
1466 for file in files {
1467 if out.contains_key(&file) {
1468 continue;
1469 }
1470 if self.source_files.contains(&file) || file.contains("node_modules") {
1471 continue;
1472 }
1473 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1474 if size + body.len() > cap {
1475 let keep = cap.saturating_sub(size);
1476 let cut = body
1477 .char_indices()
1478 .map(|(i, _)| i)
1479 .take_while(|&i| i <= keep)
1480 .last()
1481 .unwrap_or(0);
1482 body = format!("{}\n... (cut)", &body[..cut]);
1483 }
1484 size += body.len();
1485 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1486 if size >= cap {
1487 break;
1488 }
1489 }
1490 if self.language == Language::Ruby {
1491 let mut used = 0;
1492 for file in ruby::support_files(&self.root, &text) {
1493 if out.contains_key(&file) || used >= FIXTURE_CHARS {
1494 continue;
1495 }
1496 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1497 if used + body.len() > FIXTURE_CHARS {
1498 let keep = FIXTURE_CHARS - used;
1499 let cut = body
1500 .char_indices()
1501 .map(|(i, _)| i)
1502 .take_while(|&i| i <= keep)
1503 .last()
1504 .unwrap_or(0);
1505 body = format!("{}\n... (cut)", &body[..cut]);
1506 }
1507 used += body.len();
1508 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1509 }
1510 }
1511 out
1512 }
1513
1514 pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1525 let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1526 let within = self.language.asks_within_the_function();
1527 let ran = &self.ran[test];
1528 let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1529 for &a in asked {
1530 want.entry(self.statements[a].file.clone())
1531 .or_default()
1532 .insert(self.statements[a].line);
1533 }
1534 let mut files = want.keys().cloned().collect::<Vec<_>>();
1535 let mut others = ran
1536 .iter()
1537 .filter(|(f, _)| !within && !want.contains_key(*f))
1538 .collect::<Vec<_>>();
1539 others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1540 files.extend(others.into_iter().map(|(f, _)| f.clone()));
1541 let empty = BTreeSet::new();
1542 let mut out = BTreeMap::new();
1543 let mut total = 0;
1544 for f in files {
1545 let Some(source) = self.read(&f) else {
1546 continue;
1547 };
1548 let text = source.split('\n').collect::<Vec<_>>();
1549 let r = ran.get(&f).unwrap_or(&empty);
1550 let code = (1..=text.len())
1551 .filter(|&i| !self.language.comment(text[i - 1]))
1552 .collect::<Vec<_>>();
1553 let pos = code
1554 .iter()
1555 .enumerate()
1556 .map(|(n, &i)| (i, n))
1557 .collect::<BTreeMap<_, _>>();
1558 let around = |l: usize, w: usize| -> Vec<usize> {
1559 match pos.get(&l) {
1560 Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1561 None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1562 }
1563 };
1564 let mut show = BTreeSet::new();
1565 for &l in r {
1566 show.extend(around(l, CONTEXT_LINES));
1567 }
1568 for &l in want.get(&f).unwrap_or(&empty) {
1569 show.extend(around(l, WINDOW_LINES));
1570 }
1571 let end = show
1574 .iter()
1575 .rev()
1576 .copied()
1577 .find(|&i| i != 0 && i <= text.len() && !self.language.comment(text[i - 1]))
1578 .unwrap_or(0);
1579 if within {
1580 let functions = outermost_functions(self.language, &f, &source);
1581 let around =
1582 |l: usize| (l.saturating_sub(OUTSIDE_A_FUNCTION), l + OUTSIDE_A_FUNCTION);
1583 let function = |l: usize| {
1584 functions
1585 .iter()
1586 .copied()
1587 .find(|&(from, to)| from <= l && l <= to)
1588 };
1589 let begins = self
1591 .statements
1592 .iter()
1593 .filter(|s| s.file == f)
1594 .map(|s| s.line)
1595 .collect::<BTreeSet<_>>();
1596 let mut kept = Vec::new();
1597 for statement in asked.iter().map(|&a| &self.statements[a]) {
1598 if statement.file != f {
1599 continue;
1600 }
1601 kept.push(function(statement.line).unwrap_or(around(statement.line)));
1602 for l in (statement.line + 1)
1606 ..statement.line + whole_statement(statement).unwrap_or(1)
1607 {
1608 kept.push(
1609 begins
1610 .contains(&l)
1611 .then(|| function(l))
1612 .flatten()
1613 .unwrap_or(around(l)),
1614 );
1615 }
1616 }
1617 show.retain(|i| kept.iter().any(|&(from, to)| from <= *i && *i <= to));
1618 }
1619 let mut lines = Vec::new();
1620 let mut last = 0;
1621 for i in show {
1622 if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1623 continue;
1624 }
1625 if i != last + 1 {
1626 lines.push(format!(" ... {} lines not shown", i - last - 1));
1627 }
1628 lines.push(format!(
1629 "{}{:5} {}",
1630 if r.contains(&i) { '▶' } else { ' ' },
1631 i,
1632 text[i - 1]
1633 ));
1634 last = i;
1635 }
1636 if within && last != 0 && end > last {
1637 lines.push(format!(" ... {} lines not shown", end - last));
1638 }
1639 let body = lines.join("\n");
1640 if !want.contains_key(&f) && total + body.len() > budget {
1641 continue;
1642 }
1643 total += body.len();
1644 out.insert(f, body);
1645 }
1646 out
1647 }
1648
1649 pub fn questions(&self, statement: usize) -> Vec<String> {
1651 let s = &self.statements[statement];
1652 let line = s.text.lines().next().unwrap_or("").trim();
1653 let changes = match (s.change, &s.replacement) {
1654 (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1655 if self.language == Language::Rust =>
1656 {
1657 vec![rust_question(line, s.change, r)]
1658 }
1659 (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1662 vec![format!(
1663 "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1664 )]
1665 }
1666 (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1667 vec![format!(
1668 "`{line}` becomes `{r}` (the original value is still computed first)"
1669 )]
1670 }
1671 _ => self.default_questions(s, line),
1672 };
1673 changes
1674 .into_iter()
1675 .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1676 .collect()
1677 }
1678
1679 fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1680 match s.change {
1681 Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1684 "`{line}` returns the zero value of each result instead: nil for an error, pointer, slice, map or interface, 0, \"\" or false, or an empty struct (its expressions still run)"
1685 )],
1686 Change::ReturnUndefined
1689 if self.language == Language::Jvm
1690 && s.text.contains("return")
1691 && ["when", "switch", "try"].iter().any(|k| {
1692 line.strip_prefix(k)
1693 .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1694 }) =>
1695 {
1696 vec![format!(
1697 "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1698 self.language.nothing()
1699 )]
1700 }
1701 Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1702 "`{line}` returns {} instead (its expression still runs)",
1703 self.language.nothing()
1704 )],
1705 Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1706 let nothing = match self.language {
1707 Language::Rust
1708 if ["Ok(", "Err(", "return Ok(", "return Err("]
1709 .iter()
1710 .any(|p| line.starts_with(p)) =>
1711 {
1712 "`Ok(Default::default())`"
1713 }
1714 other => other.nothing(),
1715 };
1716 vec![if line.starts_with("return") {
1717 format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1718 } else {
1719 format!(
1722 "`{line}` becomes {nothing}: it is not evaluated, so nothing it does happens (no call it makes, no value it stores), and the enclosing block or method produces {nothing}"
1723 )
1724 }]
1725 }
1726 Change::ValueUndefined if self.language == Language::Go => vec![format!(
1727 "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1728 )],
1729 Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1730 "`{line}`: after it runs, the variable it assigns holds {} instead",
1731 self.language.nothing()
1732 )],
1733 Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1734 let nothing = self.language.nothing();
1735 vec![match split_initializer(line) {
1736 Some(head) => format!(
1737 "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1738 ),
1739 None => format!("`{line}`: the assigned value becomes {nothing}"),
1740 }]
1741 }
1742 Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1743 "`{line}` becomes `return None` (the expression is not evaluated)"
1744 )],
1745 Change::ReturnUndefined => vec![format!(
1746 "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1747 )],
1748 Change::ValueUndefined if self.language == Language::Python => {
1749 vec![match split_initializer(line) {
1750 Some(head) => {
1751 format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1752 }
1753 None => format!("`{line}`: the assigned value becomes None"),
1754 }]
1755 }
1756 Change::ValueUndefined => vec![match split_initializer(line) {
1757 Some(head) => format!(
1758 "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1759 ),
1760 None => format!("`{line}`: the declared value becomes undefined"),
1761 }],
1762 Change::Skip => {
1763 let word = line.trim_end_matches(';').trim();
1764 vec![match word {
1765 "break" => format!(
1766 "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1767 ),
1768 "continue" | "next" => format!(
1769 "`{line}` is deleted: the rest of the loop body runs in this iteration"
1770 ),
1771 _ if whole_statement(s).is_some() => {
1776 let lines = s.text.lines().count();
1777 format!(
1778 "the whole statement starting `{line}` is deleted, all {lines} lines of it (lines {} to {}): none of it runs",
1779 s.line,
1780 s.line + lines - 1
1781 )
1782 }
1783 _ if self.language == Language::Python
1789 && (word == "return" || word.starts_with("return ")) =>
1790 {
1791 format!(
1792 "`{line}` is deleted: the function does not return here, and execution carries on with whatever follows it"
1793 )
1794 }
1795 _ if self.language == Language::Python
1796 && (word == "raise" || word.starts_with("raise ")) =>
1797 {
1798 format!(
1799 "`{line}` is deleted: no error is raised here, and execution carries on with whatever follows it"
1800 )
1801 }
1802 _ => format!("`{line}` is deleted (it never runs)"),
1803 }]
1804 }
1805 Change::ExpressionUndefined => vec![format!(
1806 "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1807 )],
1808 Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1809 Change::BareError => vec![format!(
1810 "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1811 )],
1812 Change::Invert => {
1813 let c = match self.language {
1814 Language::Python => python_condition(line),
1815 Language::JavaScript => condition(line),
1816 Language::Ruby => ruby::condition(line),
1817 Language::Go => go::condition(line),
1818 Language::Rust => rust_source::condition(line),
1819 Language::Jvm => jvm::condition(line),
1820 };
1821 if c.starts_with("let ") {
1824 return vec![format!(
1825 "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1826 )];
1827 }
1828 vec![
1829 format!(
1830 "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1831 ),
1832 format!(
1833 "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1834 ),
1835 ]
1836 }
1837 }
1838 }
1839
1840 pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1842 let t = &self.tests[test];
1843 let mut questions = serde_json::Map::new();
1844 for (n, &a) in asked.iter().enumerate() {
1845 for (j, q) in self.questions(a).into_iter().enumerate() {
1846 questions.insert(
1847 format!("q{n}_{j}"),
1848 json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1849 );
1850 }
1851 }
1852 let mut state = json!({
1853 "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1854 "test": {"file": t.file, "name": t.name},
1855 "test_code": self.test_code(test),
1856 "code_run": self.code_run(test, asked)});
1857 if !self.language.asks_within_the_function() {
1858 state["helpers"] = json!(self.helpers(test, asked.len()));
1859 }
1860 json!({"model": model, "state": state, "questions": questions})
1861 }
1862
1863 pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1868 let t = &self.tests[test];
1869 let parts = json!([
1870 model,
1871 salt,
1872 INSTRUCTIONS,
1873 runtime(&t.runner),
1874 true,
1875 &t.file,
1876 &t.name,
1877 strip_numbers(&self.test_code(test)),
1878 self.helpers(test, 1)
1879 .into_iter()
1880 .map(|(f, b)| (f, strip_numbers(&b)))
1881 .collect::<BTreeMap<_, _>>(),
1882 self.code_run(test, &[statement])
1883 .into_iter()
1884 .map(|(f, b)| (f, strip_numbers(&b)))
1885 .collect::<BTreeMap<_, _>>(),
1886 self.questions(statement)
1887 .iter()
1888 .map(|q| strip_location(q))
1889 .collect::<Vec<_>>(),
1890 ]);
1891 format!(
1892 "{:x}",
1893 Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1894 )
1895 }
1896}
1897
1898fn flipped(line: &str) -> String {
1901 let cut = comment_start(line);
1904 let (code, comment) = line.split_at(cut);
1905 format!("{}{comment}", flipped_code(code))
1906}
1907
1908fn comment_start(line: &str) -> usize {
1911 let bytes = line.as_bytes();
1912 let mut quote = None;
1913 let mut i = 0;
1914 while i < bytes.len() {
1915 let c = bytes[i];
1916 match quote {
1917 Some(_) if c == b'\\' => i += 1,
1918 Some(q) if c == q => quote = None,
1919 Some(_) => {}
1920 None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
1921 None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
1922 None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
1923 None => {}
1924 }
1925 i += 1;
1926 }
1927 line.len()
1928}
1929
1930fn flipped_code(line: &str) -> String {
1931 let words = [
1932 ("true", "false"),
1933 ("false", "true"),
1934 ("True", "False"),
1935 ("False", "True"),
1936 ];
1937 let found = words
1938 .iter()
1939 .filter_map(|(from, to)| {
1940 line.match_indices(from)
1941 .filter(|(at, _)| {
1942 let before = line[..*at].chars().next_back();
1943 let after = line[at + from.len()..].chars().next();
1944 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1945 !word(before) && !word(after)
1946 })
1947 .last()
1948 .map(|(at, _)| (at, *from, *to))
1949 })
1950 .max_by_key(|(at, _, _)| *at);
1951 match found {
1952 Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
1953 None => line.to_owned(),
1954 }
1955}
1956
1957fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1959 let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1960 match change {
1961 Change::ReturnUndefined if line.starts_with("return") => {
1962 format!("`{line}` becomes `return {repl};`")
1963 }
1964 Change::ReturnUndefined => {
1965 format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1966 }
1967 _ => match split_initializer(line) {
1968 Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1969 None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1970 },
1971 }
1972}
1973
1974pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
1976 use std::io::Write;
1977 let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
1978 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1979 encoder.write_all(&bytes).map_err(|e| e.to_string())?;
1980 let compressed = encoder.finish().map_err(|e| e.to_string())?;
1981 let temp = path.with_extension("tmp");
1982 std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
1983 std::fs::rename(&temp, path).map_err(|e| e.to_string())
1984}
1985
1986pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
1988 use std::io::Read;
1989 let compressed = std::fs::read(path).ok()?;
1990 let mut text = Vec::new();
1991 flate2::read::GzDecoder::new(compressed.as_slice())
1992 .read_to_end(&mut text)
1993 .ok()?;
1994 serde_json::from_slice(&text).ok()
1995}
1996
1997struct Located {
2000 context: Vec<usize>,
2001 start: usize,
2002 end: usize,
2003}
2004
2005fn indent(line: &str) -> usize {
2006 line.len() - line.trim_start().len()
2007}
2008
2009fn brace_end(lines: &[&str], start: usize) -> usize {
2012 let mut depth = 0i64;
2013 let mut opened = false;
2014 for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
2015 let mut chars = line.chars().peekable();
2016 let mut quote: Option<char> = None;
2017 while let Some(c) = chars.next() {
2018 match (quote, c) {
2019 (Some(_), '\\') => {
2020 chars.next();
2021 }
2022 (Some(q), c) if c == q => quote = None,
2023 (Some(_), _) => {}
2024 (None, '"' | '`') => quote = Some(c),
2025 (None, '/') if chars.peek() == Some(&'/') => break,
2026 (None, '{') => {
2027 depth += 1;
2028 opened = true;
2029 }
2030 (None, '}') => depth -= 1,
2031 _ => {}
2032 }
2033 }
2034 if opened && depth <= 0 {
2035 return i;
2036 }
2037 }
2038 (start + BODY_LINES).min(lines.len()).saturating_sub(1)
2039}
2040
2041fn keyword_end(lines: &[&str], start: usize) -> usize {
2043 let first = lines[start].trim_end();
2044 if first.ends_with(" end") || first.ends_with('}') {
2045 return start;
2046 }
2047 let depth = indent(lines[start]);
2048 (start + 1..lines.len().min(start + BODY_LINES))
2049 .find(|&i| {
2050 let t = lines[i].trim_start();
2051 indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
2052 })
2053 .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
2054}
2055
2056const DEFINITION_LINES: usize = 120;
2057
2058fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
2061 let words = |range: std::ops::RangeInclusive<usize>| {
2062 let mut out = BTreeSet::new();
2063 for i in range {
2064 let line = lines.get(i).copied().unwrap_or("");
2065 out.extend(
2066 line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2067 .filter(|w| !w.is_empty())
2068 .map(str::to_owned),
2069 );
2070 }
2071 out
2072 };
2073 let definitions = definitions(lines, language);
2074 let mut wanted = words(start..=end);
2075 let mut picked = BTreeSet::new();
2076 let mut budget = DEFINITION_LINES;
2077 for _ in 0..2 {
2078 let mut next = BTreeSet::new();
2079 for (name, a, b) in &definitions {
2080 let (a, b) = (*a, *b);
2081 if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
2082 continue;
2083 }
2084 let size = b - a + 1;
2085 if size > budget {
2086 continue;
2087 }
2088 budget -= size;
2089 picked.insert(a);
2090 next.extend(words(a..=b));
2091 picked.extend(a..=b);
2092 }
2093 wanted = next;
2094 }
2095 picked.into_iter().collect()
2096}
2097
2098fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
2101 let ident = |s: &str| {
2102 s.chars()
2103 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
2104 .collect::<String>()
2105 };
2106 let mut out = Vec::new();
2107 for (i, line) in lines.iter().enumerate() {
2108 let t = line.trim_start();
2109 let name = match language {
2110 Language::Go => t
2111 .strip_prefix("func ")
2112 .map(|r| match r.strip_prefix('(') {
2113 Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
2114 None => r,
2115 })
2116 .or_else(|| t.strip_prefix("var "))
2117 .or_else(|| t.strip_prefix("type "))
2118 .filter(|_| indent(line) == 0)
2119 .map(ident),
2120 Language::Rust => {
2121 let r = t.strip_prefix("pub ").unwrap_or(t);
2122 let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
2123 ["fn ", "const ", "static ", "struct ", "enum "]
2124 .iter()
2125 .find_map(|k| r.strip_prefix(k))
2126 .map(ident)
2127 }
2128 Language::Ruby => t
2129 .strip_prefix("def ")
2130 .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
2131 .or_else(|| {
2132 ["let(:", "let!(:", "subject(:"]
2133 .iter()
2134 .find_map(|k| t.strip_prefix(k))
2135 .map(ident)
2136 })
2137 .or_else(|| {
2138 (t.starts_with("subject ") || t.starts_with("subject{"))
2139 .then(|| "subject".into())
2140 }),
2141 Language::Jvm => {
2142 if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
2143 Some(ident(r.1.trim_start_matches('`')))
2144 } else if t.contains('(')
2145 && !t.ends_with(';')
2146 && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
2147 && ![
2148 "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
2149 "throw", "assert", "super", "this",
2150 ]
2151 .iter()
2152 .any(|k| t.starts_with(k))
2153 {
2154 let head = t.split('(').next().unwrap_or("");
2155 let mut parts = head.split_whitespace().rev();
2156 let name = parts.next().unwrap_or("");
2157 (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
2158 } else {
2159 None
2160 }
2161 }
2162 _ => None,
2163 };
2164 let Some(name) = name.filter(|n| !n.is_empty()) else {
2165 continue;
2166 };
2167 let end = match language {
2168 Language::Ruby => keyword_end(lines, i),
2169 _ if line.contains('{')
2170 || lines
2171 .get(i + 1)
2172 .is_some_and(|n| n.trim_start().starts_with('{')) =>
2173 {
2174 brace_end(lines, i)
2175 }
2176 _ => i,
2177 };
2178 out.push((name, i, end));
2179 }
2180 out
2181}
2182
2183fn ends_with_path(path: &str, suffix: &str) -> bool {
2185 path.strip_suffix(suffix)
2186 .is_some_and(|head| head.is_empty() || head.ends_with('/'))
2187}
2188
2189fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
2192 let mut out = ignore::WalkBuilder::new(root)
2193 .build()
2194 .filter_map(Result::ok)
2195 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2196 .filter(|e| {
2197 e.path()
2198 .extension()
2199 .and_then(|x| x.to_str())
2200 .is_some_and(|x| extensions.contains(&x))
2201 })
2202 .filter_map(|e| {
2203 e.path()
2204 .strip_prefix(root)
2205 .ok()
2206 .map(|p| p.to_string_lossy().replace('\\', "/"))
2207 })
2208 .filter(|p| !p.starts_with(".supercov/"))
2209 .collect::<Vec<_>>();
2210 out.sort();
2211 out
2212}
2213
2214fn is_test_call(line: &str) -> bool {
2215 let t = line.trim_start();
2216 let rest = if let Some(r) = t.strip_prefix("it") {
2217 r
2218 } else if let Some(r) = t.strip_prefix("test") {
2219 r
2220 } else {
2221 return false;
2222 };
2223 let rest = if let Some(r) = rest.strip_prefix('.') {
2224 r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
2225 } else {
2226 rest
2227 };
2228 rest.trim_start().starts_with('(')
2229}
2230
2231fn split_initializer(line: &str) -> Option<String> {
2233 let b = line.as_bytes();
2234 for i in 1..b.len() {
2235 if b[i] == b'='
2236 && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
2237 && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
2238 {
2239 return Some(line[..=i].to_owned());
2240 }
2241 }
2242 None
2243}
2244
2245fn python_title(name: &str) -> &str {
2249 let last = name.rsplit("::").next().unwrap_or(name);
2250 let last = last.split('[').next().unwrap_or(last);
2251 last.rsplit('.').next().unwrap_or(last)
2252}
2253
2254fn python_condition(line: &str) -> String {
2255 let t = line.trim();
2256 let t = t
2257 .strip_prefix("elif")
2258 .or_else(|| t.strip_prefix("if"))
2259 .map(str::trim_start)
2260 .unwrap_or(t);
2261 t.strip_suffix(':')
2262 .map(str::trim_end)
2263 .unwrap_or(t)
2264 .to_owned()
2265}
2266
2267fn condition(line: &str) -> String {
2269 let mut t = line.trim();
2270 if let Some(r) = t.strip_prefix('}') {
2271 let r = r.trim_start();
2272 if let Some(r) = r.strip_prefix("else") {
2273 t = r.trim_start();
2274 }
2275 }
2276 let Some(r) = t.strip_prefix("if") else {
2277 return line.to_owned();
2278 };
2279 let r = r.trim_start();
2280 let Some(r) = r.strip_prefix('(') else {
2281 return line.to_owned();
2282 };
2283 let r = r.trim_end();
2284 let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2285 match r.strip_suffix(')') {
2286 Some(c) => c.to_owned(),
2287 None => line.to_owned(),
2288 }
2289}
2290
2291fn strip_numbers(text: &str) -> String {
2292 text.split('\n')
2293 .map(|l| {
2294 let (mark, rest) = match l.chars().next() {
2295 Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2296 _ => (None, l),
2297 };
2298 let trimmed = rest.trim_start();
2299 let digits = trimmed.len()
2300 - trimmed
2301 .trim_start_matches(|c: char| c.is_ascii_digit())
2302 .len();
2303 if digits > 0 && trimmed[digits..].starts_with(' ') {
2304 format!(
2305 "{}{}",
2306 mark.map(String::from).unwrap_or_default(),
2307 &trimmed[digits + 1..]
2308 )
2309 } else if let Some(n) = l
2310 .trim_start()
2311 .strip_prefix("... ")
2312 .and_then(|r| r.split_once(" lines not shown"))
2313 {
2314 let _ = n;
2315 "...".to_owned()
2316 } else {
2317 l.to_owned()
2318 }
2319 })
2320 .collect::<Vec<_>>()
2321 .join("\n")
2322}
2323
2324fn strip_location(question: &str) -> String {
2325 match question.split_once(": ") {
2327 Some((loc, rest)) => match loc.rsplit_once(':') {
2328 Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2329 format!("{file}:: {rest}")
2330 }
2331 _ => question.to_owned(),
2332 },
2333 None => question.to_owned(),
2334 }
2335}
2336
2337fn normalize(path: &Path) -> String {
2341 let mut out = Vec::new();
2342 for c in path.components() {
2343 match c {
2344 std::path::Component::ParentDir => {
2345 out.pop();
2346 }
2347 std::path::Component::CurDir => {}
2348 other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2349 }
2350 }
2351 out.join("/")
2352}
2353
2354#[cfg(test)]
2355mod tests {
2356 use super::*;
2357
2358 #[test]
2359 fn a_statement_many_tests_run_is_asked_of_the_tests_that_ran_little_else() {
2360 let source = (0..10)
2365 .map(|n| format!("const v{n} = compute({n});"))
2366 .collect::<Vec<_>>()
2367 .join("\n");
2368 let test = |file: &str, name: &str| InputTest {
2369 id: format!("{file}::{name}"),
2370 file: Some(file.to_owned()),
2371 name: name.to_owned(),
2372 role: "test".into(),
2373 runner: "node:test".into(),
2374 };
2375 let mut tests = (0..17)
2376 .map(|n| {
2377 let name = if n == 16 {
2378 "calls compute on start"
2379 } else {
2380 "serves a request"
2381 };
2382 test(&format!("tests/e2e{n:02}.test.js"), name)
2383 })
2384 .collect::<Vec<_>>();
2385 tests.extend((0..3).map(|n| test("tests/zz_unit.test.js", &format!("total case {n}"))));
2386 let everyone = (0..20).collect::<Vec<u32>>();
2387 let end_to_end = (0..17).collect::<Vec<u32>>();
2388 let points = (0..10)
2389 .map(|n| InputPoint {
2390 file: "src/sum.js".into(),
2391 line: n + 1,
2392 source: format!("const v{n} = compute({n});"),
2393 tests: if n == 0 {
2394 everyone.clone()
2395 } else {
2396 end_to_end.clone()
2397 },
2398 })
2399 .collect();
2400 let input = AssessmentInput {
2401 tests,
2402 points,
2403 decisions: Vec::new(),
2404 };
2405 let sources = BTreeMap::from([("src/sum.js".to_owned(), source)]);
2406 let population = population_from(
2407 Path::new("/nonexistent"),
2408 &input,
2409 &sources,
2410 Language::JavaScript,
2411 )
2412 .unwrap();
2413 let target = population
2414 .statements
2415 .iter()
2416 .position(|s| s.line == 1)
2417 .unwrap();
2418 let unit = |order: &[usize]| {
2419 order
2420 .iter()
2421 .filter(|&&t| population.tests[t].file == "tests/zz_unit.test.js")
2422 .count()
2423 };
2424 let spread = population.order(target, MORE_TESTS);
2425 assert_eq!(
2426 unit(&spread),
2427 0,
2428 "file order alone leaves the unit tests out"
2429 );
2430 let last = population.last_order(target, &spread, LAST_TESTS);
2431 assert_eq!(last.len(), LAST_TESTS);
2432 assert!(last.iter().all(|t| !spread.contains(t)), "{last:?}");
2433 assert_eq!(population.tests[last[0]].name, "calls compute on start");
2436 assert_eq!(unit(&last[1..]), 2, "{last:?}");
2437 assert_eq!(
2438 name_words("const hostCount = getHTTPResponse2(send_data)"),
2439 [
2440 "const",
2441 "count",
2442 "data",
2443 "host",
2444 "http",
2445 "response2",
2446 "send"
2447 ]
2448 .map(String::from)
2449 .into()
2450 );
2451 }
2452
2453 #[test]
2454 fn a_statement_is_shown_with_the_function_it_is_in() {
2455 let typescript = "import { log } from './log'\n\nexport const seal = (key: string) => {\n const parts = key.split('-').map((part) => {\n return part.trim()\n })\n return parts\n}\n\nclass Vault {\n size = 0\n @logged()\n open(door: string) {\n return () => door\n }\n}\n\nserver.on('close', function () {\n log('closed')\n})\n\nif (flag) {\n function inner() {\n return 1\n }\n}\nconst limit = 4\n";
2458 assert_eq!(
2459 outermost_functions(Language::JavaScript, "src/vault.ts", typescript),
2460 [(3, 8), (12, 15), (18, 20), (23, 25)]
2461 );
2462 let python = "import os\n\nLIMIT = 4\n\n@cached\ndef seal(key):\n def trim(part):\n return part.strip()\n return [trim(part) for part in key.split('-')]\n\nclass Vault:\n size = 0\n\n async def open(self, door):\n return lambda: door\n\nif os.name == 'nt':\n def separator():\n return ';'\n";
2463 assert_eq!(
2464 outermost_functions(Language::Python, "vault.py", python),
2465 [(5, 9), (14, 15), (18, 19)]
2466 );
2467 assert!(outermost_functions(Language::Go, "vault.go", "package vault\n").is_empty());
2469 assert!(Language::Python.asks_within_the_function());
2470 assert!(!Language::Ruby.asks_within_the_function());
2471 }
2472
2473 #[test]
2474 fn changes_follow_the_audit() {
2475 let source = "import x from 'y'\nexport const a = 1\nlet b: number\nfunction f() {\n if (a) return 2\n return;\n}\nconst el = <div>{a && b}</div>\n";
2476 let starts = statement_starts("x.tsx", source);
2477 let js = Language::JavaScript;
2478 assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2479 assert_eq!(
2480 change_at(&starts, 2, "const a = 1", js),
2481 Some(Change::ValueUndefined)
2482 );
2483 assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2484 assert_eq!(
2485 change_at(&starts, 5, "if (a) return 2", js),
2486 Some(Change::Invert)
2487 );
2488 assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2489 assert_eq!(
2490 change_at(&starts, 8, "a && b", js),
2491 Some(Change::ExpressionUndefined)
2492 );
2493 }
2494
2495 #[test]
2496 fn a_change_that_cannot_change_anything_is_not_asked() {
2497 let source = "class H extends Map {\n constructor() {\n super([])\n }\n}\nfunction f(a) {\n if (a) return undefined\n if (!a) return void 0\n let answered = false\n const done = true, more = 1\n return false\n}\n";
2501 let starts = statement_starts("x.ts", source);
2502 let js = Language::JavaScript;
2503 assert_eq!(change_at(&starts, 3, "super([])", js), None);
2504 assert_eq!(
2505 change_at(&starts, 7, "return undefined", js),
2506 Some(Change::Skip)
2507 );
2508 assert_eq!(
2509 change_at(&starts, 8, "return void 0", js),
2510 Some(Change::Skip)
2511 );
2512 assert_eq!(
2513 change_at(&starts, 9, "let answered = false", js),
2514 Some(Change::Flip)
2515 );
2516 assert_eq!(
2518 change_at(&starts, 10, "const done = true, more = 1", js),
2519 Some(Change::ValueUndefined)
2520 );
2521 assert_eq!(
2522 change_at(&starts, 11, "return false", js),
2523 Some(Change::Flip)
2524 );
2525 let python = python_statement_starts(
2526 "def f(a):\n if a:\n return None\n ready = False\n return True\n",
2527 );
2528 let py = Language::Python;
2529 assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2530 assert_eq!(
2531 change_at(&python, 4, "ready = False", py),
2532 Some(Change::Flip)
2533 );
2534 assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2535 assert_eq!(flipped("let answered = false"), "let answered = true");
2536 assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2537 assert_eq!(flipped("ready = False"), "ready = True");
2538 assert_eq!(
2539 flipped("let ready = false // true once open"),
2540 "let ready = true // true once open"
2541 );
2542 assert_eq!(
2543 flipped("ready = False # True later"),
2544 "ready = True # True later"
2545 );
2546 assert_eq!(flipped("this.#open = false"), "this.#open = true");
2547 assert_eq!(
2548 flipped("const s = 'a // b', on = false"),
2549 "const s = 'a // b', on = true"
2550 );
2551 }
2552
2553 #[test]
2554 fn a_typescript_throw_keeps_its_types() {
2555 let source = "async function list(reply) {\n const result = await reply\n if ('error' in result)\n throw new Error(`tools/list failed: ${result.error.message}`)\n return result.result\n}\n";
2558 let typed = statement_starts("x.ts", source);
2559 let js = Language::JavaScript;
2560 assert_eq!(
2561 change_at(
2562 &typed,
2563 4,
2564 "throw new Error(`tools/list failed: ${result.error.message}`)",
2565 js
2566 ),
2567 Some(Change::BareError)
2568 );
2569 let plain = statement_starts("x.js", source);
2570 assert_eq!(
2571 change_at(
2572 &plain,
2573 4,
2574 "throw new Error(`tools/list failed: ${result.error.message}`)",
2575 js
2576 ),
2577 Some(Change::Skip)
2578 );
2579 }
2580
2581 #[test]
2582 fn a_deleted_statement_says_what_goes_and_what_then_runs() {
2583 let source = "def f(items, n):\n if n < 0:\n raise ValueError(\"negative\")\n if not items:\n return None\n for item in items:\n use(item)\n log(n)\n return n\n";
2584 let point = |line: usize, text: &str| InputPoint {
2585 file: "pkg/f.py".into(),
2586 line,
2587 source: text.into(),
2588 tests: vec![0],
2589 };
2590 let input = AssessmentInput {
2591 tests: vec![InputTest {
2592 id: "tests/test_f.py::test_f".into(),
2593 file: Some("tests/test_f.py".into()),
2594 name: "tests/test_f.py::test_f".into(),
2595 role: "test".into(),
2596 runner: "pytest".into(),
2597 }],
2598 points: vec![
2599 point(3, "raise ValueError(\"negative\")"),
2600 point(5, "return None"),
2601 point(6, "for item in items:\n use(item)"),
2602 point(8, "log(n)"),
2603 ],
2604 decisions: Vec::new(),
2605 };
2606 let sources = BTreeMap::from([("pkg/f.py".to_owned(), source.to_owned())]);
2607 let python = population_from(
2608 Path::new("/nonexistent"),
2609 &input,
2610 &sources,
2611 Language::Python,
2612 )
2613 .unwrap();
2614 let question = |population: &Population, line: usize| {
2615 let at = population
2616 .statements
2617 .iter()
2618 .position(|s| s.line == line)
2619 .unwrap();
2620 population.questions(at).join(" | ")
2621 };
2622 assert_eq!(
2623 question(&python, 3),
2624 "pkg/f.py:3: `raise ValueError(\"negative\")` is deleted: no error is raised here, and execution carries on with whatever follows it."
2625 );
2626 assert_eq!(
2627 question(&python, 5),
2628 "pkg/f.py:5: `return None` is deleted: the function does not return here, and execution carries on with whatever follows it."
2629 );
2630 assert_eq!(
2631 question(&python, 6),
2632 "pkg/f.py:6: the whole statement starting `for item in items:` is deleted, all 2 lines of it (lines 6 to 7): none of it runs."
2633 );
2634 assert_eq!(
2635 question(&python, 8),
2636 "pkg/f.py:8: `log(n)` is deleted (it never runs)."
2637 );
2638 let js = AssessmentInput {
2641 tests: vec![InputTest {
2642 id: "t".into(),
2643 file: Some("test/g.test.js".into()),
2644 name: "g".into(),
2645 role: "test".into(),
2646 runner: "node:test".into(),
2647 }],
2648 points: vec![InputPoint {
2649 file: "src/g.js".into(),
2650 line: 3,
2651 source: "return;".into(),
2652 tests: vec![0],
2653 }],
2654 decisions: Vec::new(),
2655 };
2656 let sources = BTreeMap::from([(
2657 "src/g.js".to_owned(),
2658 "function g(a) {\n if (a) {\n return;\n }\n use(a);\n}\n".to_owned(),
2659 )]);
2660 let javascript = population_from(
2661 Path::new("/nonexistent"),
2662 &js,
2663 &sources,
2664 Language::JavaScript,
2665 )
2666 .unwrap();
2667 assert_eq!(
2668 question(&javascript, 3),
2669 "src/g.js:3: `return;` is deleted (it never runs)."
2670 );
2671 }
2672
2673 #[test]
2674 fn a_statement_s_text_is_what_a_test_would_quote() {
2675 assert_eq!(
2676 quoted_fragments("logger.info(` - Headers: ${describeHeaders(headers)}`)"),
2677 vec!["- Headers:"]
2678 );
2679 assert_eq!(
2680 quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2681 vec![", signal=", "Child exited: code="]
2682 );
2683 assert_eq!(
2684 quoted_fragments("log(f\"Serving {path} on {port}\")"),
2685 vec!["Serving"]
2686 );
2687 assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2689 assert!(quoted_fragments("return a + b").is_empty());
2690 }
2691
2692 #[test]
2693 fn python_changes_follow_the_audit() {
2694 let source = "import os\n\n\ndef f(x):\n \"\"\"Docs.\"\"\"\n y = x + 1\n if y > 2:\n return y\n z: int\n count += 1\n return\ndef g(v):\n if v:\n pass\n elif v is None:\n pass\n";
2695 let starts = python_statement_starts(source);
2696 let py = Language::Python;
2697 assert_eq!(change_at(&starts, 1, "import os", py), None);
2698 assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
2699 assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
2700 assert_eq!(
2701 change_at(&starts, 6, "y = x + 1", py),
2702 Some(Change::ValueUndefined)
2703 );
2704 assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
2705 assert_eq!(
2706 change_at(&starts, 8, "return y", py),
2707 Some(Change::ReturnUndefined)
2708 );
2709 assert_eq!(change_at(&starts, 9, "z: int", py), None);
2710 assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
2711 assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
2712 assert_eq!(
2713 change_at(&starts, 15, "v is None", py),
2714 Some(Change::Invert)
2715 );
2716 assert_eq!(
2717 python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
2718 "test_b"
2719 );
2720 assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
2721 assert_eq!(python_condition("if y > 2:"), "y > 2");
2722 assert_eq!(python_condition("elif v is None:"), "v is None");
2723 }
2724
2725 #[test]
2726 fn change_texts() {
2727 assert_eq!(
2728 split_initializer("const x = a === b").as_deref(),
2729 Some("const x =")
2730 );
2731 assert_eq!(
2732 split_initializer("const f = () => 1").as_deref(),
2733 Some("const f =")
2734 );
2735 assert_eq!(condition("} else if (x > 1) {"), "x > 1");
2736 assert_eq!(
2737 condition("if (!body) return null"),
2738 "if (!body) return null"
2739 );
2740 assert!(is_test_call(" it(\"works\", () => {"));
2741 assert!(is_test_call("test.each([1])('x', () => {"));
2742 assert!(!is_test_call("items.push(1)"));
2743 }
2744
2745 #[test]
2746 fn keys_ignore_line_numbers() {
2747 assert_eq!(
2748 strip_numbers("▶ 12 return x\n ... 3 lines not shown\n 15 }"),
2749 "▶ return x\n...\n }"
2750 );
2751 assert_eq!(
2752 strip_location("src/a.ts:41: `x` is deleted."),
2753 "src/a.ts:: `x` is deleted."
2754 );
2755 }
2756}