Skip to main content

backhopper_core/suites/
matcher.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//! Pluggable strategy for "does this suite reference module M?".
6//!
7//! The built-in [`SubstringMatcher`] does a cheap byte-level substring
8//! search with an atom-character boundary check (`M:` with the byte
9//! before, if any, outside `[a-zA-Z0-9_@]`). It over-includes through
10//! comments and string literals but never silently misses a real
11//! reference: false positives are cheaper than false negatives.
12//!
13//! A more precise implementation exists in `backhopper-xref-reader`
14//! (`AstSuiteMatcher`): it parses each suite into a `ModuleData` and
15//! consults `external_calls`, `unresolved`, and `imports` for actual
16//! references. Suites that fail to load fall back to the substring
17//! matcher.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use crate::model::names::ModuleName;
24
25/// Returns the subset of `triggering` modules referenced by `suite_path`.
26/// Implementations may cache parsed content across calls; the trait is
27/// `&mut self` for that reason.
28pub trait SuiteMatcher {
29    fn modules_referenced_in_suite(
30        &mut self,
31        suite_path: &Path,
32        triggering: &[ModuleName],
33    ) -> BTreeSet<ModuleName>;
34}
35
36/// Substring matcher with a per-path file-content cache.
37#[derive(Debug, Default)]
38pub struct SubstringMatcher {
39    cache: BTreeMap<PathBuf, String>,
40}
41
42impl SubstringMatcher {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    fn cached_text(&mut self, suite_path: &Path) -> &str {
48        self.cache
49            .entry(suite_path.to_path_buf())
50            .or_insert_with(|| {
51                fs::read_to_string(suite_path).unwrap_or_else(|e| {
52                    tracing::warn!(
53                        path = %suite_path.display(),
54                        error = %e,
55                        "suite text read failed; treating as no-match"
56                    );
57                    String::new()
58                })
59            })
60            .as_str()
61    }
62}
63
64impl SuiteMatcher for SubstringMatcher {
65    fn modules_referenced_in_suite(
66        &mut self,
67        suite_path: &Path,
68        triggering: &[ModuleName],
69    ) -> BTreeSet<ModuleName> {
70        let text = self.cached_text(suite_path);
71        triggering
72            .iter()
73            .filter(|m| text_references_module(text, m))
74            .cloned()
75            .collect()
76    }
77}
78
79/// Whether `text` references `module` as `module:` somewhere. The byte
80/// before each match must not be part of an atom identifier, so
81/// `not_rabbit_db:` does not match `rabbit_db`.
82pub fn text_references_module(text: &str, module: &ModuleName) -> bool {
83    text_references_named_module(text, module.as_str())
84}
85
86pub(crate) fn text_references_named_module(text: &str, name: &str) -> bool {
87    let bytes = text.as_bytes();
88    let mut start = 0usize;
89    while let Some(rel) = text[start..].find(name) {
90        let pos = start + rel;
91        if bytes.get(pos + name.len()) == Some(&b':') && (pos == 0 || !is_atom_byte(bytes[pos - 1]))
92        {
93            return true;
94        }
95        start = pos + name.len();
96    }
97    false
98}
99
100fn is_atom_byte(b: u8) -> bool {
101    b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
102}