1use 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::{is_test_path, CfgTestRanges};
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}
217
218impl DeclFilter {
219 pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
221 if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
222 return false;
223 }
224 if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
225 return false;
226 }
227 if self.skip_test_code && (is_test_path(path) || decl.in_cfg_test) {
228 return false;
229 }
230 true
231 }
232}
233
234pub fn evaluate(
245 rule: &Rule,
246 kind: CrossFileKind,
247 index: &SymbolIndex,
248 filter: &DeclFilter,
249) -> Vec<Finding> {
250 match kind {
251 CrossFileKind::SingleImplTrait => index
252 .traits
253 .values()
254 .filter(|entry| {
255 entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
256 })
257 .filter_map(|entry| entry.decls.first())
258 .filter(|(path, decl)| filter.allows(path, decl))
259 .map(|(path, decl)| Finding {
260 rule_id: rule.id.clone(),
261 severity: rule.severity.clone(),
262 category: rule.category.clone().unwrap_or_default(),
263 message: rule.message.clone(),
264 note: rule.note.clone(),
265 fix: rule.fix.clone(),
266 file: path.clone(),
267 line: decl.line,
268 column: decl.column,
269 end_line: decl.end_line,
270 end_column: decl.end_column,
271 matched_text: decl.header.clone(),
272 confidence: None,
273 escalated: false,
274 })
275 .collect(),
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use ast_grep_core::tree_sitter::LanguageExt;
282 use ast_grep_language::SupportLang;
283
284 use super::*;
285 use crate::rule::parse_rule;
286
287 fn symbols(source: &str) -> FileSymbols {
288 let root = SupportLang::Rust.ast_grep(source);
289 let cfg_test = CfgTestRanges::from_root(&root);
290 extract_rust_symbols(&root, &cfg_test)
291 }
292
293 #[test]
294 fn extracts_trait_declaration() {
295 let found = symbols("pub trait Repository {\n fn get(&self);\n}\n");
296 assert_eq!(found.traits.len(), 1);
297 let decl = &found.traits[0];
298 assert_eq!(decl.name, "Repository");
299 assert_eq!(decl.line, 1);
300 assert_eq!(decl.column, 1);
301 assert_eq!(decl.header, "pub trait Repository");
302 assert!(!decl.in_cfg_test);
303 }
304
305 #[test]
306 fn extracts_impl_trait_for_type() {
307 let found = symbols("impl Repository for Postgres {\n fn get(&self) {}\n}\n");
308 assert_eq!(found.impls.len(), 1);
309 assert_eq!(found.impls[0].trait_name, "Repository");
310 assert!(!found.impls[0].blanket);
311 }
312
313 #[test]
314 fn inherent_impl_contributes_nothing() {
315 let found = symbols("impl Foo {\n fn a(&self) {}\n}\n");
316 assert!(found.impls.is_empty());
317 }
318
319 #[test]
320 fn scoped_trait_name_resolves_to_last_segment() {
321 let found = symbols("impl fmt::Display for X {}\n");
322 assert_eq!(found.impls[0].trait_name, "Display");
323 }
324
325 #[test]
326 fn generic_trait_name_strips_arguments() {
327 let found = symbols("impl From<u32> for X {}\n");
328 assert_eq!(found.impls[0].trait_name, "From");
329 }
330
331 #[test]
332 fn blanket_impl_detected() {
333 let found = symbols("impl<T> Describe for T {}\n");
334 assert_eq!(found.impls.len(), 1);
335 assert!(found.impls[0].blanket);
336 }
337
338 #[test]
339 fn generic_impl_on_concrete_type_is_not_blanket() {
340 let found = symbols("impl<T: Clone> Describe for Wrapper<T> {}\n");
341 assert_eq!(found.impls.len(), 1);
342 assert!(!found.impls[0].blanket);
343 }
344
345 #[test]
346 fn lifetime_and_const_params_are_not_blanket() {
347 let lifetime = symbols("impl<'a> Foo for &'a Bar {}\n");
348 assert_eq!(lifetime.impls.len(), 1);
349 assert!(!lifetime.impls[0].blanket);
350
351 let konst = symbols("impl<const N: usize> Foo for [u8; N] {}\n");
352 assert_eq!(konst.impls.len(), 1);
353 assert!(!konst.impls[0].blanket);
354 }
355
356 #[test]
357 fn derive_is_not_an_impl() {
358 let found = symbols("#[derive(Clone)]\nstruct S;\n");
359 assert!(found.impls.is_empty());
360 assert!(found.is_empty());
361 }
362
363 #[test]
364 fn trait_in_cfg_test_is_flagged() {
365 let found = symbols("#[cfg(test)]\nmod tests {\n trait Mockable {}\n}\n");
366 assert_eq!(found.traits.len(), 1);
367 assert!(found.traits[0].in_cfg_test);
368 }
369
370 fn single_impl_rule() -> Rule {
371 parse_rule(
372 r#"
373id: no-single-impl-trait
374language: rust
375severity: warning
376category: slop
377cross_file: single_impl_trait
378message: "Trait with a single implementation in the project."
379"#,
380 )
381 .unwrap()
382 }
383
384 fn decl(name: &str) -> TraitDecl {
385 TraitDecl {
386 name: name.to_string(),
387 line: 3,
388 column: 1,
389 end_line: 3,
390 end_column: 20,
391 in_cfg_test: false,
392 header: format!("pub trait {name}"),
393 }
394 }
395
396 fn concrete(name: &str) -> TraitImpl {
397 TraitImpl {
398 trait_name: name.to_string(),
399 blanket: false,
400 }
401 }
402
403 fn open_filter() -> DeclFilter {
404 DeclFilter {
405 files: None,
406 ignores: None,
407 skip_test_code: false,
408 }
409 }
410
411 fn contribution(
412 path: &str,
413 traits: Vec<TraitDecl>,
414 impls: Vec<TraitImpl>,
415 ) -> (PathBuf, FileSymbols) {
416 (PathBuf::from(path), FileSymbols { traits, impls })
417 }
418
419 fn findings(contributions: &[(PathBuf, FileSymbols)], filter: &DeclFilter) -> Vec<Finding> {
421 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
422 evaluate(
423 &single_impl_rule(),
424 CrossFileKind::SingleImplTrait,
425 &index,
426 filter,
427 )
428 }
429
430 #[test]
431 fn single_concrete_impl_fires() {
432 let contributions = [
433 contribution("src/repo.rs", vec![decl("Repository")], vec![]),
434 contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
435 ];
436 assert_eq!(findings(&contributions, &open_filter()).len(), 1);
437 }
438
439 #[test]
440 fn zero_impls_stays_silent() {
441 let contributions = [contribution(
442 "src/repo.rs",
443 vec![decl("Repository")],
444 vec![],
445 )];
446 assert!(findings(&contributions, &open_filter()).is_empty());
447 }
448
449 #[test]
450 fn two_impls_stay_silent() {
451 let contributions = [
452 contribution("src/repo.rs", vec![decl("Repository")], vec![]),
453 contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
454 contribution("src/mem.rs", vec![], vec![concrete("Repository")]),
455 ];
456 assert!(findings(&contributions, &open_filter()).is_empty());
457 }
458
459 #[test]
460 fn blanket_impl_stays_silent() {
461 let blanket = TraitImpl {
462 trait_name: "Describe".to_string(),
463 blanket: true,
464 };
465 let contributions = [
466 contribution("src/describe.rs", vec![decl("Describe")], vec![blanket]),
467 contribution("src/pg.rs", vec![], vec![concrete("Describe")]),
468 ];
469 assert!(findings(&contributions, &open_filter()).is_empty());
470 }
471
472 #[test]
473 fn duplicate_trait_name_stays_silent() {
474 let contributions = [
475 contribution("src/a.rs", vec![decl("Repository")], vec![]),
476 contribution("src/b.rs", vec![decl("Repository")], vec![]),
477 contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
478 ];
479 assert!(findings(&contributions, &open_filter()).is_empty());
480 }
481
482 #[test]
483 fn impl_without_local_declaration_is_ignored() {
484 let contributions = [contribution(
485 "src/pg.rs",
486 vec![],
487 vec![concrete("Serialize")],
488 )];
489 assert!(findings(&contributions, &open_filter()).is_empty());
490 }
491
492 #[test]
493 fn decl_filter_drops_test_path_declaration() {
494 let contributions = [
495 contribution("tests/support.rs", vec![decl("Repository")], vec![]),
496 contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
497 ];
498 assert_eq!(findings(&contributions, &open_filter()).len(), 1);
499
500 let skipping = DeclFilter {
501 files: None,
502 ignores: None,
503 skip_test_code: true,
504 };
505 assert!(findings(&contributions, &skipping).is_empty());
506 }
507
508 #[test]
509 fn finding_points_at_the_declaration() {
510 let contributions = [
511 contribution("src/repo.rs", vec![decl("Repository")], vec![]),
512 contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
513 ];
514 let found = findings(&contributions, &open_filter());
515 assert_eq!(found.len(), 1);
516 assert_eq!(found[0].file, PathBuf::from("src/repo.rs"));
517 assert_eq!(found[0].line, 3);
518 assert_eq!(found[0].matched_text, "pub trait Repository");
519 }
520}