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