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