1use std::collections::{BTreeMap, BTreeSet};
12
13use serde::{Deserialize, Serialize};
14
15use crate::verify::CheckStatus;
16
17#[derive(
19 Debug,
20 Clone,
21 Copy,
22 PartialEq,
23 Eq,
24 PartialOrd,
25 Ord,
26 Hash,
27 Serialize,
28 Deserialize,
29 schemars::JsonSchema,
30)]
31#[serde(rename_all = "snake_case")]
32pub enum Toolchain {
33 Rust,
35 Python,
37 #[serde(rename = "javascript")]
39 JavaScript,
40 Go,
42}
43
44impl Toolchain {
45 #[must_use]
47 pub const fn title(self) -> &'static str {
48 match self {
49 Self::Rust => "Rust",
50 Self::Python => "Python",
51 Self::JavaScript => "JavaScript/TypeScript",
52 Self::Go => "Go",
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct CollectionRule {
61 pub toolchain: Toolchain,
63 pub text: String,
65 pub source: String,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(rename_all = "snake_case")]
72pub enum FileState {
73 Collected,
75 NotCollected,
77 Unknown,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
83#[serde(rename_all = "snake_case")]
84pub enum DecidedBy {
85 Static,
87 Junit,
89 Listing,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
95pub struct FileCollection {
96 pub path: String,
98 pub state: FileState,
100 pub decided_by: Option<DecidedBy>,
102 pub tests: usize,
104 pub test_ids: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub rule: Option<CollectionRule>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub why: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub fixes: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120pub struct ListingRun {
121 pub argv: Vec<String>,
123 pub duration_ms: u64,
125 pub status: CheckStatus,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
131pub struct Collection {
132 pub files: Vec<FileCollection>,
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub listing: Vec<ListingRun>,
137}
138
139impl Collection {
140 pub fn not_collected(&self) -> impl Iterator<Item = &FileCollection> {
142 self.files
143 .iter()
144 .filter(|f| f.state == FileState::NotCollected)
145 }
146
147 pub fn unknown(&self) -> impl Iterator<Item = &FileCollection> {
149 self.files.iter().filter(|f| f.state == FileState::Unknown)
150 }
151
152 #[must_use]
154 pub fn reasons(&self) -> Vec<String> {
155 self.not_collected().map(reason).collect()
156 }
157
158 #[must_use]
160 pub fn retry_lines(&self) -> Vec<String> {
161 self.not_collected().map(retry_line).collect()
162 }
163
164 #[must_use]
166 pub fn uncollected_paths(&self) -> Vec<String> {
167 let mut p: Vec<String> = self.not_collected().map(|f| f.path.clone()).collect();
168 p.sort();
169 p
170 }
171}
172
173pub const REASON_PREFIX: &str = "tests not collected: ";
175
176#[must_use]
178pub fn reason(f: &FileCollection) -> String {
179 let rule = f.rule.as_ref().map_or("", |r| r.text.as_str());
180 match f.why.as_deref() {
181 Some(why) => format!("{REASON_PREFIX}`{}` — {rule}; {why}", f.path),
182 None => format!("{REASON_PREFIX}`{}` — {rule}", f.path),
183 }
184}
185
186#[must_use]
188pub fn retry_line(f: &FileCollection) -> String {
189 let short = f.rule.as_ref().map_or_else(String::new, short_rule);
190 let fixes = capitalise(&f.fixes.join(", or "));
191 let mut line = format!("`{}` never ran", f.path);
192 if !short.is_empty() {
193 line.push_str(": ");
194 line.push_str(&short);
195 }
196 line.push('.');
197 if !fixes.is_empty() {
198 line.push(' ');
199 line.push_str(&fixes);
200 line.push('.');
201 }
202 line
203}
204
205fn short_rule(r: &CollectionRule) -> String {
206 r.text
207 .split(" (and the modules")
208 .next()
209 .unwrap_or(&r.text)
210 .to_owned()
211}
212
213fn capitalise(s: &str) -> String {
214 let mut c = s.chars();
215 c.next()
216 .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
217 .unwrap_or_default()
218}
219
220#[must_use]
222pub fn toolchain_of(path: &str) -> Option<Toolchain> {
223 let ext = path.rsplit_once('.').map(|(_, e)| e)?;
224 match ext {
225 "rs" => Some(Toolchain::Rust),
226 "py" => Some(Toolchain::Python),
227 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts" => Some(Toolchain::JavaScript),
228 "go" => Some(Toolchain::Go),
229 _ => None,
230 }
231}
232
233#[must_use]
235pub fn is_build_or_dependency(path: &str) -> bool {
236 path.split('/').any(|c| {
237 matches!(
238 c,
239 "target"
240 | "node_modules"
241 | "vendor"
242 | ".venv"
243 | "venv"
244 | "dist"
245 | "build"
246 | "__pycache__"
247 )
248 })
249}
250
251#[must_use]
254pub fn defines_tests(toolchain: Toolchain, source: &str) -> bool {
255 match toolchain {
256 Toolchain::Rust => {
257 let code: String = strip_comments(source).split_whitespace().collect();
258 code.contains("#[test]")
261 || ["#[rstest", "#[test_case", "#[quickcheck", "#[proptest"]
262 .iter()
263 .any(|m| code.contains(m))
264 || code.match_indices("#[").any(|(i, _)| {
265 let end = code[i..].find(']').map_or(code.len(), |e| i + e + 1);
266 let attr = &code[i..end];
267 attr.contains("::test]") || attr.contains("::test(")
268 })
269 }
270 Toolchain::Python => source.lines().any(|l| {
271 let t = l.trim_start();
272 t.starts_with("def test")
273 || t.starts_with("async def test")
274 || t.starts_with("class Test")
275 }),
276 Toolchain::JavaScript => {
277 let code = strip_comments(source);
278 [
279 "it(",
280 "test(",
281 "describe(",
282 "it.each",
283 "test.each",
284 "it.only(",
285 "test.only(",
286 ]
287 .iter()
288 .any(|m| {
289 code.match_indices(m).any(|(i, _)| {
290 i == 0
291 || !code[..i]
292 .chars()
293 .next_back()
294 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.')
295 })
296 })
297 }
298 Toolchain::Go => !go_test_like(source).is_empty(),
299 }
300}
301
302#[must_use]
305pub fn declared_tests(toolchain: Toolchain, source: &str) -> usize {
306 let code = strip_comments(source);
307 match toolchain {
308 Toolchain::Rust => {
309 let flat: String = code.split_whitespace().collect();
310 flat.match_indices("#[")
311 .filter(|(i, _)| {
312 let end = flat[*i..].find(']').map_or(flat.len(), |e| i + e + 1);
313 let attr = &flat[*i..end];
314 attr == "#[test]"
315 || attr.contains("::test]")
316 || attr.contains("::test(")
317 || attr.starts_with("#[rstest")
318 || attr.starts_with("#[test_case")
319 })
320 .count()
321 }
322 Toolchain::Python => code
323 .lines()
324 .map(str::trim_start)
325 .filter(|l| l.starts_with("def test") || l.starts_with("async def test"))
326 .count(),
327 Toolchain::JavaScript => ["it(", "test("]
328 .iter()
329 .map(|m| {
330 code.match_indices(m)
331 .filter(|(i, _)| {
332 *i == 0
333 || !code[..*i]
334 .chars()
335 .next_back()
336 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.')
337 })
338 .count()
339 })
340 .sum(),
341 Toolchain::Go => go_declared_tests(source).len(),
342 }
343}
344
345#[must_use]
348pub fn strip_comments(src: &str) -> String {
349 let mut out = String::with_capacity(src.len());
350 let b = src.as_bytes();
351 let mut i = 0;
352 let mut in_str = false;
353 while i < b.len() {
354 let c = b[i];
355 if in_str {
356 out.push(c as char);
357 if c == b'\\' && i + 1 < b.len() {
358 out.push(b[i + 1] as char);
359 i += 2;
360 continue;
361 }
362 if c == b'"' {
363 in_str = false;
364 }
365 i += 1;
366 } else if c == b'"' {
367 in_str = true;
368 out.push('"');
369 i += 1;
370 } else if c == b'/' && b.get(i + 1) == Some(&b'/') {
371 while i < b.len() && b[i] != b'\n' {
372 i += 1;
373 }
374 } else if c == b'/' && b.get(i + 1) == Some(&b'*') {
375 i += 2;
376 let mut depth = 1;
377 while i < b.len() && depth > 0 {
378 if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
379 depth += 1;
380 i += 2;
381 } else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
382 depth -= 1;
383 i += 2;
384 } else {
385 if b[i] == b'\n' {
386 out.push('\n');
387 }
388 i += 1;
389 }
390 }
391 } else {
392 let ch = src[i..].chars().next().unwrap_or(' ');
394 out.push(ch);
395 i += ch.len_utf8();
396 }
397 }
398 out
399}
400
401#[must_use]
404pub fn join(dir: &str, rel: &str) -> Option<String> {
405 let mut parts: Vec<&str> = Vec::new();
406 for c in dir.split('/').chain(rel.split('/')) {
407 match c {
408 "" | "." => {}
409 ".." => {
410 parts.pop()?;
411 }
412 c => parts.push(c),
413 }
414 }
415 Some(parts.join("/"))
416}
417
418fn parent(path: &str) -> &str {
419 path.rsplit_once('/').map_or("", |(d, _)| d)
420}
421
422fn file_name(path: &str) -> &str {
423 path.rsplit_once('/').map_or(path, |(_, f)| f)
424}
425
426fn stem(path: &str) -> &str {
427 let f = file_name(path);
428 f.split_once('.').map_or(f, |(s, _)| s)
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Default)]
435pub struct RustPackage {
436 pub root: String,
438 pub name: String,
440 pub autotests: bool,
442 pub tests: Vec<(String, String)>,
444 pub lib: Option<String>,
446 pub bins: Vec<(String, String)>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct RustTarget {
453 pub binary_id: String,
455 pub root_file: String,
457}
458
459#[must_use]
462pub fn rust_targets(pkg: &RustPackage, files: &BTreeSet<String>) -> Vec<RustTarget> {
463 let at = |rel: &str| join(&pkg.root, rel).unwrap_or_default();
464 let mut out = Vec::new();
465 let lib = at(pkg.lib.as_deref().unwrap_or("src/lib.rs"));
466 if files.contains(&lib) {
467 out.push(RustTarget {
468 binary_id: pkg.name.clone(),
469 root_file: lib,
470 });
471 }
472 let main = at("src/main.rs");
473 if files.contains(&main) {
474 out.push(RustTarget {
475 binary_id: format!("{}::bin/{}", pkg.name, pkg.name),
476 root_file: main,
477 });
478 }
479 for (name, path) in &pkg.bins {
480 let root_file = at(path);
481 if files.contains(&root_file) && !out.iter().any(|t| t.root_file == root_file) {
482 out.push(RustTarget {
483 binary_id: format!("{}::bin/{name}", pkg.name),
484 root_file,
485 });
486 }
487 }
488 let bin_dir = at("src/bin");
489 for f in files {
490 let Some(rest) = f.strip_prefix(&format!("{bin_dir}/")) else {
491 continue;
492 };
493 let name = match rest.split('/').collect::<Vec<_>>()[..] {
494 [one] if one.ends_with(".rs") => one.trim_end_matches(".rs"),
495 [dir, "main.rs"] => dir,
496 _ => continue,
497 };
498 out.push(RustTarget {
499 binary_id: format!("{}::bin/{name}", pkg.name),
500 root_file: f.clone(),
501 });
502 }
503 let mut explicit: BTreeSet<String> = BTreeSet::new();
504 for (name, path) in &pkg.tests {
505 let root_file = at(path);
506 explicit.insert(root_file.clone());
507 out.push(RustTarget {
508 binary_id: format!("{}::{name}", pkg.name),
509 root_file,
510 });
511 }
512 if pkg.autotests {
513 let tests_dir = at("tests");
514 for f in files {
515 let Some(rest) = f.strip_prefix(&format!("{tests_dir}/")) else {
516 continue;
517 };
518 let name = match rest.split('/').collect::<Vec<_>>()[..] {
519 [one] if one.ends_with(".rs") => one.trim_end_matches(".rs"),
520 [dir, "main.rs"] => dir,
521 _ => continue,
522 };
523 if explicit.contains(f) || pkg.tests.iter().any(|(n, _)| n == name) {
524 continue;
525 }
526 out.push(RustTarget {
527 binary_id: format!("{}::{name}", pkg.name),
528 root_file: f.clone(),
529 });
530 }
531 }
532 out
533}
534
535#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct ModDecl {
538 pub name: String,
540 pub path: Option<String>,
542 pub inline: Vec<(String, Option<String>)>,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
548enum Tok {
549 Ident(String),
550 Str(String),
551 Punct(char),
552}
553
554fn lex(src: &str) -> Vec<Tok> {
555 let code = strip_comments(src);
556 let chars: Vec<char> = code.chars().collect();
557 let mut out = Vec::new();
558 let mut i = 0;
559 while i < chars.len() {
560 let c = chars[i];
561 if c.is_whitespace() {
562 i += 1;
563 } else if c.is_alphabetic() || c == '_' {
564 let s = i;
565 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
566 i += 1;
567 }
568 let word: String = chars[s..i].iter().collect();
569 if word == "r" && i < chars.len() && (chars[i] == '"' || chars[i] == '#') {
571 let mut hashes = 0;
572 while i < chars.len() && chars[i] == '#' {
573 hashes += 1;
574 i += 1;
575 }
576 if i < chars.len() && chars[i] == '"' {
577 i += 1;
578 let s = i;
579 loop {
580 if i >= chars.len() {
581 break;
582 }
583 if chars[i] == '"'
584 && (0..hashes).all(|k| chars.get(i + 1 + k) == Some(&'#'))
585 {
586 break;
587 }
588 i += 1;
589 }
590 out.push(Tok::Str(chars[s..i.min(chars.len())].iter().collect()));
591 i += 1 + hashes;
592 }
593 continue;
594 }
595 out.push(Tok::Ident(word));
596 } else if c == '"' {
597 i += 1;
598 let mut s = String::new();
599 while i < chars.len() && chars[i] != '"' {
600 if chars[i] == '\\' && i + 1 < chars.len() {
601 s.push(chars[i + 1]);
602 i += 2;
603 } else {
604 s.push(chars[i]);
605 i += 1;
606 }
607 }
608 out.push(Tok::Str(s));
609 i += 1;
610 } else if c == '\'' {
611 if chars.get(i + 2) == Some(&'\'') {
613 i += 3;
614 } else if chars.get(i + 1) == Some(&'\\') {
615 i += 2;
616 while i < chars.len() && chars[i] != '\'' {
617 i += 1;
618 }
619 i += 1;
620 } else {
621 i += 1;
622 }
623 } else {
624 out.push(Tok::Punct(c));
625 i += 1;
626 }
627 }
628 out
629}
630
631#[must_use]
634pub fn rust_mod_decls(src: &str) -> Vec<ModDecl> {
635 let toks = lex(src);
636 let mut out = Vec::new();
637 let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
639 let mut depth = 0usize;
640 let mut pending_path: Option<String> = None;
641 let mut i = 0;
642 while i < toks.len() {
643 match &toks[i] {
644 Tok::Punct('#') if toks.get(i + 1) == Some(&Tok::Punct('[')) => {
645 let mut j = i + 2;
647 let mut nest = 1;
648 let is_path = toks.get(j) == Some(&Tok::Ident("path".to_owned()))
649 && toks.get(j + 1) == Some(&Tok::Punct('='));
650 if is_path && let Some(Tok::Str(p)) = toks.get(j + 2) {
651 pending_path = Some(p.clone());
652 }
653 while j < toks.len() && nest > 0 {
654 match toks[j] {
655 Tok::Punct('[') => nest += 1,
656 Tok::Punct(']') => nest -= 1,
657 _ => {}
658 }
659 j += 1;
660 }
661 i = j;
662 continue;
663 }
664 Tok::Ident(w) if w == "mod" => {
665 if let Some(Tok::Ident(name)) = toks.get(i + 1) {
666 match toks.get(i + 2) {
667 Some(Tok::Punct(';')) => {
668 out.push(ModDecl {
669 name: name.clone(),
670 path: pending_path.take(),
671 inline: stack
672 .iter()
673 .map(|(n, p, _)| (n.clone(), p.clone()))
674 .collect(),
675 });
676 i += 3;
677 continue;
678 }
679 Some(Tok::Punct('{')) => {
680 depth += 1;
681 stack.push((name.clone(), pending_path.take(), depth));
682 i += 3;
683 continue;
684 }
685 _ => {}
686 }
687 }
688 }
689 Tok::Ident(w) if matches!(w.as_str(), "pub" | "crate" | "super" | "self" | "in") => {}
690 Tok::Punct('(' | ')') => {}
691 Tok::Punct('{') => {
692 depth += 1;
693 pending_path = None;
694 }
695 Tok::Punct('}') => {
696 if stack.last().is_some_and(|(_, _, d)| *d == depth) {
697 stack.pop();
698 }
699 depth = depth.saturating_sub(1);
700 pending_path = None;
701 }
702 _ => pending_path = None,
703 }
704 i += 1;
705 }
706 out
707}
708
709#[must_use]
714pub fn rust_reachable(
715 targets: &[RustTarget],
716 read: &dyn Fn(&str) -> Option<String>,
717) -> BTreeMap<String, Vec<(String, String)>> {
718 let mut out: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
719 for t in targets {
720 let mut queue: Vec<(String, String, bool)> =
722 vec![(t.root_file.clone(), String::new(), true)];
723 let mut seen: BTreeSet<String> = BTreeSet::new();
724 while let Some((file, module, mod_rs)) = queue.pop() {
725 if !seen.insert(file.clone()) {
726 continue;
727 }
728 let Some(src) = read(&file) else { continue };
729 out.entry(file.clone())
730 .or_default()
731 .push((t.binary_id.clone(), module.clone()));
732 let dir = parent(&file).to_owned();
733 let base = if mod_rs {
735 dir.clone()
736 } else {
737 join(&dir, stem(&file)).unwrap_or_default()
738 };
739 for d in rust_mod_decls(&src) {
740 let mut inline_dir = base.clone();
741 let mut module_path: Vec<String> = module
742 .split("::")
743 .filter(|s| !s.is_empty())
744 .map(str::to_owned)
745 .collect();
746 for (n, p) in &d.inline {
747 inline_dir = join(&inline_dir, p.as_deref().unwrap_or(n)).unwrap_or_default();
748 module_path.push(n.clone());
749 }
750 module_path.push(d.name.clone());
751 let child_module = module_path.join("::");
752 let candidates: Vec<(Option<String>, bool)> = match &d.path {
753 Some(p) if d.inline.is_empty() => vec![(join(&dir, p), true)],
755 Some(p) => vec![(join(&inline_dir, p), true)],
756 None => vec![
757 (join(&inline_dir, &format!("{}.rs", d.name)), false),
758 (join(&inline_dir, &format!("{}/mod.rs", d.name)), true),
759 ],
760 };
761 for (c, rs) in candidates {
762 let Some(c) = c else { continue };
763 if read(&c).is_some() {
764 queue.push((c, child_module.clone(), rs));
765 break;
766 }
767 }
768 }
769 }
770 }
771 out
772}
773
774pub const RUST_RULE: &str =
776 "Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)";
777
778#[must_use]
780pub fn rust_rule(pkg: &RustPackage) -> CollectionRule {
781 let manifest = join(&pkg.root, "Cargo.toml").unwrap_or_else(|| "Cargo.toml".to_owned());
782 if pkg.autotests {
783 CollectionRule {
784 toolchain: Toolchain::Rust,
785 text: RUST_RULE.to_owned(),
786 source: if pkg.tests.is_empty() {
787 format!("{manifest} (default)")
788 } else {
789 format!("{manifest} [[test]]")
790 },
791 }
792 } else {
793 let listed: Vec<String> = pkg.tests.iter().map(|(_, p)| format!("`{p}`")).collect();
794 CollectionRule {
795 toolchain: Toolchain::Rust,
796 text: if listed.is_empty() {
797 "Cargo builds no integration test (`autotests = false` and no `[[test]]`), only inline test modules (and the modules they declare)".to_owned()
798 } else {
799 format!(
800 "Cargo builds only the `[[test]]` targets {} (`autotests = false`) and inline test modules (and the modules they declare)",
801 listed.join(", ")
802 )
803 },
804 source: format!("{manifest} autotests"),
805 }
806 }
807}
808
809#[must_use]
811pub fn rust_fixes(pkg_root: &str, path: &str) -> Vec<String> {
812 let s = stem(path);
813 let tests = join(pkg_root, "tests").unwrap_or_else(|| "tests".to_owned());
814 match path.strip_prefix(&format!("{tests}/")) {
815 Some(rest) if rest.contains('/') => {
816 let dir = rest.split('/').next().unwrap_or_default();
817 vec![
818 format!("move the tests to `{tests}/{s}.rs`"),
819 format!("add `{tests}/{dir}/main.rs` with `mod {s};`"),
820 ]
821 }
822 _ => vec![
823 format!("declare it with `mod {s};` from its parent module"),
824 format!("move the tests to `{tests}/{s}.rs`"),
825 ],
826 }
827}
828
829#[must_use]
833pub fn rust_tests_by_file(
834 files: &BTreeMap<String, Vec<(String, String)>>,
835 ids: &[String],
836) -> BTreeMap<String, Vec<String>> {
837 let mut by_binary: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
838 for (path, places) in files {
839 for (b, m) in places {
840 by_binary
841 .entry(b.as_str())
842 .or_default()
843 .push((m.as_str(), path.as_str()));
844 }
845 }
846 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
847 for id in ids {
848 let Some((binary, rest)) = by_binary
849 .keys()
850 .filter_map(|b| {
851 id.strip_prefix(*b)
852 .and_then(|r| r.strip_prefix("::"))
853 .map(|r| (*b, r))
854 })
855 .max_by_key(|(b, _)| b.len())
856 else {
857 continue;
858 };
859 let owner = by_binary[binary]
860 .iter()
861 .filter(|(m, _)| m.is_empty() || rest.starts_with(&format!("{m}::")))
862 .max_by_key(|(m, _)| m.len());
863 if let Some((_, path)) = owner {
864 out.entry((*path).to_owned()).or_default().push(id.clone());
865 }
866 }
867 out
868}
869
870#[must_use]
875pub fn glob_match(pattern: &str, path: &str) -> bool {
876 expand(pattern).iter().any(|p| {
877 match_segments(
878 &p.split('/').collect::<Vec<_>>(),
879 &path.split('/').collect::<Vec<_>>(),
880 )
881 })
882}
883
884fn expand(pattern: &str) -> Vec<String> {
886 let chars: Vec<char> = pattern.chars().collect();
887 let mut i = 0;
889 while i < chars.len() {
890 let ext = matches!(chars[i], '?' | '@' | '+' | '*' | '!') && chars.get(i + 1) == Some(&'(');
891 if chars[i] == '{' || ext {
892 let open = if ext { i + 1 } else { i };
893 let close_ch = if ext { ')' } else { '}' };
894 let open_ch = chars[open];
895 let mut depth = 0;
896 let mut j = open;
897 let mut parts = Vec::new();
898 let mut start = open + 1;
899 while j < chars.len() {
900 let c = chars[j];
901 if c == open_ch || (c == '{' && open_ch != '{') || (c == '(' && open_ch != '(') {
902 depth += 1;
903 } else if c == close_ch
904 || (c == '}' && close_ch != '}')
905 || (c == ')' && close_ch != ')')
906 {
907 depth -= 1;
908 if depth == 0 {
909 parts.push(chars[start..j].iter().collect::<String>());
910 break;
911 }
912 } else if depth == 1 && (c == ',' && !ext || c == '|' && ext) {
913 parts.push(chars[start..j].iter().collect::<String>());
914 start = j + 1;
915 }
916 j += 1;
917 }
918 if j >= chars.len() {
919 break;
920 }
921 let head: String = chars[..i].iter().collect();
922 let tail: String = chars[j + 1..].iter().collect();
923 let mut alts = parts;
924 if ext && matches!(chars[i], '?' | '*') {
925 alts.insert(0, String::new());
926 }
927 if ext && chars[i] == '!' {
928 alts = vec!["*".to_owned()];
930 }
931 return alts
932 .into_iter()
933 .flat_map(|a| expand(&format!("{head}{a}{tail}")))
934 .collect();
935 }
936 i += 1;
937 }
938 vec![pattern.to_owned()]
939}
940
941fn match_segments(p: &[&str], s: &[&str]) -> bool {
942 match (p.first(), s.first()) {
943 (None, None) => true,
944 (Some(&"**"), _) => {
945 match_segments(&p[1..], s) || (!s.is_empty() && match_segments(p, &s[1..]))
946 }
947 (Some(ps), Some(ss)) => match_name(ps, ss) && match_segments(&p[1..], &s[1..]),
948 _ => false,
949 }
950}
951
952#[must_use]
954pub fn match_name(pattern: &str, name: &str) -> bool {
955 let p: Vec<char> = pattern.chars().collect();
956 let n: Vec<char> = name.chars().collect();
957 fn go(p: &[char], n: &[char]) -> bool {
958 match p.first() {
959 None => n.is_empty(),
960 Some('*') => (0..=n.len()).any(|k| go(&p[1..], &n[k..])),
961 Some('?') => !n.is_empty() && go(&p[1..], &n[1..]),
962 Some('[') => {
963 let Some(end) = p.iter().position(|c| *c == ']') else {
964 return n.first() == Some(&'[') && go(&p[1..], &n[1..]);
965 };
966 let Some(c) = n.first() else { return false };
967 let class = &p[1..end];
968 let (neg, class) = match class.first() {
969 Some('!' | '^') => (true, &class[1..]),
970 _ => (false, class),
971 };
972 let mut hit = false;
973 let mut k = 0;
974 while k < class.len() {
975 if k + 2 < class.len() && class[k + 1] == '-' {
976 hit |= class[k] <= *c && *c <= class[k + 2];
977 k += 3;
978 } else {
979 hit |= class[k] == *c;
980 k += 1;
981 }
982 }
983 hit != neg && go(&p[end + 1..], &n[1..])
984 }
985 Some(c) => n.first() == Some(c) && go(&p[1..], &n[1..]),
986 }
987 }
988 go(&p, &n)
989}
990
991#[derive(Debug, Clone, PartialEq, Eq)]
995pub struct PytestRule {
996 pub python_files: Vec<String>,
998 pub testpaths: Vec<String>,
1000 pub norecursedirs: Vec<String>,
1002 pub source: String,
1004}
1005
1006impl Default for PytestRule {
1007 fn default() -> Self {
1008 Self {
1009 python_files: vec!["test_*.py".to_owned(), "*_test.py".to_owned()],
1010 testpaths: Vec::new(),
1011 norecursedirs: [
1012 "*.egg",
1013 ".*",
1014 "_darcs",
1015 "build",
1016 "CVS",
1017 "dist",
1018 "node_modules",
1019 "venv",
1020 "{arch}",
1021 ]
1022 .map(str::to_owned)
1023 .to_vec(),
1024 source: "pytest defaults".to_owned(),
1025 }
1026 }
1027}
1028
1029impl PytestRule {
1030 #[must_use]
1032 pub fn rule(&self) -> CollectionRule {
1033 let files: Vec<String> = self.python_files.iter().map(|p| format!("`{p}`")).collect();
1034 let under = if self.testpaths.is_empty() {
1035 "the rootdir".to_owned()
1036 } else {
1037 self.testpaths
1038 .iter()
1039 .map(|p| format!("`{p}`"))
1040 .collect::<Vec<_>>()
1041 .join(", ")
1042 };
1043 CollectionRule {
1044 toolchain: Toolchain::Python,
1045 text: format!(
1046 "pytest collects {} (python_files) under {} (testpaths)",
1047 files.join(" and "),
1048 under
1049 ),
1050 source: self.source.clone(),
1051 }
1052 }
1053
1054 #[must_use]
1056 pub fn breaks(&self, path: &str) -> Option<String> {
1057 let name = file_name(path);
1058 if !self.python_files.iter().any(|p| match_name(p, name)) {
1059 return Some("this file matches neither".to_owned());
1060 }
1061 if !self.testpaths.is_empty()
1062 && !self.testpaths.iter().any(|t| {
1063 let t = t.trim_end_matches('/');
1064 t == "." || path.starts_with(&format!("{t}/"))
1065 })
1066 {
1067 return Some("this file is outside testpaths".to_owned());
1068 }
1069 let dir = parent(path);
1070 if let Some(d) = dir
1071 .split('/')
1072 .filter(|d| !d.is_empty())
1073 .find(|d| self.norecursedirs.iter().any(|p| match_name(p, d)))
1074 {
1075 return Some(format!("it is under `{d}/`, which norecursedirs skips"));
1076 }
1077 None
1078 }
1079
1080 #[must_use]
1082 pub fn fixes(&self, path: &str) -> Vec<String> {
1083 let s = stem(path);
1084 let s = s.strip_prefix("test_").unwrap_or(s);
1085 let mut out = vec![format!("rename to `test_{s}.py`")];
1086 if let Some(t) = self.testpaths.first() {
1087 out.push(format!(
1088 "move under `{}/` (testpaths)",
1089 t.trim_end_matches('/')
1090 ));
1091 }
1092 out
1093 }
1094}
1095
1096#[must_use]
1099pub fn python_id_matches(path: &str, id: &str) -> bool {
1100 if id.starts_with(&format!("{path}::")) || id == path {
1101 return true;
1102 }
1103 let dotted = path.trim_end_matches(".py").replace('/', ".");
1104 id.starts_with(&format!("{dotted}::")) || id.starts_with(&format!("{dotted}."))
1105}
1106
1107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1111pub enum JsRunner {
1112 Vitest,
1114 Jest,
1116}
1117
1118impl JsRunner {
1119 #[must_use]
1121 pub const fn name(self) -> &'static str {
1122 match self {
1123 Self::Vitest => "vitest",
1124 Self::Jest => "jest",
1125 }
1126 }
1127
1128 #[must_use]
1130 pub const fn key(self) -> &'static str {
1131 match self {
1132 Self::Vitest => "include",
1133 Self::Jest => "testMatch",
1134 }
1135 }
1136
1137 #[must_use]
1139 pub fn defaults(self) -> Vec<String> {
1140 match self {
1141 Self::Vitest => vec!["**/*.{test,spec}.?(c|m)[jt]s?(x)".to_owned()],
1142 Self::Jest => vec![
1143 "**/__tests__/**/*.?([mc])[jt]s?(x)".to_owned(),
1144 "**/?(*.)+(spec|test).?([mc])[jt]s?(x)".to_owned(),
1145 ],
1146 }
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1152pub struct JsRule {
1153 pub runner: JsRunner,
1155 pub include: Vec<String>,
1157 pub exclude: Vec<String>,
1159 pub source: String,
1161}
1162
1163impl JsRule {
1164 #[must_use]
1166 pub fn rule(&self) -> CollectionRule {
1167 let pats: Vec<String> = self.include.iter().map(|p| format!("`{p}`")).collect();
1168 CollectionRule {
1169 toolchain: Toolchain::JavaScript,
1170 text: format!(
1171 "{} collects files matching {} ({})",
1172 self.runner.name(),
1173 pats.join(" or "),
1174 self.runner.key()
1175 ),
1176 source: self.source.clone(),
1177 }
1178 }
1179
1180 #[must_use]
1182 pub fn breaks(&self, path: &str) -> Option<String> {
1183 if path.split('/').any(|c| c == "node_modules")
1184 || self.exclude.iter().any(|p| glob_match(p, path))
1185 {
1186 return Some("this file is excluded".to_owned());
1187 }
1188 (!self.include.iter().any(|p| glob_match(p, path)))
1189 .then(|| "this file does not match".to_owned())
1190 }
1191
1192 #[must_use]
1194 pub fn fixes(&self, path: &str) -> Vec<String> {
1195 let name = file_name(path);
1196 let (s, ext) = name.split_once('.').unwrap_or((name, "ts"));
1197 let ext = ext.rsplit('.').next().unwrap_or("ts");
1198 vec![format!(
1199 "rename to `{s}.test.{ext}` ({}: `{}`)",
1200 self.runner.key(),
1201 self.include.first().map_or("", String::as_str)
1202 )]
1203 }
1204}
1205
1206pub const GO_RULE: &str = "go test runs `*_test.go` files in a package directory (not `testdata/` or a `_`/`.`-prefixed directory) whose functions are named `Test…`, `Example…` or `Fuzz…` followed by a non-lowercase character";
1210
1211#[must_use]
1213pub fn go_rule() -> CollectionRule {
1214 CollectionRule {
1215 toolchain: Toolchain::Go,
1216 text: GO_RULE.to_owned(),
1217 source: "go test (default)".to_owned(),
1218 }
1219}
1220
1221fn go_test_like(source: &str) -> Vec<String> {
1224 let code = strip_comments(source);
1225 code.lines()
1226 .filter_map(|l| {
1227 let rest = l.trim_start().strip_prefix("func ")?;
1228 let name: String = rest
1229 .chars()
1230 .take_while(|c| c.is_alphanumeric() || *c == '_')
1231 .collect();
1232 let lower = name.to_ascii_lowercase();
1233 (["test", "example", "fuzz"]
1234 .iter()
1235 .any(|p| lower.starts_with(p))
1236 && rest[name.len()..].trim_start().starts_with('('))
1237 .then_some(name)
1238 })
1239 .collect()
1240}
1241
1242#[must_use]
1245pub fn go_declared_tests(source: &str) -> Vec<String> {
1246 go_test_like(source)
1247 .into_iter()
1248 .filter(|n| {
1249 ["Test", "Example", "Fuzz"].iter().any(|p| {
1250 n.strip_prefix(p)
1251 .is_some_and(|r| r.chars().next().is_none_or(|c| !c.is_lowercase()))
1252 })
1253 })
1254 .collect()
1255}
1256
1257#[must_use]
1259pub fn go_build_constraint(source: &str) -> Option<String> {
1260 source
1261 .lines()
1262 .take_while(|l| !l.trim_start().starts_with("package "))
1263 .find_map(|l| {
1264 l.trim()
1265 .strip_prefix("//go:build ")
1266 .map(|e| e.trim().to_owned())
1267 })
1268}
1269
1270#[must_use]
1273pub fn go_build_satisfied(expr: &str, tags: &BTreeSet<String>) -> bool {
1274 let toks: Vec<String> = {
1275 let mut t = Vec::new();
1276 let mut cur = String::new();
1277 for c in expr.chars() {
1278 match c {
1279 '(' | ')' | '!' => {
1280 if !cur.is_empty() {
1281 t.push(std::mem::take(&mut cur));
1282 }
1283 t.push(c.to_string());
1284 }
1285 '&' | '|' => {
1286 if !cur.is_empty() && !cur.starts_with(c) {
1287 t.push(std::mem::take(&mut cur));
1288 }
1289 cur.push(c);
1290 if cur.len() == 2 {
1291 t.push(std::mem::take(&mut cur));
1292 }
1293 }
1294 c if c.is_whitespace() => {
1295 if !cur.is_empty() {
1296 t.push(std::mem::take(&mut cur));
1297 }
1298 }
1299 c => cur.push(c),
1300 }
1301 }
1302 if !cur.is_empty() {
1303 t.push(cur);
1304 }
1305 t
1306 };
1307 let on = |tag: &str| {
1308 tags.contains(tag)
1309 || tag.starts_with("go1.")
1310 || matches!(
1311 tag,
1312 "unix" | "linux" | "darwin" | "amd64" | "arm64" | "gc" | "cgo"
1313 )
1314 };
1315 fn or(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1316 let mut v = and(t, i, on);
1317 while t.get(*i).is_some_and(|x| x == "||") {
1318 *i += 1;
1319 v |= and(t, i, on);
1320 }
1321 v
1322 }
1323 fn and(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1324 let mut v = not(t, i, on);
1325 while t.get(*i).is_some_and(|x| x == "&&") {
1326 *i += 1;
1327 v &= not(t, i, on);
1328 }
1329 v
1330 }
1331 fn not(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1332 match t.get(*i).map(String::as_str) {
1333 Some("!") => {
1334 *i += 1;
1335 !not(t, i, on)
1336 }
1337 Some("(") => {
1338 *i += 1;
1339 let v = or(t, i, on);
1340 *i += 1;
1341 v
1342 }
1343 Some(x) => {
1344 *i += 1;
1345 on(x)
1346 }
1347 None => false,
1348 }
1349 }
1350 let mut i = 0;
1351 or(&toks, &mut i, &on)
1352}
1353
1354#[must_use]
1357pub fn go_breaks(
1358 path: &str,
1359 source: &str,
1360 tags: &BTreeSet<String>,
1361) -> Option<(String, Vec<String>)> {
1362 let s = stem(path);
1363 if let Some(d) = parent(path)
1364 .split('/')
1365 .find(|d| *d == "testdata" || d.starts_with('_') || d.starts_with('.'))
1366 {
1367 return Some((
1368 format!("it is under `{d}/`, which go ignores"),
1369 vec![format!("move it out of `{d}/`")],
1370 ));
1371 }
1372 if !file_name(path).ends_with("_test.go") {
1373 return Some((
1374 "its name does not end in `_test.go`".to_owned(),
1375 vec![format!("rename to `{s}_test.go`")],
1376 ));
1377 }
1378 if let Some(expr) = go_build_constraint(source)
1379 && !go_build_satisfied(&expr, tags)
1380 {
1381 return Some((
1382 format!("its build constraint `{expr}` is not set by the check"),
1383 vec![format!(
1384 "the file needs build tag `{expr}`, which the check does not set: add `-tags` to the check, or drop the constraint"
1385 )],
1386 ));
1387 }
1388 let like = go_test_like(source);
1389 if go_declared_tests(source).is_empty() && !like.is_empty() {
1390 return Some((
1391 format!(
1392 "no function is named `Test[A-Z_]…` ({})",
1393 like.iter()
1394 .map(|n| format!("`{n}`"))
1395 .collect::<Vec<_>>()
1396 .join(", ")
1397 ),
1398 vec!["rename the functions to `Test<Name>` (capital after `Test`)".to_owned()],
1399 ));
1400 }
1401 None
1402}
1403
1404#[must_use]
1407pub fn go_id_matches(import_path: &str, names: &[String], id: &str) -> bool {
1408 let (pkg, name) = id.rsplit_once("::").unwrap_or(("", id));
1409 let base = name.split('/').next().unwrap_or(name);
1410 (pkg.is_empty() || pkg == import_path) && names.iter().any(|n| n == base)
1411}
1412
1413#[must_use]
1417pub fn collected(path: &str, by: DecidedBy, ids: &[String]) -> FileCollection {
1418 FileCollection {
1419 path: path.to_owned(),
1420 state: FileState::Collected,
1421 decided_by: Some(by),
1422 tests: ids.len(),
1423 test_ids: ids.iter().take(20).cloned().collect(),
1424 rule: None,
1425 why: None,
1426 fixes: Vec::new(),
1427 }
1428}
1429
1430#[must_use]
1432pub fn not_collected(
1433 path: &str,
1434 by: DecidedBy,
1435 rule: CollectionRule,
1436 why: &str,
1437 fixes: Vec<String>,
1438) -> FileCollection {
1439 FileCollection {
1440 path: path.to_owned(),
1441 state: FileState::NotCollected,
1442 decided_by: Some(by),
1443 tests: 0,
1444 test_ids: Vec::new(),
1445 rule: Some(rule),
1446 why: Some(why.to_owned()),
1447 fixes,
1448 }
1449}
1450
1451#[must_use]
1453pub fn unknown(path: &str, why: &str) -> FileCollection {
1454 FileCollection {
1455 path: path.to_owned(),
1456 state: FileState::Unknown,
1457 decided_by: None,
1458 tests: 0,
1459 test_ids: Vec::new(),
1460 rule: None,
1461 why: Some(why.to_owned()),
1462 fixes: Vec::new(),
1463 }
1464}
1465
1466pub const COMPILED_OUT: &str =
1468 "the runner collected no test from it (its tests are compiled out or ignored)";
1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473 use proptest::prelude::*;
1474
1475 fn set(files: &[&str]) -> BTreeSet<String> {
1476 files.iter().map(|s| (*s).to_owned()).collect()
1477 }
1478
1479 fn pkg() -> RustPackage {
1480 RustPackage {
1481 root: String::new(),
1482 name: "greet".to_owned(),
1483 autotests: true,
1484 ..RustPackage::default()
1485 }
1486 }
1487
1488 fn reach(files: &[(&str, &str)]) -> BTreeMap<String, Vec<(String, String)>> {
1489 let map: BTreeMap<String, String> = files
1490 .iter()
1491 .map(|(p, t)| ((*p).to_owned(), (*t).to_owned()))
1492 .collect();
1493 let names: BTreeSet<String> = map.keys().cloned().collect();
1494 let targets = rust_targets(&pkg(), &names);
1495 rust_reachable(&targets, &|p| map.get(p).cloned())
1496 }
1497
1498 #[test]
1499 fn the_poc_files_are_unreachable_and_tests_rs_files_are_targets() {
1500 let r = reach(&[
1501 (
1502 "src/lib.rs",
1503 "pub fn greet() {}\n#[cfg(test)]\nmod tests { #[test] fn it_works() {} }\n",
1504 ),
1505 ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1506 ("tests/greet_name.rs", "#[test] fn a() {}\n"),
1507 ]);
1508 assert!(!r.contains_key("tests/unit/greet_name.rs"));
1509 assert_eq!(
1510 r["tests/greet_name.rs"],
1511 [("greet::greet_name".to_owned(), String::new())]
1512 );
1513 assert_eq!(r["src/lib.rs"], [("greet".to_owned(), String::new())]);
1514 }
1515
1516 #[test]
1517 fn a_main_rs_declares_its_modules_and_path_attributes_are_followed() {
1518 let r = reach(&[
1519 (
1520 "tests/unit/main.rs",
1521 "mod greet_name;\n// mod commented;\n#[path = \"../shared/x.rs\"]\nmod shared;\n",
1522 ),
1523 ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1524 ("tests/unit/commented.rs", "#[test] fn a() {}\n"),
1525 ("tests/shared/x.rs", "#[test] fn a() {}\n"),
1526 (
1527 "src/lib.rs",
1528 "#[cfg(feature = \"slow\")]\nmod slow;\nmod outer { mod inner; }\n#[path = \"../../escape.rs\"] mod escape;\n",
1529 ),
1530 ("src/slow.rs", "#[test] fn s() {}\n"),
1531 ("src/outer/inner.rs", "#[test] fn s() {}\n"),
1532 ]);
1533 assert_eq!(
1534 r["tests/unit/greet_name.rs"],
1535 [("greet::unit".to_owned(), "greet_name".to_owned())]
1536 );
1537 assert!(!r.contains_key("tests/unit/commented.rs"));
1538 assert_eq!(
1539 r["tests/shared/x.rs"],
1540 [("greet::unit".to_owned(), "shared".to_owned())]
1541 );
1542 assert_eq!(r["src/slow.rs"], [("greet".to_owned(), "slow".to_owned())]);
1543 assert_eq!(
1544 r["src/outer/inner.rs"],
1545 [("greet".to_owned(), "outer::inner".to_owned())]
1546 );
1547 }
1548
1549 #[test]
1550 fn autotests_off_leaves_only_listed_targets() {
1551 let p = RustPackage {
1552 autotests: false,
1553 tests: vec![("it".to_owned(), "tests/it.rs".to_owned())],
1554 ..pkg()
1555 };
1556 let t = rust_targets(&p, &set(&["tests/it.rs", "tests/other.rs", "src/lib.rs"]));
1557 let roots: Vec<&str> = t.iter().map(|t| t.root_file.as_str()).collect();
1558 assert_eq!(roots, ["src/lib.rs", "tests/it.rs"]);
1559 assert!(rust_rule(&p).text.contains("`tests/it.rs`"));
1560 }
1561
1562 #[test]
1563 fn test_ids_map_to_the_file_with_the_longest_module() {
1564 let r = reach(&[
1565 (
1566 "src/lib.rs",
1567 "mod a;\n#[cfg(test)] mod tests { #[test] fn t() {} }\n",
1568 ),
1569 ("src/a.rs", "#[test] fn x() {}\n"),
1570 ("tests/unit/main.rs", "mod greet_name;\n"),
1571 ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1572 ]);
1573 let ids = [
1574 "greet::tests::t",
1575 "greet::a::x",
1576 "greet::a::tests::y",
1577 "greet::unit::greet_name::a",
1578 "other::z",
1579 ]
1580 .map(str::to_owned);
1581 let m = rust_tests_by_file(&r, &ids);
1582 assert_eq!(m["src/lib.rs"], ["greet::tests::t"]);
1583 assert_eq!(m["src/a.rs"], ["greet::a::x", "greet::a::tests::y"]);
1584 assert_eq!(
1585 m["tests/unit/greet_name.rs"],
1586 ["greet::unit::greet_name::a"]
1587 );
1588 assert!(!m.contains_key("tests/unit/main.rs"));
1589 }
1590
1591 #[test]
1592 fn markers_tell_tests_from_helpers() {
1593 assert!(defines_tests(Toolchain::Rust, "#[test]\nfn a() {}"));
1594 assert!(defines_tests(
1595 Toolchain::Rust,
1596 "#[tokio::test]\nasync fn a() {}"
1597 ));
1598 assert!(defines_tests(
1599 Toolchain::Rust,
1600 "#[test_log::test]\nfn a() {}"
1601 ));
1602 assert!(defines_tests(
1603 Toolchain::Rust,
1604 "#[sqlx::test(migrations = \"m\")]\nasync fn a() {}"
1605 ));
1606 assert!(defines_tests(Toolchain::Rust, "# [test]\nfn a() {}"));
1607 assert!(!defines_tests(
1608 Toolchain::Rust,
1609 "pub fn setup() {}\n// #[test]\n"
1610 ));
1611 assert!(defines_tests(
1612 Toolchain::Python,
1613 "def test_a():\n pass\n"
1614 ));
1615 assert!(!defines_tests(
1616 Toolchain::Python,
1617 "import pytest\n@pytest.fixture\ndef x(): ...\n"
1618 ));
1619 assert!(defines_tests(
1620 Toolchain::JavaScript,
1621 "it('greets', () => {})"
1622 ));
1623 assert!(!defines_tests(
1624 Toolchain::JavaScript,
1625 "export const split = (s) => s.split(',')"
1626 ));
1627 assert!(defines_tests(
1628 Toolchain::Go,
1629 "package g\nfunc testLower(t *testing.T) {}\n"
1630 ));
1631 assert!(!defines_tests(
1632 Toolchain::Go,
1633 "package g\nfunc helper() {}\n"
1634 ));
1635 }
1636
1637 #[test]
1638 fn the_retry_line_and_reason_read_as_the_contract_says() {
1639 let f = not_collected(
1640 "tests/unit/greet_name.rs",
1641 DecidedBy::Static,
1642 rust_rule(&pkg()),
1643 "this file is neither",
1644 rust_fixes("", "tests/unit/greet_name.rs"),
1645 );
1646 assert_eq!(
1647 reason(&f),
1648 "tests not collected: `tests/unit/greet_name.rs` — Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare); this file is neither"
1649 );
1650 assert_eq!(
1651 retry_line(&f),
1652 "`tests/unit/greet_name.rs` never ran: Cargo builds `tests/*.rs` and `tests/<dir>/main.rs`. Move the tests to `tests/greet_name.rs`, or add `tests/unit/main.rs` with `mod greet_name;`."
1653 );
1654 }
1655
1656 #[test]
1657 fn pytest_rules() {
1658 let r = PytestRule::default();
1659 assert_eq!(
1660 r.breaks("tests/check_greet.py").as_deref(),
1661 Some("this file matches neither")
1662 );
1663 assert_eq!(r.breaks("tests/test_greet.py"), None);
1664 assert_eq!(r.breaks("greet_test.py"), None);
1665 assert!(
1666 r.breaks("build/test_x.py")
1667 .is_some_and(|w| w.contains("norecursedirs"))
1668 );
1669 let r = PytestRule {
1670 testpaths: vec!["tests".to_owned()],
1671 ..PytestRule::default()
1672 };
1673 assert_eq!(
1674 r.breaks("src/test_x.py").as_deref(),
1675 Some("this file is outside testpaths")
1676 );
1677 assert_eq!(
1678 r.rule().text,
1679 "pytest collects `test_*.py` and `*_test.py` (python_files) under `tests` (testpaths)"
1680 );
1681 assert!(python_id_matches(
1682 "tests/test_x.py",
1683 "tests/test_x.py::test_a"
1684 ));
1685 assert!(python_id_matches("tests/test_x.py", "tests.test_x::test_a"));
1686 assert!(python_id_matches(
1687 "tests/test_x.py",
1688 "tests.test_x.TestC::test_a"
1689 ));
1690 assert!(!python_id_matches(
1691 "tests/test_x.py",
1692 "tests.test_xy::test_a"
1693 ));
1694 }
1695
1696 #[test]
1697 fn js_defaults_and_globs() {
1698 let v = JsRule {
1699 runner: JsRunner::Vitest,
1700 include: JsRunner::Vitest.defaults(),
1701 exclude: Vec::new(),
1702 source: "vitest (default)".to_owned(),
1703 };
1704 assert_eq!(v.breaks("src/greet.test.ts"), None);
1705 assert_eq!(v.breaks("src/greet.spec.mjs"), None);
1706 assert_eq!(
1707 v.breaks("src/greet.tests.ts").as_deref(),
1708 Some("this file does not match")
1709 );
1710 assert!(v.breaks("node_modules/x/a.test.ts").is_some());
1711 let j = JsRule {
1712 runner: JsRunner::Jest,
1713 include: JsRunner::Jest.defaults(),
1714 exclude: Vec::new(),
1715 source: "jest (default)".to_owned(),
1716 };
1717 assert_eq!(j.breaks("src/__tests__/greet.js"), None);
1718 assert_eq!(j.breaks("greet.test.tsx"), None);
1719 assert!(j.breaks("src/greet.check.js").is_some());
1720 assert!(glob_match("src/**/*.test.ts", "src/a/b/c.test.ts"));
1721 assert!(glob_match("src/**/*.test.ts", "src/c.test.ts"));
1722 assert!(!glob_match("src/*.test.ts", "src/a/c.test.ts"));
1723 }
1724
1725 #[test]
1726 fn go_rules() {
1727 let none = BTreeSet::new();
1728 assert!(
1729 go_breaks(
1730 "pkg/greet/greet_test.go",
1731 "package greet\nfunc TestGreet(t *testing.T) {}\n",
1732 &none
1733 )
1734 .is_none()
1735 );
1736 assert!(
1737 go_breaks(
1738 "pkg/testdata/x_test.go",
1739 "func TestX(t *testing.T) {}",
1740 &none
1741 )
1742 .is_some_and(|(w, _)| w.contains("testdata"))
1743 );
1744 let tagged = "//go:build integration\n\npackage g\nfunc TestX(t *testing.T) {}\n";
1745 assert!(
1746 go_breaks("g/x_test.go", tagged, &none).is_some_and(|(w, _)| w.contains("integration"))
1747 );
1748 assert!(go_breaks("g/x_test.go", tagged, &set(&["integration"])).is_none());
1749 assert!(
1750 go_breaks(
1751 "g/x_test.go",
1752 "package g\nfunc testLower(t *testing.T) {}\n",
1753 &none
1754 )
1755 .is_some_and(|(w, _)| w.contains("testLower"))
1756 );
1757 assert_eq!(
1758 go_declared_tests(
1759 "func TestA(t *T) {}\nfunc Testb(t *T) {}\nfunc Example() {}\nfunc Test(t *T) {}\n"
1760 ),
1761 ["TestA", "Example", "Test"]
1762 );
1763 assert!(go_build_satisfied("linux || darwin", &none));
1764 assert!(!go_build_satisfied("!unix", &none));
1765 assert!(go_build_satisfied(
1766 "integration && !windows",
1767 &set(&["integration"])
1768 ));
1769 assert!(go_id_matches(
1770 "ex.com/m/g",
1771 &["TestA".to_owned()],
1772 "ex.com/m/g::TestA"
1773 ));
1774 assert!(go_id_matches(
1775 "ex.com/m/g",
1776 &["TestA".to_owned()],
1777 "TestA/sub"
1778 ));
1779 assert!(!go_id_matches(
1780 "ex.com/m/g",
1781 &["TestA".to_owned()],
1782 "ex.com/m/h::TestA"
1783 ));
1784 }
1785
1786 proptest! {
1787 #[test]
1789 fn declared_modules_are_reachable(names in proptest::collection::vec("[a-z]{1,6}", 1..6)) {
1790 let mut files: Vec<(String, String)> = Vec::new();
1791 let mut root = String::new();
1792 let mut dir = "tests/suite".to_owned();
1793 let mut prev: Option<String> = None;
1794 let mut uniq = BTreeSet::new();
1795 for n in names.iter().filter(|n| uniq.insert((*n).clone())) {
1796 let decl = format!("mod {n};\n");
1797 match &prev {
1798 None => root.push_str(&decl),
1799 Some(p) => {
1800 if let Some(f) = files.iter_mut().find(|(f, _)| f == p) {
1801 f.1.push_str(&decl);
1802 }
1803 }
1804 }
1805 let path = format!("{dir}/{n}.rs");
1806 files.push((path.clone(), "#[test] fn t() {}\n".to_owned()));
1807 dir = format!("{dir}/{n}");
1808 prev = Some(path);
1809 }
1810 files.push(("tests/suite/main.rs".to_owned(), root));
1811 let refs: Vec<(&str, &str)> = files.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
1812 let r = reach(&refs);
1813 for (f, _) in &files {
1814 prop_assert!(r.contains_key(f), "{f} unreachable");
1815 }
1816 }
1817 }
1818}