1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use ast_grep_core::{AstGrep, Doc, Node};
11use globset::GlobSet;
12use serde::{Deserialize, Serialize};
13use strum::Display;
14
15use crate::finding::Finding;
16use crate::rule::Rule;
17use crate::test_filter::{CfgTestRanges, TestPaths};
18
19mod assertion;
20mod names;
21mod test_support;
22
23use assertion::AssertionIndex;
24pub use assertion::{TestHelper, UnassertedTest};
25use names::{bare_trait_name, bare_type_name, generic_param_names, last_segment};
26
27#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[strum(serialize_all = "snake_case")]
33pub enum CrossFileKind {
34 SingleImplTrait,
35 DuplicateErrorMessage,
36 AssertionFreeTest,
37}
38
39const MIN_ERROR_MESSAGE_LEN: usize = 10;
42
43const ERROR_MACROS: &[&str] = &["bail", "anyhow", "eyre", "format_err", "ensure"];
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TraitDecl {
56 pub name: String,
57 pub line: usize,
58 pub column: usize,
59 pub end_line: usize,
60 pub end_column: usize,
61 pub in_cfg_test: bool,
63 pub header: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TraitImpl {
70 pub trait_name: String,
72 pub blanket: bool,
75 pub self_type: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ErrorMessageLit {
84 pub text: String,
86 pub line: usize,
87 pub column: usize,
88 pub end_line: usize,
89 pub end_column: usize,
90 pub in_cfg_test: bool,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct FileSymbols {
102 pub traits: Vec<TraitDecl>,
103 pub impls: Vec<TraitImpl>,
104 pub error_messages: Vec<ErrorMessageLit>,
105 pub types: Vec<String>,
109 pub helpers: Vec<TestHelper>,
111 pub unasserted_tests: Vec<UnassertedTest>,
113}
114
115impl FileSymbols {
116 pub fn is_empty(&self) -> bool {
118 self.traits.is_empty()
119 && self.impls.is_empty()
120 && self.error_messages.is_empty()
121 && self.types.is_empty()
122 && self.helpers.is_empty()
123 && self.unasserted_tests.is_empty()
124 }
125}
126
127fn string_literal_message<D: Doc>(
132 node: &Node<D>,
133 cfg_test: &CfgTestRanges,
134) -> Option<ErrorMessageLit> {
135 if &*node.kind() != "string_literal" {
136 return None;
137 }
138 let raw = node.text();
139 let raw = raw.trim();
140 let content = raw.strip_prefix('"')?.strip_suffix('"')?;
141 if content.chars().count() < MIN_ERROR_MESSAGE_LEN {
142 return None;
143 }
144 let start = node.start_pos();
145 let end = node.end_pos();
146 let line = start.line() + 1;
147 Some(ErrorMessageLit {
148 text: content.to_string(),
149 line,
150 column: start.byte_point().1 + 1,
151 end_line: end.line() + 1,
152 end_column: end.byte_point().1 + 1,
153 in_cfg_test: cfg_test.contains_line(line),
154 })
155}
156
157fn collect_error_messages<D: Doc>(
160 node: &Node<D>,
161 cfg_test: &CfgTestRanges,
162 out: &mut Vec<ErrorMessageLit>,
163) {
164 if &*node.kind() != "macro_invocation" {
165 return;
166 }
167 let is_error_macro = node
168 .field("macro")
169 .is_some_and(|m| ERROR_MACROS.contains(&last_segment(&m.text())));
170 if is_error_macro {
171 out.extend(
172 node.dfs()
173 .filter_map(|n| string_literal_message(&n, cfg_test)),
174 );
175 }
176}
177
178pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
183 let mut symbols = FileSymbols::default();
184 let root_node = root.root();
185 for node in root_node.dfs() {
186 match &*node.kind() {
187 "trait_item" => {
188 let Some(name) = node.field("name") else {
189 continue;
190 };
191 let start = node.start_pos();
192 let name_end = name.end_pos();
193 let line = start.line() + 1;
194 let text = node.text().to_string();
195 let header = match text.split_once('{') {
196 Some((head, _)) => head.trim_end().to_string(),
197 None => text.trim_end().to_string(),
198 };
199 symbols.traits.push(TraitDecl {
200 name: name.text().to_string(),
201 line,
202 column: start.byte_point().1 + 1,
203 end_line: name_end.line() + 1,
204 end_column: name_end.byte_point().1 + 1,
205 in_cfg_test: cfg_test.contains_line(line),
206 header,
207 });
208 }
209 "impl_item" => {
210 let Some(trait_ref) = node.field("trait") else {
213 continue;
214 };
215 let params = generic_param_names(&node);
216 let impl_type = node
217 .field("type")
218 .map(|n| n.text().trim().to_string())
219 .unwrap_or_default();
220 symbols.impls.push(TraitImpl {
221 trait_name: bare_trait_name(&trait_ref.text()),
222 blanket: params.contains(&impl_type),
223 self_type: bare_type_name(&impl_type),
224 });
225 }
226 "struct_item" | "enum_item" | "union_item" => {
227 if let Some(name) = node.field("name") {
228 symbols.types.push(name.text().to_string());
229 }
230 }
231 _ => {}
232 }
233 collect_error_messages(&node, cfg_test, &mut symbols.error_messages);
234 assertion::collect(
235 &node,
236 cfg_test,
237 &mut symbols.helpers,
238 &mut symbols.unasserted_tests,
239 );
240 }
241 symbols
242}
243
244#[derive(Debug, Default)]
246pub struct SymbolIndex {
247 traits: HashMap<String, TraitEntry>,
248 error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>>,
251 types: HashMap<String, Vec<PathBuf>>,
254 assertions: AssertionIndex,
255}
256
257#[derive(Debug, Default)]
258struct TraitEntry {
259 decls: Vec<(PathBuf, TraitDecl)>,
262 concrete_impls: Vec<String>,
264 blanket_impls: usize,
265}
266
267impl SymbolIndex {
268 pub fn build<'a, I>(contributions: I) -> Self
275 where
276 I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
277 {
278 let mut traits: HashMap<String, TraitEntry> = HashMap::new();
279 let mut error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>> = HashMap::new();
280 let mut types: HashMap<String, Vec<PathBuf>> = HashMap::new();
281 let mut assertions = AssertionIndex::default();
282 for (path, symbols) in contributions {
283 assertions.add(path, &symbols.helpers, &symbols.unasserted_tests);
284 for decl in &symbols.traits {
285 traits
286 .entry(decl.name.clone())
287 .or_default()
288 .decls
289 .push((path.to_path_buf(), decl.clone()));
290 }
291 for imp in &symbols.impls {
292 let entry = traits.entry(imp.trait_name.clone()).or_default();
293 if imp.blanket {
294 entry.blanket_impls += 1;
295 } else {
296 entry.concrete_impls.push(imp.self_type.clone());
297 }
298 }
299 for name in &symbols.types {
300 types
301 .entry(name.clone())
302 .or_default()
303 .push(path.to_path_buf());
304 }
305 for msg in &symbols.error_messages {
306 error_messages
307 .entry(msg.text.clone())
308 .or_default()
309 .push((path.to_path_buf(), msg.clone()));
310 }
311 }
312 Self {
313 traits,
314 error_messages,
315 types,
316 assertions,
317 }
318 }
319
320 fn declares_local_type(&self, type_name: &str, decl_path: &Path) -> bool {
324 let decl_crate = crate_root(decl_path);
325 self.types
326 .get(type_name)
327 .is_some_and(|paths| paths.iter().any(|p| crate_root(p) == decl_crate))
328 }
329}
330
331fn crate_root(path: &Path) -> Option<&Path> {
334 path.ancestors()
335 .skip(1)
336 .find(|dir| dir.join("Cargo.toml").is_file())
337}
338
339pub struct DeclFilter {
343 pub files: Option<GlobSet>,
344 pub ignores: Option<GlobSet>,
345 pub skip_test_code: bool,
346 pub(crate) test_paths: TestPaths,
347 pub assert_functions: Option<GlobSet>,
350}
351
352impl DeclFilter {
353 pub fn allows_path(&self, path: &Path) -> bool {
355 self.files.as_ref().is_none_or(|g| g.is_match(path))
356 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
357 }
358
359 pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
361 self.allows_path(path)
362 && !(self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test))
363 }
364
365 pub fn allows_message(&self, path: &Path, msg: &ErrorMessageLit) -> bool {
368 self.allows_path(path)
369 && !(self.skip_test_code && (self.test_paths.is_test(path) || msg.in_cfg_test))
370 }
371}
372
373pub fn evaluate(
390 rule: &Rule,
391 kind: CrossFileKind,
392 index: &SymbolIndex,
393 filter: &DeclFilter,
394) -> Vec<Finding> {
395 match kind {
396 CrossFileKind::SingleImplTrait => index
397 .traits
398 .values()
399 .filter(|entry| entry.decls.len() == 1 && entry.blanket_impls == 0)
400 .filter_map(
401 |entry| match (entry.decls.first(), entry.concrete_impls.as_slice()) {
402 (Some(decl), [only]) if index.declares_local_type(only, &decl.0) => Some(decl),
403 _ => None,
404 },
405 )
406 .filter(|(path, decl)| filter.allows(path, decl))
407 .map(|(path, decl)| Finding {
408 rule_id: rule.id.clone(),
409 severity: rule.severity.clone(),
410 category: rule.category.clone().unwrap_or_default(),
411 message: rule.message.clone(),
412 note: rule.note.clone(),
413 fix: rule.fix.clone(),
414 file: path.clone(),
415 line: decl.line,
416 column: decl.column,
417 end_line: decl.end_line,
418 end_column: decl.end_column,
419 matched_text: decl.header.clone(),
420 confidence: None,
421 escalated: false,
422 })
423 .collect(),
424 CrossFileKind::DuplicateErrorMessage => index
425 .error_messages
426 .values()
427 .flat_map(|occurrences| {
428 let in_scope: Vec<&(PathBuf, ErrorMessageLit)> = occurrences
429 .iter()
430 .filter(|(path, msg)| filter.allows_message(path, msg))
431 .collect();
432 let distinct_files: HashSet<&Path> =
433 in_scope.iter().map(|(path, _)| path.as_path()).collect();
434 if distinct_files.len() < 2 {
435 return Vec::new();
436 }
437 in_scope
438 .into_iter()
439 .map(|(path, msg)| Finding {
440 rule_id: rule.id.clone(),
441 severity: rule.severity.clone(),
442 category: rule.category.clone().unwrap_or_default(),
443 message: rule.message.clone(),
444 note: rule.note.clone(),
445 fix: rule.fix.clone(),
446 file: path.clone(),
447 line: msg.line,
448 column: msg.column,
449 end_line: msg.end_line,
450 end_column: msg.end_column,
451 matched_text: msg.text.clone(),
452 confidence: None,
453 escalated: false,
454 })
455 .collect()
456 })
457 .collect(),
458 CrossFileKind::AssertionFreeTest => assertion::evaluate(rule, &index.assertions, filter),
459 }
460}
461
462#[cfg(test)]
463mod assertion_tests;
464#[cfg(test)]
465mod tests;