use std::collections::HashSet;
use syn::visit::Visit;
use crate::dry::DeclaredFunction;
use crate::scope::ProjectScope;
use super::{TqWarning, TqWarningKind};
pub(crate) fn detect_no_sut_tests(
parsed: &[(String, String, syn::File)],
scope: &ProjectScope,
declared_fns: &[DeclaredFunction],
reaches_prod: &HashSet<String>,
) -> Vec<TqWarning> {
let prod_fn_names: HashSet<&str> = declared_fns
.iter()
.filter(|f| !f.is_test)
.map(|f| f.name.as_str())
.collect();
let mut warnings = Vec::new();
for (path, _, syntax) in parsed {
let mut collector = TestCallCollector::default();
collector.visit_file(syntax);
for test_fn in &collector.test_fns {
let calls_prod = test_fn.call_targets.iter().any(|target| {
prod_fn_names.contains(target.as_str())
|| scope.functions.contains(target)
|| scope.methods.contains(target)
|| reaches_prod.contains(target)
}) || test_fn
.type_qualified_calls
.iter()
.any(|type_name| scope.types.contains(type_name));
if !calls_prod {
warnings.push(TqWarning {
file: path.clone(),
line: test_fn.line,
function_name: test_fn.name.clone(),
kind: TqWarningKind::NoSut,
suppressed: false,
});
}
}
}
warnings
}
struct TestFnWithCalls {
name: String,
line: usize,
call_targets: Vec<String>,
type_qualified_calls: Vec<String>,
}
#[derive(Default)]
struct TestCallCollector {
test_fns: Vec<TestFnWithCalls>,
in_test_fn: bool,
current_calls: Vec<String>,
current_type_calls: Vec<String>,
}
impl<'ast> Visit<'ast> for TestCallCollector {
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if self.in_test_fn {
use syn::punctuated::Punctuated;
if let Ok(args) = syn::parse::Parser::parse2(
Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated,
node.tokens.clone(),
) {
args.iter()
.for_each(|expr| syn::visit::visit_expr(self, expr));
}
}
syn::visit::visit_macro(self, node);
}
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
if has_test_attr(&node.attrs) {
self.in_test_fn = true;
self.current_calls.clear();
self.current_type_calls.clear();
syn::visit::visit_item_fn(self, node);
self.in_test_fn = false;
let line = node.sig.ident.span().start().line;
self.test_fns.push(TestFnWithCalls {
name: node.sig.ident.to_string(),
line,
call_targets: std::mem::take(&mut self.current_calls),
type_qualified_calls: std::mem::take(&mut self.current_type_calls),
});
} else {
syn::visit::visit_item_fn(self, node);
}
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if self.in_test_fn {
if let syn::Expr::Path(ref p) = *node.func {
let name = path_to_name(&p.path);
self.current_calls.push(name);
if let Some(type_name) = path_type_prefix(&p.path) {
self.current_type_calls.push(type_name);
}
}
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
if self.in_test_fn {
self.current_calls.push(node.method.to_string());
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
if self.in_test_fn {
if let Some(last) = node.path.segments.last() {
self.current_type_calls.push(last.ident.to_string());
}
}
syn::visit::visit_expr_struct(self, node);
}
fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
if self.in_test_fn && node.path.segments.len() >= 2 {
let type_seg = &node.path.segments[node.path.segments.len() - 2];
self.current_type_calls.push(type_seg.ident.to_string());
}
syn::visit::visit_expr_path(self, node);
}
}
fn path_to_name(path: &syn::Path) -> String {
path.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default()
}
fn path_type_prefix(path: &syn::Path) -> Option<String> {
let len = path.segments.len();
if len >= 2 {
path.segments
.iter()
.nth(len - 2)
.map(|s| s.ident.to_string())
} else {
None
}
}
use crate::dry::has_test_attr;
#[cfg(test)]
mod tests {
use super::*;
fn make_declared(name: &str, is_test: bool) -> DeclaredFunction {
DeclaredFunction {
name: name.to_string(),
qualified_name: name.to_string(),
file: "lib.rs".to_string(),
line: 1,
is_test,
is_main: false,
is_trait_impl: false,
has_allow_dead_code: false,
is_api: false,
}
}
fn parse_and_detect(source: &str, declared: &[DeclaredFunction]) -> Vec<TqWarning> {
let syntax = syn::parse_file(source).expect("test source");
let parsed = vec![("test.rs".to_string(), source.to_string(), syntax)];
let scope_source = "fn prod_fn() {} fn helper() {}";
let scope_syntax = syn::parse_file(scope_source).expect("scope source");
let scope_refs = vec![("lib.rs", &scope_syntax)];
let scope = ProjectScope::from_files(&scope_refs);
let reaches_prod = HashSet::new();
detect_no_sut_tests(&parsed, &scope, declared, &reaches_prod)
}
#[test]
fn test_calls_prod_function_no_warning() {
let declared = vec![make_declared("prod_fn", false)];
let warnings = parse_and_detect(
r#"
#[test]
fn test_it() {
prod_fn();
assert!(true);
}
"#,
&declared,
);
assert!(warnings.is_empty());
}
#[test]
fn test_calls_only_external_emits_warning() {
let declared = vec![make_declared("prod_fn", false)];
let warnings = parse_and_detect(
r#"
#[test]
fn test_it() {
let x = 42;
}
"#,
&declared,
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, TqWarningKind::NoSut);
}
#[test]
fn test_calls_method_on_scope_no_warning() {
let declared = vec![make_declared("helper", false)];
let warnings = parse_and_detect(
r#"
#[test]
fn test_it() {
helper();
}
"#,
&declared,
);
assert!(warnings.is_empty());
}
#[test]
fn test_non_test_function_ignored() {
let declared = vec![make_declared("prod_fn", false)];
let warnings = parse_and_detect(
r#"
fn not_a_test() {
let x = 42;
}
"#,
&declared,
);
assert!(warnings.is_empty());
}
#[test]
fn test_empty_test_emits_warning() {
let declared = vec![make_declared("prod_fn", false)];
let warnings = parse_and_detect(
r#"
#[test]
fn test_empty() {}
"#,
&declared,
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].function_name, "test_empty");
}
#[test]
fn test_type_constructor_recognized_as_sut() {
let declared = vec![make_declared("new", false)];
let scope_source = "struct MyType {} impl MyType { fn new() -> Self { MyType {} } }";
let scope_syntax = syn::parse_file(scope_source).expect("scope source");
let scope_refs = vec![("lib.rs", &scope_syntax)];
let scope = ProjectScope::from_files(&scope_refs);
let source = r#"
#[test]
fn test_constructor() {
let x = MyType::new();
}
"#;
let syntax = syn::parse_file(source).expect("test source");
let parsed = vec![("test.rs".to_string(), source.to_string(), syntax)];
let reaches_prod = HashSet::new();
let warnings = detect_no_sut_tests(&parsed, &scope, &declared, &reaches_prod);
assert!(
warnings.is_empty(),
"MyType::new() should be recognized as SUT call"
);
}
#[test]
fn test_static_method_recognized_as_sut() {
let declared = vec![make_declared("load", false)];
let scope_source = "struct Config {} impl Config { fn load() -> Self { Config {} } }";
let scope_syntax = syn::parse_file(scope_source).expect("scope source");
let scope_refs = vec![("lib.rs", &scope_syntax)];
let scope = ProjectScope::from_files(&scope_refs);
let source = r#"
#[test]
fn test_load() {
let c = Config::load();
}
"#;
let syntax = syn::parse_file(source).expect("test source");
let parsed = vec![("test.rs".to_string(), source.to_string(), syntax)];
let reaches_prod = HashSet::new();
let warnings = detect_no_sut_tests(&parsed, &scope, &declared, &reaches_prod);
assert!(
warnings.is_empty(),
"Config::load() should be recognized as SUT call"
);
}
#[test]
fn test_transitive_sut_via_helper() {
let declared = vec![make_declared("prod_fn", false)];
let scope_source = "fn prod_fn() {}";
let scope_syntax = syn::parse_file(scope_source).expect("scope source");
let scope_refs = vec![("lib.rs", &scope_syntax)];
let scope = ProjectScope::from_files(&scope_refs);
let source = r#"
#[test]
fn test_via_helper() {
my_helper();
}
"#;
let syntax = syn::parse_file(source).expect("test source");
let parsed = vec![("test.rs".to_string(), source.to_string(), syntax)];
let reaches_prod: HashSet<String> = ["my_helper".to_string()].into();
let warnings = detect_no_sut_tests(&parsed, &scope, &declared, &reaches_prod);
assert!(
warnings.is_empty(),
"my_helper transitively calls prod code"
);
}
}