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 if matches!(language, Language::JavaScript | Language::Python) {
531 credit_module_level(&mut statements, sources, language);
532 }
533 let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
534 for s in &statements {
535 for &t in &s.tests {
536 ran[t].entry(s.file.clone()).or_default().insert(s.line);
537 }
538 }
539 let source_files = statements.iter().map(|s| s.file.clone()).collect();
540 Ok(Population {
541 root: root.to_path_buf(),
542 language,
543 statements,
544 tests,
545 ran,
546 source_files,
547 jvm_files,
548 files: Default::default(),
549 quotes: Default::default(),
550 })
551}
552
553fn credit_module_level(
559 statements: &mut [Statement],
560 sources: &BTreeMap<String, String>,
561 language: Language,
562) {
563 let mut files = BTreeMap::<String, Vec<usize>>::new();
564 for (index, statement) in statements.iter().enumerate() {
565 files.entry(statement.file.clone()).or_default().push(index);
566 }
567 for (file, indexes) in files {
568 let Some(source) = sources.get(&file) else {
569 continue;
570 };
571 let functions = outermost_functions(language, &file, source);
572 let inside = |line: usize| {
573 functions
574 .iter()
575 .any(|&(first, last)| first <= line && line <= last)
576 };
577 let users = indexes
578 .iter()
579 .filter(|&&index| inside(statements[index].line))
580 .flat_map(|&index| statements[index].tests.iter().copied())
581 .collect::<BTreeSet<_>>();
582 let module_level = indexes
583 .iter()
584 .copied()
585 .filter(|&index| !inside(statements[index].line))
586 .collect::<Vec<_>>();
587 for index in module_level {
588 let tests = &mut statements[index].tests;
589 for &user in &users {
590 if !tests.contains(&user) {
591 tests.push(user);
592 }
593 }
594 }
595 }
596}
597
598fn same_code(a: &str, b: &str) -> bool {
600 let squash = |code: &str| {
601 code.trim()
602 .trim_end_matches(';')
603 .chars()
604 .filter(|c| !c.is_whitespace())
605 .collect::<String>()
606 };
607 squash(a) == squash(b)
608}
609
610fn name_words(text: &str) -> BTreeSet<String> {
613 const FILLER: [&str; 13] = [
614 "test", "tests", "the", "and", "for", "with", "get", "set", "self", "from", "not", "does",
615 "should",
616 ];
617 let mut words = BTreeSet::new();
618 let chars = text.chars().collect::<Vec<_>>();
619 let mut word = String::new();
620 let mut flush = |word: &mut String| {
621 let w = word.to_lowercase();
622 if w.len() > 2 && !FILLER.contains(&w.as_str()) {
623 words.insert(w);
624 }
625 word.clear();
626 };
627 for (i, &c) in chars.iter().enumerate() {
628 if !c.is_ascii_alphanumeric() {
629 flush(&mut word);
630 continue;
631 }
632 let previous = i.checked_sub(1).map(|p| chars[p]);
633 let next = chars.get(i + 1).copied();
634 let starts = c.is_ascii_uppercase()
637 && (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
638 || (previous.is_some_and(|p| p.is_ascii_uppercase())
639 && next.is_some_and(|n| n.is_ascii_lowercase())));
640 if starts {
641 flush(&mut word);
642 }
643 word.push(c);
644 }
645 flush(&mut word);
646 words
647}
648
649fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
652 let allocator = Allocator::default();
653 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
654 let parsed = Parser::new(&allocator, source, source_type).parse();
655 let starts = line_starts(source);
656 struct Collector<'s> {
657 source: &'s str,
658 starts: &'s [usize],
659 typescript: bool,
660 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
661 }
662 impl<'a> Visit<'a> for Collector<'_> {
663 fn visit_statement(&mut self, statement: &JsStatement<'a>) {
664 let span = statement.span();
665 let line = line_of(self.starts, span.start as usize);
666 let text = self
667 .source
668 .get(span.start as usize..span.end as usize)
669 .unwrap_or("")
670 .to_owned();
671 self.out.entry(line).or_default().push((
672 text,
673 match statement {
674 JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
675 _ => change_of(statement),
676 },
677 ));
678 walk::walk_statement(self, statement);
679 }
680 }
681 let mut collector = Collector {
682 source,
683 starts: &starts,
684 typescript: source_type.is_typescript(),
685 out: BTreeMap::new(),
686 };
687 collector.visit_program(&parsed.program);
688 collector.out
689}
690
691fn is_undefined(expression: &Expression) -> bool {
695 match expression {
696 Expression::Identifier(identifier) => identifier.name == "undefined",
697 Expression::UnaryExpression(unary) => {
698 unary.operator == oxc_syntax::operator::UnaryOperator::Void
699 }
700 Expression::TSAsExpression(cast) => is_undefined(&cast.expression),
701 Expression::TSSatisfiesExpression(cast) => is_undefined(&cast.expression),
702 Expression::TSTypeAssertion(cast) => is_undefined(&cast.expression),
703 Expression::TSNonNullExpression(cast) => is_undefined(&cast.expression),
704 Expression::ParenthesizedExpression(inner) => is_undefined(&inner.expression),
705 _ => false,
706 }
707}
708
709fn change_of(statement: &JsStatement) -> Option<Change> {
710 Some(match statement {
711 JsStatement::IfStatement(_) => Change::Invert,
712 JsStatement::ReturnStatement(r) => match &r.argument {
715 Some(argument) if is_undefined(argument) => Change::Skip,
716 Some(Expression::BooleanLiteral(_)) => Change::Flip,
717 Some(_) => Change::ReturnUndefined,
718 None => Change::Skip,
719 },
720 JsStatement::VariableDeclaration(d) => {
721 if let [only] = d.declarations.as_slice()
722 && matches!(only.init, Some(Expression::BooleanLiteral(_)))
723 {
724 Change::Flip
725 } else if d.declarations.iter().any(|d| d.init.is_some()) {
726 Change::ValueUndefined
727 } else {
728 return None;
729 }
730 }
731 JsStatement::ExpressionStatement(e)
734 if matches!(&e.expression, Expression::CallExpression(call)
735 if matches!(call.callee, Expression::Super(_))) =>
736 {
737 return None;
738 }
739 JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
740 Some(Declaration::VariableDeclaration(d))
741 if d.declarations.iter().any(|d| d.init.is_some()) =>
742 {
743 Change::ValueUndefined
744 }
745 _ => return None,
746 },
747 JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
748 ExportDefaultDeclarationKind::FunctionDeclaration(_)
749 | ExportDefaultDeclarationKind::ClassDeclaration(_)
750 | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
751 _ => Change::Skip,
752 },
753 JsStatement::ImportDeclaration(_)
754 | JsStatement::ExportAllDeclaration(_)
755 | JsStatement::FunctionDeclaration(_)
756 | JsStatement::ClassDeclaration(_)
757 | JsStatement::TSTypeAliasDeclaration(_)
758 | JsStatement::TSInterfaceDeclaration(_)
759 | JsStatement::TSEnumDeclaration(_)
760 | JsStatement::TSModuleDeclaration(_)
761 | JsStatement::TSGlobalDeclaration(_)
762 | JsStatement::TSImportEqualsDeclaration(_)
763 | JsStatement::TSExportAssignment(_)
764 | JsStatement::TSNamespaceExportDeclaration(_) => return None,
765 _ => Change::Skip,
766 })
767}
768
769fn change_at(
773 starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
774 line: usize,
775 text: &str,
776 language: Language,
777) -> Option<Change> {
778 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
779 let found = starts.get(&line).and_then(|all| {
780 all.iter().find(|(t, _)| {
781 let bare = t
782 .strip_prefix("export ")
783 .map(|t| t.strip_prefix("default ").unwrap_or(t))
784 .unwrap_or(t);
785 bare.starts_with(first) || t.starts_with(first)
786 })
787 });
788 match (found, language) {
789 (Some((_, change)), _) => *change,
790 (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
791 (None, _) => None,
794 }
795}
796
797fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
802 use ruff_python_ast::{
803 Expr, Stmt,
804 visitor::{Visitor, walk_stmt},
805 };
806 use ruff_text_size::Ranged;
807 struct Collector<'s> {
808 source: &'s str,
809 starts: Vec<usize>,
810 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
811 }
812 impl<'a> Visitor<'a> for Collector<'_> {
813 fn visit_stmt(&mut self, stmt: &'a Stmt) {
814 let change = match stmt {
815 Stmt::If(_) => Some(Change::Invert),
816 Stmt::Return(r) => Some(match r.value.as_deref() {
817 Some(Expr::NoneLiteral(_)) | None => Change::Skip,
819 Some(Expr::BooleanLiteral(_)) => Change::Flip,
820 Some(_) => Change::ReturnUndefined,
821 }),
822 Stmt::Assign(a)
823 if a.targets.len() == 1
824 && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
825 {
826 Some(Change::Flip)
827 }
828 Stmt::Assign(_) => Some(Change::ValueUndefined),
829 Stmt::AnnAssign(a)
830 if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
831 {
832 Some(Change::Flip)
833 }
834 Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
835 Stmt::Import(_)
836 | Stmt::ImportFrom(_)
837 | Stmt::FunctionDef(_)
838 | Stmt::ClassDef(_)
839 | Stmt::Pass(_)
840 | Stmt::Global(_)
841 | Stmt::Nonlocal(_)
842 | Stmt::TypeAlias(_)
843 | Stmt::IpyEscapeCommand(_) => None,
844 Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
845 _ => Some(Change::Skip),
846 };
847 let range = stmt.range();
848 let (start, end) = (range.start().to_usize(), range.end().to_usize());
849 let line = line_of(&self.starts, start);
850 let text = self.source.get(start..end).unwrap_or("").to_owned();
851 self.out.entry(line).or_default().push((text, change));
852 if let Stmt::If(s) = stmt {
854 for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
856 let start = test.range().start().to_usize();
857 let text = self.source.get(start..test.range().end().to_usize());
858 self.out
859 .entry(line_of(&self.starts, start))
860 .or_default()
861 .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
862 }
863 }
864 walk_stmt(self, stmt);
865 }
866 }
867 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
868 return BTreeMap::new();
869 };
870 let mut collector = Collector {
871 source,
872 starts: line_starts(source),
873 out: BTreeMap::new(),
874 };
875 for stmt in &parsed.syntax().body {
876 collector.visit_stmt(stmt);
877 }
878 collector.out
879}
880
881fn js_test_line(lines: &[&str], name: &str) -> Option<usize> {
886 let parts = name.split(" > ").collect::<Vec<_>>();
887 let (title, groups) = parts.split_last()?;
888 let quoted = |line: &str, text: &str| {
889 ['\'', '"', '`']
890 .iter()
891 .any(|quote| line.contains(&format!("{quote}{text}")))
892 };
893 let (mut from, mut to) = (0, lines.len());
894 for group in groups {
895 let needle = prefix(group, 40);
896 if needle.is_empty() {
897 continue;
898 }
899 if let Some(at) = (from..to).find(|&i| quoted(lines[i], needle)) {
900 (from, to) = (at, (block_end(lines, at) + 1).min(lines.len()));
901 }
902 }
903 let needle = prefix(title, 40);
904 if needle.is_empty() {
905 return None;
906 }
907 (from..to)
908 .find(|&i| quoted(lines[i], needle))
909 .or_else(|| (from..to).find(|&i| lines[i].contains(needle)))
910 .or_else(|| (0..lines.len()).find(|&i| lines[i].contains(needle)))
911}
912
913fn prefix(text: &str, chars: usize) -> &str {
914 match text.char_indices().nth(chars) {
915 Some((i, _)) => &text[..i],
916 None => text,
917 }
918}
919
920fn outermost_functions(language: Language, file: &str, source: &str) -> Vec<(usize, usize)> {
925 let starts = line_starts(source);
926 let lines = |from: usize, to: usize| {
927 (
928 line_of(&starts, from),
929 line_of(&starts, to.saturating_sub(1).max(from)),
930 )
931 };
932 let mut spans = match language {
933 Language::JavaScript => {
934 let allocator = Allocator::default();
935 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
936 let parsed = Parser::new(&allocator, source, source_type).parse();
937 #[derive(Default)]
938 struct Functions {
939 within: Option<(u32, u32)>,
943 found: Vec<(u32, u32)>,
944 }
945 impl Functions {
946 fn inside(&mut self, span: (u32, u32), walk: impl FnOnce(&mut Self)) {
947 let outer = self.within.replace(span);
948 walk(self);
949 self.within = outer;
950 }
951 fn function(&mut self, own: oxc_span::Span) {
952 self.found.push(self.within.unwrap_or((own.start, own.end)));
953 }
954 }
955 fn from_decorators(
956 span: oxc_span::Span,
957 decorators: &[oxc_ast::ast::Decorator<'_>],
958 ) -> (u32, u32) {
959 (
960 decorators
961 .first()
962 .map_or(span.start, |first| first.span.start.min(span.start)),
963 span.end,
964 )
965 }
966 impl<'a> Visit<'a> for Functions {
967 fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) {
968 for statement in &program.body {
969 let span = statement.span();
970 self.inside((span.start, span.end), |this| {
971 this.visit_statement(statement)
972 });
973 }
974 }
975 fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) {
976 for statement in &block.body {
977 let span = statement.span();
978 self.inside((span.start, span.end), |this| {
979 this.visit_statement(statement)
980 });
981 }
982 }
983 fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) {
984 for statement in &block.body {
985 let span = statement.span();
986 self.inside((span.start, span.end), |this| {
987 this.visit_statement(statement)
988 });
989 }
990 }
991 fn visit_method_definition(&mut self, it: &oxc_ast::ast::MethodDefinition<'a>) {
992 self.inside(from_decorators(it.span, &it.decorators), |this| {
993 walk::walk_method_definition(this, it)
994 });
995 }
996 fn visit_property_definition(&mut self, it: &oxc_ast::ast::PropertyDefinition<'a>) {
997 self.inside(from_decorators(it.span, &it.decorators), |this| {
998 walk::walk_property_definition(this, it)
999 });
1000 }
1001 fn visit_accessor_property(&mut self, it: &oxc_ast::ast::AccessorProperty<'a>) {
1002 self.inside(from_decorators(it.span, &it.decorators), |this| {
1003 walk::walk_accessor_property(this, it)
1004 });
1005 }
1006 fn visit_static_block(&mut self, it: &oxc_ast::ast::StaticBlock<'a>) {
1007 self.inside((it.span.start, it.span.end), |this| {
1008 walk::walk_static_block(this, it)
1009 });
1010 }
1011 fn visit_function(
1013 &mut self,
1014 it: &oxc_ast::ast::Function<'a>,
1015 _flags: oxc_syntax::scope::ScopeFlags,
1016 ) {
1017 if it.body.is_some() {
1018 self.function(it.span);
1019 }
1020 }
1021 fn visit_arrow_function_expression(
1022 &mut self,
1023 it: &oxc_ast::ast::ArrowFunctionExpression<'a>,
1024 ) {
1025 self.function(it.span);
1026 }
1027 }
1028 let mut functions = Functions::default();
1029 functions.visit_program(&parsed.program);
1030 functions
1031 .found
1032 .into_iter()
1033 .map(|(from, to)| lines(from as usize, to as usize))
1034 .collect::<Vec<_>>()
1035 }
1036 Language::Python => {
1037 use ruff_python_ast::{
1038 Stmt,
1039 visitor::{Visitor, walk_stmt},
1040 };
1041 use ruff_text_size::Ranged;
1042 struct Functions(Vec<(usize, usize)>);
1043 impl<'a> Visitor<'a> for Functions {
1044 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1045 match stmt {
1046 Stmt::FunctionDef(function) => {
1047 let from = function
1048 .decorator_list
1049 .first()
1050 .map_or(function.start(), |first| {
1051 first.start().min(function.start())
1052 });
1053 self.0.push((from.to_usize(), function.end().to_usize()));
1054 }
1055 _ => walk_stmt(self, stmt),
1056 }
1057 }
1058 }
1059 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
1060 return Vec::new();
1061 };
1062 let mut functions = Functions(Vec::new());
1063 for stmt in &parsed.syntax().body {
1064 functions.visit_stmt(stmt);
1065 }
1066 functions
1067 .0
1068 .into_iter()
1069 .map(|(from, to)| lines(from, to))
1070 .collect()
1071 }
1072 _ => Vec::new(),
1073 };
1074 spans.sort_unstable();
1075 spans.dedup();
1076 spans
1077}
1078
1079fn whole_statement(statement: &Statement) -> Option<usize> {
1082 let lines = statement.text.lines().count();
1083 let word = statement
1084 .text
1085 .lines()
1086 .next()
1087 .unwrap_or("")
1088 .trim()
1089 .trim_end_matches(';')
1090 .trim();
1091 (statement.change == Change::Skip
1092 && lines > 1
1093 && !matches!(word, "break" | "continue" | "next"))
1094 .then_some(lines)
1095}
1096
1097fn line_starts(source: &str) -> Vec<usize> {
1098 std::iter::once(0)
1099 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
1100 .collect()
1101}
1102
1103fn line_of(starts: &[usize], offset: usize) -> usize {
1104 starts.partition_point(|&s| s <= offset)
1105}
1106
1107fn numbered(lines: &[&str]) -> String {
1108 lines
1109 .iter()
1110 .enumerate()
1111 .map(|(i, l)| format!("{:5} {l}", i + 1))
1112 .collect::<Vec<_>>()
1113 .join("\n")
1114}
1115
1116fn quoted_fragments(text: &str) -> Vec<String> {
1121 let mut out = Vec::new();
1122 let mut chars = text.char_indices().peekable();
1123 while let Some((start, quote)) = chars.next() {
1124 if !matches!(quote, '\'' | '"' | '`') {
1125 continue;
1126 }
1127 let mut end = None;
1128 while let Some((at, c)) = chars.next() {
1129 if c == '\\' {
1130 chars.next();
1131 } else if c == quote {
1132 end = Some(at);
1133 break;
1134 }
1135 }
1136 let Some(end) = end else { break };
1137 let body = &text[start + quote.len_utf8()..end];
1138 let mut pieces = vec![String::new()];
1141 let mut depth = 0usize;
1142 for c in body.chars() {
1143 match c {
1144 '{' => {
1145 depth += 1;
1146 pieces.push(String::new());
1147 }
1148 '}' if depth > 0 => depth -= 1,
1149 '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
1150 _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
1151 _ => {}
1152 }
1153 }
1154 for piece in pieces {
1155 let piece = piece.trim();
1156 if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
1157 out.push(piece.to_owned());
1158 }
1159 }
1160 }
1161 out.sort();
1162 out.dedup();
1163 out
1164}
1165
1166fn stem(path: &str) -> String {
1167 Path::new(path)
1168 .file_name()
1169 .and_then(|n| n.to_str())
1170 .unwrap_or("")
1171 .split('.')
1172 .next()
1173 .unwrap_or("")
1174 .to_lowercase()
1175}
1176
1177fn describe(name: &str, separator: &str) -> String {
1178 let parts = name.split(separator).collect::<Vec<_>>();
1179 parts[..parts.len().saturating_sub(1)].join(separator)
1180}
1181
1182impl Population {
1183 pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
1186 {
1187 let mut cache = self
1188 .files
1189 .lock()
1190 .expect("the file cache is only read and filled");
1191 for (file, text) in files {
1192 cache.insert(file, Some(text.into()));
1193 }
1194 }
1195 self
1196 }
1197
1198 pub fn files_read(&self) -> BTreeMap<String, String> {
1200 self.files
1201 .lock()
1202 .expect("the file cache is only read and filled")
1203 .iter()
1204 .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
1205 .collect()
1206 }
1207
1208 fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
1209 let mut files = self
1210 .files
1211 .lock()
1212 .expect("the file cache is only read and filled");
1213 files
1214 .entry(file.to_owned())
1215 .or_insert_with(|| {
1216 std::fs::read_to_string(self.root.join(file))
1217 .ok()
1218 .map(Into::into)
1219 })
1220 .clone()
1221 }
1222
1223 fn quotes(&self, test: usize, fragments: &[String]) -> bool {
1225 let file = &self.tests[test].file;
1226 fragments.iter().any(|fragment| {
1227 let key = (file.clone(), fragment.clone());
1228 if let Some(&known) = self
1229 .quotes
1230 .lock()
1231 .expect("the quote cache is only read and filled")
1232 .get(&key)
1233 {
1234 return known;
1235 }
1236 let found = self
1237 .read(file)
1238 .is_some_and(|text| text.contains(fragment.as_str()));
1239 self.quotes
1240 .lock()
1241 .expect("the quote cache is only read and filled")
1242 .insert(key, found);
1243 found
1244 })
1245 }
1246
1247 pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
1254 let s = &self.statements[statement];
1255 let base = stem(&s.file);
1256 let key = |&t: &usize| {
1257 let test = &self.tests[t];
1258 (
1259 !Path::new(&test.file)
1260 .file_name()
1261 .and_then(|n| n.to_str())
1262 .unwrap_or("")
1263 .to_lowercase()
1264 .contains(&base),
1265 test.file.clone(),
1266 test.name.clone(),
1267 )
1268 };
1269 let mut all = s.tests.clone();
1270 all.sort_by_key(key);
1271 if all.len() <= k {
1272 return all;
1273 }
1274 let mut groups = Vec::<((String, String), Vec<usize>)>::new();
1275 for &t in &all {
1276 let g = (
1277 self.tests[t].file.clone(),
1278 describe(
1279 &self.tests[t].name,
1280 separator(self.language, &self.tests[t].runner),
1281 ),
1282 );
1283 match groups.iter_mut().find(|(k, _)| *k == g) {
1284 Some((_, v)) => v.push(t),
1285 None => groups.push((g, vec![t])),
1286 }
1287 }
1288 let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
1289 for ((file, _), members) in groups {
1290 match by_file.iter_mut().find(|(f, _)| *f == file) {
1291 Some((_, v)) => v.push(members),
1292 None => by_file.push((file, vec![members])),
1293 }
1294 }
1295 by_file.sort_by_key(|(f, _)| {
1296 (
1297 !Path::new(f)
1298 .file_name()
1299 .and_then(|n| n.to_str())
1300 .unwrap_or("")
1301 .to_lowercase()
1302 .contains(&base),
1303 f.clone(),
1304 )
1305 });
1306 let mut picked = Vec::new();
1307 let fragments = quoted_fragments(&s.text);
1308 if !fragments.is_empty() {
1309 let quoting = all
1310 .iter()
1311 .copied()
1312 .filter(|&t| self.quotes(t, &fragments))
1313 .collect::<Vec<_>>();
1314 let mut files = BTreeSet::new();
1315 let (first, rest): (Vec<usize>, Vec<usize>) = quoting
1316 .iter()
1317 .partition(|&&t| files.insert(self.tests[t].file.clone()));
1318 for t in first.into_iter().chain(rest) {
1319 if picked.len() < k {
1320 picked.push(t);
1321 }
1322 }
1323 }
1324 let mut depth = 0;
1325 while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
1326 for (_, g) in &by_file {
1327 if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
1328 picked.push(g[depth][0]);
1329 }
1330 }
1331 depth += 1;
1332 }
1333 for t in all {
1334 if picked.len() >= k {
1335 break;
1336 }
1337 if !picked.contains(&t) {
1338 picked.push(t);
1339 }
1340 }
1341 picked
1342 }
1343
1344 pub fn last_order(&self, statement: usize, asked: &[usize], k: usize) -> Vec<usize> {
1355 let spread = self.order(statement, MORE_TESTS);
1356 let position = |t: &usize| spread.iter().position(|x| x == t).unwrap_or(usize::MAX);
1357 let s = &self.statements[statement];
1358 let line = name_words(s.text.lines().next().unwrap_or(""));
1359 let shared = |t: usize| {
1360 let name = &self.tests[t].name;
1361 let name = name.rsplit("::").next().unwrap_or(name);
1362 name_words(name).intersection(&line).count()
1363 };
1364 let mut rest = s
1365 .tests
1366 .iter()
1367 .copied()
1368 .filter(|t| !asked.contains(t))
1369 .collect::<Vec<_>>();
1370 rest.sort_by_cached_key(|t| {
1371 (
1372 std::cmp::Reverse(shared(*t)),
1373 self.breadth(*t),
1374 position(t),
1375 *t,
1376 )
1377 });
1378 rest.truncate(k);
1379 rest
1380 }
1381
1382 fn breadth(&self, test: usize) -> usize {
1384 self.ran[test].values().map(BTreeSet::len).sum()
1385 }
1386
1387 pub fn test_code(&self, test: usize) -> String {
1390 match self.language {
1391 Language::Python => return self.python_test_code(test),
1392 Language::JavaScript => {}
1393 _ => return self.located_test_code(test),
1394 }
1395 let t = &self.tests[test];
1396 let text = self.read(&t.file).unwrap_or_default();
1397 let lines = text.split('\n').collect::<Vec<_>>();
1398 let start = js_test_line(&lines, &t.name);
1399 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1400 let head = (0..lines.len().min(HEAD_LINES))
1401 .filter(|&i| !self.language.comment(lines[i]))
1402 .map(fmt)
1403 .collect::<Vec<_>>();
1404 let Some(start) = start else {
1405 let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
1406 return head
1407 .into_iter()
1408 .chain(std::iter::once(" ...".to_owned()))
1409 .chain(rest)
1410 .collect::<Vec<_>>()
1411 .join("\n");
1412 };
1413 let indent = lines[start].len() - lines[start].trim_start().len();
1414 let mut body = Vec::new();
1415 for (i, l) in lines
1416 .iter()
1417 .copied()
1418 .enumerate()
1419 .take(start + BODY_LINES)
1420 .skip(start)
1421 {
1422 if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1423 break;
1424 }
1425 if !self.language.comment(l) {
1426 body.push(fmt(i));
1427 }
1428 }
1429 let mut out = head;
1430 if start > HEAD_LINES {
1431 out.push(" ...".into());
1432 }
1433 out.extend(body);
1434 out.join("\n")
1435 }
1436
1437 fn located_test_code(&self, test: usize) -> String {
1440 let t = &self.tests[test];
1441 let text = self.read(&t.file).unwrap_or_default();
1442 let lines = text.split('\n').collect::<Vec<_>>();
1443 let located = match self.language {
1444 Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1445 Language::Go => go::locate(&lines, &t.name),
1446 Language::Rust => rust_source::locate(&lines, &t.name),
1447 _ => jvm::locate(&lines, &t.name),
1448 };
1449 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1450 let mut open = false;
1452 let commented = lines
1453 .iter()
1454 .map(|l| {
1455 let t = l.trim();
1456 let was = open;
1457 if self.language != Language::Ruby {
1458 if !open && t.starts_with("/*") {
1459 open = true;
1460 }
1461 if open && t.contains("*/") {
1462 open = false;
1463 return true;
1464 }
1465 }
1466 was || open || self.language.comment(l)
1467 })
1468 .collect::<Vec<_>>();
1469 let head = (0..lines.len())
1471 .filter(|&i| !commented[i])
1472 .take(HEAD_LINES)
1473 .collect::<Vec<_>>();
1474 let head_end = head.last().map_or(0, |&i| i + 1);
1475 let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1476 let Some(located) = located else {
1477 out.push(" ...".into());
1478 out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1479 return out.join("\n");
1480 };
1481 let end = located
1482 .end
1483 .min(located.start + BODY_LINES)
1484 .min(lines.len().saturating_sub(1));
1485 let mut last = head_end;
1486 let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1487 shown.extend(located.start..=end);
1488 shown.extend(used_definitions(&lines, self.language, located.start, end));
1491 for i in shown {
1492 if i < last || (commented[i] && self.language != Language::Rust) {
1494 continue;
1495 }
1496 if i > last {
1497 out.push(" ...".into());
1498 }
1499 out.push(fmt(i));
1500 last = i + 1;
1501 }
1502 out.join("\n")
1503 }
1504
1505 fn python_test_code(&self, test: usize) -> String {
1509 let t = &self.tests[test];
1510 let text = self.read(&t.file).unwrap_or_default();
1511 let lines = text.split('\n').collect::<Vec<_>>();
1512 let title = python_title(&t.name);
1513 let indent_of = |l: &str| l.len() - l.trim_start().len();
1514 let (from, to, body) = python_class_lines(&lines, &python_classes(&t.name));
1517 let defines = |i: &usize| {
1518 let t = lines[*i].trim_start();
1519 t.starts_with(&format!("def {title}(")) || t.starts_with(&format!("async def {title}("))
1520 };
1521 let own = |i: &usize| defines(i) && body.is_none_or(|body| indent_of(lines[*i]) == body);
1523 let start = (!title.is_empty())
1524 .then(|| {
1525 (from..to)
1526 .find(own)
1527 .or_else(|| (0..lines.len()).find(defines))
1528 })
1529 .flatten();
1530 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1531 let mut out = (0..lines.len().min(HEAD_LINES))
1532 .filter(|&i| !self.language.comment(lines[i]))
1533 .map(fmt)
1534 .collect::<Vec<_>>();
1535 let Some(start) = start else {
1536 out.push(" ...".into());
1537 out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1538 return out.join("\n");
1539 };
1540 let indent = indent_of(lines[start]);
1541 let mut first = start;
1542 while first > 0
1543 && lines[first - 1].trim_start().starts_with('@')
1544 && indent_of(lines[first - 1]) == indent
1545 {
1546 first -= 1;
1547 }
1548 let class = (indent > 0)
1549 .then(|| {
1550 (0..first).rev().find(|&i| {
1551 lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1552 })
1553 })
1554 .flatten();
1555 let mut last = HEAD_LINES.min(lines.len()).checked_sub(1);
1557 let mut show = |out: &mut Vec<String>, i: usize| {
1558 if i >= HEAD_LINES {
1559 if last.is_some_and(|l| i > l + 1) {
1560 out.push(" ...".into());
1561 }
1562 out.push(fmt(i));
1563 last = Some(i);
1564 }
1565 };
1566 for i in class.into_iter().chain(first..=start) {
1567 show(&mut out, i);
1568 }
1569 for (i, &l) in lines
1570 .iter()
1571 .enumerate()
1572 .skip(start + 1)
1573 .take(BODY_LINES - 1)
1574 {
1575 if !l.trim().is_empty() && indent_of(l) <= indent {
1576 break;
1577 }
1578 if !self.language.comment(l) && i >= HEAD_LINES {
1580 out.push(fmt(i));
1581 }
1582 }
1583 out.join("\n")
1584 }
1585
1586 pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1592 if self.language.asks_within_the_function() {
1593 return BTreeMap::new();
1594 }
1595 let cap = HELPER_CHARS.min(1500 + 400 * questions);
1596 let t = &self.tests[test];
1597 let text = self.read(&t.file).unwrap_or_default();
1598 let mut out = BTreeMap::new();
1599 let mut size = 0;
1600 let files = match self.language {
1601 Language::JavaScript | Language::Python => Vec::new(),
1602 Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1603 Language::Go => go::helpers(&self.root, &t.file, &text),
1604 Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1605 Language::Jvm => jvm::imports(&text)
1606 .iter()
1607 .filter_map(|import| {
1608 jvm::class_paths(&import.replace('/', "."))
1609 .iter()
1610 .find_map(|suffix| {
1611 self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1612 })
1613 .cloned()
1614 })
1615 .collect(),
1616 };
1617 for file in files {
1618 if out.contains_key(&file) {
1619 continue;
1620 }
1621 if self.source_files.contains(&file) || file.contains("node_modules") {
1622 continue;
1623 }
1624 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1625 if size + body.len() > cap {
1626 let keep = cap.saturating_sub(size);
1627 let cut = body
1628 .char_indices()
1629 .map(|(i, _)| i)
1630 .take_while(|&i| i <= keep)
1631 .last()
1632 .unwrap_or(0);
1633 body = format!("{}\n... (cut)", &body[..cut]);
1634 }
1635 size += body.len();
1636 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1637 if size >= cap {
1638 break;
1639 }
1640 }
1641 if self.language == Language::Ruby {
1642 let mut used = 0;
1643 for file in ruby::support_files(&self.root, &text) {
1644 if out.contains_key(&file) || used >= FIXTURE_CHARS {
1645 continue;
1646 }
1647 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1648 if used + body.len() > FIXTURE_CHARS {
1649 let keep = FIXTURE_CHARS - used;
1650 let cut = body
1651 .char_indices()
1652 .map(|(i, _)| i)
1653 .take_while(|&i| i <= keep)
1654 .last()
1655 .unwrap_or(0);
1656 body = format!("{}\n... (cut)", &body[..cut]);
1657 }
1658 used += body.len();
1659 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1660 }
1661 }
1662 out
1663 }
1664
1665 pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1676 let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1677 let within = self.language.asks_within_the_function();
1678 let ran = &self.ran[test];
1679 let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1680 for &a in asked {
1681 want.entry(self.statements[a].file.clone())
1682 .or_default()
1683 .insert(self.statements[a].line);
1684 }
1685 let mut files = want.keys().cloned().collect::<Vec<_>>();
1686 let mut others = ran
1687 .iter()
1688 .filter(|(f, _)| !within && !want.contains_key(*f))
1689 .collect::<Vec<_>>();
1690 others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1691 files.extend(others.into_iter().map(|(f, _)| f.clone()));
1692 let empty = BTreeSet::new();
1693 let mut out = BTreeMap::new();
1694 let mut total = 0;
1695 for f in files {
1696 let Some(source) = self.read(&f) else {
1697 continue;
1698 };
1699 let text = source.split('\n').collect::<Vec<_>>();
1700 let r = ran.get(&f).unwrap_or(&empty);
1701 let code = (1..=text.len())
1702 .filter(|&i| !self.language.comment(text[i - 1]))
1703 .collect::<Vec<_>>();
1704 let pos = code
1705 .iter()
1706 .enumerate()
1707 .map(|(n, &i)| (i, n))
1708 .collect::<BTreeMap<_, _>>();
1709 let around = |l: usize, w: usize| -> Vec<usize> {
1710 match pos.get(&l) {
1711 Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1712 None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1713 }
1714 };
1715 let mut show = BTreeSet::new();
1716 for &l in r {
1717 show.extend(around(l, CONTEXT_LINES));
1718 }
1719 for &l in want.get(&f).unwrap_or(&empty) {
1720 show.extend(around(l, WINDOW_LINES));
1721 }
1722 let end = show
1725 .iter()
1726 .rev()
1727 .copied()
1728 .find(|&i| i != 0 && i <= text.len() && !self.language.comment(text[i - 1]))
1729 .unwrap_or(0);
1730 if within {
1731 let functions = outermost_functions(self.language, &f, &source);
1732 let around =
1733 |l: usize| (l.saturating_sub(OUTSIDE_A_FUNCTION), l + OUTSIDE_A_FUNCTION);
1734 let function = |l: usize| {
1735 functions
1736 .iter()
1737 .copied()
1738 .find(|&(from, to)| from <= l && l <= to)
1739 };
1740 let begins = self
1742 .statements
1743 .iter()
1744 .filter(|s| s.file == f)
1745 .map(|s| s.line)
1746 .collect::<BTreeSet<_>>();
1747 let mut kept = Vec::new();
1748 for statement in asked.iter().map(|&a| &self.statements[a]) {
1749 if statement.file != f {
1750 continue;
1751 }
1752 kept.push(function(statement.line).unwrap_or(around(statement.line)));
1753 for l in (statement.line + 1)
1757 ..statement.line + whole_statement(statement).unwrap_or(1)
1758 {
1759 kept.push(
1760 begins
1761 .contains(&l)
1762 .then(|| function(l))
1763 .flatten()
1764 .unwrap_or(around(l)),
1765 );
1766 }
1767 }
1768 show.retain(|i| kept.iter().any(|&(from, to)| from <= *i && *i <= to));
1769 }
1770 let mut lines = Vec::new();
1771 let mut last = 0;
1772 for i in show {
1773 if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1774 continue;
1775 }
1776 if i != last + 1 {
1777 lines.push(format!(" ... {} lines not shown", i - last - 1));
1778 }
1779 lines.push(format!(
1780 "{}{:5} {}",
1781 if r.contains(&i) { '▶' } else { ' ' },
1782 i,
1783 text[i - 1]
1784 ));
1785 last = i;
1786 }
1787 if within && last != 0 && end > last {
1788 lines.push(format!(" ... {} lines not shown", end - last));
1789 }
1790 let body = lines.join("\n");
1791 if !want.contains_key(&f) && total + body.len() > budget {
1792 continue;
1793 }
1794 total += body.len();
1795 out.insert(f, body);
1796 }
1797 out
1798 }
1799
1800 pub fn questions(&self, statement: usize) -> Vec<String> {
1802 let s = &self.statements[statement];
1803 let line = s.text.lines().next().unwrap_or("").trim();
1804 let changes = match (s.change, &s.replacement) {
1805 (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1806 if self.language == Language::Rust =>
1807 {
1808 vec![rust_question(line, s.change, r)]
1809 }
1810 (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1813 vec![format!(
1814 "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1815 )]
1816 }
1817 (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1818 vec![format!(
1819 "`{line}` becomes `{r}` (the original value is still computed first)"
1820 )]
1821 }
1822 _ => self.default_questions(s, line),
1823 };
1824 changes
1825 .into_iter()
1826 .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1827 .collect()
1828 }
1829
1830 fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1831 match s.change {
1832 Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1835 "`{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)"
1836 )],
1837 Change::ReturnUndefined
1840 if self.language == Language::Jvm
1841 && s.text.contains("return")
1842 && ["when", "switch", "try"].iter().any(|k| {
1843 line.strip_prefix(k)
1844 .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1845 }) =>
1846 {
1847 vec![format!(
1848 "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1849 self.language.nothing()
1850 )]
1851 }
1852 Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1853 "`{line}` returns {} instead (its expression still runs)",
1854 self.language.nothing()
1855 )],
1856 Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1857 let nothing = match self.language {
1858 Language::Rust
1859 if ["Ok(", "Err(", "return Ok(", "return Err("]
1860 .iter()
1861 .any(|p| line.starts_with(p)) =>
1862 {
1863 "`Ok(Default::default())`"
1864 }
1865 other => other.nothing(),
1866 };
1867 vec![if line.starts_with("return") {
1868 format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1869 } else {
1870 format!(
1873 "`{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}"
1874 )
1875 }]
1876 }
1877 Change::ValueUndefined if self.language == Language::Go => vec![format!(
1878 "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1879 )],
1880 Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1881 "`{line}`: after it runs, the variable it assigns holds {} instead",
1882 self.language.nothing()
1883 )],
1884 Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1885 let nothing = self.language.nothing();
1886 vec![match split_initializer(line) {
1887 Some(head) => format!(
1888 "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1889 ),
1890 None => format!("`{line}`: the assigned value becomes {nothing}"),
1891 }]
1892 }
1893 Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1894 "`{line}` becomes `return None` (the expression is not evaluated)"
1895 )],
1896 Change::ReturnUndefined => vec![format!(
1897 "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1898 )],
1899 Change::ValueUndefined if self.language == Language::Python => {
1900 vec![match split_initializer(line) {
1901 Some(head) => {
1902 format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1903 }
1904 None => format!("`{line}`: the assigned value becomes None"),
1905 }]
1906 }
1907 Change::ValueUndefined => vec![match split_initializer(line) {
1908 Some(head) => format!(
1909 "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1910 ),
1911 None => format!("`{line}`: the declared value becomes undefined"),
1912 }],
1913 Change::Skip => {
1914 let word = line.trim_end_matches(';').trim();
1915 vec![match word {
1916 "break" => format!(
1917 "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1918 ),
1919 "continue" | "next" => format!(
1920 "`{line}` is deleted: the rest of the loop body runs in this iteration"
1921 ),
1922 _ if whole_statement(s).is_some() => {
1927 let lines = s.text.lines().count();
1928 format!(
1929 "the whole statement starting `{line}` is deleted, all {lines} lines of it (lines {} to {}): none of it runs",
1930 s.line,
1931 s.line + lines - 1
1932 )
1933 }
1934 _ if self.language == Language::Python
1940 && (word == "return" || word.starts_with("return ")) =>
1941 {
1942 format!(
1943 "`{line}` is deleted: the function does not return here, and execution carries on with whatever follows it"
1944 )
1945 }
1946 _ if self.language == Language::Python
1947 && (word == "raise" || word.starts_with("raise ")) =>
1948 {
1949 format!(
1950 "`{line}` is deleted: no error is raised here, and execution carries on with whatever follows it"
1951 )
1952 }
1953 _ => format!("`{line}` is deleted (it never runs)"),
1954 }]
1955 }
1956 Change::ExpressionUndefined => vec![format!(
1957 "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1958 )],
1959 Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1960 Change::BareError => vec![format!(
1961 "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1962 )],
1963 Change::Invert => {
1964 let c = match self.language {
1965 Language::Python => python_condition(line),
1966 Language::JavaScript => condition(line),
1967 Language::Ruby => ruby::condition(line),
1968 Language::Go => go::condition(line),
1969 Language::Rust => rust_source::condition(line),
1970 Language::Jvm => jvm::condition(line),
1971 };
1972 if c.starts_with("let ") {
1975 return vec![format!(
1976 "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1977 )];
1978 }
1979 vec![
1980 format!(
1981 "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1982 ),
1983 format!(
1984 "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1985 ),
1986 ]
1987 }
1988 }
1989 }
1990
1991 pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1993 let t = &self.tests[test];
1994 let mut questions = serde_json::Map::new();
1995 for (n, &a) in asked.iter().enumerate() {
1996 for (j, q) in self.questions(a).into_iter().enumerate() {
1997 questions.insert(
1998 format!("q{n}_{j}"),
1999 json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
2000 );
2001 }
2002 }
2003 let mut state = json!({
2004 "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
2005 "test": {"file": t.file, "name": t.name},
2006 "test_code": self.test_code(test),
2007 "code_run": self.code_run(test, asked)});
2008 if !self.language.asks_within_the_function() {
2009 state["helpers"] = json!(self.helpers(test, asked.len()));
2010 }
2011 json!({"model": model, "state": state, "questions": questions})
2012 }
2013
2014 pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
2019 let t = &self.tests[test];
2020 let parts = json!([
2021 model,
2022 salt,
2023 INSTRUCTIONS,
2024 runtime(&t.runner),
2025 true,
2026 &t.file,
2027 &t.name,
2028 strip_numbers(&self.test_code(test)),
2029 self.helpers(test, 1)
2030 .into_iter()
2031 .map(|(f, b)| (f, strip_numbers(&b)))
2032 .collect::<BTreeMap<_, _>>(),
2033 self.code_run(test, &[statement])
2034 .into_iter()
2035 .map(|(f, b)| (f, strip_numbers(&b)))
2036 .collect::<BTreeMap<_, _>>(),
2037 self.questions(statement)
2038 .iter()
2039 .map(|q| strip_location(q))
2040 .collect::<Vec<_>>(),
2041 ]);
2042 format!(
2043 "{:x}",
2044 Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
2045 )
2046 }
2047}
2048
2049fn flipped(line: &str) -> String {
2052 let cut = comment_start(line);
2055 let (code, comment) = line.split_at(cut);
2056 format!("{}{comment}", flipped_code(code))
2057}
2058
2059fn comment_start(line: &str) -> usize {
2062 let bytes = line.as_bytes();
2063 let mut quote = None;
2064 let mut i = 0;
2065 while i < bytes.len() {
2066 let c = bytes[i];
2067 match quote {
2068 Some(_) if c == b'\\' => i += 1,
2069 Some(q) if c == q => quote = None,
2070 Some(_) => {}
2071 None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
2072 None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
2073 None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
2074 None => {}
2075 }
2076 i += 1;
2077 }
2078 line.len()
2079}
2080
2081fn flipped_code(line: &str) -> String {
2082 let words = [
2083 ("true", "false"),
2084 ("false", "true"),
2085 ("True", "False"),
2086 ("False", "True"),
2087 ];
2088 let found = words
2089 .iter()
2090 .filter_map(|(from, to)| {
2091 line.match_indices(from)
2092 .filter(|(at, _)| {
2093 let before = line[..*at].chars().next_back();
2094 let after = line[at + from.len()..].chars().next();
2095 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
2096 !word(before) && !word(after)
2097 })
2098 .last()
2099 .map(|(at, _)| (at, *from, *to))
2100 })
2101 .max_by_key(|(at, _, _)| *at);
2102 match found {
2103 Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
2104 None => line.to_owned(),
2105 }
2106}
2107
2108fn rust_question(line: &str, change: Change, replacement: &str) -> String {
2110 let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
2111 match change {
2112 Change::ReturnUndefined if line.starts_with("return") => {
2113 format!("`{line}` becomes `return {repl};`")
2114 }
2115 Change::ReturnUndefined => {
2116 format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
2117 }
2118 _ => match split_initializer(line) {
2119 Some(head) => format!("`{line}` becomes `{head} {repl};`"),
2120 None => format!("`{line}`: the value it assigns becomes `{repl}`"),
2121 },
2122 }
2123}
2124
2125pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
2127 use std::io::Write;
2128 let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
2129 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2130 encoder.write_all(&bytes).map_err(|e| e.to_string())?;
2131 let compressed = encoder.finish().map_err(|e| e.to_string())?;
2132 let temp = path.with_extension("tmp");
2133 std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
2134 std::fs::rename(&temp, path).map_err(|e| e.to_string())
2135}
2136
2137pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
2139 use std::io::Read;
2140 let compressed = std::fs::read(path).ok()?;
2141 let mut text = Vec::new();
2142 flate2::read::GzDecoder::new(compressed.as_slice())
2143 .read_to_end(&mut text)
2144 .ok()?;
2145 serde_json::from_slice(&text).ok()
2146}
2147
2148struct Located {
2151 context: Vec<usize>,
2152 start: usize,
2153 end: usize,
2154}
2155
2156fn indent(line: &str) -> usize {
2157 line.len() - line.trim_start().len()
2158}
2159
2160fn brace_end(lines: &[&str], start: usize) -> usize {
2163 brace_end_within(lines, start, BODY_LINES)
2164}
2165
2166fn block_end(lines: &[&str], start: usize) -> usize {
2169 brace_end_within(lines, start, lines.len())
2170}
2171
2172fn brace_end_within(lines: &[&str], start: usize, limit: usize) -> usize {
2173 let mut depth = 0i64;
2174 let mut opened = false;
2175 for (i, line) in lines.iter().enumerate().skip(start).take(limit) {
2176 let mut chars = line.chars().peekable();
2177 let mut quote: Option<char> = None;
2178 while let Some(c) = chars.next() {
2179 match (quote, c) {
2180 (Some(_), '\\') => {
2181 chars.next();
2182 }
2183 (Some(q), c) if c == q => quote = None,
2184 (Some(_), _) => {}
2185 (None, '"' | '`') => quote = Some(c),
2186 (None, '/') if chars.peek() == Some(&'/') => break,
2187 (None, '{') => {
2188 depth += 1;
2189 opened = true;
2190 }
2191 (None, '}') => depth -= 1,
2192 _ => {}
2193 }
2194 }
2195 if opened && depth <= 0 {
2196 return i;
2197 }
2198 }
2199 start
2200 .saturating_add(limit)
2201 .min(lines.len())
2202 .saturating_sub(1)
2203}
2204
2205fn keyword_end(lines: &[&str], start: usize) -> usize {
2207 let first = lines[start].trim_end();
2208 if first.ends_with(" end") || first.ends_with('}') {
2209 return start;
2210 }
2211 let depth = indent(lines[start]);
2212 (start + 1..lines.len().min(start + BODY_LINES))
2213 .find(|&i| {
2214 let t = lines[i].trim_start();
2215 indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
2216 })
2217 .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
2218}
2219
2220const DEFINITION_LINES: usize = 120;
2221
2222fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
2225 let words = |range: std::ops::RangeInclusive<usize>| {
2226 let mut out = BTreeSet::new();
2227 for i in range {
2228 let line = lines.get(i).copied().unwrap_or("");
2229 out.extend(
2230 line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2231 .filter(|w| !w.is_empty())
2232 .map(str::to_owned),
2233 );
2234 }
2235 out
2236 };
2237 let definitions = definitions(lines, language);
2238 let mut wanted = words(start..=end);
2239 let mut picked = BTreeSet::new();
2240 let mut budget = DEFINITION_LINES;
2241 for _ in 0..2 {
2242 let mut next = BTreeSet::new();
2243 for (name, a, b) in &definitions {
2244 let (a, b) = (*a, *b);
2245 if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
2246 continue;
2247 }
2248 let size = b - a + 1;
2249 if size > budget {
2250 continue;
2251 }
2252 budget -= size;
2253 picked.insert(a);
2254 next.extend(words(a..=b));
2255 picked.extend(a..=b);
2256 }
2257 wanted = next;
2258 }
2259 picked.into_iter().collect()
2260}
2261
2262fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
2265 let ident = |s: &str| {
2266 s.chars()
2267 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
2268 .collect::<String>()
2269 };
2270 let mut out = Vec::new();
2271 for (i, line) in lines.iter().enumerate() {
2272 let t = line.trim_start();
2273 let name = match language {
2274 Language::Go => t
2275 .strip_prefix("func ")
2276 .map(|r| match r.strip_prefix('(') {
2277 Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
2278 None => r,
2279 })
2280 .or_else(|| t.strip_prefix("var "))
2281 .or_else(|| t.strip_prefix("type "))
2282 .filter(|_| indent(line) == 0)
2283 .map(ident),
2284 Language::Rust => {
2285 let r = t.strip_prefix("pub ").unwrap_or(t);
2286 let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
2287 ["fn ", "const ", "static ", "struct ", "enum "]
2288 .iter()
2289 .find_map(|k| r.strip_prefix(k))
2290 .map(ident)
2291 }
2292 Language::Ruby => t
2293 .strip_prefix("def ")
2294 .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
2295 .or_else(|| {
2296 ["let(:", "let!(:", "subject(:"]
2297 .iter()
2298 .find_map(|k| t.strip_prefix(k))
2299 .map(ident)
2300 })
2301 .or_else(|| {
2302 (t.starts_with("subject ") || t.starts_with("subject{"))
2303 .then(|| "subject".into())
2304 }),
2305 Language::Jvm => {
2306 if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
2307 Some(ident(r.1.trim_start_matches('`')))
2308 } else if t.contains('(')
2309 && !t.ends_with(';')
2310 && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
2311 && ![
2312 "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
2313 "throw", "assert", "super", "this",
2314 ]
2315 .iter()
2316 .any(|k| t.starts_with(k))
2317 {
2318 let head = t.split('(').next().unwrap_or("");
2319 let mut parts = head.split_whitespace().rev();
2320 let name = parts.next().unwrap_or("");
2321 (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
2322 } else {
2323 None
2324 }
2325 }
2326 _ => None,
2327 };
2328 let Some(name) = name.filter(|n| !n.is_empty()) else {
2329 continue;
2330 };
2331 let end = match language {
2332 Language::Ruby => keyword_end(lines, i),
2333 _ if line.contains('{')
2334 || lines
2335 .get(i + 1)
2336 .is_some_and(|n| n.trim_start().starts_with('{')) =>
2337 {
2338 brace_end(lines, i)
2339 }
2340 _ => i,
2341 };
2342 out.push((name, i, end));
2343 }
2344 out
2345}
2346
2347fn ends_with_path(path: &str, suffix: &str) -> bool {
2349 path.strip_suffix(suffix)
2350 .is_some_and(|head| head.is_empty() || head.ends_with('/'))
2351}
2352
2353fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
2356 let mut out = ignore::WalkBuilder::new(root)
2357 .build()
2358 .filter_map(Result::ok)
2359 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2360 .filter(|e| {
2361 e.path()
2362 .extension()
2363 .and_then(|x| x.to_str())
2364 .is_some_and(|x| extensions.contains(&x))
2365 })
2366 .filter_map(|e| {
2367 e.path()
2368 .strip_prefix(root)
2369 .ok()
2370 .map(|p| p.to_string_lossy().replace('\\', "/"))
2371 })
2372 .filter(|p| !p.starts_with(".supercov/"))
2373 .collect::<Vec<_>>();
2374 out.sort();
2375 out
2376}
2377
2378fn is_test_call(line: &str) -> bool {
2379 let t = line.trim_start();
2380 let rest = if let Some(r) = t.strip_prefix("it") {
2381 r
2382 } else if let Some(r) = t.strip_prefix("test") {
2383 r
2384 } else {
2385 return false;
2386 };
2387 let rest = if let Some(r) = rest.strip_prefix('.') {
2388 r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
2389 } else {
2390 rest
2391 };
2392 rest.trim_start().starts_with('(')
2393}
2394
2395fn split_initializer(line: &str) -> Option<String> {
2397 let b = line.as_bytes();
2398 for i in 1..b.len() {
2399 if b[i] == b'='
2400 && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
2401 && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
2402 {
2403 return Some(line[..=i].to_owned());
2404 }
2405 }
2406 None
2407}
2408
2409fn python_title(name: &str) -> &str {
2413 let last = name.rsplit("::").next().unwrap_or(name);
2414 let last = last.split('[').next().unwrap_or(last);
2415 last.rsplit('.').next().unwrap_or(last)
2416}
2417
2418fn python_classes(name: &str) -> Vec<&str> {
2421 let name = name.split('[').next().unwrap_or(name);
2422 if name.contains("::") {
2423 let parts = name.split("::").collect::<Vec<_>>();
2424 return parts[1..parts.len().saturating_sub(1)].to_vec();
2425 }
2426 let parts = name.split('.').collect::<Vec<_>>();
2427 parts
2428 .len()
2429 .checked_sub(2)
2430 .map(|at| parts[at])
2431 .filter(|class| class.starts_with(|c: char| c.is_ascii_uppercase()))
2432 .into_iter()
2433 .collect()
2434}
2435
2436fn python_class_lines(lines: &[&str], classes: &[&str]) -> (usize, usize, Option<usize>) {
2440 let indent_of = |l: &str| l.len() - l.trim_start().len();
2441 let (mut from, mut to, mut body) = (0, lines.len(), None);
2442 for class in classes {
2443 let declared = (from..to).find(|&i| {
2444 lines[i]
2445 .trim_start()
2446 .strip_prefix("class ")
2447 .and_then(|rest| rest.strip_prefix(class))
2448 .is_some_and(|rest| rest.starts_with(['(', ':']))
2449 });
2450 let Some(at) = declared else {
2451 return (0, lines.len(), None);
2452 };
2453 let indent = indent_of(lines[at]);
2454 let end = (at + 1..to)
2455 .find(|&i| !lines[i].trim().is_empty() && indent_of(lines[i]) <= indent)
2456 .unwrap_or(to);
2457 body = (at + 1..end)
2458 .find(|&i| !lines[i].trim().is_empty())
2459 .map(|i| indent_of(lines[i]));
2460 (from, to) = (at, end);
2461 }
2462 (from, to, body)
2463}
2464
2465fn python_condition(line: &str) -> String {
2466 let t = line.trim();
2467 let t = t
2468 .strip_prefix("elif")
2469 .or_else(|| t.strip_prefix("if"))
2470 .map(str::trim_start)
2471 .unwrap_or(t);
2472 t.strip_suffix(':')
2473 .map(str::trim_end)
2474 .unwrap_or(t)
2475 .to_owned()
2476}
2477
2478fn condition(line: &str) -> String {
2480 let mut t = line.trim();
2481 if let Some(r) = t.strip_prefix('}') {
2482 let r = r.trim_start();
2483 if let Some(r) = r.strip_prefix("else") {
2484 t = r.trim_start();
2485 }
2486 }
2487 let Some(r) = t.strip_prefix("if") else {
2488 return line.to_owned();
2489 };
2490 let r = r.trim_start();
2491 let Some(r) = r.strip_prefix('(') else {
2492 return line.to_owned();
2493 };
2494 let r = r.trim_end();
2495 let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2496 match r.strip_suffix(')') {
2497 Some(c) => c.to_owned(),
2498 None => line.to_owned(),
2499 }
2500}
2501
2502fn strip_numbers(text: &str) -> String {
2503 text.split('\n')
2504 .map(|l| {
2505 let (mark, rest) = match l.chars().next() {
2506 Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2507 _ => (None, l),
2508 };
2509 let trimmed = rest.trim_start();
2510 let digits = trimmed.len()
2511 - trimmed
2512 .trim_start_matches(|c: char| c.is_ascii_digit())
2513 .len();
2514 if digits > 0 && trimmed[digits..].starts_with(' ') {
2515 format!(
2516 "{}{}",
2517 mark.map(String::from).unwrap_or_default(),
2518 &trimmed[digits + 1..]
2519 )
2520 } else if let Some(n) = l
2521 .trim_start()
2522 .strip_prefix("... ")
2523 .and_then(|r| r.split_once(" lines not shown"))
2524 {
2525 let _ = n;
2526 "...".to_owned()
2527 } else {
2528 l.to_owned()
2529 }
2530 })
2531 .collect::<Vec<_>>()
2532 .join("\n")
2533}
2534
2535fn strip_location(question: &str) -> String {
2536 match question.split_once(": ") {
2538 Some((loc, rest)) => match loc.rsplit_once(':') {
2539 Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2540 format!("{file}:: {rest}")
2541 }
2542 _ => question.to_owned(),
2543 },
2544 None => question.to_owned(),
2545 }
2546}
2547
2548fn normalize(path: &Path) -> String {
2552 let mut out = Vec::new();
2553 for c in path.components() {
2554 match c {
2555 std::path::Component::ParentDir => {
2556 out.pop();
2557 }
2558 std::path::Component::CurDir => {}
2559 other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2560 }
2561 }
2562 out.join("/")
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567 #[test]
2568 fn a_replacement_that_is_the_statement_is_no_change() {
2569 assert!(super::same_code("return 0, nil", "return 0, nil"));
2570 assert!(super::same_code("count := 0", "count := 0;"));
2571 assert!(!super::same_code("return 0, nil", "return n, nil"));
2572 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";
2573 assert_eq!(
2574 super::go::rewrite(source, 7, Change::ReturnUndefined).as_deref(),
2575 Some("return 0, nil")
2576 );
2577 }
2578
2579 use super::*;
2580
2581 #[test]
2582 fn a_statement_many_tests_run_is_asked_of_the_tests_that_ran_little_else() {
2583 let source = (0..10)
2588 .map(|n| format!("const v{n} = compute({n});"))
2589 .collect::<Vec<_>>()
2590 .join("\n");
2591 let test = |file: &str, name: &str| InputTest {
2592 id: format!("{file}::{name}"),
2593 file: Some(file.to_owned()),
2594 name: name.to_owned(),
2595 role: "test".into(),
2596 runner: "node:test".into(),
2597 };
2598 let mut tests = (0..17)
2599 .map(|n| {
2600 let name = if n == 16 {
2601 "calls compute on start"
2602 } else {
2603 "serves a request"
2604 };
2605 test(&format!("tests/e2e{n:02}.test.js"), name)
2606 })
2607 .collect::<Vec<_>>();
2608 tests.extend((0..3).map(|n| test("tests/zz_unit.test.js", &format!("total case {n}"))));
2609 let everyone = (0..20).collect::<Vec<u32>>();
2610 let end_to_end = (0..17).collect::<Vec<u32>>();
2611 let points = (0..10)
2612 .map(|n| InputPoint {
2613 file: "src/sum.js".into(),
2614 line: n + 1,
2615 source: format!("const v{n} = compute({n});"),
2616 tests: if n == 0 {
2617 everyone.clone()
2618 } else {
2619 end_to_end.clone()
2620 },
2621 })
2622 .collect();
2623 let input = AssessmentInput {
2624 tests,
2625 points,
2626 decisions: Vec::new(),
2627 };
2628 let sources = BTreeMap::from([("src/sum.js".to_owned(), source)]);
2629 let population = population_from(
2630 Path::new("/nonexistent"),
2631 &input,
2632 &sources,
2633 Language::JavaScript,
2634 )
2635 .unwrap();
2636 let target = population
2637 .statements
2638 .iter()
2639 .position(|s| s.line == 1)
2640 .unwrap();
2641 let unit = |order: &[usize]| {
2642 order
2643 .iter()
2644 .filter(|&&t| population.tests[t].file == "tests/zz_unit.test.js")
2645 .count()
2646 };
2647 let spread = population.order(target, MORE_TESTS);
2648 assert_eq!(
2649 unit(&spread),
2650 0,
2651 "file order alone leaves the unit tests out"
2652 );
2653 let last = population.last_order(target, &spread, LAST_TESTS);
2654 assert_eq!(last.len(), LAST_TESTS);
2655 assert!(last.iter().all(|t| !spread.contains(t)), "{last:?}");
2656 assert_eq!(population.tests[last[0]].name, "calls compute on start");
2659 assert_eq!(unit(&last[1..]), 2, "{last:?}");
2660 assert_eq!(
2661 name_words("const hostCount = getHTTPResponse2(send_data)"),
2662 [
2663 "const",
2664 "count",
2665 "data",
2666 "host",
2667 "http",
2668 "response2",
2669 "send"
2670 ]
2671 .map(String::from)
2672 .into()
2673 );
2674 }
2675
2676 #[test]
2677 fn a_statement_is_shown_with_the_function_it_is_in() {
2678 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";
2681 assert_eq!(
2682 outermost_functions(Language::JavaScript, "src/vault.ts", typescript),
2683 [(3, 8), (12, 15), (18, 20), (23, 25)]
2684 );
2685 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";
2686 assert_eq!(
2687 outermost_functions(Language::Python, "vault.py", python),
2688 [(5, 9), (14, 15), (18, 19)]
2689 );
2690 assert!(outermost_functions(Language::Go, "vault.go", "package vault\n").is_empty());
2692 assert!(Language::Python.asks_within_the_function());
2693 assert!(!Language::Ruby.asks_within_the_function());
2694 }
2695
2696 #[test]
2697 fn changes_follow_the_audit() {
2698 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";
2699 let starts = statement_starts("x.tsx", source);
2700 let js = Language::JavaScript;
2701 assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2702 assert_eq!(
2703 change_at(&starts, 2, "const a = 1", js),
2704 Some(Change::ValueUndefined)
2705 );
2706 assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2707 assert_eq!(
2708 change_at(&starts, 5, "if (a) return 2", js),
2709 Some(Change::Invert)
2710 );
2711 assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2712 assert_eq!(
2713 change_at(&starts, 8, "a && b", js),
2714 Some(Change::ExpressionUndefined)
2715 );
2716 }
2717
2718 #[test]
2719 fn a_change_that_cannot_change_anything_is_not_asked() {
2720 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";
2724 let starts = statement_starts("x.ts", source);
2725 let js = Language::JavaScript;
2726 assert_eq!(change_at(&starts, 3, "super([])", js), None);
2727 assert_eq!(
2728 change_at(&starts, 7, "return undefined", js),
2729 Some(Change::Skip)
2730 );
2731 assert_eq!(
2732 change_at(&starts, 8, "return void 0", js),
2733 Some(Change::Skip)
2734 );
2735 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";
2738 let starts = statement_starts("x.ts", typed);
2739 for (line, text) in [
2740 (2, "return undefined as T"),
2741 (3, "return (undefined as unknown) as T"),
2742 (4, "return <T>undefined"),
2743 (5, "return undefined!"),
2744 ] {
2745 assert_eq!(
2746 change_at(&starts, line, text, js),
2747 Some(Change::Skip),
2748 "{text}"
2749 );
2750 }
2751 let starts = statement_starts("x.ts", source);
2752 assert_eq!(
2753 change_at(&starts, 9, "let answered = false", js),
2754 Some(Change::Flip)
2755 );
2756 assert_eq!(
2758 change_at(&starts, 10, "const done = true, more = 1", js),
2759 Some(Change::ValueUndefined)
2760 );
2761 assert_eq!(
2762 change_at(&starts, 11, "return false", js),
2763 Some(Change::Flip)
2764 );
2765 let python = python_statement_starts(
2766 "def f(a):\n if a:\n return None\n ready = False\n return True\n",
2767 );
2768 let py = Language::Python;
2769 assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2770 assert_eq!(
2771 change_at(&python, 4, "ready = False", py),
2772 Some(Change::Flip)
2773 );
2774 assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2775 assert_eq!(flipped("let answered = false"), "let answered = true");
2776 assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2777 assert_eq!(flipped("ready = False"), "ready = True");
2778 assert_eq!(
2779 flipped("let ready = false // true once open"),
2780 "let ready = true // true once open"
2781 );
2782 assert_eq!(
2783 flipped("ready = False # True later"),
2784 "ready = True # True later"
2785 );
2786 assert_eq!(flipped("this.#open = false"), "this.#open = true");
2787 assert_eq!(
2788 flipped("const s = 'a // b', on = false"),
2789 "const s = 'a // b', on = true"
2790 );
2791 }
2792
2793 #[test]
2794 fn a_typescript_throw_keeps_its_types() {
2795 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";
2798 let typed = statement_starts("x.ts", source);
2799 let js = Language::JavaScript;
2800 assert_eq!(
2801 change_at(
2802 &typed,
2803 4,
2804 "throw new Error(`tools/list failed: ${result.error.message}`)",
2805 js
2806 ),
2807 Some(Change::BareError)
2808 );
2809 let plain = statement_starts("x.js", source);
2810 assert_eq!(
2811 change_at(
2812 &plain,
2813 4,
2814 "throw new Error(`tools/list failed: ${result.error.message}`)",
2815 js
2816 ),
2817 Some(Change::Skip)
2818 );
2819 }
2820
2821 #[test]
2822 fn a_deleted_statement_says_what_goes_and_what_then_runs() {
2823 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";
2824 let point = |line: usize, text: &str| InputPoint {
2825 file: "pkg/f.py".into(),
2826 line,
2827 source: text.into(),
2828 tests: vec![0],
2829 };
2830 let input = AssessmentInput {
2831 tests: vec![InputTest {
2832 id: "tests/test_f.py::test_f".into(),
2833 file: Some("tests/test_f.py".into()),
2834 name: "tests/test_f.py::test_f".into(),
2835 role: "test".into(),
2836 runner: "pytest".into(),
2837 }],
2838 points: vec![
2839 point(3, "raise ValueError(\"negative\")"),
2840 point(5, "return None"),
2841 point(6, "for item in items:\n use(item)"),
2842 point(8, "log(n)"),
2843 ],
2844 decisions: Vec::new(),
2845 };
2846 let sources = BTreeMap::from([("pkg/f.py".to_owned(), source.to_owned())]);
2847 let python = population_from(
2848 Path::new("/nonexistent"),
2849 &input,
2850 &sources,
2851 Language::Python,
2852 )
2853 .unwrap();
2854 let question = |population: &Population, line: usize| {
2855 let at = population
2856 .statements
2857 .iter()
2858 .position(|s| s.line == line)
2859 .unwrap();
2860 population.questions(at).join(" | ")
2861 };
2862 assert_eq!(
2863 question(&python, 3),
2864 "pkg/f.py:3: `raise ValueError(\"negative\")` is deleted: no error is raised here, and execution carries on with whatever follows it."
2865 );
2866 assert_eq!(
2867 question(&python, 5),
2868 "pkg/f.py:5: `return None` is deleted: the function does not return here, and execution carries on with whatever follows it."
2869 );
2870 assert_eq!(
2871 question(&python, 6),
2872 "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."
2873 );
2874 assert_eq!(
2875 question(&python, 8),
2876 "pkg/f.py:8: `log(n)` is deleted (it never runs)."
2877 );
2878 let js = AssessmentInput {
2881 tests: vec![InputTest {
2882 id: "t".into(),
2883 file: Some("test/g.test.js".into()),
2884 name: "g".into(),
2885 role: "test".into(),
2886 runner: "node:test".into(),
2887 }],
2888 points: vec![InputPoint {
2889 file: "src/g.js".into(),
2890 line: 3,
2891 source: "return;".into(),
2892 tests: vec![0],
2893 }],
2894 decisions: Vec::new(),
2895 };
2896 let sources = BTreeMap::from([(
2897 "src/g.js".to_owned(),
2898 "function g(a) {\n if (a) {\n return;\n }\n use(a);\n}\n".to_owned(),
2899 )]);
2900 let javascript = population_from(
2901 Path::new("/nonexistent"),
2902 &js,
2903 &sources,
2904 Language::JavaScript,
2905 )
2906 .unwrap();
2907 assert_eq!(
2908 question(&javascript, 3),
2909 "src/g.js:3: `return;` is deleted (it never runs)."
2910 );
2911 }
2912
2913 #[test]
2914 fn a_statement_s_text_is_what_a_test_would_quote() {
2915 assert_eq!(
2916 quoted_fragments("logger.info(` - Headers: ${describeHeaders(headers)}`)"),
2917 vec!["- Headers:"]
2918 );
2919 assert_eq!(
2920 quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2921 vec![", signal=", "Child exited: code="]
2922 );
2923 assert_eq!(
2924 quoted_fragments("log(f\"Serving {path} on {port}\")"),
2925 vec!["Serving"]
2926 );
2927 assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2929 assert!(quoted_fragments("return a + b").is_empty());
2930 }
2931
2932 #[test]
2935 fn a_python_test_deep_in_its_file_is_shown_with_its_class_and_decorators() {
2936 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2937 let root = std::env::temp_dir().join(format!(
2938 "supercov-python-test-code-{}-{}",
2939 std::process::id(),
2940 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2941 ));
2942 std::fs::create_dir_all(root.join("tests")).unwrap();
2943 let mut file =
2944 String::from("# The shop's tests.\nimport pytest\n\nfrom shop import total\n");
2945 for i in 0..10 {
2946 file.push_str(&format!("\n\ndef helper_{i}():\n return {i}\n"));
2947 }
2948 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");
2949 std::fs::write(root.join("tests/test_total.py"), &file).unwrap();
2950 let input = AssessmentInput {
2951 tests: vec![InputTest {
2952 id: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2953 file: Some("tests/test_total.py".into()),
2954 name: "tests/test_total.py::TestTotal::test_deep[1]".into(),
2955 role: "test".into(),
2956 runner: "pytest".into(),
2957 }],
2958 points: vec![InputPoint {
2959 file: "shop.py".into(),
2960 line: 1,
2961 source: "return 0".into(),
2962 tests: vec![0],
2963 }],
2964 decisions: Vec::new(),
2965 };
2966 let sources = BTreeMap::from([("shop.py".to_owned(), "return 0\n".to_owned())]);
2967 let population = population_from(&root, &input, &sources, Language::Python).unwrap();
2968 let code = population.test_code(0);
2969 std::fs::remove_dir_all(&root).unwrap();
2970 let shown = code
2971 .lines()
2972 .map(|line| {
2973 let line = line.trim_start();
2974 line.split_once(' ').map_or(line, |(_, l)| l.trim_start())
2975 })
2976 .collect::<Vec<_>>();
2977 assert_eq!(
2978 shown,
2979 [
2980 "import pytest",
2981 "",
2982 "from shop import total",
2983 "",
2984 "",
2985 "def helper_0():",
2986 "return 0",
2987 "",
2988 "",
2989 "...",
2990 "class TestTotal:",
2991 "...",
2992 "@pytest.mark.slow",
2993 "@pytest.mark.parametrize(\"n\", [1])",
2994 "def test_deep(self, n):",
2995 "assert total([n]) == n",
2996 "",
2997 ],
2998 "{code}"
2999 );
3000 }
3001
3002 #[test]
3006 fn statement_text_is_read_the_way_each_language_writes_it() {
3007 assert_eq!(condition("if (a && b) {"), "a && b");
3008 assert_eq!(condition("} else if (ready) {"), "ready");
3009 assert_eq!(condition("if (a) return;"), "if (a) return;");
3010 assert_eq!(condition("while (a) {"), "while (a) {");
3011 assert_eq!(python_condition("elif x is None:"), "x is None");
3012 assert_eq!(python_condition("if ready:"), "ready");
3013 assert_eq!(
3014 split_initializer("const x = f(1);").as_deref(),
3015 Some("const x =")
3016 );
3017 assert_eq!(
3018 split_initializer("if (a <= b && c != d) total = 1").as_deref(),
3019 Some("if (a <= b && c != d) total =")
3020 );
3021 assert_eq!(split_initializer("a === b"), None);
3022 assert_eq!(split_initializer("items.map((x) => x)"), None);
3023 assert!(is_test_call(" it('works', () => {"));
3024 assert!(is_test_call("test.only('works', () => {"));
3025 assert!(is_test_call("test ('spaced', () => {"));
3026 assert!(!is_test_call("items.push(1)"));
3027 assert!(!is_test_call("testing(1)"));
3028 assert_eq!(
3029 strip_numbers("▶ 12 total(1)\n 13 next\n ... 4 lines not shown\nplain"),
3030 "▶total(1)\n next\n...\nplain"
3031 );
3032 assert_eq!(
3033 strip_location("src/a.js:12: `x` is deleted"),
3034 "src/a.js:: `x` is deleted"
3035 );
3036 assert_eq!(strip_location("src/a.js:main: kept"), "src/a.js:main: kept");
3037 assert_eq!(strip_location("no location"), "no location");
3038 }
3039
3040 #[test]
3044 fn helper_definitions_are_read_the_way_each_language_declares_them() {
3045 let names = |language: Language, text: &str| {
3046 let lines = text.split('\n').collect::<Vec<_>>();
3047 definitions(&lines, language)
3048 .into_iter()
3049 .map(|(name, start, end)| format!("{name} {}-{}", start + 1, end + 1))
3050 .collect::<Vec<_>>()
3051 };
3052 assert_eq!(
3053 names(
3054 Language::Jvm,
3055 "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"
3056 ),
3057 ["total 2-4", "wrap 6-9", "call 10-10"]
3058 );
3059 assert_eq!(
3060 names(
3061 Language::Jvm,
3062 "object Fixtures {\n private fun price(x: Int) = x * 2\n val list = listOf(1).map { it }\n suspend fun later() {\n }\n}\n"
3063 ),
3064 ["price 2-2", "later 4-5"]
3065 );
3066 assert_eq!(
3067 names(
3068 Language::Ruby,
3069 "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"
3070 ),
3071 [
3072 "make 2-4",
3073 "order 5-5",
3074 "user 6-6",
3075 "cart 7-7",
3076 "subject 8-8",
3077 "valid? 9-11"
3078 ]
3079 );
3080 assert_eq!(
3081 names(
3082 Language::Rust,
3083 "pub fn make() -> u8 {\n 1\n}\npub(crate) const LIMIT: u8 = 2;\nstruct Point {\n x: u8,\n}\n"
3084 ),
3085 ["make 1-3", "LIMIT 4-4", "Point 5-7"]
3086 );
3087 }
3088
3089 #[test]
3090 fn a_python_test_is_found_in_the_class_its_id_names() {
3091 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";
3092 let lines = text.split('\n').collect::<Vec<_>>();
3093 let at = |name: &str| {
3094 let (from, to, body) = python_class_lines(&lines, &python_classes(name));
3095 (from..to)
3096 .find(|&i| {
3097 lines[i].trim_start().starts_with("def test_total(")
3098 && body
3099 .is_none_or(|body| lines[i].len() - lines[i].trim_start().len() == body)
3100 })
3101 .map(|i| i + 1)
3102 };
3103 assert_eq!(at("tests/test_a.py::TestCart::test_total"), Some(5));
3104 assert_eq!(
3105 at("tests/test_a.py::TestOrder::TestRefund::test_total[1]"),
3106 Some(11)
3107 );
3108 assert_eq!(at("tests/test_a.py::TestOrder::test_total"), Some(14));
3110 assert_eq!(at("tests.test_a.TestCart.test_total"), Some(5));
3111 assert_eq!(
3112 python_classes("tests/test_a.py::test_plain"),
3113 Vec::<&str>::new()
3114 );
3115 assert_eq!(
3116 python_classes("tests.test_a.test_plain"),
3117 Vec::<&str>::new()
3118 );
3119 }
3120
3121 #[test]
3122 fn a_javascript_test_is_found_in_the_group_its_name_gives() {
3123 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";
3124 let lines = text.split('\n').collect::<Vec<_>>();
3125 let line = |name: &str| js_test_line(&lines, name).map(|i| i + 1);
3126 assert_eq!(
3127 line("cart > totals"),
3128 Some(4),
3129 "not the import that names it"
3130 );
3131 assert_eq!(line("order > refund > totals"), Some(11));
3132 assert_eq!(line("totals"), Some(4));
3133 let long = format!(
3135 "describe('a', () => {{\n it('x', () => {{}});\n}});\ndescribe('b', () => {{\n{} it('x', () => {{}});\n}});\n",
3136 " const y = 1;\n".repeat(200)
3137 );
3138 let lines = long.split('\n').collect::<Vec<_>>();
3139 assert_eq!(js_test_line(&lines, "b > x").map(|i| i + 1), Some(205));
3140 }
3141
3142 #[test]
3145 fn a_module_level_statement_is_credited_to_every_test_that_uses_the_module() {
3146 let source =
3147 "const limit = 10;\nexport function total(n) {\n return n > limit ? limit : n;\n}\n";
3148 let test = |name: &str| InputTest {
3149 id: format!("t/{name}"),
3150 file: Some("test/total.test.js".into()),
3151 name: name.into(),
3152 role: "test".into(),
3153 runner: "node:test".into(),
3154 };
3155 let input = AssessmentInput {
3156 tests: vec![
3157 test("rejects without a key"),
3158 test("caps"),
3159 test("passes small"),
3160 ],
3161 points: vec![
3162 InputPoint {
3163 file: "src/total.js".into(),
3164 line: 1,
3165 source: "const limit = 10;".into(),
3166 tests: vec![0],
3167 },
3168 InputPoint {
3169 file: "src/total.js".into(),
3170 line: 3,
3171 source: "return n > limit ? limit : n;".into(),
3172 tests: vec![1, 2],
3173 },
3174 ],
3175 decisions: Vec::new(),
3176 };
3177 let sources = BTreeMap::from([("src/total.js".to_owned(), source.to_owned())]);
3178 let population = population_from(
3179 Path::new("/nonexistent"),
3180 &input,
3181 &sources,
3182 Language::JavaScript,
3183 )
3184 .unwrap();
3185 let tests_of = |line: usize| {
3186 let mut tests = population
3187 .statements
3188 .iter()
3189 .find(|s| s.line == line)
3190 .unwrap()
3191 .tests
3192 .clone();
3193 tests.sort_unstable();
3194 tests
3195 };
3196 assert_eq!(tests_of(1), [0, 1, 2], "the loader and the module's users");
3197 assert_eq!(
3198 tests_of(3),
3199 [1, 2],
3200 "code in a function keeps its own tests"
3201 );
3202 }
3203
3204 #[test]
3205 fn python_changes_follow_the_audit() {
3206 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";
3207 let starts = python_statement_starts(source);
3208 let py = Language::Python;
3209 assert_eq!(change_at(&starts, 1, "import os", py), None);
3210 assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
3211 assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
3212 assert_eq!(
3213 change_at(&starts, 6, "y = x + 1", py),
3214 Some(Change::ValueUndefined)
3215 );
3216 assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
3217 assert_eq!(
3218 change_at(&starts, 8, "return y", py),
3219 Some(Change::ReturnUndefined)
3220 );
3221 assert_eq!(change_at(&starts, 9, "z: int", py), None);
3222 assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
3223 assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
3224 assert_eq!(
3225 change_at(&starts, 15, "v is None", py),
3226 Some(Change::Invert)
3227 );
3228 assert_eq!(
3229 python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
3230 "test_b"
3231 );
3232 assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
3233 assert_eq!(python_condition("if y > 2:"), "y > 2");
3234 assert_eq!(python_condition("elif v is None:"), "v is None");
3235 }
3236
3237 #[test]
3238 fn change_texts() {
3239 assert_eq!(
3240 split_initializer("const x = a === b").as_deref(),
3241 Some("const x =")
3242 );
3243 assert_eq!(
3244 split_initializer("const f = () => 1").as_deref(),
3245 Some("const f =")
3246 );
3247 assert_eq!(condition("} else if (x > 1) {"), "x > 1");
3248 assert_eq!(
3249 condition("if (!body) return null"),
3250 "if (!body) return null"
3251 );
3252 assert!(is_test_call(" it(\"works\", () => {"));
3253 assert!(is_test_call("test.each([1])('x', () => {"));
3254 assert!(!is_test_call("items.push(1)"));
3255 }
3256
3257 #[test]
3258 fn keys_ignore_line_numbers() {
3259 assert_eq!(
3260 strip_numbers("▶ 12 return x\n ... 3 lines not shown\n 15 }"),
3261 "▶ return x\n...\n }"
3262 );
3263 assert_eq!(
3264 strip_location("src/a.ts:41: `x` is deleted."),
3265 "src/a.ts:: `x` is deleted."
3266 );
3267 }
3268}