slopguard_core/cross_file/
mod.rs1use std::collections::HashMap;
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}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TraitDecl {
32 pub name: String,
33 pub line: usize,
34 pub column: usize,
35 pub end_line: usize,
36 pub end_column: usize,
37 pub in_cfg_test: bool,
39 pub header: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TraitImpl {
46 pub trait_name: String,
48 pub blanket: bool,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct FileSymbols {
61 pub traits: Vec<TraitDecl>,
62 pub impls: Vec<TraitImpl>,
63}
64
65impl FileSymbols {
66 pub fn is_empty(&self) -> bool {
68 self.traits.is_empty() && self.impls.is_empty()
69 }
70}
71
72fn bare_trait_name(text: &str) -> String {
77 let base = text.split('<').next().unwrap_or(text);
78 base.rsplit("::").next().unwrap_or(base).trim().to_string()
79}
80
81fn generic_param_names<D: Doc>(impl_node: &Node<D>) -> Vec<String> {
85 let Some(params) = impl_node.field("type_parameters") else {
86 return Vec::new();
87 };
88 params
89 .children()
90 .filter(|child| child.is_named())
91 .filter_map(|child| {
92 let text = child.text().to_string();
93 let head = text
96 .split([':', '=', '<'])
97 .next()
98 .unwrap_or(&text)
99 .trim()
100 .to_string();
101 let starts_ok = head.starts_with(|c: char| c.is_alphabetic() || c == '_');
102 let rest_ok = head.chars().all(|c| c.is_alphanumeric() || c == '_');
103 (starts_ok && rest_ok).then_some(head)
104 })
105 .collect()
106}
107
108pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
113 let mut symbols = FileSymbols::default();
114 let root_node = root.root();
115 for node in root_node.dfs() {
116 match &*node.kind() {
117 "trait_item" => {
118 let Some(name) = node.field("name") else {
119 continue;
120 };
121 let start = node.start_pos();
122 let name_end = name.end_pos();
123 let line = start.line() + 1;
124 let text = node.text().to_string();
125 let header = match text.split_once('{') {
126 Some((head, _)) => head.trim_end().to_string(),
127 None => text.trim_end().to_string(),
128 };
129 symbols.traits.push(TraitDecl {
130 name: name.text().to_string(),
131 line,
132 column: start.byte_point().1 + 1,
133 end_line: name_end.line() + 1,
134 end_column: name_end.byte_point().1 + 1,
135 in_cfg_test: cfg_test.contains_line(line),
136 header,
137 });
138 }
139 "impl_item" => {
140 let Some(trait_ref) = node.field("trait") else {
143 continue;
144 };
145 let params = generic_param_names(&node);
146 let impl_type = node
147 .field("type")
148 .map(|n| n.text().trim().to_string())
149 .unwrap_or_default();
150 symbols.impls.push(TraitImpl {
151 trait_name: bare_trait_name(&trait_ref.text()),
152 blanket: params.contains(&impl_type),
153 });
154 }
155 _ => {}
156 }
157 }
158 symbols
159}
160
161#[derive(Debug, Default)]
163pub struct SymbolIndex {
164 traits: HashMap<String, TraitEntry>,
165}
166
167#[derive(Debug, Default)]
168struct TraitEntry {
169 decls: Vec<(PathBuf, TraitDecl)>,
172 concrete_impls: usize,
173 blanket_impls: usize,
174}
175
176impl SymbolIndex {
177 pub fn build<'a, I>(contributions: I) -> Self
184 where
185 I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
186 {
187 let mut traits: HashMap<String, TraitEntry> = HashMap::new();
188 for (path, symbols) in contributions {
189 for decl in &symbols.traits {
190 traits
191 .entry(decl.name.clone())
192 .or_default()
193 .decls
194 .push((path.to_path_buf(), decl.clone()));
195 }
196 for imp in &symbols.impls {
197 let entry = traits.entry(imp.trait_name.clone()).or_default();
198 if imp.blanket {
199 entry.blanket_impls += 1;
200 } else {
201 entry.concrete_impls += 1;
202 }
203 }
204 }
205 Self { traits }
206 }
207}
208
209pub struct DeclFilter {
213 pub files: Option<GlobSet>,
214 pub ignores: Option<GlobSet>,
215 pub skip_test_code: bool,
216 pub(crate) test_paths: TestPaths,
217}
218
219impl DeclFilter {
220 pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
222 if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
223 return false;
224 }
225 if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
226 return false;
227 }
228 if self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test) {
229 return false;
230 }
231 true
232 }
233}
234
235pub fn evaluate(
246 rule: &Rule,
247 kind: CrossFileKind,
248 index: &SymbolIndex,
249 filter: &DeclFilter,
250) -> Vec<Finding> {
251 match kind {
252 CrossFileKind::SingleImplTrait => index
253 .traits
254 .values()
255 .filter(|entry| {
256 entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
257 })
258 .filter_map(|entry| entry.decls.first())
259 .filter(|(path, decl)| filter.allows(path, decl))
260 .map(|(path, decl)| Finding {
261 rule_id: rule.id.clone(),
262 severity: rule.severity.clone(),
263 category: rule.category.clone().unwrap_or_default(),
264 message: rule.message.clone(),
265 note: rule.note.clone(),
266 fix: rule.fix.clone(),
267 file: path.clone(),
268 line: decl.line,
269 column: decl.column,
270 end_line: decl.end_line,
271 end_column: decl.end_column,
272 matched_text: decl.header.clone(),
273 confidence: None,
274 escalated: false,
275 })
276 .collect(),
277 }
278}
279
280#[cfg(test)]
281mod tests;