pub(crate) mod assertions;
pub(crate) mod coverage;
pub(crate) mod lcov;
pub(crate) mod sut;
pub(crate) mod untested;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use syn::visit::Visit;
use crate::adapters::analyzers::dry::dead_code::DeadCodeWarning;
use crate::adapters::analyzers::dry::DeclaredFunction;
use crate::adapters::analyzers::iosp::scope::ProjectScope;
use crate::adapters::analyzers::iosp::FunctionAnalysis;
use crate::config::Config;
#[derive(Debug, Clone)]
pub struct TqWarning {
pub file: String,
pub line: usize,
pub function_name: String,
pub kind: TqWarningKind,
pub suppressed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TqWarningKind {
NoAssertion,
NoSut,
Untested,
Uncovered,
UntestedLogic {
uncovered_lines: Vec<(String, usize)>,
},
}
#[derive(Debug, Clone, Default)]
pub struct TqAnalysis {
pub warnings: Vec<TqWarning>,
}
pub(crate) struct TqContext<'a> {
pub parsed: &'a [(String, String, syn::File)],
pub scope: &'a ProjectScope,
pub config: &'a Config,
pub declared_fns: &'a [DeclaredFunction],
pub prod_calls: &'a HashSet<String>,
pub test_calls: &'a HashSet<String>,
pub all_results: &'a [FunctionAnalysis],
pub dead_code: &'a [DeadCodeWarning],
pub coverage_path: Option<&'a Path>,
}
#[derive(Default)]
struct FullCallGraphCollector {
functions: Vec<(String, Vec<String>)>,
current_fn: Option<String>,
current_calls: Vec<String>,
}
impl<'ast> Visit<'ast> for FullCallGraphCollector {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
let prev_fn = self.current_fn.take();
let prev_calls = std::mem::take(&mut self.current_calls);
let name = node.sig.ident.to_string();
self.current_fn = Some(name.clone());
syn::visit::visit_item_fn(self, node);
self.functions
.push((name, std::mem::take(&mut self.current_calls)));
self.current_fn = prev_fn;
self.current_calls = prev_calls;
}
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
let prev_fn = self.current_fn.take();
let prev_calls = std::mem::take(&mut self.current_calls);
let name = node.sig.ident.to_string();
self.current_fn = Some(name.clone());
syn::visit::visit_impl_item_fn(self, node);
self.functions
.push((name, std::mem::take(&mut self.current_calls)));
self.current_fn = prev_fn;
self.current_calls = prev_calls;
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if self.current_fn.is_some() {
if let syn::Expr::Path(ref p) = *node.func {
if let Some(last) = p.path.segments.last() {
self.current_calls.push(last.ident.to_string());
}
}
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
if self.current_fn.is_some() {
self.current_calls.push(node.method.to_string());
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
if self.current_fn.is_some() {
if let Some(last) = node.path.segments.last() {
self.current_calls.push(last.ident.to_string());
}
}
syn::visit::visit_expr_path(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
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);
}
}
pub(crate) fn build_full_call_graph(
parsed: &[(String, String, syn::File)],
) -> HashMap<String, Vec<String>> {
let mut graph: HashMap<String, Vec<String>> = HashMap::new();
for (_, _, syntax) in parsed {
let mut collector = FullCallGraphCollector::default();
collector.visit_file(syntax);
for (name, calls) in collector.functions {
graph.entry(name).or_default().extend(calls);
}
}
graph
}
pub(crate) fn build_reaches_prod_set(
call_graph: &HashMap<String, Vec<String>>,
declared_fns: &[DeclaredFunction],
) -> HashSet<String> {
let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
for (caller, callees) in call_graph {
for callee in callees {
reverse
.entry(callee.as_str())
.or_default()
.push(caller.as_str());
}
}
let mut reaches: HashSet<String> = declared_fns
.iter()
.filter(|f| !f.is_test)
.map(|f| f.name.clone())
.collect();
let mut queue: VecDeque<String> = reaches.iter().cloned().collect();
while let Some(name) = queue.pop_front() {
if let Some(callers) = reverse.get(name.as_str()) {
callers.iter().for_each(|caller| {
if reaches.insert(caller.to_string()) {
queue.push_back(caller.to_string());
}
});
}
}
reaches
}
pub(crate) fn analyze_test_quality(ctx: &TqContext<'_>) -> TqAnalysis {
let mut warnings = Vec::new();
let full_graph = build_full_call_graph(ctx.parsed);
let reaches_prod = build_reaches_prod_set(&full_graph, ctx.declared_fns);
let assertion_free = assertions::detect_assertion_free_tests(
ctx.parsed,
&ctx.config.test_quality.extra_assertion_macros,
);
warnings.extend(assertion_free);
let no_sut = sut::detect_no_sut_tests(ctx.parsed, ctx.scope, ctx.declared_fns, &reaches_prod);
warnings.extend(no_sut);
let seed: HashSet<String> = ctx
.test_calls
.iter()
.cloned()
.chain(
ctx.declared_fns
.iter()
.filter(|f| ctx.config.is_ignored_function(&f.name))
.map(|f| f.name.clone()),
)
.collect();
let transitive_tested = untested::build_transitive_tested_set(&seed, &full_graph);
let untested_fns = untested::detect_untested_functions(
ctx.declared_fns,
ctx.prod_calls,
&transitive_tested,
ctx.dead_code,
ctx.config,
);
warnings.extend(untested_fns);
ctx.coverage_path
.and_then(|p| lcov::parse_lcov(p).ok())
.iter()
.for_each(|lcov_data| {
let uncovered = coverage::detect_uncovered_functions(ctx.all_results, lcov_data);
let untested_logic = coverage::detect_untested_logic(ctx.all_results, lcov_data);
warnings.extend(uncovered);
warnings.extend(untested_logic);
});
TqAnalysis { warnings }
}
#[cfg(test)]
mod tests;