Skip to main content

lanekeep_core/
gates.rs

1//! Rejecting files before they are read or parsed.
2//!
3//! Architecture §7.1 calls this the single largest performance lever available. A rule
4//! scoped to `makeStyles` skips parsing every file whose bytes do not contain that string,
5//! and parsing is the dominant cost.
6//!
7//! Gates are **purely an optimization**. Removing one changes which files get parsed, never
8//! which violations get reported — a gate that rejects a file the handler would have
9//! reported on is a bug in the rule, not a feature of the gate. The tests hold that line by
10//! checking gates only ever narrow.
11//!
12//! They are evaluated in cost order:
13//!
14//! 1. **Path** — no file read at all.
15//! 2. **Content** — one read, a substring scan, no parse.
16
17use globset::{Glob, GlobSet, GlobSetBuilder};
18use serde::Deserialize;
19use thiserror::Error;
20
21use crate::location::FilePath;
22
23/// The gates a rule declares, as written in config.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
25#[serde(rename_all = "camelCase", default)]
26pub struct Gates {
27    /// Only files matching one of these are considered.
28    pub path_matches: Vec<String>,
29    /// Files matching any of these are skipped.
30    pub path_not_matches: Vec<String>,
31    /// Only files whose bytes contain **all** of these are parsed.
32    pub file_contains: Vec<String>,
33    /// Files whose bytes contain **any** of these are skipped.
34    pub file_not_contains: Vec<String>,
35}
36
37impl Gates {
38    /// Whether any gate is declared. A rule without gates parses every candidate file.
39    #[must_use]
40    pub fn is_empty(&self) -> bool {
41        self.path_matches.is_empty()
42            && self.path_not_matches.is_empty()
43            && self.file_contains.is_empty()
44            && self.file_not_contains.is_empty()
45    }
46}
47
48/// A malformed gate pattern.
49#[derive(Debug, Clone, PartialEq, Eq, Error)]
50#[error("invalid `{field}` gate pattern `{pattern}`: {detail}")]
51pub struct GateError {
52    /// Which gate field it came from.
53    pub field: &'static str,
54    /// The pattern as written.
55    pattern: String,
56    /// What is wrong with it.
57    detail: String,
58}
59
60/// Gates compiled for repeated evaluation.
61#[derive(Debug)]
62pub struct CompiledGates {
63    path_matches: Option<GlobSet>,
64    path_not_matches: Option<GlobSet>,
65    file_contains: Vec<String>,
66    file_not_contains: Vec<String>,
67}
68
69impl CompiledGates {
70    /// Compile a rule's gates.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`GateError`] for a malformed glob, naming the field it came from.
75    pub fn compile(gates: &Gates) -> Result<Self, GateError> {
76        Ok(Self {
77            path_matches: compile_set(&gates.path_matches, "pathMatches")?,
78            path_not_matches: compile_set(&gates.path_not_matches, "pathNotMatches")?,
79            file_contains: gates.file_contains.clone(),
80            file_not_contains: gates.file_not_contains.clone(),
81        })
82    }
83
84    /// Whether this rule could match anything in a file at this path.
85    ///
86    /// Costs no file read, so it runs first.
87    #[must_use]
88    pub fn admits_path(&self, path: &FilePath) -> bool {
89        let path = path.as_str();
90        if self
91            .path_not_matches
92            .as_ref()
93            .is_some_and(|set| set.is_match(path))
94        {
95            return false;
96        }
97        self.path_matches
98            .as_ref()
99            .is_none_or(|set| set.is_match(path))
100    }
101
102    /// Whether this rule could match anything in these bytes.
103    ///
104    /// Costs one read and a substring scan, and saves a parse. `memchr`'s searcher is used
105    /// rather than a naive scan because this runs once per rule per file, and a file that
106    /// no rule admits is the case worth making cheap.
107    #[must_use]
108    pub fn admits_content(&self, bytes: &[u8]) -> bool {
109        for needle in &self.file_not_contains {
110            if contains(bytes, needle.as_bytes()) {
111                return false;
112            }
113        }
114        // `all`, not `any`: a rule naming two strings needs both present, or it is
115        // declaring something it does not mean.
116        self.file_contains
117            .iter()
118            .all(|needle| contains(bytes, needle.as_bytes()))
119    }
120
121    /// Whether any content gate is declared, so a caller can skip reading when none is.
122    #[must_use]
123    pub fn has_content_gates(&self) -> bool {
124        !self.file_contains.is_empty() || !self.file_not_contains.is_empty()
125    }
126}
127
128fn contains(haystack: &[u8], needle: &[u8]) -> bool {
129    if needle.is_empty() {
130        return true;
131    }
132    memchr::memmem::find(haystack, needle).is_some()
133}
134
135fn compile_set(patterns: &[String], field: &'static str) -> Result<Option<GlobSet>, GateError> {
136    if patterns.is_empty() {
137        return Ok(None);
138    }
139    let mut builder = GlobSetBuilder::new();
140    for pattern in patterns {
141        let glob = Glob::new(pattern).map_err(|e| GateError {
142            field,
143            pattern: pattern.clone(),
144            detail: e.to_string(),
145        })?;
146        builder.add(glob);
147    }
148    builder.build().map(Some).map_err(|e| GateError {
149        field,
150        pattern: patterns.join(", "),
151        detail: e.to_string(),
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn gates(
160        path_matches: &[&str],
161        path_not_matches: &[&str],
162        file_contains: &[&str],
163        file_not_contains: &[&str],
164    ) -> CompiledGates {
165        let own = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect();
166        CompiledGates::compile(&Gates {
167            path_matches: own(path_matches),
168            path_not_matches: own(path_not_matches),
169            file_contains: own(file_contains),
170            file_not_contains: own(file_not_contains),
171        })
172        .expect("compiles")
173    }
174
175    #[test]
176    fn no_gates_admits_everything() {
177        let g = gates(&[], &[], &[], &[]);
178        assert!(g.admits_path(&FilePath::new("anything.ts")));
179        assert!(g.admits_content(b""));
180        assert!(g.admits_content(b"whatever"));
181        assert!(!g.has_content_gates());
182    }
183
184    #[test]
185    fn path_matches_narrows() {
186        let g = gates(&["src/**/*.tsx"], &[], &[], &[]);
187        assert!(g.admits_path(&FilePath::new("src/a/B.tsx")));
188        assert!(!g.admits_path(&FilePath::new("src/a.ts")));
189        assert!(!g.admits_path(&FilePath::new("lib/a.tsx")));
190    }
191
192    #[test]
193    fn path_not_matches_wins_over_path_matches() {
194        let g = gates(&["src/**"], &["**/generated/**"], &[], &[]);
195        assert!(g.admits_path(&FilePath::new("src/a.ts")));
196        assert!(!g.admits_path(&FilePath::new("src/generated/a.ts")));
197    }
198
199    #[test]
200    fn file_contains_requires_all_of_them() {
201        // `all`, not `any`. A rule naming two strings needs both, or it declared something
202        // it did not mean and will parse files it cannot possibly match in.
203        let g = gates(&[], &[], &["makeStyles", "theme"], &[]);
204        assert!(g.admits_content(b"import { makeStyles } from 'x'; theme.spacing"));
205        assert!(!g.admits_content(b"import { makeStyles } from 'x'"));
206        assert!(!g.admits_content(b"theme.spacing"));
207    }
208
209    #[test]
210    fn file_not_contains_rejects_on_any_of_them() {
211        let g = gates(&[], &[], &[], &["@generated", "DO NOT EDIT"]);
212        assert!(g.admits_content(b"ordinary source"));
213        assert!(!g.admits_content(b"// @generated by something"));
214        assert!(!g.admits_content(b"// DO NOT EDIT"));
215    }
216
217    #[test]
218    fn content_gates_are_reported_so_a_caller_can_skip_the_read() {
219        assert!(!gates(&["**/*.ts"], &[], &[], &[]).has_content_gates());
220        assert!(gates(&[], &[], &["x"], &[]).has_content_gates());
221        assert!(gates(&[], &[], &[], &["x"]).has_content_gates());
222    }
223
224    #[test]
225    fn gates_only_ever_narrow() {
226        // The property that keeps gates an optimization rather than a semantic. Whatever a
227        // gated rule admits, an ungated one admits too — so removing a gate can never hide
228        // a violation, only slow the run down.
229        let ungated = gates(&[], &[], &[], &[]);
230        let gated = gates(&["src/**"], &["**/gen/**"], &["needle"], &["skip"]);
231
232        for path in ["src/a.ts", "src/gen/a.ts", "lib/a.ts"] {
233            let path = FilePath::new(path);
234            if gated.admits_path(&path) {
235                assert!(
236                    ungated.admits_path(&path),
237                    "gating admitted more for {path}"
238                );
239            }
240        }
241        for bytes in [b"needle".as_slice(), b"skip needle", b"nothing"] {
242            if gated.admits_content(bytes) {
243                assert!(
244                    ungated.admits_content(bytes),
245                    "gating admitted more content"
246                );
247            }
248        }
249    }
250
251    #[test]
252    fn an_empty_needle_matches_anything() {
253        // Degenerate but reachable from config, and it must not reject every file.
254        let g = gates(&[], &[], &[""], &[]);
255        assert!(g.admits_content(b""));
256        assert!(g.admits_content(b"anything"));
257    }
258
259    #[test]
260    fn content_matching_is_byte_exact() {
261        let g = gates(&[], &[], &["makeStyles"], &[]);
262        assert!(
263            !g.admits_content(b"makestyles"),
264            "matching must be case-sensitive"
265        );
266        assert!(g.admits_content("prefix makeStyles suffix".as_bytes()));
267    }
268
269    #[test]
270    fn a_malformed_pattern_names_its_field() {
271        let err = CompiledGates::compile(&Gates {
272            path_matches: vec!["src/[".to_owned()],
273            ..Gates::default()
274        })
275        .expect_err("malformed");
276        assert_eq!(err.field, "pathMatches");
277
278        let err = CompiledGates::compile(&Gates {
279            path_not_matches: vec!["src/[".to_owned()],
280            ..Gates::default()
281        })
282        .expect_err("malformed");
283        assert_eq!(err.field, "pathNotMatches");
284    }
285
286    #[test]
287    fn gates_report_whether_they_are_empty() {
288        assert!(Gates::default().is_empty());
289        assert!(
290            !Gates {
291                file_contains: vec!["x".to_owned()],
292                ..Gates::default()
293            }
294            .is_empty()
295        );
296    }
297}