Skip to main content

slopguard_core/cross_file/
assertion.rs

1//! The assertion-free-test analysis. A `#[test]` that checks nothing itself is
2//! reported unless it calls, directly or through other helpers, test code that
3//! asserts. Helpers are matched by bare name across the whole project, so a
4//! shared test helper (`tests/common/mod.rs`, a `*-test-support` crate) is seen.
5
6use 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
19/// Macros whose `name![..]` form is an inline snapshot: the expected value
20/// handed to a checking helper (snapbox `str![]`, expect-test `expect![]`).
21const SNAPSHOT_MACROS: &[&str] = &["str", "expect", "expect_file", "file"];
22
23/// Body statements the compiler checks just by building the test. A test made
24/// only of these (plus typed `let _: T = ..` bindings) is a compile-time check.
25const 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
40/// Body children that neither check nor declare anything.
41const NEUTRAL_KINDS: &[&str] = &[
42    "attribute_item",
43    "inner_attribute_item",
44    "line_comment",
45    "block_comment",
46    "empty_statement",
47];
48
49/// A function or `macro_rules!` macro a test may delegate its checks to.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TestHelper {
52    pub name: String,
53    /// Checks something on its own: `assert`/`panic`, `?`, `.unwrap()` /
54    /// `.expect()`, or an inline snapshot.
55    pub asserts: bool,
56    /// Bare names of the functions, methods and macros it calls, sorted and
57    /// deduplicated.
58    pub calls: Vec<String>,
59    /// The helper sits inside a `#[cfg(test)]` item.
60    pub in_cfg_test: bool,
61}
62
63/// A test function that checks nothing on its own.
64#[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    /// The whole function, reported as `matched_text` like the former AST rule
71    /// did, so existing baseline entries still match.
72    pub text: String,
73    /// Bare names of everything the test calls, sorted and deduplicated.
74    pub calls: Vec<String>,
75}
76
77/// Record a `function_item` or `macro_definition` node: a test that does not
78/// assert becomes an [`UnassertedTest`], any other function or macro that
79/// asserts or calls something becomes a [`TestHelper`]. Other nodes are ignored.
80pub(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
126/// The attributes stacked directly on an item. The walk goes back over
127/// attributes and comments only and stops at the first other sibling, so an
128/// attribute on an earlier item never counts.
129fn 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
143/// `#[test]`, `#[tokio::test(..)]`, `#[cargo_test]`: an attribute whose path's
144/// last segment contains `test`, followed by `]` or `(`. `#[cfg(test)]` does
145/// not qualify: its path is `cfg`.
146fn 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
159/// Whether a function or macro checks something itself: `assert` or `panic`
160/// anywhere in its text (`assert_eq!`, `panic!`, `prop_assert!`, a helper named
161/// `assert_valid`), a `?`, a panicking accessor, or an inline snapshot.
162fn 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
170/// `.unwrap()`, `.expect(..)`, `.unwrap_err()`, `.expect_err(..)`: each fails
171/// the test on the wrong variant, like `?` does. `.unwrap_or(..)` does not.
172fn 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
182/// `str![..]`, `expect![..]`, `expect_file![..]`, `file![..]`.
183fn 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
192/// Bare names of everything under `node` that is called: plain, scoped and
193/// generic function calls, method calls, macro invocations, and the `name(..)`
194/// and `name!` sequences inside macro token trees, whose content is not parsed.
195fn 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
218/// The bare name of a call's callee: `f`, `a::b::f`, `x.f`, `f::<T>`.
219fn 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
229/// Inside a token tree, an identifier directly followed by a parenthesized
230/// token tree (`name(..)`) or by `!` (`name!`) is a call.
231fn 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
250/// Whether a test body only declares items and typed `let _: T = ..`
251/// bindings: it checks that the code compiles (a derive, a trait bound, a type
252/// annotation), which is its whole intent. An empty body is not one.
253fn 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/// Every file's tests and helpers, folded into one index.
275#[derive(Debug, Default)]
276pub(super) struct AssertionIndex {
277    helpers: Vec<(PathBuf, TestHelper)>,
278    tests: Vec<(PathBuf, UnassertedTest)>,
279    /// Every indexed file, even one contributing nothing: its crate's manifest
280    /// may make a support crate a normal dependency.
281    files: Vec<PathBuf>,
282}
283
284impl AssertionIndex {
285    /// Add one file's contribution.
286    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
295/// Report the tests whose calls reach no asserting helper and no function
296/// listed in the rule's `assert_functions` option.
297///
298/// Only test code can vouch for a test: a helper inside `#[cfg(test)]`, in a
299/// test path (built-in layout or `scan.test_paths`), or in a test-support crate.
300/// A production function with a precondition `assert!` does not, or every test
301/// calling a `new` somewhere would be silenced.
302pub(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
346/// The helpers that live in test code. The per-file verdict is computed once
347/// per distinct path: test-support detection reads manifests.
348fn 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
364/// Names of the helpers that assert: directly, through a configured assert
365/// function, or by calling another asserting helper. A worklist over the
366/// reversed call graph reaches the fixpoint in one pass over the edges.
367fn 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}