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