Skip to main content

code_kb_core/
test_commands.rs

1//! The shell command that runs one test, from the marker files of the project.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// A class that encloses a test. `test_case` says it derives from `unittest.TestCase`.
8pub struct TestClass<'a> {
9    pub name: &'a str,
10    pub test_case: bool,
11}
12
13/// A test whose command is wanted. `path` is relative to the project root and uses `/`. `kind` is
14/// the symbol kind (`function` or `method`) and `line` its first line. `classes` are the classes
15/// that enclose the test, outermost first.
16pub struct TestSpec<'a> {
17    pub language: &'a str,
18    pub kind: &'a str,
19    pub path: &'a str,
20    pub line: usize,
21    pub name: &'a str,
22    pub classes: &'a [TestClass<'a>],
23}
24
25/// Finds run commands. A marker file (`Cargo.toml`, `go.mod`, `package.json`, `pom.xml`,
26/// `build.gradle`, `build.gradle.kts`, `gradlew`) is read from disk at most once per folder for
27/// the life of the finder. A Rust or Go test file is read at most once for the same time.
28pub struct CommandFinder {
29    root: PathBuf,
30    folders: HashMap<String, Folder>,
31    sources: HashMap<String, Vec<String>>,
32}
33
34#[derive(Clone, Copy)]
35enum Runner {
36    Vitest,
37    Jest,
38    Mocha,
39    NodeTest,
40}
41
42#[derive(Clone, Copy)]
43enum Build {
44    Maven,
45    Gradle,
46}
47
48#[derive(Clone)]
49struct Cargo {
50    package: Option<String>,
51    workspace: bool,
52}
53
54#[derive(Clone)]
55struct PackageJson {
56    runner: Option<Runner>,
57}
58
59struct Folder {
60    cargo: Option<Cargo>,
61    go_mod: bool,
62    package_json: Option<PackageJson>,
63    build: Option<Build>,
64    gradlew: bool,
65}
66
67impl Folder {
68    fn read(dir: &Path) -> Self {
69        let text = |name: &str| {
70            fs::read(dir.join(name))
71                .ok()
72                .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
73        };
74        let has = |name: &str| dir.join(name).is_file();
75        Folder {
76            cargo: text("Cargo.toml").map(|toml| Cargo {
77                package: package_name(&toml),
78                workspace: has_workspace(&toml),
79            }),
80            go_mod: has("go.mod"),
81            package_json: text("package.json").map(|json| PackageJson {
82                runner: runner(&json),
83            }),
84            build: if has("pom.xml") {
85                Some(Build::Maven)
86            } else if has("build.gradle") || has("build.gradle.kts") {
87                Some(Build::Gradle)
88            } else {
89                None
90            },
91            gradlew: has("gradlew"),
92        }
93    }
94}
95
96/// The `name` key of the `[package]` table.
97pub(crate) fn package_name(toml: &str) -> Option<String> {
98    let mut in_package = false;
99    for line in toml.lines().map(str::trim) {
100        if line.starts_with('[') {
101            in_package = line == "[package]";
102        } else if in_package
103            && let Some((key, value)) = line.split_once('=')
104            && key.trim() == "name"
105        {
106            return value.split(['"', '\'']).nth(1).map(str::to_string);
107        }
108    }
109    None
110}
111
112fn has_workspace(toml: &str) -> bool {
113    toml.lines()
114        .map(str::trim)
115        .any(|line| line == "[workspace]" || line.starts_with("[workspace."))
116}
117
118fn runner(package_json: &str) -> Option<Runner> {
119    let json: serde_json::Value = serde_json::from_str(package_json).ok()?;
120    let script = json["scripts"]["test"].as_str().unwrap_or_default();
121    let uses = |name: &str| {
122        ["devDependencies", "dependencies"]
123            .iter()
124            .any(|section| json[*section].get(name).is_some())
125            || script.split_whitespace().any(|word| word == name)
126    };
127    if uses("vitest") {
128        Some(Runner::Vitest)
129    } else if uses("jest") {
130        Some(Runner::Jest)
131    } else if uses("mocha") {
132        Some(Runner::Mocha)
133    } else if script.contains("node --test") {
134        Some(Runner::NodeTest)
135    } else {
136        None
137    }
138}
139
140fn parent_dir(path: &str) -> &str {
141    path.rsplit_once('/').map_or("", |(parent, _)| parent)
142}
143
144fn relative<'a>(dir: &str, path: &'a str) -> &'a str {
145    if dir.is_empty() {
146        path
147    } else {
148        path.strip_prefix(dir)
149            .and_then(|rest| rest.strip_prefix('/'))
150            .unwrap_or(path)
151    }
152}
153
154fn backslashed(name: &str, special: impl Fn(char) -> bool) -> String {
155    let mut out = String::with_capacity(name.len());
156    for c in name.chars() {
157        if special(c) {
158            out.push('\\');
159        }
160        out.push(c);
161    }
162    out
163}
164
165/// `name` made safe inside a double-quoted shell word.
166fn double_quoted(name: &str) -> String {
167    backslashed(name, |c| matches!(c, '"' | '\\' | '$' | '`'))
168}
169
170fn single_quoted(text: &str) -> String {
171    format!("'{}'", text.replace('\'', "'\\''"))
172}
173
174/// `text` as one shell word: unchanged when it holds only `A-Z a-z 0-9 _ . / -`, else
175/// single-quoted.
176fn shell_word(text: &str) -> String {
177    let plain = !text.is_empty()
178        && text
179            .chars()
180            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'));
181    if plain {
182        text.to_string()
183    } else {
184        single_quoted(text)
185    }
186}
187
188/// `command` run from the folder `dir` below the project root.
189fn in_folder(dir: &str, command: String) -> String {
190    if dir.is_empty() {
191        command
192    } else {
193        format!("cd {} && {command}", shell_word(dir))
194    }
195}
196
197/// Stands for a character that a command line cannot carry: NUL, or half of a surrogate pair.
198const UNREPRESENTABLE: char = '\0';
199
200/// A JavaScript test name as the string its source literal holds. julie gives the literal text, so
201/// `\'` becomes `'`, `\\` becomes `\`, and `\n`, `\x41`, `\u0041`, `\u{41}`, and `\101` become
202/// the character they name. A backslash before a line break is a line continuation: both vanish.
203/// A character a command line cannot carry becomes NUL.
204fn js_unescaped(name: &str) -> String {
205    let mut out = String::with_capacity(name.len());
206    let mut rest = name;
207    while let Some(c) = rest.chars().next() {
208        rest = &rest[c.len_utf8()..];
209        if c != '\\' {
210            out.push(c);
211            continue;
212        }
213        let Some(escaped) = rest.chars().next() else {
214            out.push('\\');
215            break;
216        };
217        if let Some(len) = line_break_len(rest) {
218            rest = &rest[len..];
219            continue;
220        }
221        let (decoded, len) = decoded_escape(rest).unwrap_or((escaped, escaped.len_utf8()));
222        out.push(decoded);
223        rest = &rest[len..];
224    }
225    out
226}
227
228/// The length of the line break that starts `text`: `\n`, `\r\n`, `\r`, U+2028, or U+2029.
229fn line_break_len(text: &str) -> Option<usize> {
230    if text.starts_with("\r\n") {
231        return Some(2);
232    }
233    text.chars()
234        .next()
235        .filter(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}'))
236        .map(char::len_utf8)
237}
238
239/// The character that the escape after a backslash names, with the length of the escape in bytes.
240/// `None` for an escape that names itself or is malformed.
241fn decoded_escape(text: &str) -> Option<(char, usize)> {
242    match text.as_bytes().first()? {
243        b'n' => Some(('\n', 1)),
244        b't' => Some(('\t', 1)),
245        b'r' => Some(('\r', 1)),
246        b'b' => Some(('\u{8}', 1)),
247        b'f' => Some(('\u{c}', 1)),
248        b'v' => Some(('\u{b}', 1)),
249        b'0'..=b'3' => octal_escape(text, 3),
250        b'4'..=b'7' => octal_escape(text, 2),
251        b'x' => Some((char::from_u32(hex_value(text.get(1..3)?)?)?, 3)),
252        b'u' => unicode_escape(text),
253        _ => None,
254    }
255}
256
257fn octal_escape(text: &str, max_digits: usize) -> Option<(char, usize)> {
258    let len = text
259        .bytes()
260        .take(max_digits)
261        .take_while(|byte| matches!(byte, b'0'..=b'7'))
262        .count();
263    Some((
264        char::from_u32(u32::from_str_radix(&text[..len], 8).ok()?)?,
265        len,
266    ))
267}
268
269fn hex_value(digits: &str) -> Option<u32> {
270    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
271        return None;
272    }
273    u32::from_str_radix(digits, 16).ok()
274}
275
276/// `text` starts with the `u` of `\uHHHH` or `\u{H…}`. A pair of `\u` surrogates is one character.
277fn unicode_escape(text: &str) -> Option<(char, usize)> {
278    if let Some(braced) = text.strip_prefix("u{") {
279        let digits = &braced[..braced.find('}')?];
280        let code = hex_value(digits).filter(|code| *code <= 0x10FFFF)?;
281        let character = char::from_u32(code).unwrap_or(UNREPRESENTABLE);
282        return Some((character, digits.len() + 3));
283    }
284    let high = hex_value(text.get(1..5)?)?;
285    let low = text
286        .get(5..)
287        .and_then(|after| after.strip_prefix("\\u"))
288        .and_then(|after| hex_value(after.get(..4)?))
289        .filter(|low| (0xDC00..=0xDFFF).contains(low));
290    match (high, low) {
291        (0xD800..=0xDBFF, Some(low)) => {
292            let code = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
293            Some((char::from_u32(code)?, 11))
294        }
295        _ => Some((char::from_u32(high).unwrap_or(UNREPRESENTABLE), 5)),
296    }
297}
298
299/// The length of the `${…}` hole of a template literal that starts `text`, up to its matching `}`.
300/// A string or a template inside the hole may hold a `}`.
301fn hole_len(text: &str) -> Option<usize> {
302    let code = text.strip_prefix("${")?;
303    Some(2 + closing_brace(code.as_bytes())? + 1)
304}
305
306/// The index of the `}` that closes the code in `code`, which starts after a `{`.
307fn closing_brace(code: &[u8]) -> Option<usize> {
308    let mut depth = 0;
309    let mut at = 0;
310    while at < code.len() {
311        match code[at] {
312            b'{' => depth += 1,
313            b'}' if depth == 0 => return Some(at),
314            b'}' => depth -= 1,
315            b'\'' | b'"' | b'`' => {
316                at = after_literal(code, at)?;
317                continue;
318            }
319            _ => {}
320        }
321        at += 1;
322    }
323    None
324}
325
326/// The index after the string or template literal that starts at `start`. A template may hold
327/// holes of its own.
328fn after_literal(code: &[u8], start: usize) -> Option<usize> {
329    let quote = code[start];
330    let mut at = start + 1;
331    while at < code.len() {
332        match code[at] {
333            b'\\' => at += 1,
334            b'$' if quote == b'`' && code.get(at + 1) == Some(&b'{') => {
335                at += 2 + closing_brace(&code[at + 2..])?;
336            }
337            byte if byte == quote => return Some(at + 1),
338            _ => {}
339        }
340        at += 1;
341    }
342    None
343}
344
345/// The text of a JavaScript test name between its `${…}` holes, decoded by `js_unescaped`. A
346/// character a command line cannot carry ends a part like a hole does.
347fn literal_parts(name: &str) -> Vec<String> {
348    let bytes = name.as_bytes();
349    let mut raw_parts = Vec::new();
350    let (mut start, mut at) = (0, 0);
351    while at < bytes.len() {
352        if bytes[at] == b'\\' {
353            at += 2;
354        } else if bytes[at] == b'$'
355            && let Some(len) = hole_len(&name[at..])
356        {
357            raw_parts.push(&name[start..at]);
358            at += len;
359            start = at;
360        } else {
361            at += 1;
362        }
363    }
364    raw_parts.push(&name[start..]);
365    raw_parts
366        .into_iter()
367        .flat_map(|raw| {
368            js_unescaped(raw)
369                .split(UNREPRESENTABLE)
370                .map(str::to_string)
371                .collect::<Vec<_>>()
372        })
373        .collect()
374}
375
376fn push_regex_escaped(out: &mut String, c: char) {
377    match c {
378        '\n' => out.push_str("\\n"),
379        '\t' => out.push_str("\\t"),
380        '\r' => out.push_str("\\r"),
381        '\u{2028}' | '\u{2029}' => out.push_str(&format!("\\u{:04x}", u32::from(c))),
382        c if c.is_control() => out.push_str(&format!("\\x{:02x}", u32::from(c))),
383        '\\' | '^' | '$' | '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' => {
384            out.push('\\');
385            out.push(c);
386        }
387        _ => out.push(c),
388    }
389}
390
391fn is_word_byte(byte: &u8) -> bool {
392    byte.is_ascii_alphanumeric() || *byte == b'_'
393}
394
395/// The length of the `test.each` placeholder that starts `text`: `%s %d %i %f %j %o %O %p %#`, or
396/// `$name` with optional `.path` segments.
397fn placeholder_len(text: &str) -> Option<usize> {
398    let bytes = text.as_bytes();
399    match bytes {
400        [
401            b'%',
402            b's' | b'd' | b'i' | b'f' | b'j' | b'o' | b'O' | b'p' | b'#',
403            ..,
404        ] => Some(2),
405        [b'$', first, ..] if first.is_ascii_alphabetic() || *first == b'_' => {
406            let mut len = 1;
407            loop {
408                len += bytes[len..].iter().take_while(|b| is_word_byte(b)).count();
409                match &bytes[len..] {
410                    [b'.', next, ..] if is_word_byte(next) => len += 1,
411                    _ => return Some(len),
412                }
413            }
414        }
415        _ => None,
416    }
417}
418
419/// `name` as the regular expression of `-t`: each placeholder matches any text, `%%` matches one
420/// or two percent signs (`test.each` writes one, a plain test writes two), and every other
421/// character matches itself.
422fn each_pattern(name: &str) -> String {
423    let mut out = String::with_capacity(name.len());
424    let mut rest = name;
425    while let Some(c) = rest.chars().next() {
426        if rest.starts_with("%%") {
427            out.push_str("%%?");
428            rest = &rest[2..];
429        } else if let Some(len) = placeholder_len(rest) {
430            out.push_str(".*");
431            rest = &rest[len..];
432        } else {
433            push_regex_escaped(&mut out, c);
434            rest = &rest[c.len_utf8()..];
435        }
436    }
437    out
438}
439
440/// The regular expression of `-t` for a name: each hole between its literal parts matches any
441/// text.
442fn name_pattern(parts: &[String]) -> String {
443    parts
444        .iter()
445        .map(|part| each_pattern(part))
446        .collect::<Vec<_>>()
447        .join(".*")
448}
449
450/// A character that would split the command line: a control character other than a tab, or a
451/// Unicode line or paragraph separator.
452fn splits_a_line(c: char) -> bool {
453    (c.is_control() && c != '\t') || matches!(c, '\u{2028}' | '\u{2029}')
454}
455
456/// The part of a name that `--fgrep` can match as a substring: the longest piece, between holes
457/// and characters that split a line, that holds more than blanks. The first of equal pieces wins.
458fn longest_literal(parts: &[String]) -> Option<&str> {
459    parts
460        .iter()
461        .flat_map(|part| part.split(splits_a_line))
462        .filter(|piece| !piece.trim().is_empty())
463        .rev()
464        .max_by_key(|piece| piece.len())
465}
466
467fn depth(dir: &str) -> usize {
468    if dir.is_empty() {
469        0
470    } else {
471        dir.split('/').count()
472    }
473}
474
475/// The path of the Gradle wrapper in `wrapper_dir` as seen from `dir`, a folder at or below it.
476fn wrapper_path(dir: &str, wrapper_dir: &str) -> String {
477    match depth(dir) - depth(wrapper_dir) {
478        0 => "./gradlew".to_string(),
479        up => format!("{}gradlew", "../".repeat(up)),
480    }
481}
482
483/// Whether `go test` reads `name` as a function of the kind `prefix`: the prefix alone, or the
484/// prefix followed by anything but a lowercase letter.
485fn names_go_test(name: &str, prefix: &str) -> bool {
486    name.strip_prefix(prefix)
487        .is_some_and(|rest| !rest.chars().next().is_some_and(char::is_lowercase))
488}
489
490/// Whether pytest collects a test in these classes under its defaults: every class is named
491/// `Test…` or derives from `unittest.TestCase`, and no class around another is a `TestCase`,
492/// because pytest collects only the methods of one.
493fn pytest_collects(classes: &[TestClass]) -> bool {
494    let named = classes
495        .iter()
496        .all(|class| class.test_case || class.name.starts_with("Test"));
497    let inside_test_case = classes
498        .split_last()
499        .is_some_and(|(_, outer)| outer.iter().any(|class| class.test_case));
500    named && !inside_test_case
501}
502
503/// `pytest path::Class::name`. pytest rejects a node id whose path holds `[` and splits one at
504/// `::`, so a test in such a folder runs from that folder; a file name with either has no command.
505fn python(test: &TestSpec) -> Option<String> {
506    if !pytest_collects(test.classes) {
507        return None;
508    }
509    let breaks_a_node_id = |text: &str| text.contains('[') || text.contains("::");
510    let (dir, file) = test.path.rsplit_once('/').unwrap_or(("", test.path));
511    if breaks_a_node_id(file) {
512        return None;
513    }
514    let (dir, file) = if breaks_a_node_id(dir) {
515        (dir, file)
516    } else {
517        ("", test.path)
518    };
519    let classes: String = test
520        .classes
521        .iter()
522        .map(|class| format!("{}::", class.name))
523        .collect();
524    Some(in_folder(
525        dir,
526        format!("pytest {}::{classes}{}", shell_word(file), test.name),
527    ))
528}
529
530impl CommandFinder {
531    /// A finder for the project at `root`. It never looks above `root`.
532    pub fn new(root: &Path) -> Self {
533        Self {
534            root: root.to_path_buf(),
535            folders: HashMap::new(),
536            sources: HashMap::new(),
537        }
538    }
539
540    /// The command that runs `test`, or `None` when the language or the runner is unknown.
541    pub fn command(&mut self, test: &TestSpec) -> Option<String> {
542        match test.language {
543            "python" => python(test),
544            "rust" => Some(self.rust(test)),
545            "go" => self.go(test),
546            "javascript" | "typescript" | "tsx" | "jsx" => self.script(test),
547            "java" => self.java(test),
548            "csharp" => test.classes.last().map(|class| {
549                let filter = double_quoted(&format!("{}.{}", class.name, test.name));
550                format!("dotnet test --filter \"FullyQualifiedName~{filter}\"")
551            }),
552            _ => None,
553        }
554    }
555
556    fn folder(&mut self, dir: &str) -> &Folder {
557        let root = &self.root;
558        self.folders
559            .entry(dir.to_string())
560            .or_insert_with(|| Folder::read(&root.join(dir)))
561    }
562
563    /// The folder nearest to `dir`, at or above it and not above the root, that `pick` accepts,
564    /// with what `pick` found there.
565    fn nearest<T>(
566        &mut self,
567        dir: &str,
568        pick: impl Fn(&Folder) -> Option<T>,
569    ) -> Option<(String, T)> {
570        let mut dir = dir;
571        loop {
572            if let Some(found) = pick(self.folder(dir)) {
573                return Some((dir.to_string(), found));
574            }
575            if dir.is_empty() {
576                return None;
577            }
578            dir = parent_dir(dir);
579        }
580    }
581
582    /// The lines of the file `path`, empty when it cannot be read.
583    fn source(&mut self, path: &str) -> &[String] {
584        let root = &self.root;
585        self.sources.entry(path.to_string()).or_insert_with(|| {
586            fs::read_to_string(root.join(path))
587                .map(|text| text.lines().map(str::to_string).collect())
588                .unwrap_or_default()
589        })
590    }
591
592    fn rust(&mut self, test: &TestSpec) -> String {
593        let ignored = if self.has_ignore_attribute(test) {
594            " -- --ignored"
595        } else {
596            ""
597        };
598        let name = format!("{}{ignored}", test.name);
599        let Some((dir, cargo)) = self.nearest(parent_dir(test.path), |folder| folder.cargo.clone())
600        else {
601            return format!("cargo test {name}");
602        };
603        let command = match cargo.package {
604            None => format!("cargo test {name}"),
605            Some(package) => match relative(&dir, test.path)
606                .strip_prefix("tests/")
607                .and_then(|rest| rest.strip_suffix(".rs"))
608                .filter(|stem| !stem.contains('/'))
609            {
610                Some(stem) => {
611                    format!("cargo test -p {package} --test {} {name}", shell_word(stem))
612                }
613                None => format!("cargo test -p {package} {name}"),
614            },
615        };
616        if self
617            .folder("")
618            .cargo
619            .as_ref()
620            .is_some_and(|root| root.workspace)
621        {
622            command
623        } else {
624            in_folder(&dir, command)
625        }
626    }
627
628    /// Whether an attribute above the function says `#[ignore]` or `#[ignore = "…"]`.
629    fn has_ignore_attribute(&mut self, test: &TestSpec) -> bool {
630        let above = test.line.saturating_sub(1);
631        self.source(test.path)
632            .iter()
633            .take(above)
634            .rev()
635            .map(|line| line.trim())
636            .take_while(|line| line.is_empty() || line.starts_with('#') || line.starts_with("//"))
637            .filter(|line| line.starts_with('#'))
638            .any(|line| {
639                line.match_indices("#[ignore").any(|(at, found)| {
640                    matches!(
641                        line[at + found.len()..].trim_start().chars().next(),
642                        Some(']' | '=')
643                    )
644                })
645            })
646    }
647
648    /// `None` for a function that `go test` does not run by name: a method, a name that is not
649    /// `Test`, `Benchmark`, `Fuzz`, or `Example` followed by a capital, `TestMain`, a file that is
650    /// not `_test.go`, or an example with no `// Output:` comment.
651    fn go(&mut self, test: &TestSpec) -> Option<String> {
652        let name = test.name;
653        if test.kind != "function" || !test.path.ends_with("_test.go") {
654            return None;
655        }
656        let selector = if names_go_test(name, "Benchmark") {
657            format!("-bench '^{name}$' -run '^$'")
658        } else if (names_go_test(name, "Test") && name != "TestMain")
659            || names_go_test(name, "Fuzz")
660            || (names_go_test(name, "Example") && self.go_example_prints_output(test))
661        {
662            format!("-run '^{name}$'")
663        } else {
664            return None;
665        };
666        let module = self
667            .nearest(parent_dir(test.path), |folder| folder.go_mod.then_some(()))
668            .map_or_else(String::new, |(dir, _)| dir);
669        let dir = match parent_dir(relative(&module, test.path)) {
670            "" => ".",
671            dir => dir,
672        };
673        Some(in_folder(
674            &module,
675            format!("go test {} {selector}", shell_word(&format!("./{dir}"))),
676        ))
677    }
678
679    /// Whether the example that starts on `test.line` ends in an `// Output:` comment. `go test`
680    /// compiles an example without one and does not run it. The body ends at the first `}` in
681    /// column 0, which `gofmt` guarantees.
682    fn go_example_prints_output(&mut self, test: &TestSpec) -> bool {
683        let mut body = self
684            .source(test.path)
685            .iter()
686            .skip(test.line.saturating_sub(1));
687        if body
688            .next()
689            .is_none_or(|first| first.trim_end().ends_with('}'))
690        {
691            return false;
692        }
693        body.take_while(|line| line.as_str() != "}").any(|line| {
694            let comment = line.trim().strip_prefix("//").unwrap_or("").trim_start();
695            let comment = comment.to_ascii_lowercase();
696            comment.starts_with("output:") || comment.starts_with("unordered output:")
697        })
698    }
699
700    fn script(&mut self, test: &TestSpec) -> Option<String> {
701        let (dir, package) =
702            self.nearest(parent_dir(test.path), |folder| folder.package_json.clone())?;
703        let path = shell_word(relative(&dir, test.path));
704        let parts = literal_parts(test.name);
705        let pattern = || double_quoted(&name_pattern(&parts));
706        let command = match package.runner? {
707            Runner::Vitest => format!("npx vitest run {path} -t \"{}\"", pattern()),
708            Runner::Jest => format!("npx jest --runTestsByPath {path} -t \"{}\"", pattern()),
709            Runner::Mocha => match longest_literal(&parts) {
710                Some(part) => format!("npx mocha {path} --fgrep \"{}\"", double_quoted(part)),
711                None => format!("npx mocha {path}"),
712            },
713            Runner::NodeTest => format!(
714                "node --test-name-pattern \"{}\" {path}",
715                double_quoted(&format!("^{}$", name_pattern(&parts)))
716            ),
717        };
718        Some(in_folder(&dir, command))
719    }
720
721    fn java(&mut self, test: &TestSpec) -> Option<String> {
722        if test.classes.is_empty() {
723            return None;
724        }
725        let class = test
726            .classes
727            .iter()
728            .map(|class| class.name)
729            .collect::<Vec<_>>()
730            .join("$");
731        let (dir, build) = self.nearest(parent_dir(test.path), |folder| folder.build)?;
732        let command = match build {
733            Build::Maven => format!(
734                "mvn test -Dtest={}#{}",
735                shell_word(&class),
736                shell_word(test.name)
737            ),
738            Build::Gradle => {
739                let tool = match self.nearest(&dir, |folder| folder.gradlew.then_some(())) {
740                    Some((wrapper_dir, _)) => wrapper_path(&dir, &wrapper_dir),
741                    None => "gradle".to_string(),
742                };
743                let filter = single_quoted(&format!("{class}.{}", test.name));
744                format!("{tool} test --tests {filter}")
745            }
746        };
747        Some(in_folder(&dir, command))
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use crate::safe_tempdir;
755    use std::fs;
756
757    fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
758        let temp = safe_tempdir();
759        for (path, content) in files {
760            let full = temp.path().join(path);
761            fs::create_dir_all(full.parent().unwrap()).unwrap();
762            fs::write(full, content).unwrap();
763        }
764        temp
765    }
766
767    fn command(
768        project: &tempfile::TempDir,
769        language: &str,
770        path: &str,
771        name: &str,
772        class: Option<&str>,
773    ) -> Option<String> {
774        nested(project, language, path, name, class.as_slice())
775    }
776
777    fn script(files: &[(&str, &str)], path: &str, name: &str) -> Option<String> {
778        command(&project(files), "typescript", path, name, None)
779    }
780
781    fn nested(
782        project: &tempfile::TempDir,
783        language: &str,
784        path: &str,
785        name: &str,
786        classes: &[&str],
787    ) -> Option<String> {
788        let classes: Vec<TestClass> = classes
789            .iter()
790            .map(|name| TestClass {
791                name,
792                test_case: false,
793            })
794            .collect();
795        CommandFinder::new(project.path()).command(&TestSpec {
796            language,
797            kind: "function",
798            path,
799            line: 1,
800            name,
801            classes: &classes,
802        })
803    }
804
805    #[test]
806    fn python_tests_run_through_pytest_with_the_class_only_for_a_member_of_one() {
807        let empty = project(&[]);
808
809        assert_eq!(
810            command(&empty, "python", "tests/test_a.py", "test_one", None).as_deref(),
811            Some("pytest tests/test_a.py::test_one")
812        );
813        assert_eq!(
814            command(
815                &empty,
816                "python",
817                "tests/test_a.py",
818                "test_one",
819                Some("TestA")
820            )
821            .as_deref(),
822            Some("pytest tests/test_a.py::TestA::test_one")
823        );
824    }
825
826    #[test]
827    fn a_rust_test_names_the_package_of_the_nearest_cargo_toml() {
828        let crate_dir = project(&[
829            (
830                "Cargo.toml",
831                "[workspace]\nmembers = [\"crates/a\"]\n\n[package]\nname = \"outer\"\n",
832            ),
833            (
834                "crates/a/Cargo.toml",
835                "[dependencies]\nname = \"not-this\"\n\n[package]\nversion = \"0.1.0\"\nname = \"inner-crate\" # the crate\n\n[lib]\nname = \"also_not\"\n",
836            ),
837        ]);
838
839        let run = |path: &str| command(&crate_dir, "rust", path, "case", None);
840
841        assert_eq!(
842            run("crates/a/src/lib.rs").as_deref(),
843            Some("cargo test -p inner-crate case")
844        );
845        assert_eq!(
846            run("crates/a/tests/it.rs").as_deref(),
847            Some("cargo test -p inner-crate --test it case")
848        );
849        assert_eq!(
850            run("crates/a/tests/sub/it.rs").as_deref(),
851            Some("cargo test -p inner-crate case")
852        );
853        assert_eq!(
854            run("src/main.rs").as_deref(),
855            Some("cargo test -p outer case")
856        );
857    }
858
859    #[test]
860    fn a_rust_test_without_a_package_name_runs_by_name_alone() {
861        let none = project(&[]);
862        let workspace_only = project(&[("Cargo.toml", "[workspace]\nmembers = []\n")]);
863
864        assert_eq!(
865            command(&none, "rust", "tests/it.rs", "case", None).as_deref(),
866            Some("cargo test case")
867        );
868        assert_eq!(
869            command(&workspace_only, "rust", "src/lib.rs", "case", None).as_deref(),
870            Some("cargo test case")
871        );
872    }
873
874    #[test]
875    fn a_go_test_names_its_folder_relative_to_the_nearest_go_mod() {
876        let module = project(&[
877            ("go.mod", "module example.com/m\n"),
878            ("sub/go.mod", "module example.com/m/sub\n"),
879        ]);
880
881        let run = |path: &str| command(&module, "go", path, "TestRun", None);
882
883        assert_eq!(
884            run("pkg/run_test.go").as_deref(),
885            Some("go test ./pkg -run '^TestRun$'")
886        );
887        assert_eq!(
888            run("run_test.go").as_deref(),
889            Some("go test ./. -run '^TestRun$'")
890        );
891        assert_eq!(
892            run("sub/deep/pkg/run_test.go").as_deref(),
893            Some("cd sub && go test ./deep/pkg -run '^TestRun$'")
894        );
895        assert_eq!(
896            run("sub/run_test.go").as_deref(),
897            Some("cd sub && go test ./. -run '^TestRun$'")
898        );
899    }
900
901    #[test]
902    fn a_script_test_picks_the_runner_the_nearest_package_json_names() {
903        let run = |body: &str| {
904            script(
905                &[("package.json", body)],
906                "src/a.test.ts",
907                "adds \"two\" numbers",
908            )
909        };
910
911        assert_eq!(
912            run(r#"{"devDependencies":{"vitest":"^1"}}"#).as_deref(),
913            Some(r#"npx vitest run src/a.test.ts -t "adds \"two\" numbers""#)
914        );
915        assert_eq!(
916            run(r#"{"dependencies":{"jest":"^29"}}"#).as_deref(),
917            Some(r#"npx jest --runTestsByPath src/a.test.ts -t "adds \"two\" numbers""#)
918        );
919        assert_eq!(
920            run(r#"{"devDependencies":{"mocha":"^10"}}"#).as_deref(),
921            Some(r#"npx mocha src/a.test.ts --fgrep "adds \"two\" numbers""#)
922        );
923        assert_eq!(
924            run(r#"{"scripts":{"test":"node --test"}}"#).as_deref(),
925            Some(r#"node --test-name-pattern "^adds \"two\" numbers\$" src/a.test.ts"#)
926        );
927        assert_eq!(
928            run(r#"{"scripts":{"test":"cross-env CI=1 jest --runInBand"}}"#).as_deref(),
929            Some(r#"npx jest --runTestsByPath src/a.test.ts -t "adds \"two\" numbers""#)
930        );
931        assert_eq!(run(r#"{"scripts":{"test":"tap"}}"#), None);
932        assert_eq!(run("not json"), None);
933    }
934
935    #[test]
936    fn a_script_test_uses_the_nearest_package_json_only() {
937        let files = [
938            ("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#),
939            ("pkg/package.json", r#"{"type":"module"}"#),
940        ];
941
942        assert_eq!(script(&files, "pkg/a.test.ts", "case"), None);
943        assert_eq!(
944            script(&files, "other/a.test.ts", "case").as_deref(),
945            Some(r#"npx vitest run other/a.test.ts -t "case""#)
946        );
947        assert_eq!(script(&[], "a.test.ts", "case"), None);
948    }
949
950    #[test]
951    fn a_script_name_escapes_the_characters_a_double_quoted_shell_word_expands() {
952        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
953
954        assert_eq!(
955            script(&files, "a.test.ts", r#"costs $5 `now` a\\b"#).as_deref(),
956            Some(r#"npx mocha a.test.ts --fgrep "costs \$5 \`now\` a\\b""#)
957        );
958    }
959
960    #[test]
961    fn a_vitest_or_jest_name_escapes_the_characters_of_a_regular_expression() {
962        let name = "should not render evil javascript links in anchor href (prevent XSS)";
963        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
964        let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
965
966        assert_eq!(
967            script(&vitest, "a.test.ts", name).as_deref(),
968            Some(
969                r#"npx vitest run a.test.ts -t "should not render evil javascript links in anchor href \\(prevent XSS\\)""#
970            )
971        );
972        assert_eq!(
973            script(&jest, "a.test.ts", "parses v1.2 (beta)").as_deref(),
974            Some(r#"npx jest --runTestsByPath a.test.ts -t "parses v1\\.2 \\(beta\\)""#)
975        );
976    }
977
978    #[test]
979    fn a_vitest_name_escapes_every_regular_expression_character_before_the_shell_quoting() {
980        let files = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
981
982        assert_eq!(
983            script(&files, "a.test.ts", r#"\\ ^ $ . * + ? ( ) [ ] { } | "x""#).as_deref(),
984            Some(
985                r#"npx vitest run a.test.ts -t "\\\\ \\^ \\\$ \\. \\* \\+ \\? \\( \\) \\[ \\] \\{ \\} \\| \"x\"""#
986            )
987        );
988    }
989
990    #[test]
991    fn a_mocha_name_is_a_plain_substring_so_it_keeps_its_regular_expression_characters() {
992        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
993
994        assert_eq!(
995            script(&files, "a.test.ts", "parses v1.2 (beta)").as_deref(),
996            Some(r#"npx mocha a.test.ts --fgrep "parses v1.2 (beta)""#)
997        );
998    }
999
1000    #[test]
1001    fn a_script_name_decodes_its_source_escapes_before_any_escaping() {
1002        let run = |dependency: &str, name: &str| {
1003            script(
1004                &[(
1005                    "package.json",
1006                    &format!(r#"{{"devDependencies":{{"{dependency}":"^1"}}}}"#),
1007                )],
1008                "a.test.js",
1009                name,
1010            )
1011        };
1012
1013        assert_eq!(
1014            run("vitest", r"doesn\'t throw on empty input").as_deref(),
1015            Some(r#"npx vitest run a.test.js -t "doesn't throw on empty input""#)
1016        );
1017        assert_eq!(
1018            run("jest", r#"says \"hi\" politely"#).as_deref(),
1019            Some(r#"npx jest --runTestsByPath a.test.js -t "says \"hi\" politely""#)
1020        );
1021        assert_eq!(
1022            run("vitest", r"tick \` and back\\slash").as_deref(),
1023            Some(r#"npx vitest run a.test.js -t "tick \` and back\\\\slash""#)
1024        );
1025        assert_eq!(
1026            run("vitest", r"two\nlines\tand\rmore").as_deref(),
1027            Some(r#"npx vitest run a.test.js -t "two\\nlines\\tand\\rmore""#)
1028        );
1029        assert_eq!(
1030            run("mocha", r#"doesn\'t say \"hi\" or back\\slash"#).as_deref(),
1031            Some(r#"npx mocha a.test.js --fgrep "doesn't say \"hi\" or back\\slash""#)
1032        );
1033    }
1034
1035    #[test]
1036    fn a_vitest_or_jest_name_turns_each_format_placeholder_into_a_wildcard() {
1037        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1038        let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1039
1040        assert_eq!(
1041            script(
1042                &vitest,
1043                "a.test.ts",
1044                "adds %i + %d = %s, %f %j %o %O %p #%#"
1045            )
1046            .as_deref(),
1047            Some(r#"npx vitest run a.test.ts -t "adds .* \\+ .* = .*, .* .* .* .* .* #.*""#)
1048        );
1049        assert_eq!(
1050            script(
1051                &jest,
1052                "a.test.ts",
1053                "$a is $b.c.d. costs $5 or 100% and 50%x"
1054            )
1055            .as_deref(),
1056            Some(
1057                r#"npx jest --runTestsByPath a.test.ts -t ".* is .*\\. costs \\\$5 or 100% and 50%x""#
1058            )
1059        );
1060    }
1061
1062    #[test]
1063    fn a_mocha_name_keeps_its_format_placeholders() {
1064        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1065
1066        assert_eq!(
1067            script(&files, "a.test.ts", "adds %i + %s for $name").as_deref(),
1068            Some(r#"npx mocha a.test.ts --fgrep "adds %i + %s for \$name""#)
1069        );
1070    }
1071
1072    #[test]
1073    fn a_mocha_name_keeps_a_double_percent_as_written() {
1074        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1075
1076        assert_eq!(
1077            script(&files, "a.test.ts", "pct 100%% sure").as_deref(),
1078            Some(r#"npx mocha a.test.ts --fgrep "pct 100%% sure""#)
1079        );
1080    }
1081
1082    #[test]
1083    fn a_vitest_or_jest_name_matches_a_double_percent_as_one_or_two_percent_signs() {
1084        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1085        let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1086
1087        assert_eq!(
1088            script(&vitest, "a.test.ts", "pct 100%% sure %s").as_deref(),
1089            Some(r#"npx vitest run a.test.ts -t "pct 100%%? sure .*""#)
1090        );
1091        assert_eq!(
1092            script(&jest, "a.test.ts", "%%%s%%").as_deref(),
1093            Some(r#"npx jest --runTestsByPath a.test.ts -t "%%?.*%%?""#)
1094        );
1095    }
1096
1097    #[test]
1098    fn a_vitest_or_jest_name_turns_each_template_hole_into_a_wildcard() {
1099        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1100        let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1101
1102        assert_eq!(
1103            script(&vitest, "a.test.ts", "handles ${kind} kind").as_deref(),
1104            Some(r#"npx vitest run a.test.ts -t "handles .* kind""#)
1105        );
1106        assert_eq!(
1107            script(&jest, "a.test.ts", "${a} and ${b.c}! (${ {x: 1}.x })").as_deref(),
1108            Some(r#"npx jest --runTestsByPath a.test.ts -t ".* and .*! \\(.*\\)""#)
1109        );
1110        assert_eq!(
1111            script(&vitest, "a.test.ts", "costs ${5").as_deref(),
1112            Some(r#"npx vitest run a.test.ts -t "costs \\\$\\{5""#)
1113        );
1114        assert_eq!(
1115            script(&vitest, "a.test.ts", r"costs \${5} total").as_deref(),
1116            Some(r#"npx vitest run a.test.ts -t "costs \\\$\\{5\\} total""#)
1117        );
1118        assert_eq!(
1119            script(&vitest, "a.test.ts", "$a and ${b}$c").as_deref(),
1120            Some(r#"npx vitest run a.test.ts -t ".* and .*.*""#)
1121        );
1122    }
1123
1124    #[test]
1125    fn a_mocha_name_with_a_template_hole_uses_its_longest_literal_part() {
1126        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1127        let run = |name: &str| script(&files, "a.test.ts", name);
1128
1129        assert_eq!(
1130            run("handles ${kind} kind").as_deref(),
1131            Some(r#"npx mocha a.test.ts --fgrep "handles ""#)
1132        );
1133        assert_eq!(
1134            run("a ${x} and a longer ${ {y: 1}.y } tail").as_deref(),
1135            Some(r#"npx mocha a.test.ts --fgrep " and a longer ""#)
1136        );
1137        assert_eq!(
1138            run("ab${x}cd").as_deref(),
1139            Some(r#"npx mocha a.test.ts --fgrep "ab""#)
1140        );
1141        assert_eq!(
1142            run(r"costs \${5} total").as_deref(),
1143            Some(r#"npx mocha a.test.ts --fgrep "costs \${5} total""#)
1144        );
1145    }
1146
1147    #[test]
1148    fn a_mocha_name_with_no_literal_part_has_no_name_filter() {
1149        let files = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1150        let run = |name: &str| script(&files, "a.test.ts", name);
1151
1152        assert_eq!(run("${kind}").as_deref(), Some("npx mocha a.test.ts"));
1153        assert_eq!(run("${a} ${b}").as_deref(), Some("npx mocha a.test.ts"));
1154    }
1155
1156    #[test]
1157    fn a_script_name_decodes_hex_unicode_and_octal_escapes() {
1158        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1159        let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1160
1161        assert_eq!(
1162            script(&vitest, "a.test.js", r"caf\xe9 é \u{1F600} 😀").as_deref(),
1163            Some("npx vitest run a.test.js -t \"café é 😀 😀\"")
1164        );
1165        assert_eq!(
1166            script(&mocha, "a.test.js", r"\101\60\8 \x41B \\x41").as_deref(),
1167            Some(r#"npx mocha a.test.js --fgrep "A08 AB \\x41""#)
1168        );
1169        assert_eq!(
1170            script(&vitest, "a.test.js", r"a\bb\fc\vd").as_deref(),
1171            Some(r#"npx vitest run a.test.js -t "a\\x08b\\x0cc\\x0bd""#)
1172        );
1173        assert_eq!(
1174            script(&vitest, "a.test.js", r"bad \xZ1 \u12 \u{110000} \u{}").as_deref(),
1175            Some(r#"npx vitest run a.test.js -t "bad xZ1 u12 u\\{110000\\} u\\{\\}""#)
1176        );
1177    }
1178
1179    #[test]
1180    fn a_character_a_command_cannot_carry_splits_a_script_name_like_a_template_hole() {
1181        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1182        let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1183
1184        assert_eq!(
1185            script(&vitest, "a.test.js", r"a\0b\x00c\ud83dd").as_deref(),
1186            Some(r#"npx vitest run a.test.js -t "a.*b.*c.*d""#)
1187        );
1188        assert_eq!(
1189            script(&mocha, "a.test.js", r"a\0bcd\ud83de").as_deref(),
1190            Some(r#"npx mocha a.test.js --fgrep "bcd""#)
1191        );
1192    }
1193
1194    #[test]
1195    fn a_backslash_before_a_multibyte_character_or_at_the_end_of_a_name_keeps_the_hole_scan_in_step()
1196     {
1197        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1198
1199        assert_eq!(
1200            script(&vitest, "a.test.js", r"caf\é ${x} é${y}").as_deref(),
1201            Some(r#"npx vitest run a.test.js -t "café .* é.*""#)
1202        );
1203        assert_eq!(
1204            script(&vitest, "a.test.js", r"end\").as_deref(),
1205            Some(r#"npx vitest run a.test.js -t "end\\\\""#)
1206        );
1207    }
1208
1209    #[test]
1210    fn a_path_with_a_space_or_shell_character_is_single_quoted_in_every_command() {
1211        let scripts =
1212            |dependency: &str| format!(r#"{{"devDependencies":{{"{dependency}":"^1"}}}}"#);
1213        let tricky = "it's $x/a b.test.ts";
1214        let quoted = r#"'it'\''s $x/a b.test.ts'"#;
1215        let run =
1216            |dependency: &str| script(&[("package.json", &scripts(dependency))], tricky, "case");
1217
1218        assert_eq!(
1219            run("vitest").as_deref(),
1220            Some(format!(r#"npx vitest run {quoted} -t "case""#).as_str())
1221        );
1222        assert_eq!(
1223            run("jest").as_deref(),
1224            Some(format!(r#"npx jest --runTestsByPath {quoted} -t "case""#).as_str())
1225        );
1226        assert_eq!(
1227            run("mocha").as_deref(),
1228            Some(format!(r#"npx mocha {quoted} --fgrep "case""#).as_str())
1229        );
1230        assert_eq!(
1231            script(
1232                &[("package.json", r#"{"scripts":{"test":"node --test"}}"#)],
1233                tricky,
1234                "case"
1235            )
1236            .as_deref(),
1237            Some(format!(r#"node --test-name-pattern "^case\$" {quoted}"#).as_str())
1238        );
1239    }
1240
1241    #[test]
1242    fn a_path_with_a_space_is_single_quoted_for_pytest_cargo_and_go() {
1243        let empty = project(&[]);
1244        let cargo = project(&[("Cargo.toml", "[package]\nname = \"p\"\n")]);
1245        let module = project(&[("go.mod", "module m\n")]);
1246
1247        assert_eq!(
1248            command(&empty, "python", "tests/a b.py", "test_one", Some("TestA")).as_deref(),
1249            Some("pytest 'tests/a b.py'::TestA::test_one")
1250        );
1251        assert_eq!(
1252            command(&empty, "python", "tests/a_b.py", "test_one", None).as_deref(),
1253            Some("pytest tests/a_b.py::test_one")
1254        );
1255        assert_eq!(
1256            command(&cargo, "rust", "tests/my test.rs", "case", None).as_deref(),
1257            Some("cargo test -p p --test 'my test' case")
1258        );
1259        assert_eq!(
1260            command(&module, "go", "my pkg/run_test.go", "TestRun", None).as_deref(),
1261            Some("go test './my pkg' -run '^TestRun$'")
1262        );
1263    }
1264
1265    #[test]
1266    fn a_jest_command_names_its_file_by_path_so_no_character_is_a_regular_expression() {
1267        let jest = [("package.json", r#"{"devDependencies":{"jest":"^29"}}"#)];
1268
1269        for (path, word) in [
1270            ("test/(grp)/c.test.js", "'test/(grp)/c.test.js'"),
1271            ("test/[id]/c.test.js", "'test/[id]/c.test.js'"),
1272            ("test/a+b/c.test.js", "'test/a+b/c.test.js'"),
1273            ("test/d$x/c.test.js", "'test/d$x/c.test.js'"),
1274            ("test/a.test.js", "test/a.test.js"),
1275        ] {
1276            assert_eq!(
1277                script(&jest, path, "case").as_deref(),
1278                Some(format!(r#"npx jest --runTestsByPath {word} -t "case""#).as_str()),
1279                "{path}"
1280            );
1281        }
1282    }
1283
1284    #[test]
1285    fn a_node_test_command_runs_the_file_itself_so_no_character_is_a_glob() {
1286        let files = [("package.json", r#"{"scripts":{"test":"node --test"}}"#)];
1287
1288        for (path, word) in [
1289            ("test/[id]/a.test.js", "'test/[id]/a.test.js'"),
1290            ("test/+(x)/a.test.js", "'test/+(x)/a.test.js'"),
1291            ("test/!(x)/a.test.js", "'test/!(x)/a.test.js'"),
1292            ("test/{a,b}/a.test.js", "'test/{a,b}/a.test.js'"),
1293            ("test/a.test.js", "test/a.test.js"),
1294        ] {
1295            assert_eq!(
1296                script(&files, path, "case").as_deref(),
1297                Some(format!(r#"node --test-name-pattern "^case\$" {word}"#).as_str()),
1298                "{path}"
1299            );
1300        }
1301    }
1302
1303    #[test]
1304    fn a_python_test_in_a_nested_class_names_every_enclosing_class() {
1305        let empty = project(&[]);
1306
1307        assert_eq!(
1308            nested(
1309                &empty,
1310                "python",
1311                "tests/test_n.py",
1312                "test_deep",
1313                &["TestOuter", "TestInner"]
1314            )
1315            .as_deref(),
1316            Some("pytest tests/test_n.py::TestOuter::TestInner::test_deep")
1317        );
1318        assert_eq!(
1319            nested(
1320                &empty,
1321                "python",
1322                "tests/test_n.py",
1323                "test_top",
1324                &["TestOuter"]
1325            )
1326            .as_deref(),
1327            Some("pytest tests/test_n.py::TestOuter::test_top")
1328        );
1329    }
1330
1331    #[test]
1332    fn a_java_test_in_a_nested_class_names_the_binary_class_name_as_one_shell_word() {
1333        let maven = project(&[("pom.xml", "<project/>")]);
1334        let gradle = project(&[("gradlew", ""), ("build.gradle", "")]);
1335        let path = "src/test/java/OuterTest.java";
1336        let classes = ["OuterTest", "InnerTest", "DeepTest"];
1337
1338        assert_eq!(
1339            nested(&maven, "java", path, "checks", &classes).as_deref(),
1340            Some(r#"mvn test -Dtest='OuterTest$InnerTest$DeepTest'#checks"#)
1341        );
1342        assert_eq!(
1343            nested(&gradle, "java", path, "checks", &classes).as_deref(),
1344            Some(r#"./gradlew test --tests 'OuterTest$InnerTest$DeepTest.checks'"#)
1345        );
1346        assert_eq!(
1347            nested(&maven, "java", path, "checks", &["OuterTest"]).as_deref(),
1348            Some("mvn test -Dtest=OuterTest#checks")
1349        );
1350    }
1351
1352    #[test]
1353    fn a_java_or_csharp_name_with_a_shell_character_stays_one_shell_word() {
1354        let maven = project(&[("pom.xml", "<project/>")]);
1355        let gradle = project(&[("build.gradle", "")]);
1356        let path = "src/test/java/FooTest.java";
1357
1358        assert_eq!(
1359            nested(&maven, "java", path, "a$b `c` it's", &["Foo$1"]).as_deref(),
1360            Some(r#"mvn test -Dtest='Foo$1'#'a$b `c` it'\''s'"#)
1361        );
1362        assert_eq!(
1363            nested(&gradle, "java", path, "a$b it's", &["Foo$1"]).as_deref(),
1364            Some(r#"gradle test --tests 'Foo$1.a$b it'\''s'"#)
1365        );
1366        assert_eq!(
1367            nested(&maven, "csharp", "T/FooTests.cs", "C\"D$`e`\\f", &["A$B"]).as_deref(),
1368            Some(r#"dotnet test --filter "FullyQualifiedName~A\$B.C\"D\$\`e\`\\f""#)
1369        );
1370    }
1371
1372    #[test]
1373    fn a_csharp_test_in_a_nested_class_names_the_innermost_class() {
1374        let empty = project(&[]);
1375
1376        assert_eq!(
1377            nested(
1378                &empty,
1379                "csharp",
1380                "T/FooTests.cs",
1381                "Checks",
1382                &["FooTests", "Inner"]
1383            )
1384            .as_deref(),
1385            Some("dotnet test --filter \"FullyQualifiedName~Inner.Checks\"")
1386        );
1387    }
1388
1389    #[test]
1390    fn a_backslash_before_a_line_break_in_a_script_name_drops_both() {
1391        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1392        let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1393
1394        for name in [
1395            "part one \\\npart two",
1396            "part one \\\r\npart two",
1397            "part one \\\rpart two",
1398            "part one \\\u{2028}part two",
1399            "part one \\\u{2029}part two",
1400        ] {
1401            assert_eq!(
1402                script(&vitest, "a.test.js", name).as_deref(),
1403                Some(r#"npx vitest run a.test.js -t "part one part two""#),
1404                "{name:?}"
1405            );
1406            assert_eq!(
1407                script(&mocha, "a.test.js", name).as_deref(),
1408                Some(r#"npx mocha a.test.js --fgrep "part one part two""#),
1409                "{name:?}"
1410            );
1411        }
1412        assert_eq!(
1413            script(&vitest, "a.test.js", "a\\\\\nb").as_deref(),
1414            Some(r#"npx vitest run a.test.js -t "a\\\\\\nb""#)
1415        );
1416    }
1417
1418    #[test]
1419    fn a_string_or_template_inside_a_template_hole_does_not_end_the_hole() {
1420        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1421        let run = |name: &str| script(&vitest, "a.test.js", name);
1422
1423        for name in [
1424            r#"literal ${"}"} inside hole"#,
1425            r"literal ${'}'} inside hole",
1426            "literal ${`}`} inside hole",
1427            r#"literal ${"\"}"} inside hole"#,
1428            r#"literal ${`x ${"}"} y`} inside hole"#,
1429            r"literal ${f('}', {a: 1}.a)} inside hole",
1430        ] {
1431            assert_eq!(
1432                run(name).as_deref(),
1433                Some(r#"npx vitest run a.test.js -t "literal .* inside hole""#),
1434                "{name}"
1435            );
1436        }
1437        assert_eq!(
1438            run(r#"a ${"} b"#).as_deref(),
1439            Some(r#"npx vitest run a.test.js -t "a \\\$\\{\"\\} b""#)
1440        );
1441    }
1442
1443    #[test]
1444    fn a_mocha_name_cuts_at_a_character_that_would_split_the_command_line() {
1445        let mocha = [("package.json", r#"{"devDependencies":{"mocha":"^10"}}"#)];
1446        let run = |name: &str| script(&mocha, "a.test.js", name);
1447
1448        assert_eq!(
1449            run(r"multi\nline name").as_deref(),
1450            Some(r#"npx mocha a.test.js --fgrep "line name""#)
1451        );
1452        assert_eq!(
1453            run(r"carriage\rreturn").as_deref(),
1454            Some(r#"npx mocha a.test.js --fgrep "carriage""#)
1455        );
1456        assert_eq!(
1457            run(r"backspace \b and formfeed \f").as_deref(),
1458            Some(r#"npx mocha a.test.js --fgrep " and formfeed ""#)
1459        );
1460        assert_eq!(
1461            run("real\nnewline").as_deref(),
1462            Some(r#"npx mocha a.test.js --fgrep "newline""#)
1463        );
1464        assert_eq!(
1465            run("ab\u{2028}cdef\u{85}gh").as_deref(),
1466            Some(r#"npx mocha a.test.js --fgrep "cdef""#)
1467        );
1468        assert_eq!(
1469            run(r"a\nb\fc").as_deref(),
1470            Some(r#"npx mocha a.test.js --fgrep "a""#)
1471        );
1472        assert_eq!(run(r"\n\f").as_deref(), Some("npx mocha a.test.js"));
1473        assert_eq!(
1474            run(r"tab\there").as_deref(),
1475            Some("npx mocha a.test.js --fgrep \"tab\there\"")
1476        );
1477    }
1478
1479    #[test]
1480    fn a_vitest_or_jest_name_writes_a_unicode_line_separator_as_an_escape() {
1481        let vitest = [("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)];
1482
1483        assert_eq!(
1484            script(&vitest, "a.test.js", "a\u{2028}b\u{2029}c").as_deref(),
1485            Some(r#"npx vitest run a.test.js -t "a\\u2028b\\u2029c""#)
1486        );
1487    }
1488
1489    #[test]
1490    fn a_go_test_below_the_root_changes_into_the_folder_of_its_go_mod() {
1491        let module = project(&[
1492            ("svc/go.mod", "module example.com/svc\n"),
1493            ("my svc/go.mod", "module example.com/mine\n"),
1494            ("a/b/go.mod", "module example.com/ab\n"),
1495        ]);
1496
1497        let run = |path: &str| command(&module, "go", path, "TestAdd", None);
1498
1499        assert_eq!(
1500            run("svc/pkg/m_test.go").as_deref(),
1501            Some("cd svc && go test ./pkg -run '^TestAdd$'")
1502        );
1503        assert_eq!(
1504            run("svc/m_test.go").as_deref(),
1505            Some("cd svc && go test ./. -run '^TestAdd$'")
1506        );
1507        assert_eq!(
1508            run("my svc/pkg/m_test.go").as_deref(),
1509            Some("cd 'my svc' && go test ./pkg -run '^TestAdd$'")
1510        );
1511        assert_eq!(
1512            run("a/b/c/m_test.go").as_deref(),
1513            Some("cd a/b && go test ./c -run '^TestAdd$'")
1514        );
1515    }
1516
1517    #[test]
1518    fn a_script_test_below_the_root_changes_into_the_folder_of_its_package_json() {
1519        let run = |manifest: &str, dir: &str| {
1520            script(
1521                &[(&format!("{dir}/package.json"), manifest)],
1522                &format!("{dir}/test/x.test.js"),
1523                "case",
1524            )
1525        };
1526
1527        assert_eq!(
1528            run(r#"{"devDependencies":{"vitest":"^1"}}"#, "packages/a").as_deref(),
1529            Some(r#"cd packages/a && npx vitest run test/x.test.js -t "case""#)
1530        );
1531        assert_eq!(
1532            run(r#"{"devDependencies":{"jest":"^29"}}"#, "packages/a").as_deref(),
1533            Some(r#"cd packages/a && npx jest --runTestsByPath test/x.test.js -t "case""#)
1534        );
1535        assert_eq!(
1536            run(r#"{"devDependencies":{"mocha":"^10"}}"#, "web").as_deref(),
1537            Some(r#"cd web && npx mocha test/x.test.js --fgrep "case""#)
1538        );
1539        assert_eq!(
1540            run(r#"{"scripts":{"test":"node --test"}}"#, "my pkg").as_deref(),
1541            Some(r#"cd 'my pkg' && node --test-name-pattern "^case\$" test/x.test.js"#)
1542        );
1543    }
1544
1545    #[test]
1546    fn a_rust_test_changes_into_its_crate_unless_the_root_manifest_is_a_workspace() {
1547        let member = "[package]\nname = \"inner\"\n";
1548        let no_root = project(&[("crates/n/Cargo.toml", member)]);
1549        let plain_root = project(&[
1550            ("Cargo.toml", "[package]\nname = \"outer\"\n"),
1551            ("crates/n/Cargo.toml", member),
1552        ]);
1553        let workspace_root = project(&[
1554            ("Cargo.toml", "[workspace]\nmembers = [\"crates/n\"]\n"),
1555            ("crates/n/Cargo.toml", member),
1556        ]);
1557        let unnamed = project(&[("crates/n/Cargo.toml", "[workspace]\nmembers = []\n")]);
1558
1559        let run =
1560            |project: &tempfile::TempDir, path: &str| command(project, "rust", path, "case", None);
1561
1562        assert_eq!(
1563            run(&no_root, "crates/n/src/lib.rs").as_deref(),
1564            Some("cd crates/n && cargo test -p inner case")
1565        );
1566        assert_eq!(
1567            run(&no_root, "crates/n/tests/it.rs").as_deref(),
1568            Some("cd crates/n && cargo test -p inner --test it case")
1569        );
1570        assert_eq!(
1571            run(&plain_root, "crates/n/src/lib.rs").as_deref(),
1572            Some("cd crates/n && cargo test -p inner case")
1573        );
1574        assert_eq!(
1575            run(&plain_root, "src/lib.rs").as_deref(),
1576            Some("cargo test -p outer case")
1577        );
1578        assert_eq!(
1579            run(&workspace_root, "crates/n/src/lib.rs").as_deref(),
1580            Some("cargo test -p inner case")
1581        );
1582        assert_eq!(
1583            run(&unnamed, "crates/n/src/lib.rs").as_deref(),
1584            Some("cd crates/n && cargo test case")
1585        );
1586    }
1587
1588    #[test]
1589    fn a_python_test_below_the_root_runs_from_the_root_by_its_path() {
1590        let nested_project = project(&[("pkg/pyproject.toml", "[project]\nname = \"p\"\n")]);
1591
1592        assert_eq!(
1593            command(
1594                &nested_project,
1595                "python",
1596                "pkg/tests/test_a.py",
1597                "test_one",
1598                None
1599            )
1600            .as_deref(),
1601            Some("pytest pkg/tests/test_a.py::test_one")
1602        );
1603    }
1604
1605    #[test]
1606    fn java_tests_run_through_the_nearest_maven_or_gradle_build() {
1607        let maven = project(&[("app/pom.xml", "<project/>")]);
1608        let wrapped = project(&[("gradlew", ""), ("app/build.gradle", "")]);
1609        let kotlin_dsl = project(&[("gradlew", ""), ("app/build.gradle.kts", "")]);
1610        let bare = project(&[("app/build.gradle", "")]);
1611        let both = project(&[("pom.xml", "<project/>"), ("build.gradle", "")]);
1612        let none = project(&[]);
1613        let path = "app/src/test/java/FooTest.java";
1614
1615        let run =
1616            |project: &tempfile::TempDir| command(project, "java", path, "checks", Some("FooTest"));
1617
1618        assert_eq!(
1619            run(&maven).as_deref(),
1620            Some("cd app && mvn test -Dtest=FooTest#checks")
1621        );
1622        assert_eq!(
1623            run(&wrapped).as_deref(),
1624            Some("cd app && ../gradlew test --tests 'FooTest.checks'")
1625        );
1626        assert_eq!(
1627            run(&kotlin_dsl).as_deref(),
1628            Some("cd app && ../gradlew test --tests 'FooTest.checks'")
1629        );
1630        assert_eq!(
1631            run(&bare).as_deref(),
1632            Some("cd app && gradle test --tests 'FooTest.checks'")
1633        );
1634        assert_eq!(
1635            command(&both, "java", path, "checks", Some("FooTest")).as_deref(),
1636            Some("mvn test -Dtest=FooTest#checks")
1637        );
1638        assert_eq!(run(&none), None);
1639        assert_eq!(command(&maven, "java", path, "checks", None), None);
1640    }
1641
1642    #[test]
1643    fn csharp_tests_run_through_dotnet_test_with_a_class_filter() {
1644        let empty = project(&[]);
1645
1646        assert_eq!(
1647            command(
1648                &empty,
1649                "csharp",
1650                "T/FooTests.cs",
1651                "Checks",
1652                Some("FooTests")
1653            )
1654            .as_deref(),
1655            Some("dotnet test --filter \"FullyQualifiedName~FooTests.Checks\"")
1656        );
1657        assert_eq!(
1658            command(&empty, "csharp", "T/FooTests.cs", "Checks", None),
1659            None
1660        );
1661    }
1662
1663    #[test]
1664    fn other_languages_have_no_command() {
1665        let empty = project(&[]);
1666
1667        for language in ["ruby", "php", "kotlin", "swift", "cpp", "vue"] {
1668            assert_eq!(
1669                command(&empty, language, "tests/a_test.x", "case", Some("A")),
1670                None,
1671                "{language}"
1672            );
1673        }
1674    }
1675
1676    #[test]
1677    fn a_marker_file_is_read_once_per_folder_for_the_life_of_the_finder() {
1678        let project = project(&[("package.json", r#"{"devDependencies":{"vitest":"^1"}}"#)]);
1679        let mut finder = CommandFinder::new(project.path());
1680        let spec = |path| TestSpec {
1681            language: "typescript",
1682            kind: "function",
1683            path,
1684            line: 1,
1685            name: "case",
1686            classes: &[],
1687        };
1688        let first = finder.command(&spec("a.test.ts"));
1689
1690        fs::write(
1691            project.path().join("package.json"),
1692            r#"{"devDependencies":{"jest":"^29"}}"#,
1693        )
1694        .unwrap();
1695        let second = finder.command(&spec("b.test.ts"));
1696
1697        assert_eq!(
1698            first.as_deref(),
1699            Some(r#"npx vitest run a.test.ts -t "case""#)
1700        );
1701        assert_eq!(
1702            second.as_deref(),
1703            Some(r#"npx vitest run b.test.ts -t "case""#)
1704        );
1705    }
1706
1707    fn at(
1708        project: &tempfile::TempDir,
1709        language: &str,
1710        kind: &str,
1711        path: &str,
1712        name: &str,
1713    ) -> Option<String> {
1714        let text = fs::read_to_string(project.path().join(path)).unwrap();
1715        let line = text
1716            .lines()
1717            .position(|line| line.contains(&format!("{name}(")))
1718            .unwrap()
1719            + 1;
1720        CommandFinder::new(project.path()).command(&TestSpec {
1721            language,
1722            kind,
1723            path,
1724            line,
1725            name,
1726            classes: &[],
1727        })
1728    }
1729
1730    fn pytest(project: &tempfile::TempDir, path: &str, classes: &[(&str, bool)]) -> Option<String> {
1731        let classes: Vec<TestClass> = classes
1732            .iter()
1733            .map(|(name, test_case)| TestClass {
1734                name,
1735                test_case: *test_case,
1736            })
1737            .collect();
1738        CommandFinder::new(project.path()).command(&TestSpec {
1739            language: "python",
1740            kind: "method",
1741            path,
1742            line: 1,
1743            name: "test_m",
1744            classes: &classes,
1745        })
1746    }
1747
1748    #[test]
1749    fn a_go_benchmark_runs_with_the_bench_flag_and_no_test() {
1750        let module = project(&[("go.mod", "module m\n")]);
1751
1752        assert_eq!(
1753            command(&module, "go", "pkg/run_test.go", "BenchmarkRun", None).as_deref(),
1754            Some("go test ./pkg -bench '^BenchmarkRun$' -run '^$'")
1755        );
1756        assert_eq!(
1757            command(&module, "go", "run_test.go", "Benchmark_snake", None).as_deref(),
1758            Some("go test ./. -bench '^Benchmark_snake$' -run '^$'")
1759        );
1760    }
1761
1762    #[test]
1763    fn a_go_fuzz_target_and_a_test_run_by_name_when_the_go_tool_reads_the_name_as_one() {
1764        let module = project(&[("go.mod", "module m\n")]);
1765
1766        for name in [
1767            "TestRun",
1768            "Test_snake",
1769            "Test",
1770            "FuzzRun",
1771            "Fuzz",
1772            "TestÜber",
1773        ] {
1774            assert_eq!(
1775                command(&module, "go", "pkg/run_test.go", name, None).as_deref(),
1776                Some(format!("go test ./pkg -run '^{name}$'").as_str()),
1777                "{name}"
1778            );
1779        }
1780    }
1781
1782    #[test]
1783    fn a_go_function_the_go_tool_does_not_run_by_name_has_no_command() {
1784        let module = project(&[("go.mod", "module m\n")]);
1785
1786        for name in [
1787            "helperNotATest",
1788            "Testlower",
1789            "Benchmarkx",
1790            "TestMain",
1791            "setupTest",
1792        ] {
1793            assert_eq!(
1794                command(&module, "go", "pkg/run_test.go", name, None),
1795                None,
1796                "{name}"
1797            );
1798        }
1799        assert_eq!(command(&module, "go", "pkg/run.go", "TestRun", None), None);
1800        let method = CommandFinder::new(module.path()).command(&TestSpec {
1801            language: "go",
1802            kind: "method",
1803            path: "pkg/run_test.go",
1804            line: 1,
1805            name: "TestSuiteMethod",
1806            classes: &[],
1807        });
1808        assert_eq!(method, None);
1809    }
1810
1811    #[test]
1812    fn a_go_example_runs_only_when_its_body_ends_in_an_output_comment() {
1813        let module = project(&[
1814            ("go.mod", "module m\n"),
1815            (
1816                "pkg/ex_test.go",
1817                "package pkg\n\nfunc ExampleOne() {\n\tfmt.Println(1)\n\t// Output: 1\n}\n\nfunc ExampleTwo() {\n\tfmt.Println(2)\n\t// Unordered output: 2\n}\n\nfunc ExampleThree() {\n\tfmt.Println(3)\n}\n\nfunc ExampleFour() {\n\tfmt.Println(4)\n\t// output: 4\n}\n\nfunc ExampleFive() { fmt.Println(5) }\n\nfunc ExampleSix() {\n\t// shows 6\n}\n",
1818            ),
1819        ]);
1820        let run = |name: &str| at(&module, "go", "function", "pkg/ex_test.go", name);
1821
1822        assert_eq!(
1823            run("ExampleOne").as_deref(),
1824            Some("go test ./pkg -run '^ExampleOne$'")
1825        );
1826        assert_eq!(
1827            run("ExampleTwo").as_deref(),
1828            Some("go test ./pkg -run '^ExampleTwo$'")
1829        );
1830        assert_eq!(
1831            run("ExampleFour").as_deref(),
1832            Some("go test ./pkg -run '^ExampleFour$'")
1833        );
1834        for name in ["ExampleThree", "ExampleFive", "ExampleSix"] {
1835            assert_eq!(run(name), None, "{name}");
1836        }
1837    }
1838
1839    #[test]
1840    fn a_maven_or_gradle_test_below_the_root_changes_into_the_folder_of_its_build() {
1841        let maven = project(&[
1842            ("mvn/pom.xml", "<project/>"),
1843            ("my app/pom.xml", "<project/>"),
1844        ]);
1845        let local_wrapper = project(&[("gr/gradlew", ""), ("gr/build.gradle", "")]);
1846        let far_wrapper = project(&[("gradlew", ""), ("a/b/build.gradle.kts", "")]);
1847        let mid_wrapper = project(&[("a/gradlew", ""), ("a/b/build.gradle", "")]);
1848        let root_build = project(&[("gradlew", ""), ("build.gradle", "")]);
1849
1850        let run = |project: &tempfile::TempDir, dir: &str| {
1851            command(
1852                project,
1853                "java",
1854                &format!("{dir}/src/test/java/FooTest.java"),
1855                "checks",
1856                Some("FooTest"),
1857            )
1858        };
1859
1860        assert_eq!(
1861            run(&maven, "mvn").as_deref(),
1862            Some("cd mvn && mvn test -Dtest=FooTest#checks")
1863        );
1864        assert_eq!(
1865            run(&maven, "my app").as_deref(),
1866            Some("cd 'my app' && mvn test -Dtest=FooTest#checks")
1867        );
1868        assert_eq!(
1869            run(&local_wrapper, "gr").as_deref(),
1870            Some("cd gr && ./gradlew test --tests 'FooTest.checks'")
1871        );
1872        assert_eq!(
1873            run(&far_wrapper, "a/b").as_deref(),
1874            Some("cd a/b && ../../gradlew test --tests 'FooTest.checks'")
1875        );
1876        assert_eq!(
1877            run(&mid_wrapper, "a/b").as_deref(),
1878            Some("cd a/b && ../gradlew test --tests 'FooTest.checks'")
1879        );
1880        assert_eq!(
1881            command(
1882                &root_build,
1883                "java",
1884                "src/test/java/FooTest.java",
1885                "checks",
1886                Some("FooTest")
1887            )
1888            .as_deref(),
1889            Some("./gradlew test --tests 'FooTest.checks'")
1890        );
1891    }
1892
1893    #[test]
1894    fn a_python_test_in_a_folder_with_square_brackets_runs_from_that_folder() {
1895        let empty = project(&[]);
1896
1897        assert_eq!(
1898            command(
1899                &empty,
1900                "python",
1901                "tests/a[b] (c)/test_p.py",
1902                "test_m",
1903                Some("TestK")
1904            )
1905            .as_deref(),
1906            Some("cd 'tests/a[b] (c)' && pytest test_p.py::TestK::test_m")
1907        );
1908        assert_eq!(
1909            command(&empty, "python", "a[b]/test_p.py", "test_free", None).as_deref(),
1910            Some("cd 'a[b]' && pytest test_p.py::test_free")
1911        );
1912        assert_eq!(
1913            command(&empty, "python", "a::b/test_p.py", "test_free", None).as_deref(),
1914            Some("cd 'a::b' && pytest test_p.py::test_free")
1915        );
1916        assert_eq!(
1917            command(&empty, "python", "tests/a]b/test_p.py", "test_free", None).as_deref(),
1918            Some("pytest 'tests/a]b/test_p.py'::test_free")
1919        );
1920    }
1921
1922    #[test]
1923    fn a_python_test_in_a_file_whose_name_holds_square_brackets_has_no_command() {
1924        let empty = project(&[]);
1925
1926        assert_eq!(
1927            command(&empty, "python", "tests/test_[a].py", "test_m", None),
1928            None
1929        );
1930        assert_eq!(
1931            command(&empty, "python", "tests/test::a.py", "test_m", None),
1932            None
1933        );
1934    }
1935
1936    #[test]
1937    fn a_python_test_in_a_class_pytest_does_not_collect_has_no_command() {
1938        let empty = project(&[]);
1939        let path = "tests/test_c.py";
1940
1941        assert_eq!(pytest(&empty, path, &[("Helper", false)]), None);
1942        assert_eq!(
1943            pytest(&empty, path, &[("TestOuter", false), ("Helper", false)]),
1944            None
1945        );
1946        assert_eq!(
1947            pytest(
1948                &empty,
1949                path,
1950                &[
1951                    ("TestOuter", false),
1952                    ("Helper", false),
1953                    ("TestInner", false)
1954                ]
1955            ),
1956            None
1957        );
1958        assert_eq!(
1959            pytest(&empty, path, &[("Testing", false)]).as_deref(),
1960            Some("pytest tests/test_c.py::Testing::test_m")
1961        );
1962    }
1963
1964    #[test]
1965    fn a_python_test_case_subclass_is_collected_under_any_name_but_its_nested_classes_are_not() {
1966        let empty = project(&[]);
1967        let path = "tests/test_c.py";
1968
1969        assert_eq!(
1970            pytest(&empty, path, &[("FooTests", true)]).as_deref(),
1971            Some("pytest tests/test_c.py::FooTests::test_m")
1972        );
1973        assert_eq!(
1974            pytest(&empty, path, &[("TestOuter", false), ("Inner", true)]).as_deref(),
1975            Some("pytest tests/test_c.py::TestOuter::Inner::test_m")
1976        );
1977        assert_eq!(
1978            pytest(&empty, path, &[("FooTests", true), ("TestInner", false)]),
1979            None
1980        );
1981        assert_eq!(
1982            pytest(&empty, path, &[("TestFoo", true), ("TestInner", false)]),
1983            None
1984        );
1985    }
1986
1987    #[test]
1988    fn a_rust_test_marked_ignore_runs_with_the_ignored_flag() {
1989        let cargo = project(&[
1990            ("Cargo.toml", "[package]\nname = \"p\"\n"),
1991            (
1992                "src/lib.rs",
1993                "#[cfg(test)]\nmod tests {\n    #[test]\n    #[ignore]\n    fn slow() {}\n\n    #[test]\n    #[ignore = \"needs network\"]\n    fn net() {}\n\n    #[ignore] // later\n    #[test]\n    fn flipped() {}\n\n    #[test]\n    #[ignored_by_us]\n    fn custom() {}\n\n    /// #[ignore]\n    #[test]\n    fn documented() {}\n\n    #[test]\n    fn fast() {}\n\n    #[test] #[ignore]\n    fn one_line() {}\n}\n",
1994            ),
1995            (
1996                "tests/it.rs",
1997                "#[test]\n#[ignore]\nfn slow_it() {}\n\n#[test]\nfn ok_it() {}\n",
1998            ),
1999        ]);
2000        let run = |path: &str, name: &str| at(&cargo, "rust", "function", path, name);
2001
2002        for name in ["slow", "net", "flipped", "one_line"] {
2003            assert_eq!(
2004                run("src/lib.rs", name).as_deref(),
2005                Some(format!("cargo test -p p {name} -- --ignored").as_str()),
2006                "{name}"
2007            );
2008        }
2009        for name in ["custom", "documented", "fast"] {
2010            assert_eq!(
2011                run("src/lib.rs", name).as_deref(),
2012                Some(format!("cargo test -p p {name}").as_str()),
2013                "{name}"
2014            );
2015        }
2016        assert_eq!(
2017            run("tests/it.rs", "slow_it").as_deref(),
2018            Some("cargo test -p p --test it slow_it -- --ignored")
2019        );
2020        assert_eq!(
2021            run("tests/it.rs", "ok_it").as_deref(),
2022            Some("cargo test -p p --test it ok_it")
2023        );
2024    }
2025
2026    #[test]
2027    fn an_ignored_rust_test_below_the_root_or_without_a_package_keeps_the_ignored_flag() {
2028        let member = project(&[
2029            ("crates/n/Cargo.toml", "[package]\nname = \"inner\"\n"),
2030            ("crates/n/src/lib.rs", "#[test]\n#[ignore]\nfn slow() {}\n"),
2031        ]);
2032        let bare = project(&[("src/lib.rs", "#[test]\n#[ignore]\nfn slow() {}\n")]);
2033
2034        assert_eq!(
2035            at(&member, "rust", "function", "crates/n/src/lib.rs", "slow").as_deref(),
2036            Some("cd crates/n && cargo test -p inner slow -- --ignored")
2037        );
2038        assert_eq!(
2039            at(&bare, "rust", "function", "src/lib.rs", "slow").as_deref(),
2040            Some("cargo test slow -- --ignored")
2041        );
2042    }
2043
2044    #[test]
2045    fn a_node_test_runs_only_the_named_test_even_when_the_name_holds_regular_expression_characters()
2046    {
2047        let files = [("package.json", r#"{"scripts":{"test":"node --test"}}"#)];
2048
2049        assert_eq!(
2050            script(&files, "a.test.js", "sums a.b (1+2) [3] $4").as_deref(),
2051            Some(r#"node --test-name-pattern "^sums a\\.b \\(1\\+2\\) \\[3\\] \\\$4\$" a.test.js"#)
2052        );
2053        assert_eq!(
2054            script(&files, "a.test.js", "/api/users").as_deref(),
2055            Some(r#"node --test-name-pattern "^/api/users\$" a.test.js"#)
2056        );
2057        assert_eq!(
2058            script(&files, "a.test.js", "handles ${kind} kind").as_deref(),
2059            Some(r#"node --test-name-pattern "^handles .* kind\$" a.test.js"#)
2060        );
2061    }
2062}