Skip to main content

codehelion_core/
test_code.rs

1//! Recognising units that exist to test other code.
2//!
3//! Test suites repeat themselves on purpose. The same fixture is built, the
4//! same call is made and the same assertion is written across dozens of cases,
5//! because a test that shares its setup with its neighbours stops being
6//! readable on its own. Reporting that repetition next to duplication in the
7//! code under test buries the latter: on a well-tested project most clone
8//! groups live in the suite.
9//!
10//! So the fact is recorded here and left to presentation to act on, exactly as
11//! [`crate::boilerplate`] classification is. A unit marked as test code is
12//! still parsed, still compared and still grouped; only where its group lands
13//! in a report changes, and it can always be shown.
14//!
15//! # What counts
16//!
17//! An explicit marker in the source is the strongest evidence: whatever the
18//! language's own test tooling makes an author write to declare a case. In
19//! Rust that is an attribute; in C and C++ it is the macro the framework
20//! defines, which stands where a function's return type and name would. A
21//! unit inside a marked container — a module compiled only for tests — is test
22//! code too, since it exists to serve the cases in it.
23//!
24//! Paths are also evidence, not a suppression rule. The conventional test
25//! paths in [`DEFAULT_TEST_PATHS`] classify otherwise unmarked helpers as test
26//! code so presentation can rank them below production findings without hiding
27//! them. The caller may replace or disable those patterns. Reports retain
28//! whether a group was recognised by a marker or a path, and a marker wins
29//! whenever both apply, so the two sources of evidence never disagree
30//! silently.
31//!
32//! # A container the file does not hold
33//!
34//! A Rust module can be declared in one file and written in another:
35//! `#[cfg(test)] mod tests;` beside a `tests.rs`, or a `tests/` directory of
36//! them. The marker is on the declaration, so nothing in the file it governs
37//! carries it, and reading each file alone leaves every helper in that tree
38//! looking like ordinary code — a suite of a hundred cases can come back
39//! unrecognised because its `#[test]` functions were the only ones ever
40//! marked.
41//!
42//! [`declared_test_modules`] closes that by following the declaration to the
43//! file it names, and onwards through whatever that file declares in turn.
44//! This is not the directory convention arriving by another route: what is
45//! read is still the author's own `#[cfg(test)]`, and a `tests` directory
46//! nobody declared that way is still ordinary code. It needs the whole file
47//! set at once, which is why it sits apart from [`is_marked`] rather than
48//! inside it.
49
50use std::collections::{BTreeMap, VecDeque};
51use std::ffi::OsStr;
52use std::path::{Path, PathBuf};
53
54use crate::discovery::Language;
55use crate::frontend::{Token, TokenKind};
56
57/// Version of the test-code recognition rules.
58///
59/// Recorded alongside the other detector versions: a change in what counts as
60/// test code changes how a report is ordered, so results from two versions are
61/// not comparable without saying so.
62pub const TEST_CODE_VERSION: &str = "test-code-v1";
63
64/// Conventional paths that contain test code.
65///
66/// The patterns are applied only to source files the scan already selected, so
67/// the `.*` suffixes cover the Rust, C, and C++ extensions without classifying
68/// files from another language. They are configuration defaults rather than a
69/// hidden rule: callers can replace them or set the configured list to empty.
70pub const DEFAULT_TEST_PATHS: &[&str] = &[
71    "**/tests/**",
72    "**/test/**",
73    "**/__tests__/**",
74    "**/*_test.*",
75    "**/*_tests.*",
76    "**/test_*.*",
77    "**/*_spec.*",
78];
79
80/// Why a unit or group is recognised as test code.
81///
82/// A marker is stronger than a path. For a group, the value is present only
83/// when every member is test code; it is `Marker` when any member has marker
84/// evidence and `Path` only when every member has path evidence.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
86#[serde(rename_all = "lowercase")]
87pub enum TestCodeEvidence {
88    /// The source declares the test with a language or framework marker.
89    Marker,
90    /// The file's path matches a configured test-path convention.
91    Path,
92}
93
94impl TestCodeEvidence {
95    /// The spelling used in persisted reports and database rows.
96    #[must_use]
97    pub const fn name(self) -> &'static str {
98        match self {
99            Self::Marker => "marker",
100            Self::Path => "path",
101        }
102    }
103
104    /// Decode a persisted evidence spelling.
105    #[must_use]
106    pub fn from_name(name: &str) -> Option<Self> {
107        match name {
108            "marker" => Some(Self::Marker),
109            "path" => Some(Self::Path),
110            _ => None,
111        }
112    }
113}
114
115/// Aggregate member evidence for one group.
116///
117/// Every member must be test code for a group to be test code. Among those
118/// groups, one marker is enough to name the aggregate `marker`; otherwise all
119/// members are path-derived and it names `path`.
120#[must_use]
121pub fn aggregate_evidence(
122    evidence: impl IntoIterator<Item = Option<TestCodeEvidence>>,
123) -> Option<TestCodeEvidence> {
124    let mut any = false;
125    let mut marker = false;
126    for item in evidence {
127        let item = item?;
128        any = true;
129        marker |= item == TestCodeEvidence::Marker;
130    }
131    any.then_some(if marker {
132        TestCodeEvidence::Marker
133    } else {
134        TestCodeEvidence::Path
135    })
136}
137
138/// The identifier a test attribute is built around.
139///
140/// `#[test]`, `#[cfg(test)]` and the async runtimes' `#[<runtime>::test]` all
141/// carry it, and a marker is expected to spell it exactly — a name that merely
142/// contains the word (`#[test_util::setup]`) is a different thing.
143const TEST_IDENT: &str = "test";
144
145/// The C and C++ macros that declare a case and carry its body.
146///
147/// A case in these languages is written `MACRO(suite, name) { ... }`, which
148/// parses as a definition whose name is the macro. That name is the author's
149/// explicit declaration that the body is a test, and it is the same kind of
150/// evidence a Rust attribute is.
151///
152/// Only body-carrying macros are listed. Registration macros
153/// (`INSTANTIATE_TEST_SUITE_P`, `BOOST_AUTO_TEST_SUITE`) declare no body, so
154/// nothing they mark is a unit, and assertion macros sit inside a body that
155/// has already been marked by the case around it.
156///
157/// The list is deliberately short and exact, and covers `GoogleTest`, Google
158/// Benchmark, Boost.Test, Catch2 and doctest. It is kept sorted rather than
159/// grouped by framework, because several of these names belong to more than
160/// one. A framework that is not here is reached by path rules, which is the
161/// same answer any project gets for a convention this module cannot read.
162const CASE_MACROS: &[&str] = &[
163    "BENCHMARK_DEFINE_F",
164    "BENCHMARK_F",
165    "BENCHMARK_TEMPLATE_F",
166    "BOOST_AUTO_TEST_CASE",
167    "BOOST_AUTO_TEST_CASE_TEMPLATE",
168    "BOOST_DATA_TEST_CASE",
169    "BOOST_FIXTURE_TEST_CASE",
170    "SCENARIO",
171    "TEMPLATE_TEST_CASE",
172    "TEST",
173    "TEST_CASE",
174    "TEST_CASE_METHOD",
175    "TEST_F",
176    "TEST_P",
177    "TYPED_TEST",
178    "TYPED_TEST_P",
179];
180
181/// Whether a node's own leading tokens mark it as test code.
182///
183/// `tokens` is the node's token slice, starting at the first token the node
184/// covers; both markers this reads sit at the front of an item, so they are
185/// always at the front of that slice.
186///
187/// Only the front is read, so a `test` or a `TEST` appearing later — as the
188/// item's own name, a parameter, a called function — is never a marker.
189#[must_use]
190pub fn is_marked(language: Language, tokens: &[Token]) -> bool {
191    match language {
192        Language::Rust => rust_attributes(tokens).any(names_test),
193        // C and C++ have no attribute for this and no container the mark could
194        // be inherited from: `BOOST_AUTO_TEST_SUITE` and its `_END` are two
195        // separate invocations at file scope, not a construct that encloses
196        // the cases between them. Each case therefore carries its own marker
197        // or is not recognised.
198        Language::C | Language::Cpp => opens_a_case(tokens),
199    }
200}
201
202/// Whether the tokens begin with a case macro applied to something.
203///
204/// The call parenthesis is required: a bare identifier that happens to spell a
205/// macro name is a use of that name, not a declaration.
206fn opens_a_case(tokens: &[Token]) -> bool {
207    let [name, open, ..] = tokens else {
208        return false;
209    };
210    name.kind == TokenKind::Identifier
211        && CASE_MACROS.contains(&&*name.text)
212        && open.kind == TokenKind::Punctuation
213        && open.text == "("
214}
215
216/// The leading `#[...]` attribute bodies of a Rust item, each without its
217/// delimiters, in source order.
218fn rust_attributes(tokens: &[Token]) -> impl Iterator<Item = &[Token]> {
219    let mut rest = tokens;
220    std::iter::from_fn(move || {
221        let (body, tail) = leading_attribute(rest)?;
222        rest = tail;
223        Some(body)
224    })
225}
226
227/// The body of the leading attribute, without its delimiters, and the tokens
228/// after it.
229///
230/// `#[attr]` on the item and `#![attr]` on the enclosing scope both start an
231/// attribute; anything else ends the run.
232fn leading_attribute(tokens: &[Token]) -> Option<(&[Token], &[Token])> {
233    let after_hash = after_punctuation(tokens, "#")?;
234    let after_bang = after_punctuation(after_hash, "!").unwrap_or(after_hash);
235    let body = after_punctuation(after_bang, "[")?;
236    let end = closing_bracket(body)?;
237    Some((&body[..end], &body[end + 1..]))
238}
239
240/// What an item is left with once its leading attribute run is past.
241fn after_attributes(tokens: &[Token]) -> &[Token] {
242    let mut rest = tokens;
243    while let Some((_, tail)) = leading_attribute(rest) {
244        rest = tail;
245    }
246    rest
247}
248
249/// The tokens after one leading punctuation token with the given text.
250fn after_punctuation<'a>(tokens: &'a [Token], text: &str) -> Option<&'a [Token]> {
251    let (first, rest) = tokens.split_first()?;
252    (first.kind == TokenKind::Punctuation && first.text == text).then_some(rest)
253}
254
255/// Index of the `]` that closes the bracket this body sits in, counting nested
256/// pairs, or `None` when the source is truncated before it.
257fn closing_bracket(body: &[Token]) -> Option<usize> {
258    closing(body, "[", "]")
259}
260
261/// Index of the delimiter closing the group this body sits in, counting nested
262/// pairs, or `None` when the source is truncated before it.
263fn closing(body: &[Token], open: &str, close: &str) -> Option<usize> {
264    let mut depth = 0usize;
265    for (index, token) in body.iter().enumerate() {
266        if token.kind != TokenKind::Punctuation {
267            continue;
268        }
269        if &*token.text == open {
270            depth += 1;
271        } else if &*token.text == close {
272            if depth == 0 {
273                return Some(index);
274            }
275            depth -= 1;
276        }
277    }
278    None
279}
280
281/// Whether an attribute makes its item test-only or declares a test case.
282fn names_test(body: &[Token]) -> bool {
283    let Some((head, arguments)) = attribute_parts(body) else {
284        return false;
285    };
286    match (head, arguments) {
287        ("cfg", Some(predicate)) => {
288            predicate_values(predicate, false) & TRUE_VALUE == 0
289                && predicate_values(predicate, true) & TRUE_VALUE != 0
290        }
291        ("cfg_attr", Some(arguments)) => split_arguments(arguments)
292            .into_iter()
293            .skip(1)
294            .any(names_test),
295        (TEST_IDENT, _) => true,
296        _ => false,
297    }
298}
299
300/// Last component of an attribute path and its parenthesized arguments.
301fn attribute_parts(body: &[Token]) -> Option<(&str, Option<&[Token]>)> {
302    let open = body.iter().position(|token| token.text == "(");
303    let path = open.map_or(body, |index| &body[..index]);
304    let head = path
305        .iter()
306        .rev()
307        .find(|token| matches!(token.kind, TokenKind::Identifier | TokenKind::Keyword))?
308        .text
309        .as_str();
310    let arguments = open.and_then(|index| {
311        let tail = &body[index + 1..];
312        closing(tail, "(", ")").map(|end| &tail[..end])
313    });
314    Some((head, arguments))
315}
316
317const FALSE_VALUE: u8 = 1;
318const TRUE_VALUE: u8 = 2;
319const BOTH_VALUES: u8 = FALSE_VALUE | TRUE_VALUE;
320
321/// Possible truth values of one cfg predicate for a fixed value of `test`.
322fn predicate_values(tokens: &[Token], test_enabled: bool) -> u8 {
323    let Some((head, arguments)) = attribute_parts(tokens) else {
324        return BOTH_VALUES;
325    };
326    match (head, arguments) {
327        (TEST_IDENT, None) => {
328            if test_enabled {
329                TRUE_VALUE
330            } else {
331                FALSE_VALUE
332            }
333        }
334        ("not", Some(arguments)) => {
335            let values = predicate_values(arguments, test_enabled);
336            ((values & FALSE_VALUE) << 1) | ((values & TRUE_VALUE) >> 1)
337        }
338        ("all", Some(arguments)) => split_arguments(arguments)
339            .into_iter()
340            .map(|argument| predicate_values(argument, test_enabled))
341            .fold(TRUE_VALUE, possible_and),
342        ("any", Some(arguments)) => split_arguments(arguments)
343            .into_iter()
344            .map(|argument| predicate_values(argument, test_enabled))
345            .fold(FALSE_VALUE, possible_or),
346        _ => BOTH_VALUES,
347    }
348}
349
350fn possible_and(left: u8, right: u8) -> u8 {
351    possible_binary(left, right, |a, b| a && b)
352}
353
354fn possible_or(left: u8, right: u8) -> u8 {
355    possible_binary(left, right, |a, b| a || b)
356}
357
358fn possible_binary(left: u8, right: u8, operation: impl Fn(bool, bool) -> bool) -> u8 {
359    let mut values = 0;
360    for left_value in [false, true] {
361        if left & value_bit(left_value) == 0 {
362            continue;
363        }
364        for right_value in [false, true] {
365            if right & value_bit(right_value) != 0 {
366                values |= value_bit(operation(left_value, right_value));
367            }
368        }
369    }
370    values
371}
372
373const fn value_bit(value: bool) -> u8 {
374    if value { TRUE_VALUE } else { FALSE_VALUE }
375}
376
377/// Split comma-separated predicate or `cfg_attr` arguments at top level.
378fn split_arguments(tokens: &[Token]) -> Vec<&[Token]> {
379    let mut arguments = Vec::new();
380    let mut start = 0;
381    let mut depth = 0usize;
382    for (index, token) in tokens.iter().enumerate() {
383        match token.text.as_str() {
384            "(" | "[" | "{" => depth += 1,
385            ")" | "]" | "}" => depth = depth.saturating_sub(1),
386            "," if depth == 0 => {
387                arguments.push(&tokens[start..index]);
388                start = index + 1;
389            }
390            _ => {}
391        }
392    }
393    arguments.push(&tokens[start..]);
394    arguments
395}
396
397/// One file, as module resolution needs to see it.
398#[derive(Debug, Clone, Copy)]
399pub struct ModuleFile<'a> {
400    /// Path the file was discovered at. Only its shape matters — the
401    /// directory it sits in and its stem — so any consistent root will do,
402    /// provided every file in the set shares it.
403    pub path: &'a Path,
404    /// Language the file was parsed as. Anything but Rust is passed over.
405    pub language: Language,
406    /// The file's tokens, comments and whitespace already removed.
407    pub tokens: &'a [Token],
408}
409
410/// Which of these files are the body of a module the tree declares test-only.
411///
412/// Returns one flag per input, in the same order. A file is flagged when some
413/// file declares it with `#[cfg(test)] mod <name>;`, and so is everything that
414/// file declares in turn: a test module's own submodules are part of the
415/// suite whether or not anybody repeated the attribute on them.
416///
417/// Only declarations at a file's top level are followed. One written inside an
418/// inline `mod` names a file in a directory nested a further level down, and
419/// resolving that would mean tracking the module path a declaration sits at —
420/// worth doing when a project turns up that needs it, and not before.
421#[must_use]
422pub fn declared_test_modules(files: &[ModuleFile<'_>]) -> Vec<bool> {
423    let mut suite = vec![false; files.len()];
424    let by_path: BTreeMap<&Path, usize> = files
425        .iter()
426        .enumerate()
427        .filter(|(_, file)| file.language == Language::Rust)
428        .map(|(index, file)| (file.path, index))
429        .collect();
430    let declared: Vec<Vec<Declaration<'_>>> = files
431        .iter()
432        .map(|file| {
433            if file.language == Language::Rust {
434                module_declarations(file.tokens)
435            } else {
436                Vec::new()
437            }
438        })
439        .collect();
440
441    let mut pending = VecDeque::new();
442    let enter = |name: &str, from: &Path, suite: &mut Vec<bool>, pending: &mut VecDeque<_>| {
443        for candidate in module_bodies(from, name) {
444            if let Some(&index) = by_path.get(candidate.as_path()) {
445                if !suite[index] {
446                    suite[index] = true;
447                    pending.push_back(index);
448                }
449            }
450        }
451    };
452
453    for (index, declarations) in declared.iter().enumerate() {
454        for declaration in declarations.iter().filter(|entry| entry.marked) {
455            enter(
456                declaration.name,
457                files[index].path,
458                &mut suite,
459                &mut pending,
460            );
461        }
462    }
463    // Everything a file already in the suite declares is in it too, marked or
464    // not: the attribute was written once, on the module the rest hang off.
465    while let Some(index) = pending.pop_front() {
466        for declaration in &declared[index] {
467            enter(
468                declaration.name,
469                files[index].path,
470                &mut suite,
471                &mut pending,
472            );
473        }
474    }
475    suite
476}
477
478/// A module declared without a body, and whether its declaration is marked as
479/// test-only.
480struct Declaration<'a> {
481    name: &'a str,
482    marked: bool,
483}
484
485/// The bodiless module declarations at a file's top level.
486fn module_declarations(tokens: &[Token]) -> Vec<Declaration<'_>> {
487    let mut declarations = Vec::new();
488    for item in top_level_items(tokens) {
489        if let Some(name) = bodiless_module(item) {
490            declarations.push(Declaration {
491                name,
492                marked: rust_attributes(item).any(names_test),
493            });
494        }
495    }
496    declarations
497}
498
499/// The file's top-level items, each from its first attribute to the `;` or `}`
500/// that ends it.
501///
502/// Nothing here parses; an item is what lies between two terminators found at
503/// the outermost nesting level — a `;` written there, or the `}` that closes a
504/// body back to it. That is enough for the one shape this reads, and a file it
505/// makes no sense of yields items no other rule matches.
506fn top_level_items(tokens: &[Token]) -> impl Iterator<Item = &[Token]> {
507    let mut start = 0usize;
508    let mut depth = 0usize;
509    let mut index = 0usize;
510    std::iter::from_fn(move || {
511        while index < tokens.len() {
512            let token = &tokens[index];
513            index += 1;
514            if token.kind != TokenKind::Punctuation {
515                continue;
516            }
517            let ends = match &*token.text {
518                "{" | "(" | "[" => {
519                    depth += 1;
520                    false
521                }
522                // Only a brace closes an item: the `]` ending an attribute and
523                // the `)` ending a visibility both come back to the outermost
524                // level in the middle of one.
525                "}" | ")" | "]" => {
526                    depth = depth.saturating_sub(1);
527                    depth == 0 && token.text == "}"
528                }
529                ";" => depth == 0,
530                _ => false,
531            };
532            if ends {
533                let item = &tokens[start..index];
534                start = index;
535                return Some(item);
536            }
537        }
538        None
539    })
540}
541
542/// The name of the module this item declares without a body, if that is what
543/// it is.
544///
545/// `mod name;` and nothing else: once the attributes and the visibility are
546/// past, three tokens have to be all that is left, which is what tells a
547/// declaration from a `mod name { .. }` whose contents are right there.
548fn bodiless_module(item: &[Token]) -> Option<&str> {
549    let rest = after_visibility(after_attributes(item));
550    let [keyword, name, terminator] = rest else {
551        return None;
552    };
553    let declares = word_is(keyword, "mod")
554        && name.kind == TokenKind::Identifier
555        && terminator.kind == TokenKind::Punctuation
556        && terminator.text == ";";
557    declares.then(|| &*name.text)
558}
559
560/// What an item is left with once `pub`, with any restriction it carries, is
561/// past.
562fn after_visibility(tokens: &[Token]) -> &[Token] {
563    let Some((first, rest)) = tokens.split_first() else {
564        return tokens;
565    };
566    if !word_is(first, "pub") {
567        return tokens;
568    }
569    let Some(restriction) = after_punctuation(rest, "(") else {
570        return rest;
571    };
572    closing(restriction, "(", ")").map_or(rest, |end| &restriction[end + 1..])
573}
574
575/// Whether a token is the given word, however the frontend classified it.
576fn word_is(token: &Token, word: &str) -> bool {
577    matches!(token.kind, TokenKind::Identifier | TokenKind::Keyword) && token.text == word
578}
579
580/// The files that could hold the body of `name` as declared by `from`.
581///
582/// Rust looks in one place or the other, never both, but which one depends on
583/// where the declaring file itself sits; offering both and taking whichever
584/// exists costs nothing and spares this a rule it would only get wrong.
585fn module_bodies(from: &Path, name: &str) -> [PathBuf; 2] {
586    let directory = from.parent().unwrap_or_else(|| Path::new(""));
587    // A module's own file gives its name to the directory its children live
588    // in — except for the three that stand for a directory already.
589    let base = match from.file_stem().and_then(OsStr::to_str) {
590        Some("mod" | "lib" | "main") | None => directory.to_path_buf(),
591        Some(stem) => directory.join(stem),
592    };
593    [
594        base.join(format!("{name}.rs")),
595        base.join(name).join("mod.rs"),
596    ]
597}
598
599#[cfg(test)]
600mod tests {
601    #![allow(clippy::unwrap_used)]
602
603    use super::*;
604    use crate::frontend::{Lexeme, SourceSpan};
605
606    /// One token per piece: alphabetic pieces lex as identifiers, the rest as
607    /// punctuation, which is all the attribute scan distinguishes.
608    fn tokens(pieces: &[&str]) -> Vec<Token> {
609        pieces
610            .iter()
611            .map(|piece| Token {
612                kind: if piece.chars().next().is_some_and(char::is_alphanumeric) {
613                    TokenKind::Identifier
614                } else {
615                    TokenKind::Punctuation
616                },
617                text: Lexeme::from(*piece),
618                span: SourceSpan {
619                    start_byte: 0,
620                    end_byte: 0,
621                    start_line: 1,
622                    start_column: 1,
623                },
624            })
625            .collect()
626    }
627
628    #[test]
629    fn a_test_attribute_marks_the_item_it_precedes() {
630        let source = tokens(&["#", "[", "test", "]", "fn", "check", "(", ")"]);
631        assert!(is_marked(Language::Rust, &source));
632    }
633
634    #[test]
635    fn a_configuration_predicate_naming_tests_marks_the_item() {
636        let source = tokens(&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", "{"]);
637        assert!(is_marked(Language::Rust, &source));
638    }
639
640    #[test]
641    fn a_runtime_qualified_test_attribute_is_still_a_test_attribute() {
642        let source = tokens(&["#", "[", "tokio", ":", ":", "test", "]", "fn", "check"]);
643        assert!(is_marked(Language::Rust, &source));
644    }
645
646    #[test]
647    fn the_marker_is_read_only_from_the_leading_attributes() {
648        // `test` here is the function's own name and a parameter, past the
649        // attribute run. Reading it as a marker would sweep in every unit
650        // that merely talks about testing.
651        let source = tokens(&["#", "[", "inline", "]", "fn", "test", "(", "test", ")"]);
652        assert!(!is_marked(Language::Rust, &source));
653    }
654
655    #[test]
656    fn a_nested_attribute_is_searched_to_its_own_end() {
657        let source = tokens(&[
658            "#", "[", "cfg", "(", "all", "(", "unix", ",", "test", ")", ")", "]", "fn", "check",
659        ]);
660        assert!(is_marked(Language::Rust, &source));
661    }
662
663    #[test]
664    fn negated_test_cfg_marks_production_code_not_test_code() {
665        let source = tokens(&[
666            "#",
667            "[",
668            "cfg",
669            "(",
670            "not",
671            "(",
672            "test",
673            ")",
674            ")",
675            "]",
676            "fn",
677            "production",
678        ]);
679        assert!(!is_marked(Language::Rust, &source));
680
681        let double_negated = tokens(&[
682            "#", "[", "cfg", "(", "not", "(", "not", "(", "test", ")", ")", ")", "]", "fn", "check",
683        ]);
684        assert!(is_marked(Language::Rust, &double_negated));
685    }
686
687    #[test]
688    fn cfg_attr_condition_is_not_mistaken_for_the_applied_attribute() {
689        let production = tokens(&[
690            "#",
691            "[",
692            "cfg_attr",
693            "(",
694            "test",
695            ",",
696            "allow",
697            "(",
698            "dead_code",
699            ")",
700            ")",
701            "]",
702            "fn",
703            "production",
704        ]);
705        assert!(!is_marked(Language::Rust, &production));
706
707        let test = tokens(&[
708            "#", "[", "cfg_attr", "(", "feature", "=", "runtime", ",", "tokio", ":", ":", "test",
709            ")", "]", "fn", "check",
710        ]);
711        assert!(is_marked(Language::Rust, &test));
712    }
713
714    #[test]
715    fn a_marker_after_an_unrelated_attribute_is_still_found() {
716        let source = tokens(&[
717            "#",
718            "[",
719            "allow",
720            "(",
721            "dead_code",
722            ")",
723            "]",
724            "#",
725            "[",
726            "test",
727            "]",
728            "fn",
729            "check",
730        ]);
731        assert!(is_marked(Language::Rust, &source));
732    }
733
734    #[test]
735    fn an_inner_attribute_is_read_like_an_outer_one() {
736        let source = tokens(&["#", "!", "[", "cfg", "(", "test", ")", "]", "fn", "check"]);
737        assert!(is_marked(Language::Rust, &source));
738    }
739
740    #[test]
741    fn a_truncated_attribute_marks_nothing() {
742        // The parser is error-tolerant, so an unclosed attribute reaches here.
743        // It must end the scan rather than run off the end of the item.
744        let source = tokens(&["#", "[", "test", "fn", "check"]);
745        assert!(!is_marked(Language::Rust, &source));
746    }
747
748    #[test]
749    fn a_name_that_merely_contains_the_word_is_not_a_marker() {
750        let source = tokens(&["#", "[", "test_util", ":", ":", "setup", "]", "fn", "check"]);
751        assert!(!is_marked(Language::Rust, &source));
752    }
753
754    #[test]
755    fn attribute_syntax_marks_nothing_in_c_or_cpp() {
756        // Neither language has the attribute, so a file that spells one is
757        // ordinary code that happens to look Rust-like.
758        let source = tokens(&["#", "[", "test", "]", "void", "check", "(", ")"]);
759        assert!(!is_marked(Language::C, &source));
760        assert!(!is_marked(Language::Cpp, &source));
761    }
762
763    #[test]
764    fn a_case_macro_marks_the_definition_it_opens() {
765        for name in ["TEST", "TEST_F", "BOOST_AUTO_TEST_CASE", "TEST_CASE"] {
766            let source = tokens(&[name, "(", "Suite", ",", "Name", ")", "{"]);
767            assert!(is_marked(Language::Cpp, &source), "{name}");
768            assert!(is_marked(Language::C, &source), "{name}");
769        }
770    }
771
772    #[test]
773    fn a_case_macro_is_a_marker_only_where_it_declares_something() {
774        // Used as a value, not applied: whatever this is, it is not a case.
775        let source = tokens(&["TEST", ";"]);
776        assert!(!is_marked(Language::Cpp, &source));
777        // Applied, but not at the front — the item is `run`, which calls it.
778        let source = tokens(&["void", "run", "(", ")", "{", "TEST", "(", "x", ")"]);
779        assert!(!is_marked(Language::Cpp, &source));
780    }
781
782    #[test]
783    fn a_name_that_merely_starts_with_a_case_macro_is_not_one() {
784        let source = tokens(&["TEST_HELPER", "(", "x", ")", "{"]);
785        assert!(!is_marked(Language::Cpp, &source));
786    }
787
788    #[test]
789    fn rust_does_not_read_the_c_markers() {
790        // Rust has the attribute, so a bare identifier is never the evidence.
791        let source = tokens(&["TEST", "(", "Suite", ",", "Name", ")", "{"]);
792        assert!(!is_marked(Language::Rust, &source));
793    }
794
795    #[test]
796    fn the_case_macro_list_is_sorted_and_free_of_repeats() {
797        // Sorted so a reader can find a name, and so an addition lands next to
798        // its neighbours rather than wherever it was typed.
799        let mut sorted = CASE_MACROS.to_vec();
800        sorted.sort_unstable();
801        sorted.dedup();
802        assert_eq!(sorted, CASE_MACROS);
803    }
804
805    #[test]
806    fn an_empty_item_marks_nothing() {
807        assert!(!is_marked(Language::Rust, &[]));
808    }
809
810    #[test]
811    fn default_test_paths_cover_directories_and_rust_c_cpp_file_conventions() {
812        assert_eq!(
813            DEFAULT_TEST_PATHS,
814            [
815                "**/tests/**",
816                "**/test/**",
817                "**/__tests__/**",
818                "**/*_test.*",
819                "**/*_tests.*",
820                "**/test_*.*",
821                "**/*_spec.*",
822            ]
823        );
824    }
825
826    #[test]
827    fn aggregate_evidence_requires_every_member_and_prefers_markers() {
828        assert_eq!(
829            aggregate_evidence([Some(TestCodeEvidence::Path), Some(TestCodeEvidence::Path)]),
830            Some(TestCodeEvidence::Path)
831        );
832        assert_eq!(
833            aggregate_evidence([Some(TestCodeEvidence::Path), Some(TestCodeEvidence::Marker),]),
834            Some(TestCodeEvidence::Marker)
835        );
836        assert_eq!(
837            aggregate_evidence([Some(TestCodeEvidence::Marker), None]),
838            None
839        );
840    }
841
842    /// The suite flags for a set of files given as `(path, source pieces)`,
843    /// every one of them Rust.
844    fn suite_over(files: &[(&str, &[&str])]) -> Vec<bool> {
845        let streams: Vec<Vec<Token>> = files.iter().map(|(_, pieces)| tokens(pieces)).collect();
846        let inputs: Vec<ModuleFile<'_>> = files
847            .iter()
848            .zip(&streams)
849            .map(|((path, _), stream)| ModuleFile {
850                path: Path::new(path),
851                language: Language::Rust,
852                tokens: stream,
853            })
854            .collect();
855        declared_test_modules(&inputs)
856    }
857
858    #[test]
859    fn a_declared_test_module_puts_the_file_it_names_in_the_suite() {
860        let suite = suite_over(&[
861            (
862                "src/lib.rs",
863                &["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
864            ),
865            ("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
866        ]);
867        assert_eq!(suite, vec![false, true]);
868    }
869
870    #[test]
871    fn a_test_module_hands_the_suite_on_to_what_it_declares() {
872        // Only the first declaration carries the attribute. Everything below
873        // it is the same suite, spelled across as many files as it took.
874        let suite = suite_over(&[
875            (
876                "src/lib.rs",
877                &["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
878            ),
879            ("src/tests.rs", &["mod", "parser", ";"]),
880            ("src/tests/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
881        ]);
882        assert_eq!(suite, vec![false, true, true]);
883    }
884
885    #[test]
886    fn a_declaration_below_the_code_it_covers_is_still_found() {
887        // Where the declaration actually sits: after the routines the suite
888        // exercises, so everything before it has to be walked past first.
889        let suite = suite_over(&[
890            (
891                "src/lib.rs",
892                &[
893                    "pub", "fn", "width", "(", ")", "{", "text", ".", "count", "(", ")", "}", "#",
894                    "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";",
895                ],
896            ),
897            ("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
898        ]);
899        assert_eq!(suite, vec![false, true]);
900    }
901
902    #[test]
903    fn a_module_whose_body_is_a_directory_is_found_there() {
904        let suite = suite_over(&[
905            (
906                "src/lib.rs",
907                &["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
908            ),
909            ("src/tests/mod.rs", &["fn", "check", "(", ")", "{", "}"]),
910        ]);
911        assert_eq!(suite, vec![false, true]);
912    }
913
914    #[test]
915    fn a_module_declared_without_the_marker_is_ordinary_code() {
916        let suite = suite_over(&[
917            ("src/lib.rs", &["mod", "parser", ";"]),
918            ("src/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
919        ]);
920        assert_eq!(suite, vec![false, false]);
921    }
922
923    #[test]
924    fn a_directory_named_for_tests_that_nobody_declared_is_not_a_marked_module() {
925        // This resolver follows only Rust module declarations. The caller
926        // applies configured path evidence later, after it has the whole
927        // structural report to classify.
928        let suite = suite_over(&[
929            ("src/lib.rs", &["fn", "run", "(", ")", "{", "}"]),
930            ("src/tests/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
931        ]);
932        assert_eq!(suite, vec![false, false]);
933    }
934
935    #[test]
936    fn a_module_written_where_it_is_declared_claims_no_file() {
937        // `mod tests { .. }` is its own body; a file of that name beside it is
938        // a different module, and the marker here says nothing about it.
939        let suite = suite_over(&[
940            (
941                "src/lib.rs",
942                &[
943                    "#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", "{", "fn", "check",
944                    "(", ")", "{", "}", "}",
945                ],
946            ),
947            ("src/tests.rs", &["fn", "other", "(", ")", "{", "}"]),
948        ]);
949        assert_eq!(suite, vec![false, false]);
950    }
951
952    #[test]
953    fn a_declaration_is_read_through_its_visibility() {
954        let suite = suite_over(&[
955            (
956                "src/lib.rs",
957                &[
958                    "#", "[", "cfg", "(", "test", ")", "]", "pub", "(", "crate", ")", "mod",
959                    "tests", ";",
960                ],
961            ),
962            ("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
963        ]);
964        assert_eq!(suite, vec![false, true]);
965    }
966
967    #[test]
968    fn a_declaration_the_source_is_truncated_before_marks_nothing() {
969        // The parser is error-tolerant, so an item with no terminator reaches
970        // here. It must end the scan rather than run off the end.
971        let suite = suite_over(&[
972            (
973                "src/lib.rs",
974                &["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests"],
975            ),
976            ("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
977        ]);
978        assert_eq!(suite, vec![false, false]);
979    }
980
981    #[test]
982    fn only_rust_files_are_read_for_declarations() {
983        // The syntax belongs to one language. A C++ file whose tokens happen
984        // to spell it is saying something else entirely.
985        let declaration = tokens(&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"]);
986        let body = tokens(&["fn", "check", "(", ")", "{", "}"]);
987        let suite = declared_test_modules(&[
988            ModuleFile {
989                path: Path::new("src/lib.rs"),
990                language: Language::Cpp,
991                tokens: &declaration,
992            },
993            ModuleFile {
994                path: Path::new("src/tests.rs"),
995                language: Language::Rust,
996                tokens: &body,
997            },
998        ]);
999        assert_eq!(suite, vec![false, false]);
1000    }
1001}