use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use ast_grep_core::{AstGrep, Doc, Node};
use globset::GlobSet;
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::finding::Finding;
use crate::rule::Rule;
use crate::test_filter::{CfgTestRanges, TestPaths};
mod assertion;
mod names;
mod test_support;
use assertion::AssertionIndex;
pub use assertion::{TestHelper, UnassertedTest};
use names::{bare_trait_name, bare_type_name, generic_param_names, last_segment};
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum CrossFileKind {
SingleImplTrait,
DuplicateErrorMessage,
AssertionFreeTest,
}
const MIN_ERROR_MESSAGE_LEN: usize = 10;
const ERROR_MACROS: &[&str] = &["bail", "anyhow", "eyre", "format_err", "ensure"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraitDecl {
pub name: String,
pub line: usize,
pub column: usize,
pub end_line: usize,
pub end_column: usize,
pub in_cfg_test: bool,
pub header: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraitImpl {
pub trait_name: String,
pub blanket: bool,
pub self_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorMessageLit {
pub text: String,
pub line: usize,
pub column: usize,
pub end_line: usize,
pub end_column: usize,
pub in_cfg_test: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileSymbols {
pub traits: Vec<TraitDecl>,
pub impls: Vec<TraitImpl>,
pub error_messages: Vec<ErrorMessageLit>,
pub types: Vec<String>,
pub helpers: Vec<TestHelper>,
pub unasserted_tests: Vec<UnassertedTest>,
}
impl FileSymbols {
pub fn is_empty(&self) -> bool {
self.traits.is_empty()
&& self.impls.is_empty()
&& self.error_messages.is_empty()
&& self.types.is_empty()
&& self.helpers.is_empty()
&& self.unasserted_tests.is_empty()
}
}
fn string_literal_message<D: Doc>(
node: &Node<D>,
cfg_test: &CfgTestRanges,
) -> Option<ErrorMessageLit> {
if &*node.kind() != "string_literal" {
return None;
}
let raw = node.text();
let raw = raw.trim();
let content = raw.strip_prefix('"')?.strip_suffix('"')?;
if content.chars().count() < MIN_ERROR_MESSAGE_LEN {
return None;
}
let start = node.start_pos();
let end = node.end_pos();
let line = start.line() + 1;
Some(ErrorMessageLit {
text: content.to_string(),
line,
column: start.byte_point().1 + 1,
end_line: end.line() + 1,
end_column: end.byte_point().1 + 1,
in_cfg_test: cfg_test.contains_line(line),
})
}
fn collect_error_messages<D: Doc>(
node: &Node<D>,
cfg_test: &CfgTestRanges,
out: &mut Vec<ErrorMessageLit>,
) {
if &*node.kind() != "macro_invocation" {
return;
}
let is_error_macro = node
.field("macro")
.is_some_and(|m| ERROR_MACROS.contains(&last_segment(&m.text())));
if is_error_macro {
out.extend(
node.dfs()
.filter_map(|n| string_literal_message(&n, cfg_test)),
);
}
}
pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
let mut symbols = FileSymbols::default();
let root_node = root.root();
for node in root_node.dfs() {
match &*node.kind() {
"trait_item" => {
let Some(name) = node.field("name") else {
continue;
};
let start = node.start_pos();
let name_end = name.end_pos();
let line = start.line() + 1;
let text = node.text().to_string();
let header = match text.split_once('{') {
Some((head, _)) => head.trim_end().to_string(),
None => text.trim_end().to_string(),
};
symbols.traits.push(TraitDecl {
name: name.text().to_string(),
line,
column: start.byte_point().1 + 1,
end_line: name_end.line() + 1,
end_column: name_end.byte_point().1 + 1,
in_cfg_test: cfg_test.contains_line(line),
header,
});
}
"impl_item" => {
let Some(trait_ref) = node.field("trait") else {
continue;
};
let params = generic_param_names(&node);
let impl_type = node
.field("type")
.map(|n| n.text().trim().to_string())
.unwrap_or_default();
symbols.impls.push(TraitImpl {
trait_name: bare_trait_name(&trait_ref.text()),
blanket: params.contains(&impl_type),
self_type: bare_type_name(&impl_type),
});
}
"struct_item" | "enum_item" | "union_item" => {
if let Some(name) = node.field("name") {
symbols.types.push(name.text().to_string());
}
}
_ => {}
}
collect_error_messages(&node, cfg_test, &mut symbols.error_messages);
assertion::collect(
&node,
cfg_test,
&mut symbols.helpers,
&mut symbols.unasserted_tests,
);
}
symbols
}
#[derive(Debug, Default)]
pub struct SymbolIndex {
traits: HashMap<String, TraitEntry>,
error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>>,
types: HashMap<String, Vec<PathBuf>>,
assertions: AssertionIndex,
}
#[derive(Debug, Default)]
struct TraitEntry {
decls: Vec<(PathBuf, TraitDecl)>,
concrete_impls: Vec<String>,
blanket_impls: usize,
}
impl SymbolIndex {
pub fn build<'a, I>(contributions: I) -> Self
where
I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
{
let mut traits: HashMap<String, TraitEntry> = HashMap::new();
let mut error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>> = HashMap::new();
let mut types: HashMap<String, Vec<PathBuf>> = HashMap::new();
let mut assertions = AssertionIndex::default();
for (path, symbols) in contributions {
assertions.add(path, &symbols.helpers, &symbols.unasserted_tests);
for decl in &symbols.traits {
traits
.entry(decl.name.clone())
.or_default()
.decls
.push((path.to_path_buf(), decl.clone()));
}
for imp in &symbols.impls {
let entry = traits.entry(imp.trait_name.clone()).or_default();
if imp.blanket {
entry.blanket_impls += 1;
} else {
entry.concrete_impls.push(imp.self_type.clone());
}
}
for name in &symbols.types {
types
.entry(name.clone())
.or_default()
.push(path.to_path_buf());
}
for msg in &symbols.error_messages {
error_messages
.entry(msg.text.clone())
.or_default()
.push((path.to_path_buf(), msg.clone()));
}
}
Self {
traits,
error_messages,
types,
assertions,
}
}
fn declares_local_type(&self, type_name: &str, decl_path: &Path) -> bool {
let decl_crate = crate_root(decl_path);
self.types
.get(type_name)
.is_some_and(|paths| paths.iter().any(|p| crate_root(p) == decl_crate))
}
}
fn crate_root(path: &Path) -> Option<&Path> {
path.ancestors()
.skip(1)
.find(|dir| dir.join("Cargo.toml").is_file())
}
pub struct DeclFilter {
pub files: Option<GlobSet>,
pub ignores: Option<GlobSet>,
pub skip_test_code: bool,
pub(crate) test_paths: TestPaths,
pub assert_functions: Option<GlobSet>,
}
impl DeclFilter {
pub fn allows_path(&self, path: &Path) -> bool {
self.files.as_ref().is_none_or(|g| g.is_match(path))
&& !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
}
pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
self.allows_path(path)
&& !(self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test))
}
pub fn allows_message(&self, path: &Path, msg: &ErrorMessageLit) -> bool {
self.allows_path(path)
&& !(self.skip_test_code && (self.test_paths.is_test(path) || msg.in_cfg_test))
}
}
pub fn evaluate(
rule: &Rule,
kind: CrossFileKind,
index: &SymbolIndex,
filter: &DeclFilter,
) -> Vec<Finding> {
match kind {
CrossFileKind::SingleImplTrait => index
.traits
.values()
.filter(|entry| entry.decls.len() == 1 && entry.blanket_impls == 0)
.filter_map(
|entry| match (entry.decls.first(), entry.concrete_impls.as_slice()) {
(Some(decl), [only]) if index.declares_local_type(only, &decl.0) => Some(decl),
_ => None,
},
)
.filter(|(path, decl)| filter.allows(path, decl))
.map(|(path, decl)| Finding {
rule_id: rule.id.clone(),
severity: rule.severity.clone(),
category: rule.category.clone().unwrap_or_default(),
message: rule.message.clone(),
note: rule.note.clone(),
fix: rule.fix.clone(),
file: path.clone(),
line: decl.line,
column: decl.column,
end_line: decl.end_line,
end_column: decl.end_column,
matched_text: decl.header.clone(),
confidence: None,
escalated: false,
})
.collect(),
CrossFileKind::DuplicateErrorMessage => index
.error_messages
.values()
.flat_map(|occurrences| {
let in_scope: Vec<&(PathBuf, ErrorMessageLit)> = occurrences
.iter()
.filter(|(path, msg)| filter.allows_message(path, msg))
.collect();
let distinct_files: HashSet<&Path> =
in_scope.iter().map(|(path, _)| path.as_path()).collect();
if distinct_files.len() < 2 {
return Vec::new();
}
in_scope
.into_iter()
.map(|(path, msg)| Finding {
rule_id: rule.id.clone(),
severity: rule.severity.clone(),
category: rule.category.clone().unwrap_or_default(),
message: rule.message.clone(),
note: rule.note.clone(),
fix: rule.fix.clone(),
file: path.clone(),
line: msg.line,
column: msg.column,
end_line: msg.end_line,
end_column: msg.end_column,
matched_text: msg.text.clone(),
confidence: None,
escalated: false,
})
.collect()
})
.collect(),
CrossFileKind::AssertionFreeTest => assertion::evaluate(rule, &index.assertions, filter),
}
}
#[cfg(test)]
mod assertion_tests;
#[cfg(test)]
mod tests;