slopguard_core/cross_file/
assertion.rs1use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use ast_grep_core::{Doc, Node};
10use serde::{Deserialize, Serialize};
11
12use super::names::last_segment;
13use super::test_support::SupportCrates;
14use super::DeclFilter;
15use crate::finding::Finding;
16use crate::rule::Rule;
17use crate::test_filter::CfgTestRanges;
18
19const SNAPSHOT_MACROS: &[&str] = &["str", "expect", "expect_file", "file"];
22
23const COMPILE_ONLY_KINDS: &[&str] = &[
26 "struct_item",
27 "enum_item",
28 "union_item",
29 "impl_item",
30 "trait_item",
31 "function_item",
32 "type_item",
33 "const_item",
34 "static_item",
35 "use_declaration",
36 "mod_item",
37 "macro_definition",
38];
39
40const NEUTRAL_KINDS: &[&str] = &[
42 "attribute_item",
43 "inner_attribute_item",
44 "line_comment",
45 "block_comment",
46 "empty_statement",
47];
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TestHelper {
52 pub name: String,
53 pub asserts: bool,
56 pub calls: Vec<String>,
59 pub in_cfg_test: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct UnassertedTest {
66 pub line: usize,
67 pub column: usize,
68 pub end_line: usize,
69 pub end_column: usize,
70 pub text: String,
73 pub calls: Vec<String>,
75}
76
77pub(super) fn collect<D: Doc>(
81 node: &Node<D>,
82 cfg_test: &CfgTestRanges,
83 helpers: &mut Vec<TestHelper>,
84 tests: &mut Vec<UnassertedTest>,
85) {
86 let kind = node.kind();
87 if !matches!(&*kind, "function_item" | "macro_definition") {
88 return;
89 }
90 let Some(name) = node.field("name") else {
91 return;
92 };
93 let text = node.text();
94 let asserts = asserts_directly(node, &text);
95 let calls = called_names(node);
96 let start = node.start_pos();
97 let line = start.line() + 1;
98 let attributes = stacked_attributes(node);
99 let is_test = &*kind == "function_item" && attributes.iter().any(|a| is_test_attribute(a));
100 if !is_test {
101 if asserts || !calls.is_empty() {
102 helpers.push(TestHelper {
103 name: name.text().to_string(),
104 asserts,
105 calls,
106 in_cfg_test: cfg_test.contains_line(line),
107 });
108 }
109 return;
110 }
111 let should_panic = attributes.iter().any(|a| a.contains("should_panic"));
112 if should_panic || asserts || is_compile_only(node) {
113 return;
114 }
115 let end = node.end_pos();
116 tests.push(UnassertedTest {
117 line,
118 column: start.byte_point().1 + 1,
119 end_line: end.line() + 1,
120 end_column: end.byte_point().1 + 1,
121 text: text.to_string(),
122 calls,
123 });
124}
125
126fn stacked_attributes<D: Doc>(node: &Node<D>) -> Vec<String> {
130 let mut attributes = Vec::new();
131 let mut current = node.prev();
132 while let Some(sibling) = current {
133 match &*sibling.kind() {
134 "attribute_item" => attributes.push(sibling.text().to_string()),
135 "line_comment" | "block_comment" => {}
136 _ => break,
137 }
138 current = sibling.prev();
139 }
140 attributes
141}
142
143fn is_test_attribute(attribute: &str) -> bool {
147 let Some(inner) = attribute.strip_prefix("#[") else {
148 return false;
149 };
150 let inner = inner.trim_start();
151 let path_len = inner
152 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == ':'))
153 .unwrap_or(inner.len());
154 let (path, rest) = inner.split_at(path_len);
155 let closes = rest.trim_start().starts_with([']', '(']);
156 closes && last_segment(path).contains("test")
157}
158
159fn asserts_directly<D: Doc>(node: &Node<D>, text: &str) -> bool {
163 text.contains("assert")
164 || text.contains("panic")
165 || has_panicking_accessor(text)
166 || has_inline_snapshot(text)
167 || node.dfs().any(|n| &*n.kind() == "try_expression")
168}
169
170fn has_panicking_accessor(text: &str) -> bool {
173 [".unwrap", ".expect"].iter().any(|method| {
174 text.match_indices(method).any(|(at, found)| {
175 let rest = &text[at + found.len()..];
176 let rest = rest.strip_prefix("_err").unwrap_or(rest);
177 rest.trim_start().starts_with('(')
178 })
179 })
180}
181
182fn has_inline_snapshot(text: &str) -> bool {
184 text.match_indices('!').any(|(at, _)| {
185 let before = &text[..at];
186 let prefix = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
187 let name = &before[prefix.len()..];
188 SNAPSHOT_MACROS.contains(&name) && text[at + 1..].trim_start().starts_with('[')
189 })
190}
191
192fn called_names<D: Doc>(node: &Node<D>) -> Vec<String> {
196 let mut names = Vec::new();
197 for descendant in node.dfs() {
198 match &*descendant.kind() {
199 "call_expression" => {
200 names.extend(descendant.field("function").and_then(|f| callee_name(&f)));
201 }
202 "macro_invocation" => {
203 names.extend(
204 descendant
205 .field("macro")
206 .map(|m| last_segment(&m.text()).to_string()),
207 );
208 }
209 "token_tree" => names.extend(token_tree_calls(&descendant)),
210 _ => {}
211 }
212 }
213 names.sort();
214 names.dedup();
215 names
216}
217
218fn callee_name<D: Doc>(callee: &Node<D>) -> Option<String> {
220 match &*callee.kind() {
221 "identifier" => Some(callee.text().to_string()),
222 "scoped_identifier" => callee.field("name").map(|n| n.text().to_string()),
223 "field_expression" => callee.field("field").map(|n| n.text().to_string()),
224 "generic_function" => callee.field("function").and_then(|f| callee_name(&f)),
225 _ => None,
226 }
227}
228
229fn token_tree_calls<D: Doc>(tree: &Node<D>) -> Vec<String> {
232 let children: Vec<Node<D>> = tree.children().collect();
233 children
234 .windows(2)
235 .filter_map(|pair| {
236 let [ident, next] = pair else {
237 return None;
238 };
239 if &*ident.kind() != "identifier" {
240 return None;
241 }
242 let next_text = next.text();
243 let is_call =
244 (&*next.kind() == "token_tree" && next_text.starts_with('(')) || next_text == "!";
245 is_call.then(|| ident.text().to_string())
246 })
247 .collect()
248}
249
250fn is_compile_only<D: Doc>(function: &Node<D>) -> bool {
254 let Some(body) = function.field("body") else {
255 return false;
256 };
257 let mut declares = false;
258 for child in body.children().filter(|c| c.is_named()) {
259 let kind = child.kind();
260 if NEUTRAL_KINDS.contains(&&*kind) {
261 continue;
262 }
263 let typed_discard = &*kind == "let_declaration"
264 && child.field("type").is_some()
265 && child.field("pattern").is_some_and(|p| p.text() == "_");
266 if !(typed_discard || COMPILE_ONLY_KINDS.contains(&&*kind)) {
267 return false;
268 }
269 declares = true;
270 }
271 declares
272}
273
274#[derive(Debug, Default)]
276pub(super) struct AssertionIndex {
277 helpers: Vec<(PathBuf, TestHelper)>,
278 tests: Vec<(PathBuf, UnassertedTest)>,
279 files: Vec<PathBuf>,
282}
283
284impl AssertionIndex {
285 pub(super) fn add(&mut self, path: &Path, helpers: &[TestHelper], tests: &[UnassertedTest]) {
287 self.files.push(path.to_path_buf());
288 self.helpers
289 .extend(helpers.iter().map(|h| (path.to_path_buf(), h.clone())));
290 self.tests
291 .extend(tests.iter().map(|t| (path.to_path_buf(), t.clone())));
292 }
293}
294
295pub(super) fn evaluate(rule: &Rule, index: &AssertionIndex, filter: &DeclFilter) -> Vec<Finding> {
303 let candidates: Vec<&(PathBuf, UnassertedTest)> = index
304 .tests
305 .iter()
306 .filter(|(path, _)| filter.allows_path(path))
307 .collect();
308 if candidates.is_empty() {
309 return Vec::new();
310 }
311 let configured = |name: &str| {
312 filter
313 .assert_functions
314 .as_ref()
315 .is_some_and(|globs| globs.is_match(name))
316 };
317 let helpers = test_code_helpers(index, filter);
318 let asserting = asserting_names(&helpers, &configured);
319 candidates
320 .into_iter()
321 .filter(|(_, test)| {
322 !test
323 .calls
324 .iter()
325 .any(|call| asserting.contains(call.as_str()) || configured(call))
326 })
327 .map(|(path, test)| Finding {
328 rule_id: rule.id.clone(),
329 severity: rule.severity.clone(),
330 category: rule.category.clone().unwrap_or_default(),
331 message: rule.message.clone(),
332 note: rule.note.clone(),
333 fix: rule.fix.clone(),
334 file: path.clone(),
335 line: test.line,
336 column: test.column,
337 end_line: test.end_line,
338 end_column: test.end_column,
339 matched_text: test.text.clone(),
340 confidence: None,
341 escalated: false,
342 })
343 .collect()
344}
345
346fn test_code_helpers<'a>(index: &'a AssertionIndex, filter: &DeclFilter) -> Vec<&'a TestHelper> {
349 let support = SupportCrates::detect(index.files.iter().map(PathBuf::as_path));
350 let mut test_file: HashMap<&Path, bool> = HashMap::new();
351 index
352 .helpers
353 .iter()
354 .filter(|(path, helper)| {
355 helper.in_cfg_test
356 || *test_file
357 .entry(path.as_path())
358 .or_insert_with(|| filter.test_paths.is_test(path) || support.contains(path))
359 })
360 .map(|(_, helper)| helper)
361 .collect()
362}
363
364fn asserting_names<'a>(
368 helpers: &[&'a TestHelper],
369 configured: &impl Fn(&str) -> bool,
370) -> HashSet<&'a str> {
371 let mut callers: HashMap<&str, Vec<&str>> = HashMap::new();
372 for helper in helpers {
373 for call in &helper.calls {
374 callers
375 .entry(call.as_str())
376 .or_default()
377 .push(helper.name.as_str());
378 }
379 }
380 let mut asserting: HashSet<&str> = HashSet::new();
381 let mut queue: Vec<&str> = Vec::new();
382 for helper in helpers {
383 let seeds = helper.asserts || helper.calls.iter().any(|call| configured(call));
384 if seeds && asserting.insert(helper.name.as_str()) {
385 queue.push(helper.name.as_str());
386 }
387 }
388 while let Some(name) = queue.pop() {
389 for caller in callers.get(name).into_iter().flatten() {
390 if asserting.insert(caller) {
391 queue.push(caller);
392 }
393 }
394 }
395 asserting
396}