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