use std::collections::{BTreeMap, VecDeque};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::discovery::Language;
use crate::frontend::{Token, TokenKind};
pub const TEST_CODE_VERSION: &str = "test-code-v1";
pub const DEFAULT_TEST_PATHS: &[&str] = &[
"**/tests/**",
"**/test/**",
"**/__tests__/**",
"**/*_test.*",
"**/*_tests.*",
"**/test_*.*",
"**/*_spec.*",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TestCodeEvidence {
Marker,
Path,
}
impl TestCodeEvidence {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Marker => "marker",
Self::Path => "path",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"marker" => Some(Self::Marker),
"path" => Some(Self::Path),
_ => None,
}
}
}
#[must_use]
pub fn aggregate_evidence(
evidence: impl IntoIterator<Item = Option<TestCodeEvidence>>,
) -> Option<TestCodeEvidence> {
let mut any = false;
let mut marker = false;
for item in evidence {
let item = item?;
any = true;
marker |= item == TestCodeEvidence::Marker;
}
any.then_some(if marker {
TestCodeEvidence::Marker
} else {
TestCodeEvidence::Path
})
}
const TEST_IDENT: &str = "test";
const CASE_MACROS: &[&str] = &[
"BENCHMARK_DEFINE_F",
"BENCHMARK_F",
"BENCHMARK_TEMPLATE_F",
"BOOST_AUTO_TEST_CASE",
"BOOST_AUTO_TEST_CASE_TEMPLATE",
"BOOST_DATA_TEST_CASE",
"BOOST_FIXTURE_TEST_CASE",
"SCENARIO",
"TEMPLATE_TEST_CASE",
"TEST",
"TEST_CASE",
"TEST_CASE_METHOD",
"TEST_F",
"TEST_P",
"TYPED_TEST",
"TYPED_TEST_P",
];
#[must_use]
pub fn is_marked(language: Language, tokens: &[Token]) -> bool {
match language {
Language::Rust => rust_attributes(tokens).any(names_test),
Language::C | Language::Cpp => opens_a_case(tokens),
}
}
fn opens_a_case(tokens: &[Token]) -> bool {
let [name, open, ..] = tokens else {
return false;
};
name.kind == TokenKind::Identifier
&& CASE_MACROS.contains(&&*name.text)
&& open.kind == TokenKind::Punctuation
&& open.text == "("
}
fn rust_attributes(tokens: &[Token]) -> impl Iterator<Item = &[Token]> {
let mut rest = tokens;
std::iter::from_fn(move || {
let (body, tail) = leading_attribute(rest)?;
rest = tail;
Some(body)
})
}
fn leading_attribute(tokens: &[Token]) -> Option<(&[Token], &[Token])> {
let after_hash = after_punctuation(tokens, "#")?;
let after_bang = after_punctuation(after_hash, "!").unwrap_or(after_hash);
let body = after_punctuation(after_bang, "[")?;
let end = closing_bracket(body)?;
Some((&body[..end], &body[end + 1..]))
}
fn after_attributes(tokens: &[Token]) -> &[Token] {
let mut rest = tokens;
while let Some((_, tail)) = leading_attribute(rest) {
rest = tail;
}
rest
}
fn after_punctuation<'a>(tokens: &'a [Token], text: &str) -> Option<&'a [Token]> {
let (first, rest) = tokens.split_first()?;
(first.kind == TokenKind::Punctuation && first.text == text).then_some(rest)
}
fn closing_bracket(body: &[Token]) -> Option<usize> {
closing(body, "[", "]")
}
fn closing(body: &[Token], open: &str, close: &str) -> Option<usize> {
let mut depth = 0usize;
for (index, token) in body.iter().enumerate() {
if token.kind != TokenKind::Punctuation {
continue;
}
if &*token.text == open {
depth += 1;
} else if &*token.text == close {
if depth == 0 {
return Some(index);
}
depth -= 1;
}
}
None
}
fn names_test(body: &[Token]) -> bool {
let Some((head, arguments)) = attribute_parts(body) else {
return false;
};
match (head, arguments) {
("cfg", Some(predicate)) => {
predicate_values(predicate, false) & TRUE_VALUE == 0
&& predicate_values(predicate, true) & TRUE_VALUE != 0
}
("cfg_attr", Some(arguments)) => split_arguments(arguments)
.into_iter()
.skip(1)
.any(names_test),
(TEST_IDENT, _) => true,
_ => false,
}
}
fn attribute_parts(body: &[Token]) -> Option<(&str, Option<&[Token]>)> {
let open = body.iter().position(|token| token.text == "(");
let path = open.map_or(body, |index| &body[..index]);
let head = path
.iter()
.rev()
.find(|token| matches!(token.kind, TokenKind::Identifier | TokenKind::Keyword))?
.text
.as_str();
let arguments = open.and_then(|index| {
let tail = &body[index + 1..];
closing(tail, "(", ")").map(|end| &tail[..end])
});
Some((head, arguments))
}
const FALSE_VALUE: u8 = 1;
const TRUE_VALUE: u8 = 2;
const BOTH_VALUES: u8 = FALSE_VALUE | TRUE_VALUE;
fn predicate_values(tokens: &[Token], test_enabled: bool) -> u8 {
let Some((head, arguments)) = attribute_parts(tokens) else {
return BOTH_VALUES;
};
match (head, arguments) {
(TEST_IDENT, None) => {
if test_enabled {
TRUE_VALUE
} else {
FALSE_VALUE
}
}
("not", Some(arguments)) => {
let values = predicate_values(arguments, test_enabled);
((values & FALSE_VALUE) << 1) | ((values & TRUE_VALUE) >> 1)
}
("all", Some(arguments)) => split_arguments(arguments)
.into_iter()
.map(|argument| predicate_values(argument, test_enabled))
.fold(TRUE_VALUE, possible_and),
("any", Some(arguments)) => split_arguments(arguments)
.into_iter()
.map(|argument| predicate_values(argument, test_enabled))
.fold(FALSE_VALUE, possible_or),
_ => BOTH_VALUES,
}
}
fn possible_and(left: u8, right: u8) -> u8 {
possible_binary(left, right, |a, b| a && b)
}
fn possible_or(left: u8, right: u8) -> u8 {
possible_binary(left, right, |a, b| a || b)
}
fn possible_binary(left: u8, right: u8, operation: impl Fn(bool, bool) -> bool) -> u8 {
let mut values = 0;
for left_value in [false, true] {
if left & value_bit(left_value) == 0 {
continue;
}
for right_value in [false, true] {
if right & value_bit(right_value) != 0 {
values |= value_bit(operation(left_value, right_value));
}
}
}
values
}
const fn value_bit(value: bool) -> u8 {
if value { TRUE_VALUE } else { FALSE_VALUE }
}
fn split_arguments(tokens: &[Token]) -> Vec<&[Token]> {
let mut arguments = Vec::new();
let mut start = 0;
let mut depth = 0usize;
for (index, token) in tokens.iter().enumerate() {
match token.text.as_str() {
"(" | "[" | "{" => depth += 1,
")" | "]" | "}" => depth = depth.saturating_sub(1),
"," if depth == 0 => {
arguments.push(&tokens[start..index]);
start = index + 1;
}
_ => {}
}
}
arguments.push(&tokens[start..]);
arguments
}
#[derive(Debug, Clone, Copy)]
pub struct ModuleFile<'a> {
pub path: &'a Path,
pub language: Language,
pub tokens: &'a [Token],
}
#[must_use]
pub fn declared_test_modules(files: &[ModuleFile<'_>]) -> Vec<bool> {
let mut suite = vec![false; files.len()];
let by_path: BTreeMap<&Path, usize> = files
.iter()
.enumerate()
.filter(|(_, file)| file.language == Language::Rust)
.map(|(index, file)| (file.path, index))
.collect();
let declared: Vec<Vec<Declaration<'_>>> = files
.iter()
.map(|file| {
if file.language == Language::Rust {
module_declarations(file.tokens)
} else {
Vec::new()
}
})
.collect();
let mut pending = VecDeque::new();
let enter = |name: &str, from: &Path, suite: &mut Vec<bool>, pending: &mut VecDeque<_>| {
for candidate in module_bodies(from, name) {
if let Some(&index) = by_path.get(candidate.as_path()) {
if !suite[index] {
suite[index] = true;
pending.push_back(index);
}
}
}
};
for (index, declarations) in declared.iter().enumerate() {
for declaration in declarations.iter().filter(|entry| entry.marked) {
enter(
declaration.name,
files[index].path,
&mut suite,
&mut pending,
);
}
}
while let Some(index) = pending.pop_front() {
for declaration in &declared[index] {
enter(
declaration.name,
files[index].path,
&mut suite,
&mut pending,
);
}
}
suite
}
struct Declaration<'a> {
name: &'a str,
marked: bool,
}
fn module_declarations(tokens: &[Token]) -> Vec<Declaration<'_>> {
let mut declarations = Vec::new();
for item in top_level_items(tokens) {
if let Some(name) = bodiless_module(item) {
declarations.push(Declaration {
name,
marked: rust_attributes(item).any(names_test),
});
}
}
declarations
}
fn top_level_items(tokens: &[Token]) -> impl Iterator<Item = &[Token]> {
let mut start = 0usize;
let mut depth = 0usize;
let mut index = 0usize;
std::iter::from_fn(move || {
while index < tokens.len() {
let token = &tokens[index];
index += 1;
if token.kind != TokenKind::Punctuation {
continue;
}
let ends = match &*token.text {
"{" | "(" | "[" => {
depth += 1;
false
}
"}" | ")" | "]" => {
depth = depth.saturating_sub(1);
depth == 0 && token.text == "}"
}
";" => depth == 0,
_ => false,
};
if ends {
let item = &tokens[start..index];
start = index;
return Some(item);
}
}
None
})
}
fn bodiless_module(item: &[Token]) -> Option<&str> {
let rest = after_visibility(after_attributes(item));
let [keyword, name, terminator] = rest else {
return None;
};
let declares = word_is(keyword, "mod")
&& name.kind == TokenKind::Identifier
&& terminator.kind == TokenKind::Punctuation
&& terminator.text == ";";
declares.then(|| &*name.text)
}
fn after_visibility(tokens: &[Token]) -> &[Token] {
let Some((first, rest)) = tokens.split_first() else {
return tokens;
};
if !word_is(first, "pub") {
return tokens;
}
let Some(restriction) = after_punctuation(rest, "(") else {
return rest;
};
closing(restriction, "(", ")").map_or(rest, |end| &restriction[end + 1..])
}
fn word_is(token: &Token, word: &str) -> bool {
matches!(token.kind, TokenKind::Identifier | TokenKind::Keyword) && token.text == word
}
fn module_bodies(from: &Path, name: &str) -> [PathBuf; 2] {
let directory = from.parent().unwrap_or_else(|| Path::new(""));
let base = match from.file_stem().and_then(OsStr::to_str) {
Some("mod" | "lib" | "main") | None => directory.to_path_buf(),
Some(stem) => directory.join(stem),
};
[
base.join(format!("{name}.rs")),
base.join(name).join("mod.rs"),
]
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::frontend::{Lexeme, SourceSpan};
fn tokens(pieces: &[&str]) -> Vec<Token> {
pieces
.iter()
.map(|piece| Token {
kind: if piece.chars().next().is_some_and(char::is_alphanumeric) {
TokenKind::Identifier
} else {
TokenKind::Punctuation
},
text: Lexeme::from(*piece),
span: SourceSpan {
start_byte: 0,
end_byte: 0,
start_line: 1,
start_column: 1,
},
})
.collect()
}
#[test]
fn a_test_attribute_marks_the_item_it_precedes() {
let source = tokens(&["#", "[", "test", "]", "fn", "check", "(", ")"]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn a_configuration_predicate_naming_tests_marks_the_item() {
let source = tokens(&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", "{"]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn a_runtime_qualified_test_attribute_is_still_a_test_attribute() {
let source = tokens(&["#", "[", "tokio", ":", ":", "test", "]", "fn", "check"]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn the_marker_is_read_only_from_the_leading_attributes() {
let source = tokens(&["#", "[", "inline", "]", "fn", "test", "(", "test", ")"]);
assert!(!is_marked(Language::Rust, &source));
}
#[test]
fn a_nested_attribute_is_searched_to_its_own_end() {
let source = tokens(&[
"#", "[", "cfg", "(", "all", "(", "unix", ",", "test", ")", ")", "]", "fn", "check",
]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn negated_test_cfg_marks_production_code_not_test_code() {
let source = tokens(&[
"#",
"[",
"cfg",
"(",
"not",
"(",
"test",
")",
")",
"]",
"fn",
"production",
]);
assert!(!is_marked(Language::Rust, &source));
let double_negated = tokens(&[
"#", "[", "cfg", "(", "not", "(", "not", "(", "test", ")", ")", ")", "]", "fn", "check",
]);
assert!(is_marked(Language::Rust, &double_negated));
}
#[test]
fn cfg_attr_condition_is_not_mistaken_for_the_applied_attribute() {
let production = tokens(&[
"#",
"[",
"cfg_attr",
"(",
"test",
",",
"allow",
"(",
"dead_code",
")",
")",
"]",
"fn",
"production",
]);
assert!(!is_marked(Language::Rust, &production));
let test = tokens(&[
"#", "[", "cfg_attr", "(", "feature", "=", "runtime", ",", "tokio", ":", ":", "test",
")", "]", "fn", "check",
]);
assert!(is_marked(Language::Rust, &test));
}
#[test]
fn a_marker_after_an_unrelated_attribute_is_still_found() {
let source = tokens(&[
"#",
"[",
"allow",
"(",
"dead_code",
")",
"]",
"#",
"[",
"test",
"]",
"fn",
"check",
]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn an_inner_attribute_is_read_like_an_outer_one() {
let source = tokens(&["#", "!", "[", "cfg", "(", "test", ")", "]", "fn", "check"]);
assert!(is_marked(Language::Rust, &source));
}
#[test]
fn a_truncated_attribute_marks_nothing() {
let source = tokens(&["#", "[", "test", "fn", "check"]);
assert!(!is_marked(Language::Rust, &source));
}
#[test]
fn a_name_that_merely_contains_the_word_is_not_a_marker() {
let source = tokens(&["#", "[", "test_util", ":", ":", "setup", "]", "fn", "check"]);
assert!(!is_marked(Language::Rust, &source));
}
#[test]
fn attribute_syntax_marks_nothing_in_c_or_cpp() {
let source = tokens(&["#", "[", "test", "]", "void", "check", "(", ")"]);
assert!(!is_marked(Language::C, &source));
assert!(!is_marked(Language::Cpp, &source));
}
#[test]
fn a_case_macro_marks_the_definition_it_opens() {
for name in ["TEST", "TEST_F", "BOOST_AUTO_TEST_CASE", "TEST_CASE"] {
let source = tokens(&[name, "(", "Suite", ",", "Name", ")", "{"]);
assert!(is_marked(Language::Cpp, &source), "{name}");
assert!(is_marked(Language::C, &source), "{name}");
}
}
#[test]
fn a_case_macro_is_a_marker_only_where_it_declares_something() {
let source = tokens(&["TEST", ";"]);
assert!(!is_marked(Language::Cpp, &source));
let source = tokens(&["void", "run", "(", ")", "{", "TEST", "(", "x", ")"]);
assert!(!is_marked(Language::Cpp, &source));
}
#[test]
fn a_name_that_merely_starts_with_a_case_macro_is_not_one() {
let source = tokens(&["TEST_HELPER", "(", "x", ")", "{"]);
assert!(!is_marked(Language::Cpp, &source));
}
#[test]
fn rust_does_not_read_the_c_markers() {
let source = tokens(&["TEST", "(", "Suite", ",", "Name", ")", "{"]);
assert!(!is_marked(Language::Rust, &source));
}
#[test]
fn the_case_macro_list_is_sorted_and_free_of_repeats() {
let mut sorted = CASE_MACROS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted, CASE_MACROS);
}
#[test]
fn an_empty_item_marks_nothing() {
assert!(!is_marked(Language::Rust, &[]));
}
#[test]
fn default_test_paths_cover_directories_and_rust_c_cpp_file_conventions() {
assert_eq!(
DEFAULT_TEST_PATHS,
[
"**/tests/**",
"**/test/**",
"**/__tests__/**",
"**/*_test.*",
"**/*_tests.*",
"**/test_*.*",
"**/*_spec.*",
]
);
}
#[test]
fn aggregate_evidence_requires_every_member_and_prefers_markers() {
assert_eq!(
aggregate_evidence([Some(TestCodeEvidence::Path), Some(TestCodeEvidence::Path)]),
Some(TestCodeEvidence::Path)
);
assert_eq!(
aggregate_evidence([Some(TestCodeEvidence::Path), Some(TestCodeEvidence::Marker),]),
Some(TestCodeEvidence::Marker)
);
assert_eq!(
aggregate_evidence([Some(TestCodeEvidence::Marker), None]),
None
);
}
fn suite_over(files: &[(&str, &[&str])]) -> Vec<bool> {
let streams: Vec<Vec<Token>> = files.iter().map(|(_, pieces)| tokens(pieces)).collect();
let inputs: Vec<ModuleFile<'_>> = files
.iter()
.zip(&streams)
.map(|((path, _), stream)| ModuleFile {
path: Path::new(path),
language: Language::Rust,
tokens: stream,
})
.collect();
declared_test_modules(&inputs)
}
#[test]
fn a_declared_test_module_puts_the_file_it_names_in_the_suite() {
let suite = suite_over(&[
(
"src/lib.rs",
&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
),
("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, true]);
}
#[test]
fn a_test_module_hands_the_suite_on_to_what_it_declares() {
let suite = suite_over(&[
(
"src/lib.rs",
&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
),
("src/tests.rs", &["mod", "parser", ";"]),
("src/tests/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, true, true]);
}
#[test]
fn a_declaration_below_the_code_it_covers_is_still_found() {
let suite = suite_over(&[
(
"src/lib.rs",
&[
"pub", "fn", "width", "(", ")", "{", "text", ".", "count", "(", ")", "}", "#",
"[", "cfg", "(", "test", ")", "]", "mod", "tests", ";",
],
),
("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, true]);
}
#[test]
fn a_module_whose_body_is_a_directory_is_found_there() {
let suite = suite_over(&[
(
"src/lib.rs",
&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"],
),
("src/tests/mod.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, true]);
}
#[test]
fn a_module_declared_without_the_marker_is_ordinary_code() {
let suite = suite_over(&[
("src/lib.rs", &["mod", "parser", ";"]),
("src/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, false]);
}
#[test]
fn a_directory_named_for_tests_that_nobody_declared_is_not_a_marked_module() {
let suite = suite_over(&[
("src/lib.rs", &["fn", "run", "(", ")", "{", "}"]),
("src/tests/parser.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, false]);
}
#[test]
fn a_module_written_where_it_is_declared_claims_no_file() {
let suite = suite_over(&[
(
"src/lib.rs",
&[
"#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", "{", "fn", "check",
"(", ")", "{", "}", "}",
],
),
("src/tests.rs", &["fn", "other", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, false]);
}
#[test]
fn a_declaration_is_read_through_its_visibility() {
let suite = suite_over(&[
(
"src/lib.rs",
&[
"#", "[", "cfg", "(", "test", ")", "]", "pub", "(", "crate", ")", "mod",
"tests", ";",
],
),
("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, true]);
}
#[test]
fn a_declaration_the_source_is_truncated_before_marks_nothing() {
let suite = suite_over(&[
(
"src/lib.rs",
&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests"],
),
("src/tests.rs", &["fn", "check", "(", ")", "{", "}"]),
]);
assert_eq!(suite, vec![false, false]);
}
#[test]
fn only_rust_files_are_read_for_declarations() {
let declaration = tokens(&["#", "[", "cfg", "(", "test", ")", "]", "mod", "tests", ";"]);
let body = tokens(&["fn", "check", "(", ")", "{", "}"]);
let suite = declared_test_modules(&[
ModuleFile {
path: Path::new("src/lib.rs"),
language: Language::Cpp,
tokens: &declaration,
},
ModuleFile {
path: Path::new("src/tests.rs"),
language: Language::Rust,
tokens: &body,
},
]);
assert_eq!(suite, vec![false, false]);
}
}