Skip to main content

miwa/
extract.rs

1//! Test extraction for Go, Rust, C#, Java, Kotlin, TypeScript and JavaScript 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/// Extracts every `[marker]` occurrence, where `marker` is one or more
342/// `[A-Za-z0-9._-]` characters.
343fn extract_markers(text: &str) -> Vec<String> {
344    let mut markers = Vec::new();
345    let mut rest = text;
346    while let Some(start) = rest.find('[') {
347        let after_open = &rest[start + 1..];
348        let Some(end) = after_open.find(']') else {
349            break;
350        };
351        let inner = &after_open[..end];
352        if !inner.is_empty()
353            && inner
354                .chars()
355                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
356        {
357            markers.push(inner.to_string());
358        }
359        rest = &after_open[end + 1..];
360    }
361    markers
362}
363
364fn dir_of(path: &str) -> String {
365    match path.rsplit_once('/') {
366        Some((dir, _)) => dir.to_string(),
367        None => ".".to_string(),
368    }
369}
370
371const CSHARP_TEST_ATTRIBUTES: [&str; 7] = [
372    "Fact",
373    "Theory",
374    "Test",
375    "TestCase",
376    "TestCaseSource",
377    "TestMethod",
378    "DataTestMethod",
379];
380
381/// Extract C# tests (xUnit / NUnit / MSTest) from a `*.cs` file: methods inside
382/// types (also through namespaces and `#if` blocks) carrying a test attribute.
383/// Markers come from the comment block immediately preceding the method, and
384/// `content` runs from the first non-attribute token to the end of the method.
385pub fn extract_csharp_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
386    let mut parser = tree_sitter::Parser::new();
387    parser
388        .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
389        .expect("valid c# grammar");
390    let tree = parser
391        .parse(source, None)
392        .ok_or_else(|| ExtractError::Parse {
393            path: path.to_string(),
394        })?;
395    let root = tree.root_node();
396    if root.has_error() {
397        return Err(ExtractError::Parse {
398            path: path.to_string(),
399        });
400    }
401    let base = path.strip_suffix(".cs").unwrap_or(path);
402    let mut results = Vec::new();
403    walk_csharp_container(root, source, base, &[], &mut results);
404    Ok(results)
405}
406
407fn walk_csharp_container(
408    container: tree_sitter::Node,
409    source: &str,
410    base: &str,
411    types: &[String],
412    results: &mut Vec<ExtractedTest>,
413) {
414    let mut cursor = container.walk();
415    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
416    for (idx, node) in children.iter().enumerate() {
417        match node.kind() {
418            "preproc_if" | "preproc_elif" | "preproc_else" => {
419                walk_csharp_container(*node, source, base, types, results);
420            }
421            "namespace_declaration" => {
422                if let Some(body) = node.child_by_field_name("body") {
423                    walk_csharp_container(body, source, base, types, results);
424                }
425            }
426            "class_declaration"
427            | "struct_declaration"
428            | "record_declaration"
429            | "interface_declaration" => {
430                let (Some(name_node), Some(body)) = (
431                    node.child_by_field_name("name"),
432                    node.child_by_field_name("body"),
433                ) else {
434                    continue;
435                };
436                let name = name_node.utf8_text(source.as_bytes()).unwrap_or("");
437                let mut new_types = types.to_vec();
438                new_types.push(name.to_string());
439                walk_csharp_container(body, source, base, &new_types, results);
440            }
441            "method_declaration" if !types.is_empty() => {
442                if !csharp_is_test_method(*node, source) {
443                    continue;
444                }
445                let Some(name_node) = node.child_by_field_name("name") else {
446                    continue;
447                };
448                let name = name_node
449                    .utf8_text(source.as_bytes())
450                    .unwrap_or("")
451                    .to_string();
452                let mut c = node.walk();
453                let Some(first) = node
454                    .children(&mut c)
455                    .find(|ch| ch.kind() != "attribute_list" && ch.kind() != "comment")
456                else {
457                    continue;
458                };
459                let content = source[first.start_byte()..node.end_byte()].to_string();
460                let markers = collect_preceding_markers(&children, idx, source, "comment");
461                results.push(ExtractedTest {
462                    id: format!("{base}:{}:{name}", types.join("/")),
463                    name,
464                    markers,
465                    content,
466                });
467            }
468            _ => {}
469        }
470    }
471}
472
473fn csharp_is_test_method(method: tree_sitter::Node, source: &str) -> bool {
474    let mut cursor = method.walk();
475    for list in method.children(&mut cursor) {
476        if list.kind() != "attribute_list" {
477            continue;
478        }
479        let mut c2 = list.walk();
480        for attr in list.children(&mut c2) {
481            if attr.kind() != "attribute" {
482                continue;
483            }
484            let Some(mut name) = attr.child_by_field_name("name") else {
485                continue;
486            };
487            while name.kind() == "qualified_name" {
488                match name.child_by_field_name("name") {
489                    Some(n) => name = n,
490                    None => break,
491                }
492            }
493            let text = name.utf8_text(source.as_bytes()).unwrap_or("");
494            if CSHARP_TEST_ATTRIBUTES.contains(&text) {
495                return true;
496            }
497        }
498    }
499    false
500}
501
502const TYPESCRIPT_TEST_MODIFIERS: [&str; 6] =
503    ["skip", "only", "todo", "fails", "concurrent", "sequential"];
504const TYPESCRIPT_DESCRIBE_MODIFIERS: [&str; 6] = [
505    "skip",
506    "only",
507    "todo",
508    "concurrent",
509    "sequential",
510    "shuffle",
511];
512
513/// File extensions (without the dot) of TypeScript / JavaScript (Vitest)
514/// test files: `<name>.test.<ext>` / `<name>.spec.<ext>`.
515pub const TYPESCRIPT_TEST_EXTENSIONS: [&str; 8] =
516    ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
517
518/// Whether `path` is a TypeScript / JavaScript (Vitest) test file, i.e. ends
519/// with `.test.<ext>` or `.spec.<ext>` for one of the supported extensions.
520pub fn is_typescript_test_file(path: &str) -> bool {
521    TYPESCRIPT_TEST_EXTENSIONS.iter().any(|ext| {
522        path.ends_with(&format!(".test.{ext}")) || path.ends_with(&format!(".spec.{ext}"))
523    })
524}
525
526/// Extract TypeScript / JavaScript (Vitest) tests from a `*.test.<ext>` /
527/// `*.spec.<ext>` file (see [`TYPESCRIPT_TEST_EXTENSIONS`]): `it` / `test`
528/// call statements at the top level or directly inside `describe` callbacks.
529/// Markers come from the comment block immediately preceding the call, and
530/// `content` is the whole call statement.
531pub fn extract_typescript_tests(
532    path: &str,
533    source: &str,
534) -> Result<Vec<ExtractedTest>, ExtractError> {
535    let mut parser = tree_sitter::Parser::new();
536    let lang: tree_sitter::Language = if [".ts", ".mts", ".cts"]
537        .iter()
538        .any(|ext| path.ends_with(ext))
539    {
540        tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
541    } else {
542        tree_sitter_typescript::LANGUAGE_TSX.into()
543    };
544    parser
545        .set_language(&lang)
546        .expect("valid typescript grammar");
547    let tree = parser
548        .parse(source, None)
549        .ok_or_else(|| ExtractError::Parse {
550            path: path.to_string(),
551        })?;
552    let root = tree.root_node();
553    if root.has_error() {
554        return Err(ExtractError::Parse {
555            path: path.to_string(),
556        });
557    }
558    let base = TYPESCRIPT_TEST_EXTENSIONS
559        .iter()
560        .find_map(|ext| path.strip_suffix(&format!(".{ext}")))
561        .unwrap_or(path);
562    let mut results = Vec::new();
563    walk_typescript_statements(root, source, base, &[], &mut results);
564    Ok(results)
565}
566
567/// Returns the base identifier and the property chain of a callee made only
568/// of identifiers and member accesses (`test.concurrent.only`).
569fn typescript_callee_chain<'a>(
570    callee: tree_sitter::Node,
571    source: &'a str,
572) -> Option<(&'a str, Vec<&'a str>)> {
573    let mut props = Vec::new();
574    let mut cur = callee;
575    loop {
576        match cur.kind() {
577            "identifier" => {
578                props.reverse();
579                return Some((cur.utf8_text(source.as_bytes()).ok()?, props));
580            }
581            "member_expression" => {
582                let mut c = cur.walk();
583                if cur.children(&mut c).any(|ch| ch.kind() == "optional_chain") {
584                    return None;
585                }
586                let prop = cur.child_by_field_name("property")?;
587                if prop.kind() != "property_identifier" {
588                    return None;
589                }
590                props.push(prop.utf8_text(source.as_bytes()).ok()?);
591                cur = cur.child_by_field_name("object")?;
592            }
593            _ => return None,
594        }
595    }
596}
597
598/// First-argument name: a plain string or a substitution-free template
599/// string, sliced from the source without interpreting escapes.
600fn typescript_name_arg<'a>(args: tree_sitter::Node, source: &'a str) -> Option<&'a str> {
601    let first = args.named_child(0)?;
602    match first.kind() {
603        "string" => {}
604        "template_string" => {
605            let mut c = first.walk();
606            if first
607                .children(&mut c)
608                .any(|ch| ch.kind() == "template_substitution")
609            {
610                return None;
611            }
612        }
613        _ => return None,
614    }
615    let text = first.utf8_text(source.as_bytes()).ok()?;
616    if text.len() < 2 {
617        return None;
618    }
619    let name = &text[1..text.len() - 1];
620    if !is_valid_test_name(name) {
621        return None;
622    }
623    Some(name)
624}
625
626fn walk_typescript_statements(
627    container: tree_sitter::Node,
628    source: &str,
629    base: &str,
630    describes: &[String],
631    results: &mut Vec<ExtractedTest>,
632) {
633    let mut cursor = container.walk();
634    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
635    for (idx, node) in children.iter().enumerate() {
636        if node.kind() != "expression_statement" {
637            continue;
638        }
639        let Some(call) = node.named_child(0) else {
640            continue;
641        };
642        if call.kind() != "call_expression" {
643            continue;
644        }
645        let (Some(callee), Some(args)) = (
646            call.child_by_field_name("function"),
647            call.child_by_field_name("arguments"),
648        ) else {
649            continue;
650        };
651        let Some((head, props)) = typescript_callee_chain(callee, source) else {
652            continue;
653        };
654        let Some(name) = typescript_name_arg(args, source) else {
655            continue;
656        };
657        if (head == "test" || head == "it")
658            && props.iter().all(|p| TYPESCRIPT_TEST_MODIFIERS.contains(p))
659        {
660            let markers = collect_preceding_markers(&children, idx, source, "comment");
661            let content = node.utf8_text(source.as_bytes()).unwrap_or("").to_string();
662            results.push(ExtractedTest {
663                id: format!("{base}:{}:{name}", describes.join("/")),
664                name: name.to_string(),
665                markers,
666                content,
667            });
668        } else if head == "describe"
669            && props
670                .iter()
671                .all(|p| TYPESCRIPT_DESCRIBE_MODIFIERS.contains(p))
672        {
673            let Some(cb) = args.named_child(1) else {
674                continue;
675            };
676            if cb.kind() != "arrow_function" && cb.kind() != "function_expression" {
677                continue;
678            }
679            let Some(body) = cb.child_by_field_name("body") else {
680                continue;
681            };
682            if body.kind() != "statement_block" {
683                continue;
684            }
685            let mut new_desc = describes.to_vec();
686            new_desc.push(name.to_string());
687            walk_typescript_statements(body, source, base, &new_desc, results);
688        }
689    }
690}
691
692// ---- Java / Kotlin (JUnit) ----
693
694/// Annotation names (last identifier, qualifiers ignored) marking a JUnit
695/// style test method / function in Java and Kotlin.
696const JVM_TEST_ANNOTATIONS: [&str; 5] = [
697    "Test",
698    "ParameterizedTest",
699    "RepeatedTest",
700    "TestFactory",
701    "TestTemplate",
702];
703
704const JVM_COMMENT_KINDS: [&str; 2] = ["line_comment", "block_comment"];
705
706/// Start byte of the hashed range: the first token of the declaration that is
707/// neither an annotation nor a comment (annotations / comments that lead the
708/// modifiers and the comments right after them are skipped).
709fn jvm_content_start(decl: tree_sitter::Node) -> Option<usize> {
710    fn skip(kind: &str) -> bool {
711        matches!(
712            kind,
713            "annotation" | "marker_annotation" | "line_comment" | "block_comment"
714        )
715    }
716    let mut cursor = decl.walk();
717    for child in decl.children(&mut cursor) {
718        if child.kind() == "modifiers" {
719            let mut c2 = child.walk();
720            let first = child.children(&mut c2).find(|n| !skip(n.kind()));
721            if let Some(first) = first {
722                return Some(first.start_byte());
723            }
724        } else if !skip(child.kind()) {
725            return Some(child.start_byte());
726        }
727    }
728    None
729}
730
731fn jvm_name_is_test(name: &str) -> bool {
732    JVM_TEST_ANNOTATIONS.contains(&name)
733}
734
735// -- Java --
736
737/// Extract Java (JUnit) tests: methods annotated with one of
738/// [`JVM_TEST_ANNOTATIONS`] directly inside class / record / interface / enum
739/// bodies. Markers come from the `//` / `/* */` comment block immediately
740/// preceding the method; `content` starts at the first non-annotation token.
741pub fn extract_java_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
742    let mut parser = tree_sitter::Parser::new();
743    parser
744        .set_language(&tree_sitter_java::LANGUAGE.into())
745        .expect("valid java grammar");
746    let tree = parser
747        .parse(source, None)
748        .ok_or_else(|| ExtractError::Parse {
749            path: path.to_string(),
750        })?;
751    let root = tree.root_node();
752    if root.has_error() {
753        return Err(ExtractError::Parse {
754            path: path.to_string(),
755        });
756    }
757    let base = path.strip_suffix(".java").unwrap_or(path);
758    let mut results = Vec::new();
759    walk_java_container(root, source, base, &[], &mut results);
760    Ok(results)
761}
762
763fn walk_java_container(
764    container: tree_sitter::Node,
765    source: &str,
766    base: &str,
767    types: &[String],
768    results: &mut Vec<ExtractedTest>,
769) {
770    let mut cursor = container.walk();
771    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
772    for (idx, node) in children.iter().enumerate() {
773        match node.kind() {
774            "class_declaration"
775            | "record_declaration"
776            | "interface_declaration"
777            | "enum_declaration" => {
778                let (Some(name_node), Some(body)) = (
779                    node.child_by_field_name("name"),
780                    node.child_by_field_name("body"),
781                ) else {
782                    continue;
783                };
784                let name = name_node.utf8_text(source.as_bytes()).unwrap_or("");
785                let mut new_types = types.to_vec();
786                new_types.push(name.to_string());
787                if body.kind() == "enum_body" {
788                    let mut c = body.walk();
789                    let decls: Vec<_> = body
790                        .children(&mut c)
791                        .filter(|n| n.kind() == "enum_body_declarations")
792                        .collect();
793                    for d in decls {
794                        walk_java_container(d, source, base, &new_types, results);
795                    }
796                } else {
797                    walk_java_container(body, source, base, &new_types, results);
798                }
799            }
800            "method_declaration" if !types.is_empty() => {
801                if !java_is_test_method(*node, source) {
802                    continue;
803                }
804                let Some(name_node) = node.child_by_field_name("name") else {
805                    continue;
806                };
807                let Some(start) = jvm_content_start(*node) else {
808                    continue;
809                };
810                let name = name_node
811                    .utf8_text(source.as_bytes())
812                    .unwrap_or("")
813                    .to_string();
814                results.push(ExtractedTest {
815                    id: format!("{base}:{}:{name}", types.join("/")),
816                    name,
817                    markers: collect_preceding_markers_with(
818                        &children,
819                        idx,
820                        source,
821                        &|n| n.utf8_text(source.as_bytes()).ok(),
822                        &JVM_COMMENT_KINDS,
823                        &|n| n.utf8_text(source.as_bytes()).is_ok_and(is_doc_comment),
824                    ),
825                    content: source[start..node.end_byte()].to_string(),
826                });
827            }
828            _ => {}
829        }
830    }
831}
832
833fn java_is_test_method(method: tree_sitter::Node, source: &str) -> bool {
834    let mut cursor = method.walk();
835    let Some(modifiers) = method
836        .children(&mut cursor)
837        .find(|n| n.kind() == "modifiers")
838    else {
839        return false;
840    };
841    let mut c2 = modifiers.walk();
842    for ann in modifiers.children(&mut c2) {
843        if ann.kind() != "marker_annotation" && ann.kind() != "annotation" {
844            continue;
845        }
846        let Some(mut name) = ann.child_by_field_name("name") else {
847            continue;
848        };
849        while name.kind() == "scoped_identifier" {
850            match name.child_by_field_name("name") {
851                Some(n) => name = n,
852                None => break,
853            }
854        }
855        if jvm_name_is_test(name.utf8_text(source.as_bytes()).unwrap_or("")) {
856            return true;
857        }
858    }
859    false
860}
861
862// -- Kotlin --
863
864/// The Kotlin grammar rejects identifiers with many non-Latin characters.
865/// The source is therefore parsed as an "ASCII shadow": every non-ASCII
866/// code point becomes one ASCII character (`' '` for whitespace and U+FEFF,
867/// `'x'` otherwise). `map` records `(shadow_offset, original_offset)` pairs at
868/// every point where the two offsets diverge, so shadow byte ranges can be
869/// mapped back to the original source.
870struct KotlinShadow<'a> {
871    original: &'a str,
872    shadow: String,
873    map: Vec<(usize, usize)>,
874}
875
876impl<'a> KotlinShadow<'a> {
877    fn new(original: &'a str) -> Self {
878        let mut shadow = String::with_capacity(original.len());
879        let mut map = vec![(0usize, 0usize)];
880        for (off, ch) in original.char_indices() {
881            if ch.is_ascii() {
882                shadow.push(ch);
883            } else {
884                shadow.push(if ch.is_whitespace() || ch == '\u{feff}' {
885                    ' '
886                } else {
887                    'x'
888                });
889                map.push((shadow.len(), off + ch.len_utf8()));
890            }
891        }
892        KotlinShadow {
893            original,
894            shadow,
895            map,
896        }
897    }
898
899    /// Replaces the given shadow byte ranges with spaces (newlines kept).
900    fn blank_ranges(&mut self, ranges: &[(usize, usize)]) {
901        let mut bytes = std::mem::take(&mut self.shadow).into_bytes();
902        for &(a, b) in ranges {
903            for byte in &mut bytes[a..b] {
904                if *byte != b'\n' {
905                    *byte = b' ';
906                }
907            }
908        }
909        self.shadow = String::from_utf8(bytes).expect("ascii shadow");
910    }
911
912    fn to_original(&self, shadow_offset: usize) -> usize {
913        let i = self.map.partition_point(|&(s, _)| s <= shadow_offset) - 1;
914        let (s, o) = self.map[i];
915        o + (shadow_offset - s)
916    }
917
918    fn slice(&self, start: usize, end: usize) -> Option<&'a str> {
919        self.original
920            .get(self.to_original(start)..self.to_original(end))
921    }
922
923    fn node_text(&self, node: tree_sitter::Node) -> Option<&'a str> {
924        self.slice(node.start_byte(), node.end_byte())
925    }
926}
927
928/// Extract Kotlin (JUnit) tests: functions annotated with one of
929/// [`JVM_TEST_ANNOTATIONS`] at the top level or directly inside class /
930/// object / companion object bodies. Parsing uses an ASCII shadow of the
931/// source (see [`KotlinShadow`]); names and `content` come from the original.
932pub fn extract_kotlin_tests(path: &str, source: &str) -> Result<Vec<ExtractedTest>, ExtractError> {
933    kotlin_extract(path, source, true)
934}
935
936/// Top-level node kinds the Kotlin grammar may legitimately produce under
937/// `source_file`. Anything else (e.g. a leftover `annotated_expression`)
938/// means the grammar failed to recognise a declaration.
939const KOTLIN_ROOT_KINDS: [&str; 11] = [
940    "shebang",
941    "file_annotation",
942    "package_header",
943    "import",
944    "class_declaration",
945    "object_declaration",
946    "function_declaration",
947    "property_declaration",
948    "type_alias",
949    "line_comment",
950    "block_comment",
951];
952
953/// `retry_annotated_expression` enables the class-annotation blanking
954/// workaround (disabled only by tests of the fallback error path).
955fn kotlin_extract(
956    path: &str,
957    source: &str,
958    retry_annotated_expression: bool,
959) -> Result<Vec<ExtractedTest>, ExtractError> {
960    let parse_err = || ExtractError::Parse {
961        path: path.to_string(),
962    };
963    let mut shadow = KotlinShadow::new(source);
964    let mut parser = tree_sitter::Parser::new();
965    parser
966        .set_language(&tree_sitter_kotlin_ng::LANGUAGE.into())
967        .expect("valid kotlin grammar");
968    let mut tree = parser.parse(&shadow.shadow, None).ok_or_else(parse_err)?;
969    if tree.root_node().has_error() {
970        return Err(parse_err());
971    }
972    // Grammar bug: a class with 2+ annotations, no supertype and an
973    // `@Annotation inner class` in its body parses as one
974    // `annotated_expression` with no error. The class annotations are not
975    // needed for extraction, so blank them out and parse again.
976    if retry_annotated_expression && kotlin_has_annotated_expression(tree.root_node()) {
977        let ranges = kotlin_annotated_expression_annotations(tree.root_node());
978        drop(tree);
979        shadow.blank_ranges(&ranges);
980        tree = parser.parse(&shadow.shadow, None).ok_or_else(parse_err)?;
981        if tree.root_node().has_error() {
982            return Err(parse_err());
983        }
984    }
985    let root = tree.root_node();
986    // A structure other than declarations at file level means the grammar
987    // missed a declaration: never silently report zero tests.
988    let mut cursor = root.walk();
989    let unexpected = root
990        .children(&mut cursor)
991        .any(|n| !KOTLIN_ROOT_KINDS.contains(&n.kind()));
992    if unexpected {
993        return Err(parse_err());
994    }
995    let base = path.strip_suffix(".kt").unwrap_or(path);
996    let mut results = Vec::new();
997    walk_kotlin_container(root, &shadow, base, &[], &mut results).map_err(|()| parse_err())?;
998    Ok(results)
999}
1000
1001fn kotlin_has_annotated_expression(root: tree_sitter::Node) -> bool {
1002    let mut cursor = root.walk();
1003    let found = root
1004        .children(&mut cursor)
1005        .any(|n| n.kind() == "annotated_expression");
1006    found
1007}
1008
1009fn kotlin_annotated_expression_annotations(root: tree_sitter::Node) -> Vec<(usize, usize)> {
1010    let mut ranges = Vec::new();
1011    let mut cursor = root.walk();
1012    for expr in root.children(&mut cursor) {
1013        if expr.kind() != "annotated_expression" {
1014            continue;
1015        }
1016        let mut c2 = expr.walk();
1017        for child in expr.children(&mut c2) {
1018            if child.kind() == "annotation" {
1019                ranges.push((child.start_byte(), child.end_byte()));
1020            }
1021        }
1022    }
1023    ranges
1024}
1025
1026fn kotlin_ident(text: &str) -> String {
1027    text.strip_prefix('`')
1028        .and_then(|t| t.strip_suffix('`'))
1029        .unwrap_or(text)
1030        .to_string()
1031}
1032
1033fn walk_kotlin_container(
1034    container: tree_sitter::Node,
1035    shadow: &KotlinShadow,
1036    base: &str,
1037    types: &[String],
1038    results: &mut Vec<ExtractedTest>,
1039) -> Result<(), ()> {
1040    let mut cursor = container.walk();
1041    let children: Vec<tree_sitter::Node> = container.children(&mut cursor).collect();
1042    for (idx, node) in children.iter().enumerate() {
1043        match node.kind() {
1044            "class_declaration" | "object_declaration" | "companion_object" => {
1045                let name = match node.child_by_field_name("name") {
1046                    Some(n) => match shadow.node_text(n) {
1047                        Some(t) => kotlin_ident(t),
1048                        None => {
1049                            debug_assert!(false, "kotlin name range off a char boundary");
1050                            return Err(());
1051                        }
1052                    },
1053                    None if node.kind() == "companion_object" => "Companion".to_string(),
1054                    None => continue,
1055                };
1056                let mut c = node.walk();
1057                let Some(body) = node
1058                    .children(&mut c)
1059                    .find(|n| matches!(n.kind(), "class_body" | "enum_class_body"))
1060                else {
1061                    continue;
1062                };
1063                let mut new_types = types.to_vec();
1064                new_types.push(name);
1065                walk_kotlin_container(body, shadow, base, &new_types, results)?;
1066            }
1067            "function_declaration" => {
1068                if !kotlin_is_test_function(*node, shadow) {
1069                    continue;
1070                }
1071                let Some(name_node) = node.child_by_field_name("name") else {
1072                    continue;
1073                };
1074                let Some(name_text) = shadow.node_text(name_node) else {
1075                    debug_assert!(false, "kotlin name range off a char boundary");
1076                    return Err(());
1077                };
1078                let name = kotlin_ident(name_text);
1079                if !is_valid_test_name(&name) {
1080                    continue;
1081                }
1082                let Some(start) = jvm_content_start(*node) else {
1083                    continue;
1084                };
1085                let Some(content) = shadow.slice(start, node.end_byte()) else {
1086                    debug_assert!(false, "kotlin content range off a char boundary");
1087                    return Err(());
1088                };
1089                results.push(ExtractedTest {
1090                    id: format!("{base}:{}:{name}", types.join("/")),
1091                    name,
1092                    // Markers are read from the original source: the shadow
1093                    // turns `[正常系]` into a bogus `[xxx]`.
1094                    markers: collect_preceding_markers_with(
1095                        &children,
1096                        idx,
1097                        &shadow.shadow,
1098                        &|n| shadow.node_text(n),
1099                        &JVM_COMMENT_KINDS,
1100                        &|n| shadow.node_text(n).is_some_and(is_doc_comment),
1101                    ),
1102                    content: content.to_string(),
1103                });
1104            }
1105            _ => {}
1106        }
1107    }
1108    Ok(())
1109}
1110
1111fn kotlin_is_test_function(func: tree_sitter::Node, shadow: &KotlinShadow) -> bool {
1112    let mut cursor = func.walk();
1113    let Some(modifiers) = func.children(&mut cursor).find(|n| n.kind() == "modifiers") else {
1114        return false;
1115    };
1116    let mut c2 = modifiers.walk();
1117    let found = modifiers
1118        .children(&mut c2)
1119        .filter(|n| n.kind() == "annotation")
1120        .any(|ann| kotlin_annotation_has_test(ann, shadow));
1121    found
1122}
1123
1124/// Whether any `user_type` under the annotation (outside argument lists) ends
1125/// in a test annotation name.
1126fn kotlin_annotation_has_test(node: tree_sitter::Node, shadow: &KotlinShadow) -> bool {
1127    let mut cursor = node.walk();
1128    for child in node.children(&mut cursor) {
1129        match child.kind() {
1130            "user_type" => {
1131                let mut c = child.walk();
1132                let last = child
1133                    .children(&mut c)
1134                    .filter(|n| matches!(n.kind(), "identifier" | "type_identifier"))
1135                    .last();
1136                if let Some(last) = last {
1137                    if jvm_name_is_test(shadow.node_text(last).unwrap_or("")) {
1138                        return true;
1139                    }
1140                }
1141            }
1142            "value_arguments" | "use_site_target" => {}
1143            _ => {
1144                if kotlin_annotation_has_test(child, shadow) {
1145                    return true;
1146                }
1147            }
1148        }
1149    }
1150    false
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156
1157    fn find<'a>(tests: &'a [ExtractedTest], name: &str) -> &'a ExtractedTest {
1158        tests
1159            .iter()
1160            .find(|t| t.name == name)
1161            .unwrap_or_else(|| panic!("test named {name} not found in {tests:?}"))
1162    }
1163
1164    // [FR-012]
1165    #[test]
1166    fn goは直前に隣接するコメントをマーカーとして抽出する() {
1167        let source = r#"package auth
1168
1169// [FR-001]
1170func TestRejectsShortPassword(t *testing.T) {
1171    doSomething()
1172}
1173"#;
1174        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1175        let t = find(&tests, "TestRejectsShortPassword");
1176        assert_eq!(t.markers, vec!["FR-001".to_string()]);
1177    }
1178
1179    // [FR-012]
1180    #[test]
1181    fn goは空行を挟んだコメントや欠落をマーカーなしとして無視する() {
1182        let source = r#"package auth
1183
1184// [FR-002]
1185
1186func TestWithBlankLine(t *testing.T) {
1187}
1188
1189func TestNoComment(t *testing.T) {
1190}
1191"#;
1192        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1193        assert!(find(&tests, "TestWithBlankLine").markers.is_empty());
1194        assert!(find(&tests, "TestNoComment").markers.is_empty());
1195    }
1196
1197    // [FR-013]
1198    #[test]
1199    fn goのtest識別子はディレクトリと関数名を組み合わせた形式になる() {
1200        let source = "package auth\n\n// [FR-001]\nfunc TestLogin(t *testing.T) {}\n";
1201        let tests = extract_go_tests("internal/auth/login_test.go", source).unwrap();
1202        assert_eq!(find(&tests, "TestLogin").id, "internal/auth:TestLogin");
1203    }
1204
1205    // [FR-013]
1206    #[test]
1207    fn goのtest識別子はリポジトリ直下ならドットになる() {
1208        let source = "package main\n\n// [FR-001]\nfunc TestRoot(t *testing.T) {}\n";
1209        let tests = extract_go_tests("login_test.go", source).unwrap();
1210        assert_eq!(find(&tests, "TestRoot").id, ".:TestRoot");
1211    }
1212
1213    // [FR-003]
1214    #[test]
1215    fn rustの関数本体の変更はcontentに反映される() {
1216        let base = r#"// [FR-001]
1217#[test]
1218fn rejects_short_password() {
1219    assert!(true);
1220}
1221"#;
1222        let changed = r#"// [FR-001]
1223#[test]
1224fn rejects_short_password() {
1225    assert!(false);
1226}
1227"#;
1228        let a = find(
1229            &extract_rust_tests("src/auth.rs", base, &[]).unwrap(),
1230            "rejects_short_password",
1231        )
1232        .content
1233        .clone();
1234        let b = find(
1235            &extract_rust_tests("src/auth.rs", changed, &[]).unwrap(),
1236            "rejects_short_password",
1237        )
1238        .content
1239        .clone();
1240        assert_ne!(a, b);
1241        assert!(a.contains("fn rejects_short_password"));
1242        assert!(a.contains("assert!(true)"));
1243    }
1244
1245    // [FR-003]
1246    #[test]
1247    fn goの関数本体の変更はcontentに反映される() {
1248        let base = "package auth\n\n// [FR-001]\nfunc TestRejectsShortPassword(t *testing.T) {\n\tassertTrue(t, true)\n}\n";
1249        let changed = "package auth\n\n// [FR-001]\nfunc TestRejectsShortPassword(t *testing.T) {\n\tassertTrue(t, false)\n}\n";
1250        let a = find(
1251            &extract_go_tests("internal/auth/login_test.go", base).unwrap(),
1252            "TestRejectsShortPassword",
1253        )
1254        .content
1255        .clone();
1256        let b = find(
1257            &extract_go_tests("internal/auth/login_test.go", changed).unwrap(),
1258            "TestRejectsShortPassword",
1259        )
1260        .content
1261        .clone();
1262        assert_ne!(a, b);
1263        assert!(a.contains("func TestRejectsShortPassword"));
1264        assert!(a.contains("assertTrue(t, true)"));
1265    }
1266
1267    // [FR-004]
1268    #[test]
1269    fn rustのcontentは外側の属性とマーカーコメントを含まない() {
1270        let source = r#"// [FR-001]
1271#[test]
1272fn rejects_short_password() {
1273    assert!(true);
1274}
1275"#;
1276        let extracted = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1277        let t = find(&extracted, "rejects_short_password");
1278        assert!(!t.content.contains("#[test]"));
1279        assert!(!t.content.contains("[FR-001]"));
1280        assert!(t.content.contains("fn rejects_short_password"));
1281    }
1282
1283    fn rs(source: &str, extra: &[&str]) -> Vec<ExtractedTest> {
1284        let extra: Vec<String> = extra.iter().map(|s| s.to_string()).collect();
1285        extract_rust_tests("src/auth.rs", source, &extra).unwrap()
1286    }
1287
1288    fn has(tests: &[ExtractedTest], name: &str) -> bool {
1289        tests.iter().any(|t| t.name == name)
1290    }
1291
1292    // [FR-015]
1293    #[test]
1294    fn rustは属性パスの末尾がtestならtestとして抽出する() {
1295        let source = r#"// [FR-001]
1296#[test]
1297fn plain_test() {}
1298
1299// [FR-002]
1300#[tokio::test]
1301async fn async_test() {}
1302
1303// [FR-003]
1304#[async_std::test]
1305async fn async_std_test() {}
1306
1307// [FR-004]
1308#[tokio::test(flavor = "multi_thread")]
1309async fn multi_thread_test() {}
1310
1311// [FR-005]
1312#[ test ]
1313fn spaced_test() {}
1314"#;
1315        let tests = rs(source, &[]);
1316        for name in [
1317            "plain_test",
1318            "async_test",
1319            "async_std_test",
1320            "multi_thread_test",
1321            "spaced_test",
1322        ] {
1323            assert!(has(&tests, name), "{name} should be extracted");
1324        }
1325        assert_eq!(
1326            find(&tests, "async_test").markers,
1327            vec!["FR-002".to_string()]
1328        );
1329    }
1330
1331    // [FR-015]
1332    #[test]
1333    fn rustのrstestは追加指定がなければ対象外で指定があれば対象になる() {
1334        let source = "// [FR-001]\n#[rstest]\nfn t() {}\n";
1335        assert!(!has(&rs(source, &[]), "t"));
1336        assert!(has(&rs(source, &["rstest"]), "t"));
1337
1338        let scoped = "// [FR-001]\n#[rstest::rstest]\nfn t() {}\n";
1339        assert!(!has(&rs(scoped, &[]), "t"));
1340        assert!(has(&rs(scoped, &["rstest"]), "t"));
1341    }
1342
1343    // [FR-015]
1344    #[test]
1345    fn rustのtest_caseとcfg_attrのtestは対象外() {
1346        let source = r#"// [FR-001]
1347#[test_case(1)]
1348fn case_test() {}
1349
1350// [FR-002]
1351#[cfg_attr(x, test)]
1352fn cfg_attr_test() {}
1353"#;
1354        let tests = rs(source, &[]);
1355        assert!(!has(&tests, "case_test"));
1356        assert!(!has(&tests, "cfg_attr_test"));
1357
1358        let tests = rs(source, &["test_case"]);
1359        assert!(has(&tests, "case_test"));
1360        assert!(!has(&tests, "cfg_attr_test"));
1361    }
1362
1363    // [FR-015]
1364    #[test]
1365    fn rustのinner_attributeのtestはtestを作らない() {
1366        let source = "#![test]\n\n// [FR-001]\nfn helper() {}\n";
1367        assert!(rs(source, &[]).is_empty());
1368    }
1369
1370    // [FR-016]
1371    #[test]
1372    fn rustは最初の対象属性の直前のコメントをマーカーにする() {
1373        let source = "// [FR-001]\n#[rstest]\n#[case(1)]\n#[tokio::test]\nfn t() {}\n";
1374        let tests = rs(source, &["rstest"]);
1375        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
1376    }
1377
1378    // [FR-016]
1379    #[test]
1380    fn rustは対象属性の設定によりマーカー位置が変わる() {
1381        let source = "#[rstest]\n// [FR-001]\n#[tokio::test]\nfn t() {}\n";
1382        let without = rs(source, &[]);
1383        assert_eq!(find(&without, "t").markers, vec!["FR-001".to_string()]);
1384        let with = rs(source, &["rstest"]);
1385        assert!(find(&with, "t").markers.is_empty());
1386    }
1387
1388    // [FR-016]
1389    #[test]
1390    fn rustは属性間や属性末尾のコメントがあってもtestとして検出しマーカーを取る() {
1391        let between = "// [FR-001]\n#[test]\n// note\n#[ignore]\nfn between() {}\n";
1392        let tests = rs(between, &[]);
1393        assert_eq!(find(&tests, "between").markers, vec!["FR-001".to_string()]);
1394
1395        let trailing = "// [FR-001]\n#[test] // trailing\n#[ignore]\nfn trailing() {}\n";
1396        let tests = rs(trailing, &[]);
1397        assert_eq!(find(&tests, "trailing").markers, vec!["FR-001".to_string()]);
1398
1399        let block = "// [FR-001]\n#[test]\n/* block */\n#[ignore]\nfn block() {}\n";
1400        assert_eq!(
1401            find(&rs(block, &[]), "block").markers,
1402            vec!["FR-001".to_string()]
1403        );
1404    }
1405
1406    // [FR-004]
1407    #[test]
1408    fn tokioのtest属性はcontentに含まれない() {
1409        let source = "// [FR-001]\n#[tokio::test]\nasync fn t() { assert!(true); }\n";
1410        let tests = rs(source, &[]);
1411        let t = find(&tests, "t");
1412        assert!(!t.content.contains("tokio::test"));
1413        assert!(!t.content.contains("#["));
1414        assert!(t.content.contains("async fn t"));
1415    }
1416
1417    // [FR-016]
1418    #[test]
1419    fn rustはtest属性の直前に隣接するコメントをマーカーとする() {
1420        let source = r#"// [FR-001]
1421#[test]
1422fn adjacent() {}
1423
1424// [FR-002]
1425
1426#[test]
1427fn blank_line_between() {}
1428
1429#[test]
1430fn no_comment() {}
1431"#;
1432        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1433        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
1434        assert!(find(&tests, "blank_line_between").markers.is_empty());
1435        assert!(find(&tests, "no_comment").markers.is_empty());
1436    }
1437
1438    // [FR-017]
1439    #[test]
1440    fn rustのtest識別子はトップレベルなら空モジュールパスになる() {
1441        let source = "// [FR-001]\n#[test]\nfn roundtrip() {}\n";
1442        let tests = extract_rust_tests("src/lock.rs", source, &[]).unwrap();
1443        assert_eq!(find(&tests, "roundtrip").id, "src/lock::roundtrip");
1444    }
1445
1446    // [FR-017]
1447    #[test]
1448    fn rustのtest識別子はinline_modを外側から内側の順に連結する() {
1449        let source = r#"mod tests {
1450    // [FR-001]
1451    #[test]
1452    fn roundtrip() {}
1453}
1454"#;
1455        let tests = extract_rust_tests("src/lock.rs", source, &[]).unwrap();
1456        assert_eq!(find(&tests, "roundtrip").id, "src/lock:tests:roundtrip");
1457    }
1458
1459    // [FR-017]
1460    #[test]
1461    fn rustのtest識別子はネストしたinline_modをスラッシュで連結する() {
1462        let source = r#"mod outer {
1463    mod inner {
1464        // [FR-001]
1465        #[test]
1466        fn t() {}
1467    }
1468}
1469"#;
1470        let tests = extract_rust_tests("src/x.rs", source, &[]).unwrap();
1471        assert_eq!(find(&tests, "t").id, "src/x:outer/inner:t");
1472    }
1473
1474    // [FR-016]
1475    #[test]
1476    fn rustは属性が間に挟まるとtestの直前のコメントをマーカーにしない() {
1477        let source = r#"// [FR-001]
1478#[cfg(feature = "x")]
1479#[test]
1480fn not_adjacent() {}
1481"#;
1482        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1483        assert!(find(&tests, "not_adjacent").markers.is_empty());
1484    }
1485
1486    // [FR-016]
1487    #[test]
1488    fn rustは他の属性の後ろでもtestの直前のコメントはマーカーになる() {
1489        let source = r#"#[cfg(feature = "x")]
1490// [FR-001]
1491#[test]
1492fn adjacent_after_other_attr() {}
1493"#;
1494        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1495        assert_eq!(
1496            find(&tests, "adjacent_after_other_attr").markers,
1497            vec!["FR-001".to_string()]
1498        );
1499    }
1500
1501    // [FR-016]
1502    #[test]
1503    fn rustはtestとfnの間にコメントがあってもtestとして検出しマーカーを採用する() {
1504        let source = r#"// [FR-001]
1505#[test]
1506// 補足
1507fn t() {}
1508"#;
1509        let tests = extract_rust_tests("src/auth.rs", source, &[]).unwrap();
1510        let found = find(&tests, "t");
1511        assert_eq!(found.markers, vec!["FR-001".to_string()]);
1512        assert!(!found.content.contains("補足"));
1513    }
1514
1515    // ---- C# ----
1516
1517    fn cs(source: &str) -> Vec<ExtractedTest> {
1518        extract_csharp_tests("tests/AuthTests.cs", source).unwrap()
1519    }
1520
1521    fn cs_wrap(method: &str) -> String {
1522        format!("public class AuthTests\n{{\n{method}\n}}\n")
1523    }
1524
1525    // [FR-049]
1526    #[test]
1527    fn csharpはif_endif内のtestメソッドも検出する() {
1528        let source = "public class A {\n#if DEBUG\n    // [FR-001]\n    [Fact]\n    public void T() { }\n#endif\n}\n";
1529        let tests = extract_csharp_tests("tests/A.cs", source).unwrap();
1530        assert_eq!(tests.len(), 1);
1531        assert_eq!(tests[0].id, "tests/A:A:T");
1532        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
1533    }
1534
1535    // [FR-049]
1536    #[test]
1537    fn csharpは7種類の属性をそれぞれtestとして検出する() {
1538        for attr in [
1539            "Fact",
1540            "Theory",
1541            "Test",
1542            "TestCase",
1543            "TestCaseSource",
1544            "TestMethod",
1545            "DataTestMethod",
1546        ] {
1547            let source = cs_wrap(&format!("    [{attr}]\n    public void T() {{ }}"));
1548            let tests = cs(&source);
1549            assert_eq!(tests.len(), 1, "attribute {attr} should be detected");
1550            assert_eq!(tests[0].name, "T");
1551        }
1552    }
1553
1554    // [FR-049]
1555    #[test]
1556    fn csharpはattribute接尾辞付きや無関係な属性のみのメソッドを検出しない() {
1557        let source = cs_wrap(
1558            "    [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() { }",
1559        );
1560        assert!(cs(&source).is_empty());
1561    }
1562
1563    // [FR-049]
1564    #[test]
1565    fn csharpは引数付きの属性も検出する() {
1566        let source = cs_wrap("    [Fact(Skip = \"x\")]\n    public void T() { }");
1567        assert_eq!(cs(&source).len(), 1);
1568    }
1569
1570    // [FR-049]
1571    #[test]
1572    fn csharpは名前空間修飾された属性も検出する() {
1573        let source = cs_wrap(
1574            "    [Xunit.Fact]\n    public void A() { }\n\n    [global::Xunit.Fact]\n    public void B() { }",
1575        );
1576        let tests = cs(&source);
1577        assert_eq!(tests.len(), 2);
1578        find(&tests, "A");
1579        find(&tests, "B");
1580    }
1581
1582    // [FR-049]
1583    #[test]
1584    fn csharpはtarget指定付きの属性も検出する() {
1585        let source = cs_wrap("    [method: Fact]\n    public void T() { }");
1586        assert_eq!(cs(&source).len(), 1);
1587    }
1588
1589    // [FR-049]
1590    #[test]
1591    fn csharpは同一属性リスト内の他の属性と並んだ属性も検出する() {
1592        let source = cs_wrap("    [Trait(\"a\", \"b\"), Fact]\n    public void T() { }");
1593        assert_eq!(cs(&source).len(), 1);
1594    }
1595
1596    // [FR-049]
1597    #[test]
1598    fn csharpは複数の属性リストの順序に関わらず検出する() {
1599        let source = cs_wrap(
1600            "    [Theory]\n    [InlineData(1)]\n    public void A(int x) { }\n\n    [InlineData(1)]\n    [Theory]\n    public void B(int x) { }",
1601        );
1602        let tests = cs(&source);
1603        assert_eq!(tests.len(), 2);
1604        find(&tests, "A");
1605        find(&tests, "B");
1606    }
1607
1608    // [FR-050]
1609    #[test]
1610    fn csharpは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
1611        let source = cs_wrap(
1612            "    // [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() { }",
1613        );
1614        let tests = cs(&source);
1615        assert_eq!(find(&tests, "Adjacent").markers, vec!["FR-001".to_string()]);
1616        assert!(find(&tests, "Blank").markers.is_empty());
1617        assert!(find(&tests, "None").markers.is_empty());
1618    }
1619
1620    // [FR-050]
1621    #[test]
1622    fn csharpはドキュメントコメントとブロックコメントもマーカーにできる() {
1623        let source = cs_wrap(
1624            "    /// [FR-001]\n    [Fact]\n    public void Doc() { }\n\n    /* [FR-002] */\n    [Fact]\n    public void Block() { }",
1625        );
1626        let tests = cs(&source);
1627        assert_eq!(find(&tests, "Doc").markers, vec!["FR-001".to_string()]);
1628        assert_eq!(find(&tests, "Block").markers, vec!["FR-002".to_string()]);
1629    }
1630
1631    // [FR-050]
1632    #[test]
1633    fn csharpは属性と署名の間のコメントをマーカーにせずcontentにも含めない() {
1634        let source = cs_wrap(
1635            "    [Fact]\n    // [FR-001]\n    public void T() { }\n\n    [Fact]\n    [Trait(\"a\", \"b\")]\n    // 補足\n    public void U() { }",
1636        );
1637        let tests = cs(&source);
1638        let t = find(&tests, "T");
1639        assert!(t.markers.is_empty());
1640        assert!(!t.content.contains("FR-001"));
1641        let u = find(&tests, "U");
1642        assert!(!u.content.contains("補足"));
1643    }
1644
1645    // [FR-050]
1646    #[test]
1647    fn csharpは属性リストの間のコメントをマーカーにせずcontentにも含めない() {
1648        let source = cs_wrap(
1649            "    [Theory]\n    // [FR-001]\n    [InlineData(1)]\n    public void T(int x) { }",
1650        );
1651        let tests = cs(&source);
1652        let t = find(&tests, "T");
1653        assert!(t.markers.is_empty());
1654        assert!(!t.content.contains("FR-001"));
1655    }
1656
1657    // [FR-051]
1658    #[test]
1659    fn csharpのtest識別子はトップレベルclassならファイルパスと型名とメソッド名になる() {
1660        let source =
1661            "public class AuthTests\n{\n    [Fact]\n    public void RejectsShort() { }\n}\n";
1662        let tests = cs(source);
1663        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:RejectsShort");
1664    }
1665
1666    // [FR-051]
1667    #[test]
1668    fn csharpのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
1669        let source = "public class Outer\n{\n    public class Inner\n    {\n        [Test]\n        public void T3() { }\n    }\n}\n";
1670        let tests = cs(source);
1671        assert_eq!(tests[0].id, "tests/AuthTests:Outer/Inner:T3");
1672    }
1673
1674    // [FR-051]
1675    #[test]
1676    fn csharpのtest識別子はstruct_record_interfaceの中でも型名を辿る() {
1677        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";
1678        let tests = cs(source);
1679        assert_eq!(find(&tests, "A").id, "tests/AuthTests:S:A");
1680        assert_eq!(find(&tests, "B").id, "tests/AuthTests:R:B");
1681        assert_eq!(find(&tests, "C").id, "tests/AuthTests:I:C");
1682    }
1683
1684    // [FR-051]
1685    #[test]
1686    fn csharpのtest識別子はblock_namespaceを含まない() {
1687        let source = "namespace Foo.Bar\n{\n    public class AuthTests\n    {\n        [Fact]\n        public void T() { }\n    }\n}\n";
1688        let tests = cs(source);
1689        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:T");
1690    }
1691
1692    // [FR-051]
1693    #[test]
1694    fn csharpのtest識別子はfile_scoped_namespaceを含まない() {
1695        let source =
1696            "namespace Foo;\n\npublic class AuthTests\n{\n    [Fact]\n    public void T() { }\n}\n";
1697        let tests = cs(source);
1698        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:T");
1699    }
1700
1701    // [FR-052]
1702    #[test]
1703    fn csharpのcontentは属性を含まず修飾子から始まる() {
1704        let source = cs_wrap(
1705            "    // [FR-001]\n    [Fact]\n    [Trait(\"a\", \"b\")]\n    public void T() { Assert.True(true); }",
1706        );
1707        let tests = cs(&source);
1708        let c = &tests[0].content;
1709        assert!(c.starts_with("public void T()"), "content = {c:?}");
1710        assert!(c.ends_with('}'));
1711        assert!(!c.contains("Fact"));
1712        assert!(!c.contains("Trait"));
1713        assert!(!c.contains("FR-001"));
1714    }
1715
1716    // [FR-052]
1717    #[test]
1718    fn csharpの本体変更はcontentに反映される() {
1719        let a = cs(&cs_wrap(
1720            "    [Fact]\n    public void T() { Assert.True(true); }",
1721        ));
1722        let b = cs(&cs_wrap(
1723            "    [Fact]\n    public void T() { Assert.True(false); }",
1724        ));
1725        assert_ne!(a[0].content, b[0].content);
1726    }
1727
1728    // [FR-052]
1729    #[test]
1730    fn csharpの属性だけの変更はcontentを変えない() {
1731        let a = cs(&cs_wrap(
1732            "    [Fact]\n    public void T() { Assert.True(true); }",
1733        ));
1734        let b = cs(&cs_wrap(
1735            "    [Fact(Skip = \"x\")]\n    [Trait(\"a\", \"b\")]\n    public void T() { Assert.True(true); }",
1736        ));
1737        assert_eq!(a[0].content, b[0].content);
1738    }
1739
1740    // [FR-014]
1741    #[test]
1742    fn csharpは構文エラーのソースでエラーを返す() {
1743        let source = "public class A {\n    [Fact]\n    public void T( {\n}\n";
1744        assert!(extract_csharp_tests("tests/A.cs", source).is_err());
1745    }
1746
1747    // ---- TypeScript / JavaScript ----
1748
1749    fn ts(source: &str) -> Vec<ExtractedTest> {
1750        extract_typescript_tests("src/a.test.ts", source).unwrap()
1751    }
1752
1753    // [FR-054]
1754    #[test]
1755    fn typescriptはtestとitの呼び出しを検出する() {
1756        let tests = ts("test(\"a\", () => {});\nit(\"b\", () => {});\n");
1757        assert_eq!(tests.len(), 2);
1758        find(&tests, "a");
1759        find(&tests, "b");
1760    }
1761
1762    // [FR-054]
1763    #[test]
1764    fn typescriptは許可された修飾子付きの呼び出しを検出する() {
1765        let source = "it.skip(\"a\", () => {});\ntest.only(\"b\", () => {});\ntest.concurrent.only(\"c\", () => {});\ntest.todo(\"d\", () => {});\n";
1766        let tests = ts(source);
1767        assert_eq!(tests.len(), 4);
1768        for n in ["a", "b", "c", "d"] {
1769            find(&tests, n);
1770        }
1771    }
1772
1773    // [FR-054]
1774    #[test]
1775    fn typescriptはdescribe_skipの中のtestも検出する() {
1776        let tests = ts("describe.skip(\"d\", () => {\n  it(\"a\", () => {});\n});\n");
1777        assert_eq!(tests.len(), 1);
1778        assert_eq!(tests[0].id, "src/a.test:d:a");
1779    }
1780
1781    // [FR-054]
1782    #[test]
1783    fn typescriptはfunction式のコールバックも検出する() {
1784        let source = "describe(\"d\", function () {\n  it(\"a\", function () {});\n});\n";
1785        let tests = ts(source);
1786        assert_eq!(tests.len(), 1);
1787        assert_eq!(tests[0].id, "src/a.test:d:a");
1788    }
1789
1790    // [FR-053]
1791    #[test]
1792    fn typescriptはtsxファイルのjsxを含むソースも解析できる() {
1793        let source = "it(\"renders\", () => {\n  const x = <div className=\"a\">hi</div>;\n  expect(x).toBeTruthy();\n});\n";
1794        let tests = extract_typescript_tests("src/a.test.tsx", source).unwrap();
1795        assert_eq!(tests.len(), 1);
1796        assert_eq!(tests[0].id, "src/a.test::renders");
1797    }
1798
1799    // [FR-054]
1800    #[test]
1801    fn typescriptは許可外の修飾子や呼び出し結果の呼び出しを検出しない() {
1802        let source = r#"test.each([1, 2])("each %i", (n) => {});
1803test.skipIf(c)("skipif", () => {});
1804test.extend({ a: 1 });
1805test.describe("desc", () => {});
1806test.runIf(c)("runif", () => {});
1807it?.skip("optional", () => {});
1808"#;
1809        assert!(ts(source).is_empty());
1810    }
1811
1812    // [FR-054]
1813    #[test]
1814    fn typescriptはexport_defaultやskipif_describeやforループ内のitを検出しない() {
1815        let source = r#"export default describe("d", () => { it("y", () => {}); })
1816describe.skipIf(c)("d", () => { it("x", () => {}); });
1817for (const n of [1]) {
1818  it("loop", () => {});
1819}
1820"#;
1821        assert!(ts(source).is_empty());
1822    }
1823
1824    // [FR-054]
1825    #[test]
1826    fn typescriptは許可された修飾子を検出し走査する() {
1827        let source = r#"test.fails("f", () => {});
1828it.sequential("s", () => {});
1829describe.shuffle("d", () => {
1830  it("inner", () => {});
1831});
1832"#;
1833        let tests = ts(source);
1834        assert_eq!(tests.len(), 3);
1835        find(&tests, "f");
1836        find(&tests, "s");
1837        assert_eq!(find(&tests, "inner").id, "src/a.test:d:inner");
1838    }
1839
1840    // [FR-054]
1841    #[test]
1842    fn typescriptはdescribe_eachの中のtestを検出しない() {
1843        let source = "describe.each([1])(\"d %i\", (n) => {\n  it(\"a\", () => {});\n});\n";
1844        assert!(ts(source).is_empty());
1845    }
1846
1847    // [FR-054]
1848    #[test]
1849    fn typescriptはawaitなど式文直下でない呼び出しを検出しない() {
1850        let source = "await it(\"a\", () => {});\nvoid it(\"b\", () => {});\n";
1851        assert!(ts(source).is_empty());
1852    }
1853
1854    // [FR-054]
1855    #[test]
1856    fn typescriptは第1引数が文字列リテラルでない呼び出しを検出しない() {
1857        let source = "const name = \"x\";\nit(name, () => {});\nit(\"a\" + \"b\", () => {});\n";
1858        assert!(ts(source).is_empty());
1859    }
1860
1861    // [FR-054]
1862    #[test]
1863    fn typescriptはhelper関数やtest本体やif文の中のitを検出しない() {
1864        let source = r#"function helper() {
1865  it("in helper", () => {});
1866}
1867test("outer", () => {
1868  it("in test", () => {});
1869});
1870if (cond) {
1871  it("in if", () => {});
1872}
1873"#;
1874        let tests = ts(source);
1875        assert_eq!(tests.len(), 1);
1876        assert_eq!(tests[0].name, "outer");
1877    }
1878
1879    // [FR-056]
1880    #[test]
1881    fn typescriptは置換なしのテンプレート文字列を対象にし置換ありを無視する() {
1882        let source = "it(`plain`, () => {});\nit(`with ${x}`, () => {});\n";
1883        let tests = ts(source);
1884        assert_eq!(tests.len(), 1);
1885        assert_eq!(tests[0].name, "plain");
1886    }
1887
1888    // [FR-056]
1889    #[test]
1890    fn typescriptは空文字名と改行を含む名前を無視する() {
1891        let source = "it(\"\", () => {});\nit(`line1\nline2`, () => {});\nit(\"ok\", () => {});\n";
1892        let tests = ts(source);
1893        assert_eq!(tests.len(), 1);
1894        assert_eq!(tests[0].name, "ok");
1895    }
1896
1897    // [FR-056]
1898    #[test]
1899    fn typescriptは行区切り文字を含む名前を無視する() {
1900        let source = "it(\"a\u{2028}b\", () => {});\nit(\"ok\", () => {});\n";
1901        let tests = ts(source);
1902        assert_eq!(tests.len(), 1);
1903        assert_eq!(tests[0].name, "ok");
1904    }
1905
1906    // [FR-056]
1907    #[test]
1908    fn typescriptはエスケープを解釈せずソース表記のまま名前にする() {
1909        let source = "it('it\\'s', () => {});\n";
1910        let tests = ts(source);
1911        assert_eq!(tests.len(), 1);
1912        assert_eq!(tests[0].name, "it\\'s");
1913        assert_eq!(tests[0].id, "src/a.test::it\\'s");
1914    }
1915
1916    // [FR-056]
1917    #[test]
1918    fn typescriptのtest識別子はdescribeの外なら空のdescribeパスになる() {
1919        let tests = ts("it(\"top\", () => {});\n");
1920        assert_eq!(tests[0].id, "src/a.test::top");
1921    }
1922
1923    // [FR-056]
1924    #[test]
1925    fn typescriptのtest識別子はdescribeを外側から内側へスラッシュで連結する() {
1926        let source = "describe(\"auth\", () => {\n  it(\"one\", () => {});\n  describe(\"inner\", () => {\n    it(\"two\", () => {});\n  });\n});\n";
1927        let tests = ts(source);
1928        assert_eq!(find(&tests, "one").id, "src/a.test:auth:one");
1929        assert_eq!(find(&tests, "two").id, "src/a.test:auth/inner:two");
1930    }
1931
1932    // [FR-056]
1933    #[test]
1934    fn typescriptのtest識別子は対象拡張子をすべて除去しtestやspecは残す() {
1935        let src = "it(\"t\", () => {});\n";
1936        for (path, id) in [
1937            ("src/a.test.ts", "src/a.test::t"),
1938            ("src/a.spec.tsx", "src/a.spec::t"),
1939            ("src/a.test.mjs", "src/a.test::t"),
1940            ("src/a.spec.cts", "src/a.spec::t"),
1941            ("src/a.test.jsx", "src/a.test::t"),
1942            ("src/a.test.js", "src/a.test::t"),
1943            ("src/a.spec.cjs", "src/a.spec::t"),
1944            ("src/a.test.mts", "src/a.test::t"),
1945        ] {
1946            let tests = extract_typescript_tests(path, src).unwrap();
1947            assert_eq!(tests[0].id, id, "{path}");
1948        }
1949    }
1950
1951    // [FR-056]
1952    #[test]
1953    fn typescriptのtest識別子は対象外の拡張子を除去しない() {
1954        let tests =
1955            extract_typescript_tests("src/a.test.ts.bak", "it(\"t\", () => {});\n").unwrap();
1956        assert_eq!(tests[0].id, "src/a.test.ts.bak::t");
1957    }
1958
1959    // [FR-053]
1960    #[test]
1961    fn typescriptはjs系拡張子のファイルからtestを検出する() {
1962        for path in [
1963            "src/a.test.js",
1964            "src/a.spec.mjs",
1965            "src/a.test.cjs",
1966            "src/a.spec.mts",
1967            "src/a.test.cts",
1968        ] {
1969            let tests = extract_typescript_tests(path, "it(\"t\", () => {});\n").unwrap();
1970            assert_eq!(tests.len(), 1, "{path}");
1971        }
1972    }
1973
1974    // [FR-053]
1975    #[test]
1976    fn typescriptはts系拡張子でtsx文法が拒否する角括弧キャストを解析できる() {
1977        let source = "it(\"t\", () => {\n  const n = <number>x;\n});\n";
1978        for path in ["src/a.test.ts", "src/a.test.mts", "src/a.spec.cts"] {
1979            let tests = extract_typescript_tests(path, source).unwrap();
1980            assert_eq!(tests.len(), 1, "{path}");
1981        }
1982    }
1983
1984    // [FR-053]
1985    #[test]
1986    fn typescriptはjsxとjsファイルのjsxを含むソースも解析できる() {
1987        let source = "it(\"renders\", () => {\n  render(<App />);\n});\n";
1988        for path in ["src/a.test.jsx", "src/a.test.js"] {
1989            let tests = extract_typescript_tests(path, source).unwrap();
1990            assert_eq!(tests.len(), 1, "{path}");
1991            assert_eq!(tests[0].id, "src/a.test::renders", "{path}");
1992        }
1993    }
1994
1995    // [FR-055]
1996    #[test]
1997    fn typescriptは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
1998        let source = "// [FR-001]\nit(\"adjacent\", () => {});\n\n// [FR-002]\n\nit(\"blank\", () => {});\n\nit(\"none\", () => {});\n";
1999        let tests = ts(source);
2000        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2001        assert!(find(&tests, "blank").markers.is_empty());
2002        assert!(find(&tests, "none").markers.is_empty());
2003    }
2004
2005    // [FR-055]
2006    #[test]
2007    fn typescriptはdescribe内のtestでも直前のコメントをマーカーにする() {
2008        let source = "describe(\"d\", () => {\n  // [FR-001]\n  it(\"a\", () => {});\n});\n";
2009        let tests = ts(source);
2010        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
2011    }
2012
2013    // [FR-003]
2014    #[test]
2015    fn typescriptのcontentは呼び出し文全体でマーカーコメントを含まない() {
2016        let source = "// [FR-001]\nit(\"a\", () => {\n  expect(1).toBe(1);\n});\n";
2017        let tests = ts(source);
2018        let c = &tests[0].content;
2019        assert!(c.starts_with("it(\"a\""), "content = {c:?}");
2020        assert!(c.ends_with(");"), "content = {c:?}");
2021        assert!(!c.contains("FR-001"));
2022    }
2023
2024    // [FR-003]
2025    #[test]
2026    fn typescriptの本体変更はcontentに反映される() {
2027        let a = ts("it(\"a\", () => { expect(1).toBe(1); });\n");
2028        let b = ts("it(\"a\", () => { expect(1).toBe(2); });\n");
2029        assert_ne!(a[0].content, b[0].content);
2030    }
2031
2032    // [FR-014]
2033    #[test]
2034    fn typescriptは構文エラーのソースでエラーを返す() {
2035        let source = "it(\"a\", () => {\n";
2036        assert!(extract_typescript_tests("src/a.test.ts", source).is_err());
2037    }
2038
2039    // ---- Java ----
2040
2041    fn jv(source: &str) -> Vec<ExtractedTest> {
2042        extract_java_tests("tests/AuthTests.java", source).unwrap()
2043    }
2044
2045    fn jv_wrap(body: &str) -> String {
2046        format!("public class AuthTests {{\n{body}\n}}\n")
2047    }
2048
2049    // [FR-059]
2050    #[test]
2051    fn javaは5種類の注釈をそれぞれtestとして検出する() {
2052        for ann in [
2053            "Test",
2054            "ParameterizedTest",
2055            "RepeatedTest",
2056            "TestFactory",
2057            "TestTemplate",
2058        ] {
2059            let source = jv_wrap(&format!("    @{ann}\n    void t() {{ }}"));
2060            let tests = jv(&source);
2061            assert_eq!(tests.len(), 1, "annotation {ann} should be detected");
2062            assert_eq!(tests[0].name, "t");
2063        }
2064    }
2065
2066    // [FR-059]
2067    #[test]
2068    fn javaは修飾名の注釈と引数付きの注釈も検出する() {
2069        let source = jv_wrap(
2070            "    @org.junit.jupiter.api.Test\n    void a() { }\n\n    @Test(timeout = 1)\n    void b() { }",
2071        );
2072        let tests = jv(&source);
2073        assert_eq!(tests.len(), 2);
2074        find(&tests, "a");
2075        find(&tests, "b");
2076    }
2077
2078    // [FR-059]
2079    #[test]
2080    fn javaは無関係な注釈のみのメソッドを検出しない() {
2081        let source = jv_wrap(
2082            "    @Disabled\n    void a() { }\n\n    void b() { }\n\n    @Tag(\"x\")\n    void c() { }",
2083        );
2084        assert!(jv(&source).is_empty());
2085    }
2086
2087    // [FR-059]
2088    #[test]
2089    fn javaは修飾子の後ろに書かれた注釈も検出する() {
2090        let source = jv_wrap("    public @Test void t() { }");
2091        let tests = jv(&source);
2092        assert_eq!(tests.len(), 1);
2093        assert!(tests[0].content.contains("@Test"), "{:?}", tests[0].content);
2094    }
2095
2096    // [FR-060]
2097    #[test]
2098    fn javaは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
2099        let source = jv_wrap(
2100            "    // [FR-001]\n    @Test\n    void adjacent() { }\n\n    // [FR-002]\n\n    @Test\n    void blank() { }\n\n    @Test\n    void none() { }",
2101        );
2102        let tests = jv(&source);
2103        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2104        assert!(find(&tests, "blank").markers.is_empty());
2105        assert!(find(&tests, "none").markers.is_empty());
2106    }
2107
2108    // [FR-060]
2109    #[test]
2110    fn javaはブロックコメントをマーカーにできる() {
2111        let source = jv_wrap("    /* [FR-002] */\n    @Test\n    void t() { }");
2112        assert_eq!(jv(&source)[0].markers, vec!["FR-002".to_string()]);
2113    }
2114
2115    // [FR-060]
2116    #[test]
2117    fn javaはjavadocコメントをマーカーにしない() {
2118        let source = jv_wrap("    /** [FR-001] */\n    @Test\n    void t() { }");
2119        assert!(jv(&source)[0].markers.is_empty());
2120    }
2121
2122    // [FR-060]
2123    #[test]
2124    fn javaは注釈と署名の間のコメントをマーカーにせずcontentにも含めない() {
2125        let source = jv_wrap("    @Test\n    // [FR-001]\n    // 補足\n    void t() { }");
2126        let tests = jv(&source);
2127        assert!(tests[0].markers.is_empty());
2128        assert!(!tests[0].content.contains("FR-001"));
2129        assert!(!tests[0].content.contains("補足"));
2130    }
2131
2132    // [FR-061]
2133    #[test]
2134    fn javaのtest識別子はclassならファイルパスと型名とメソッド名になる() {
2135        let tests = jv(&jv_wrap("    @Test\n    void rejectsShort() { }"));
2136        assert_eq!(tests[0].id, "tests/AuthTests:AuthTests:rejectsShort");
2137    }
2138
2139    // [FR-061]
2140    #[test]
2141    fn javaのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
2142        let source = "public class Outer {\n    @Nested\n    class Inner {\n        @Test\n        void t() { }\n    }\n}\n";
2143        let tests = jv(source);
2144        assert_eq!(tests[0].id, "tests/AuthTests:Outer/Inner:t");
2145    }
2146
2147    // [FR-061]
2148    #[test]
2149    fn javaのtest識別子はrecord_interface_enumの中でも型名を辿る() {
2150        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";
2151        let tests = jv(source);
2152        assert_eq!(find(&tests, "a").id, "tests/AuthTests:R:a");
2153        assert_eq!(find(&tests, "b").id, "tests/AuthTests:I:b");
2154        assert_eq!(find(&tests, "c").id, "tests/AuthTests:E:c");
2155    }
2156
2157    // [FR-061]
2158    #[test]
2159    fn javaのtest識別子はpackageを含まない() {
2160        let source =
2161            "package com.example;\n\npublic class AuthTests {\n    @Test\n    void t() { }\n}\n";
2162        assert_eq!(jv(source)[0].id, "tests/AuthTests:AuthTests:t");
2163    }
2164
2165    // [FR-059]
2166    #[test]
2167    fn javaはトップレベルメソッドを対象外にする() {
2168        let source = "@Test\nvoid t() { }\n";
2169        let tests = extract_java_tests("tests/AuthTests.java", source).unwrap();
2170        assert!(tests.is_empty());
2171        // a class alongside is still extracted
2172        let source = "class A {\n    @Test\n    void u() { }\n}\n";
2173        assert_eq!(jv(source).len(), 1);
2174    }
2175
2176    // [FR-059]
2177    #[test]
2178    fn javaは匿名クラスとenum定数本体の中を対象外にする() {
2179        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";
2180        let tests = jv(source);
2181        let names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2182        assert_eq!(names, vec!["real", "outside"], "{names:?}");
2183    }
2184
2185    // [FR-062]
2186    #[test]
2187    fn javaのcontentは注釈を含まず修飾子から始まる() {
2188        let source = jv_wrap(
2189            "    // [FR-001]\n    @Test\n    @Tag(\"a\")\n    public void t() { assertTrue(true); }",
2190        );
2191        let c = &jv(&source)[0].content;
2192        assert!(c.starts_with("public void t()"), "content = {c:?}");
2193        assert!(c.ends_with('}'));
2194        assert!(!c.contains("@Test"));
2195        assert!(!c.contains("@Tag"));
2196        assert!(!c.contains("FR-001"));
2197    }
2198
2199    // [FR-062]
2200    #[test]
2201    fn javaのcontentは型パラメータ付きでも修飾子から始まる() {
2202        let source = jv_wrap("    @Test\n    static <T> void t() { }");
2203        let c = &jv(&source)[0].content;
2204        assert!(c.starts_with("static <T> void t()"), "content = {c:?}");
2205    }
2206
2207    // [FR-062]
2208    #[test]
2209    fn javaのcontentは修飾子なしなら型パラメータから始まる() {
2210        let source = jv_wrap("    @Test\n    <T> void t() { }");
2211        let c = &jv(&source)[0].content;
2212        assert!(c.starts_with("<T> void t()"), "content = {c:?}");
2213    }
2214
2215    // [FR-003]
2216    #[test]
2217    fn javaの本体変更はcontentに反映される() {
2218        let a = jv(&jv_wrap("    @Test\n    void t() { assertTrue(true); }"));
2219        let b = jv(&jv_wrap("    @Test\n    void t() { assertTrue(false); }"));
2220        assert_ne!(a[0].content, b[0].content);
2221    }
2222
2223    // [FR-062]
2224    #[test]
2225    fn javaの注釈だけの変更はcontentを変えない() {
2226        let a = jv(&jv_wrap("    @Test\n    void t() { assertTrue(true); }"));
2227        let b = jv(&jv_wrap(
2228            "    @Test(timeout = 1)\n    @Tag(\"a\")\n    void t() { assertTrue(true); }",
2229        ));
2230        assert_eq!(a[0].content, b[0].content);
2231    }
2232
2233    // [FR-014]
2234    #[test]
2235    fn javaは構文エラーのソースでエラーを返す() {
2236        let source = "public class A {\n    @Test\n    void t( {\n}\n";
2237        assert!(extract_java_tests("tests/A.java", source).is_err());
2238    }
2239
2240    // ---- Kotlin ----
2241
2242    fn kt(source: &str) -> Vec<ExtractedTest> {
2243        extract_kotlin_tests("tests/AuthTest.kt", source).unwrap()
2244    }
2245
2246    fn kt_wrap(body: &str) -> String {
2247        format!("class AuthTest {{\n{body}\n}}\n")
2248    }
2249
2250    // [FR-059]
2251    #[test]
2252    fn kotlinは5種類の注釈をそれぞれtestとして検出する() {
2253        for ann in [
2254            "Test",
2255            "ParameterizedTest",
2256            "RepeatedTest",
2257            "TestFactory",
2258            "TestTemplate",
2259        ] {
2260            let source = kt_wrap(&format!("    @{ann}\n    fun t() {{ }}"));
2261            let tests = kt(&source);
2262            assert_eq!(tests.len(), 1, "annotation {ann} should be detected");
2263            assert_eq!(tests[0].name, "t");
2264        }
2265    }
2266
2267    // [FR-059]
2268    #[test]
2269    fn kotlinは修飾名と複数注釈リストとuse_site_target付きの注釈も検出する() {
2270        let source = kt_wrap(
2271            "    @org.junit.jupiter.api.Test\n    fun a() { }\n\n    @[Test Tag(\"a\")]\n    fun b() { }\n\n    @get:Test\n    fun c() { }",
2272        );
2273        let tests = kt(&source);
2274        assert_eq!(tests.len(), 3, "{tests:?}");
2275        find(&tests, "a");
2276        find(&tests, "b");
2277        find(&tests, "c");
2278    }
2279
2280    // [FR-059]
2281    #[test]
2282    fn kotlinは無関係な注釈のみの関数を検出しない() {
2283        let source = kt_wrap("    @Disabled\n    fun a() { }\n\n    fun b() { }");
2284        assert!(kt(&source).is_empty());
2285    }
2286
2287    // [FR-060]
2288    #[test]
2289    fn kotlinは直前に隣接するコメントをマーカーとして抽出し空行を挟むと無視する() {
2290        let source = kt_wrap(
2291            "    // [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() { }",
2292        );
2293        let tests = kt(&source);
2294        assert_eq!(find(&tests, "adjacent").markers, vec!["FR-001".to_string()]);
2295        assert!(find(&tests, "blank").markers.is_empty());
2296        assert_eq!(find(&tests, "block").markers, vec!["FR-003".to_string()]);
2297    }
2298
2299    // [FR-060]
2300    #[test]
2301    fn kotlinはkdocコメントをマーカーにしない() {
2302        let source = kt_wrap("    /** [Foo] */\n    @Test\n    fun t() { }");
2303        assert!(kt(&source)[0].markers.is_empty());
2304    }
2305
2306    // [FR-060]
2307    #[test]
2308    fn kotlinは注釈と署名の間のコメントをマーカーにせずcontentにも含めない() {
2309        let source = kt_wrap(
2310            "    @Test // [FR-001]\n    fun a() { }\n\n    @Test\n    // [FR-002]\n    fun b() { }",
2311        );
2312        let tests = kt(&source);
2313        for name in ["a", "b"] {
2314            let t = find(&tests, name);
2315            assert!(t.markers.is_empty(), "{name}: {:?}", t.markers);
2316            assert!(!t.content.contains("FR-00"), "{name}: {:?}", t.content);
2317        }
2318    }
2319
2320    // [FR-061]
2321    #[test]
2322    fn kotlinのバッククォート名はバッククォートを除いた名前になる() {
2323        let source = kt_wrap("    @Test\n    fun `一覧 取得 は 200`() { }");
2324        let tests = kt(&source);
2325        assert_eq!(tests[0].name, "一覧 取得 は 200");
2326        assert_eq!(tests[0].id, "tests/AuthTest:AuthTest:一覧 取得 は 200");
2327    }
2328
2329    // [FR-061]
2330    #[test]
2331    fn kotlinのtest識別子はネストした型を外側から内側へスラッシュで連結する() {
2332        let source = "class AuthTest {\n    @Nested\n    inner class Inner {\n        @Test\n        fun t() { }\n    }\n}\n";
2333        assert_eq!(kt(source)[0].id, "tests/AuthTest:AuthTest/Inner:t");
2334    }
2335
2336    // [FR-061]
2337    #[test]
2338    fn kotlinのtest識別子はobjectとcompanion_objectを辿る() {
2339        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";
2340        // two companion objects in one class is invalid Kotlin semantically but
2341        // syntactically fine; the grammar does not reject it.
2342        let tests = kt(source);
2343        assert_eq!(find(&tests, "a").id, "tests/AuthTest:O:a");
2344        assert_eq!(find(&tests, "b").id, "tests/AuthTest:C/Companion:b");
2345        assert_eq!(find(&tests, "c").id, "tests/AuthTest:C/Factory:c");
2346    }
2347
2348    // [FR-061]
2349    #[test]
2350    fn kotlinのトップレベル関数は型部分が空の識別子になる() {
2351        let source = "@Test\nfun top() { }\n";
2352        let tests = kt(source);
2353        assert_eq!(tests.len(), 1);
2354        assert_eq!(tests[0].id, "tests/AuthTest::top");
2355    }
2356
2357    // [FR-061]
2358    #[test]
2359    fn kotlinのtest識別子はpackageを含まない() {
2360        let source = "package com.example\n\nclass AuthTest {\n    @Test\n    fun t() { }\n}\n";
2361        assert_eq!(kt(source)[0].id, "tests/AuthTest:AuthTest:t");
2362    }
2363
2364    // [FR-061]
2365    #[test]
2366    fn kotlinはコンストラクタ注釈付きの主コンストラクタを持つクラスでもtestを取れる() {
2367        let source = "@SpringBootTest\nclass X @Autowired constructor(private val a: Int) : Base(a) {\n    @Test\n    fun `日本語名`() { }\n}\n";
2368        let tests = kt(source);
2369        assert_eq!(tests.len(), 1, "{tests:?}");
2370        assert_eq!(tests[0].id, "tests/AuthTest:X:日本語名");
2371    }
2372
2373    // [FR-059]
2374    #[test]
2375    fn kotlinは関数本体内のローカル関数や匿名objectやenum定数本体やgetterを対象外にする() {
2376        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";
2377        let tests = kt(source);
2378        let names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2379        assert_eq!(names, vec!["real", "outside"], "{names:?}");
2380    }
2381
2382    // [FR-063]
2383    #[test]
2384    fn kotlinは漢字の関数名と型名のtestを抽出し識別子とcontentが元の文字を保つ() {
2385        let source = "class AuthTest {\n    @Nested\n    inner class 一覧取得 {\n        @Test\n        fun 取得() { println(\"漢字\") }\n    }\n}\n";
2386        let tests = kt(source);
2387        assert_eq!(tests.len(), 1, "{tests:?}");
2388        assert_eq!(tests[0].id, "tests/AuthTest:AuthTest/一覧取得:取得");
2389        assert_eq!(tests[0].name, "取得");
2390        assert!(tests[0].content.contains("取得"));
2391        assert!(tests[0].content.contains("漢字"));
2392        assert!(!tests[0].content.contains('x'), "{:?}", tests[0].content);
2393    }
2394
2395    // [FR-063]
2396    #[test]
2397    fn kotlinはカタカナとハングルの関数名も抽出する() {
2398        let source = kt_wrap("    @Test\n    fun テスト() { }\n\n    @Test\n    fun 테스트() { }");
2399        let tests = kt(&source);
2400        assert_eq!(tests.len(), 2, "{tests:?}");
2401        find(&tests, "テスト");
2402        find(&tests, "테스트");
2403    }
2404
2405    // [FR-063]
2406    #[test]
2407    fn kotlinは非asciiの文字リテラルを含むファイルも解析できる() {
2408        let source =
2409            kt_wrap("    @Test\n    fun t() {\n        val c = 'あ'\n        val d = '漢'\n    }");
2410        let tests = kt(&source);
2411        assert_eq!(tests.len(), 1);
2412        assert!(tests[0].content.contains("'あ'"));
2413    }
2414
2415    // [FR-063]
2416    #[test]
2417    fn kotlinはbom付きファイルも解析できる() {
2418        let source = format!("\u{feff}{}", kt_wrap("    @Test\n    fun t() { }"));
2419        let tests = kt(&source);
2420        assert_eq!(tests.len(), 1);
2421        assert_eq!(tests[0].name, "t");
2422    }
2423
2424    // [FR-062]
2425    #[test]
2426    fn kotlinのcontentは注釈を含まず修飾子から始まる() {
2427        let source = kt_wrap(
2428            "    // [FR-001]\n    @Test\n    internal suspend fun t() { assertTrue(true) }",
2429        );
2430        let c = &kt(&source)[0].content;
2431        assert!(c.starts_with("internal suspend fun t()"), "content = {c:?}");
2432        assert!(c.ends_with('}'));
2433        assert!(!c.contains("@Test"));
2434        assert!(!c.contains("FR-001"));
2435    }
2436
2437    // [FR-062]
2438    #[test]
2439    fn kotlinのcontentは注釈のみならfunから始まる() {
2440        let source = kt_wrap("    @Test\n    @Tag(\"a\")\n    fun t() { }");
2441        let c = &kt(&source)[0].content;
2442        assert!(c.starts_with("fun t()"), "content = {c:?}");
2443    }
2444
2445    // [FR-062]
2446    #[test]
2447    fn kotlinの修飾子の後ろに書かれた注釈はcontentに含まれる() {
2448        let source = kt_wrap("    internal @Test fun t() { }");
2449        let tests = kt(&source);
2450        assert_eq!(tests.len(), 1);
2451        assert!(
2452            tests[0].content.starts_with("internal"),
2453            "{:?}",
2454            tests[0].content
2455        );
2456        assert!(tests[0].content.contains("@Test"), "{:?}", tests[0].content);
2457    }
2458
2459    // [FR-062]
2460    #[test]
2461    fn kotlinの式本体は終端までcontentに含まれる() {
2462        let source = kt_wrap("    @Test\n    fun t() = assertTrue(computeSomething())");
2463        let c = &kt(&source)[0].content;
2464        assert!(c.ends_with("computeSomething())"), "content = {c:?}");
2465    }
2466
2467    // [FR-003]
2468    #[test]
2469    fn kotlinの本体変更はcontentに反映される() {
2470        let a = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(true) }"));
2471        let b = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(false) }"));
2472        assert_ne!(a[0].content, b[0].content);
2473    }
2474
2475    // [FR-062]
2476    #[test]
2477    fn kotlinの注釈だけの変更はcontentを変えない() {
2478        let a = kt(&kt_wrap("    @Test\n    fun t() { assertTrue(true) }"));
2479        let b = kt(&kt_wrap(
2480            "    @Test\n    @Tag(\"a\")\n    fun t() { assertTrue(true) }",
2481        ));
2482        assert_eq!(a[0].content, b[0].content);
2483    }
2484
2485    // [FR-014]
2486    #[test]
2487    fn kotlinは構文エラーのソースでエラーを返す() {
2488        let source = "class A { fun x() {} }\n";
2489        assert!(extract_kotlin_tests("tests/A.kt", source).is_err());
2490    }
2491
2492    // [FR-060]
2493    #[test]
2494    fn kotlinのマーカーは元のソースから取り非asciiの角括弧は偽マーカーにならない() {
2495        let source = kt_wrap(
2496            "    // [正常系]\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() { }",
2497        );
2498        let tests = kt(&source);
2499        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2500        assert!(find(&tests, "u").markers.is_empty());
2501        assert!(find(&tests, "v").markers.is_empty());
2502    }
2503
2504    // [FR-060]
2505    #[test]
2506    fn kotlinはdocコメントを挟んでも手前のマーカーが有効になる() {
2507        let source = kt_wrap("    // [FR-001]\n    /** KDoc */\n    @Test\n    fun b() { }");
2508        assert_eq!(kt(&source)[0].markers, vec!["FR-001".to_string()]);
2509    }
2510
2511    // [FR-060]
2512    #[test]
2513    fn javaはdocコメントを挟んでも手前のマーカーが有効になる() {
2514        let source = jv_wrap("    // [FR-001]\n    /** Javadoc */\n    @Test\n    void b() { }");
2515        assert_eq!(jv(&source)[0].markers, vec!["FR-001".to_string()]);
2516    }
2517
2518    // [FR-060]
2519    #[test]
2520    fn javaは注釈同士の間のコメントをマーカーにせずcontentにも含めない() {
2521        let source =
2522            jv_wrap("    @Test\n    // [FR-001]\n    @Tag(\"a\")\n    // 補足\n    void t() { }");
2523        let t = &jv(&source)[0];
2524        assert!(t.markers.is_empty());
2525        assert!(!t.content.contains("FR-001"));
2526        assert!(!t.content.contains("補足"));
2527    }
2528
2529    // [FR-062]
2530    #[test]
2531    fn kotlinは注釈の間と直後のコメントをcontentに含めない() {
2532        let source = kt_wrap(
2533            "    @Test\n    // 間のメモ\n    @Tag(\"a\")\n    // 直後のメモ\n    fun t() { }",
2534        );
2535        let t = &kt(&source)[0];
2536        assert!(!t.content.contains("メモ"), "{:?}", t.content);
2537        assert!(t.content.starts_with("fun t()"), "{:?}", t.content);
2538    }
2539
2540    // [FR-060]
2541    #[test]
2542    fn kotlinは注釈同士の間のコメントをマーカーにしない() {
2543        let source = kt_wrap("    @Test\n    // [FR-001]\n    @Tag(\"a\")\n    fun t() { }");
2544        assert!(kt(&source)[0].markers.is_empty());
2545    }
2546
2547    // [FR-061]
2548    #[test]
2549    fn kotlinはdata_classとinterfaceの直下のtestも辿る() {
2550        let source = "data class D(val a: Int) {\n    @Test\n    fun a() { }\n}\n\ninterface I {\n    @Test\n    fun b() { }\n}\n";
2551        let tests = kt(source);
2552        assert_eq!(find(&tests, "a").id, "tests/AuthTest:D:a");
2553        assert_eq!(find(&tests, "b").id, "tests/AuthTest:I:b");
2554    }
2555
2556    // [FR-061]
2557    #[test]
2558    fn kotlinは制御文字や空を含む名前のtestを無視する() {
2559        let source =
2560            kt_wrap("    @Test\n    fun `tab\there`() { }\n\n    @Test\n    fun `ok name`() { }");
2561        let tests = kt(&source);
2562        assert_eq!(tests.len(), 1, "{tests:?}");
2563        assert_eq!(tests[0].name, "ok name");
2564    }
2565
2566    // [FR-063]
2567    #[test]
2568    fn kotlinは注釈2つで基底型なしのクラス内の注釈付きinner_classでもtestを取れる() {
2569        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class X {\n        // [FR-001]\n        @Test\n        fun t() { }\n    }\n}\n";
2570        let tests = kt(source);
2571        assert_eq!(tests.len(), 1, "{tests:?}");
2572        assert_eq!(tests[0].id, "tests/AuthTest:P/X:t");
2573        assert_eq!(tests[0].markers, vec!["FR-001".to_string()]);
2574    }
2575
2576    // [FR-063]
2577    #[test]
2578    fn kotlinはnestedを含む複数注釈クラスでも非asciiの名前を元のまま取る() {
2579        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class 一覧取得 {\n        @Test\n        fun `取得 する`() { }\n    }\n}\n";
2580        let tests = kt(source);
2581        assert_eq!(tests[0].id, "tests/AuthTest:P/一覧取得:取得 する");
2582    }
2583
2584    // [FR-063]
2585    #[test]
2586    fn kotlinは宣言以外のトップレベル構造やキーワードを含む文字列でもok空になる() {
2587        for source in [
2588            "package a\n// helper for class X\nval x = 1\n",
2589            "package a\nval fixture = object : Runnable {\n    override fun run() {}\n}\n",
2590            "package a\nval f = fun() { }\n",
2591            "package a\nconst val NAME = \"class\"\n",
2592        ] {
2593            let tests = kt(source);
2594            assert!(tests.is_empty(), "{source:?}: {tests:?}");
2595        }
2596    }
2597
2598    // [FR-063]
2599    #[test]
2600    fn kotlinは文法の不具合で宣言が認識されなかった場合に構文エラーを返す() {
2601        let source = "@A\n@C\nclass P {\n    @Nested\n    inner class X {\n    }\n}\n";
2602        assert!(kotlin_extract("tests/P.kt", source, true).is_ok());
2603        assert!(kotlin_extract("tests/P.kt", source, false).is_err());
2604    }
2605
2606    // [FR-063]
2607    #[test]
2608    fn kotlinはshebangやimportやtypealiasなど宣言以外の通常の構造でok空になる() {
2609        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";
2610        assert!(kt(source).is_empty());
2611    }
2612
2613    // ---- コメント隣接判定(FR-016 / FR-064) ----
2614
2615    // [FR-016]
2616    #[test]
2617    fn rustはdocコメントを挟んでもマーカーを取る() {
2618        let tests = rs("// [FR-001]\n/// doc\n#[test]\nfn t() {}\n", &[]);
2619        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2620    }
2621
2622    // [FR-016]
2623    #[test]
2624    fn rustの外側docコメントはマーカーにならない() {
2625        let tests = rs("/// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2626        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2627    }
2628
2629    // [FR-016]
2630    #[test]
2631    fn rustの内側docコメントはマーカーにならない() {
2632        let tests = rs("mod m {\n//! [FR-001]\n#[test]\nfn t() {}\n}\n", &[]);
2633        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2634    }
2635
2636    // [FR-016]
2637    #[test]
2638    fn rustはブロックdocコメントを挟んでもマーカーを取る() {
2639        let tests = rs("// [FR-001]\n/** doc */\n#[test]\nfn t() {}\n", &[]);
2640        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2641    }
2642
2643    // [FR-016]
2644    #[test]
2645    fn rustのスラッシュ4つのコメントは通常コメントとしてマーカーになる() {
2646        let tests = rs("//// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2647        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2648    }
2649
2650    // [FR-016]
2651    #[test]
2652    fn rustはdocコメントの後に空行があるとマーカーを取らない() {
2653        let tests = rs("// [FR-001]\n/// doc\n\n#[test]\nfn t() {}\n", &[]);
2654        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2655    }
2656
2657    // [FR-016]
2658    #[test]
2659    fn rustはcrlfでもdocコメントを挟んでマーカーを取る() {
2660        let tests = rs("// [FR-001]\r\n/// doc\r\n#[test]\r\nfn t() {}\r\n", &[]);
2661        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2662    }
2663
2664    // [FR-064]
2665    #[test]
2666    fn goは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2667        let source =
2668            "package a\n\nfunc TestA(t *testing.T) {} // [FR-001]\nfunc TestB(t *testing.T) {}\n";
2669        let tests = extract_go_tests("a/x_test.go", source).unwrap();
2670        assert!(find(&tests, "TestB").markers.is_empty());
2671    }
2672
2673    // [FR-064]
2674    #[test]
2675    fn rustは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2676        let tests = rs("fn x() {} // [FR-001]\n#[test]\nfn t() {}\n", &[]);
2677        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2678    }
2679
2680    // [FR-064]
2681    #[test]
2682    fn csharpは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2683        let source =
2684            cs_wrap("    public void X() {} // [FR-001]\n    [Fact]\n    public void T() {}");
2685        let tests = cs(&source);
2686        assert!(find(&tests, "T").markers.is_empty());
2687    }
2688
2689    // [FR-064]
2690    #[test]
2691    fn typescriptは前の文の末尾行コメントを次のtestのマーカーにしない() {
2692        let tests = ts("it(\"a\", () => {}); // [FR-001]\nit(\"b\", () => {});\n");
2693        assert!(find(&tests, "b").markers.is_empty());
2694    }
2695
2696    // [FR-064]
2697    #[test]
2698    fn javaは前の宣言の末尾行コメントを次のtestのマーカーにしない() {
2699        let source = jv_wrap("    void x() {} // [FR-001]\n    @Test\n    void t() {}");
2700        let tests = jv(&source);
2701        assert!(find(&tests, "t").markers.is_empty());
2702    }
2703
2704    // [FR-064]
2705    #[test]
2706    fn kotlinは非ascii識別子の後の末尾行コメントを次のtestのマーカーにしない() {
2707        let source = kt_wrap("    fun `日本語`() {} // [FR-001]\n    @Test\n    fun t() {}");
2708        let tests = kt(&source);
2709        assert!(find(&tests, "t").markers.is_empty());
2710    }
2711
2712    // [FR-064]
2713    #[test]
2714    fn rustはインデント付きの行頭コメントをマーカーにする() {
2715        let tests = rs(
2716            "mod m {\n    // [FR-001]\n    #[test]\n    fn t() {}\n}\n",
2717            &[],
2718        );
2719        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2720    }
2721
2722    // [FR-064]
2723    #[test]
2724    fn typescriptは同じ行に複数のコメントがあると2つ目以降は行頭でないので打ち切る() {
2725        let tests = ts("/* [FR-001] */ // note\nit(\"b\", () => {});\n");
2726        assert!(find(&tests, "b").markers.is_empty());
2727    }
2728
2729    // [FR-064]
2730    #[test]
2731    fn rustはbomの直後の1行目マーカーを取る() {
2732        let tests = rs("\u{feff}// [FR-001]\n#[test]\nfn t() {}\n", &[]);
2733        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2734    }
2735
2736    // [FR-064]
2737    #[test]
2738    fn goはbomの直後の1行目マーカーを取る() {
2739        let source = "\u{feff}// [FR-001]\nfunc TestA(t *testing.T) {}\n";
2740        let tests = extract_go_tests("a/x_test.go", source).unwrap();
2741        assert_eq!(find(&tests, "TestA").markers, vec!["FR-001".to_string()]);
2742    }
2743
2744    // [FR-016]
2745    #[test]
2746    fn rustの内側ブロックdocコメントはマーカーにならない() {
2747        let tests = rs("mod m {\n/*! [FR-001] */\n#[test]\nfn t() {}\n}\n", &[]);
2748        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2749    }
2750
2751    // [FR-016]
2752    #[test]
2753    fn rustのスラッシュ星3つのコメントは通常コメントとしてマーカーになる() {
2754        let tests = rs("/*** [FR-001] */\n#[test]\nfn t() {}\n", &[]);
2755        assert_eq!(find(&tests, "t").markers, vec!["FR-001".to_string()]);
2756    }
2757
2758    // [FR-016]
2759    #[test]
2760    fn rustの外側ブロックdocコメントはマーカーにならない() {
2761        let tests = rs("/** [FR-001] */\n#[test]\nfn t() {}\n", &[]);
2762        assert_eq!(find(&tests, "t").markers, Vec::<String>::new());
2763    }
2764}