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 MAX_QUESTIONS: usize = 150;
48pub const THRESHOLD: f64 = 0.5;
50const HEAD_LINES: usize = 10;
51const HELPER_CHARS: usize = 3000;
52const FIXTURE_CHARS: usize = 12000;
55const CONTEXT_LINES: usize = 0;
56const WINDOW_LINES: usize = 4;
57const CODE_CHARS: usize = 10000;
58const BODY_LINES: usize = 150;
59
60pub 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.";
61
62fn runtime(runner: &str) -> &'static str {
63 match runner {
64 "node:test" => {
65 "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."
66 }
67 "jest" => {
68 "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."
69 }
70 "ava" => {
71 "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."
72 }
73 "tap" => {
74 "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."
75 }
76 "mocha" => {
77 "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."
78 }
79 "pytest" => {
80 "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."
81 }
82 "rspec" => {
83 "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."
84 }
85 "minitest" => {
86 "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."
87 }
88 "test-unit" => {
89 "test-unit; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test."
90 }
91 "cucumber" => {
92 "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."
93 }
94 "go-test" => {
95 "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."
96 }
97 "rust-libtest" | "rust-nextest" | "nextest" => {
98 "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."
99 }
100 "rustdoc" => {
101 "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."
102 }
103 "junit-platform" => {
104 "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."
105 }
106 "testng" => {
107 "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."
108 }
109 "unittest" => {
110 "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."
111 }
112 _ => {
113 "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."
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub enum Change {
121 Invert,
123 ReturnUndefined,
125 ValueUndefined,
127 ExpressionUndefined,
129 Skip,
131 Flip,
135 BareError,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Language {
146 JavaScript,
147 Python,
148 Ruby,
149 Go,
150 Rust,
151 Jvm,
153}
154
155impl Language {
156 pub fn from_manifest(name: &str) -> Option<Self> {
158 match name {
159 "javascript" => Some(Self::JavaScript),
160 "python" => Some(Self::Python),
161 "ruby" => Some(Self::Ruby),
162 "go" => Some(Self::Go),
163 "rust" => Some(Self::Rust),
164 "jvm" => Some(Self::Jvm),
165 _ => None,
166 }
167 }
168 fn comment(self, line: &str) -> bool {
169 let t = line.trim_start();
170 match self {
171 Self::Python | Self::Ruby => t.starts_with('#'),
172 Self::JavaScript | Self::Go | Self::Rust | Self::Jvm => {
173 t.starts_with("//")
174 || t.starts_with("/*")
175 || t.starts_with("*/")
176 || t == "*"
177 || t.starts_with("* ")
178 || t.starts_with("*\t")
179 }
180 }
181 }
182 fn nothing(self) -> &'static str {
184 match self {
185 Self::JavaScript => "undefined",
186 Self::Python => "None",
187 Self::Ruby => "nil",
188 Self::Go => "the zero value",
189 Self::Rust => "`Default::default()`",
190 Self::Jvm => "the default value (null, or 0 or false for a primitive)",
191 }
192 }
193}
194
195fn separator(language: Language, runner: &str) -> &'static str {
197 match (language, runner) {
198 (Language::JavaScript, _) => " > ",
199 (Language::Python, _) => "::",
200 (_, "rspec") => ":",
201 (_, "minitest" | "test-unit" | "junit-platform" | "testng") => "#",
202 (_, "go-test") => "/",
203 (_, "rustdoc") => " - ",
204 _ => "::",
205 }
206}
207
208#[derive(Debug, Clone)]
209pub struct Statement {
210 pub file: String,
211 pub line: usize,
212 pub text: String,
213 pub change: Change,
214 pub replacement: Option<String>,
217 pub tests: Vec<usize>,
219}
220
221#[derive(Debug, Clone)]
222pub struct TestRef {
223 pub id: String,
224 pub file: String,
225 pub name: String,
226 pub runner: String,
227}
228
229pub struct Population {
230 pub root: PathBuf,
231 pub language: Language,
232 pub statements: Vec<Statement>,
233 pub tests: Vec<TestRef>,
234 ran: Vec<BTreeMap<String, BTreeSet<usize>>>,
236 source_files: BTreeSet<String>,
239 jvm_files: Vec<String>,
241 aliases: Vec<(String, String)>,
242 files: std::sync::Mutex<BTreeMap<String, Option<std::sync::Arc<str>>>>,
244 quotes: std::sync::Mutex<std::collections::HashMap<(String, String), bool>>,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
255pub struct AssessmentInput {
256 pub tests: Vec<InputTest>,
257 pub points: Vec<InputPoint>,
258 pub decisions: Vec<InputPoint>,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
262pub struct InputTest {
263 pub id: String,
264 pub file: Option<String>,
265 pub name: String,
266 pub role: String,
267 pub runner: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
273pub struct InputPoint {
274 pub file: String,
275 pub line: usize,
276 pub source: String,
277 pub tests: Vec<u32>,
278}
279
280pub fn assessment_input(report: &CoverageReport) -> AssessmentInput {
281 let view = &report.filters.passed;
282 let tests = view
283 .tests
284 .iter()
285 .map(|test| InputTest {
286 id: test.id.clone(),
287 file: test.file.clone(),
288 name: test.name.clone(),
289 role: test.role.clone(),
290 runner: test.provenance.runner.clone(),
291 })
292 .collect::<Vec<_>>();
293 let index = tests
294 .iter()
295 .enumerate()
296 .map(|(i, test)| (test.id.as_str(), i as u32))
297 .collect::<BTreeMap<_, _>>();
298 let indexes = |ids: &[crate::interned::Id]| {
299 ids.iter()
300 .filter_map(|id| index.get(id.as_str()).copied())
301 .collect::<Vec<_>>()
302 };
303 let points = view
304 .points
305 .iter()
306 .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
307 .map(|p| InputPoint {
308 file: p.meta.file.clone(),
309 line: p.meta.line,
310 source: p.meta.source.clone(),
311 tests: indexes(&p.tests),
312 })
313 .collect();
314 let decisions = view
315 .decisions
316 .iter()
317 .filter(|d| d.executed)
318 .map(|d| InputPoint {
319 file: d.meta.file.clone(),
320 line: d.meta.line,
321 source: d.meta.source.clone(),
322 tests: indexes(&d.tests),
323 })
324 .collect();
325 AssessmentInput {
326 tests,
327 points,
328 decisions,
329 }
330}
331
332pub fn population(
333 root: &Path,
334 report: &CoverageReport,
335 sources: &BTreeMap<String, String>,
336 language: Language,
337) -> Result<Population, String> {
338 population_from(root, &assessment_input(report), sources, language)
339}
340
341pub fn population_from(
342 root: &Path,
343 input: &AssessmentInput,
344 sources: &BTreeMap<String, String>,
345 language: Language,
346) -> Result<Population, String> {
347 let mut tests = Vec::new();
348 let jvm_files = if language == Language::Jvm {
350 project_files(root, &["java", "kt"])
351 } else {
352 Vec::new()
353 };
354 let mut index = BTreeMap::<u32, usize>::new();
356 for (position, test) in input.tests.iter().enumerate() {
357 let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
358 Some(file) => file.clone(),
359 None if language == Language::Jvm => {
360 let found = jvm::class_paths(&test.name)
361 .iter()
362 .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
363 match found {
364 Some(file) => file.clone(),
365 None => continue,
366 }
367 }
368 None => continue,
369 };
370 if test.role != "test" {
371 continue;
372 }
373 index.insert(position as u32, tests.len());
374 tests.push(TestRef {
375 id: test.id.clone(),
376 file,
377 name: test.name.clone(),
378 runner: test.runner.clone(),
379 });
380 }
381 let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
382 let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
383 let signatures = if language == Language::Rust {
384 rust_source::signatures(
385 sources
386 .iter()
387 .filter(|(file, _)| file.ends_with(".rs"))
388 .map(|(file, source)| (file.as_str(), source.as_str())),
389 )
390 } else {
391 rust_source::Signatures::default()
392 };
393 let mut statements = Vec::new();
394 let mut seen = BTreeSet::new();
395 let executed = input
399 .points
400 .iter()
401 .map(|p| (&p.file, p.line, &p.source, &p.tests, false));
402 let elifs = input
403 .decisions
404 .iter()
405 .filter(|_| language != Language::JavaScript)
406 .map(|d| (&d.file, d.line, &d.source, &d.tests, true));
407 for (file, line, text, point_tests, decision) in executed.chain(elifs) {
408 let key = (file.clone(), line);
409 if !seen.insert(key) {
410 continue; }
412 let ids = point_tests
413 .iter()
414 .filter_map(|position| index.get(position).copied())
415 .collect::<Vec<_>>();
416 if ids.is_empty() {
417 continue;
418 }
419 if language == Language::Rust && rust_source::test_path(file) {
420 continue;
421 }
422 let Some(source) = sources.get(file) else {
423 continue;
424 };
425 let lines = changes
426 .entry(file.clone())
427 .or_insert_with(|| match language {
428 Language::JavaScript => statement_starts(file, source),
429 Language::Python => python_statement_starts(source),
430 Language::Ruby => ruby::statement_starts(source),
431 Language::Go => go::statement_starts(source),
432 Language::Rust => rust_source::statement_starts(source),
433 Language::Jvm => jvm::statement_starts(file, source),
434 });
435 let Some(change) = change_at(lines, line, text, language) else {
436 continue;
437 };
438 if decision && change != Change::Invert {
439 continue;
440 }
441 let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
442 let replacement = match language {
443 Language::Rust if valued => {
444 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
445 let first = first.trim_end_matches(';');
446 replacements
447 .entry(file.clone())
448 .or_insert_with(|| rust_source::replacements(file, source, &signatures))
449 .get(&line)
450 .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
451 .map(|(_, r)| r.clone())
452 }
453 Language::Go if valued => go::rewrite(source, line, change),
458 _ => None,
459 };
460 statements.push(Statement {
461 file: file.clone(),
462 line,
463 text: text.clone(),
464 change,
465 replacement,
466 tests: ids,
467 });
468 }
469 let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
470 for s in &statements {
471 for &t in &s.tests {
472 ran[t].entry(s.file.clone()).or_default().insert(s.line);
473 }
474 }
475 let source_files = statements.iter().map(|s| s.file.clone()).collect();
476 Ok(Population {
477 root: root.to_path_buf(),
478 language,
479 statements,
480 tests,
481 ran,
482 source_files,
483 jvm_files,
484 aliases: tsconfig_aliases(root),
485 files: Default::default(),
486 quotes: Default::default(),
487 })
488}
489
490fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
493 let allocator = Allocator::default();
494 let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
495 let parsed = Parser::new(&allocator, source, source_type).parse();
496 let starts = line_starts(source);
497 struct Collector<'s> {
498 source: &'s str,
499 starts: &'s [usize],
500 typescript: bool,
501 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
502 }
503 impl<'a> Visit<'a> for Collector<'_> {
504 fn visit_statement(&mut self, statement: &JsStatement<'a>) {
505 let span = statement.span();
506 let line = line_of(self.starts, span.start as usize);
507 let text = self
508 .source
509 .get(span.start as usize..span.end as usize)
510 .unwrap_or("")
511 .to_owned();
512 self.out.entry(line).or_default().push((
513 text,
514 match statement {
515 JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
516 _ => change_of(statement),
517 },
518 ));
519 walk::walk_statement(self, statement);
520 }
521 }
522 let mut collector = Collector {
523 source,
524 starts: &starts,
525 typescript: source_type.is_typescript(),
526 out: BTreeMap::new(),
527 };
528 collector.visit_program(&parsed.program);
529 collector.out
530}
531
532fn is_undefined(expression: &Expression) -> bool {
534 match expression {
535 Expression::Identifier(identifier) => identifier.name == "undefined",
536 Expression::UnaryExpression(unary) => {
537 unary.operator == oxc_syntax::operator::UnaryOperator::Void
538 }
539 _ => false,
540 }
541}
542
543fn change_of(statement: &JsStatement) -> Option<Change> {
544 Some(match statement {
545 JsStatement::IfStatement(_) => Change::Invert,
546 JsStatement::ReturnStatement(r) => match &r.argument {
549 Some(argument) if is_undefined(argument) => Change::Skip,
550 Some(Expression::BooleanLiteral(_)) => Change::Flip,
551 Some(_) => Change::ReturnUndefined,
552 None => Change::Skip,
553 },
554 JsStatement::VariableDeclaration(d) => {
555 if let [only] = d.declarations.as_slice()
556 && matches!(only.init, Some(Expression::BooleanLiteral(_)))
557 {
558 Change::Flip
559 } else if d.declarations.iter().any(|d| d.init.is_some()) {
560 Change::ValueUndefined
561 } else {
562 return None;
563 }
564 }
565 JsStatement::ExpressionStatement(e)
568 if matches!(&e.expression, Expression::CallExpression(call)
569 if matches!(call.callee, Expression::Super(_))) =>
570 {
571 return None;
572 }
573 JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
574 Some(Declaration::VariableDeclaration(d))
575 if d.declarations.iter().any(|d| d.init.is_some()) =>
576 {
577 Change::ValueUndefined
578 }
579 _ => return None,
580 },
581 JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
582 ExportDefaultDeclarationKind::FunctionDeclaration(_)
583 | ExportDefaultDeclarationKind::ClassDeclaration(_)
584 | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
585 _ => Change::Skip,
586 },
587 JsStatement::ImportDeclaration(_)
588 | JsStatement::ExportAllDeclaration(_)
589 | JsStatement::FunctionDeclaration(_)
590 | JsStatement::ClassDeclaration(_)
591 | JsStatement::TSTypeAliasDeclaration(_)
592 | JsStatement::TSInterfaceDeclaration(_)
593 | JsStatement::TSEnumDeclaration(_)
594 | JsStatement::TSModuleDeclaration(_)
595 | JsStatement::TSGlobalDeclaration(_)
596 | JsStatement::TSImportEqualsDeclaration(_)
597 | JsStatement::TSExportAssignment(_)
598 | JsStatement::TSNamespaceExportDeclaration(_) => return None,
599 _ => Change::Skip,
600 })
601}
602
603fn change_at(
607 starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
608 line: usize,
609 text: &str,
610 language: Language,
611) -> Option<Change> {
612 let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
613 let found = starts.get(&line).and_then(|all| {
614 all.iter().find(|(t, _)| {
615 let bare = t
616 .strip_prefix("export ")
617 .map(|t| t.strip_prefix("default ").unwrap_or(t))
618 .unwrap_or(t);
619 bare.starts_with(first) || t.starts_with(first)
620 })
621 });
622 match (found, language) {
623 (Some((_, change)), _) => *change,
624 (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
625 (None, _) => None,
628 }
629}
630
631fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
636 use ruff_python_ast::{
637 Expr, Stmt,
638 visitor::{Visitor, walk_stmt},
639 };
640 use ruff_text_size::Ranged;
641 struct Collector<'s> {
642 source: &'s str,
643 starts: Vec<usize>,
644 out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
645 }
646 impl<'a> Visitor<'a> for Collector<'_> {
647 fn visit_stmt(&mut self, stmt: &'a Stmt) {
648 let change = match stmt {
649 Stmt::If(_) => Some(Change::Invert),
650 Stmt::Return(r) => Some(match r.value.as_deref() {
651 Some(Expr::NoneLiteral(_)) | None => Change::Skip,
653 Some(Expr::BooleanLiteral(_)) => Change::Flip,
654 Some(_) => Change::ReturnUndefined,
655 }),
656 Stmt::Assign(a)
657 if a.targets.len() == 1
658 && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
659 {
660 Some(Change::Flip)
661 }
662 Stmt::Assign(_) => Some(Change::ValueUndefined),
663 Stmt::AnnAssign(a)
664 if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
665 {
666 Some(Change::Flip)
667 }
668 Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
669 Stmt::Import(_)
670 | Stmt::ImportFrom(_)
671 | Stmt::FunctionDef(_)
672 | Stmt::ClassDef(_)
673 | Stmt::Pass(_)
674 | Stmt::Global(_)
675 | Stmt::Nonlocal(_)
676 | Stmt::TypeAlias(_)
677 | Stmt::IpyEscapeCommand(_) => None,
678 Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
679 _ => Some(Change::Skip),
680 };
681 let range = stmt.range();
682 let (start, end) = (range.start().to_usize(), range.end().to_usize());
683 let line = line_of(&self.starts, start);
684 let text = self.source.get(start..end).unwrap_or("").to_owned();
685 self.out.entry(line).or_default().push((text, change));
686 if let Stmt::If(s) = stmt {
688 for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
690 let start = test.range().start().to_usize();
691 let text = self.source.get(start..test.range().end().to_usize());
692 self.out
693 .entry(line_of(&self.starts, start))
694 .or_default()
695 .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
696 }
697 }
698 walk_stmt(self, stmt);
699 }
700 }
701 let Ok(parsed) = ruff_python_parser::parse_module(source) else {
702 return BTreeMap::new();
703 };
704 let mut collector = Collector {
705 source,
706 starts: line_starts(source),
707 out: BTreeMap::new(),
708 };
709 for stmt in &parsed.syntax().body {
710 collector.visit_stmt(stmt);
711 }
712 collector.out
713}
714
715fn prefix(text: &str, chars: usize) -> &str {
716 match text.char_indices().nth(chars) {
717 Some((i, _)) => &text[..i],
718 None => text,
719 }
720}
721
722fn line_starts(source: &str) -> Vec<usize> {
723 std::iter::once(0)
724 .chain(source.match_indices('\n').map(|(i, _)| i + 1))
725 .collect()
726}
727
728fn line_of(starts: &[usize], offset: usize) -> usize {
729 starts.partition_point(|&s| s <= offset)
730}
731
732fn numbered(lines: &[&str]) -> String {
733 lines
734 .iter()
735 .enumerate()
736 .map(|(i, l)| format!("{:5} {l}", i + 1))
737 .collect::<Vec<_>>()
738 .join("\n")
739}
740
741fn quoted_fragments(text: &str) -> Vec<String> {
746 let mut out = Vec::new();
747 let mut chars = text.char_indices().peekable();
748 while let Some((start, quote)) = chars.next() {
749 if !matches!(quote, '\'' | '"' | '`') {
750 continue;
751 }
752 let mut end = None;
753 while let Some((at, c)) = chars.next() {
754 if c == '\\' {
755 chars.next();
756 } else if c == quote {
757 end = Some(at);
758 break;
759 }
760 }
761 let Some(end) = end else { break };
762 let body = &text[start + quote.len_utf8()..end];
763 let mut pieces = vec![String::new()];
766 let mut depth = 0usize;
767 for c in body.chars() {
768 match c {
769 '{' => {
770 depth += 1;
771 pieces.push(String::new());
772 }
773 '}' if depth > 0 => depth -= 1,
774 '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
775 _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
776 _ => {}
777 }
778 }
779 for piece in pieces {
780 let piece = piece.trim();
781 if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
782 out.push(piece.to_owned());
783 }
784 }
785 }
786 out.sort();
787 out.dedup();
788 out
789}
790
791fn stem(path: &str) -> String {
792 Path::new(path)
793 .file_name()
794 .and_then(|n| n.to_str())
795 .unwrap_or("")
796 .split('.')
797 .next()
798 .unwrap_or("")
799 .to_lowercase()
800}
801
802fn describe(name: &str, separator: &str) -> String {
803 let parts = name.split(separator).collect::<Vec<_>>();
804 parts[..parts.len().saturating_sub(1)].join(separator)
805}
806
807impl Population {
808 pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
811 {
812 let mut cache = self
813 .files
814 .lock()
815 .expect("the file cache is only read and filled");
816 for (file, text) in files {
817 cache.insert(file, Some(text.into()));
818 }
819 }
820 self
821 }
822
823 pub fn files_read(&self) -> BTreeMap<String, String> {
825 self.files
826 .lock()
827 .expect("the file cache is only read and filled")
828 .iter()
829 .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
830 .collect()
831 }
832
833 fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
834 let mut files = self
835 .files
836 .lock()
837 .expect("the file cache is only read and filled");
838 files
839 .entry(file.to_owned())
840 .or_insert_with(|| {
841 std::fs::read_to_string(self.root.join(file))
842 .ok()
843 .map(Into::into)
844 })
845 .clone()
846 }
847
848 fn quotes(&self, test: usize, fragments: &[String]) -> bool {
850 let file = &self.tests[test].file;
851 fragments.iter().any(|fragment| {
852 let key = (file.clone(), fragment.clone());
853 if let Some(&known) = self
854 .quotes
855 .lock()
856 .expect("the quote cache is only read and filled")
857 .get(&key)
858 {
859 return known;
860 }
861 let found = self
862 .read(file)
863 .is_some_and(|text| text.contains(fragment.as_str()));
864 self.quotes
865 .lock()
866 .expect("the quote cache is only read and filled")
867 .insert(key, found);
868 found
869 })
870 }
871
872 pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
879 let s = &self.statements[statement];
880 let base = stem(&s.file);
881 let key = |&t: &usize| {
882 let test = &self.tests[t];
883 (
884 !Path::new(&test.file)
885 .file_name()
886 .and_then(|n| n.to_str())
887 .unwrap_or("")
888 .to_lowercase()
889 .contains(&base),
890 test.file.clone(),
891 test.name.clone(),
892 )
893 };
894 let mut all = s.tests.clone();
895 all.sort_by_key(key);
896 if all.len() <= k {
897 return all;
898 }
899 let mut groups = Vec::<((String, String), Vec<usize>)>::new();
900 for &t in &all {
901 let g = (
902 self.tests[t].file.clone(),
903 describe(
904 &self.tests[t].name,
905 separator(self.language, &self.tests[t].runner),
906 ),
907 );
908 match groups.iter_mut().find(|(k, _)| *k == g) {
909 Some((_, v)) => v.push(t),
910 None => groups.push((g, vec![t])),
911 }
912 }
913 let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
914 for ((file, _), members) in groups {
915 match by_file.iter_mut().find(|(f, _)| *f == file) {
916 Some((_, v)) => v.push(members),
917 None => by_file.push((file, vec![members])),
918 }
919 }
920 by_file.sort_by_key(|(f, _)| {
921 (
922 !Path::new(f)
923 .file_name()
924 .and_then(|n| n.to_str())
925 .unwrap_or("")
926 .to_lowercase()
927 .contains(&base),
928 f.clone(),
929 )
930 });
931 let mut picked = Vec::new();
932 let fragments = quoted_fragments(&s.text);
933 if !fragments.is_empty() {
934 let quoting = all
935 .iter()
936 .copied()
937 .filter(|&t| self.quotes(t, &fragments))
938 .collect::<Vec<_>>();
939 let mut files = BTreeSet::new();
940 let (first, rest): (Vec<usize>, Vec<usize>) = quoting
941 .iter()
942 .partition(|&&t| files.insert(self.tests[t].file.clone()));
943 for t in first.into_iter().chain(rest) {
944 if picked.len() < k {
945 picked.push(t);
946 }
947 }
948 }
949 let mut depth = 0;
950 while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
951 for (_, g) in &by_file {
952 if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
953 picked.push(g[depth][0]);
954 }
955 }
956 depth += 1;
957 }
958 for t in all {
959 if picked.len() >= k {
960 break;
961 }
962 if !picked.contains(&t) {
963 picked.push(t);
964 }
965 }
966 picked
967 }
968
969 pub fn test_code(&self, test: usize) -> String {
972 match self.language {
973 Language::Python => return self.python_test_code(test),
974 Language::JavaScript => {}
975 _ => return self.located_test_code(test),
976 }
977 let t = &self.tests[test];
978 let text = self.read(&t.file).unwrap_or_default();
979 let lines = text.split('\n').collect::<Vec<_>>();
980 let title = t.name.rsplit(" > ").next().unwrap_or(&t.name);
981 let needle = prefix(title, 40);
982 let start = (!needle.is_empty())
983 .then(|| lines.iter().position(|l| l.contains(needle)))
984 .flatten();
985 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
986 let head = (0..lines.len().min(HEAD_LINES))
987 .filter(|&i| !self.language.comment(lines[i]))
988 .map(fmt)
989 .collect::<Vec<_>>();
990 let Some(start) = start else {
991 let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
992 return head
993 .into_iter()
994 .chain(std::iter::once(" ...".to_owned()))
995 .chain(rest)
996 .collect::<Vec<_>>()
997 .join("\n");
998 };
999 let indent = lines[start].len() - lines[start].trim_start().len();
1000 let mut body = Vec::new();
1001 for (i, l) in lines
1002 .iter()
1003 .copied()
1004 .enumerate()
1005 .take(start + BODY_LINES)
1006 .skip(start)
1007 {
1008 if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1009 break;
1010 }
1011 if !self.language.comment(l) {
1012 body.push(fmt(i));
1013 }
1014 }
1015 let mut out = head;
1016 if start > HEAD_LINES {
1017 out.push(" ...".into());
1018 }
1019 out.extend(body);
1020 out.join("\n")
1021 }
1022
1023 fn located_test_code(&self, test: usize) -> String {
1026 let t = &self.tests[test];
1027 let text = self.read(&t.file).unwrap_or_default();
1028 let lines = text.split('\n').collect::<Vec<_>>();
1029 let located = match self.language {
1030 Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1031 Language::Go => go::locate(&lines, &t.name),
1032 Language::Rust => rust_source::locate(&lines, &t.name),
1033 _ => jvm::locate(&lines, &t.name),
1034 };
1035 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1036 let mut open = false;
1038 let commented = lines
1039 .iter()
1040 .map(|l| {
1041 let t = l.trim();
1042 let was = open;
1043 if self.language != Language::Ruby {
1044 if !open && t.starts_with("/*") {
1045 open = true;
1046 }
1047 if open && t.contains("*/") {
1048 open = false;
1049 return true;
1050 }
1051 }
1052 was || open || self.language.comment(l)
1053 })
1054 .collect::<Vec<_>>();
1055 let head = (0..lines.len())
1057 .filter(|&i| !commented[i])
1058 .take(HEAD_LINES)
1059 .collect::<Vec<_>>();
1060 let head_end = head.last().map_or(0, |&i| i + 1);
1061 let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1062 let Some(located) = located else {
1063 out.push(" ...".into());
1064 out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1065 return out.join("\n");
1066 };
1067 let end = located
1068 .end
1069 .min(located.start + BODY_LINES)
1070 .min(lines.len().saturating_sub(1));
1071 let mut last = head_end;
1072 let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1073 shown.extend(located.start..=end);
1074 shown.extend(used_definitions(&lines, self.language, located.start, end));
1077 for i in shown {
1078 if i < last || (commented[i] && self.language != Language::Rust) {
1080 continue;
1081 }
1082 if i > last {
1083 out.push(" ...".into());
1084 }
1085 out.push(fmt(i));
1086 last = i + 1;
1087 }
1088 out.join("\n")
1089 }
1090
1091 fn python_test_code(&self, test: usize) -> String {
1095 let t = &self.tests[test];
1096 let text = self.read(&t.file).unwrap_or_default();
1097 let lines = text.split('\n').collect::<Vec<_>>();
1098 let title = python_title(&t.name);
1099 let indent_of = |l: &str| l.len() - l.trim_start().len();
1100 let start = (!title.is_empty())
1101 .then(|| {
1102 lines.iter().position(|l| {
1103 let t = l.trim_start();
1104 t.starts_with(&format!("def {title}("))
1105 || t.starts_with(&format!("async def {title}("))
1106 })
1107 })
1108 .flatten();
1109 let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1110 let mut out = (0..lines.len().min(HEAD_LINES))
1111 .filter(|&i| !self.language.comment(lines[i]))
1112 .map(fmt)
1113 .collect::<Vec<_>>();
1114 let Some(start) = start else {
1115 out.push(" ...".into());
1116 out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1117 return out.join("\n");
1118 };
1119 let indent = indent_of(lines[start]);
1120 let mut first = start;
1121 while first > 0
1122 && lines[first - 1].trim_start().starts_with('@')
1123 && indent_of(lines[first - 1]) == indent
1124 {
1125 first -= 1;
1126 }
1127 let class = (indent > 0)
1128 .then(|| {
1129 (0..first).rev().find(|&i| {
1130 lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1131 })
1132 })
1133 .flatten();
1134 for i in class.into_iter().chain(first..start) {
1135 if i >= HEAD_LINES {
1136 if out.last().is_some_and(|l| l != " ...") {
1137 out.push(" ...".into());
1138 }
1139 out.push(fmt(i));
1140 }
1141 }
1142 if start >= HEAD_LINES
1143 && out.last().is_some_and(|l| l != " ...")
1144 && first == start
1145 && class.is_none()
1146 {
1147 out.push(" ...".into());
1148 }
1149 for (i, &l) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1150 if i > start && !l.trim().is_empty() && indent_of(l) <= indent {
1151 break;
1152 }
1153 if !self.language.comment(l) && i >= HEAD_LINES {
1154 out.push(fmt(i));
1155 }
1156 }
1157 out.join("\n")
1158 }
1159
1160 pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1163 let cap = HELPER_CHARS.min(1500 + 400 * questions);
1164 let t = &self.tests[test];
1165 let text = self.read(&t.file).unwrap_or_default();
1166 let mut out = BTreeMap::new();
1167 let mut size = 0;
1168 let files = match self.language {
1169 Language::JavaScript => import_specifiers(&text)
1170 .iter()
1171 .filter_map(|spec| self.resolve(&t.file, spec))
1172 .collect::<Vec<_>>(),
1173 Language::Python => self.python_helpers(&t.file, &text),
1174 Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1175 Language::Go => go::helpers(&self.root, &t.file, &text),
1176 Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1177 Language::Jvm => jvm::imports(&text)
1178 .iter()
1179 .filter_map(|import| {
1180 jvm::class_paths(&import.replace('/', "."))
1181 .iter()
1182 .find_map(|suffix| {
1183 self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1184 })
1185 .cloned()
1186 })
1187 .collect(),
1188 };
1189 for file in files {
1190 if out.contains_key(&file) {
1191 continue;
1192 }
1193 if self.source_files.contains(&file) || file.contains("node_modules") {
1194 continue;
1195 }
1196 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1197 if size + body.len() > cap {
1198 let keep = cap.saturating_sub(size);
1199 let cut = body
1200 .char_indices()
1201 .map(|(i, _)| i)
1202 .take_while(|&i| i <= keep)
1203 .last()
1204 .unwrap_or(0);
1205 body = format!("{}\n... (cut)", &body[..cut]);
1206 }
1207 size += body.len();
1208 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1209 if size >= cap {
1210 break;
1211 }
1212 }
1213 if self.language == Language::Ruby {
1214 let mut used = 0;
1215 for file in ruby::support_files(&self.root, &text) {
1216 if out.contains_key(&file) || used >= FIXTURE_CHARS {
1217 continue;
1218 }
1219 let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1220 if used + body.len() > FIXTURE_CHARS {
1221 let keep = FIXTURE_CHARS - used;
1222 let cut = body
1223 .char_indices()
1224 .map(|(i, _)| i)
1225 .take_while(|&i| i <= keep)
1226 .last()
1227 .unwrap_or(0);
1228 body = format!("{}\n... (cut)", &body[..cut]);
1229 }
1230 used += body.len();
1231 out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1232 }
1233 }
1234 out
1235 }
1236
1237 fn python_helpers(&self, from: &str, text: &str) -> Vec<String> {
1241 let mut out = Vec::new();
1242 let mut dir = Path::new(from).parent();
1243 while let Some(d) = dir {
1244 let conftest = d.join("conftest.py").to_string_lossy().replace('\\', "/");
1245 if self.root.join(&conftest).is_file() {
1246 out.push(conftest);
1247 }
1248 dir = d.parent();
1249 }
1250 let here = Path::new(from).parent().unwrap_or(Path::new(""));
1251 for module in python_imports(text) {
1252 let dots = module.chars().take_while(|c| *c == '.').count();
1253 let rest = module[dots..].replace('.', "/");
1254 let bases = if dots > 0 {
1255 let mut base = here.to_path_buf();
1256 for _ in 1..dots {
1257 base.pop();
1258 }
1259 vec![base]
1260 } else {
1261 vec![PathBuf::new(), here.to_path_buf(), PathBuf::from("src")]
1262 };
1263 for base in bases {
1264 let path = base.join(&rest);
1265 let path = path.to_string_lossy().replace('\\', "/");
1266 let found = [format!("{path}.py"), format!("{path}/__init__.py")]
1267 .into_iter()
1268 .find(|c| !c.starts_with('/') && self.root.join(c).is_file());
1269 if let Some(found) = found {
1270 out.push(found);
1271 break;
1272 }
1273 }
1274 }
1275 out
1276 }
1277
1278 fn resolve(&self, from: &str, spec: &str) -> Option<String> {
1279 let base = if let Some((alias, target)) = self
1280 .aliases
1281 .iter()
1282 .find(|(a, _)| spec.starts_with(a.as_str()))
1283 {
1284 PathBuf::from(format!("{target}{}", &spec[alias.len()..]))
1285 } else if spec.starts_with('.') {
1286 Path::new(from).parent().unwrap_or(Path::new("")).join(spec)
1287 } else {
1288 return None;
1289 };
1290 let s = normalize(&base);
1291 let candidates = [
1292 s.clone(),
1293 format!("{s}.ts"),
1294 format!("{s}.tsx"),
1295 format!("{s}.js"),
1296 format!("{s}.mjs"),
1297 s.replace(".js", ".ts"),
1298 format!("{s}/index.ts"),
1299 format!("{s}/index.tsx"),
1300 format!("{s}/index.js"),
1301 ];
1302 candidates.into_iter().find(|c| self.root.join(c).is_file())
1303 }
1304
1305 pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1312 let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1313 let ran = &self.ran[test];
1314 let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1315 for &a in asked {
1316 want.entry(self.statements[a].file.clone())
1317 .or_default()
1318 .insert(self.statements[a].line);
1319 }
1320 let mut files = want.keys().cloned().collect::<Vec<_>>();
1321 let mut others = ran
1322 .iter()
1323 .filter(|(f, _)| !want.contains_key(*f))
1324 .collect::<Vec<_>>();
1325 others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1326 files.extend(others.into_iter().map(|(f, _)| f.clone()));
1327 let empty = BTreeSet::new();
1328 let mut out = BTreeMap::new();
1329 let mut total = 0;
1330 for f in files {
1331 let Some(source) = self.read(&f) else {
1332 continue;
1333 };
1334 let text = source.split('\n').collect::<Vec<_>>();
1335 let r = ran.get(&f).unwrap_or(&empty);
1336 let code = (1..=text.len())
1337 .filter(|&i| !self.language.comment(text[i - 1]))
1338 .collect::<Vec<_>>();
1339 let pos = code
1340 .iter()
1341 .enumerate()
1342 .map(|(n, &i)| (i, n))
1343 .collect::<BTreeMap<_, _>>();
1344 let around = |l: usize, w: usize| -> Vec<usize> {
1345 match pos.get(&l) {
1346 Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1347 None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1348 }
1349 };
1350 let mut show = BTreeSet::new();
1351 for &l in r {
1352 show.extend(around(l, CONTEXT_LINES));
1353 }
1354 for &l in want.get(&f).unwrap_or(&empty) {
1355 show.extend(around(l, WINDOW_LINES));
1356 }
1357 let mut lines = Vec::new();
1358 let mut last = 0;
1359 for i in show {
1360 if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1361 continue;
1362 }
1363 if i != last + 1 {
1364 lines.push(format!(" ... {} lines not shown", i - last - 1));
1365 }
1366 lines.push(format!(
1367 "{}{:5} {}",
1368 if r.contains(&i) { '▶' } else { ' ' },
1369 i,
1370 text[i - 1]
1371 ));
1372 last = i;
1373 }
1374 let body = lines.join("\n");
1375 if !want.contains_key(&f) && total + body.len() > budget {
1376 continue;
1377 }
1378 total += body.len();
1379 out.insert(f, body);
1380 }
1381 out
1382 }
1383
1384 pub fn questions(&self, statement: usize) -> Vec<String> {
1386 let s = &self.statements[statement];
1387 let line = s.text.lines().next().unwrap_or("").trim();
1388 let changes = match (s.change, &s.replacement) {
1389 (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1390 if self.language == Language::Rust =>
1391 {
1392 vec![rust_question(line, s.change, r)]
1393 }
1394 (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1397 vec![format!(
1398 "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1399 )]
1400 }
1401 (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1402 vec![format!(
1403 "`{line}` becomes `{r}` (the original value is still computed first)"
1404 )]
1405 }
1406 _ => self.default_questions(s, line),
1407 };
1408 changes
1409 .into_iter()
1410 .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1411 .collect()
1412 }
1413
1414 fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1415 match s.change {
1416 Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1419 "`{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)"
1420 )],
1421 Change::ReturnUndefined
1424 if self.language == Language::Jvm
1425 && s.text.contains("return")
1426 && ["when", "switch", "try"].iter().any(|k| {
1427 line.strip_prefix(k)
1428 .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1429 }) =>
1430 {
1431 vec![format!(
1432 "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1433 self.language.nothing()
1434 )]
1435 }
1436 Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1437 "`{line}` returns {} instead (its expression still runs)",
1438 self.language.nothing()
1439 )],
1440 Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1441 let nothing = match self.language {
1442 Language::Rust
1443 if ["Ok(", "Err(", "return Ok(", "return Err("]
1444 .iter()
1445 .any(|p| line.starts_with(p)) =>
1446 {
1447 "`Ok(Default::default())`"
1448 }
1449 other => other.nothing(),
1450 };
1451 vec![if line.starts_with("return") {
1452 format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1453 } else {
1454 format!(
1457 "`{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}"
1458 )
1459 }]
1460 }
1461 Change::ValueUndefined if self.language == Language::Go => vec![format!(
1462 "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1463 )],
1464 Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1465 "`{line}`: after it runs, the variable it assigns holds {} instead",
1466 self.language.nothing()
1467 )],
1468 Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1469 let nothing = self.language.nothing();
1470 vec![match split_initializer(line) {
1471 Some(head) => format!(
1472 "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1473 ),
1474 None => format!("`{line}`: the assigned value becomes {nothing}"),
1475 }]
1476 }
1477 Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1478 "`{line}` becomes `return None` (the expression is not evaluated)"
1479 )],
1480 Change::ReturnUndefined => vec![format!(
1481 "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1482 )],
1483 Change::ValueUndefined if self.language == Language::Python => {
1484 vec![match split_initializer(line) {
1485 Some(head) => {
1486 format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1487 }
1488 None => format!("`{line}`: the assigned value becomes None"),
1489 }]
1490 }
1491 Change::ValueUndefined => vec![match split_initializer(line) {
1492 Some(head) => format!(
1493 "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1494 ),
1495 None => format!("`{line}`: the declared value becomes undefined"),
1496 }],
1497 Change::Skip => {
1498 let word = line.trim_end_matches(';').trim();
1499 vec![match word {
1500 "break" => format!(
1501 "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1502 ),
1503 "continue" | "next" => format!(
1504 "`{line}` is deleted: the rest of the loop body runs in this iteration"
1505 ),
1506 _ => format!("`{line}` is deleted (it never runs)"),
1507 }]
1508 }
1509 Change::ExpressionUndefined => vec![format!(
1510 "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1511 )],
1512 Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1513 Change::BareError => vec![format!(
1514 "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1515 )],
1516 Change::Invert => {
1517 let c = match self.language {
1518 Language::Python => python_condition(line),
1519 Language::JavaScript => condition(line),
1520 Language::Ruby => ruby::condition(line),
1521 Language::Go => go::condition(line),
1522 Language::Rust => rust_source::condition(line),
1523 Language::Jvm => jvm::condition(line),
1524 };
1525 if c.starts_with("let ") {
1528 return vec![format!(
1529 "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1530 )];
1531 }
1532 vec![
1533 format!(
1534 "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1535 ),
1536 format!(
1537 "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1538 ),
1539 ]
1540 }
1541 }
1542 }
1543
1544 pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1546 let t = &self.tests[test];
1547 let mut questions = serde_json::Map::new();
1548 for (n, &a) in asked.iter().enumerate() {
1549 for (j, q) in self.questions(a).into_iter().enumerate() {
1550 questions.insert(
1551 format!("q{n}_{j}"),
1552 json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1553 );
1554 }
1555 }
1556 json!({"model": model, "state": {
1557 "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1558 "test": {"file": t.file, "name": t.name},
1559 "test_code": self.test_code(test), "helpers": self.helpers(test, asked.len()),
1560 "code_run": self.code_run(test, asked)}, "questions": questions})
1561 }
1562
1563 pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1568 let t = &self.tests[test];
1569 let parts = json!([
1570 model,
1571 salt,
1572 INSTRUCTIONS,
1573 runtime(&t.runner),
1574 true,
1575 &t.file,
1576 &t.name,
1577 strip_numbers(&self.test_code(test)),
1578 self.helpers(test, 1)
1579 .into_iter()
1580 .map(|(f, b)| (f, strip_numbers(&b)))
1581 .collect::<BTreeMap<_, _>>(),
1582 self.code_run(test, &[statement])
1583 .into_iter()
1584 .map(|(f, b)| (f, strip_numbers(&b)))
1585 .collect::<BTreeMap<_, _>>(),
1586 self.questions(statement)
1587 .iter()
1588 .map(|q| strip_location(q))
1589 .collect::<Vec<_>>(),
1590 ]);
1591 format!(
1592 "{:x}",
1593 Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1594 )
1595 }
1596}
1597
1598fn flipped(line: &str) -> String {
1601 let cut = comment_start(line);
1604 let (code, comment) = line.split_at(cut);
1605 format!("{}{comment}", flipped_code(code))
1606}
1607
1608fn comment_start(line: &str) -> usize {
1611 let bytes = line.as_bytes();
1612 let mut quote = None;
1613 let mut i = 0;
1614 while i < bytes.len() {
1615 let c = bytes[i];
1616 match quote {
1617 Some(_) if c == b'\\' => i += 1,
1618 Some(q) if c == q => quote = None,
1619 Some(_) => {}
1620 None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
1621 None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
1622 None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
1623 None => {}
1624 }
1625 i += 1;
1626 }
1627 line.len()
1628}
1629
1630fn flipped_code(line: &str) -> String {
1631 let words = [
1632 ("true", "false"),
1633 ("false", "true"),
1634 ("True", "False"),
1635 ("False", "True"),
1636 ];
1637 let found = words
1638 .iter()
1639 .filter_map(|(from, to)| {
1640 line.match_indices(from)
1641 .filter(|(at, _)| {
1642 let before = line[..*at].chars().next_back();
1643 let after = line[at + from.len()..].chars().next();
1644 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1645 !word(before) && !word(after)
1646 })
1647 .last()
1648 .map(|(at, _)| (at, *from, *to))
1649 })
1650 .max_by_key(|(at, _, _)| *at);
1651 match found {
1652 Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
1653 None => line.to_owned(),
1654 }
1655}
1656
1657fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1659 let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1660 match change {
1661 Change::ReturnUndefined if line.starts_with("return") => {
1662 format!("`{line}` becomes `return {repl};`")
1663 }
1664 Change::ReturnUndefined => {
1665 format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1666 }
1667 _ => match split_initializer(line) {
1668 Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1669 None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1670 },
1671 }
1672}
1673
1674pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
1676 use std::io::Write;
1677 let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
1678 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1679 encoder.write_all(&bytes).map_err(|e| e.to_string())?;
1680 let compressed = encoder.finish().map_err(|e| e.to_string())?;
1681 let temp = path.with_extension("tmp");
1682 std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
1683 std::fs::rename(&temp, path).map_err(|e| e.to_string())
1684}
1685
1686pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
1688 use std::io::Read;
1689 let compressed = std::fs::read(path).ok()?;
1690 let mut text = Vec::new();
1691 flate2::read::GzDecoder::new(compressed.as_slice())
1692 .read_to_end(&mut text)
1693 .ok()?;
1694 serde_json::from_slice(&text).ok()
1695}
1696
1697struct Located {
1700 context: Vec<usize>,
1701 start: usize,
1702 end: usize,
1703}
1704
1705fn indent(line: &str) -> usize {
1706 line.len() - line.trim_start().len()
1707}
1708
1709fn brace_end(lines: &[&str], start: usize) -> usize {
1712 let mut depth = 0i64;
1713 let mut opened = false;
1714 for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1715 let mut chars = line.chars().peekable();
1716 let mut quote: Option<char> = None;
1717 while let Some(c) = chars.next() {
1718 match (quote, c) {
1719 (Some(_), '\\') => {
1720 chars.next();
1721 }
1722 (Some(q), c) if c == q => quote = None,
1723 (Some(_), _) => {}
1724 (None, '"' | '`') => quote = Some(c),
1725 (None, '/') if chars.peek() == Some(&'/') => break,
1726 (None, '{') => {
1727 depth += 1;
1728 opened = true;
1729 }
1730 (None, '}') => depth -= 1,
1731 _ => {}
1732 }
1733 }
1734 if opened && depth <= 0 {
1735 return i;
1736 }
1737 }
1738 (start + BODY_LINES).min(lines.len()).saturating_sub(1)
1739}
1740
1741fn keyword_end(lines: &[&str], start: usize) -> usize {
1743 let first = lines[start].trim_end();
1744 if first.ends_with(" end") || first.ends_with('}') {
1745 return start;
1746 }
1747 let depth = indent(lines[start]);
1748 (start + 1..lines.len().min(start + BODY_LINES))
1749 .find(|&i| {
1750 let t = lines[i].trim_start();
1751 indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
1752 })
1753 .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
1754}
1755
1756const DEFINITION_LINES: usize = 120;
1757
1758fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
1761 let words = |range: std::ops::RangeInclusive<usize>| {
1762 let mut out = BTreeSet::new();
1763 for i in range {
1764 let line = lines.get(i).copied().unwrap_or("");
1765 out.extend(
1766 line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1767 .filter(|w| !w.is_empty())
1768 .map(str::to_owned),
1769 );
1770 }
1771 out
1772 };
1773 let definitions = definitions(lines, language);
1774 let mut wanted = words(start..=end);
1775 let mut picked = BTreeSet::new();
1776 let mut budget = DEFINITION_LINES;
1777 for _ in 0..2 {
1778 let mut next = BTreeSet::new();
1779 for (name, a, b) in &definitions {
1780 let (a, b) = (*a, *b);
1781 if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
1782 continue;
1783 }
1784 let size = b - a + 1;
1785 if size > budget {
1786 continue;
1787 }
1788 budget -= size;
1789 picked.insert(a);
1790 next.extend(words(a..=b));
1791 picked.extend(a..=b);
1792 }
1793 wanted = next;
1794 }
1795 picked.into_iter().collect()
1796}
1797
1798fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
1801 let ident = |s: &str| {
1802 s.chars()
1803 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
1804 .collect::<String>()
1805 };
1806 let mut out = Vec::new();
1807 for (i, line) in lines.iter().enumerate() {
1808 let t = line.trim_start();
1809 let name = match language {
1810 Language::Go => t
1811 .strip_prefix("func ")
1812 .map(|r| match r.strip_prefix('(') {
1813 Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
1814 None => r,
1815 })
1816 .or_else(|| t.strip_prefix("var "))
1817 .or_else(|| t.strip_prefix("type "))
1818 .filter(|_| indent(line) == 0)
1819 .map(ident),
1820 Language::Rust => {
1821 let r = t.strip_prefix("pub ").unwrap_or(t);
1822 let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
1823 ["fn ", "const ", "static ", "struct ", "enum "]
1824 .iter()
1825 .find_map(|k| r.strip_prefix(k))
1826 .map(ident)
1827 }
1828 Language::Ruby => t
1829 .strip_prefix("def ")
1830 .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
1831 .or_else(|| {
1832 ["let(:", "let!(:", "subject(:"]
1833 .iter()
1834 .find_map(|k| t.strip_prefix(k))
1835 .map(ident)
1836 })
1837 .or_else(|| {
1838 (t.starts_with("subject ") || t.starts_with("subject{"))
1839 .then(|| "subject".into())
1840 }),
1841 Language::Jvm => {
1842 if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
1843 Some(ident(r.1.trim_start_matches('`')))
1844 } else if t.contains('(')
1845 && !t.ends_with(';')
1846 && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
1847 && ![
1848 "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
1849 "throw", "assert", "super", "this",
1850 ]
1851 .iter()
1852 .any(|k| t.starts_with(k))
1853 {
1854 let head = t.split('(').next().unwrap_or("");
1855 let mut parts = head.split_whitespace().rev();
1856 let name = parts.next().unwrap_or("");
1857 (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
1858 } else {
1859 None
1860 }
1861 }
1862 _ => None,
1863 };
1864 let Some(name) = name.filter(|n| !n.is_empty()) else {
1865 continue;
1866 };
1867 let end = match language {
1868 Language::Ruby => keyword_end(lines, i),
1869 _ if line.contains('{')
1870 || lines
1871 .get(i + 1)
1872 .is_some_and(|n| n.trim_start().starts_with('{')) =>
1873 {
1874 brace_end(lines, i)
1875 }
1876 _ => i,
1877 };
1878 out.push((name, i, end));
1879 }
1880 out
1881}
1882
1883fn ends_with_path(path: &str, suffix: &str) -> bool {
1885 path.strip_suffix(suffix)
1886 .is_some_and(|head| head.is_empty() || head.ends_with('/'))
1887}
1888
1889fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
1892 let mut out = ignore::WalkBuilder::new(root)
1893 .build()
1894 .filter_map(Result::ok)
1895 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
1896 .filter(|e| {
1897 e.path()
1898 .extension()
1899 .and_then(|x| x.to_str())
1900 .is_some_and(|x| extensions.contains(&x))
1901 })
1902 .filter_map(|e| {
1903 e.path()
1904 .strip_prefix(root)
1905 .ok()
1906 .map(|p| p.to_string_lossy().replace('\\', "/"))
1907 })
1908 .filter(|p| !p.starts_with(".supercov/"))
1909 .collect::<Vec<_>>();
1910 out.sort();
1911 out
1912}
1913
1914fn is_test_call(line: &str) -> bool {
1915 let t = line.trim_start();
1916 let rest = if let Some(r) = t.strip_prefix("it") {
1917 r
1918 } else if let Some(r) = t.strip_prefix("test") {
1919 r
1920 } else {
1921 return false;
1922 };
1923 let rest = if let Some(r) = rest.strip_prefix('.') {
1924 r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
1925 } else {
1926 rest
1927 };
1928 rest.trim_start().starts_with('(')
1929}
1930
1931fn split_initializer(line: &str) -> Option<String> {
1933 let b = line.as_bytes();
1934 for i in 1..b.len() {
1935 if b[i] == b'='
1936 && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
1937 && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
1938 {
1939 return Some(line[..=i].to_owned());
1940 }
1941 }
1942 None
1943}
1944
1945fn python_title(name: &str) -> &str {
1949 let last = name.rsplit("::").next().unwrap_or(name);
1950 let last = last.split('[').next().unwrap_or(last);
1951 last.rsplit('.').next().unwrap_or(last)
1952}
1953
1954fn python_condition(line: &str) -> String {
1955 let t = line.trim();
1956 let t = t
1957 .strip_prefix("elif")
1958 .or_else(|| t.strip_prefix("if"))
1959 .map(str::trim_start)
1960 .unwrap_or(t);
1961 t.strip_suffix(':')
1962 .map(str::trim_end)
1963 .unwrap_or(t)
1964 .to_owned()
1965}
1966
1967fn python_imports(text: &str) -> Vec<String> {
1970 let mut out = Vec::new();
1971 for line in text.split('\n') {
1972 let t = line.trim_start();
1973 if let Some(rest) = t.strip_prefix("from ") {
1974 let Some((module, names)) = rest.split_once(" import ") else {
1975 continue;
1976 };
1977 let module = module.trim();
1978 if module.chars().all(|c| c == '.') {
1979 for name in names.trim_matches(|c| c == '(' || c == ')').split(',') {
1980 let name = name.split(" as ").next().unwrap_or("").trim();
1981 if !name.is_empty() && name != "*" {
1982 out.push(format!("{module}{name}"));
1983 }
1984 }
1985 } else {
1986 out.push(module.to_owned());
1987 }
1988 } else if let Some(rest) = t.strip_prefix("import ") {
1989 for part in rest.split(',') {
1990 let module = part.split(" as ").next().unwrap_or("").trim();
1991 if !module.is_empty() {
1992 out.push(module.to_owned());
1993 }
1994 }
1995 }
1996 }
1997 out
1998}
1999
2000fn condition(line: &str) -> String {
2002 let mut t = line.trim();
2003 if let Some(r) = t.strip_prefix('}') {
2004 let r = r.trim_start();
2005 if let Some(r) = r.strip_prefix("else") {
2006 t = r.trim_start();
2007 }
2008 }
2009 let Some(r) = t.strip_prefix("if") else {
2010 return line.to_owned();
2011 };
2012 let r = r.trim_start();
2013 let Some(r) = r.strip_prefix('(') else {
2014 return line.to_owned();
2015 };
2016 let r = r.trim_end();
2017 let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2018 match r.strip_suffix(')') {
2019 Some(c) => c.to_owned(),
2020 None => line.to_owned(),
2021 }
2022}
2023
2024fn strip_numbers(text: &str) -> String {
2025 text.split('\n')
2026 .map(|l| {
2027 let (mark, rest) = match l.chars().next() {
2028 Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2029 _ => (None, l),
2030 };
2031 let trimmed = rest.trim_start();
2032 let digits = trimmed.len()
2033 - trimmed
2034 .trim_start_matches(|c: char| c.is_ascii_digit())
2035 .len();
2036 if digits > 0 && trimmed[digits..].starts_with(' ') {
2037 format!(
2038 "{}{}",
2039 mark.map(String::from).unwrap_or_default(),
2040 &trimmed[digits + 1..]
2041 )
2042 } else if let Some(n) = l
2043 .trim_start()
2044 .strip_prefix("... ")
2045 .and_then(|r| r.split_once(" lines not shown"))
2046 {
2047 let _ = n;
2048 "...".to_owned()
2049 } else {
2050 l.to_owned()
2051 }
2052 })
2053 .collect::<Vec<_>>()
2054 .join("\n")
2055}
2056
2057fn strip_location(question: &str) -> String {
2058 match question.split_once(": ") {
2060 Some((loc, rest)) => match loc.rsplit_once(':') {
2061 Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2062 format!("{file}:: {rest}")
2063 }
2064 _ => question.to_owned(),
2065 },
2066 None => question.to_owned(),
2067 }
2068}
2069
2070fn import_specifiers(text: &str) -> Vec<String> {
2071 let mut out = Vec::new();
2072 for line in text.split('\n') {
2073 let t = line.trim_start();
2074 let quoted = |s: &str| -> Option<String> {
2075 let s = s.trim_start();
2076 let q = s.chars().next().filter(|c| *c == '\'' || *c == '"')?;
2077 let rest = &s[1..];
2078 rest.find(q).map(|end| rest[..end].to_owned())
2079 };
2080 if (t.starts_with("import ") || t.starts_with("export "))
2081 && let Some(i) = t.find(" from ")
2082 {
2083 out.extend(quoted(&t[i + 6..]));
2084 } else if let Some(i) = t.find("import(") {
2085 out.extend(quoted(&t[i + 7..]));
2086 } else if let Some(i) = t.find("require(") {
2087 out.extend(quoted(&t[i + 8..]));
2088 } else if let Some(r) = t.strip_prefix("} from ") {
2089 out.extend(quoted(r));
2090 }
2091 }
2092 out
2093}
2094
2095fn normalize(path: &Path) -> String {
2099 let mut out = Vec::new();
2100 for c in path.components() {
2101 match c {
2102 std::path::Component::ParentDir => {
2103 out.pop();
2104 }
2105 std::path::Component::CurDir => {}
2106 other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2107 }
2108 }
2109 out.join("/")
2110}
2111
2112fn tsconfig_aliases(root: &Path) -> Vec<(String, String)> {
2115 let Ok(text) = std::fs::read_to_string(root.join("tsconfig.json")) else {
2116 return vec![];
2117 };
2118 let cleaned = text
2119 .lines()
2120 .filter(|l| !l.trim_start().starts_with("//"))
2121 .collect::<Vec<_>>()
2122 .join("\n");
2123 let Ok(config) = serde_json::from_str::<Value>(&cleaned) else {
2124 return vec![];
2125 };
2126 let mut out = Vec::new();
2127 if let Some(paths) = config["compilerOptions"]["paths"].as_object() {
2128 for (alias, targets) in paths {
2129 let Some(target) = targets
2130 .as_array()
2131 .and_then(|t| t.first())
2132 .and_then(Value::as_str)
2133 else {
2134 continue;
2135 };
2136 let (Some(a), Some(t)) = (alias.strip_suffix('*'), target.strip_suffix('*')) else {
2137 continue;
2138 };
2139 out.push((a.to_owned(), t.trim_start_matches("./").to_owned()));
2140 }
2141 }
2142 out
2143}
2144
2145#[cfg(test)]
2146mod tests {
2147 use super::*;
2148
2149 #[test]
2150 fn changes_follow_the_audit() {
2151 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";
2152 let starts = statement_starts("x.tsx", source);
2153 let js = Language::JavaScript;
2154 assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2155 assert_eq!(
2156 change_at(&starts, 2, "const a = 1", js),
2157 Some(Change::ValueUndefined)
2158 );
2159 assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2160 assert_eq!(
2161 change_at(&starts, 5, "if (a) return 2", js),
2162 Some(Change::Invert)
2163 );
2164 assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2165 assert_eq!(
2166 change_at(&starts, 8, "a && b", js),
2167 Some(Change::ExpressionUndefined)
2168 );
2169 }
2170
2171 #[test]
2172 fn a_change_that_cannot_change_anything_is_not_asked() {
2173 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";
2177 let starts = statement_starts("x.ts", source);
2178 let js = Language::JavaScript;
2179 assert_eq!(change_at(&starts, 3, "super([])", js), None);
2180 assert_eq!(
2181 change_at(&starts, 7, "return undefined", js),
2182 Some(Change::Skip)
2183 );
2184 assert_eq!(
2185 change_at(&starts, 8, "return void 0", js),
2186 Some(Change::Skip)
2187 );
2188 assert_eq!(
2189 change_at(&starts, 9, "let answered = false", js),
2190 Some(Change::Flip)
2191 );
2192 assert_eq!(
2194 change_at(&starts, 10, "const done = true, more = 1", js),
2195 Some(Change::ValueUndefined)
2196 );
2197 assert_eq!(
2198 change_at(&starts, 11, "return false", js),
2199 Some(Change::Flip)
2200 );
2201 let python = python_statement_starts(
2202 "def f(a):\n if a:\n return None\n ready = False\n return True\n",
2203 );
2204 let py = Language::Python;
2205 assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2206 assert_eq!(
2207 change_at(&python, 4, "ready = False", py),
2208 Some(Change::Flip)
2209 );
2210 assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2211 assert_eq!(flipped("let answered = false"), "let answered = true");
2212 assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2213 assert_eq!(flipped("ready = False"), "ready = True");
2214 assert_eq!(
2215 flipped("let ready = false // true once open"),
2216 "let ready = true // true once open"
2217 );
2218 assert_eq!(
2219 flipped("ready = False # True later"),
2220 "ready = True # True later"
2221 );
2222 assert_eq!(flipped("this.#open = false"), "this.#open = true");
2223 assert_eq!(
2224 flipped("const s = 'a // b', on = false"),
2225 "const s = 'a // b', on = true"
2226 );
2227 }
2228
2229 #[test]
2230 fn a_typescript_throw_keeps_its_types() {
2231 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";
2234 let typed = statement_starts("x.ts", source);
2235 let js = Language::JavaScript;
2236 assert_eq!(
2237 change_at(
2238 &typed,
2239 4,
2240 "throw new Error(`tools/list failed: ${result.error.message}`)",
2241 js
2242 ),
2243 Some(Change::BareError)
2244 );
2245 let plain = statement_starts("x.js", source);
2246 assert_eq!(
2247 change_at(
2248 &plain,
2249 4,
2250 "throw new Error(`tools/list failed: ${result.error.message}`)",
2251 js
2252 ),
2253 Some(Change::Skip)
2254 );
2255 }
2256
2257 #[test]
2258 fn a_statement_s_text_is_what_a_test_would_quote() {
2259 assert_eq!(
2260 quoted_fragments("logger.info(` - Headers: ${describeHeaders(headers)}`)"),
2261 vec!["- Headers:"]
2262 );
2263 assert_eq!(
2264 quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2265 vec![", signal=", "Child exited: code="]
2266 );
2267 assert_eq!(
2268 quoted_fragments("log(f\"Serving {path} on {port}\")"),
2269 vec!["Serving"]
2270 );
2271 assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2273 assert!(quoted_fragments("return a + b").is_empty());
2274 }
2275
2276 #[test]
2277 fn python_changes_follow_the_audit() {
2278 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";
2279 let starts = python_statement_starts(source);
2280 let py = Language::Python;
2281 assert_eq!(change_at(&starts, 1, "import os", py), None);
2282 assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
2283 assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
2284 assert_eq!(
2285 change_at(&starts, 6, "y = x + 1", py),
2286 Some(Change::ValueUndefined)
2287 );
2288 assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
2289 assert_eq!(
2290 change_at(&starts, 8, "return y", py),
2291 Some(Change::ReturnUndefined)
2292 );
2293 assert_eq!(change_at(&starts, 9, "z: int", py), None);
2294 assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
2295 assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
2296 assert_eq!(
2297 change_at(&starts, 15, "v is None", py),
2298 Some(Change::Invert)
2299 );
2300 assert_eq!(
2301 python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
2302 "test_b"
2303 );
2304 assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
2305 assert_eq!(python_condition("if y > 2:"), "y > 2");
2306 assert_eq!(python_condition("elif v is None:"), "v is None");
2307 assert_eq!(
2308 python_imports(
2309 "from .helpers import make\nfrom . import fixtures, util as u\nimport tests.support, json as j\n"
2310 ),
2311 vec![".helpers", ".fixtures", ".util", "tests.support", "json"]
2312 );
2313 }
2314
2315 #[test]
2316 fn change_texts() {
2317 assert_eq!(
2318 split_initializer("const x = a === b").as_deref(),
2319 Some("const x =")
2320 );
2321 assert_eq!(
2322 split_initializer("const f = () => 1").as_deref(),
2323 Some("const f =")
2324 );
2325 assert_eq!(condition("} else if (x > 1) {"), "x > 1");
2326 assert_eq!(
2327 condition("if (!body) return null"),
2328 "if (!body) return null"
2329 );
2330 assert!(is_test_call(" it(\"works\", () => {"));
2331 assert!(is_test_call("test.each([1])('x', () => {"));
2332 assert!(!is_test_call("items.push(1)"));
2333 }
2334
2335 #[test]
2336 fn keys_ignore_line_numbers() {
2337 assert_eq!(
2338 strip_numbers("▶ 12 return x\n ... 3 lines not shown\n 15 }"),
2339 "▶ return x\n...\n }"
2340 );
2341 assert_eq!(
2342 strip_location("src/a.ts:41: `x` is deleted."),
2343 "src/a.ts:: `x` is deleted."
2344 );
2345 }
2346}