Skip to main content

backhopper_core/compat/
test_suite.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! `TestSuiteFile<S>` with the type-state pattern.
6//!
7//! Backs candidate 1 of `018_ci_signal_gaps_after_jun_2026_v40_round`.
8//! The three states force the parse, then resolve, then read sequence
9//! at compile time so a caller cannot read missing-module data from a
10//! suite that has not been parsed or resolved against a target tree.
11//!
12//! Pipeline:
13//!
14//!  * `TestSuiteFile<Raw>::parse` → `TestSuiteFile<Parsed>`
15//!  * `TestSuiteFile<Parsed>::resolve` → `TestSuiteFile<Resolved>`
16//!  * only `TestSuiteFile<Resolved>` exposes `missing_modules` and
17//!    `into_findings`
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::marker::PhantomData;
21use std::path::{Path, PathBuf};
22use std::str;
23
24use thiserror::Error;
25
26use crate::compat::otp::is_otp_module;
27use crate::compat::target_tree_index::TargetTreeIndex;
28use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath};
29use crate::model::verdict::{Reason, TestCallSite};
30
31pub mod state {
32    mod sealed {
33        pub trait Sealed {}
34        impl Sealed for super::Raw {}
35        impl Sealed for super::Parsed {}
36        impl Sealed for super::Resolved {}
37    }
38
39    pub trait SuiteState: sealed::Sealed {}
40
41    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42    pub struct Raw;
43    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44    pub struct Parsed;
45    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46    pub struct Resolved;
47
48    impl SuiteState for Raw {}
49    impl SuiteState for Parsed {}
50    impl SuiteState for Resolved {}
51}
52
53use state::{Parsed, Raw, Resolved, SuiteState};
54
55/// One `Module:Function/Arity` reference seen inside the suite source.
56/// Pure parser output: no judgement about whether `module` exists on
57/// any target.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HelperCall {
60    pub module: ModuleName,
61    pub function: FunctionName,
62    pub arity: Arity,
63    pub line: u32,
64}
65
66/// One unresolved helper module on the target, with the call sites
67/// the suite uses to reach it.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct MissingHelper {
70    pub module: ModuleName,
71    pub call_sites: Vec<TestCallSite>,
72}
73
74/// Reasons `TestSuiteFile::<Raw>::parse` may give up. None of these
75/// abort verdict generation: the caller logs and drops the suite from
76/// the missing-module pass.
77#[non_exhaustive]
78#[derive(Debug, Error, PartialEq, Eq)]
79pub enum ParseError {
80    #[error("suite source is empty: {0}")]
81    EmptySource(RelativePath),
82}
83
84/// Modules we never flag as missing even when absent from the target
85/// tree. Two sources:
86///
87///   1. the existing OTP allowlist at `compat::otp::is_otp_module`,
88///      shared with the `untracked_calls` diagnostic so the test-helper
89///      resolver and the call-site classifier agree on what counts as
90///      stdlib;
91///   2. a small set of common-test, property-test, and mocking modules
92///      that live outside OTP itself but are routine in RabbitMQ test
93///      SUITEs (and never sit under `deps/<app>/test`).
94pub fn is_stdlib_or_otp(module: &ModuleName) -> bool {
95    if is_otp_module(module) {
96        return true;
97    }
98    matches!(
99        module.as_str(),
100        "ct" | "ct_helper"
101            | "ct_property_test"
102            | "common_test"
103            | "meck"
104            | "proper"
105            | "proper_types"
106    )
107}
108
109#[derive(Debug, Clone)]
110pub struct TestSuiteFile<S: SuiteState> {
111    suite_path: RelativePath,
112    source: String,
113    calls: Vec<HelperCall>,
114    missing: Vec<MissingHelper>,
115    _state: PhantomData<S>,
116}
117
118impl TestSuiteFile<Raw> {
119    pub fn new(suite_path: RelativePath, source: String) -> Self {
120        Self {
121            suite_path,
122            source,
123            calls: Vec::new(),
124            missing: Vec::new(),
125            _state: PhantomData,
126        }
127    }
128
129    /// Walk the suite source and extract `Module:Function(...)`
130    /// references where `Module` is a literal lowercase atom. Skips
131    /// `?MODULE:`, `apply/3`, dispatch through a variable: those land
132    /// in `Diagnostics.unanalyzed`, not here.
133    pub fn parse(self) -> Result<TestSuiteFile<Parsed>, ParseError> {
134        if self.source.trim().is_empty() {
135            return Err(ParseError::EmptySource(self.suite_path));
136        }
137        let calls = scan_helper_calls(&self.source);
138        Ok(TestSuiteFile {
139            suite_path: self.suite_path,
140            source: self.source,
141            calls,
142            missing: Vec::new(),
143            _state: PhantomData,
144        })
145    }
146
147    pub fn suite_path(&self) -> &RelativePath {
148        &self.suite_path
149    }
150
151    pub fn source(&self) -> &str {
152        &self.source
153    }
154}
155
156impl TestSuiteFile<Parsed> {
157    /// Distinct helper modules referenced by the suite, in
158    /// first-occurrence order. Useful for the always-on diagnostic
159    /// path (no target tree required).
160    pub fn referenced_modules(&self) -> Vec<ModuleName> {
161        let mut seen = BTreeSet::new();
162        let mut out = Vec::new();
163        for c in &self.calls {
164            if seen.insert(c.module.clone()) {
165                out.push(c.module.clone());
166            }
167        }
168        out
169    }
170
171    pub fn calls(&self) -> &[HelperCall] {
172        &self.calls
173    }
174
175    /// Resolve every referenced module against the target tree using
176    /// the per-family `search_path_globs` (e.g. `deps/*/test` for
177    /// `ProjectFamily::Rabbitmq`). Modules in `is_stdlib_or_otp` are
178    /// dropped: they live outside the repo and the operator never
179    /// needs to chase them.
180    pub fn resolve(
181        self,
182        target: &TargetTreeIndex,
183        search_path_globs: &[String],
184    ) -> TestSuiteFile<Resolved> {
185        let resolver = HelperResolver::new(target, search_path_globs);
186        let mut by_module: BTreeMap<ModuleName, Vec<TestCallSite>> = BTreeMap::new();
187        for call in &self.calls {
188            if is_stdlib_or_otp(&call.module) {
189                continue;
190            }
191            if resolver.resolves(&call.module) {
192                continue;
193            }
194            by_module
195                .entry(call.module.clone())
196                .or_default()
197                .push(TestCallSite {
198                    function: call.function.clone(),
199                    arity: call.arity,
200                    line: call.line,
201                });
202        }
203        let missing = by_module
204            .into_iter()
205            .map(|(module, call_sites)| MissingHelper { module, call_sites })
206            .collect();
207        TestSuiteFile {
208            suite_path: self.suite_path,
209            source: self.source,
210            calls: self.calls,
211            missing,
212            _state: PhantomData,
213        }
214    }
215
216    pub fn suite_path(&self) -> &RelativePath {
217        &self.suite_path
218    }
219}
220
221impl TestSuiteFile<Resolved> {
222    pub fn suite_path(&self) -> &RelativePath {
223        &self.suite_path
224    }
225
226    pub fn missing_modules(&self) -> &[MissingHelper] {
227        &self.missing
228    }
229
230    /// Promote the missing helpers into both the typed verdict reasons
231    /// and the diagnostic count map in one pass, without cloning the
232    /// suite source or the parsed calls.
233    pub fn into_findings(self) -> ResolvedFindings {
234        if self.missing.is_empty() {
235            return ResolvedFindings {
236                reasons: Vec::new(),
237                diagnostic_entry: None,
238            };
239        }
240        let mut reasons = Vec::with_capacity(self.missing.len());
241        let mut counts: BTreeMap<ModuleName, usize> = BTreeMap::new();
242        for m in self.missing {
243            counts.insert(m.module.clone(), m.call_sites.len());
244            reasons.push(Reason::TestModuleSymbolMissing {
245                suite_path: self.suite_path.clone(),
246                missing_module: m.module,
247                call_sites: m.call_sites,
248            });
249        }
250        ResolvedFindings {
251            reasons,
252            diagnostic_entry: Some((self.suite_path, counts)),
253        }
254    }
255}
256
257/// Combined verdict and diagnostic output of one resolved suite.
258/// Returned by `TestSuiteFile::<Resolved>::into_findings` so callers
259/// get both surfaces without a clone-then-drop dance.
260#[non_exhaustive]
261#[derive(Debug, Clone, Default)]
262pub struct ResolvedFindings {
263    pub reasons: Vec<Reason>,
264    pub diagnostic_entry: Option<(RelativePath, BTreeMap<ModuleName, usize>)>,
265}
266
267/// Walk the source character by character, balancing `()`, `[]`, `{}`,
268/// `''`, `""`, and `%` line comments. Records `module:function(`
269/// references where `module` is a literal lowercase atom. The walker
270/// is deliberately conservative: anything it cannot disambiguate
271/// (dispatch through a variable, `?MODULE:f`, `apply/3`) is dropped
272/// rather than guessed.
273fn scan_helper_calls(source: &str) -> Vec<HelperCall> {
274    let bytes = source.as_bytes();
275    let mut out = Vec::new();
276    let mut i = 0;
277    let mut line = 1u32;
278    while i < bytes.len() {
279        let b = bytes[i];
280        if b == b'\n' {
281            line += 1;
282            i += 1;
283            continue;
284        }
285        if b == b'%' {
286            while i < bytes.len() && bytes[i] != b'\n' {
287                i += 1;
288            }
289            continue;
290        }
291        if b == b'"' {
292            i = skip_string(bytes, i, b'"');
293            continue;
294        }
295        if b == b'\'' {
296            i = skip_string(bytes, i, b'\'');
297            continue;
298        }
299        // Erlang variables and placeholders start with an uppercase
300        // letter or underscore and continue with identifier chars. We
301        // skip the whole span; otherwise the next iteration would
302        // restart in the middle of `Mod` and treat `od:f(...)` as a
303        // helper call.
304        if b.is_ascii_uppercase() || b == b'_' {
305            while i < bytes.len() && is_atom_continue(bytes[i]) {
306                i += 1;
307            }
308            continue;
309        }
310        // Macros (`?MACRO`) start with `?`. We skip the `?` plus the
311        // following identifier so that `?MODULE:f(...)` does not
312        // surface `module:f(...)` (the next iteration starts after
313        // the macro identifier).
314        if b == b'?' {
315            i += 1;
316            while i < bytes.len() && is_atom_continue(bytes[i]) {
317                i += 1;
318            }
319            continue;
320        }
321        if is_atom_start(b) {
322            let start = i;
323            while i < bytes.len() && is_atom_continue(bytes[i]) {
324                i += 1;
325            }
326            let atom = str::from_utf8(&bytes[start..i]).unwrap_or("");
327            if i < bytes.len() && bytes[i] == b':' {
328                let after_colon = i + 1;
329                let mut j = after_colon;
330                while j < bytes.len() && is_atom_continue(bytes[j]) {
331                    j += 1;
332                }
333                if j > after_colon {
334                    let func = str::from_utf8(&bytes[after_colon..j]).unwrap_or("");
335                    if !func.starts_with(|c: char| c.is_ascii_lowercase()) {
336                        i = j;
337                        continue;
338                    }
339                    let lookahead = skip_spaces(bytes, j);
340                    if lookahead < bytes.len() && bytes[lookahead] == b'(' {
341                        let arity = count_top_level_args(bytes, lookahead);
342                        if let Ok(arity_u8) = u8::try_from(arity)
343                            && let (Ok(module), Ok(function)) =
344                                (ModuleName::new(atom), FunctionName::new(func))
345                        {
346                            out.push(HelperCall {
347                                module,
348                                function,
349                                arity: Arity::new(arity_u8),
350                                line,
351                            });
352                        }
353                        i = lookahead;
354                        continue;
355                    }
356                }
357                i = j;
358                continue;
359            }
360            continue;
361        }
362        i += 1;
363    }
364    out
365}
366
367fn is_atom_start(b: u8) -> bool {
368    b.is_ascii_lowercase()
369}
370
371fn is_atom_continue(b: u8) -> bool {
372    b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
373}
374
375fn skip_string(bytes: &[u8], start: usize, quote: u8) -> usize {
376    let mut i = start + 1;
377    while i < bytes.len() {
378        if bytes[i] == b'\\' && i + 1 < bytes.len() {
379            i += 2;
380            continue;
381        }
382        if bytes[i] == quote {
383            return i + 1;
384        }
385        i += 1;
386    }
387    i
388}
389
390fn skip_spaces(bytes: &[u8], mut i: usize) -> usize {
391    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
392        i += 1;
393    }
394    i
395}
396
397/// Count comma-separated top-level arguments inside the parenthesised
398/// group starting at `bytes[open]` (which must be `b'('`). A call with
399/// no arguments returns 0. Nested brackets and strings are skipped.
400fn count_top_level_args(bytes: &[u8], open: usize) -> u32 {
401    debug_assert_eq!(bytes[open], b'(');
402    let mut depth = 1i32;
403    let mut i = open + 1;
404    let mut saw_token = false;
405    let mut commas = 0u32;
406    while i < bytes.len() && depth > 0 {
407        let b = bytes[i];
408        match b {
409            b'(' | b'[' | b'{' => {
410                depth += 1;
411                saw_token = true;
412            }
413            b')' | b']' | b'}' => {
414                depth -= 1;
415                if depth == 0 {
416                    break;
417                }
418            }
419            b',' if depth == 1 => {
420                commas += 1;
421            }
422            b'"' => {
423                i = skip_string(bytes, i, b'"');
424                saw_token = true;
425                continue;
426            }
427            b'\'' => {
428                i = skip_string(bytes, i, b'\'');
429                saw_token = true;
430                continue;
431            }
432            b'%' => {
433                while i < bytes.len() && bytes[i] != b'\n' {
434                    i += 1;
435                }
436                continue;
437            }
438            b if !b.is_ascii_whitespace() => {
439                saw_token = true;
440            }
441            _ => {}
442        }
443        i += 1;
444    }
445    if saw_token { commas + 1 } else { 0 }
446}
447
448/// Resolve a single module against the same search-path globs the
449/// SUITE helper resolver uses. Exposed for the source-attribute
450/// resolvers (`behaviour_resolves` in `source_attributes`) so the two
451/// surfaces share one glob-expansion implementation.
452pub fn module_resolves(
453    target: &TargetTreeIndex,
454    search_path_globs: &[String],
455    module: &ModuleName,
456) -> bool {
457    HelperResolver::new(target, search_path_globs).resolves(module)
458}
459
460/// Compile per-family glob set into a closed list of
461/// `(prefix_components_count, prefix_components, suffix_components)`
462/// triples. At query time the resolver tests each candidate file path
463/// against each triple: prefix matched component-wise, exactly one
464/// wildcard segment in the middle, suffix matched, filename equals
465/// `<module>.erl`. Linear in the number of present paths on the
466/// target, which the `TargetTreeIndex` already pre-computes.
467struct HelperResolver<'a> {
468    target: &'a TargetTreeIndex,
469    literal_roots: Vec<PathBuf>,
470    wildcard_roots: Vec<WildcardRoot>,
471}
472
473#[derive(Debug, Clone)]
474struct WildcardRoot {
475    prefix: PathBuf,
476    suffix: PathBuf,
477}
478
479impl<'a> HelperResolver<'a> {
480    fn new(target: &'a TargetTreeIndex, search_path_globs: &[String]) -> Self {
481        let mut literal_roots = Vec::new();
482        let mut wildcard_roots = Vec::new();
483        for g in search_path_globs {
484            match split_single_wildcard(g) {
485                Some((prefix, suffix)) => wildcard_roots.push(WildcardRoot { prefix, suffix }),
486                None => literal_roots.push(PathBuf::from(g)),
487            }
488        }
489        Self {
490            target,
491            literal_roots,
492            wildcard_roots,
493        }
494    }
495
496    /// True when `<module>.erl` is present at any literal search root
497    /// or matches any wildcard root on the target tree.
498    fn resolves(&self, module: &ModuleName) -> bool {
499        let filename = format!("{}.erl", module.as_str());
500        for root in &self.literal_roots {
501            if self.target.contains_path(&root.join(&filename)) {
502                return true;
503            }
504        }
505        if self.wildcard_roots.is_empty() {
506            return false;
507        }
508        // Unique-basename fast path: the index resolves the module to its
509        // one `.erl` file, so match that single candidate against the
510        // wildcard roots instead of scanning every present path.
511        if let Some(path) = self.target.module_erl_path(module) {
512            return self
513                .wildcard_roots
514                .iter()
515                .any(|w| path_matches_wildcard(path, &w.prefix, &w.suffix));
516        }
517        // `None`: the module is absent, or two files share the basename
518        // (Erlang-invalid). Fall back to the linear scan so a clashing
519        // file stays matchable.
520        for path in self.target.present_paths() {
521            if path.file_name().and_then(|s| s.to_str()) != Some(filename.as_str()) {
522                continue;
523            }
524            for w in &self.wildcard_roots {
525                if path_matches_wildcard(path, &w.prefix, &w.suffix) {
526                    return true;
527                }
528            }
529        }
530        false
531    }
532}
533
534/// Splits a glob like `deps/*/test` into `(deps, test)` (the segments
535/// before and after the single `*`). Returns `None` when the glob has
536/// no wildcard or has more than one: we only support the single-`*`
537/// form `<prefix>/*/<suffix>`.
538fn split_single_wildcard(glob: &str) -> Option<(PathBuf, PathBuf)> {
539    let segs: Vec<&str> = glob.split('/').collect();
540    let mut star_at = None;
541    for (i, s) in segs.iter().enumerate() {
542        if s.contains('*') {
543            if *s != "*" || star_at.is_some() {
544                return None;
545            }
546            star_at = Some(i);
547        }
548    }
549    let star_at = star_at?;
550    let prefix: PathBuf = segs[..star_at].iter().collect();
551    let suffix: PathBuf = segs[star_at + 1..].iter().collect();
552    Some((prefix, suffix))
553}
554
555/// `path` matches `<prefix>/<one-segment>/<suffix>/<filename>`. The
556/// caller has already verified the filename; this checks the
557/// containing directory.
558fn path_matches_wildcard(path: &Path, prefix: &Path, suffix: &Path) -> bool {
559    let Some(parent) = path.parent() else {
560        return false;
561    };
562    let parent_comps: Vec<_> = parent.components().collect();
563    let prefix_comps: Vec<_> = prefix.components().collect();
564    let suffix_comps: Vec<_> = suffix.components().collect();
565    if parent_comps.len() != prefix_comps.len() + 1 + suffix_comps.len() {
566        return false;
567    }
568    if parent_comps[..prefix_comps.len()] != prefix_comps[..] {
569        return false;
570    }
571    let suffix_start = prefix_comps.len() + 1;
572    parent_comps[suffix_start..] == suffix_comps[..]
573}