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
278impl AssessmentInput {
279 pub fn only_tests(&self, keep: impl Fn(&InputTest) -> bool) -> AssessmentInput {
282 let mut renumbered = vec![None; self.tests.len()];
283 let mut tests = Vec::new();
284 for (index, test) in self.tests.iter().enumerate() {
285 if keep(test) {
286 renumbered[index] = Some(tests.len() as u32);
287 tests.push(test.clone());
288 }
289 }
290 let narrow = |points: &[InputPoint]| {
291 points
292 .iter()
293 .filter_map(|point| {
294 let tests = point
295 .tests
296 .iter()
297 .filter_map(|test| renumbered.get(*test as usize).copied().flatten())
298 .collect::<Vec<_>>();
299 (!tests.is_empty()).then(|| InputPoint {
300 tests,
301 ..point.clone()
302 })
303 })
304 .collect()
305 };
306 AssessmentInput {
307 points: narrow(&self.points),
308 decisions: narrow(&self.decisions),
309 tests,
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
315pub struct InputTest {
316 pub id: String,
317 pub file: Option<String>,
318 pub name: String,
319 pub role: String,
320 pub runner: String,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
326pub struct InputPoint {
327 pub file: String,
328 pub line: usize,
329 pub source: String,
330 pub tests: Vec<u32>,
331}
332
333pub fn assessment_input(report: &CoverageReport) -> AssessmentInput {
334 let view = &report.filters.passed;
335 let tests = view
336 .tests
337 .iter()
338 .map(|test| InputTest {
339 id: test.id.clone(),
340 file: test.file.clone(),
341 name: test.name.clone(),
342 role: test.role.clone(),
343 runner: test.provenance.runner.clone(),
344 })
345 .collect::<Vec<_>>();
346 let index = tests
347 .iter()
348 .enumerate()
349 .map(|(i, test)| (test.id.as_str(), i as u32))
350 .collect::<BTreeMap<_, _>>();
351 let indexes = |ids: &[crate::interned::Id]| {
352 ids.iter()
353 .filter_map(|id| index.get(id.as_str()).copied())
354 .collect::<Vec<_>>()
355 };
356 let points = view
357 .points
358 .iter()
359 .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
360 .map(|p| InputPoint {
361 file: p.meta.file.clone(),
362 line: p.meta.line,
363 source: p.meta.source.clone(),
364 tests: indexes(&p.tests),
365 })
366 .collect();
367 let decisions = view
368 .decisions
369 .iter()
370 .filter(|d| d.executed)
371 .map(|d| InputPoint {
372 file: d.meta.file.clone(),
373 line: d.meta.line,
374 source: d.meta.source.clone(),
375 tests: indexes(&d.tests),
376 })
377 .collect();
378 AssessmentInput {
379 tests,
380 points,
381 decisions,
382 }
383}
384
385pub fn population(
386 root: &Path,
387 report: &CoverageReport,
388 sources: &BTreeMap<String, String>,
389 language: Language,
390) -> Result<Population, String> {
391 population_from(root, &assessment_input(report), sources, language)
392}
393
394pub fn population_from(
395 root: &Path,
396 input: &AssessmentInput,
397 sources: &BTreeMap<String, String>,
398 language: Language,
399) -> Result<Population, String> {
400 let mut tests = Vec::new();
401 let jvm_files = if language == Language::Jvm {
403 project_files(root, &["java", "kt"])
404 } else {
405 Vec::new()
406 };
407 let mut index = BTreeMap::<u32, usize>::new();
409 for (position, test) in input.tests.iter().enumerate() {
410 let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
411 Some(file) => file.clone(),
412 None if language == Language::Jvm => {
413 let found = jvm::class_paths(&test.name)
414 .iter()
415 .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
416 match found {
417 Some(file) => file.clone(),
418 None => continue,
419 }
420 }
421 None => continue,
422 };
423 if test.role != "test" {
424 continue;
425 }
426 index.insert(position as u32, tests.len());
427 tests.push(TestRef {
428 id: test.id.clone(),
429 file,
430 name: test.name.clone(),
431 runner: test.runner.clone(),
432 });
433 }
434 let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
435 let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
436 let signatures = if language == Language::Rust {
437 rust_source::signatures(
438 sources
439 .iter()
440 .filter(|(file, _)| file.ends_with(".rs"))
441 .map(|(file, source)| (file.as_str(), source.as_str())),
442 )
443 } else {
444 rust_source::Signatures::default()
445 };
446 let mut statements = Vec::new();
447 let mut seen = BTreeSet::new();
448 let executed = input
452 .points
453 .iter()
454 .map(|p| (&p.file, p.line, &p.source, &p.tests, false));
455 let elifs = input
456 .decisions
457 .iter()
458 .filter(|_| language != Language::JavaScript)
459 .map(|d| (&d.file, d.line, &d.source, &d.tests, true));
460 for (file, line, text, point_tests, decision) in executed.chain(elifs) {
461 let key = (file.clone(), line);
462 if !seen.insert(key) {
463 continue; }
465 let ids = point_tests
466 .iter()
467 .filter_map(|position| index.get(position).copied())
468 .collect::<Vec<_>>();
469 if ids.is_empty() {
470 continue;
471 }
472 if language == Language::Rust && rust_source::test_path(file) {
473 continue;
474 }
475 let Some(source) = sources.get(file) else {
476 continue;
477 };
478 let lines = changes
479 .entry(file.clone())
480 .or_insert_with(|| match language {
481 Language::JavaScript => statement_starts(file, source),
482 Language::Python => python_statement_starts(source),
483 Language::Ruby => ruby::statement_starts(source),
484 Language::Go => go::statement_starts(source),
485 Language::Rust => rust_source::statement_starts(source),
486 Language::Jvm => jvm::statement_starts(file, source),
487 });
488 let Some(change) = change_at(lines, line, text, language) else {
489 continue;
490 };
491 if decision && change != Change::Invert {
492 continue;
493 }
494 let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
495 let replacement = match language {
496 Language::Rust if valued => {
497 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
498 let first = first.trim_end_matches(';');
499 replacements
500 .entry(file.clone())
501 .or_insert_with(|| rust_source::replacements(file, source, &signatures))
502 .get(&line)
503 .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
504 .map(|(_, r)| r.clone())
505 }
506 Language::Go if valued => go::rewrite(source, line, change),
511 _ => None,
512 };
513 if replacement
516 .as_deref()
517 .is_some_and(|written| same_code(written, text))
518 {
519 continue;
520 }
521 statements.push(Statement {
522 file: file.clone(),
523 line,
524 text: text.clone(),
525 change,
526 replacement,
527 tests: ids,
528 });
529 }
530 let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
531 for s in &statements {
532 for &t in &s.tests {
533 ran[t].entry(s.file.clone()).or_default().insert(s.line);
534 }
535 }
536 let source_files = statements.iter().map(|s| s.file.clone()).collect();
537 Ok(Population {
538 root: root.to_path_buf(),
539 language,
540 statements,
541 tests,
542 ran,
543 source_files,
544 jvm_files,
545 files: Default::default(),
546 quotes: Default::default(),
547 })
548}
549
550fn same_code(a: &str, b: &str) -> bool {
552 let squash = |code: &str| {
553 code.trim()
554 .trim_end_matches(';')
555 .chars()
556 .filter(|c| !c.is_whitespace())
557 .collect::<String>()
558 };
559 squash(a) == squash(b)
560}
561
562fn name_words(text: &str) -> BTreeSet<String> {
565 const FILLER: [&str; 13] = [
566 "test", "tests", "the", "and", "for", "with", "get", "set", "self", "from", "not", "does",
567 "should",
568 ];
569 let mut words = BTreeSet::new();
570 let chars = text.chars().collect::<Vec<_>>();
571 let mut word = String::new();
572 let mut flush = |word: &mut String| {
573 let w = word.to_lowercase();
574 if w.len() > 2 && !FILLER.contains(&w.as_str()) {
575 words.insert(w);
576 }
577 word.clear();
578 };
579 for (i, &c) in chars.iter().enumerate() {
580 if !c.is_ascii_alphanumeric() {
581 flush(&mut word);
582 continue;
583 }
584 let previous = i.checked_sub(1).map(|p| chars[p]);
585 let next = chars.get(i + 1).copied();
586 let starts = c.is_ascii_uppercase()
589 && (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
590 || (previous.is_some_and(|p| p.is_ascii_uppercase())
591 && next.is_some_and(|n| n.is_ascii_lowercase())));
592 if starts {
593 flush(&mut word);
594 }
595 word.push(c);
596 }
597 flush(&mut word);
598 words
599}
600
601fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
604 let allocator = Allocator::default();
605 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
606 let parsed = Parser::new(&allocator, source, source_type).parse();
607 let starts = line_starts(source);
608 struct Collector<'s> {
609 source: &'s str,
610 starts: &'s [usize],
611 typescript: bool,
612 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
613 }
614 impl<'a> Visit<'a> for Collector<'_> {
615 fn visit_statement(&mut self, statement: &JsStatement<'a>) {
616 let span = statement.span();
617 let line = line_of(self.starts, span.start as usize);
618 let text = self
619 .source
620 .get(span.start as usize..span.end as usize)
621 .unwrap_or("")
622 .to_owned();
623 self.out.entry(line).or_default().push((
624 text,
625 match statement {
626 JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
627 _ => change_of(statement),
628 },
629 ));
630 walk::walk_statement(self, statement);
631 }
632 }
633 let mut collector = Collector {
634 source,
635 starts: &starts,
636 typescript: source_type.is_typescript(),
637 out: BTreeMap::new(),
638 };
639 collector.visit_program(&parsed.program);
640 collector.out
641}
642
643fn is_undefined(expression: &Expression) -> bool {
647 match expression {
648 Expression::Identifier(identifier) => identifier.name == "undefined",
649 Expression::UnaryExpression(unary) => {
650 unary.operator == oxc_syntax::operator::UnaryOperator::Void
651 }
652 Expression::TSAsExpression(cast) => is_undefined(&cast.expression),
653 Expression::TSSatisfiesExpression(cast) => is_undefined(&cast.expression),
654 Expression::TSTypeAssertion(cast) => is_undefined(&cast.expression),
655 Expression::TSNonNullExpression(cast) => is_undefined(&cast.expression),
656 Expression::ParenthesizedExpression(inner) => is_undefined(&inner.expression),
657 _ => false,
658 }
659}
660
661fn change_of(statement: &JsStatement) -> Option<Change> {
662 Some(match statement {
663 JsStatement::IfStatement(_) => Change::Invert,
664 JsStatement::ReturnStatement(r) => match &r.argument {
667 Some(argument) if is_undefined(argument) => Change::Skip,
668 Some(Expression::BooleanLiteral(_)) => Change::Flip,
669 Some(_) => Change::ReturnUndefined,
670 None => Change::Skip,
671 },
672 JsStatement::VariableDeclaration(d) => {
673 if let [only] = d.declarations.as_slice()
674 && matches!(only.init, Some(Expression::BooleanLiteral(_)))
675 {
676 Change::Flip
677 } else if d.declarations.iter().any(|d| d.init.is_some()) {
678 Change::ValueUndefined
679 } else {
680 return None;
681 }
682 }
683 JsStatement::ExpressionStatement(e)
686 if matches!(&e.expression, Expression::CallExpression(call)
687 if matches!(call.callee, Expression::Super(_))) =>
688 {
689 return None;
690 }
691 JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
692 Some(Declaration::VariableDeclaration(d))
693 if d.declarations.iter().any(|d| d.init.is_some()) =>
694 {
695 Change::ValueUndefined
696 }
697 _ => return None,
698 },
699 JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
700 ExportDefaultDeclarationKind::FunctionDeclaration(_)
701 | ExportDefaultDeclarationKind::ClassDeclaration(_)
702 | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
703 _ => Change::Skip,
704 },
705 JsStatement::ImportDeclaration(_)
706 | JsStatement::ExportAllDeclaration(_)
707 | JsStatement::FunctionDeclaration(_)
708 | JsStatement::ClassDeclaration(_)
709 | JsStatement::TSTypeAliasDeclaration(_)
710 | JsStatement::TSInterfaceDeclaration(_)
711 | JsStatement::TSEnumDeclaration(_)
712 | JsStatement::TSModuleDeclaration(_)
713 | JsStatement::TSGlobalDeclaration(_)
714 | JsStatement::TSImportEqualsDeclaration(_)
715 | JsStatement::TSExportAssignment(_)
716 | JsStatement::TSNamespaceExportDeclaration(_) => return None,
717 _ => Change::Skip,
718 })
719}
720
721fn change_at(
725 starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
726 line: usize,
727 text: &str,
728 language: Language,
729) -> Option<Change> {
730 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
731 let found = starts.get(&line).and_then(|all| {
732 all.iter().find(|(t, _)| {
733 let bare = t
734 .strip_prefix("export ")
735 .map(|t| t.strip_prefix("default ").unwrap_or(t))
736 .unwrap_or(t);
737 bare.starts_with(first) || t.starts_with(first)
738 })
739 });
740 match (found, language) {
741 (Some((_, change)), _) => *change,
742 (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
743 (None, _) => None,
746 }
747}
748
749fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
754 use ruff_python_ast::{
755 Expr, Stmt,
756 visitor::{Visitor, walk_stmt},
757 };
758 use ruff_text_size::Ranged;
759 struct Collector<'s> {
760 source: &'s str,
761 starts: Vec<usize>,
762 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
763 }
764 impl<'a> Visitor<'a> for Collector<'_> {
765 fn visit_stmt(&mut self, stmt: &'a Stmt) {
766 let change = match stmt {
767 Stmt::If(_) => Some(Change::Invert),
768 Stmt::Return(r) => Some(match r.value.as_deref() {
769 Some(Expr::NoneLiteral(_)) | None => Change::Skip,
771 Some(Expr::BooleanLiteral(_)) => Change::Flip,
772 Some(_) => Change::ReturnUndefined,
773 }),
774 Stmt::Assign(a)
775 if a.targets.len() == 1
776 && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
777 {
778 Some(Change::Flip)
779 }
780 Stmt::Assign(_) => Some(Change::ValueUndefined),
781 Stmt::AnnAssign(a)
782 if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
783 {
784 Some(Change::Flip)
785 }
786 Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
787 Stmt::Import(_)
788 | Stmt::ImportFrom(_)
789 | Stmt::FunctionDef(_)
790 | Stmt::ClassDef(_)
791 | Stmt::Pass(_)
792 | Stmt::Global(_)
793 | Stmt::Nonlocal(_)
794 | Stmt::TypeAlias(_)
795 | Stmt::IpyEscapeCommand(_) => None,
796 Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
797 _ => Some(Change::Skip),
798 };
799 let range = stmt.range();
800 let (start, end) = (range.start().to_usize(), range.end().to_usize());
801 let line = line_of(&self.starts, start);
802 let text = self.source.get(start..end).unwrap_or("").to_owned();
803 self.out.entry(line).or_default().push((text, change));
804 if let Stmt::If(s) = stmt {
806 for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
808 let start = test.range().start().to_usize();
809 let text = self.source.get(start..test.range().end().to_usize());
810 self.out
811 .entry(line_of(&self.starts, start))
812 .or_default()
813 .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
814 }
815 }
816 walk_stmt(self, stmt);
817 }
818 }
819 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
820 return BTreeMap::new();
821 };
822 let mut collector = Collector {
823 source,
824 starts: line_starts(source),
825 out: BTreeMap::new(),
826 };
827 for stmt in &parsed.syntax().body {
828 collector.visit_stmt(stmt);
829 }
830 collector.out
831}
832
833fn js_test_line(lines: &[&str], name: &str) -> Option<usize> {
838 let parts = name.split(" > ").collect::<Vec<_>>();
839 let (title, groups) = parts.split_last()?;
840 let quoted = |line: &str, text: &str| {
841 ['\'', '"', '`']
842 .iter()
843 .any(|quote| line.contains(&format!("{quote}{text}")))
844 };
845 let (mut from, mut to) = (0, lines.len());
846 for group in groups {
847 let needle = prefix(group, 40);
848 if needle.is_empty() {
849 continue;
850 }
851 if let Some(at) = (from..to).find(|&i| quoted(lines[i], needle)) {
852 (from, to) = (at, (block_end(lines, at) + 1).min(lines.len()));
853 }
854 }
855 let needle = prefix(title, 40);
856 if needle.is_empty() {
857 return None;
858 }
859 (from..to)
860 .find(|&i| quoted(lines[i], needle))
861 .or_else(|| (from..to).find(|&i| lines[i].contains(needle)))
862 .or_else(|| (0..lines.len()).find(|&i| lines[i].contains(needle)))
863}
864
865fn prefix(text: &str, chars: usize) -> &str {
866 match text.char_indices().nth(chars) {
867 Some((i, _)) => &text[..i],
868 None => text,
869 }
870}
871
872fn outermost_functions(language: Language, file: &str, source: &str) -> Vec<(usize, usize)> {
877 let starts = line_starts(source);
878 let lines = |from: usize, to: usize| {
879 (
880 line_of(&starts, from),
881 line_of(&starts, to.saturating_sub(1).max(from)),
882 )
883 };
884 let mut spans = match language {
885 Language::JavaScript => {
886 let allocator = Allocator::default();
887 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
888 let parsed = Parser::new(&allocator, source, source_type).parse();
889 #[derive(Default)]
890 struct Functions {
891 within: Option<(u32, u32)>,
895 found: Vec<(u32, u32)>,
896 }
897 impl Functions {
898 fn inside(&mut self, span: (u32, u32), walk: impl FnOnce(&mut Self)) {
899 let outer = self.within.replace(span);
900 walk(self);
901 self.within = outer;
902 }
903 fn function(&mut self, own: oxc_span::Span) {
904 self.found.push(self.within.unwrap_or((own.start, own.end)));
905 }
906 }
907 fn from_decorators(
908 span: oxc_span::Span,
909 decorators: &[oxc_ast::ast::Decorator<'_>],
910 ) -> (u32, u32) {
911 (
912 decorators
913 .first()
914 .map_or(span.start, |first| first.span.start.min(span.start)),
915 span.end,
916 )
917 }
918 impl<'a> Visit<'a> for Functions {
919 fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) {
920 for statement in &program.body {
921 let span = statement.span();
922 self.inside((span.start, span.end), |this| {
923 this.visit_statement(statement)
924 });
925 }
926 }
927 fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) {
928 for statement in &block.body {
929 let span = statement.span();
930 self.inside((span.start, span.end), |this| {
931 this.visit_statement(statement)
932 });
933 }
934 }
935 fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) {
936 for statement in &block.body {
937 let span = statement.span();
938 self.inside((span.start, span.end), |this| {
939 this.visit_statement(statement)
940 });
941 }
942 }
943 fn visit_method_definition(&mut self, it: &oxc_ast::ast::MethodDefinition<'a>) {
944 self.inside(from_decorators(it.span, &it.decorators), |this| {
945 walk::walk_method_definition(this, it)
946 });
947 }
948 fn visit_property_definition(&mut self, it: &oxc_ast::ast::PropertyDefinition<'a>) {
949 self.inside(from_decorators(it.span, &it.decorators), |this| {
950 walk::walk_property_definition(this, it)
951 });
952 }
953 fn visit_accessor_property(&mut self, it: &oxc_ast::ast::AccessorProperty<'a>) {
954 self.inside(from_decorators(it.span, &it.decorators), |this| {
955 walk::walk_accessor_property(this, it)
956 });
957 }
958 fn visit_static_block(&mut self, it: &oxc_ast::ast::StaticBlock<'a>) {
959 self.inside((it.span.start, it.span.end), |this| {
960 walk::walk_static_block(this, it)
961 });
962 }
963 fn visit_function(
965 &mut self,
966 it: &oxc_ast::ast::Function<'a>,
967 _flags: oxc_syntax::scope::ScopeFlags,
968 ) {
969 if it.body.is_some() {
970 self.function(it.span);
971 }
972 }
973 fn visit_arrow_function_expression(
974 &mut self,
975 it: &oxc_ast::ast::ArrowFunctionExpression<'a>,
976 ) {
977 self.function(it.span);
978 }
979 }
980 let mut functions = Functions::default();
981 functions.visit_program(&parsed.program);
982 functions
983 .found
984 .into_iter()
985 .map(|(from, to)| lines(from as usize, to as usize))
986 .collect::<Vec<_>>()
987 }
988 Language::Python => {
989 use ruff_python_ast::{
990 Stmt,
991 visitor::{Visitor, walk_stmt},
992 };
993 use ruff_text_size::Ranged;
994 struct Functions(Vec<(usize, usize)>);
995 impl<'a> Visitor<'a> for Functions {
996 fn visit_stmt(&mut self, stmt: &'a Stmt) {
997 match stmt {
998 Stmt::FunctionDef(function) => {
999 let from = function
1000 .decorator_list
1001 .first()
1002 .map_or(function.start(), |first| {
1003 first.start().min(function.start())
1004 });
1005 self.0.push((from.to_usize(), function.end().to_usize()));
1006 }
1007 _ => walk_stmt(self, stmt),
1008 }
1009 }
1010 }
1011 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
1012 return Vec::new();
1013 };
1014 let mut functions = Functions(Vec::new());
1015 for stmt in &parsed.syntax().body {
1016 functions.visit_stmt(stmt);
1017 }
1018 functions
1019 .0
1020 .into_iter()
1021 .map(|(from, to)| lines(from, to))
1022 .collect()
1023 }
1024 _ => Vec::new(),
1025 };
1026 spans.sort_unstable();
1027 spans.dedup();
1028 spans
1029}
1030
1031fn whole_statement(statement: &Statement) -> Option<usize> {
1034 let lines = statement.text.lines().count();
1035 let word = statement
1036 .text
1037 .lines()
1038 .next()
1039 .unwrap_or("")
1040 .trim()
1041 .trim_end_matches(';')
1042 .trim();
1043 (statement.change == Change::Skip
1044 && lines > 1
1045 && !matches!(word, "break" | "continue" | "next"))
1046 .then_some(lines)
1047}
1048
1049fn line_starts(source: &str) -> Vec<usize> {
1050 std::iter::once(0)
1051 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
1052 .collect()
1053}
1054
1055fn line_of(starts: &[usize], offset: usize) -> usize {
1056 starts.partition_point(|&s| s <= offset)
1057}
1058
1059fn numbered(lines: &[&str]) -> String {
1060 lines
1061 .iter()
1062 .enumerate()
1063 .map(|(i, l)| format!("{:5} {l}", i + 1))
1064 .collect::<Vec<_>>()
1065 .join("\n")
1066}
1067
1068fn quoted_fragments(text: &str) -> Vec<String> {
1073 let mut out = Vec::new();
1074 let mut chars = text.char_indices().peekable();
1075 while let Some((start, quote)) = chars.next() {
1076 if !matches!(quote, '\'' | '"' | '`') {
1077 continue;
1078 }
1079 let mut end = None;
1080 while let Some((at, c)) = chars.next() {
1081 if c == '\\' {
1082 chars.next();
1083 } else if c == quote {
1084 end = Some(at);
1085 break;
1086 }
1087 }
1088 let Some(end) = end else { break };
1089 let body = &text[start + quote.len_utf8()..end];
1090 let mut pieces = vec![String::new()];
1093 let mut depth = 0usize;
1094 for c in body.chars() {
1095 match c {
1096 '{' => {
1097 depth += 1;
1098 pieces.push(String::new());
1099 }
1100 '}' if depth > 0 => depth -= 1,
1101 '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
1102 _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
1103 _ => {}
1104 }
1105 }
1106 for piece in pieces {
1107 let piece = piece.trim();
1108 if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
1109 out.push(piece.to_owned());
1110 }
1111 }
1112 }
1113 out.sort();
1114 out.dedup();
1115 out
1116}
1117
1118fn stem(path: &str) -> String {
1119 Path::new(path)
1120 .file_name()
1121 .and_then(|n| n.to_str())
1122 .unwrap_or("")
1123 .split('.')
1124 .next()
1125 .unwrap_or("")
1126 .to_lowercase()
1127}
1128
1129fn describe(name: &str, separator: &str) -> String {
1130 let parts = name.split(separator).collect::<Vec<_>>();
1131 parts[..parts.len().saturating_sub(1)].join(separator)
1132}
1133
1134impl Population {
1135 pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
1138 {
1139 let mut cache = self
1140 .files
1141 .lock()
1142 .expect("the file cache is only read and filled");
1143 for (file, text) in files {
1144 cache.insert(file, Some(text.into()));
1145 }
1146 }
1147 self
1148 }
1149
1150 pub fn files_read(&self) -> BTreeMap<String, String> {
1152 self.files
1153 .lock()
1154 .expect("the file cache is only read and filled")
1155 .iter()
1156 .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
1157 .collect()
1158 }
1159
1160 fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
1161 let mut files = self
1162 .files
1163 .lock()
1164 .expect("the file cache is only read and filled");
1165 files
1166 .entry(file.to_owned())
1167 .or_insert_with(|| {
1168 std::fs::read_to_string(self.root.join(file))
1169 .ok()
1170 .map(Into::into)
1171 })
1172 .clone()
1173 }
1174
1175 fn quotes(&self, test: usize, fragments: &[String]) -> bool {
1177 let file = &self.tests[test].file;
1178 fragments.iter().any(|fragment| {
1179 let key = (file.clone(), fragment.clone());
1180 if let Some(&known) = self
1181 .quotes
1182 .lock()
1183 .expect("the quote cache is only read and filled")
1184 .get(&key)
1185 {
1186 return known;
1187 }
1188 let found = self
1189 .read(file)
1190 .is_some_and(|text| text.contains(fragment.as_str()));
1191 self.quotes
1192 .lock()
1193 .expect("the quote cache is only read and filled")
1194 .insert(key, found);
1195 found
1196 })
1197 }
1198
1199 pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
1206 let s = &self.statements[statement];
1207 let base = stem(&s.file);
1208 let key = |&t: &usize| {
1209 let test = &self.tests[t];
1210 (
1211 !Path::new(&test.file)
1212 .file_name()
1213 .and_then(|n| n.to_str())
1214 .unwrap_or("")
1215 .to_lowercase()
1216 .contains(&base),
1217 test.file.clone(),
1218 test.name.clone(),
1219 )
1220 };
1221 let mut all = s.tests.clone();
1222 all.sort_by_key(key);
1223 if all.len() <= k {
1224 return all;
1225 }
1226 let mut groups = Vec::<((String, String), Vec<usize>)>::new();
1227 for &t in &all {
1228 let g = (
1229 self.tests[t].file.clone(),
1230 describe(
1231 &self.tests[t].name,
1232 separator(self.language, &self.tests[t].runner),
1233 ),
1234 );
1235 match groups.iter_mut().find(|(k, _)| *k == g) {
1236 Some((_, v)) => v.push(t),
1237 None => groups.push((g, vec![t])),
1238 }
1239 }
1240 let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
1241 for ((file, _), members) in groups {
1242 match by_file.iter_mut().find(|(f, _)| *f == file) {
1243 Some((_, v)) => v.push(members),
1244 None => by_file.push((file, vec![members])),
1245 }
1246 }
1247 by_file.sort_by_key(|(f, _)| {
1248 (
1249 !Path::new(f)
1250 .file_name()
1251 .and_then(|n| n.to_str())
1252 .unwrap_or("")
1253 .to_lowercase()
1254 .contains(&base),
1255 f.clone(),
1256 )
1257 });
1258 let mut picked = Vec::new();
1259 let fragments = quoted_fragments(&s.text);
1260 if !fragments.is_empty() {
1261 let quoting = all
1262 .iter()
1263 .copied()
1264 .filter(|&t| self.quotes(t, &fragments))
1265 .collect::<Vec<_>>();
1266 let mut files = BTreeSet::new();
1267 let (first, rest): (Vec<usize>, Vec<usize>) = quoting
1268 .iter()
1269 .partition(|&&t| files.insert(self.tests[t].file.clone()));
1270 for t in first.into_iter().chain(rest) {
1271 if picked.len() < k {
1272 picked.push(t);
1273 }
1274 }
1275 }
1276 let mut depth = 0;
1277 while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
1278 for (_, g) in &by_file {
1279 if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
1280 picked.push(g[depth][0]);
1281 }
1282 }
1283 depth += 1;
1284 }
1285 for t in all {
1286 if picked.len() >= k {
1287 break;
1288 }
1289 if !picked.contains(&t) {
1290 picked.push(t);
1291 }
1292 }
1293 picked
1294 }
1295
1296 pub fn last_order(&self, statement: usize, asked: &[usize], k: usize) -> Vec<usize> {
1307 let spread = self.order(statement, MORE_TESTS);
1308 let position = |t: &usize| spread.iter().position(|x| x == t).unwrap_or(usize::MAX);
1309 let s = &self.statements[statement];
1310 let line = name_words(s.text.lines().next().unwrap_or(""));
1311 let shared = |t: usize| {
1312 let name = &self.tests[t].name;
1313 let name = name.rsplit("::").next().unwrap_or(name);
1314 name_words(name).intersection(&line).count()
1315 };
1316 let mut rest = s
1317 .tests
1318 .iter()
1319 .copied()
1320 .filter(|t| !asked.contains(t))
1321 .collect::<Vec<_>>();
1322 rest.sort_by_cached_key(|t| {
1323 (
1324 std::cmp::Reverse(shared(*t)),
1325 self.breadth(*t),
1326 position(t),
1327 *t,
1328 )
1329 });
1330 rest.truncate(k);
1331 rest
1332 }
1333
1334 fn breadth(&self, test: usize) -> usize {
1336 self.ran[test].values().map(BTreeSet::len).sum()
1337 }
1338
1339 pub fn test_code(&self, test: usize) -> String {
1342 match self.language {
1343 Language::Python => return self.python_test_code(test),
1344 Language::JavaScript => {}
1345 _ => return self.located_test_code(test),
1346 }
1347 let t = &self.tests[test];
1348 let text = self.read(&t.file).unwrap_or_default();
1349 let lines = text.split('\n').collect::<Vec<_>>();
1350 let start = js_test_line(&lines, &t.name);
1351 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1352 let head = (0..lines.len().min(HEAD_LINES))
1353 .filter(|&i| !self.language.comment(lines[i]))
1354 .map(fmt)
1355 .collect::<Vec<_>>();
1356 let Some(start) = start else {
1357 let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
1358 return head
1359 .into_iter()
1360 .chain(std::iter::once(" ...".to_owned()))
1361 .chain(rest)
1362 .collect::<Vec<_>>()
1363 .join("\n");
1364 };
1365 let indent = lines[start].len() - lines[start].trim_start().len();
1366 let mut body = Vec::new();
1367 for (i, l) in lines
1368 .iter()
1369 .copied()
1370 .enumerate()
1371 .take(start + BODY_LINES)
1372 .skip(start)
1373 {
1374 if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1375 break;
1376 }
1377 if !self.language.comment(l) {
1378 body.push(fmt(i));
1379 }
1380 }
1381 let mut out = head;
1382 if start > HEAD_LINES {
1383 out.push(" ...".into());
1384 }
1385 out.extend(body);
1386 out.join("\n")
1387 }
1388
1389 fn located_test_code(&self, test: usize) -> String {
1392 let t = &self.tests[test];
1393 let text = self.read(&t.file).unwrap_or_default();
1394 let lines = text.split('\n').collect::<Vec<_>>();
1395 let located = match self.language {
1396 Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1397 Language::Go => go::locate(&lines, &t.name),
1398 Language::Rust => rust_source::locate(&lines, &t.name),
1399 _ => jvm::locate(&lines, &t.name),
1400 };
1401 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1402 let mut open = false;
1404 let commented = lines
1405 .iter()
1406 .map(|l| {
1407 let t = l.trim();
1408 let was = open;
1409 if self.language != Language::Ruby {
1410 if !open && t.starts_with("/*") {
1411 open = true;
1412 }
1413 if open && t.contains("*/") {
1414 open = false;
1415 return true;
1416 }
1417 }
1418 was || open || self.language.comment(l)
1419 })
1420 .collect::<Vec<_>>();
1421 let head = (0..lines.len())
1423 .filter(|&i| !commented[i])
1424 .take(HEAD_LINES)
1425 .collect::<Vec<_>>();
1426 let head_end = head.last().map_or(0, |&i| i + 1);
1427 let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1428 let Some(located) = located else {
1429 out.push(" ...".into());
1430 out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1431 return out.join("\n");
1432 };
1433 let end = located
1434 .end
1435 .min(located.start + BODY_LINES)
1436 .min(lines.len().saturating_sub(1));
1437 let mut last = head_end;
1438 let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1439 shown.extend(located.start..=end);
1440 shown.extend(used_definitions(&lines, self.language, located.start, end));
1443 for i in shown {
1444 if i < last || (commented[i] && self.language != Language::Rust) {
1446 continue;
1447 }
1448 if i > last {
1449 out.push(" ...".into());
1450 }
1451 out.push(fmt(i));
1452 last = i + 1;
1453 }
1454 out.join("\n")
1455 }
1456
1457 fn python_test_code(&self, test: usize) -> String {
1461 let t = &self.tests[test];
1462 let text = self.read(&t.file).unwrap_or_default();
1463 let lines = text.split('\n').collect::<Vec<_>>();
1464 let title = python_title(&t.name);
1465 let indent_of = |l: &str| l.len() - l.trim_start().len();
1466 let (from, to, body) = python_class_lines(&lines, &python_classes(&t.name));
1469 let defines = |i: &usize| {
1470 let t = lines[*i].trim_start();
1471 t.starts_with(&format!("def {title}(")) || t.starts_with(&format!("async def {title}("))
1472 };
1473 let own = |i: &usize| defines(i) && body.is_none_or(|body| indent_of(lines[*i]) == body);
1475 let start = (!title.is_empty())
1476 .then(|| {
1477 (from..to)
1478 .find(own)
1479 .or_else(|| (0..lines.len()).find(defines))
1480 })
1481 .flatten();
1482 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1483 let mut out = (0..lines.len().min(HEAD_LINES))
1484 .filter(|&i| !self.language.comment(lines[i]))
1485 .map(fmt)
1486 .collect::<Vec<_>>();
1487 let Some(start) = start else {
1488 out.push(" ...".into());
1489 out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1490 return out.join("\n");
1491 };
1492 let indent = indent_of(lines[start]);
1493 let mut first = start;
1494 while first > 0
1495 && lines[first - 1].trim_start().starts_with('@')
1496 && indent_of(lines[first - 1]) == indent
1497 {
1498 first -= 1;
1499 }
1500 let class = (indent > 0)
1501 .then(|| {
1502 (0..first).rev().find(|&i| {
1503 lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1504 })
1505 })
1506 .flatten();
1507 let mut last = HEAD_LINES.min(lines.len()).checked_sub(1);
1509 let mut show = |out: &mut Vec<String>, i: usize| {
1510 if i >= HEAD_LINES {
1511 if last.is_some_and(|l| i > l + 1) {
1512 out.push(" ...".into());
1513 }
1514 out.push(fmt(i));
1515 last = Some(i);
1516 }
1517 };
1518 for i in class.into_iter().chain(first..=start) {
1519 show(&mut out, i);
1520 }
1521 for (i, &l) in lines
1522 .iter()
1523 .enumerate()
1524 .skip(start + 1)
1525 .take(BODY_LINES - 1)
1526 {
1527 if !l.trim().is_empty() && indent_of(l) <= indent {
1528 break;
1529 }
1530 if !self.language.comment(l) && i >= HEAD_LINES {
1532 out.push(fmt(i));
1533 }
1534 }
1535 out.join("\n")
1536 }
1537
1538 pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1544 if self.language.asks_within_the_function() {
1545 return BTreeMap::new();
1546 }
1547 let cap = HELPER_CHARS.min(1500 + 400 * questions);
1548 let t = &self.tests[test];
1549 let text = self.read(&t.file).unwrap_or_default();
1550 let mut out = BTreeMap::new();
1551 let mut size = 0;
1552 let files = match self.language {
1553 Language::JavaScript | Language::Python => Vec::new(),
1554 Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1555 Language::Go => go::helpers(&self.root, &t.file, &text),
1556 Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1557 Language::Jvm => jvm::imports(&text)
1558 .iter()
1559 .filter_map(|import| {
1560 jvm::class_paths(&import.replace('/', "."))
1561 .iter()
1562 .find_map(|suffix| {
1563 self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1564 })
1565 .cloned()
1566 })
1567 .collect(),
1568 };
1569 for file in files {
1570 if out.contains_key(&file) {
1571 continue;
1572 }
1573 if self.source_files.contains(&file) || file.contains("node_modules") {
1574 continue;
1575 }
1576 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1577 if size + body.len() > cap {
1578 let keep = cap.saturating_sub(size);
1579 let cut = body
1580 .char_indices()
1581 .map(|(i, _)| i)
1582 .take_while(|&i| i <= keep)
1583 .last()
1584 .unwrap_or(0);
1585 body = format!("{}\n... (cut)", &body[..cut]);
1586 }
1587 size += body.len();
1588 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1589 if size >= cap {
1590 break;
1591 }
1592 }
1593 if self.language == Language::Ruby {
1594 let mut used = 0;
1595 for file in ruby::support_files(&self.root, &text) {
1596 if out.contains_key(&file) || used >= FIXTURE_CHARS {
1597 continue;
1598 }
1599 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1600 if used + body.len() > FIXTURE_CHARS {
1601 let keep = FIXTURE_CHARS - used;
1602 let cut = body
1603 .char_indices()
1604 .map(|(i, _)| i)
1605 .take_while(|&i| i <= keep)
1606 .last()
1607 .unwrap_or(0);
1608 body = format!("{}\n... (cut)", &body[..cut]);
1609 }
1610 used += body.len();
1611 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1612 }
1613 }
1614 out
1615 }
1616
1617 pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1628 let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1629 let within = self.language.asks_within_the_function();
1630 let ran = &self.ran[test];
1631 let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1632 for &a in asked {
1633 want.entry(self.statements[a].file.clone())
1634 .or_default()
1635 .insert(self.statements[a].line);
1636 }
1637 let mut files = want.keys().cloned().collect::<Vec<_>>();
1638 let mut others = ran
1639 .iter()
1640 .filter(|(f, _)| !within && !want.contains_key(*f))
1641 .collect::<Vec<_>>();
1642 others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1643 files.extend(others.into_iter().map(|(f, _)| f.clone()));
1644 let empty = BTreeSet::new();
1645 let mut out = BTreeMap::new();
1646 let mut total = 0;
1647 for f in files {
1648 let Some(source) = self.read(&f) else {
1649 continue;
1650 };
1651 let text = source.split('\n').collect::<Vec<_>>();
1652 let r = ran.get(&f).unwrap_or(&empty);
1653 let code = (1..=text.len())
1654 .filter(|&i| !self.language.comment(text[i - 1]))
1655 .collect::<Vec<_>>();
1656 let pos = code
1657 .iter()
1658 .enumerate()
1659 .map(|(n, &i)| (i, n))
1660 .collect::<BTreeMap<_, _>>();
1661 let around = |l: usize, w: usize| -> Vec<usize> {
1662 match pos.get(&l) {
1663 Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1664 None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1665 }
1666 };
1667 let mut show = BTreeSet::new();
1668 for &l in r {
1669 show.extend(around(l, CONTEXT_LINES));
1670 }
1671 for &l in want.get(&f).unwrap_or(&empty) {
1672 show.extend(around(l, WINDOW_LINES));
1673 }
1674 let end = show
1677 .iter()
1678 .rev()
1679 .copied()
1680 .find(|&i| i != 0 && i <= text.len() && !self.language.comment(text[i - 1]))
1681 .unwrap_or(0);
1682 if within {
1683 let functions = outermost_functions(self.language, &f, &source);
1684 let around =
1685 |l: usize| (l.saturating_sub(OUTSIDE_A_FUNCTION), l + OUTSIDE_A_FUNCTION);
1686 let function = |l: usize| {
1687 functions
1688 .iter()
1689 .copied()
1690 .find(|&(from, to)| from <= l && l <= to)
1691 };
1692 let begins = self
1694 .statements
1695 .iter()
1696 .filter(|s| s.file == f)
1697 .map(|s| s.line)
1698 .collect::<BTreeSet<_>>();
1699 let mut kept = Vec::new();
1700 for statement in asked.iter().map(|&a| &self.statements[a]) {
1701 if statement.file != f {
1702 continue;
1703 }
1704 kept.push(function(statement.line).unwrap_or(around(statement.line)));
1705 for l in (statement.line + 1)
1709 ..statement.line + whole_statement(statement).unwrap_or(1)
1710 {
1711 kept.push(
1712 begins
1713 .contains(&l)
1714 .then(|| function(l))
1715 .flatten()
1716 .unwrap_or(around(l)),
1717 );
1718 }
1719 }
1720 show.retain(|i| kept.iter().any(|&(from, to)| from <= *i && *i <= to));
1721 }
1722 let mut lines = Vec::new();
1723 let mut last = 0;
1724 for i in show {
1725 if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1726 continue;
1727 }
1728 if i != last + 1 {
1729 lines.push(format!(" ... {} lines not shown", i - last - 1));
1730 }
1731 lines.push(format!(
1732 "{}{:5} {}",
1733 if r.contains(&i) { '▶' } else { ' ' },
1734 i,
1735 text[i - 1]
1736 ));
1737 last = i;
1738 }
1739 if within && last != 0 && end > last {
1740 lines.push(format!(" ... {} lines not shown", end - last));
1741 }
1742 let body = lines.join("\n");
1743 if !want.contains_key(&f) && total + body.len() > budget {
1744 continue;
1745 }
1746 total += body.len();
1747 out.insert(f, body);
1748 }
1749 out
1750 }
1751
1752 pub fn questions(&self, statement: usize) -> Vec<String> {
1754 let s = &self.statements[statement];
1755 let line = s.text.lines().next().unwrap_or("").trim();
1756 let changes = match (s.change, &s.replacement) {
1757 (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1758 if self.language == Language::Rust =>
1759 {
1760 vec![rust_question(line, s.change, r)]
1761 }
1762 (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1765 vec![format!(
1766 "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1767 )]
1768 }
1769 (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1770 vec![format!(
1771 "`{line}` becomes `{r}` (the original value is still computed first)"
1772 )]
1773 }
1774 _ => self.default_questions(s, line),
1775 };
1776 changes
1777 .into_iter()
1778 .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1779 .collect()
1780 }
1781
1782 fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1783 match s.change {
1784 Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1787 "`{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)"
1788 )],
1789 Change::ReturnUndefined
1792 if self.language == Language::Jvm
1793 && s.text.contains("return")
1794 && ["when", "switch", "try"].iter().any(|k| {
1795 line.strip_prefix(k)
1796 .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1797 }) =>
1798 {
1799 vec![format!(
1800 "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1801 self.language.nothing()
1802 )]
1803 }
1804 Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1805 "`{line}` returns {} instead (its expression still runs)",
1806 self.language.nothing()
1807 )],
1808 Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1809 let nothing = match self.language {
1810 Language::Rust
1811 if ["Ok(", "Err(", "return Ok(", "return Err("]
1812 .iter()
1813 .any(|p| line.starts_with(p)) =>
1814 {
1815 "`Ok(Default::default())`"
1816 }
1817 other => other.nothing(),
1818 };
1819 vec![if line.starts_with("return") {
1820 format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1821 } else {
1822 format!(
1825 "`{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}"
1826 )
1827 }]
1828 }
1829 Change::ValueUndefined if self.language == Language::Go => vec![format!(
1830 "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1831 )],
1832 Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1833 "`{line}`: after it runs, the variable it assigns holds {} instead",
1834 self.language.nothing()
1835 )],
1836 Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1837 let nothing = self.language.nothing();
1838 vec![match split_initializer(line) {
1839 Some(head) => format!(
1840 "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1841 ),
1842 None => format!("`{line}`: the assigned value becomes {nothing}"),
1843 }]
1844 }
1845 Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1846 "`{line}` becomes `return None` (the expression is not evaluated)"
1847 )],
1848 Change::ReturnUndefined => vec![format!(
1849 "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1850 )],
1851 Change::ValueUndefined if self.language == Language::Python => {
1852 vec![match split_initializer(line) {
1853 Some(head) => {
1854 format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1855 }
1856 None => format!("`{line}`: the assigned value becomes None"),
1857 }]
1858 }
1859 Change::ValueUndefined => vec![match split_initializer(line) {
1860 Some(head) => format!(
1861 "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1862 ),
1863 None => format!("`{line}`: the declared value becomes undefined"),
1864 }],
1865 Change::Skip => {
1866 let word = line.trim_end_matches(';').trim();
1867 vec![match word {
1868 "break" => format!(
1869 "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1870 ),
1871 "continue" | "next" => format!(
1872 "`{line}` is deleted: the rest of the loop body runs in this iteration"
1873 ),
1874 _ if whole_statement(s).is_some() => {
1879 let lines = s.text.lines().count();
1880 format!(
1881 "the whole statement starting `{line}` is deleted, all {lines} lines of it (lines {} to {}): none of it runs",
1882 s.line,
1883 s.line + lines - 1
1884 )
1885 }
1886 _ if self.language == Language::Python
1892 && (word == "return" || word.starts_with("return ")) =>
1893 {
1894 format!(
1895 "`{line}` is deleted: the function does not return here, and execution carries on with whatever follows it"
1896 )
1897 }
1898 _ if self.language == Language::Python
1899 && (word == "raise" || word.starts_with("raise ")) =>
1900 {
1901 format!(
1902 "`{line}` is deleted: no error is raised here, and execution carries on with whatever follows it"
1903 )
1904 }
1905 _ => format!("`{line}` is deleted (it never runs)"),
1906 }]
1907 }
1908 Change::ExpressionUndefined => vec![format!(
1909 "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1910 )],
1911 Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1912 Change::BareError => vec![format!(
1913 "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1914 )],
1915 Change::Invert => {
1916 let c = match self.language {
1917 Language::Python => python_condition(line),
1918 Language::JavaScript => condition(line),
1919 Language::Ruby => ruby::condition(line),
1920 Language::Go => go::condition(line),
1921 Language::Rust => rust_source::condition(line),
1922 Language::Jvm => jvm::condition(line),
1923 };
1924 if c.starts_with("let ") {
1927 return vec![format!(
1928 "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1929 )];
1930 }
1931 vec![
1932 format!(
1933 "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1934 ),
1935 format!(
1936 "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1937 ),
1938 ]
1939 }
1940 }
1941 }
1942
1943 pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1945 let t = &self.tests[test];
1946 let mut questions = serde_json::Map::new();
1947 for (n, &a) in asked.iter().enumerate() {
1948 for (j, q) in self.questions(a).into_iter().enumerate() {
1949 questions.insert(
1950 format!("q{n}_{j}"),
1951 json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1952 );
1953 }
1954 }
1955 let mut state = json!({
1956 "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1957 "test": {"file": t.file, "name": t.name},
1958 "test_code": self.test_code(test),
1959 "code_run": self.code_run(test, asked)});
1960 if !self.language.asks_within_the_function() {
1961 state["helpers"] = json!(self.helpers(test, asked.len()));
1962 }
1963 json!({"model": model, "state": state, "questions": questions})
1964 }
1965
1966 pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1971 let t = &self.tests[test];
1972 let parts = json!([
1973 model,
1974 salt,
1975 INSTRUCTIONS,
1976 runtime(&t.runner),
1977 true,
1978 &t.file,
1979 &t.name,
1980 strip_numbers(&self.test_code(test)),
1981 self.helpers(test, 1)
1982 .into_iter()
1983 .map(|(f, b)| (f, strip_numbers(&b)))
1984 .collect::<BTreeMap<_, _>>(),
1985 self.code_run(test, &[statement])
1986 .into_iter()
1987 .map(|(f, b)| (f, strip_numbers(&b)))
1988 .collect::<BTreeMap<_, _>>(),
1989 self.questions(statement)
1990 .iter()
1991 .map(|q| strip_location(q))
1992 .collect::<Vec<_>>(),
1993 ]);
1994 format!(
1995 "{:x}",
1996 Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1997 )
1998 }
1999}
2000
2001fn flipped(line: &str) -> String {
2004 let cut = comment_start(line);
2007 let (code, comment) = line.split_at(cut);
2008 format!("{}{comment}", flipped_code(code))
2009}
2010
2011fn comment_start(line: &str) -> usize {
2014 let bytes = line.as_bytes();
2015 let mut quote = None;
2016 let mut i = 0;
2017 while i < bytes.len() {
2018 let c = bytes[i];
2019 match quote {
2020 Some(_) if c == b'\\' => i += 1,
2021 Some(q) if c == q => quote = None,
2022 Some(_) => {}
2023 None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
2024 None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
2025 None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
2026 None => {}
2027 }
2028 i += 1;
2029 }
2030 line.len()
2031}
2032
2033fn flipped_code(line: &str) -> String {
2034 let words = [
2035 ("true", "false"),
2036 ("false", "true"),
2037 ("True", "False"),
2038 ("False", "True"),
2039 ];
2040 let found = words
2041 .iter()
2042 .filter_map(|(from, to)| {
2043 line.match_indices(from)
2044 .filter(|(at, _)| {
2045 let before = line[..*at].chars().next_back();
2046 let after = line[at + from.len()..].chars().next();
2047 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
2048 !word(before) && !word(after)
2049 })
2050 .last()
2051 .map(|(at, _)| (at, *from, *to))
2052 })
2053 .max_by_key(|(at, _, _)| *at);
2054 match found {
2055 Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
2056 None => line.to_owned(),
2057 }
2058}
2059
2060fn rust_question(line: &str, change: Change, replacement: &str) -> String {
2062 let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
2063 match change {
2064 Change::ReturnUndefined if line.starts_with("return") => {
2065 format!("`{line}` becomes `return {repl};`")
2066 }
2067 Change::ReturnUndefined => {
2068 format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
2069 }
2070 _ => match split_initializer(line) {
2071 Some(head) => format!("`{line}` becomes `{head} {repl};`"),
2072 None => format!("`{line}`: the value it assigns becomes `{repl}`"),
2073 },
2074 }
2075}
2076
2077pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
2079 use std::io::Write;
2080 let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
2081 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2082 encoder.write_all(&bytes).map_err(|e| e.to_string())?;
2083 let compressed = encoder.finish().map_err(|e| e.to_string())?;
2084 let temp = path.with_extension("tmp");
2085 std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
2086 std::fs::rename(&temp, path).map_err(|e| e.to_string())
2087}
2088
2089pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
2091 use std::io::Read;
2092 let compressed = std::fs::read(path).ok()?;
2093 let mut text = Vec::new();
2094 flate2::read::GzDecoder::new(compressed.as_slice())
2095 .read_to_end(&mut text)
2096 .ok()?;
2097 serde_json::from_slice(&text).ok()
2098}
2099
2100struct Located {
2103 context: Vec<usize>,
2104 start: usize,
2105 end: usize,
2106}
2107
2108fn indent(line: &str) -> usize {
2109 line.len() - line.trim_start().len()
2110}
2111
2112fn brace_end(lines: &[&str], start: usize) -> usize {
2115 brace_end_within(lines, start, BODY_LINES)
2116}
2117
2118fn block_end(lines: &[&str], start: usize) -> usize {
2121 brace_end_within(lines, start, lines.len())
2122}
2123
2124fn brace_end_within(lines: &[&str], start: usize, limit: usize) -> usize {
2125 let mut depth = 0i64;
2126 let mut opened = false;
2127 for (i, line) in lines.iter().enumerate().skip(start).take(limit) {
2128 let mut chars = line.chars().peekable();
2129 let mut quote: Option<char> = None;
2130 while let Some(c) = chars.next() {
2131 match (quote, c) {
2132 (Some(_), '\\') => {
2133 chars.next();
2134 }
2135 (Some(q), c) if c == q => quote = None,
2136 (Some(_), _) => {}
2137 (None, '"' | '`') => quote = Some(c),
2138 (None, '/') if chars.peek() == Some(&'/') => break,
2139 (None, '{') => {
2140 depth += 1;
2141 opened = true;
2142 }
2143 (None, '}') => depth -= 1,
2144 _ => {}
2145 }
2146 }
2147 if opened && depth <= 0 {
2148 return i;
2149 }
2150 }
2151 start
2152 .saturating_add(limit)
2153 .min(lines.len())
2154 .saturating_sub(1)
2155}
2156
2157fn keyword_end(lines: &[&str], start: usize) -> usize {
2159 let first = lines[start].trim_end();
2160 if first.ends_with(" end") || first.ends_with('}') {
2161 return start;
2162 }
2163 let depth = indent(lines[start]);
2164 (start + 1..lines.len().min(start + BODY_LINES))
2165 .find(|&i| {
2166 let t = lines[i].trim_start();
2167 indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
2168 })
2169 .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
2170}
2171
2172const DEFINITION_LINES: usize = 120;
2173
2174fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
2177 let words = |range: std::ops::RangeInclusive<usize>| {
2178 let mut out = BTreeSet::new();
2179 for i in range {
2180 let line = lines.get(i).copied().unwrap_or("");
2181 out.extend(
2182 line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2183 .filter(|w| !w.is_empty())
2184 .map(str::to_owned),
2185 );
2186 }
2187 out
2188 };
2189 let definitions = definitions(lines, language);
2190 let mut wanted = words(start..=end);
2191 let mut picked = BTreeSet::new();
2192 let mut budget = DEFINITION_LINES;
2193 for _ in 0..2 {
2194 let mut next = BTreeSet::new();
2195 for (name, a, b) in &definitions {
2196 let (a, b) = (*a, *b);
2197 if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
2198 continue;
2199 }
2200 let size = b - a + 1;
2201 if size > budget {
2202 continue;
2203 }
2204 budget -= size;
2205 picked.insert(a);
2206 next.extend(words(a..=b));
2207 picked.extend(a..=b);
2208 }
2209 wanted = next;
2210 }
2211 picked.into_iter().collect()
2212}
2213
2214fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
2217 let ident = |s: &str| {
2218 s.chars()
2219 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
2220 .collect::<String>()
2221 };
2222 let mut out = Vec::new();
2223 for (i, line) in lines.iter().enumerate() {
2224 let t = line.trim_start();
2225 let name = match language {
2226 Language::Go => t
2227 .strip_prefix("func ")
2228 .map(|r| match r.strip_prefix('(') {
2229 Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
2230 None => r,
2231 })
2232 .or_else(|| t.strip_prefix("var "))
2233 .or_else(|| t.strip_prefix("type "))
2234 .filter(|_| indent(line) == 0)
2235 .map(ident),
2236 Language::Rust => {
2237 let r = t.strip_prefix("pub ").unwrap_or(t);
2238 let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
2239 ["fn ", "const ", "static ", "struct ", "enum "]
2240 .iter()
2241 .find_map(|k| r.strip_prefix(k))
2242 .map(ident)
2243 }
2244 Language::Ruby => t
2245 .strip_prefix("def ")
2246 .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
2247 .or_else(|| {
2248 ["let(:", "let!(:", "subject(:"]
2249 .iter()
2250 .find_map(|k| t.strip_prefix(k))
2251 .map(ident)
2252 })
2253 .or_else(|| {
2254 (t.starts_with("subject ") || t.starts_with("subject{"))
2255 .then(|| "subject".into())
2256 }),
2257 Language::Jvm => {
2258 if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
2259 Some(ident(r.1.trim_start_matches('`')))
2260 } else if t.contains('(')
2261 && !t.ends_with(';')
2262 && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
2263 && ![
2264 "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
2265 "throw", "assert", "super", "this",
2266 ]
2267 .iter()
2268 .any(|k| t.starts_with(k))
2269 {
2270 let head = t.split('(').next().unwrap_or("");
2271 let mut parts = head.split_whitespace().rev();
2272 let name = parts.next().unwrap_or("");
2273 (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
2274 } else {
2275 None
2276 }
2277 }
2278 _ => None,
2279 };
2280 let Some(name) = name.filter(|n| !n.is_empty()) else {
2281 continue;
2282 };
2283 let end = match language {
2284 Language::Ruby => keyword_end(lines, i),
2285 _ if line.contains('{')
2286 || lines
2287 .get(i + 1)
2288 .is_some_and(|n| n.trim_start().starts_with('{')) =>
2289 {
2290 brace_end(lines, i)
2291 }
2292 _ => i,
2293 };
2294 out.push((name, i, end));
2295 }
2296 out
2297}
2298
2299fn ends_with_path(path: &str, suffix: &str) -> bool {
2301 path.strip_suffix(suffix)
2302 .is_some_and(|head| head.is_empty() || head.ends_with('/'))
2303}
2304
2305fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
2308 let mut out = ignore::WalkBuilder::new(root)
2309 .build()
2310 .filter_map(Result::ok)
2311 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2312 .filter(|e| {
2313 e.path()
2314 .extension()
2315 .and_then(|x| x.to_str())
2316 .is_some_and(|x| extensions.contains(&x))
2317 })
2318 .filter_map(|e| {
2319 e.path()
2320 .strip_prefix(root)
2321 .ok()
2322 .map(|p| p.to_string_lossy().replace('\\', "/"))
2323 })
2324 .filter(|p| !p.starts_with(".supercov/"))
2325 .collect::<Vec<_>>();
2326 out.sort();
2327 out
2328}
2329
2330fn is_test_call(line: &str) -> bool {
2331 let t = line.trim_start();
2332 let rest = if let Some(r) = t.strip_prefix("it") {
2333 r
2334 } else if let Some(r) = t.strip_prefix("test") {
2335 r
2336 } else {
2337 return false;
2338 };
2339 let rest = if let Some(r) = rest.strip_prefix('.') {
2340 r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
2341 } else {
2342 rest
2343 };
2344 rest.trim_start().starts_with('(')
2345}
2346
2347fn split_initializer(line: &str) -> Option<String> {
2349 let b = line.as_bytes();
2350 for i in 1..b.len() {
2351 if b[i] == b'='
2352 && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
2353 && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
2354 {
2355 return Some(line[..=i].to_owned());
2356 }
2357 }
2358 None
2359}
2360
2361fn python_title(name: &str) -> &str {
2365 let last = name.rsplit("::").next().unwrap_or(name);
2366 let last = last.split('[').next().unwrap_or(last);
2367 last.rsplit('.').next().unwrap_or(last)
2368}
2369
2370fn python_classes(name: &str) -> Vec<&str> {
2373 let name = name.split('[').next().unwrap_or(name);
2374 if name.contains("::") {
2375 let parts = name.split("::").collect::<Vec<_>>();
2376 return parts[1..parts.len().saturating_sub(1)].to_vec();
2377 }
2378 let parts = name.split('.').collect::<Vec<_>>();
2379 parts
2380 .len()
2381 .checked_sub(2)
2382 .map(|at| parts[at])
2383 .filter(|class| class.starts_with(|c: char| c.is_ascii_uppercase()))
2384 .into_iter()
2385 .collect()
2386}
2387
2388fn python_class_lines(lines: &[&str], classes: &[&str]) -> (usize, usize, Option<usize>) {
2392 let indent_of = |l: &str| l.len() - l.trim_start().len();
2393 let (mut from, mut to, mut body) = (0, lines.len(), None);
2394 for class in classes {
2395 let declared = (from..to).find(|&i| {
2396 lines[i]
2397 .trim_start()
2398 .strip_prefix("class ")
2399 .and_then(|rest| rest.strip_prefix(class))
2400 .is_some_and(|rest| rest.starts_with(['(', ':']))
2401 });
2402 let Some(at) = declared else {
2403 return (0, lines.len(), None);
2404 };
2405 let indent = indent_of(lines[at]);
2406 let end = (at + 1..to)
2407 .find(|&i| !lines[i].trim().is_empty() && indent_of(lines[i]) <= indent)
2408 .unwrap_or(to);
2409 body = (at + 1..end)
2410 .find(|&i| !lines[i].trim().is_empty())
2411 .map(|i| indent_of(lines[i]));
2412 (from, to) = (at, end);
2413 }
2414 (from, to, body)
2415}
2416
2417fn python_condition(line: &str) -> String {
2418 let t = line.trim();
2419 let t = t
2420 .strip_prefix("elif")
2421 .or_else(|| t.strip_prefix("if"))
2422 .map(str::trim_start)
2423 .unwrap_or(t);
2424 t.strip_suffix(':')
2425 .map(str::trim_end)
2426 .unwrap_or(t)
2427 .to_owned()
2428}
2429
2430fn condition(line: &str) -> String {
2432 let mut t = line.trim();
2433 if let Some(r) = t.strip_prefix('}') {
2434 let r = r.trim_start();
2435 if let Some(r) = r.strip_prefix("else") {
2436 t = r.trim_start();
2437 }
2438 }
2439 let Some(r) = t.strip_prefix("if") else {
2440 return line.to_owned();
2441 };
2442 let r = r.trim_start();
2443 let Some(r) = r.strip_prefix('(') else {
2444 return line.to_owned();
2445 };
2446 let r = r.trim_end();
2447 let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2448 match r.strip_suffix(')') {
2449 Some(c) => c.to_owned(),
2450 None => line.to_owned(),
2451 }
2452}
2453
2454fn strip_numbers(text: &str) -> String {
2455 text.split('\n')
2456 .map(|l| {
2457 let (mark, rest) = match l.chars().next() {
2458 Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2459 _ => (None, l),
2460 };
2461 let trimmed = rest.trim_start();
2462 let digits = trimmed.len()
2463 - trimmed
2464 .trim_start_matches(|c: char| c.is_ascii_digit())
2465 .len();
2466 if digits > 0 && trimmed[digits..].starts_with(' ') {
2467 format!(
2468 "{}{}",
2469 mark.map(String::from).unwrap_or_default(),
2470 &trimmed[digits + 1..]
2471 )
2472 } else if let Some(n) = l
2473 .trim_start()
2474 .strip_prefix("... ")
2475 .and_then(|r| r.split_once(" lines not shown"))
2476 {
2477 let _ = n;
2478 "...".to_owned()
2479 } else {
2480 l.to_owned()
2481 }
2482 })
2483 .collect::<Vec<_>>()
2484 .join("\n")
2485}
2486
2487fn strip_location(question: &str) -> String {
2488 match question.split_once(": ") {
2490 Some((loc, rest)) => match loc.rsplit_once(':') {
2491 Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2492 format!("{file}:: {rest}")
2493 }
2494 _ => question.to_owned(),
2495 },
2496 None => question.to_owned(),
2497 }
2498}
2499
2500fn normalize(path: &Path) -> String {
2504 let mut out = Vec::new();
2505 for c in path.components() {
2506 match c {
2507 std::path::Component::ParentDir => {
2508 out.pop();
2509 }
2510 std::path::Component::CurDir => {}
2511 other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2512 }
2513 }
2514 out.join("/")
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519 #[test]
2520 fn a_replacement_that_is_the_statement_is_no_change() {
2521 assert!(super::same_code("return 0, nil", "return 0, nil"));
2522 assert!(super::same_code("count := 0", "count := 0;"));
2523 assert!(!super::same_code("return 0, nil", "return n, nil"));
2524 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";
2525 assert_eq!(
2526 super::go::rewrite(source, 7, Change::ReturnUndefined).as_deref(),
2527 Some("return 0, nil")
2528 );
2529 }
2530
2531 use super::*;
2532
2533 #[test]
2534 fn a_statement_many_tests_run_is_asked_of_the_tests_that_ran_little_else() {
2535 let source = (0..10)
2540 .map(|n| format!("const v{n} = compute({n});"))
2541 .collect::<Vec<_>>()
2542 .join("\n");
2543 let test = |file: &str, name: &str| InputTest {
2544 id: format!("{file}::{name}"),
2545 file: Some(file.to_owned()),
2546 name: name.to_owned(),
2547 role: "test".into(),
2548 runner: "node:test".into(),
2549 };
2550 let mut tests = (0..17)
2551 .map(|n| {
2552 let name = if n == 16 {
2553 "calls compute on start"
2554 } else {
2555 "serves a request"
2556 };
2557 test(&format!("tests/e2e{n:02}.test.js"), name)
2558 })
2559 .collect::<Vec<_>>();
2560 tests.extend((0..3).map(|n| test("tests/zz_unit.test.js", &format!("total case {n}"))));
2561 let everyone = (0..20).collect::<Vec<u32>>();
2562 let end_to_end = (0..17).collect::<Vec<u32>>();
2563 let points = (0..10)
2564 .map(|n| InputPoint {
2565 file: "src/sum.js".into(),
2566 line: n + 1,
2567 source: format!("const v{n} = compute({n});"),
2568 tests: if n == 0 {
2569 everyone.clone()
2570 } else {
2571 end_to_end.clone()
2572 },
2573 })
2574 .collect();
2575 let input = AssessmentInput {
2576 tests,
2577 points,
2578 decisions: Vec::new(),
2579 };
2580 let sources = BTreeMap::from([("src/sum.js".to_owned(), source)]);
2581 let population = population_from(
2582 Path::new("/nonexistent"),
2583 &input,
2584 &sources,
2585 Language::JavaScript,
2586 )
2587 .unwrap();
2588 let target = population
2589 .statements
2590 .iter()
2591 .position(|s| s.line == 1)
2592 .unwrap();
2593 let unit = |order: &[usize]| {
2594 order
2595 .iter()
2596 .filter(|&&t| population.tests[t].file == "tests/zz_unit.test.js")
2597 .count()
2598 };
2599 let spread = population.order(target, MORE_TESTS);
2600 assert_eq!(
2601 unit(&spread),
2602 0,
2603 "file order alone leaves the unit tests out"
2604 );
2605 let last = population.last_order(target, &spread, LAST_TESTS);
2606 assert_eq!(last.len(), LAST_TESTS);
2607 assert!(last.iter().all(|t| !spread.contains(t)), "{last:?}");
2608 assert_eq!(population.tests[last[0]].name, "calls compute on start");
2611 assert_eq!(unit(&last[1..]), 2, "{last:?}");
2612 assert_eq!(
2613 name_words("const hostCount = getHTTPResponse2(send_data)"),
2614 [
2615 "const",
2616 "count",
2617 "data",
2618 "host",
2619 "http",
2620 "response2",
2621 "send"
2622 ]
2623 .map(String::from)
2624 .into()
2625 );
2626 }
2627
2628 #[test]
2629 fn a_statement_is_shown_with_the_function_it_is_in() {
2630 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";
2633 assert_eq!(
2634 outermost_functions(Language::JavaScript, "src/vault.ts", typescript),
2635 [(3, 8), (12, 15), (18, 20), (23, 25)]
2636 );
2637 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";
2638 assert_eq!(
2639 outermost_functions(Language::Python, "vault.py", python),
2640 [(5, 9), (14, 15), (18, 19)]
2641 );
2642 assert!(outermost_functions(Language::Go, "vault.go", "package vault\n").is_empty());
2644 assert!(Language::Python.asks_within_the_function());
2645 assert!(!Language::Ruby.asks_within_the_function());
2646 }
2647
2648 #[test]
2649 fn changes_follow_the_audit() {
2650 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";
2651 let starts = statement_starts("x.tsx", source);
2652 let js = Language::JavaScript;
2653 assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2654 assert_eq!(
2655 change_at(&starts, 2, "const a = 1", js),
2656 Some(Change::ValueUndefined)
2657 );
2658 assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2659 assert_eq!(
2660 change_at(&starts, 5, "if (a) return 2", js),
2661 Some(Change::Invert)
2662 );
2663 assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2664 assert_eq!(
2665 change_at(&starts, 8, "a && b", js),
2666 Some(Change::ExpressionUndefined)
2667 );
2668 }
2669
2670 #[test]
2671 fn a_change_that_cannot_change_anything_is_not_asked() {
2672 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";
2676 let starts = statement_starts("x.ts", source);
2677 let js = Language::JavaScript;
2678 assert_eq!(change_at(&starts, 3, "super([])", js), None);
2679 assert_eq!(
2680 change_at(&starts, 7, "return undefined", js),
2681 Some(Change::Skip)
2682 );
2683 assert_eq!(
2684 change_at(&starts, 8, "return void 0", js),
2685 Some(Change::Skip)
2686 );
2687 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";
2690 let starts = statement_starts("x.ts", typed);
2691 for (line, text) in [
2692 (2, "return undefined as T"),
2693 (3, "return (undefined as unknown) as T"),
2694 (4, "return <T>undefined"),
2695 (5, "return undefined!"),
2696 ] {
2697 assert_eq!(
2698 change_at(&starts, line, text, js),
2699 Some(Change::Skip),
2700 "{text}"
2701 );
2702 }
2703 let starts = statement_starts("x.ts", source);
2704 assert_eq!(
2705 change_at(&starts, 9, "let answered = false", js),
2706 Some(Change::Flip)
2707 );
2708 assert_eq!(
2710 change_at(&starts, 10, "const done = true, more = 1", js),
2711 Some(Change::ValueUndefined)
2712 );
2713 assert_eq!(
2714 change_at(&starts, 11, "return false", js),
2715 Some(Change::Flip)
2716 );
2717 let python = python_statement_starts(
2718 "def f(a):\n if a:\n return None\n ready = False\n return True\n",
2719 );
2720 let py = Language::Python;
2721 assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2722 assert_eq!(
2723 change_at(&python, 4, "ready = False", py),
2724 Some(Change::Flip)
2725 );
2726 assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2727 assert_eq!(flipped("let answered = false"), "let answered = true");
2728 assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2729 assert_eq!(flipped("ready = False"), "ready = True");
2730 assert_eq!(
2731 flipped("let ready = false // true once open"),
2732 "let ready = true // true once open"
2733 );
2734 assert_eq!(
2735 flipped("ready = False # True later"),
2736 "ready = True # True later"
2737 );
2738 assert_eq!(flipped("this.#open = false"), "this.#open = true");
2739 assert_eq!(
2740 flipped("const s = 'a // b', on = false"),
2741 "const s = 'a // b', on = true"
2742 );
2743 }
2744
2745 #[test]
2746 fn a_typescript_throw_keeps_its_types() {
2747 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";
2750 let typed = statement_starts("x.ts", source);
2751 let js = Language::JavaScript;
2752 assert_eq!(
2753 change_at(
2754 &typed,
2755 4,
2756 "throw new Error(`tools/list failed: ${result.error.message}`)",
2757 js
2758 ),
2759 Some(Change::BareError)
2760 );
2761 let plain = statement_starts("x.js", source);
2762 assert_eq!(
2763 change_at(
2764 &plain,
2765 4,
2766 "throw new Error(`tools/list failed: ${result.error.message}`)",
2767 js
2768 ),
2769 Some(Change::Skip)
2770 );
2771 }
2772
2773 #[test]
2774 fn a_deleted_statement_says_what_goes_and_what_then_runs() {
2775 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";
2776 let point = |line: usize, text: &str| InputPoint {
2777 file: "pkg/f.py".into(),
2778 line,
2779 source: text.into(),
2780 tests: vec![0],
2781 };
2782 let input = AssessmentInput {
2783 tests: vec![InputTest {
2784 id: "tests/test_f.py::test_f".into(),
2785 file: Some("tests/test_f.py".into()),
2786 name: "tests/test_f.py::test_f".into(),
2787 role: "test".into(),
2788 runner: "pytest".into(),
2789 }],
2790 points: vec![
2791 point(3, "raise ValueError(\"negative\")"),
2792 point(5, "return None"),
2793 point(6, "for item in items:\n use(item)"),
2794 point(8, "log(n)"),
2795 ],
2796 decisions: Vec::new(),
2797 };
2798 let sources = BTreeMap::from([("pkg/f.py".to_owned(), source.to_owned())]);
2799 let python = population_from(
2800 Path::new("/nonexistent"),
2801 &input,
2802 &sources,
2803 Language::Python,
2804 )
2805 .unwrap();
2806 let question = |population: &Population, line: usize| {
2807 let at = population
2808 .statements
2809 .iter()
2810 .position(|s| s.line == line)
2811 .unwrap();
2812 population.questions(at).join(" | ")
2813 };
2814 assert_eq!(
2815 question(&python, 3),
2816 "pkg/f.py:3: `raise ValueError(\"negative\")` is deleted: no error is raised here, and execution carries on with whatever follows it."
2817 );
2818 assert_eq!(
2819 question(&python, 5),
2820 "pkg/f.py:5: `return None` is deleted: the function does not return here, and execution carries on with whatever follows it."
2821 );
2822 assert_eq!(
2823 question(&python, 6),
2824 "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."
2825 );
2826 assert_eq!(
2827 question(&python, 8),
2828 "pkg/f.py:8: `log(n)` is deleted (it never runs)."
2829 );
2830 let js = AssessmentInput {
2833 tests: vec![InputTest {
2834 id: "t".into(),
2835 file: Some("test/g.test.js".into()),
2836 name: "g".into(),
2837 role: "test".into(),
2838 runner: "node:test".into(),
2839 }],
2840 points: vec![InputPoint {
2841 file: "src/g.js".into(),
2842 line: 3,
2843 source: "return;".into(),
2844 tests: vec![0],
2845 }],
2846 decisions: Vec::new(),
2847 };
2848 let sources = BTreeMap::from([(
2849 "src/g.js".to_owned(),
2850 "function g(a) {\n if (a) {\n return;\n }\n use(a);\n}\n".to_owned(),
2851 )]);
2852 let javascript = population_from(
2853 Path::new("/nonexistent"),
2854 &js,
2855 &sources,
2856 Language::JavaScript,
2857 )
2858 .unwrap();
2859 assert_eq!(
2860 question(&javascript, 3),
2861 "src/g.js:3: `return;` is deleted (it never runs)."
2862 );
2863 }
2864
2865 #[test]
2866 fn a_statement_s_text_is_what_a_test_would_quote() {
2867 assert_eq!(
2868 quoted_fragments("logger.info(` - Headers: ${describeHeaders(headers)}`)"),
2869 vec!["- Headers:"]
2870 );
2871 assert_eq!(
2872 quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2873 vec![", signal=", "Child exited: code="]
2874 );
2875 assert_eq!(
2876 quoted_fragments("log(f\"Serving {path} on {port}\")"),
2877 vec!["Serving"]
2878 );
2879 assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2881 assert!(quoted_fragments("return a + b").is_empty());
2882 }
2883
2884 #[test]
2887 fn a_python_test_deep_in_its_file_is_shown_with_its_class_and_decorators() {
2888 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2889 let root = std::env::temp_dir().join(format!(
2890 "supercov-python-test-code-{}-{}",
2891 std::process::id(),
2892 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2893 ));
2894 std::fs::create_dir_all(root.join("tests")).unwrap();
2895 let mut file =
2896 String::from("# The shop's tests.\nimport pytest\n\nfrom shop import total\n");
2897 for i in 0..10 {
2898 file.push_str(&format!("\n\ndef helper_{i}():\n return {i}\n"));
2899 }
2900 file.push_str("\n\nclass TestTotal:\n def test_first(self):\n assert total([]) == 0\n\n @pytest.mark.slow\n @pytest.mark.parametrize(\"n\", [1])\n def test_deep(self, n):\n # one item\n assert total([n]) == n\n\n def test_after(self):\n pass\n");
2901 std::fs::write(root.join("tests/test_total.py"), &file).unwrap();
2902 let input = AssessmentInput {
2903 tests: vec![InputTest {
2904 id: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2905 file: Some("tests/test_total.py".into()),
2906 name: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2907 role: "test".into(),
2908 runner: "pytest".into(),
2909 }],
2910 points: vec![InputPoint {
2911 file: "shop.py".into(),
2912 line: 1,
2913 source: "return 0".into(),
2914 tests: vec![0],
2915 }],
2916 decisions: Vec::new(),
2917 };
2918 let sources = BTreeMap::from([("shop.py".to_owned(), "return 0\n".to_owned())]);
2919 let population = population_from(&root, &input, &sources, Language::Python).unwrap();
2920 let code = population.test_code(0);
2921 std::fs::remove_dir_all(&root).unwrap();
2922 let shown = code
2923 .lines()
2924 .map(|line| {
2925 let line = line.trim_start();
2926 line.split_once(' ').map_or(line, |(_, l)| l.trim_start())
2927 })
2928 .collect::<Vec<_>>();
2929 assert_eq!(
2930 shown,
2931 [
2932 "import pytest",
2933 "",
2934 "from shop import total",
2935 "",
2936 "",
2937 "def helper_0():",
2938 "return 0",
2939 "",
2940 "",
2941 "...",
2942 "class TestTotal:",
2943 "...",
2944 "@pytest.mark.slow",
2945 "@pytest.mark.parametrize(\"n\", [1])",
2946 "def test_deep(self, n):",
2947 "assert total([n]) == n",
2948 "",
2949 ],
2950 "{code}"
2951 );
2952 }
2953
2954 #[test]
2958 fn statement_text_is_read_the_way_each_language_writes_it() {
2959 assert_eq!(condition("if (a && b) {"), "a && b");
2960 assert_eq!(condition("} else if (ready) {"), "ready");
2961 assert_eq!(condition("if (a) return;"), "if (a) return;");
2962 assert_eq!(condition("while (a) {"), "while (a) {");
2963 assert_eq!(python_condition("elif x is None:"), "x is None");
2964 assert_eq!(python_condition("if ready:"), "ready");
2965 assert_eq!(
2966 split_initializer("const x = f(1);").as_deref(),
2967 Some("const x =")
2968 );
2969 assert_eq!(
2970 split_initializer("if (a <= b && c != d) total = 1").as_deref(),
2971 Some("if (a <= b && c != d) total =")
2972 );
2973 assert_eq!(split_initializer("a === b"), None);
2974 assert_eq!(split_initializer("items.map((x) => x)"), None);
2975 assert!(is_test_call(" it('works', () => {"));
2976 assert!(is_test_call("test.only('works', () => {"));
2977 assert!(is_test_call("test ('spaced', () => {"));
2978 assert!(!is_test_call("items.push(1)"));
2979 assert!(!is_test_call("testing(1)"));
2980 assert_eq!(
2981 strip_numbers("▶ 12 total(1)\n 13 next\n ... 4 lines not shown\nplain"),
2982 "▶total(1)\n next\n...\nplain"
2983 );
2984 assert_eq!(
2985 strip_location("src/a.js:12: `x` is deleted"),
2986 "src/a.js:: `x` is deleted"
2987 );
2988 assert_eq!(strip_location("src/a.js:main: kept"), "src/a.js:main: kept");
2989 assert_eq!(strip_location("no location"), "no location");
2990 }
2991
2992 #[test]
2996 fn helper_definitions_are_read_the_way_each_language_declares_them() {
2997 let names = |language: Language, text: &str| {
2998 let lines = text.split('\n').collect::<Vec<_>>();
2999 definitions(&lines, language)
3000 .into_iter()
3001 .map(|(name, start, end)| format!("{name} {}-{}", start + 1, end + 1))
3002 .collect::<Vec<_>>()
3003 };
3004 assert_eq!(
3005 names(
3006 Language::Jvm,
3007 "class Helpers {\n static int total(int a) {\n return a;\n }\n private static final Map<String, Integer> PRICES = Map.of(\"a\", 1);\n public <T> List<T> wrap(T value)\n {\n return List.of(value);\n }\n void call() { helper(1); }\n}\n"
3008 ),
3009 ["total 2-4", "wrap 6-9", "call 10-10"]
3010 );
3011 assert_eq!(
3012 names(
3013 Language::Jvm,
3014 "object Fixtures {\n private fun price(x: Int) = x * 2\n val list = listOf(1).map { it }\n suspend fun later() {\n }\n}\n"
3015 ),
3016 ["price 2-2", "later 4-5"]
3017 );
3018 assert_eq!(
3019 names(
3020 Language::Ruby,
3021 "module Helpers\n def self.make(x)\n x\n end\n let(:order) { 1 }\n let!(:user) { 2 }\n subject(:cart) { 3 }\n subject { 4 }\n def valid?\n true\n end\nend\n"
3022 ),
3023 [
3024 "make 2-4",
3025 "order 5-5",
3026 "user 6-6",
3027 "cart 7-7",
3028 "subject 8-8",
3029 "valid? 9-11"
3030 ]
3031 );
3032 assert_eq!(
3033 names(
3034 Language::Rust,
3035 "pub fn make() -> u8 {\n 1\n}\npub(crate) const LIMIT: u8 = 2;\nstruct Point {\n x: u8,\n}\n"
3036 ),
3037 ["make 1-3", "LIMIT 4-4", "Point 5-7"]
3038 );
3039 }
3040
3041 #[test]
3042 fn a_python_test_is_found_in_the_class_its_id_names() {
3043 let text = "import pytest\n\n\nclass TestCart:\n def test_total(self):\n assert cart() == 0\n\n\nclass TestOrder:\n class TestRefund:\n def test_total(self):\n assert refund() == 1\n\n def test_total(self):\n assert order() == 2\n";
3044 let lines = text.split('\n').collect::<Vec<_>>();
3045 let at = |name: &str| {
3046 let (from, to, body) = python_class_lines(&lines, &python_classes(name));
3047 (from..to)
3048 .find(|&i| {
3049 lines[i].trim_start().starts_with("def test_total(")
3050 && body
3051 .is_none_or(|body| lines[i].len() - lines[i].trim_start().len() == body)
3052 })
3053 .map(|i| i + 1)
3054 };
3055 assert_eq!(at("tests/test_a.py::TestCart::test_total"), Some(5));
3056 assert_eq!(
3057 at("tests/test_a.py::TestOrder::TestRefund::test_total[1]"),
3058 Some(11)
3059 );
3060 assert_eq!(at("tests/test_a.py::TestOrder::test_total"), Some(14));
3062 assert_eq!(at("tests.test_a.TestCart.test_total"), Some(5));
3063 assert_eq!(
3064 python_classes("tests/test_a.py::test_plain"),
3065 Vec::<&str>::new()
3066 );
3067 assert_eq!(
3068 python_classes("tests.test_a.test_plain"),
3069 Vec::<&str>::new()
3070 );
3071 }
3072
3073 #[test]
3074 fn a_javascript_test_is_found_in_the_group_its_name_gives() {
3075 let text = "import { totals } from '../src/cart.js';\n\ndescribe('cart', () => {\n it('totals', () => {\n expect(totals([])).toBe(0);\n });\n});\n\ndescribe('order', () => {\n describe('refund', () => {\n it('totals', () => {});\n });\n it('totals', () => {\n expect(1).toBe(1);\n });\n});\n";
3076 let lines = text.split('\n').collect::<Vec<_>>();
3077 let line = |name: &str| js_test_line(&lines, name).map(|i| i + 1);
3078 assert_eq!(
3079 line("cart > totals"),
3080 Some(4),
3081 "not the import that names it"
3082 );
3083 assert_eq!(line("order > refund > totals"), Some(11));
3084 assert_eq!(line("totals"), Some(4));
3085 let long = format!(
3087 "describe('a', () => {{\n it('x', () => {{}});\n}});\ndescribe('b', () => {{\n{} it('x', () => {{}});\n}});\n",
3088 " const y = 1;\n".repeat(200)
3089 );
3090 let lines = long.split('\n').collect::<Vec<_>>();
3091 assert_eq!(js_test_line(&lines, "b > x").map(|i| i + 1), Some(205));
3092 }
3093
3094 #[test]
3095 fn python_changes_follow_the_audit() {
3096 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";
3097 let starts = python_statement_starts(source);
3098 let py = Language::Python;
3099 assert_eq!(change_at(&starts, 1, "import os", py), None);
3100 assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
3101 assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
3102 assert_eq!(
3103 change_at(&starts, 6, "y = x + 1", py),
3104 Some(Change::ValueUndefined)
3105 );
3106 assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
3107 assert_eq!(
3108 change_at(&starts, 8, "return y", py),
3109 Some(Change::ReturnUndefined)
3110 );
3111 assert_eq!(change_at(&starts, 9, "z: int", py), None);
3112 assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
3113 assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
3114 assert_eq!(
3115 change_at(&starts, 15, "v is None", py),
3116 Some(Change::Invert)
3117 );
3118 assert_eq!(
3119 python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
3120 "test_b"
3121 );
3122 assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
3123 assert_eq!(python_condition("if y > 2:"), "y > 2");
3124 assert_eq!(python_condition("elif v is None:"), "v is None");
3125 }
3126
3127 #[test]
3128 fn change_texts() {
3129 assert_eq!(
3130 split_initializer("const x = a === b").as_deref(),
3131 Some("const x =")
3132 );
3133 assert_eq!(
3134 split_initializer("const f = () => 1").as_deref(),
3135 Some("const f =")
3136 );
3137 assert_eq!(condition("} else if (x > 1) {"), "x > 1");
3138 assert_eq!(
3139 condition("if (!body) return null"),
3140 "if (!body) return null"
3141 );
3142 assert!(is_test_call(" it(\"works\", () => {"));
3143 assert!(is_test_call("test.each([1])('x', () => {"));
3144 assert!(!is_test_call("items.push(1)"));
3145 }
3146
3147 #[test]
3148 fn keys_ignore_line_numbers() {
3149 assert_eq!(
3150 strip_numbers("▶ 12 return x\n ... 3 lines not shown\n 15 }"),
3151 "▶ return x\n...\n }"
3152 );
3153 assert_eq!(
3154 strip_location("src/a.ts:41: `x` is deleted."),
3155 "src/a.ts:: `x` is deleted."
3156 );
3157 }
3158}