Skip to main content

miwa/
extract.rs

1//! Test extraction for Go, Rust, C#, Java, Kotlin, TypeScript, JavaScript and Dart source files.
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct ExtractedTest {
5    pub id: String,
6    pub name: String,
7    pub markers: Vec<String>,
8    pub content: String,
9}
10
11#[derive(Debug, thiserror::Error)]
12pub enum ExtractError {
13    #[error("parse error in {path}")]
14    Parse { path: String },
15}
16
17/// Extract Go tests from a `*_test.go` file: top-level `function_declaration`s
18/// whose name starts with `Test`. Markers are collected from the block of
19/// comments immediately (no blank line) preceding the function.
20pub fn extract_go_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
21    let mut parser = tree_sitter::Parser::new();
22    parser
23        .set_language(&tree_sitter_go::LANGUAGE.into())
24        .expect("valid go grammar");
25    let tree = parser
26        .parse(source, None)
27        .ok_or_else(|| ExtractError::Parse {
28            path: path.to_string(),
29        })?;
30    let root = tree.root_node();
31    if root.has_error() {
32        return Err(ExtractError::Parse {
33            path: path.to_string(),
34        });
35    }
36
37    let dir = dir_of(path);
38    let mut results = Vec::new();
39
40    let mut cursor = root.walk();
41    let children: Vec<tree_sitter::Node> = root.children(&mut cursor).collect();
42    for (idx, node) in children.iter().enumerate() {
43        if node.kind() != "function_declaration" {
44            continue;
45        }
46        let Some(name_node) = node.child_by_field_name("name") else {
47            continue;
48        };
49        let name = name_node.utf8_text(source.as_bytes()).unwrap_or("");
50        if !name.starts_with("Test") {
51            continue;
52        }
53        let markers = collect_preceding_markers(&children, idx, source, "comment");
54        let content = node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
55        results.push(ExtractedTest {
56            id: format!("{dir}:{name}"),
57            name: name.to_string(),
58            markers,
59            content,
60        });
61    }
62    Ok(results)
63}
64
65/// Extract Rust tests: `function_item`s carrying a test attribute, i.e. an
66/// outer attribute whose path ends in `test` (`#[test]`, `#[tokio::test]`, ...)
67/// or in one of `extra_attributes` (last path segment, e.g. `rstest`).
68/// Markers come from the comment block immediately preceding the first
69/// (topmost) test attribute. `content` is the source of the `function_item`
70/// (attributes not included).
71pub fn extract_rust_tests(
72    path: &str,
73    source: &str,
74    extra_attributes: &[String],
75) -> Result<Vec<ExtractedTest>, ExtractError> {
76    let mut parser = tree_sitter::Parser::new();
77    parser
78        .set_language(&tree_sitter_rust::LANGUAGE.into())
79        .expect("valid rust grammar");
80    let tree = parser
81        .parse(source, None)
82        .ok_or_else(|| ExtractError::Parse {
83            path: path.to_string(),
84        })?;
85    let root = tree.root_node();
86    if root.has_error() {
87        return Err(ExtractError::Parse {
88            path: path.to_string(),
89        });
90    }
91
92    let base = path.strip_suffix(".rs").unwrap_or(path);
93    let mut results = Vec::new();
94    walk_rust_items(root, source, base, &[], extra_attributes, &mut results);
95    Ok(results)
96}
97
98fn walk_rust_items(
99    container: tree_sitter::Node,
100    source: &str,
101    base: &str,
102    mods: &[String],
103    extra_attributes: &[String],
104    results: &mut Vec<ExtractedTest>,
105) {
106    let mut cursor = container.walk();
107    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
108    for (idx, node) in children.iter().enumerate() {
109        match node.kind() {
110            "function_item" => {
111                let Some(name_node) = node.child_by_field_name("name") else {
112                    continue;
113                };
114                let name = name_node
115                    .utf8_text(source.as_bytes())
116                    .unwrap_or("")
117                    .to_string();
118                let Some(markers) = rust_test_markers(&children, idx, source, extra_attributes)
119                else {
120                    continue;
121                };
122                let content = node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
123                let id = if mods.is_empty() {
124                    format!("{base}::{name}")
125                } else {
126                    format!("{base}:{}:{name}", mods.join("/"))
127                };
128                results.push(ExtractedTest {
129                    id,
130                    name,
131                    markers,
132                    content,
133                });
134            }
135            "mod_item" => {
136                let Some(name_node) = node.child_by_field_name("name") else {
137                    continue;
138                };
139                let Some(body) = node.child_by_field_name("body") else {
140                    continue;
141                };
142                let mod_name = name_node
143                    .utf8_text(source.as_bytes())
144                    .unwrap_or("")
145                    .to_string();
146                let mut new_mods = mods.to_vec();
147                new_mods.push(mod_name);
148                walk_rust_items(body, source, base, &new_mods, extra_attributes, results);
149            }
150            _ => {}
151        }
152    }
153}
154
155/// Last row a comment occupies. tree-sitter-rust includes the trailing newline
156/// of `///` / `//!` line comments, so such a node ends at column 0 of the
157/// next row; that row is not part of the comment.
158fn comment_end_row(node: tree_sitter::Node) -> usize {
159    let end = node.end_position();
160    if end.column == 0 && end.row > node.start_position().row {
161        end.row - 1
162    } else {
163        end.row
164    }
165}
166
167/// Whether only whitespace (or a BOM) precedes the node on its line in
168/// `parsed` (the string the tree was built from). FR-064.
169fn is_own_line_comment(parsed: &str, node: tree_sitter::Node) -> bool {
170    let s = node.start_byte();
171    let Some(head) = parsed.get(..s) else {
172        return false;
173    };
174    let ls = head.rfind('\n').map_or(0, |i| i + 1);
175    head[ls..]
176        .chars()
177        .all(|c| c.is_whitespace() || c == '\u{feff}')
178}
179
180/// Walks backward from `children[target_idx]`, collecting a contiguous
181/// (no-blank-line) block of own-line comment nodes whose kind is in
182/// `comment_kinds`, and extracts `[marker]` tokens from them, top to bottom.
183/// A comment that does not start its line (e.g. trailing a previous item)
184/// ends the block (FR-064). `parsed` is the string the tree was parsed from;
185/// `text_of` yields the text of a node (Kotlin reads the original source, not
186/// its ASCII shadow). Nodes for which `is_doc` holds are read over without
187/// contributing markers and without ending the block.
188fn collect_preceding_markers_with<'a>(
189    children: &[tree_sitter::Node],
190    target_idx: usize,
191    parsed: &str,
192    text_of: &dyn Fn(tree_sitter::Node) -> Option<&'a str>,
193    comment_kinds: &[&str],
194    is_doc: &dyn Fn(tree_sitter::Node) -> bool,
195) -> Vec<String> {
196    let mut next_start_row = children[target_idx].start_position().row;
197    let mut j = target_idx as isize - 1;
198    let mut comments = Vec::new();
199    while j >= 0 {
200        let node = children[j as usize];
201        if !comment_kinds.contains(&node.kind()) {
202            break;
203        }
204        let Some(text) = text_of(node) else {
205            break;
206        };
207        if comment_end_row(node) + 1 != next_start_row || !is_own_line_comment(parsed, node) {
208            break;
209        }
210        if !is_doc(node) {
211            comments.push(text);
212        }
213        next_start_row = node.start_position().row;
214        j -= 1;
215    }
216    comments.reverse();
217    let mut markers = Vec::new();
218    for text in comments {
219        markers.extend(extract_markers(text));
220    }
221    markers
222}
223
224/// Single-kind variant of [`collect_preceding_markers_with`] (no doc skip).
225fn collect_preceding_markers(
226    children: &[tree_sitter::Node],
227    target_idx: usize,
228    source: &str,
229    comment_kind: &str,
230) -> Vec<String> {
231    collect_preceding_markers_with(
232        children,
233        target_idx,
234        source,
235        &|n| n.utf8_text(source.as_bytes()).ok(),
236        &[comment_kind],
237        &|_| false,
238    )
239}
240
241/// Rust doc comments (`///`, `//!`, `/** */`, `/*! */`) carry an
242/// `outer_doc_comment_marker` / `inner_doc_comment_marker` child. Decided by
243/// node structure so `////`, `/***` and `/**/` stay ordinary comments.
244fn is_rust_doc_comment(node: tree_sitter::Node) -> bool {
245    let mut cursor = node.walk();
246    let found = node.children(&mut cursor).any(|c| {
247        matches!(
248            c.kind(),
249            "outer_doc_comment_marker" | "inner_doc_comment_marker"
250        )
251    });
252    found
253}
254
255/// Returns `None` if the attribute chain above `children[func_idx]` has no
256/// test attribute. The chain is the contiguous run of `attribute_item`s and
257/// comments directly above the `fn` (comments do not end the chain). A test
258/// attribute is one whose path's last segment is `test` or listed in
259/// `extra_attributes`. Otherwise returns the markers from the comment block
260/// immediately (no blank line) preceding the topmost test attribute.
261fn rust_test_markers(
262    children: &[tree_sitter::Node],
263    func_idx: usize,
264    source: &str,
265    extra_attributes: &[String],
266) -> Option<Vec<String>> {
267    let mut test_attr_idx: Option<usize> = None;
268    let mut j = func_idx as isize - 1;
269    while j >= 0 {
270        let node = children[j as usize];
271        if node.kind() == "attribute_item" {
272            if rust_is_test_attribute(node, source, extra_attributes) {
273                test_attr_idx = Some(j as usize);
274            }
275        } else if !RUST_COMMENT_KINDS.contains(&node.kind()) {
276            break;
277        }
278        j -= 1;
279    }
280    let test_attr_idx = test_attr_idx?;
281    Some(collect_preceding_markers_with(
282        children,
283        test_attr_idx,
284        source,
285        &|n| n.utf8_text(source.as_bytes()).ok(),
286        &RUST_COMMENT_KINDS,
287        &is_rust_doc_comment,
288    ))
289}
290
291/// Whether an `attribute_item` is a test attribute. The attribute path's last
292/// segment is the text of the first named child of `attribute` when it is an
293/// `identifier`, or the `name` field when it is a `scoped_identifier`;
294/// arguments are never inspected. It matches when that segment is `test` or
295/// equals one of `extra_attributes`. `#![test]` is an `inner_attribute_item`,
296/// so it never reaches this function.
297fn rust_is_test_attribute(
298    item: tree_sitter::Node,
299    source: &str,
300    extra_attributes: &[String],
301) -> bool {
302    let mut cursor = item.walk();
303    let Some(attribute) = item
304        .named_children(&mut cursor)
305        .find(|c| c.kind() == "attribute")
306    else {
307        return false;
308    };
309    let Some(path) = attribute.named_child(0) else {
310        return false;
311    };
312    let segment = match path.kind() {
313        "identifier" => path,
314        "scoped_identifier" => match path.child_by_field_name("name") {
315            Some(n) => n,
316            None => return false,
317        },
318        _ => return false,
319    };
320    let text = segment.utf8_text(source.as_bytes()).unwrap_or("");
321    text == "test" || extra_attributes.iter().any(|a| a == text)
322}
323
324const RUST_COMMENT_KINDS: [&str; 2] = ["line_comment", "block_comment"];
325
326/// Doc comments (Javadoc / KDoc) are never markers in Java / Kotlin (and do
327/// not end the adjacent comment block).
328fn is_doc_comment(text: &str) -> bool {
329    text.starts_with("/**")
330}
331
332/// Test names that are empty or contain control characters / line separators
333/// (U+0085, U+2028, U+2029) are ignored.
334fn is_valid_test_name(name: &str) -> bool {
335    !name.is_empty()
336        && !name
337            .chars()
338            .any(|c| c.is_control() || matches!(c, '\u{85}' | '\u{2028}' | '\u{2029}'))
339}
340
341/// Strips the comment syntax once per kind and returns the body together with
342/// whether it is a block comment body. Line comments lose `//` and at most one
343/// more `/` (`///`); block comments lose `/*`, at most one more `*` (`/**`) and
344/// the closing `*/`. Any further `/` or `*` stays in the body.
345fn comment_body(text: &str) -> (&str, bool) {
346    if let Some(rest) = text.strip_prefix("//") {
347        return (rest.strip_prefix('/').unwrap_or(rest), false);
348    }
349    match text.strip_prefix("/*") {
350        Some(rest) => {
351            let rest = rest.strip_suffix("*/").unwrap_or(rest);
352            (rest.strip_prefix('*').unwrap_or(rest), true)
353        }
354        None => (text, false),
355    }
356}
357
358/// Returns the markers of one comment. The comment is a marker comment only
359/// when its body (comment syntax and surrounding whitespace removed) consists
360/// solely of whitespace-separated `[marker]` tokens, where `marker` is one or
361/// more `[A-Za-z0-9._-]` characters. Any other text makes it a plain comment
362/// with no markers. FR-121.
363fn extract_markers(text: &str) -> Vec<String> {
364    let (body, is_block) = comment_body(text);
365    let mut markers = Vec::new();
366    for (i, line) in body.lines().enumerate() {
367        // Continuation lines of a block comment may start with one `*`.
368        let line = match (is_block && i > 0, line.trim_start().strip_prefix('*')) {
369            (true, Some(rest)) => rest,
370            _ => line,
371        };
372        for token in line.split_whitespace() {
373            let Some(inner) = token
374                .strip_prefix('[')
375                .and_then(|t| t.strip_suffix(']'))
376                .filter(|inner| {
377                    !inner.is_empty()
378                        && inner
379                            .chars()
380                            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
381                })
382            else {
383                return Vec::new();
384            };
385            markers.push(inner.to_string());
386        }
387    }
388    markers
389}
390
391fn dir_of(path: &str) -> String {
392    match path.rsplit_once('/') {
393        Some((dir, _)) => dir.to_string(),
394        None => ".".to_string(),
395    }
396}
397
398const CSHARP_TEST_ATTRIBUTES: [&str; 7] = [
399    "Fact",
400    "Theory",
401    "Test",
402    "TestCase",
403    "TestCaseSource",
404    "TestMethod",
405    "DataTestMethod",
406];
407
408/// Extract C# tests (xUnit / NUnit / MSTest) from a `*.cs` file: methods inside
409/// types (also through namespaces and `#if` blocks) carrying a test attribute.
410/// Markers come from the comment block immediately preceding the method, and
411/// `content` runs from the first non-attribute token to the end of the method.
412pub fn extract_csharp_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
413    let mut parser = tree_sitter::Parser::new();
414    parser
415        .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
416        .expect("valid c# grammar");
417    let tree = parser
418        .parse(source, None)
419        .ok_or_else(|| ExtractError::Parse {
420            path: path.to_string(),
421        })?;
422    let root = tree.root_node();
423    if root.has_error() {
424        return Err(ExtractError::Parse {
425            path: path.to_string(),
426        });
427    }
428    let base = path.strip_suffix(".cs").unwrap_or(path);
429    let mut results = Vec::new();
430    walk_csharp_container(root, source, base, &[], &mut results);
431    Ok(results)
432}
433
434fn walk_csharp_container(
435    container: tree_sitter::Node,
436    source: &str,
437    base: &str,
438    types: &[String],
439    results: &mut Vec<ExtractedTest>,
440) {
441    let mut cursor = container.walk();
442    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
443    for (idx, node) in children.iter().enumerate() {
444        match node.kind() {
445            "preproc_if" | "preproc_elif" | "preproc_else" => {
446                walk_csharp_container(*node, source, base, types, results);
447            }
448            "namespace_declaration" => {
449                if let Some(body) = node.child_by_field_name("body") {
450                    walk_csharp_container(body, source, base, types, results);
451                }
452            }
453            "class_declaration"
454            | "struct_declaration"
455            | "record_declaration"
456            | "interface_declaration" => {
457                let (Some(name_node), Some(body)) = (
458                    node.child_by_field_name("name"),
459                    node.child_by_field_name("body"),
460                ) else {
461                    continue;
462                };
463                let name = name_node.utf8_text(source.as_bytes()).unwrap_or("");
464                let mut new_types = types.to_vec();
465                new_types.push(name.to_string());
466                walk_csharp_container(body, source, base, &new_types, results);
467            }
468            "method_declaration" if !types.is_empty() => {
469                if !csharp_is_test_method(*node, source) {
470                    continue;
471                }
472                let Some(name_node) = node.child_by_field_name("name") else {
473                    continue;
474                };
475                let name = name_node
476                    .utf8_text(source.as_bytes())
477                    .unwrap_or("")
478                    .to_string();
479                let mut c = node.walk();
480                let Some(first) = node
481                    .children(&mut c)
482                    .find(|ch| ch.kind() != "attribute_list" && ch.kind() != "comment")
483                else {
484                    continue;
485                };
486                let content = source[first.start_byte()..node.end_byte()].to_string();
487                let markers = collect_preceding_markers(&children, idx, source, "comment");
488                results.push(ExtractedTest {
489                    id: format!("{base}:{}:{name}", types.join("/")),
490                    name,
491                    markers,
492                    content,
493                });
494            }
495            _ => {}
496        }
497    }
498}
499
500fn csharp_is_test_method(method: tree_sitter::Node, source: &str) -> bool {
501    let mut cursor = method.walk();
502    for list in method.children(&mut cursor) {
503        if list.kind() != "attribute_list" {
504            continue;
505        }
506        let mut c2 = list.walk();
507        for attr in list.children(&mut c2) {
508            if attr.kind() != "attribute" {
509                continue;
510            }
511            let Some(mut name) = attr.child_by_field_name("name") else {
512                continue;
513            };
514            while name.kind() == "qualified_name" {
515                match name.child_by_field_name("name") {
516                    Some(n) => name = n,
517                    None => break,
518                }
519            }
520            let text = name.utf8_text(source.as_bytes()).unwrap_or("");
521            if CSHARP_TEST_ATTRIBUTES.contains(&text) {
522                return true;
523            }
524        }
525    }
526    false
527}
528
529const TYPESCRIPT_TEST_MODIFIERS: [&str; 6] =
530    ["skip", "only", "todo", "fails", "concurrent", "sequential"];
531const TYPESCRIPT_DESCRIBE_MODIFIERS: [&str; 6] = [
532    "skip",
533    "only",
534    "todo",
535    "concurrent",
536    "sequential",
537    "shuffle",
538];
539
540/// File extensions (without the dot) of TypeScript / JavaScript (Vitest)
541/// test files: `<name>.test.<ext>` / `<name>.spec.<ext>`.
542pub const TYPESCRIPT_TEST_EXTENSIONS: [&str; 8] =
543    ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
544
545/// Whether `path` is a TypeScript / JavaScript (Vitest) test file, i.e. ends
546/// with `.test.<ext>` or `.spec.<ext>` for one of the supported extensions.
547pub fn is_typescript_test_file(path: &str) -> bool {
548    TYPESCRIPT_TEST_EXTENSIONS.iter().any(|ext| {
549        path.ends_with(&format!(".test.{ext}")) || path.ends_with(&format!(".spec.{ext}"))
550    })
551}
552
553/// Extract TypeScript / JavaScript (Vitest) tests from a `*.test.<ext>` /
554/// `*.spec.<ext>` file (see [`TYPESCRIPT_TEST_EXTENSIONS`]): `it` / `test`
555/// call statements at the top level or directly inside `describe` callbacks.
556/// Markers come from the comment block immediately preceding the call, and
557/// `content` is the whole call statement.
558pub fn extract_typescript_tests(
559    path: &str,
560    source: &str,
561) -> Result<Vec<ExtractedTest>, ExtractError> {
562    let mut parser = tree_sitter::Parser::new();
563    let lang: tree_sitter::Language = if [".ts", ".mts", ".cts"]
564        .iter()
565        .any(|ext| path.ends_with(ext))
566    {
567        tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
568    } else {
569        tree_sitter_typescript::LANGUAGE_TSX.into()
570    };
571    parser
572        .set_language(&lang)
573        .expect("valid typescript grammar");
574    let tree = parser
575        .parse(source, None)
576        .ok_or_else(|| ExtractError::Parse {
577            path: path.to_string(),
578        })?;
579    let root = tree.root_node();
580    if root.has_error() {
581        return Err(ExtractError::Parse {
582            path: path.to_string(),
583        });
584    }
585    let base = TYPESCRIPT_TEST_EXTENSIONS
586        .iter()
587        .find_map(|ext| path.strip_suffix(&format!(".{ext}")))
588        .unwrap_or(path);
589    let mut results = Vec::new();
590    walk_typescript_statements(root, source, base, &[], &mut results);
591    Ok(results)
592}
593
594/// Returns the base identifier and the property chain of a callee made only
595/// of identifiers and member accesses (`test.concurrent.only`).
596fn typescript_callee_chain<'a>(
597    callee: tree_sitter::Node,
598    source: &'a str,
599) -> Option<(&'a str, Vec<&'a str>)> {
600    let mut props = Vec::new();
601    let mut cur = callee;
602    loop {
603        match cur.kind() {
604            "identifier" => {
605                props.reverse();
606                return Some((cur.utf8_text(source.as_bytes()).ok()?, props));
607            }
608            "member_expression" => {
609                let mut c = cur.walk();
610                if cur.children(&mut c).any(|ch| ch.kind() == "optional_chain") {
611                    return None;
612                }
613                let prop = cur.child_by_field_name("property")?;
614                if prop.kind() != "property_identifier" {
615                    return None;
616                }
617                props.push(prop.utf8_text(source.as_bytes()).ok()?);
618                cur = cur.child_by_field_name("object")?;
619            }
620            _ => return None,
621        }
622    }
623}
624
625/// First-argument name: a plain string or a substitution-free template
626/// string, sliced from the source without interpreting escapes.
627fn typescript_name_arg<'a>(args: tree_sitter::Node, source: &'a str) -> Option<&'a str> {
628    let first = args.named_child(0)?;
629    match first.kind() {
630        "string" => {}
631        "template_string" => {
632            let mut c = first.walk();
633            if first
634                .children(&mut c)
635                .any(|ch| ch.kind() == "template_substitution")
636            {
637                return None;
638            }
639        }
640        _ => return None,
641    }
642    let text = first.utf8_text(source.as_bytes()).ok()?;
643    if text.len() < 2 {
644        return None;
645    }
646    let name = &text[1..text.len() - 1];
647    if !is_valid_test_name(name) {
648        return None;
649    }
650    Some(name)
651}
652
653fn walk_typescript_statements(
654    container: tree_sitter::Node,
655    source: &str,
656    base: &str,
657    describes: &[String],
658    results: &mut Vec<ExtractedTest>,
659) {
660    let mut cursor = container.walk();
661    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
662    for (idx, node) in children.iter().enumerate() {
663        if node.kind() != "expression_statement" {
664            continue;
665        }
666        let Some(call) = node.named_child(0) else {
667            continue;
668        };
669        if call.kind() != "call_expression" {
670            continue;
671        }
672        let (Some(callee), Some(args)) = (
673            call.child_by_field_name("function"),
674            call.child_by_field_name("arguments"),
675        ) else {
676            continue;
677        };
678        let Some((head, props)) = typescript_callee_chain(callee, source) else {
679            continue;
680        };
681        let Some(name) = typescript_name_arg(args, source) else {
682            continue;
683        };
684        if (head == "test" || head == "it")
685            && props.iter().all(|p| TYPESCRIPT_TEST_MODIFIERS.contains(p))
686        {
687            let markers = collect_preceding_markers(&children, idx, source, "comment");
688            let content = node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
689            results.push(ExtractedTest {
690                id: format!("{base}:{}:{name}", describes.join("/")),
691                name: name.to_string(),
692                markers,
693                content,
694            });
695        } else if head == "describe"
696            && props
697                .iter()
698                .all(|p| TYPESCRIPT_DESCRIBE_MODIFIERS.contains(p))
699        {
700            let Some(cb) = args.named_child(1) else {
701                continue;
702            };
703            if cb.kind() != "arrow_function" && cb.kind() != "function_expression" {
704                continue;
705            }
706            let Some(body) = cb.child_by_field_name("body") else {
707                continue;
708            };
709            if body.kind() != "statement_block" {
710                continue;
711            }
712            let mut new_desc = describes.to_vec();
713            new_desc.push(name.to_string());
714            walk_typescript_statements(body, source, base, &new_desc, results);
715        }
716    }
717}
718
719// ---- Dart / Flutter (package:test / flutter_test) ----
720
721/// Whether `path` is a Dart test file, i.e. ends with `_test.dart`.
722pub fn is_dart_test_file(path: &str) -> bool {
723    path.ends_with("_test.dart")
724}
725
726/// Dart doc comments (`///`, `/** */`) are never markers and do not end the
727/// adjacent comment block. Decided by text so `////`, `/***` and `/**/` stay
728/// ordinary comments (as for Rust).
729fn is_dart_doc_comment(text: &str) -> bool {
730    (text.starts_with("///") && !text.starts_with("////"))
731        || (text.starts_with("/**") && !text.starts_with("/***") && !text.starts_with("/**/"))
732}
733
734/// Extract Dart (`package:test` / `flutter_test`) tests from a `*_test.dart`
735/// file: `test` / `testWidgets` call statements directly inside `main` or
736/// inside the callback of a `group` that is itself reachable that way.
737/// Markers come from the comment block immediately preceding the call, and
738/// `content` is the whole call statement.
739pub fn extract_dart_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
740    let mut parser = tree_sitter::Parser::new();
741    parser
742        .set_language(&tree_sitter_dart::LANGUAGE.into())
743        .expect("valid dart grammar");
744    let tree = parser
745        .parse(source, None)
746        .ok_or_else(|| ExtractError::Parse {
747            path: path.to_string(),
748        })?;
749    let root = tree.root_node();
750    if root.has_error() {
751        return Err(ExtractError::Parse {
752            path: path.to_string(),
753        });
754    }
755    let base = path.strip_suffix(".dart").unwrap_or(path);
756    let mut results = Vec::new();
757    let mut cursor = root.walk();
758    for decl in root.children(&mut cursor) {
759        if decl.kind() != "function_declaration" {
760            continue;
761        }
762        let is_main = decl
763            .child_by_field_name("signature")
764            .and_then(|s| s.child_by_field_name("name"))
765            .and_then(|n| n.utf8_text(source.as_bytes()).ok())
766            == Some("main");
767        if !is_main {
768            continue;
769        }
770        let Some(block) = decl
771            .child_by_field_name("body")
772            .and_then(|b| b.named_child(0))
773            .filter(|b| b.kind() == "block")
774        else {
775            continue;
776        };
777        walk_dart_statements(block, source, base, &[], &mut results);
778    }
779    Ok(results)
780}
781
782/// Positional arguments of a Dart `arguments` node (named arguments such as
783/// `skip:` are left out).
784fn dart_positional_args(args: tree_sitter::Node) -> Vec<tree_sitter::Node> {
785    let mut cursor = args.walk();
786    args.named_children(&mut cursor)
787        .filter(|n| n.kind() != "named_argument" && n.kind() != "comment")
788        .collect()
789}
790
791/// Name from a single-line (possibly raw, possibly adjacent-concatenated)
792/// `string_literal`: the source text inside each part's quotes, joined. Strings
793/// with interpolation or triple quotes yield `None`; escapes are not
794/// interpreted.
795fn dart_string_name(lit: tree_sitter::Node, source: &str) -> Option<String> {
796    if lit.kind() != "string_literal" {
797        return None;
798    }
799    let mut name = String::new();
800    let mut cursor = lit.walk();
801    let mut parts = 0;
802    for part in lit.children(&mut cursor) {
803        let prefix = match part.kind() {
804            "string_literal_single_quotes" | "string_literal_double_quotes" => 1,
805            "raw_string_literal_single_quotes" | "raw_string_literal_double_quotes" => 2,
806            "comment" => continue,
807            _ => return None,
808        };
809        let mut c = part.walk();
810        if part
811            .children(&mut c)
812            .any(|ch| ch.kind() == "template_substitution")
813        {
814            return None;
815        }
816        let text = part.utf8_text(source.as_bytes()).ok()?;
817        if text.len() < prefix + 1 {
818            return None;
819        }
820        name.push_str(text.get(prefix..text.len() - 1)?);
821        parts += 1;
822    }
823    if parts == 0 || !is_valid_test_name(&name) {
824        return None;
825    }
826    Some(name)
827}
828
829fn walk_dart_statements(
830    block: tree_sitter::Node,
831    source: &str,
832    base: &str,
833    groups: &[String],
834    results: &mut Vec<ExtractedTest>,
835) {
836    let mut cursor = block.walk();
837    let children: Vec<tree_sitter::Node> = block.children(&mut cursor).collect();
838    for (idx, node) in children.iter().enumerate() {
839        if node.kind() != "expression_statement" {
840            continue;
841        }
842        let Some(call) = node.named_child(0) else {
843            continue;
844        };
845        if call.kind() != "call_expression" {
846            continue;
847        }
848        let (Some(callee), Some(args)) = (
849            call.child_by_field_name("function"),
850            call.child_by_field_name("arguments"),
851        ) else {
852            continue;
853        };
854        if callee.kind() != "identifier" {
855            continue;
856        }
857        let head = callee.utf8_text(source.as_bytes()).unwrap_or("");
858        if !matches!(head, "test" | "testWidgets" | "group") {
859            continue;
860        }
861        let positional = dart_positional_args(args);
862        let Some(name) = positional
863            .first()
864            .and_then(|a| dart_string_name(*a, source))
865        else {
866            continue;
867        };
868        if head == "group" {
869            let Some(cb) = positional
870                .get(1)
871                .filter(|cb| cb.kind() == "function_expression")
872            else {
873                continue;
874            };
875            let Some(inner) = cb
876                .child_by_field_name("body")
877                .filter(|b| b.kind() == "function_expression_body")
878                .and_then(|b| b.named_child(0))
879                .filter(|b| b.kind() == "block")
880            else {
881                continue;
882            };
883            let mut new_groups = groups.to_vec();
884            new_groups.push(name);
885            walk_dart_statements(inner, source, base, &new_groups, results);
886        } else {
887            let markers = collect_preceding_markers_with(
888                &children,
889                idx,
890                source,
891                &|n| n.utf8_text(source.as_bytes()).ok(),
892                &["comment"],
893                &|n| {
894                    n.utf8_text(source.as_bytes())
895                        .is_ok_and(is_dart_doc_comment)
896                },
897            );
898            let content = node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
899            results.push(ExtractedTest {
900                id: format!("{base}:{}:{name}", groups.join("/")),
901                name,
902                markers,
903                content,
904            });
905        }
906    }
907}
908
909// ---- Java / Kotlin (JUnit) ----
910
911/// Annotation names (last identifier, qualifiers ignored) marking a JUnit
912/// style test method / function in Java and Kotlin.
913const JVM_TEST_ANNOTATIONS: [&str; 5] = [
914    "Test",
915    "ParameterizedTest",
916    "RepeatedTest",
917    "TestFactory",
918    "TestTemplate",
919];
920
921const JVM_COMMENT_KINDS: [&str; 2] = ["line_comment", "block_comment"];
922
923/// Start byte of the hashed range: the first token of the declaration that is
924/// neither an annotation nor a comment (annotations / comments that lead the
925/// modifiers and the comments right after them are skipped).
926fn jvm_content_start(decl: tree_sitter::Node) -> Option<usize> {
927    fn skip(kind: &str) -> bool {
928        matches!(
929            kind,
930            "annotation" | "marker_annotation" | "line_comment" | "block_comment"
931        )
932    }
933    let mut cursor = decl.walk();
934    for child in decl.children(&mut cursor) {
935        if child.kind() == "modifiers" {
936            let mut c2 = child.walk();
937            let first = child.children(&mut c2).find(|n| !skip(n.kind()));
938            if let Some(first) = first {
939                return Some(first.start_byte());
940            }
941        } else if !skip(child.kind()) {
942            return Some(child.start_byte());
943        }
944    }
945    None
946}
947
948fn jvm_name_is_test(name: &str) -> bool {
949    JVM_TEST_ANNOTATIONS.contains(&name)
950}
951
952// -- Java --
953
954/// Extract Java (JUnit) tests: methods annotated with one of
955/// [`JVM_TEST_ANNOTATIONS`] directly inside class / record / interface / enum
956/// bodies. Markers come from the `//` / `/* */` comment block immediately
957/// preceding the method; `content` starts at the first non-annotation token.
958pub fn extract_java_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
959    let mut parser = tree_sitter::Parser::new();
960    parser
961        .set_language(&tree_sitter_java::LANGUAGE.into())
962        .expect("valid java grammar");
963    let tree = parser
964        .parse(source, None)
965        .ok_or_else(|| ExtractError::Parse {
966            path: path.to_string(),
967        })?;
968    let root = tree.root_node();
969    if root.has_error() {
970        return Err(ExtractError::Parse {
971            path: path.to_string(),
972        });
973    }
974    let base = path.strip_suffix(".java").unwrap_or(path);
975    let mut results = Vec::new();
976    walk_java_container(root, source, base, &[], &mut results);
977    Ok(results)
978}
979
980fn walk_java_container(
981    container: tree_sitter::Node,
982    source: &str,
983    base: &str,
984    types: &[String],
985    results: &mut Vec<ExtractedTest>,
986) {
987    let mut cursor = container.walk();
988    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
989    for (idx, node) in children.iter().enumerate() {
990        match node.kind() {
991            "class_declaration"
992            | "record_declaration"
993            | "interface_declaration"
994            | "enum_declaration" => {
995                let (Some(name_node), Some(body)) = (
996                    node.child_by_field_name("name"),
997                    node.child_by_field_name("body"),
998                ) else {
999                    continue;
1000                };
1001                let name = name_node.utf8_text(source.as_bytes()).unwrap_or("");
1002                let mut new_types = types.to_vec();
1003                new_types.push(name.to_string());
1004                if body.kind() == "enum_body" {
1005                    let mut c = body.walk();
1006                    let decls: Vec<_> = body
1007                        .children(&mut c)
1008                        .filter(|n| n.kind() == "enum_body_declarations")
1009                        .collect();
1010                    for d in decls {
1011                        walk_java_container(d, source, base, &new_types, results);
1012                    }
1013                } else {
1014                    walk_java_container(body, source, base, &new_types, results);
1015                }
1016            }
1017            "method_declaration" if !types.is_empty() => {
1018                if !java_is_test_method(*node, source) {
1019                    continue;
1020                }
1021                let Some(name_node) = node.child_by_field_name("name") else {
1022                    continue;
1023                };
1024                let Some(start) = jvm_content_start(*node) else {
1025                    continue;
1026                };
1027                let name = name_node
1028                    .utf8_text(source.as_bytes())
1029                    .unwrap_or("")
1030                    .to_string();
1031                results.push(ExtractedTest {
1032                    id: format!("{base}:{}:{name}", types.join("/")),
1033                    name,
1034                    markers: collect_preceding_markers_with(
1035                        &children,
1036                        idx,
1037                        source,
1038                        &|n| n.utf8_text(source.as_bytes()).ok(),
1039                        &JVM_COMMENT_KINDS,
1040                        &|n| n.utf8_text(source.as_bytes()).is_ok_and(is_doc_comment),
1041                    ),
1042                    content: source[start..node.end_byte()].to_string(),
1043                });
1044            }
1045            _ => {}
1046        }
1047    }
1048}
1049
1050fn java_is_test_method(method: tree_sitter::Node, source: &str) -> bool {
1051    let mut cursor = method.walk();
1052    let Some(modifiers) = method
1053        .children(&mut cursor)
1054        .find(|n| n.kind() == "modifiers")
1055    else {
1056        return false;
1057    };
1058    let mut c2 = modifiers.walk();
1059    for ann in modifiers.children(&mut c2) {
1060        if ann.kind() != "marker_annotation" && ann.kind() != "annotation" {
1061            continue;
1062        }
1063        let Some(mut name) = ann.child_by_field_name("name") else {
1064            continue;
1065        };
1066        while name.kind() == "scoped_identifier" {
1067            match name.child_by_field_name("name") {
1068                Some(n) => name = n,
1069                None => break,
1070            }
1071        }
1072        if jvm_name_is_test(name.utf8_text(source.as_bytes()).unwrap_or("")) {
1073            return true;
1074        }
1075    }
1076    false
1077}
1078
1079// -- Kotlin --
1080
1081/// The Kotlin grammar rejects identifiers with many non-Latin characters.
1082/// The source is therefore parsed as an "ASCII shadow": every non-ASCII
1083/// code point becomes one ASCII character (`' '` for whitespace and U+FEFF,
1084/// `'x'` otherwise). `map` records `(shadow_offset, original_offset)` pairs at
1085/// every point where the two offsets diverge, so shadow byte ranges can be
1086/// mapped back to the original source.
1087struct KotlinShadow<'a> {
1088    original: &'a str,
1089    shadow: String,
1090    map: Vec<(usize, usize)>,
1091}
1092
1093impl<'a> KotlinShadow<'a> {
1094    fn new(original: &'a str) -> Self {
1095        let mut shadow = String::with_capacity(original.len());
1096        let mut map = vec![(0usize, 0usize)];
1097        for (off, ch) in original.char_indices() {
1098            if ch.is_ascii() {
1099                shadow.push(ch);
1100            } else {
1101                shadow.push(if ch.is_whitespace() || ch == '\u{feff}' {
1102                    ' '
1103                } else {
1104                    'x'
1105                });
1106                map.push((shadow.len(), off + ch.len_utf8()));
1107            }
1108        }
1109        KotlinShadow {
1110            original,
1111            shadow,
1112            map,
1113        }
1114    }
1115
1116    /// Replaces the given shadow byte ranges with spaces (newlines kept).
1117    fn blank_ranges(&mut self, ranges: &[(usize, usize)]) {
1118        let mut bytes = std::mem::take(&mut self.shadow).into_bytes();
1119        for &(a, b) in ranges {
1120            for byte in &mut bytes[a..b] {
1121                if *byte != b'\n' {
1122                    *byte = b' ';
1123                }
1124            }
1125        }
1126        self.shadow = String::from_utf8(bytes).expect("ascii shadow");
1127    }
1128
1129    fn to_original(&self, shadow_offset: usize) -> usize {
1130        let i = self.map.partition_point(|&(s, _)| s <= shadow_offset) - 1;
1131        let (s, o) = self.map[i];
1132        o + (shadow_offset - s)
1133    }
1134
1135    fn slice(&self, start: usize, end: usize) -> Option<&'a str> {
1136        self.original
1137            .get(self.to_original(start)..self.to_original(end))
1138    }
1139
1140    fn node_text(&self, node: tree_sitter::Node) -> Option<&'a str> {
1141        self.slice(node.start_byte(), node.end_byte())
1142    }
1143}
1144
1145/// Extract Kotlin (JUnit) tests: functions annotated with one of
1146/// [`JVM_TEST_ANNOTATIONS`] at the top level or directly inside class /
1147/// object / companion object bodies. Parsing uses an ASCII shadow of the
1148/// source (see [`KotlinShadow`]); names and `content` come from the original.
1149pub fn extract_kotlin_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
1150    kotlin_extract(path, source, true)
1151}
1152
1153/// Top-level node kinds the Kotlin grammar may legitimately produce under
1154/// `source_file`. Anything else (e.g. a leftover `annotated_expression`)
1155/// means the grammar failed to recognise a declaration.
1156const KOTLIN_ROOT_KINDS: [&str; 11] = [
1157    "shebang",
1158    "file_annotation",
1159    "package_header",
1160    "import",
1161    "class_declaration",
1162    "object_declaration",
1163    "function_declaration",
1164    "property_declaration",
1165    "type_alias",
1166    "line_comment",
1167    "block_comment",
1168];
1169
1170/// `retry_annotated_expression` enables the class-annotation blanking
1171/// workaround (disabled only by tests of the fallback error path).
1172fn kotlin_extract(
1173    path: &str,
1174    source: &str,
1175    retry_annotated_expression: bool,
1176) -> Result<Vec<ExtractedTest>, ExtractError> {
1177    let parse_err = || ExtractError::Parse {
1178        path: path.to_string(),
1179    };
1180    let mut shadow = KotlinShadow::new(source);
1181    let mut parser = tree_sitter::Parser::new();
1182    parser
1183        .set_language(&tree_sitter_kotlin_ng::LANGUAGE.into())
1184        .expect("valid kotlin grammar");
1185    let mut tree = parser.parse(&shadow.shadow, None).ok_or_else(parse_err)?;
1186    if tree.root_node().has_error() {
1187        return Err(parse_err());
1188    }
1189    // Grammar bugs that leave an `annotated_expression` at file level with no
1190    // error: (1) a class with 2+ annotations, no supertype and an
1191    // `@Annotation inner class` in its body; (2) an annotation with
1192    // positional arguments (`@S("L")`) after a top-level declaration and a
1193    // comment. The annotations are not needed for extraction, so blank them
1194    // out and parse again.
1195    if retry_annotated_expression && kotlin_has_annotated_expression(tree.root_node()) {
1196        let ranges = kotlin_annotated_expression_ranges(tree.root_node());
1197        drop(tree);
1198        shadow.blank_ranges(&ranges);
1199        tree = parser.parse(&shadow.shadow, None).ok_or_else(parse_err)?;
1200        if tree.root_node().has_error() {
1201            return Err(parse_err());
1202        }
1203    }
1204    let root = tree.root_node();
1205    // A structure other than declarations at file level means the grammar
1206    // missed a declaration: never silently report zero tests.
1207    let mut cursor = root.walk();
1208    let unexpected = root
1209        .children(&mut cursor)
1210        .any(|n| !KOTLIN_ROOT_KINDS.contains(&n.kind()));
1211    if unexpected {
1212        return Err(parse_err());
1213    }
1214    let base = path.strip_suffix(".kt").unwrap_or(path);
1215    let mut results = Vec::new();
1216    walk_kotlin_container(root, &shadow, base, &[], &mut results).map_err(|()| parse_err())?;
1217    Ok(results)
1218}
1219
1220fn kotlin_has_annotated_expression(root: tree_sitter::Node) -> bool {
1221    let mut cursor = root.walk();
1222    let found = root
1223        .children(&mut cursor)
1224        .any(|n| n.kind() == "annotated_expression");
1225    found
1226}
1227
1228/// Byte ranges to blank for each file-level `annotated_expression`. With
1229/// positional arguments (`@S("L")`) the grammar splits the annotation into an
1230/// `annotation` (`@S`) and a `parenthesized_expression` (`("L")`); only the
1231/// latter is blanked, which leaves a plain annotation that parses as part of
1232/// the declaration's modifiers (so marker adjacency still sees its first
1233/// row). Otherwise (the class case) the `annotation` children are blanked. The
1234/// expression as a whole is never blanked: in the class case it spans the
1235/// entire class body.
1236fn kotlin_annotated_expression_ranges(root: tree_sitter::Node) -> Vec<(usize, usize)> {
1237    let mut ranges = Vec::new();
1238    let mut cursor = root.walk();
1239    for expr in root.children(&mut cursor) {
1240        if expr.kind() != "annotated_expression" {
1241            continue;
1242        }
1243        let mut c2 = expr.walk();
1244        let children: Vec<tree_sitter::Node> = expr.children(&mut c2).collect();
1245        let args: Vec<_> = children
1246            .windows(2)
1247            .filter(|w| w[0].kind() == "annotation" && w[1].kind() == "parenthesized_expression")
1248            .map(|w| &w[1])
1249            .collect();
1250        let picked: Vec<_> = if args.is_empty() {
1251            children
1252                .iter()
1253                .filter(|c| c.kind() == "annotation")
1254                .collect()
1255        } else {
1256            args
1257        };
1258        ranges.extend(picked.iter().map(|n| (n.start_byte(), n.end_byte())));
1259    }
1260    ranges
1261}
1262
1263fn kotlin_ident(text: &str) -> String {
1264    text.strip_prefix('`')
1265        .and_then(|t| t.strip_suffix('`'))
1266        .unwrap_or(text)
1267        .to_string()
1268}
1269
1270fn walk_kotlin_container(
1271    container: tree_sitter::Node,
1272    shadow: &KotlinShadow,
1273    base: &str,
1274    types: &[String],
1275    results: &mut Vec<ExtractedTest>,
1276) -> Result<(), ()> {
1277    let mut cursor = container.walk();
1278    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
1279    for (idx, node) in children.iter().enumerate() {
1280        match node.kind() {
1281            "class_declaration" | "object_declaration" | "companion_object" => {
1282                let name = match node.child_by_field_name("name") {
1283                    Some(n) => match shadow.node_text(n) {
1284                        Some(t) => kotlin_ident(t),
1285                        None => {
1286                            debug_assert!(false, "kotlin name range off a char boundary");
1287                            return Err(());
1288                        }
1289                    },
1290                    None if node.kind() == "companion_object" => "Companion".to_string(),
1291                    None => continue,
1292                };
1293                let mut c = node.walk();
1294                let Some(body) = node
1295                    .children(&mut c)
1296                    .find(|n| matches!(n.kind(), "class_body" | "enum_class_body"))
1297                else {
1298                    continue;
1299                };
1300                let mut new_types = types.to_vec();
1301                new_types.push(name);
1302                walk_kotlin_container(body, shadow, base, &new_types, results)?;
1303            }
1304            "function_declaration" => {
1305                if !kotlin_is_test_function(*node, shadow) {
1306                    continue;
1307                }
1308                let Some(name_node) = node.child_by_field_name("name") else {
1309                    continue;
1310                };
1311                let Some(name_text) = shadow.node_text(name_node) else {
1312                    debug_assert!(false, "kotlin name range off a char boundary");
1313                    return Err(());
1314                };
1315                let name = kotlin_ident(name_text);
1316                if !is_valid_test_name(&name) {
1317                    continue;
1318                }
1319                let Some(start) = jvm_content_start(*node) else {
1320                    continue;
1321                };
1322                let Some(content) = shadow.slice(start, node.end_byte()) else {
1323                    debug_assert!(false, "kotlin content range off a char boundary");
1324                    return Err(());
1325                };
1326                results.push(ExtractedTest {
1327                    id: format!("{base}:{}:{name}", types.join("/")),
1328                    name,
1329                    // Markers are read from the original source: the shadow
1330                    // turns `[正常系]` into a bogus `[xxx]`.
1331                    markers: collect_preceding_markers_with(
1332                        &children,
1333                        idx,
1334                        &shadow.shadow,
1335                        &|n| shadow.node_text(n),
1336                        &JVM_COMMENT_KINDS,
1337                        &|n| shadow.node_text(n).is_some_and(is_doc_comment),
1338                    ),
1339                    content: content.to_string(),
1340                });
1341            }
1342            _ => {}
1343        }
1344    }
1345    Ok(())
1346}
1347
1348fn kotlin_is_test_function(func: tree_sitter::Node, shadow: &KotlinShadow) -> bool {
1349    let mut cursor = func.walk();
1350    let Some(modifiers) = func.children(&mut cursor).find(|n| n.kind() == "modifiers") else {
1351        return false;
1352    };
1353    let mut c2 = modifiers.walk();
1354    let found = modifiers
1355        .children(&mut c2)
1356        .filter(|n| n.kind() == "annotation")
1357        .any(|ann| kotlin_annotation_has_test(ann, shadow));
1358    found
1359}
1360
1361/// Whether any `user_type` under the annotation (outside argument lists) ends
1362/// in a test annotation name.
1363fn kotlin_annotation_has_test(node: tree_sitter::Node, shadow: &KotlinShadow) -> bool {
1364    let mut cursor = node.walk();
1365    for child in node.children(&mut cursor) {
1366        match child.kind() {
1367            "user_type" => {
1368                let mut c = child.walk();
1369                let last = child
1370                    .children(&mut c)
1371                    .filter(|n| matches!(n.kind(), "identifier" | "type_identifier"))
1372                    .last();
1373                if let Some(last) = last {
1374                    if jvm_name_is_test(shadow.node_text(last).unwrap_or("")) {
1375                        return true;
1376                    }
1377                }
1378            }
1379            "value_arguments" | "use_site_target" => {}
1380            _ => {
1381                if kotlin_annotation_has_test(child, shadow) {
1382                    return true;
1383                }
1384            }
1385        }
1386    }
1387    false
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393
1394    fn find<'a>(tests: &'a [ExtractedTest], name: &str) -> &'a ExtractedTest {
1395        tests
1396            .iter()
1397            .find(|t| t.name == name)
1398            .unwrap_or_else(|| panic!("test named {name} not found in {tests:?}"))
1399    }
1400
1401    // [FR-012]
1402    #[test]
1403    fn goは直前に隣接するコメントをマーカーとして抽出する() {
1404        let source = r#"package auth
1405
1406// [FR-001]
1407func TestRejectsShortPassword(t *testing.T) {
1408    doSomething()
1409}
1410"#;
1411        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1412        let t = find(&tests, "TestRejectsShortPassword");
1413        assert_eq!(t.markers, vec!["FR-001".to_string()]);
1414    }
1415
1416    // [FR-012]
1417    #[test]
1418    fn goは空行を挟んだコメントや欠落をマーカーなしとして無視する() {
1419        let source = r#"package auth
1420
1421// [FR-002]
1422
1423func TestWithBlankLine(t *testing.T) {
1424}
1425
1426func TestNoComment(t *testing.T) {
1427}
1428"#;
1429        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1430        assert!(find(&tests, "TestWithBlankLine").markers.is_empty());
1431        assert!(find(&tests, "TestNoComment").markers.is_empty());
1432    }
1433
1434    // [FR-013]
1435    #[test]
1436    fn goのtest識別子はディレクトリと関数名を組み合わせた形式になる() {
1437        let source = "package auth\n\n// [FR-001]\nfunc TestLogin(t *testing.T) {}\n";
1438        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1439        assert_eq!(find(&tests, "TestLogin").id, "internal/auth:TestLogin");
1440    }
1441
1442    // [FR-013]
1443    #[test]
1444    fn goのtest識別子はリポジトリ直下ならドットになる() {
1445        let source = "package main\n\n// [FR-001]\nfunc TestRoot(t *testing.T) {}\n";
1446        let tests = extract_go_tests("login_test.go", source).unwrap();
1447        assert_eq!(find(&tests, "TestRoot").id, ".:TestRoot");
1448    }
1449
1450    // [FR-003]
1451    #[test]
1452    fn rustの関数本体の変更はcontentに反映される() {
1453        let base = r#"// [FR-001]
1454#[test]
1455fn rejects_short_password() {
1456    assert!(true);
1457}
1458"#;
1459        let changed = r#"// [FR-001]
1460#[test]
1461fn rejects_short_password() {
1462    assert!(false);
1463}
1464"#;
1465        let a = find(
1466            &extract_rust_tests("src/auth.rs", base, &[]).unwrap(),
1467            "rejects_short_password",
1468        )
1469        .content
1470        .clone();
1471        let b = find(
1472            &extract_rust_tests("src/auth.rs", changed, &[]).unwrap(),
1473            "rejects_short_password",
1474        )
1475        .content
1476        .clone();
1477        assert_ne!(a, b);
1478        assert!(a.contains("fn rejects_short_password"));
1479        assert!(a.contains("assert!(true)"));
1480    }
1481
1482    // [FR-003]
1483    #[test]
1484    fn goの関数本体の変更はcontentに反映される() {
1485        let base = "package auth\n\n// [FR-001]\nfunc TestRejectsShortPassword(t *testing.T) {\n\tassertTrue(t, true)\n}\n";
1486        let changed = "package auth\n\n// [FR-001]\nfunc TestRejectsShortPassword(t *testing.T) {\n\tassertTrue(t, false)\n}\n";
1487        let a = find(
1488            &extract_go_tests("internal/auth/login_test.go", base).unwrap(),
1489            "TestRejectsShortPassword",
1490        )
1491        .content
1492        .clone();
1493        let b = find(
1494            &extract_go_tests("internal/auth/login_test.go", changed).unwrap(),
1495            "TestRejectsShortPassword",
1496        )
1497        .content
1498        .clone();
1499        assert_ne!(a, b);
1500        assert!(a.contains("func TestRejectsShortPassword"));
1501        assert!(a.contains("assertTrue(t, true)"));
1502    }
1503
1504    // [FR-004]
1505    #[test]
1506    fn rustのcontentは外側の属性とマーカーコメントを含まない() {
1507        let source = r#"// [FR-001]
1508#[test]
1509fn rejects_short_password() {
1510    assert!(true);
1511}
1512"#;
1513        let extracted = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1514        let t = find(&extracted, "rejects_short_password");
1515        assert!(!t.content.contains("#[test]"));
1516        assert!(!t.content.contains("[FR-001]"));
1517        assert!(t.content.contains("fn rejects_short_password"));
1518    }
1519
1520    fn rs(source: &str, extra: &[&str]) -> Vec<ExtractedTest> {
1521        let extra: Vec<String> = extra.iter().map(|s| s.to_string()).collect();
1522        extract_rust_tests("src/auth.rs", source, &extra).unwrap()
1523    }
1524
1525    fn has(tests: &[ExtractedTest], name: &str) -> bool {
1526        tests.iter().any(|t| t.name == name)
1527    }
1528
1529    // [FR-015]
1530    #[test]
1531    fn rustは属性パスの末尾がtestならtestとして抽出する() {
1532        let source = r#"// [FR-001]
1533#[test]
1534fn plain_test() {}
1535
1536// [FR-002]
1537#[tokio::test]
1538async fn async_test() {}
1539
1540// [FR-003]
1541#[async_std::test]
1542async fn async_std_test() {}
1543
1544// [FR-004]
1545#[tokio::test(flavor = "multi_thread")]
1546async fn multi_thread_test() {}
1547
1548// [FR-005]
1549#[ test ]
1550fn spaced_test() {}
1551"#;
1552        let tests = rs(source, &[]);
1553        for name in [
1554            "plain_test",
1555            "async_test",
1556            "async_std_test",
1557            "multi_thread_test",
1558            "spaced_test",
1559        ] {
1560            assert!(has(&tests, name), "{name} should be extracted");
1561        }
1562        assert_eq!(
1563            find(&tests, "async_test").markers,
1564            vec!["FR-002".to_string()]
1565        );
1566    }
1567
1568    // [FR-015]
1569    #[test]
1570    fn rustのrstestは追加指定がなければ対象外で指定があれば対象になる() {
1571        let source = "// [FR-001]\n#[rstest]\nfn t() {}\n";
1572        assert!(!has(&rs(source, &[]), "t"));
1573        assert!(has(&rs(source, &["rstest"]), "t"));
1574
1575        let scoped = "// [FR-001]\n#[rstest::rstest]\nfn t() {}\n";
1576        assert!(!has(&rs(scoped, &[]), "t"));
1577        assert!(has(&rs(scoped, &["rstest"]), "t"));
1578    }
1579
1580    // [FR-015]
1581    #[test]
1582    fn rustのtest_caseとcfg_attrのtestは対象外() {
1583        let source = r#"// [FR-001]
1584#[test_case(1)]
1585fn case_test() {}
1586
1587// [FR-002]
1588#[cfg_attr(x, test)]
1589fn cfg_attr_test() {}
1590"#;
1591        let tests = rs(source, &[]);
1592        assert!(!has(&tests, "case_test"));
1593        assert!(!has(&tests, "cfg_attr_test"));
1594
1595        let tests = rs(source, &["test_case"]);
1596        assert!(has(&tests, "case_test"));
1597        assert!(!has(&tests, "cfg_attr_test"));
1598    }
1599
1600    // [FR-015]
1601    #[test]
1602    fn rustのinner_attributeのtestはtestを作らない() {
1603        let source = "#![test]\n\n// [FR-001]\nfn helper() {}\n";
1604        assert!(rs(source, &[]).is_empty());
1605    }
1606
1607    // [FR-016]
1608    #[test]
1609    fn rustは最初の対象属性の直前のコメントをマーカーにする() {
1610        let source = "// [FR-001]\n#[rstest]\n#[case(1)]\n#[tokio::test]\nfn t() {}\n";
1611        let tests = rs(source, &["rstest"]);
1612        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
1613    }
1614
1615    // [FR-016]
1616    #[test]
1617    fn rustは対象属性の設定によりマーカー位置が変わる() {
1618        let source = "#[rstest]\n// [FR-001]\n#[tokio::test]\nfn t() {}\n";
1619        let without = rs(source, &[]);
1620        assert_eq!(find(&without, "t").markers, vec!["FR-001".to_string()]);
1621        let with = rs(source, &["rstest"]);
1622        assert!(find(&with, "t").markers.is_empty());
1623    }
1624
1625    // [FR-016]
1626    #[test]
1627    fn rustは属性間や属性末尾のコメントがあってもtestとして検出しマーカーを取る() {
1628        let between = "// [FR-001]\n#[test]\n// note\n#[ignore]\nfn between() {}\n";
1629        let tests = rs(between, &[]);
1630        assert_eq!(find(&tests, "between").markers, vec!["FR-001".to_string()]);
1631
1632        let trailing = "// [FR-001]\n#[test] // trailing\n#[ignore]\nfn trailing() {}\n";
1633        let tests = rs(trailing, &[]);
1634        assert_eq!(find(&tests, "trailing").markers, vec!["FR-001".to_string()]);
1635
1636        let block = "// [FR-001]\n#[test]\n/* block */\n#[ignore]\nfn block() {}\n";
1637        assert_eq!(
1638            find(&rs(block, &[]), "block").markers,
1639            vec!["FR-001".to_string()]
1640        );
1641    }
1642
1643    // [FR-004]
1644    #[test]
1645    fn tokioのtest属性はcontentに含まれない() {
1646        let source = "// [FR-001]\n#[tokio::test]\nasync fn t() { assert!(true); }\n";
1647        let tests = rs(source, &[]);
1648        let t = find(&tests, "t");
1649        assert!(!t.content.contains("tokio::test"));
1650        assert!(!t.content.contains("#["));
1651        assert!(t.content.contains("async fn t"));
1652    }
1653
1654    // [FR-016]
1655    #[test]
1656    fn rustはtest属性の直前に隣接するコメントをマーカーとする() {
1657        let source = r#"// [FR-001]
1658#[test]
1659fn adjacent() {}
1660
1661// [FR-002]
1662
1663#[test]
1664fn blank_line_between() {}
1665
1666#[test]
1667fn no_comment() {}
1668"#;
1669        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1670        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
1671        assert!(find(&tests, "blank_line_between").markers.is_empty());
1672        assert!(find(&tests, "no_comment").markers.is_empty());
1673    }
1674
1675    // [FR-017]
1676    #[test]
1677    fn rustのtest識別子はトップレベルなら空モジュールパスになる() {
1678        let source = "// [FR-001]\n#[test]\nfn roundtrip() {}\n";
1679        let tests = extract_rust_tests("src/lock.rs", source, &[]).unwrap();
1680        assert_eq!(find(&tests, "roundtrip").id, "src/lock::roundtrip");
1681    }
1682
1683    // [FR-017]
1684    #[test]
1685    fn rustのtest識別子はinline_modを外側から内側の順に連結する() {
1686        let source = r#"mod tests {
1687    // [FR-001]
1688    #[test]
1689    fn roundtrip() {}
1690}
1691"#;
1692        let tests = extract_rust_tests("src/lock.rs", source, &[]).unwrap();
1693        assert_eq!(find(&tests, "roundtrip").id, "src/lock:tests:roundtrip");
1694    }
1695
1696    // [FR-017]
1697    #[test]
1698    fn rustのtest識別子はネストしたinline_modをスラッシュで連結する() {
1699        let source = r#"mod outer {
1700    mod inner {
1701        // [FR-001]
1702        #[test]
1703        fn t() {}
1704    }
1705}
1706"#;
1707        let tests = extract_rust_tests("src/x.rs", source, &[]).unwrap();
1708        assert_eq!(find(&tests, "t").id, "src/x:outer/inner:t");
1709    }
1710
1711    // [FR-016]
1712    #[test]
1713    fn rustは属性が間に挟まるとtestの直前のコメントをマーカーにしない() {
1714        let source = r#"// [FR-001]
1715#[cfg(feature = "x")]
1716#[test]
1717fn not_adjacent() {}
1718"#;
1719        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1720        assert!(find(&tests, "not_adjacent").markers.is_empty());
1721    }
1722
1723    // [FR-016]
1724    #[test]
1725    fn rustは他の属性の後ろでもtestの直前のコメントはマーカーになる() {
1726        let source = r#"#[cfg(feature = "x")]
1727// [FR-001]
1728#[test]
1729fn adjacent_after_other_attr() {}
1730"#;
1731        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1732        assert_eq!(
1733            find(&tests, "adjacent_after_other_attr").markers,
1734            vec!["FR-001".to_string()]
1735        );
1736    }
1737
1738    // [FR-016]
1739    #[test]
1740    fn rustはtestとfnの間にコメントがあってもtestとして検出しマーカーを採用する() {
1741        let source = r#"// [FR-001]
1742#[test]
1743// 補足
1744fn t() {}
1745"#;
1746        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1747        let found = find(&tests, "t");
1748        assert_eq!(found.markers, vec!["FR-001".to_string()]);
1749        assert!(!found.content.contains("補足"));
1750    }
1751
1752    // ---- C# ----
1753
1754    fn cs(source: &str) -> Vec<ExtractedTest> {
1755        extract_csharp_tests("tests/AuthTests.cs", source).unwrap()
1756    }
1757
1758    fn cs_wrap(method: &str) -> String {
1759        format!("public class AuthTests\n{{\n{method}\n}}\n")
1760    }
1761
1762    // [FR-049]
1763    #[test]
1764    fn csharpはif_endif内のtestメソッドも検出する() {
1765        let source = "public class A {\n#if DEBUG\n    // [FR-001]\n    [Fact]\n    public void T() { }\n#endif\n}\n";
1766        let tests = extract_csharp_tests("tests/A.cs", source).unwrap();
1767        assert_eq!(tests.len(), 1);
1768        assert_eq!(tests[0].id, "tests/A:A:T");
1769        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
1770    }
1771
1772    // [FR-049]
1773    #[test]
1774    fn csharpは7種類の属性をそれぞれtestとして検出する() {
1775        for attr in [
1776            "Fact",
1777            "Theory",
1778            "Test",
1779            "TestCase",
1780            "TestCaseSource",
1781            "TestMethod",
1782            "DataTestMethod",
1783        ] {
1784            let source = cs_wrap(&format!("    [{attr}]\n    public void T() {{ }}"));
1785            let tests = cs(&source);
1786            assert_eq!(tests.len(), 1, "attribute {attr} should be detected");
1787            assert_eq!(tests[0].name, "T");
1788        }
1789    }
1790
1791    // [FR-049]
1792    #[test]
1793    fn csharpはattribute接尾辞付きや無関係な属性のみのメソッドを検出しない() {
1794        let source = cs_wrap(
1795            "    [FactAttribute]\n    public void A() { }\n\n    [Obsolete]\n    public void B() { }\n\n    public void C() { }\n\n    [Category(\"x\")]\n    public void D() { }",
1796        );
1797        assert!(cs(&source).is_empty());
1798    }
1799
1800    // [FR-049]
1801    #[test]
1802    fn csharpは引数付きの属性も検出する() {
1803        let source = cs_wrap("    [Fact(Skip = \"x\")]\n    public void T() { }");
1804        assert_eq!(cs(&source).len(), 1);
1805    }
1806
1807    // [FR-049]
1808    #[test]
1809    fn csharpは名前空間修飾された属性も検出する() {
1810        let source = cs_wrap(
1811            "    [Xunit.Fact]\n    public void A() { }\n\n    [global::Xunit.Fact]\n    public void B() { }",
1812        );
1813        let tests = cs(&source);
1814        assert_eq!(tests.len(), 2);
1815        find(&tests, "A");
1816        find(&tests, "B");
1817    }
1818
1819    // [FR-049]
1820    #[test]
1821    fn csharpはtarget指定付きの属性も検出する() {
1822        let source = cs_wrap("    [method: Fact]\n    public void T() { }");
1823        assert_eq!(cs(&source).len(), 1);
1824    }
1825
1826    // [FR-049]
1827    #[test]
1828    fn csharpは同一属性リスト内の他の属性と並んだ属性も検出する() {
1829        let source = cs_wrap("    [Trait(\"a\", \"b\"), Fact]\n    public void T() { }");
1830        assert_eq!(cs(&source).len(), 1);
1831    }
1832
1833    // [FR-049]
1834    #[test]
1835    fn csharpは複数の属性リストの順序に関わらず検出する() {
1836        let source = cs_wrap(
1837            "    [Theory]\n    [InlineData(1)]\n    public void A(int x) { }\n\n    [InlineData(1)]\n    [Theory]\n    public void B(int x) { }",
1838        );
1839        let tests = cs(&source);
1840        assert_eq!(tests.len(), 2);
1841        find(&tests, "A");
1842        find(&tests, "B");
1843    }
1844
1845    // [FR-050]
1846    #[test]
1847    fn csharpは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
1848        let source = cs_wrap(
1849            "    // [FR-001]\n    [Fact]\n    public void Adjacent() { }\n\n    // [FR-002]\n\n    [Fact]\n    public void Blank() { }\n\n    [Fact]\n    public void None() { }",
1850        );
1851        let tests = cs(&source);
1852        assert_eq!(find(&tests, "Adjacent").markers, vec!["FR-001".to_string()]);
1853        assert!(find(&tests, "Blank").markers.is_empty());
1854        assert!(find(&tests, "None").markers.is_empty());
1855    }
1856
1857    // [FR-050]
1858    #[test]
1859    fn csharpはドキュメントコメントとブロックコメントもマーカーにできる() {
1860        let source = cs_wrap(
1861            "    /// [FR-001]\n    [Fact]\n    public void Doc() { }\n\n    /* [FR-002] */\n    [Fact]\n    public void Block() { }",
1862        );
1863        let tests = cs(&source);
1864        assert_eq!(find(&tests, "Doc").markers, vec!["FR-001".to_string()]);
1865        assert_eq!(find(&tests, "Block").markers, vec!["FR-002".to_string()]);
1866    }
1867
1868    // [FR-050]
1869    #[test]
1870    fn csharpは属性と署名の間のコメントをマーカーにせずcontentにも含めない() {
1871        let source = cs_wrap(
1872            "    [Fact]\n    // [FR-001]\n    public void T() { }\n\n    [Fact]\n    [Trait(\"a\", \"b\")]\n    // 補足\n    public void U() { }",
1873        );
1874        let tests = cs(&source);
1875        let t = find(&tests, "T");
1876        assert!(t.markers.is_empty());
1877        assert!(!t.content.contains("FR-001"));
1878        let u = find(&tests, "U");
1879        assert!(!u.content.contains("補足"));
1880    }
1881
1882    // [FR-050]
1883    #[test]
1884    fn csharpは属性リストの間のコメントをマーカーにせずcontentにも含めない() {
1885        let source = cs_wrap(
1886            "    [Theory]\n    // [FR-001]\n    [InlineData(1)]\n    public void T(int x) { }",
1887        );
1888        let tests = cs(&source);
1889        let t = find(&tests, "T");
1890        assert!(t.markers.is_empty());
1891        assert!(!t.content.contains("FR-001"));
1892    }
1893
1894    // [FR-051]
1895    #[test]
1896    fn csharpのtest識別子はトップレベルclassならファイルパスと型名とメソッド名になる() {
1897        let source =
1898            "public class AuthTests\n{\n    [Fact]\n    public void RejectsShort() { }\n}\n";
1899        let tests = cs(source);
1900        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:RejectsShort");
1901    }
1902
1903    // [FR-051]
1904    #[test]
1905    fn csharpのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
1906        let source = "public class Outer\n{\n    public class Inner\n    {\n        [Test]\n        public void T3() { }\n    }\n}\n";
1907        let tests = cs(source);
1908        assert_eq!(tests[0].id, "tests/AuthTests:Outer/Inner:T3");
1909    }
1910
1911    // [FR-051]
1912    #[test]
1913    fn csharpのtest識別子はstruct_record_interfaceの中でも型名を辿る() {
1914        let source = "public struct S\n{\n    [Fact]\n    public void A() { }\n}\n\npublic record R\n{\n    [Fact]\n    public void B() { }\n}\n\npublic interface I\n{\n    [Fact]\n    public void C() { }\n}\n";
1915        let tests = cs(source);
1916        assert_eq!(find(&tests, "A").id, "tests/AuthTests:S:A");
1917        assert_eq!(find(&tests, "B").id, "tests/AuthTests:R:B");
1918        assert_eq!(find(&tests, "C").id, "tests/AuthTests:I:C");
1919    }
1920
1921    // [FR-051]
1922    #[test]
1923    fn csharpのtest識別子はblock_namespaceを含まない() {
1924        let source = "namespace Foo.Bar\n{\n    public class AuthTests\n    {\n        [Fact]\n        public void T() { }\n    }\n}\n";
1925        let tests = cs(source);
1926        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:T");
1927    }
1928
1929    // [FR-051]
1930    #[test]
1931    fn csharpのtest識別子はfile_scoped_namespaceを含まない() {
1932        let source =
1933            "namespace Foo;\n\npublic class AuthTests\n{\n    [Fact]\n    public void T() { }\n}\n";
1934        let tests = cs(source);
1935        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:T");
1936    }
1937
1938    // [FR-052]
1939    #[test]
1940    fn csharpのcontentは属性を含まず修飾子から始まる() {
1941        let source = cs_wrap(
1942            "    // [FR-001]\n    [Fact]\n    [Trait(\"a\", \"b\")]\n    public void T() { Assert.True(true); }",
1943        );
1944        let tests = cs(&source);
1945        let c = &tests[0].content;
1946        assert!(c.starts_with("public void T()"), "content = {c:?}");
1947        assert!(c.ends_with('}'));
1948        assert!(!c.contains("Fact"));
1949        assert!(!c.contains("Trait"));
1950        assert!(!c.contains("FR-001"));
1951    }
1952
1953    // [FR-052]
1954    #[test]
1955    fn csharpの本体変更はcontentに反映される() {
1956        let a = cs(&cs_wrap(
1957            "    [Fact]\n    public void T() { Assert.True(true); }",
1958        ));
1959        let b = cs(&cs_wrap(
1960            "    [Fact]\n    public void T() { Assert.True(false); }",
1961        ));
1962        assert_ne!(a[0].content, b[0].content);
1963    }
1964
1965    // [FR-052]
1966    #[test]
1967    fn csharpの属性だけの変更はcontentを変えない() {
1968        let a = cs(&cs_wrap(
1969            "    [Fact]\n    public void T() { Assert.True(true); }",
1970        ));
1971        let b = cs(&cs_wrap(
1972            "    [Fact(Skip = \"x\")]\n    [Trait(\"a\", \"b\")]\n    public void T() { Assert.True(true); }",
1973        ));
1974        assert_eq!(a[0].content, b[0].content);
1975    }
1976
1977    // [FR-014]
1978    #[test]
1979    fn csharpは構文エラーのソースでエラーを返す() {
1980        let source = "public class A {\n    [Fact]\n    public void T( {\n}\n";
1981        assert!(extract_csharp_tests("tests/A.cs", source).is_err());
1982    }
1983
1984    // ---- TypeScript / JavaScript ----
1985
1986    fn ts(source: &str) -> Vec<ExtractedTest> {
1987        extract_typescript_tests("src/a.test.ts", source).unwrap()
1988    }
1989
1990    // [FR-054]
1991    #[test]
1992    fn typescriptはtestとitの呼び出しを検出する() {
1993        let tests = ts("test(\"a\", () => {});\nit(\"b\", () => {});\n");
1994        assert_eq!(tests.len(), 2);
1995        find(&tests, "a");
1996        find(&tests, "b");
1997    }
1998
1999    // [FR-054]
2000    #[test]
2001    fn typescriptは許可された修飾子付きの呼び出しを検出する() {
2002        let source = "it.skip(\"a\", () => {});\ntest.only(\"b\", () => {});\ntest.concurrent.only(\"c\", () => {});\ntest.todo(\"d\", () => {});\n";
2003        let tests = ts(source);
2004        assert_eq!(tests.len(), 4);
2005        for n in ["a", "b", "c", "d"] {
2006            find(&tests, n);
2007        }
2008    }
2009
2010    // [FR-054]
2011    #[test]
2012    fn typescriptはdescribe_skipの中のtestも検出する() {
2013        let tests = ts("describe.skip(\"d\", () => {\n  it(\"a\", () => {});\n});\n");
2014        assert_eq!(tests.len(), 1);
2015        assert_eq!(tests[0].id, "src/a.test:d:a");
2016    }
2017
2018    // [FR-054]
2019    #[test]
2020    fn typescriptはfunction式のコールバックも検出する() {
2021        let source = "describe(\"d\", function () {\n  it(\"a\", function () {});\n});\n";
2022        let tests = ts(source);
2023        assert_eq!(tests.len(), 1);
2024        assert_eq!(tests[0].id, "src/a.test:d:a");
2025    }
2026
2027    // [FR-053]
2028    #[test]
2029    fn typescriptはtsxファイルのjsxを含むソースも解析できる() {
2030        let source = "it(\"renders\", () => {\n  const x = <div className=\"a\">hi</div>;\n  expect(x).toBeTruthy();\n});\n";
2031        let tests = extract_typescript_tests("src/a.test.tsx", source).unwrap();
2032        assert_eq!(tests.len(), 1);
2033        assert_eq!(tests[0].id, "src/a.test::renders");
2034    }
2035
2036    // [FR-054]
2037    #[test]
2038    fn typescriptは許可外の修飾子や呼び出し結果の呼び出しを検出しない() {
2039        let source = r#"test.each([1, 2])("each %i", (n) => {});
2040test.skipIf(c)("skipif", () => {});
2041test.extend({ a: 1 });
2042test.describe("desc", () => {});
2043test.runIf(c)("runif", () => {});
2044it?.skip("optional", () => {});
2045"#;
2046        assert!(ts(source).is_empty());
2047    }
2048
2049    // [FR-054]
2050    #[test]
2051    fn typescriptはexport_defaultやskipif_describeやforループ内のitを検出しない() {
2052        let source = r#"export default describe("d", () => { it("y", () => {}); })
2053describe.skipIf(c)("d", () => { it("x", () => {}); });
2054for (const n of [1]) {
2055  it("loop", () => {});
2056}
2057"#;
2058        assert!(ts(source).is_empty());
2059    }
2060
2061    // [FR-054]
2062    #[test]
2063    fn typescriptは許可された修飾子を検出し走査する() {
2064        let source = r#"test.fails("f", () => {});
2065it.sequential("s", () => {});
2066describe.shuffle("d", () => {
2067  it("inner", () => {});
2068});
2069"#;
2070        let tests = ts(source);
2071        assert_eq!(tests.len(), 3);
2072        find(&tests, "f");
2073        find(&tests, "s");
2074        assert_eq!(find(&tests, "inner").id, "src/a.test:d:inner");
2075    }
2076
2077    // [FR-054]
2078    #[test]
2079    fn typescriptはdescribe_eachの中のtestを検出しない() {
2080        let source = "describe.each([1])(\"d %i\", (n) => {\n  it(\"a\", () => {});\n});\n";
2081        assert!(ts(source).is_empty());
2082    }
2083
2084    // [FR-054]
2085    #[test]
2086    fn typescriptはawaitなど式文直下でない呼び出しを検出しない() {
2087        let source = "await it(\"a\", () => {});\nvoid it(\"b\", () => {});\n";
2088        assert!(ts(source).is_empty());
2089    }
2090
2091    // [FR-054]
2092    #[test]
2093    fn typescriptは第1引数が文字列リテラルでない呼び出しを検出しない() {
2094        let source = "const name = \"x\";\nit(name, () => {});\nit(\"a\" + \"b\", () => {});\n";
2095        assert!(ts(source).is_empty());
2096    }
2097
2098    // [FR-054]
2099    #[test]
2100    fn typescriptはhelper関数やtest本体やif文の中のitを検出しない() {
2101        let source = r#"function helper() {
2102  it("in helper", () => {});
2103}
2104test("outer", () => {
2105  it("in test", () => {});
2106});
2107if (cond) {
2108  it("in if", () => {});
2109}
2110"#;
2111        let tests = ts(source);
2112        assert_eq!(tests.len(), 1);
2113        assert_eq!(tests[0].name, "outer");
2114    }
2115
2116    // [FR-056]
2117    #[test]
2118    fn typescriptは置換なしのテンプレート文字列を対象にし置換ありを無視する() {
2119        let source = "it(`plain`, () => {});\nit(`with ${x}`, () => {});\n";
2120        let tests = ts(source);
2121        assert_eq!(tests.len(), 1);
2122        assert_eq!(tests[0].name, "plain");
2123    }
2124
2125    // [FR-056]
2126    #[test]
2127    fn typescriptは空文字名と改行を含む名前を無視する() {
2128        let source = "it(\"\", () => {});\nit(`line1\nline2`, () => {});\nit(\"ok\", () => {});\n";
2129        let tests = ts(source);
2130        assert_eq!(tests.len(), 1);
2131        assert_eq!(tests[0].name, "ok");
2132    }
2133
2134    // [FR-056]
2135    #[test]
2136    fn typescriptは行区切り文字を含む名前を無視する() {
2137        let source = "it(\"a\u{2028}b\", () => {});\nit(\"ok\", () => {});\n";
2138        let tests = ts(source);
2139        assert_eq!(tests.len(), 1);
2140        assert_eq!(tests[0].name, "ok");
2141    }
2142
2143    // [FR-056]
2144    #[test]
2145    fn typescriptはエスケープを解釈せずソース表記のまま名前にする() {
2146        let source = "it('it\\'s', () => {});\n";
2147        let tests = ts(source);
2148        assert_eq!(tests.len(), 1);
2149        assert_eq!(tests[0].name, "it\\'s");
2150        assert_eq!(tests[0].id, "src/a.test::it\\'s");
2151    }
2152
2153    // [FR-056]
2154    #[test]
2155    fn typescriptのtest識別子はdescribeの外なら空のdescribeパスになる() {
2156        let tests = ts("it(\"top\", () => {});\n");
2157        assert_eq!(tests[0].id, "src/a.test::top");
2158    }
2159
2160    // [FR-056]
2161    #[test]
2162    fn typescriptのtest識別子はdescribeを外側から内側へスラッシュで連結する() {
2163        let source = "describe(\"auth\", () => {\n  it(\"one\", () => {});\n  describe(\"inner\", () => {\n    it(\"two\", () => {});\n  });\n});\n";
2164        let tests = ts(source);
2165        assert_eq!(find(&tests, "one").id, "src/a.test:auth:one");
2166        assert_eq!(find(&tests, "two").id, "src/a.test:auth/inner:two");
2167    }
2168
2169    // [FR-056]
2170    #[test]
2171    fn typescriptのtest識別子は対象拡張子をすべて除去しtestやspecは残す() {
2172        let src = "it(\"t\", () => {});\n";
2173        for (path, id) in [
2174            ("src/a.test.ts", "src/a.test::t"),
2175            ("src/a.spec.tsx", "src/a.spec::t"),
2176            ("src/a.test.mjs", "src/a.test::t"),
2177            ("src/a.spec.cts", "src/a.spec::t"),
2178            ("src/a.test.jsx", "src/a.test::t"),
2179            ("src/a.test.js", "src/a.test::t"),
2180            ("src/a.spec.cjs", "src/a.spec::t"),
2181            ("src/a.test.mts", "src/a.test::t"),
2182        ] {
2183            let tests = extract_typescript_tests(path, src).unwrap();
2184            assert_eq!(tests[0].id, id, "{path}");
2185        }
2186    }
2187
2188    // [FR-056]
2189    #[test]
2190    fn typescriptのtest識別子は対象外の拡張子を除去しない() {
2191        let tests =
2192            extract_typescript_tests("src/a.test.ts.bak", "it(\"t\", () => {});\n").unwrap();
2193        assert_eq!(tests[0].id, "src/a.test.ts.bak::t");
2194    }
2195
2196    // [FR-053]
2197    #[test]
2198    fn typescriptはjs系拡張子のファイルからtestを検出する() {
2199        for path in [
2200            "src/a.test.js",
2201            "src/a.spec.mjs",
2202            "src/a.test.cjs",
2203            "src/a.spec.mts",
2204            "src/a.test.cts",
2205        ] {
2206            let tests = extract_typescript_tests(path, "it(\"t\", () => {});\n").unwrap();
2207            assert_eq!(tests.len(), 1, "{path}");
2208        }
2209    }
2210
2211    // [FR-053]
2212    #[test]
2213    fn typescriptはts系拡張子でtsx文法が拒否する角括弧キャストを解析できる() {
2214        let source = "it(\"t\", () => {\n  const n = <number>x;\n});\n";
2215        for path in ["src/a.test.ts", "src/a.test.mts", "src/a.spec.cts"] {
2216            let tests = extract_typescript_tests(path, source).unwrap();
2217            assert_eq!(tests.len(), 1, "{path}");
2218        }
2219    }
2220
2221    // [FR-053]
2222    #[test]
2223    fn typescriptはjsxとjsファイルのjsxを含むソースも解析できる() {
2224        let source = "it(\"renders\", () => {\n  render(<App />);\n});\n";
2225        for path in ["src/a.test.jsx", "src/a.test.js"] {
2226            let tests = extract_typescript_tests(path, source).unwrap();
2227            assert_eq!(tests.len(), 1, "{path}");
2228            assert_eq!(tests[0].id, "src/a.test::renders", "{path}");
2229        }
2230    }
2231
2232    // [FR-055]
2233    #[test]
2234    fn typescriptは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
2235        let source = "// [FR-001]\nit(\"adjacent\", () => {});\n\n// [FR-002]\n\nit(\"blank\", () => {});\n\nit(\"none\", () => {});\n";
2236        let tests = ts(source);
2237        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2238        assert!(find(&tests, "blank").markers.is_empty());
2239        assert!(find(&tests, "none").markers.is_empty());
2240    }
2241
2242    // [FR-055]
2243    #[test]
2244    fn typescriptはdescribe内のtestでも直前のコメントをマーカーにする() {
2245        let source = "describe(\"d\", () => {\n  // [FR-001]\n  it(\"a\", () => {});\n});\n";
2246        let tests = ts(source);
2247        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
2248    }
2249
2250    // [FR-003]
2251    #[test]
2252    fn typescriptのcontentは呼び出し文全体でマーカーコメントを含まない() {
2253        let source = "// [FR-001]\nit(\"a\", () => {\n  expect(1).toBe(1);\n});\n";
2254        let tests = ts(source);
2255        let c = &tests[0].content;
2256        assert!(c.starts_with("it(\"a\""), "content = {c:?}");
2257        assert!(c.ends_with(");"), "content = {c:?}");
2258        assert!(!c.contains("FR-001"));
2259    }
2260
2261    // [FR-003]
2262    #[test]
2263    fn typescriptの本体変更はcontentに反映される() {
2264        let a = ts("it(\"a\", () => { expect(1).toBe(1); });\n");
2265        let b = ts("it(\"a\", () => { expect(1).toBe(2); });\n");
2266        assert_ne!(a[0].content, b[0].content);
2267    }
2268
2269    // [FR-014]
2270    #[test]
2271    fn typescriptは構文エラーのソースでエラーを返す() {
2272        let source = "it(\"a\", () => {\n";
2273        assert!(extract_typescript_tests("src/a.test.ts", source).is_err());
2274    }
2275
2276    // ---- Java ----
2277
2278    fn jv(source: &str) -> Vec<ExtractedTest> {
2279        extract_java_tests("tests/AuthTests.java", source).unwrap()
2280    }
2281
2282    fn jv_wrap(body: &str) -> String {
2283        format!("public class AuthTests {{\n{body}\n}}\n")
2284    }
2285
2286    // [FR-059]
2287    #[test]
2288    fn javaは5種類の注釈をそれぞれtestとして検出する() {
2289        for ann in [
2290            "Test",
2291            "ParameterizedTest",
2292            "RepeatedTest",
2293            "TestFactory",
2294            "TestTemplate",
2295        ] {
2296            let source = jv_wrap(&format!("    @{ann}\n    void t() {{ }}"));
2297            let tests = jv(&source);
2298            assert_eq!(tests.len(), 1, "annotation {ann} should be detected");
2299            assert_eq!(tests[0].name, "t");
2300        }
2301    }
2302
2303    // [FR-059]
2304    #[test]
2305    fn javaは修飾名の注釈と引数付きの注釈も検出する() {
2306        let source = jv_wrap(
2307            "    @org.junit.jupiter.api.Test\n    void a() { }\n\n    @Test(timeout = 1)\n    void b() { }",
2308        );
2309        let tests = jv(&source);
2310        assert_eq!(tests.len(), 2);
2311        find(&tests, "a");
2312        find(&tests, "b");
2313    }
2314
2315    // [FR-059]
2316    #[test]
2317    fn javaは無関係な注釈のみのメソッドを検出しない() {
2318        let source = jv_wrap(
2319            "    @Disabled\n    void a() { }\n\n    void b() { }\n\n    @Tag(\"x\")\n    void c() { }",
2320        );
2321        assert!(jv(&source).is_empty());
2322    }
2323
2324    // [FR-059]
2325    #[test]
2326    fn javaは修飾子の後ろに書かれた注釈も検出する() {
2327        let source = jv_wrap("    public @Test void t() { }");
2328        let tests = jv(&source);
2329        assert_eq!(tests.len(), 1);
2330        assert!(tests[0].content.contains("@Test"), "{:?}", tests[0].content);
2331    }
2332
2333    // [FR-060]
2334    #[test]
2335    fn javaは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
2336        let source = jv_wrap(
2337            "    // [FR-001]\n    @Test\n    void adjacent() { }\n\n    // [FR-002]\n\n    @Test\n    void blank() { }\n\n    @Test\n    void none() { }",
2338        );
2339        let tests = jv(&source);
2340        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2341        assert!(find(&tests, "blank").markers.is_empty());
2342        assert!(find(&tests, "none").markers.is_empty());
2343    }
2344
2345    // [FR-060]
2346    #[test]
2347    fn javaはブロックコメントをマーカーにできる() {
2348        let source = jv_wrap("    /* [FR-002] */\n    @Test\n    void t() { }");
2349        assert_eq!(jv(&source)[0].markers, vec!["FR-002".to_string()]);
2350    }
2351
2352    // [FR-060]
2353    #[test]
2354    fn javaはjavadocコメントをマーカーにしない() {
2355        let source = jv_wrap("    /** [FR-001] */\n    @Test\n    void t() { }");
2356        assert!(jv(&source)[0].markers.is_empty());
2357    }
2358
2359    // [FR-060]
2360    #[test]
2361    fn javaは注釈と署名の間のコメントをマーカーにせずcontentにも含めない() {
2362        let source = jv_wrap("    @Test\n    // [FR-001]\n    // 補足\n    void t() { }");
2363        let tests = jv(&source);
2364        assert!(tests[0].markers.is_empty());
2365        assert!(!tests[0].content.contains("FR-001"));
2366        assert!(!tests[0].content.contains("補足"));
2367    }
2368
2369    // [FR-061]
2370    #[test]
2371    fn javaのtest識別子はclassならファイルパスと型名とメソッド名になる() {
2372        let tests = jv(&jv_wrap("    @Test\n    void rejectsShort() { }"));
2373        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:rejectsShort");
2374    }
2375
2376    // [FR-061]
2377    #[test]
2378    fn javaのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
2379        let source = "public class Outer {\n    @Nested\n    class Inner {\n        @Test\n        void t() { }\n    }\n}\n";
2380        let tests = jv(source);
2381        assert_eq!(tests[0].id, "tests/AuthTests:Outer/Inner:t");
2382    }
2383
2384    // [FR-061]
2385    #[test]
2386    fn javaのtest識別子はrecord_interface_enumの中でも型名を辿る() {
2387        let source = "record R() {\n    @Test\n    void a() { }\n}\n\ninterface I {\n    @Test\n    default void b() { }\n}\n\nenum E {\n    X, Y;\n\n    @Test\n    void c() { }\n}\n";
2388        let tests = jv(source);
2389        assert_eq!(find(&tests, "a").id, "tests/AuthTests:R:a");
2390        assert_eq!(find(&tests, "b").id, "tests/AuthTests:I:b");
2391        assert_eq!(find(&tests, "c").id, "tests/AuthTests:E:c");
2392    }
2393
2394    // [FR-061]
2395    #[test]
2396    fn javaのtest識別子はpackageを含まない() {
2397        let source =
2398            "package com.example;\n\npublic class AuthTests {\n    @Test\n    void t() { }\n}\n";
2399        assert_eq!(jv(source)[0].id, "tests/AuthTests:AuthTests:t");
2400    }
2401
2402    // [FR-059]
2403    #[test]
2404    fn javaはトップレベルメソッドを対象外にする() {
2405        let source = "@Test\nvoid t() { }\n";
2406        let tests = extract_java_tests("tests/AuthTests.java", source).unwrap();
2407        assert!(tests.is_empty());
2408        // a class alongside is still extracted
2409        let source = "class A {\n    @Test\n    void u() { }\n}\n";
2410        assert_eq!(jv(source).len(), 1);
2411    }
2412
2413    // [FR-059]
2414    #[test]
2415    fn javaは匿名クラスとenum定数本体の中を対象外にする() {
2416        let source = "public class A {\n    void helper() {\n        Runnable r = new Runnable() {\n            @Test\n            public void run() { }\n        };\n    }\n\n    @Test\n    void real() { }\n}\n\nenum E {\n    X {\n        @Test\n        void inConst() { }\n    };\n\n    @Test\n    void outside() { }\n}\n";
2417        let tests = jv(source);
2418        let names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2419        assert_eq!(names, vec!["real", "outside"], "{names:?}");
2420    }
2421
2422    // [FR-062]
2423    #[test]
2424    fn javaのcontentは注釈を含まず修飾子から始まる() {
2425        let source = jv_wrap(
2426            "    // [FR-001]\n    @Test\n    @Tag(\"a\")\n    public void t() { assertTrue(true); }",
2427        );
2428        let c = &jv(&source)[0].content;
2429        assert!(c.starts_with("public void t()"), "content = {c:?}");
2430        assert!(c.ends_with('}'));
2431        assert!(!c.contains("@Test"));
2432        assert!(!c.contains("@Tag"));
2433        assert!(!c.contains("FR-001"));
2434    }
2435
2436    // [FR-062]
2437    #[test]
2438    fn javaのcontentは型パラメータ付きでも修飾子から始まる() {
2439        let source = jv_wrap("    @Test\n    static <T> void t() { }");
2440        let c = &jv(&source)[0].content;
2441        assert!(c.starts_with("static <T> void t()"), "content = {c:?}");
2442    }
2443
2444    // [FR-062]
2445    #[test]
2446    fn javaのcontentは修飾子なしなら型パラメータから始まる() {
2447        let source = jv_wrap("    @Test\n    <T> void t() { }");
2448        let c = &jv(&source)[0].content;
2449        assert!(c.starts_with("<T> void t()"), "content = {c:?}");
2450    }
2451
2452    // [FR-003]
2453    #[test]
2454    fn javaの本体変更はcontentに反映される() {
2455        let a = jv(&jv_wrap("    @Test\n    void t() { assertTrue(true); }"));
2456        let b = jv(&jv_wrap("    @Test\n    void t() { assertTrue(false); }"));
2457        assert_ne!(a[0].content, b[0].content);
2458    }
2459
2460    // [FR-062]
2461    #[test]
2462    fn javaの注釈だけの変更はcontentを変えない() {
2463        let a = jv(&jv_wrap("    @Test\n    void t() { assertTrue(true); }"));
2464        let b = jv(&jv_wrap(
2465            "    @Test(timeout = 1)\n    @Tag(\"a\")\n    void t() { assertTrue(true); }",
2466        ));
2467        assert_eq!(a[0].content, b[0].content);
2468    }
2469
2470    // [FR-014]
2471    #[test]
2472    fn javaは構文エラーのソースでエラーを返す() {
2473        let source = "public class A {\n    @Test\n    void t( {\n}\n";
2474        assert!(extract_java_tests("tests/A.java", source).is_err());
2475    }
2476
2477    // ---- Kotlin ----
2478
2479    fn kt(source: &str) -> Vec<ExtractedTest> {
2480        extract_kotlin_tests("tests/AuthTest.kt", source).unwrap()
2481    }
2482
2483    fn kt_wrap(body: &str) -> String {
2484        format!("class AuthTest {{\n{body}\n}}\n")
2485    }
2486
2487    // [FR-059]
2488    #[test]
2489    fn kotlinは5種類の注釈をそれぞれtestとして検出する() {
2490        for ann in [
2491            "Test",
2492            "ParameterizedTest",
2493            "RepeatedTest",
2494            "TestFactory",
2495            "TestTemplate",
2496        ] {
2497            let source = kt_wrap(&format!("    @{ann}\n    fun t() {{ }}"));
2498            let tests = kt(&source);
2499            assert_eq!(tests.len(), 1, "annotation {ann} should be detected");
2500            assert_eq!(tests[0].name, "t");
2501        }
2502    }
2503
2504    // [FR-059]
2505    #[test]
2506    fn kotlinは修飾名と複数注釈リストとuse_site_target付きの注釈も検出する() {
2507        let source = kt_wrap(
2508            "    @org.junit.jupiter.api.Test\n    fun a() { }\n\n    @[Test Tag(\"a\")]\n    fun b() { }\n\n    @get:Test\n    fun c() { }",
2509        );
2510        let tests = kt(&source);
2511        assert_eq!(tests.len(), 3, "{tests:?}");
2512        find(&tests, "a");
2513        find(&tests, "b");
2514        find(&tests, "c");
2515    }
2516
2517    // [FR-059]
2518    #[test]
2519    fn kotlinは無関係な注釈のみの関数を検出しない() {
2520        let source = kt_wrap("    @Disabled\n    fun a() { }\n\n    fun b() { }");
2521        assert!(kt(&source).is_empty());
2522    }
2523
2524    // [FR-060]
2525    #[test]
2526    fn kotlinは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
2527        let source = kt_wrap(
2528            "    // [FR-001]\n    @Test\n    fun adjacent() { }\n\n    // [FR-002]\n\n    @Test\n    fun blank() { }\n\n    /* [FR-003] */\n    @Test\n    fun block() { }",
2529        );
2530        let tests = kt(&source);
2531        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2532        assert!(find(&tests, "blank").markers.is_empty());
2533        assert_eq!(find(&tests, "block").markers, vec!["FR-003".to_string()]);
2534    }
2535
2536    // [FR-060]
2537    #[test]
2538    fn kotlinはkdocコメントをマーカーにしない() {
2539        let source = kt_wrap("    /** [Foo] */\n    @Test\n    fun t() { }");
2540        assert!(kt(&source)[0].markers.is_empty());
2541    }
2542
2543    // [FR-060]
2544    #[test]
2545    fn kotlinは注釈と署名の間のコメントをマーカーにせずcontentにも含めない() {
2546        let source = kt_wrap(
2547            "    @Test // [FR-001]\n    fun a() { }\n\n    @Test\n    // [FR-002]\n    fun b() { }",
2548        );
2549        let tests = kt(&source);
2550        for name in ["a", "b"] {
2551            let t = find(&tests, name);
2552            assert!(t.markers.is_empty(), "{name}: {:?}", t.markers);
2553            assert!(!t.content.contains("FR-00"), "{name}: {:?}", t.content);
2554        }
2555    }
2556
2557    // [FR-061]
2558    #[test]
2559    fn kotlinのバッククォート名はバッククォートを除いた名前になる() {
2560        let source = kt_wrap("    @Test\n    fun `一覧 取得 は 200`() { }");
2561        let tests = kt(&source);
2562        assert_eq!(tests[0].name, "一覧 取得 は 200");
2563        assert_eq!(tests[0].id, "tests/AuthTest:AuthTest:一覧 取得 は 200");
2564    }
2565
2566    // [FR-061]
2567    #[test]
2568    fn kotlinのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
2569        let source = "class AuthTest {\n    @Nested\n    inner class Inner {\n        @Test\n        fun t() { }\n    }\n}\n";
2570        assert_eq!(kt(source)[0].id, "tests/AuthTest:AuthTest/Inner:t");
2571    }
2572
2573    // [FR-061]
2574    #[test]
2575    fn kotlinのtest識別子はobjectとcompanion_objectを辿る() {
2576        let source = "object O {\n    @Test\n    fun a() { }\n}\n\nclass C {\n    companion object {\n        @Test\n        fun b() { }\n    }\n\n    companion object Factory {\n        @Test\n        fun c() { }\n    }\n}\n";
2577        // two companion objects in one class is invalid Kotlin semantically but
2578        // syntactically fine; the grammar does not reject it.
2579        let tests = kt(source);
2580        assert_eq!(find(&tests, "a").id, "tests/AuthTest:O:a");
2581        assert_eq!(find(&tests, "b").id, "tests/AuthTest:C/Companion:b");
2582        assert_eq!(find(&tests, "c").id, "tests/AuthTest:C/Factory:c");
2583    }
2584
2585    // [FR-061]
2586    #[test]
2587    fn kotlinのトップレベル関数は型部分が空の識別子になる() {
2588        let source = "@Test\nfun top() { }\n";
2589        let tests = kt(source);
2590        assert_eq!(tests.len(), 1);
2591        assert_eq!(tests[0].id, "tests/AuthTest::top");
2592    }
2593
2594    // [FR-061]
2595    #[test]
2596    fn kotlinのtest識別子はpackageを含まない() {
2597        let source = "package com.example\n\nclass AuthTest {\n    @Test\n    fun t() { }\n}\n";
2598        assert_eq!(kt(source)[0].id, "tests/AuthTest:AuthTest:t");
2599    }
2600
2601    // [FR-061]
2602    #[test]
2603    fn kotlinはコンストラクタ注釈付きの主コンストラクタを持つクラスでもtestを取れる() {
2604        let source = "@SpringBootTest\nclass X @Autowired constructor(private val a: Int) : Base(a) {\n    @Test\n    fun `日本語名`() { }\n}\n";
2605        let tests = kt(source);
2606        assert_eq!(tests.len(), 1, "{tests:?}");
2607        assert_eq!(tests[0].id, "tests/AuthTest:X:日本語名");
2608    }
2609
2610    // [FR-059]
2611    #[test]
2612    fn kotlinは関数本体内のローカル関数や匿名objectやenum定数本体やgetterを対象外にする() {
2613        let source = "class A {\n    @Test\n    fun real() {\n        @Test\n        fun local() { }\n    }\n\n    val x: Int\n        @Test\n        get() = 1\n\n    val r = object : Runnable {\n        @Test\n        fun inObj() { }\n    }\n}\n\nenum class E {\n    X {\n        @Test\n        fun inConst() { }\n    };\n\n    @Test\n    fun outside() { }\n}\n";
2614        let tests = kt(source);
2615        let names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2616        assert_eq!(names, vec!["real", "outside"], "{names:?}");
2617    }
2618
2619    // [FR-063]
2620    #[test]
2621    fn kotlinは漢字の関数名と型名のtestを抽出し識別子とcontentが元の文字を保つ() {
2622        let source = "class AuthTest {\n    @Nested\n    inner class 一覧取得 {\n        @Test\n        fun 取得() { println(\"漢字\") }\n    }\n}\n";
2623        let tests = kt(source);
2624        assert_eq!(tests.len(), 1, "{tests:?}");
2625        assert_eq!(tests[0].id, "tests/AuthTest:AuthTest/一覧取得:取得");
2626        assert_eq!(tests[0].name, "取得");
2627        assert!(tests[0].content.contains("取得"));
2628        assert!(tests[0].content.contains("漢字"));
2629        assert!(!tests[0].content.contains('x'), "{:?}", tests[0].content);
2630    }
2631
2632    // [FR-063]
2633    #[test]
2634    fn kotlinはカタカナとハングルの関数名も抽出する() {
2635        let source = kt_wrap("    @Test\n    fun テスト() { }\n\n    @Test\n    fun 테스트() { }");
2636        let tests = kt(&source);
2637        assert_eq!(tests.len(), 2, "{tests:?}");
2638        find(&tests, "テスト");
2639        find(&tests, "테스트");
2640    }
2641
2642    // [FR-063]
2643    #[test]
2644    fn kotlinは非asciiの文字リテラルを含むファイルも解析できる() {
2645        let source =
2646            kt_wrap("    @Test\n    fun t() {\n        val c = 'あ'\n        val d = '漢'\n    }");
2647        let tests = kt(&source);
2648        assert_eq!(tests.len(), 1);
2649        assert!(tests[0].content.contains("'あ'"));
2650    }
2651
2652    // [FR-063]
2653    #[test]
2654    fn kotlinはbom付きファイルも解析できる() {
2655        let source = format!("\u{feff}{}", kt_wrap("    @Test\n    fun t() { }"));
2656        let tests = kt(&source);
2657        assert_eq!(tests.len(), 1);
2658        assert_eq!(tests[0].name, "t");
2659    }
2660
2661    // [FR-062]
2662    #[test]
2663    fn kotlinのcontentは注釈を含まず修飾子から始まる() {
2664        let source = kt_wrap(
2665            "    // [FR-001]\n    @Test\n    internal suspend fun t() { assertTrue(true) }",
2666        );
2667        let c = &kt(&source)[0].content;
2668        assert!(c.starts_with("internal suspend fun t()"), "content = {c:?}");
2669        assert!(c.ends_with('}'));
2670        assert!(!c.contains("@Test"));
2671        assert!(!c.contains("FR-001"));
2672    }
2673
2674    // [FR-062]
2675    #[test]
2676    fn kotlinのcontentは注釈のみならfunから始まる() {
2677        let source = kt_wrap("    @Test\n    @Tag(\"a\")\n    fun t() { }");
2678        let c = &kt(&source)[0].content;
2679        assert!(c.starts_with("fun t()"), "content = {c:?}");
2680    }
2681
2682    // [FR-062]
2683    #[test]
2684    fn kotlinの修飾子の後ろに書かれた注釈はcontentに含まれる() {
2685        let source = kt_wrap("    internal @Test fun t() { }");
2686        let tests = kt(&source);
2687        assert_eq!(tests.len(), 1);
2688        assert!(
2689            tests[0].content.starts_with("internal"),
2690            "{:?}",
2691            tests[0].content
2692        );
2693        assert!(tests[0].content.contains("@Test"), "{:?}", tests[0].content);
2694    }
2695
2696    // [FR-062]
2697    #[test]
2698    fn kotlinの式本体は終端までcontentに含まれる() {
2699        let source = kt_wrap("    @Test\n    fun t() = assertTrue(computeSomething())");
2700        let c = &kt(&source)[0].content;
2701        assert!(c.ends_with("computeSomething())"), "content = {c:?}");
2702    }
2703
2704    // [FR-003]
2705    #[test]
2706    fn kotlinの本体変更はcontentに反映される() {
2707        let a = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(true) }"));
2708        let b = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(false) }"));
2709        assert_ne!(a[0].content, b[0].content);
2710    }
2711
2712    // [FR-062]
2713    #[test]
2714    fn kotlinの注釈だけの変更はcontentを変えない() {
2715        let a = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(true) }"));
2716        let b = kt(&kt_wrap(
2717            "    @Test\n    @Tag(\"a\")\n    fun t() { assertTrue(true) }",
2718        ));
2719        assert_eq!(a[0].content, b[0].content);
2720    }
2721
2722    // [FR-014]
2723    #[test]
2724    fn kotlinは構文エラーのソースでエラーを返す() {
2725        let source = "class A { fun x() {} }\n";
2726        assert!(extract_kotlin_tests("tests/A.kt", source).is_err());
2727    }
2728
2729    // [FR-060]
2730    #[test]
2731    fn kotlinのマーカーは元のソースから取り非asciiの角括弧は偽マーカーにならない() {
2732        let source = kt_wrap(
2733            "    // [正常系]\n    // [FR-001]\n    @Test\n    fun t() { }\n\n    // [仮] メモ\n    @Test\n    fun u() { }\n\n    // [FR\u{2010}001]\n    @Test\n    fun v() { }",
2734        );
2735        let tests = kt(&source);
2736        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2737        assert!(find(&tests, "u").markers.is_empty());
2738        assert!(find(&tests, "v").markers.is_empty());
2739    }
2740
2741    // [FR-060]
2742    #[test]
2743    fn kotlinはdocコメントを挟んでも手前のマーカーが有効になる() {
2744        let source = kt_wrap("    // [FR-001]\n    /** KDoc */\n    @Test\n    fun b() { }");
2745        assert_eq!(kt(&source)[0].markers, vec!["FR-001".to_string()]);
2746    }
2747
2748    // [FR-060]
2749    #[test]
2750    fn javaはdocコメントを挟んでも手前のマーカーが有効になる() {
2751        let source = jv_wrap("    // [FR-001]\n    /** Javadoc */\n    @Test\n    void b() { }");
2752        assert_eq!(jv(&source)[0].markers, vec!["FR-001".to_string()]);
2753    }
2754
2755    // [FR-060]
2756    #[test]
2757    fn javaは注釈同士の間のコメントをマーカーにせずcontentにも含めない() {
2758        let source =
2759            jv_wrap("    @Test\n    // [FR-001]\n    @Tag(\"a\")\n    // 補足\n    void t() { }");
2760        let t = &jv(&source)[0];
2761        assert!(t.markers.is_empty());
2762        assert!(!t.content.contains("FR-001"));
2763        assert!(!t.content.contains("補足"));
2764    }
2765
2766    // [FR-062]
2767    #[test]
2768    fn kotlinは注釈の間と直後のコメントをcontentに含めない() {
2769        let source = kt_wrap(
2770            "    @Test\n    // 間のメモ\n    @Tag(\"a\")\n    // 直後のメモ\n    fun t() { }",
2771        );
2772        let t = &kt(&source)[0];
2773        assert!(!t.content.contains("メモ"), "{:?}", t.content);
2774        assert!(t.content.starts_with("fun t()"), "{:?}", t.content);
2775    }
2776
2777    // [FR-060]
2778    #[test]
2779    fn kotlinは注釈同士の間のコメントをマーカーにしない() {
2780        let source = kt_wrap("    @Test\n    // [FR-001]\n    @Tag(\"a\")\n    fun t() { }");
2781        assert!(kt(&source)[0].markers.is_empty());
2782    }
2783
2784    // [FR-061]
2785    #[test]
2786    fn kotlinはdata_classとinterfaceの直下のtestも辿る() {
2787        let source = "data class D(val a: Int) {\n    @Test\n    fun a() { }\n}\n\ninterface I {\n    @Test\n    fun b() { }\n}\n";
2788        let tests = kt(source);
2789        assert_eq!(find(&tests, "a").id, "tests/AuthTest:D:a");
2790        assert_eq!(find(&tests, "b").id, "tests/AuthTest:I:b");
2791    }
2792
2793    // [FR-061]
2794    #[test]
2795    fn kotlinは制御文字や空を含む名前のtestを無視する() {
2796        let source =
2797            kt_wrap("    @Test\n    fun `tab\there`() { }\n\n    @Test\n    fun `ok name`() { }");
2798        let tests = kt(&source);
2799        assert_eq!(tests.len(), 1, "{tests:?}");
2800        assert_eq!(tests[0].name, "ok name");
2801    }
2802
2803    // [FR-063]
2804    #[test]
2805    fn kotlinは注釈2つで基底型なしのクラス内の注釈付きinner_classでもtestを取れる() {
2806        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class X {\n        // [FR-001]\n        @Test\n        fun t() { }\n    }\n}\n";
2807        let tests = kt(source);
2808        assert_eq!(tests.len(), 1, "{tests:?}");
2809        assert_eq!(tests[0].id, "tests/AuthTest:P/X:t");
2810        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
2811    }
2812
2813    // [FR-063]
2814    #[test]
2815    fn kotlinはnestedを含む複数注釈クラスでも非asciiの名前を元のまま取る() {
2816        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class 一覧取得 {\n        @Test\n        fun `取得 する`() { }\n    }\n}\n";
2817        let tests = kt(source);
2818        assert_eq!(tests[0].id, "tests/AuthTest:P/一覧取得:取得 する");
2819    }
2820
2821    // [FR-063]
2822    #[test]
2823    fn kotlinは宣言以外のトップレベル構造やキーワードを含む文字列でもok空になる() {
2824        for source in [
2825            "package a\n// helper for class X\nval x = 1\n",
2826            "package a\nval fixture = object : Runnable {\n    override fun run() {}\n}\n",
2827            "package a\nval f = fun() { }\n",
2828            "package a\nconst val NAME = \"class\"\n",
2829        ] {
2830            let tests = kt(source);
2831            assert!(tests.is_empty(), "{source:?}: {tests:?}");
2832        }
2833    }
2834
2835    // [FR-063]
2836    #[test]
2837    fn kotlinは文法の不具合で宣言が認識されなかった場合に構文エラーを返す() {
2838        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class X {\n    }\n}\n";
2839        assert!(kotlin_extract("tests/P.kt", source, true).is_ok());
2840        assert!(kotlin_extract("tests/P.kt", source, false).is_err());
2841    }
2842
2843    // [FR-063]
2844    #[test]
2845    fn kotlinはshebangやimportやtypealiasなど宣言以外の通常の構造でok空になる() {
2846        let source = "#!/usr/bin/env kotlin\n@file:JvmName(\"X\")\npackage a\n\nimport b.C\n\ntypealias N = String\nval x = 1\nval y: Int\n    get() = 2\n";
2847        assert!(kt(source).is_empty());
2848    }
2849
2850    // ---- コメント隣接判定(FR-016 / FR-064) ----
2851
2852    // [FR-016]
2853    #[test]
2854    fn rustはdocコメントを挟んでもマーカーを取る() {
2855        let tests = rs("// [FR-001]\n/// doc\n#[test]\nfn t() {}\n", &[]);
2856        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2857    }
2858
2859    // [FR-016]
2860    #[test]
2861    fn rustの外側docコメントはマーカーにならない() {
2862        let tests = rs("/// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2863        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2864    }
2865
2866    // [FR-016]
2867    #[test]
2868    fn rustの内側docコメントはマーカーにならない() {
2869        let tests = rs("mod m {\n//! [FR-001]\n#[test]\nfn t() {}\n}\n", &[]);
2870        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2871    }
2872
2873    // [FR-016]
2874    #[test]
2875    fn rustはブロックdocコメントを挟んでもマーカーを取る() {
2876        let tests = rs("// [FR-001]\n/** doc */\n#[test]\nfn t() {}\n", &[]);
2877        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2878    }
2879
2880    // [FR-016]
2881    #[test]
2882    fn rustのスラッシュ4つのコメントは通常コメントだが余分な記号が本文に残りマーカーにならない() {
2883        let tests = rs("//// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2884        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2885    }
2886
2887    // [FR-016]
2888    #[test]
2889    fn rustはdocコメントの後に空行があるとマーカーを取らない() {
2890        let tests = rs("// [FR-001]\n/// doc\n\n#[test]\nfn t() {}\n", &[]);
2891        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2892    }
2893
2894    // [FR-016]
2895    #[test]
2896    fn rustはcrlfでもdocコメントを挟んでマーカーを取る() {
2897        let tests = rs("// [FR-001]\r\n/// doc\r\n#[test]\r\nfn t() {}\r\n", &[]);
2898        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2899    }
2900
2901    // [FR-064]
2902    #[test]
2903    fn goは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2904        let source =
2905            "package a\n\nfunc TestA(t *testing.T) {} // [FR-001]\nfunc TestB(t *testing.T) {}\n";
2906        let tests = extract_go_tests("a/x_test.go", source).unwrap();
2907        assert!(find(&tests, "TestB").markers.is_empty());
2908    }
2909
2910    // [FR-064]
2911    #[test]
2912    fn rustは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2913        let tests = rs("fn x() {} // [FR-001]\n#[test]\nfn t() {}\n", &[]);
2914        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2915    }
2916
2917    // [FR-064]
2918    #[test]
2919    fn csharpは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2920        let source =
2921            cs_wrap("    public void X() {} // [FR-001]\n    [Fact]\n    public void T() {}");
2922        let tests = cs(&source);
2923        assert!(find(&tests, "T").markers.is_empty());
2924    }
2925
2926    // [FR-064]
2927    #[test]
2928    fn typescriptは前の文の末尾行コメントを次のtestのマーカーにしない() {
2929        let tests = ts("it(\"a\", () => {}); // [FR-001]\nit(\"b\", () => {});\n");
2930        assert!(find(&tests, "b").markers.is_empty());
2931    }
2932
2933    // [FR-064]
2934    #[test]
2935    fn javaは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2936        let source = jv_wrap("    void x() {} // [FR-001]\n    @Test\n    void t() {}");
2937        let tests = jv(&source);
2938        assert!(find(&tests, "t").markers.is_empty());
2939    }
2940
2941    // [FR-064]
2942    #[test]
2943    fn kotlinは非ascii識別子の後の末尾行コメントを次のtestのマーカーにしない() {
2944        let source = kt_wrap("    fun `日本語`() {} // [FR-001]\n    @Test\n    fun t() {}");
2945        let tests = kt(&source);
2946        assert!(find(&tests, "t").markers.is_empty());
2947    }
2948
2949    // [FR-064]
2950    #[test]
2951    fn rustはインデント付きの行頭コメントをマーカーにする() {
2952        let tests = rs(
2953            "mod m {\n    // [FR-001]\n    #[test]\n    fn t() {}\n}\n",
2954            &[],
2955        );
2956        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2957    }
2958
2959    // [FR-064]
2960    #[test]
2961    fn typescriptは同じ行に複数のコメントがあると2つ目以降は行頭でないので打ち切る() {
2962        let tests = ts("/* [FR-001] */ // note\nit(\"b\", () => {});\n");
2963        assert!(find(&tests, "b").markers.is_empty());
2964    }
2965
2966    // [FR-064]
2967    #[test]
2968    fn rustはbomの直後の1行目マーカーを取る() {
2969        let tests = rs("\u{feff}// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2970        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2971    }
2972
2973    // [FR-064]
2974    #[test]
2975    fn goはbomの直後の1行目マーカーを取る() {
2976        let source = "\u{feff}// [FR-001]\nfunc TestA(t *testing.T) {}\n";
2977        let tests = extract_go_tests("a/x_test.go", source).unwrap();
2978        assert_eq!(find(&tests, "TestA").markers, vec!["FR-001".to_string()]);
2979    }
2980
2981    // [FR-016]
2982    #[test]
2983    fn rustの内側ブロックdocコメントはマーカーにならない() {
2984        let tests = rs("mod m {\n/*! [FR-001] */\n#[test]\nfn t() {}\n}\n", &[]);
2985        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2986    }
2987
2988    // [FR-016]
2989    #[test]
2990    fn rustのスラッシュ星3つのコメントは通常コメントだが余分な記号が本文に残りマーカーにならない() {
2991        let tests = rs("/*** [FR-001] */\n#[test]\nfn t() {}\n", &[]);
2992        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2993    }
2994
2995    // [FR-016]
2996    #[test]
2997    fn rustの外側ブロックdocコメントはマーカーにならない() {
2998        let tests = rs("/** [FR-001] */\n#[test]\nfn t() {}\n", &[]);
2999        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
3000    }
3001
3002    // ---- マーカーの厳格化(FR-121) ----
3003
3004    /// (コメント, 期待するマーカー)。コメントの直後に test が続く。
3005    fn marker_cases() -> Vec<(&'static str, Vec<&'static str>)> {
3006        vec![
3007            ("// [FR-001]", vec!["FR-001"]),
3008            ("//   [FR-001]   ", vec!["FR-001"]),
3009            ("// [FR-001] [FR-002]", vec!["FR-001", "FR-002"]),
3010            ("// [FR-001]\n// [FR-002]", vec!["FR-001", "FR-002"]),
3011            ("//// [FR-001]", vec![]),
3012            ("/*** [FR-001] */", vec![]),
3013            ("// [FR-001] 説明", vec![]),
3014            ("// 説明 [FR-001]", vec![]),
3015            ("// 説明 [0] 説明", vec![]),
3016            ("// [FR-001] [正常系]", vec![]),
3017            ("// [FR-001][FR-002]", vec![]),
3018            ("//", vec![]),
3019            ("/* [FR-001] */", vec!["FR-001"]),
3020            ("/*\n * [FR-001]\n */", vec!["FR-001"]),
3021            (
3022                "/*\n * [FR-001]\n * [FR-002]\n */",
3023                vec!["FR-001", "FR-002"],
3024            ),
3025            ("/* [FR-001] [FR-002] */", vec!["FR-001", "FR-002"]),
3026            ("/*\n * [FR-001] 説明\n */", vec![]),
3027            ("/* 説明 [0] */", vec![]),
3028        ]
3029    }
3030
3031    fn assert_marker_cases(markers_of: impl Fn(&str) -> Vec<String>) {
3032        for (comment, expected) in marker_cases() {
3033            assert_eq!(markers_of(comment), expected, "comment: {comment:?}");
3034        }
3035    }
3036
3037    // [FR-121]
3038    #[test]
3039    fn goはコメントが単一トークンだけのときマーカーにする() {
3040        assert_marker_cases(|c| {
3041            let source = format!("{c}\nfunc TestA(t *testing.T) {{}}\n");
3042            find(&extract_go_tests("a/x_test.go", &source).unwrap(), "TestA")
3043                .markers
3044                .clone()
3045        });
3046    }
3047
3048    // [FR-121]
3049    #[test]
3050    fn rustはコメントが単一トークンだけのときマーカーにする() {
3051        assert_marker_cases(|c| {
3052            rs(&format!("{c}\n#[test]\nfn t() {{}}\n"), &[])[0]
3053                .markers
3054                .clone()
3055        });
3056    }
3057
3058    // [FR-121]
3059    #[test]
3060    fn csharpはコメントが単一トークンだけのときマーカーにする() {
3061        assert_marker_cases(|c| {
3062            cs(&cs_wrap(&format!("{c}\n[Fact]\npublic void T() {{ }}")))[0]
3063                .markers
3064                .clone()
3065        });
3066    }
3067
3068    // [FR-121]
3069    #[test]
3070    fn javaはコメントが単一トークンだけのときマーカーにする() {
3071        assert_marker_cases(|c| {
3072            jv(&jv_wrap(&format!("{c}\n@Test\nvoid t() {{ }}")))[0]
3073                .markers
3074                .clone()
3075        });
3076    }
3077
3078    // [FR-121]
3079    #[test]
3080    fn kotlinはコメントが単一トークンだけのときマーカーにする() {
3081        assert_marker_cases(|c| {
3082            kt(&kt_wrap(&format!("{c}\n@Test\nfun t() {{ }}")))[0]
3083                .markers
3084                .clone()
3085        });
3086    }
3087
3088    // [FR-121]
3089    #[test]
3090    fn typescriptはコメントが単一トークンだけのときマーカーにする() {
3091        assert_marker_cases(|c| {
3092            ts(&format!("{c}\nit(\"t\", () => {{}});\n"))[0]
3093                .markers
3094                .clone()
3095        });
3096    }
3097
3098    // [FR-121]
3099    #[test]
3100    fn dartはコメントが単一トークンだけのときマーカーにする() {
3101        assert_marker_cases(|c| {
3102            dt(&format!("void main() {{\n{c}\ntest('t', () {{}});\n}}\n"))[0]
3103                .markers
3104                .clone()
3105        });
3106    }
3107    // [FR-121]
3108    #[test]
3109    fn csharpの三連スラッシュコメントも単一トークンだけならマーカーにする() {
3110        let tests = cs(&cs_wrap("/// [FR-001]\n[Fact]\npublic void T() { }"));
3111        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
3112        let tests = cs(&cs_wrap("/// [FR-001] 説明\n[Fact]\npublic void T() { }"));
3113        assert!(tests[0].markers.is_empty());
3114    }
3115
3116    // ---- Dart / Flutter ----
3117
3118    fn dt(source: &str) -> Vec<ExtractedTest> {
3119        extract_dart_tests("mobile/test/a_test.dart", source).unwrap()
3120    }
3121
3122    // [FR-122]
3123    #[test]
3124    fn dartはtest_dartで終わるパスだけをテストファイルとして扱う() {
3125        assert!(is_dart_test_file("test/a_test.dart"));
3126        assert!(is_dart_test_file("a_test.dart"));
3127        assert!(!is_dart_test_file("test/a.dart"));
3128        assert!(!is_dart_test_file("test/a_test.dart.bak"));
3129        assert!(!is_dart_test_file("test/a_test.rs"));
3130        assert!(!is_dart_test_file("test/atest.dart"));
3131    }
3132
3133    // [FR-123]
3134    #[test]
3135    fn dartはmain直下のtestとtestwidgetsを検出する() {
3136        let tests =
3137            dt("void main() {\n  test('a', () {});\n  testWidgets('b', (tester) async {});\n}\n");
3138        assert_eq!(tests.len(), 2);
3139        find(&tests, "a");
3140        find(&tests, "b");
3141    }
3142
3143    // [FR-123]
3144    #[test]
3145    fn dartはgroupの入れ子を辿る() {
3146        let tests = dt("void main() {\n  group('outer', () {\n    group('inner', () {\n      test('t', () {});\n    });\n    test('u', () {});\n  });\n}\n");
3147        assert_eq!(tests.len(), 2);
3148        assert_eq!(find(&tests, "t").id, "mobile/test/a_test:outer/inner:t");
3149        assert_eq!(find(&tests, "u").id, "mobile/test/a_test:outer:u");
3150    }
3151
3152    // [FR-125]
3153    #[test]
3154    fn dartのtest識別子はgroupの外なら空のgroupパスになる() {
3155        let tests = dt("void main() { test('t', () {}); }\n");
3156        assert_eq!(tests[0].id, "mobile/test/a_test::t");
3157    }
3158
3159    // [FR-123]
3160    #[test]
3161    fn dartはfuture_void_main_asyncでも検出する() {
3162        let tests = dt("Future<void> main() async {\n  test('a', () {});\n}\n");
3163        assert_eq!(tests.len(), 1);
3164        let tests = dt("main() { test('a', () {}); }\n");
3165        assert_eq!(tests.len(), 1);
3166    }
3167
3168    // [FR-123]
3169    #[test]
3170    fn dartはasyncコールバックとアロー本体のtestも検出する() {
3171        let tests = dt("void main() {\n  test('a', () async {});\n  test('b', () => expect(1, 1));\n  group('g', () async {\n    test('c', () {});\n  });\n}\n");
3172        assert_eq!(tests.len(), 3);
3173        assert_eq!(find(&tests, "c").id, "mobile/test/a_test:g:c");
3174    }
3175
3176    // [FR-123]
3177    #[test]
3178    fn dartのgroupはアロー本体だと中へ降りない() {
3179        let tests = dt("void main() {\n  group('g', () => test('t', () {}));\n}\n");
3180        assert!(tests.is_empty(), "{tests:?}");
3181    }
3182
3183    // [FR-123]
3184    #[test]
3185    fn dartは名前付き引数付きでも1件のtestとして扱う() {
3186        let tests = dt("void main() {\n  test('a', () {}, skip: true, timeout: Timeout.none);\n  group('g', () {\n    test('b', () {});\n  }, skip: 'x');\n}\n");
3187        assert_eq!(tests.len(), 2);
3188        assert_eq!(find(&tests, "b").id, "mobile/test/a_test:g:b");
3189    }
3190
3191    // [FR-125]
3192    #[test]
3193    fn dartはraw文字列を引用符の内側のソース表記のまま名前にする() {
3194        let tests = dt("void main() {\n  test(r'a\\n$b', () {});\n  test(r\"c\\d\", () {});\n}\n");
3195        assert_eq!(tests.len(), 2);
3196        find(&tests, "a\\n$b");
3197        find(&tests, "c\\d");
3198    }
3199
3200    // [FR-125]
3201    #[test]
3202    fn dartは隣接文字列連結を各部分の内側で連結して名前にする() {
3203        let tests = dt("void main() {\n  test('abc '\n      \"def \"\n      r'g\\h', () {});\n}\n");
3204        assert_eq!(tests.len(), 1);
3205        assert_eq!(tests[0].name, "abc def g\\h");
3206    }
3207
3208    // [FR-125]
3209    #[test]
3210    fn dartはエスケープを解釈せずソース表記のまま名前にする() {
3211        let tests = dt("void main() {\n  test('it\\'s \\$x', () {});\n}\n");
3212        assert_eq!(tests.len(), 1);
3213        assert_eq!(tests[0].name, "it\\'s \\$x");
3214    }
3215
3216    // [FR-125]
3217    #[test]
3218    fn dartは補間を含む名前のtestとgroupを無視する() {
3219        let tests = dt("void main() {\n  test('a $x', () {});\n  test('b ${y}', () {});\n  test('c' 'd$z', () {});\n  group('g$x', () {\n    test('t', () {});\n  });\n}\n");
3220        assert!(tests.is_empty(), "{tests:?}");
3221    }
3222
3223    // [FR-125]
3224    #[test]
3225    fn dartは三重引用符の名前を無視する() {
3226        let tests = dt("void main() {\n  test('''a''', () {});\n  test(\"\"\"b\"\"\", () {});\n  test(r'''c''', () {});\n  test('d' '''e''', () {});\n}\n");
3227        assert!(tests.is_empty(), "{tests:?}");
3228    }
3229
3230    // [FR-125]
3231    #[test]
3232    fn dartは無効な名前のgroup配下を走査しない() {
3233        for name in ["''", "'''x'''", "'a\u{2028}b'", "'a\u{1}b'"] {
3234            let source = format!(
3235                "void main() {{\n  group({name}, () {{\n    test('t', () {{}});\n  }});\n}}\n"
3236            );
3237            let tests = dt(&source);
3238            assert!(tests.is_empty(), "{name:?}: {tests:?}");
3239        }
3240    }
3241
3242    // [FR-125]
3243    #[test]
3244    fn dartは行区切り文字や制御文字を含むtest名を無視する() {
3245        let tests = dt("void main() {\n  test('a\u{2028}b', () {});\n  test('c\u{1}d', () {});\n  test('ok', () {});\n}\n");
3246        assert_eq!(tests.len(), 1, "{tests:?}");
3247        assert_eq!(tests[0].name, "ok");
3248    }
3249
3250    // [FR-125]
3251    #[test]
3252    fn dartは空文字名を無視する() {
3253        let tests = dt(
3254            "void main() {\n  test('', () {});\n  test('' '', () {});\n  test('ok', () {});\n}\n",
3255        );
3256        assert_eq!(tests.len(), 1);
3257        assert_eq!(tests[0].name, "ok");
3258    }
3259
3260    // [FR-125]
3261    #[test]
3262    fn dartは第1引数が文字列リテラルでない呼び出しを無視する() {
3263        let tests = dt("void main() {\n  test(name, () {});\n  group(name, () {\n    test('t', () {});\n  });\n  test();\n  test(skip: true);\n}\n");
3264        assert!(tests.is_empty(), "{tests:?}");
3265    }
3266
3267    // [FR-123]
3268    #[test]
3269    fn dartは修飾付きやメソッドチェーンやawait付きの呼び出しを無視する() {
3270        let tests = dt("import 'package:test/test.dart' as t;\nvoid main() {\n  t.test('a', () {});\n  t.group('g', () {\n    test('b', () {});\n  });\n  test('c', () {}).foo();\n  await test('d', () {});\n  test('e', () {});\n}\n");
3271        assert_eq!(tests.len(), 1, "{tests:?}");
3272        assert_eq!(tests[0].name, "e");
3273    }
3274
3275    // [FR-123]
3276    #[test]
3277    fn dartはtestとgroupとtestwidgets以外の呼び出しを無視する() {
3278        let tests =
3279            dt("void main() {\n  setUp(() {});\n  it('a', () {});\n  Test('b', () {});\n}\n");
3280        assert!(tests.is_empty(), "{tests:?}");
3281    }
3282
3283    // [FR-123]
3284    #[test]
3285    fn dartはforとifと関数とtest本体の中のtestを無視する() {
3286        let source = "void helper() {\n  test('h', () {});\n}\nvoid main() {\n  for (final x in [1]) {\n    test('f', () {});\n  }\n  if (true) {\n    test('i', () {});\n  }\n  test('outer', () {\n    test('inner', () {});\n  });\n  void local() {\n    test('l', () {});\n  }\n  group('g', () {\n    for (var i = 0; i < 2; i++) {\n      test('gf', () {});\n    }\n    test('ok', () {});\n  });\n}\n";
3287        let tests = dt(source);
3288        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
3289        names.sort();
3290        assert_eq!(names, vec!["ok", "outer"], "{tests:?}");
3291    }
3292
3293    // [FR-123]
3294    #[test]
3295    fn dartはmain以外のトップレベル関数を入口にしない() {
3296        let tests =
3297            dt("void other() {\n  test('a', () {});\n}\nvoid mainx() {\n  test('b', () {});\n}\n");
3298        assert!(tests.is_empty(), "{tests:?}");
3299    }
3300
3301    // [FR-123]
3302    #[test]
3303    fn dartはmainが無いファイルを0件にする() {
3304        assert!(dt("int x = 1;\n").is_empty());
3305        assert!(dt("").is_empty());
3306    }
3307
3308    // [FR-124]
3309    #[test]
3310    fn dartは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
3311        let source = "void main() {\n  // [FR-001]\n  test('a', () {});\n\n  // [FR-002]\n\n  test('b', () {});\n  /* [FR-003] */\n  test('c', () {});\n}\n";
3312        let tests = dt(source);
3313        assert_eq!(find(&tests, "a").markers, vec!["FR-001".to_string()]);
3314        assert!(find(&tests, "b").markers.is_empty());
3315        assert_eq!(find(&tests, "c").markers, vec!["FR-003".to_string()]);
3316    }
3317
3318    // [FR-124]
3319    #[test]
3320    fn dartはgroup内のtestでも直前のコメントをマーカーにする() {
3321        let tests = dt(
3322            "void main() {\n  group('g', () {\n    // [FR-001]\n    test('t', () {});\n  });\n}\n",
3323        );
3324        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
3325    }
3326
3327    // [FR-124]
3328    #[test]
3329    fn dartのドキュメントコメントはマーカーにならない() {
3330        let source = "void main() {\n  /// [FR-001]\n  test('a', () {});\n  /** [FR-002] */\n  test('b', () {});\n  //// [FR-003]\n  test('c', () {});\n}\n";
3331        let tests = dt(source);
3332        for n in ["a", "b", "c"] {
3333            assert!(find(&tests, n).markers.is_empty(), "{n}");
3334        }
3335    }
3336
3337    // [FR-124]
3338    #[test]
3339    fn dartはマーカーとtestの間にdocコメントが挟まってもマーカーを有効にする() {
3340        let source = "void main() {\n  // [FR-001]\n  /// 説明 [Foo]\n  test('a', () {});\n  // [FR-002]\n  /** doc */\n  test('b', () {});\n}\n";
3341        let tests = dt(source);
3342        assert_eq!(find(&tests, "a").markers, vec!["FR-001".to_string()]);
3343        assert_eq!(find(&tests, "b").markers, vec!["FR-002".to_string()]);
3344    }
3345
3346    // [FR-124]
3347    #[test]
3348    fn dartは前の文の末尾行コメントを次のtestのマーカーにしない() {
3349        let tests = dt("void main() {\n  test('a', () {}); // [FR-001]\n  test('b', () {});\n}\n");
3350        assert!(find(&tests, "b").markers.is_empty());
3351    }
3352
3353    // [FR-123]
3354    #[test]
3355    fn dartのcontentは呼び出し文全体でマーカーコメントを含まない() {
3356        let tests = dt("void main() {\n  // [FR-001]\n  test('a', () { expect(1, 1); });\n}\n");
3357        assert_eq!(tests[0].content, "test('a', () { expect(1, 1); });");
3358    }
3359
3360    // [FR-123]
3361    #[test]
3362    fn dartの本体変更はcontentに反映される() {
3363        let a = dt("void main() { test('a', () { expect(1, 1); }); }\n");
3364        let b = dt("void main() { test('a', () { expect(1, 2); }); }\n");
3365        assert_ne!(a[0].content, b[0].content);
3366    }
3367
3368    // [FR-123]
3369    #[test]
3370    fn dartは同名のtestをそのまま複数返す() {
3371        let tests = dt("void main() {\n  test('a', () {});\n  test('a', () {});\n}\n");
3372        assert_eq!(tests.len(), 2);
3373        assert_eq!(tests[0].id, tests[1].id);
3374    }
3375
3376    // [FR-014]
3377    #[test]
3378    fn dartは構文エラーのソースでエラーを返す() {
3379        let source = "void main() {\n  test('a', () {\n}\n";
3380        assert!(extract_dart_tests("mobile/test/a_test.dart", source).is_err());
3381    }
3382
3383    // ---- Kotlin の位置引数付き注釈(FR-063) ----
3384
3385    const KOTLIN_KDOC_FIXTURE: &str = include_str!("../fixtures/kotlin_kdoc_annotation.kt");
3386    const KOTLIN_SUPPORT_FIXTURE: &str = include_str!("../fixtures/kotlin_annotation_support.kt");
3387
3388    // [FR-063]
3389    #[test]
3390    fn kotlinは直前の宣言とコメントの後の位置引数付き注釈でも解析できる() {
3391        let tests = kt("fun g() {}\n// a\n@S(\"L\")\nfun f() {}\n");
3392        assert!(tests.is_empty(), "{tests:?}");
3393    }
3394
3395    // [FR-063]
3396    #[test]
3397    fn kotlinは非asciiのkdocと位置引数付き注釈の組み合わせでも解析できる() {
3398        let tests = kt(KOTLIN_KDOC_FIXTURE);
3399        assert!(tests.is_empty(), "{tests:?}");
3400    }
3401
3402    // [FR-063]
3403    #[test]
3404    fn kotlinは位置引数付き注釈を持つサポートコードのファイルを解析できる() {
3405        let tests = kt(KOTLIN_SUPPORT_FIXTURE);
3406        assert!(tests.is_empty(), "{tests:?}");
3407    }
3408
3409    // [FR-063]
3410    #[test]
3411    fn kotlinは位置引数付き注釈の後ろにtestが続いてもtestを取る() {
3412        let tests = kt("fun g(){}\n// a\n@DisplayName(\"x\")\n@Test\nfun f(){}\n");
3413        assert_eq!(tests.len(), 1, "{tests:?}");
3414        assert_eq!(tests[0].name, "f");
3415    }
3416
3417    // [FR-063]
3418    #[test]
3419    fn kotlinは位置引数付き注釈の後ろのtestのマーカーも取る() {
3420        let tests = kt("fun g(){}\n// a\n// [FR-001]\n@DisplayName(\"x\")\n@Test\nfun f(){}\n");
3421        assert_eq!(tests.len(), 1, "{tests:?}");
3422        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
3423    }
3424
3425    // [FR-014]
3426    #[test]
3427    fn kotlinは位置引数付き注釈のある本当の構文エラーを引き続きエラーにする() {
3428        let source = "fun g() {}\n// a\n@S(\"L\")\nfun f( {\n";
3429        assert!(extract_kotlin_tests("tests/A.kt", source).is_err());
3430    }
3431
3432    // [FR-063]
3433    #[test]
3434    fn kotlinは位置引数付き注釈が複数あってもtestを取る() {
3435        let tests = kt("fun g(){}\n// a\n@A(\"x\")\n@B(\"y\")\n@Test\nfun f(){}\n");
3436        assert_eq!(tests.len(), 1, "{tests:?}");
3437    }
3438}