slopguard_core/cross_file/
mod.rs1use 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
19#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[strum(serialize_all = "snake_case")]
25pub enum CrossFileKind {
26 SingleImplTrait,
27 DuplicateErrorMessage,
28}
29
30const MIN_ERROR_MESSAGE_LEN: usize = 10;
33
34const ERROR_MACROS: &[&str] = &[
37 "panic",
38 "unreachable",
39 "todo",
40 "unimplemented",
41 "bail",
42 "anyhow",
43];
44
45const ERROR_METHODS: &[&str] = &["expect", "expect_err"];
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TraitDecl {
51 pub name: String,
52 pub line: usize,
53 pub column: usize,
54 pub end_line: usize,
55 pub end_column: usize,
56 pub in_cfg_test: bool,
58 pub header: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TraitImpl {
65 pub trait_name: String,
67 pub blanket: bool,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ErrorMessageLit {
76 pub text: String,
78 pub line: usize,
79 pub column: usize,
80 pub end_line: usize,
81 pub end_column: usize,
82 pub in_cfg_test: bool,
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct FileSymbols {
94 pub traits: Vec<TraitDecl>,
95 pub impls: Vec<TraitImpl>,
96 pub error_messages: Vec<ErrorMessageLit>,
97}
98
99impl FileSymbols {
100 pub fn is_empty(&self) -> bool {
102 self.traits.is_empty() && self.impls.is_empty() && self.error_messages.is_empty()
103 }
104}
105
106fn bare_trait_name(text: &str) -> String {
111 let base = text.split('<').next().unwrap_or(text);
112 base.rsplit("::").next().unwrap_or(base).trim().to_string()
113}
114
115fn generic_param_names<D: Doc>(impl_node: &Node<D>) -> Vec<String> {
119 let Some(params) = impl_node.field("type_parameters") else {
120 return Vec::new();
121 };
122 params
123 .children()
124 .filter(|child| child.is_named())
125 .filter_map(|child| {
126 let text = child.text().to_string();
127 let head = text
130 .split([':', '=', '<'])
131 .next()
132 .unwrap_or(&text)
133 .trim()
134 .to_string();
135 let starts_ok = head.starts_with(|c: char| c.is_alphabetic() || c == '_');
136 let rest_ok = head.chars().all(|c| c.is_alphanumeric() || c == '_');
137 (starts_ok && rest_ok).then_some(head)
138 })
139 .collect()
140}
141
142fn last_segment(text: &str) -> &str {
144 text.rsplit("::").next().unwrap_or(text).trim()
145}
146
147fn string_literal_message<D: Doc>(
152 node: &Node<D>,
153 cfg_test: &CfgTestRanges,
154) -> Option<ErrorMessageLit> {
155 if &*node.kind() != "string_literal" {
156 return None;
157 }
158 let raw = node.text();
159 let raw = raw.trim();
160 let content = raw.strip_prefix('"')?.strip_suffix('"')?;
161 if content.chars().count() < MIN_ERROR_MESSAGE_LEN {
162 return None;
163 }
164 let start = node.start_pos();
165 let end = node.end_pos();
166 let line = start.line() + 1;
167 Some(ErrorMessageLit {
168 text: content.to_string(),
169 line,
170 column: start.byte_point().1 + 1,
171 end_line: end.line() + 1,
172 end_column: end.byte_point().1 + 1,
173 in_cfg_test: cfg_test.contains_line(line),
174 })
175}
176
177fn collect_error_messages<D: Doc>(
180 node: &Node<D>,
181 cfg_test: &CfgTestRanges,
182 out: &mut Vec<ErrorMessageLit>,
183) {
184 match &*node.kind() {
185 "macro_invocation" => {
186 let is_error_macro = node
187 .field("macro")
188 .is_some_and(|m| ERROR_MACROS.contains(&last_segment(&m.text())));
189 if is_error_macro {
190 out.extend(
191 node.dfs()
192 .filter_map(|n| string_literal_message(&n, cfg_test)),
193 );
194 }
195 }
196 "call_expression" => {
197 let is_error_method = node.field("function").is_some_and(|f| {
198 &*f.kind() == "field_expression"
199 && f.field("field")
200 .is_some_and(|name| ERROR_METHODS.contains(&name.text().trim()))
201 });
202 if is_error_method {
203 if let Some(args) = node.field("arguments") {
204 out.extend(
205 args.children()
206 .filter_map(|n| string_literal_message(&n, cfg_test)),
207 );
208 }
209 }
210 }
211 _ => {}
212 }
213}
214
215pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
220 let mut symbols = FileSymbols::default();
221 let root_node = root.root();
222 for node in root_node.dfs() {
223 match &*node.kind() {
224 "trait_item" => {
225 let Some(name) = node.field("name") else {
226 continue;
227 };
228 let start = node.start_pos();
229 let name_end = name.end_pos();
230 let line = start.line() + 1;
231 let text = node.text().to_string();
232 let header = match text.split_once('{') {
233 Some((head, _)) => head.trim_end().to_string(),
234 None => text.trim_end().to_string(),
235 };
236 symbols.traits.push(TraitDecl {
237 name: name.text().to_string(),
238 line,
239 column: start.byte_point().1 + 1,
240 end_line: name_end.line() + 1,
241 end_column: name_end.byte_point().1 + 1,
242 in_cfg_test: cfg_test.contains_line(line),
243 header,
244 });
245 }
246 "impl_item" => {
247 let Some(trait_ref) = node.field("trait") else {
250 continue;
251 };
252 let params = generic_param_names(&node);
253 let impl_type = node
254 .field("type")
255 .map(|n| n.text().trim().to_string())
256 .unwrap_or_default();
257 symbols.impls.push(TraitImpl {
258 trait_name: bare_trait_name(&trait_ref.text()),
259 blanket: params.contains(&impl_type),
260 });
261 }
262 _ => {}
263 }
264 collect_error_messages(&node, cfg_test, &mut symbols.error_messages);
265 }
266 symbols
267}
268
269#[derive(Debug, Default)]
271pub struct SymbolIndex {
272 traits: HashMap<String, TraitEntry>,
273 error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>>,
276}
277
278#[derive(Debug, Default)]
279struct TraitEntry {
280 decls: Vec<(PathBuf, TraitDecl)>,
283 concrete_impls: usize,
284 blanket_impls: usize,
285}
286
287impl SymbolIndex {
288 pub fn build<'a, I>(contributions: I) -> Self
295 where
296 I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
297 {
298 let mut traits: HashMap<String, TraitEntry> = HashMap::new();
299 let mut error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>> = HashMap::new();
300 for (path, symbols) in contributions {
301 for decl in &symbols.traits {
302 traits
303 .entry(decl.name.clone())
304 .or_default()
305 .decls
306 .push((path.to_path_buf(), decl.clone()));
307 }
308 for imp in &symbols.impls {
309 let entry = traits.entry(imp.trait_name.clone()).or_default();
310 if imp.blanket {
311 entry.blanket_impls += 1;
312 } else {
313 entry.concrete_impls += 1;
314 }
315 }
316 for msg in &symbols.error_messages {
317 error_messages
318 .entry(msg.text.clone())
319 .or_default()
320 .push((path.to_path_buf(), msg.clone()));
321 }
322 }
323 Self {
324 traits,
325 error_messages,
326 }
327 }
328}
329
330pub struct DeclFilter {
334 pub files: Option<GlobSet>,
335 pub ignores: Option<GlobSet>,
336 pub skip_test_code: bool,
337 pub(crate) test_paths: TestPaths,
338}
339
340impl DeclFilter {
341 pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
343 if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
344 return false;
345 }
346 if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
347 return false;
348 }
349 if self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test) {
350 return false;
351 }
352 true
353 }
354
355 pub fn allows_message(&self, path: &Path, msg: &ErrorMessageLit) -> bool {
358 if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
359 return false;
360 }
361 if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
362 return false;
363 }
364 if self.skip_test_code && (self.test_paths.is_test(path) || msg.in_cfg_test) {
365 return false;
366 }
367 true
368 }
369}
370
371pub fn evaluate(
382 rule: &Rule,
383 kind: CrossFileKind,
384 index: &SymbolIndex,
385 filter: &DeclFilter,
386) -> Vec<Finding> {
387 match kind {
388 CrossFileKind::SingleImplTrait => index
389 .traits
390 .values()
391 .filter(|entry| {
392 entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
393 })
394 .filter_map(|entry| entry.decls.first())
395 .filter(|(path, decl)| filter.allows(path, decl))
396 .map(|(path, decl)| Finding {
397 rule_id: rule.id.clone(),
398 severity: rule.severity.clone(),
399 category: rule.category.clone().unwrap_or_default(),
400 message: rule.message.clone(),
401 note: rule.note.clone(),
402 fix: rule.fix.clone(),
403 file: path.clone(),
404 line: decl.line,
405 column: decl.column,
406 end_line: decl.end_line,
407 end_column: decl.end_column,
408 matched_text: decl.header.clone(),
409 confidence: None,
410 escalated: false,
411 })
412 .collect(),
413 CrossFileKind::DuplicateErrorMessage => index
414 .error_messages
415 .values()
416 .flat_map(|occurrences| {
417 let in_scope: Vec<&(PathBuf, ErrorMessageLit)> = occurrences
418 .iter()
419 .filter(|(path, msg)| filter.allows_message(path, msg))
420 .collect();
421 let distinct_files: HashSet<&Path> =
422 in_scope.iter().map(|(path, _)| path.as_path()).collect();
423 if distinct_files.len() < 2 {
424 return Vec::new();
425 }
426 in_scope
427 .into_iter()
428 .map(|(path, msg)| Finding {
429 rule_id: rule.id.clone(),
430 severity: rule.severity.clone(),
431 category: rule.category.clone().unwrap_or_default(),
432 message: rule.message.clone(),
433 note: rule.note.clone(),
434 fix: rule.fix.clone(),
435 file: path.clone(),
436 line: msg.line,
437 column: msg.column,
438 end_line: msg.end_line,
439 end_column: msg.end_column,
440 matched_text: msg.text.clone(),
441 confidence: None,
442 escalated: false,
443 })
444 .collect()
445 })
446 .collect(),
447 }
448}
449
450#[cfg(test)]
451mod tests;