Skip to main content

lanekeep_core/
discovery.rs

1//! Finding the files to check.
2//!
3//! Discovery is gitignore-aware, so build output and vendored dependencies are skipped
4//! without every project having to exclude them by hand.
5//!
6//! The returned order is sorted. Nothing downstream depends on it — violations are sorted
7//! before reporting — but discovery feeding files to workers in filesystem order would make
8//! the *work distribution* vary between runs on identical input, which turns a timing
9//! difference into something that looks like nondeterminism when a run breaches a budget.
10
11use std::path::{Path, PathBuf};
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use thiserror::Error;
15
16use crate::location::FilePath;
17
18/// Why discovery could not run.
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum DiscoveryError {
21    /// A glob in `include` or `exclude` is malformed.
22    #[error("invalid {field} pattern `{pattern}`: {detail}")]
23    InvalidGlob {
24        /// Which config field it came from.
25        field: &'static str,
26        /// The pattern as written.
27        pattern: String,
28        /// What is wrong with it.
29        detail: String,
30    },
31
32    /// The project root cannot be walked.
33    #[error("cannot read project root `{path}`: {detail}")]
34    Unreadable {
35        /// The root as given.
36        path: String,
37        /// What went wrong.
38        detail: String,
39    },
40}
41
42/// Why discovery would not take a file, asked without walking.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Rejection {
45    /// It is inside lanekeep's own directory, which no configuration includes.
46    Lanekeep,
47    /// An `exclude` glob matched it.
48    Excluded {
49        /// The pattern that matched, as the config wrote it.
50        pattern: String,
51    },
52    /// `include` is non-empty and no pattern in it matched.
53    NotIncluded,
54}
55
56/// Which files a run considers.
57#[derive(Debug)]
58pub struct Discovery {
59    root: PathBuf,
60    include: GlobSet,
61    exclude: GlobSet,
62    has_include: bool,
63    exclude_patterns: Vec<String>,
64}
65
66impl Discovery {
67    /// Build a discovery over a project root.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`DiscoveryError::InvalidGlob`] for a malformed pattern, with the field it
72    /// came from — an error naming only the pattern leaves the reader searching for it.
73    pub fn new(
74        root: impl AsRef<Path>,
75        include: &[String],
76        exclude: &[String],
77    ) -> Result<Self, DiscoveryError> {
78        let root = root.as_ref();
79        let canonical = root
80            .canonicalize()
81            .map_err(|e| DiscoveryError::Unreadable {
82                path: root.display().to_string(),
83                detail: e.to_string(),
84            })?;
85
86        Ok(Self {
87            root: canonical,
88            include: build_set(include, "include")?,
89            exclude: build_set(exclude, "exclude")?,
90            has_include: !include.is_empty(),
91            exclude_patterns: exclude.to_vec(),
92        })
93    }
94
95    /// The project root, canonicalized.
96    #[must_use]
97    pub fn root(&self) -> &Path {
98        &self.root
99    }
100
101    /// Why a path relative to the root would not be checked, without walking.
102    ///
103    /// `None` means the globs select it; the walk may still leave it out (a `.gitignore`
104    /// rule), which only the walk can see.
105    ///
106    /// Exclusion wins over inclusion: a project listing a broad `include` and a narrow
107    /// `exclude` means the exclusion, and the other order would make `exclude` useless.
108    ///
109    /// `.lanekeep/` at the root wins over both, and is not configurable: it is lanekeep's own
110    /// directory — the cache, the precompiled components, the `tsc` driver — and nothing in it
111    /// was written by the project. See `in_lanekeep_directory` below for the whole reasoning.
112    #[must_use]
113    pub fn rejects(&self, relative: &FilePath) -> Option<Rejection> {
114        let path = relative.as_str();
115        if in_lanekeep_directory(path) {
116            return Some(Rejection::Lanekeep);
117        }
118        // `is_match` short-circuits, which is what keeps `selects` cheap on the walk's hot
119        // path; the full scan runs only for a file a rejection will quote, to name the
120        // pattern as the config wrote it.
121        if self.exclude.is_match(path)
122            && let Some(index) = self.exclude.matches(path).first()
123        {
124            return Some(Rejection::Excluded {
125                pattern: self.exclude_patterns[*index].clone(),
126            });
127        }
128        if self.has_include && !self.include.is_match(path) {
129            return Some(Rejection::NotIncluded);
130        }
131        None
132    }
133
134    /// Whether a path relative to the root is selected. See `rejects` for why a path is
135    /// not.
136    #[must_use]
137    pub fn selects(&self, relative: &FilePath) -> bool {
138        self.rejects(relative).is_none()
139    }
140
141    /// Every selected file, sorted.
142    ///
143    /// Infallible: the root was canonicalized when this was built, and a single unreadable
144    /// entry is skipped rather than failing a run over a tree that may contain anything.
145    #[must_use]
146    pub fn walk(&self) -> Vec<FilePath> {
147        let mut out = Vec::new();
148
149        for entry in ignore::WalkBuilder::new(&self.root)
150            .hidden(false)
151            .git_ignore(true)
152            .git_global(true)
153            .git_exclude(true)
154            .parents(true)
155            // Honor .gitignore even outside a repository. The walker otherwise treats
156            // ignore files as meaningless without a .git directory, which would make
157            // discovery depend on whether the project happens to be checked out — the
158            // same tree giving different answers in a tarball than in a clone.
159            .require_git(false)
160            .build()
161        {
162            // A single unreadable entry is not a reason to fail the run.
163            let Ok(entry) = entry else { continue };
164            if !entry.file_type().is_some_and(|t| t.is_file()) {
165                continue;
166            }
167            let Ok(relative) = entry.path().strip_prefix(&self.root) else {
168                continue;
169            };
170
171            let relative = FilePath::new(relative);
172            if self.selects(&relative) {
173                out.push(relative);
174            }
175        }
176
177        out.sort();
178        out.dedup();
179        out
180    }
181}
182
183/// Whether a path relative to the root is inside lanekeep's own directory.
184///
185/// The walk sees hidden entries deliberately — a project's `.github/` is code someone may want
186/// checked — and `.lanekeep/` at the root is the one hidden directory that is never a subject.
187/// It is lanekeep's own: the cache, the precompiled components, and the `tsc` driver lanekeep
188/// writes there and then runs. Nothing in it was written by the project, and a rule reporting
189/// on it is reporting on lanekeep. Under `types.provider: 'tsc'` it was worse than noise — the
190/// driver is JavaScript, so with `allowJs` it entered the program listing and put lanekeep's
191/// own version into the key a second time, by a path that only looks like a project file.
192///
193/// Unconditional, and not something `exclude` can turn off: there is no configuration under
194/// which checking it is what someone meant. Matched on the leading path *component*, so
195/// `src/.lanekeep-notes.ts` and a project's own `vendor/.lanekeep/` are untouched.
196fn in_lanekeep_directory(relative: &str) -> bool {
197    relative
198        .split('/')
199        .next()
200        .is_some_and(|first| first == ".lanekeep")
201}
202
203fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
204    let mut builder = GlobSetBuilder::new();
205    for pattern in patterns {
206        let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
207            field,
208            pattern: pattern.clone(),
209            detail: e.to_string(),
210        })?;
211        builder.add(glob);
212    }
213    builder.build().map_err(|e| DiscoveryError::InvalidGlob {
214        field,
215        pattern: patterns.join(", "),
216        detail: e.to_string(),
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use std::fs;
223
224    use super::*;
225
226    struct Fixture {
227        dir: PathBuf,
228    }
229
230    impl Fixture {
231        fn new(name: &str, files: &[&str]) -> Self {
232            let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
233            let _ = fs::remove_dir_all(&dir);
234            for path in files {
235                let full = dir.join(path);
236                if let Some(parent) = full.parent() {
237                    fs::create_dir_all(parent).expect("creates parent");
238                }
239                fs::write(&full, "const x = 1;\n").expect("writes");
240            }
241            fs::create_dir_all(&dir).expect("creates dir");
242            Self { dir }
243        }
244
245        fn write(&self, path: &str, contents: &str) {
246            let full = self.dir.join(path);
247            if let Some(parent) = full.parent() {
248                fs::create_dir_all(parent).expect("creates parent");
249            }
250            fs::write(full, contents).expect("writes");
251        }
252
253        fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
254            let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
255            let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
256            Discovery::new(&self.dir, &include, &exclude)
257                .expect("builds")
258                .walk()
259                .iter()
260                .map(|p| p.as_str().to_owned())
261                .collect()
262        }
263    }
264
265    impl Drop for Fixture {
266        fn drop(&mut self) {
267            let _ = fs::remove_dir_all(&self.dir);
268        }
269    }
270
271    /// lanekeep's own directory is never a subject, whatever `include` says.
272    ///
273    /// The walk sees hidden entries on purpose, and nothing excluded `.lanekeep/` — so the
274    /// `tsc` driver lanekeep writes there was discovered as a project file, and under `allowJs`
275    /// it entered the compiler's program listing and the run key with it.
276    #[test]
277    fn lanekeeps_own_directory_is_never_selected() {
278        let fixture = Fixture::new(
279            "own-directory",
280            &[
281                "src/a.ts",
282                ".lanekeep/driver-abc.mjs",
283                ".lanekeep/components/x.wasm",
284                // A project's own file that merely starts the same way, and one nested under a
285                // directory of that name somewhere else: neither is lanekeep's.
286                "src/.lanekeep-notes.ts",
287                "vendor/.lanekeep/keep.ts",
288            ],
289        );
290        let found = fixture.walk(&["**/*"], &[]);
291        assert!(
292            !found.iter().any(|p| p.starts_with(".lanekeep/")),
293            "lanekeep's own directory reached the corpus: {found:?}"
294        );
295        assert!(found.contains(&"src/a.ts".to_owned()), "{found:?}");
296        assert!(
297            found.contains(&"src/.lanekeep-notes.ts".to_owned()),
298            "a project file whose name merely begins the same way is a subject: {found:?}"
299        );
300        assert!(
301            found.contains(&"vendor/.lanekeep/keep.ts".to_owned()),
302            "only the directory at the root is lanekeep's: {found:?}"
303        );
304    }
305
306    /// And `selects` agrees, which is the half `--since` and `--staged` go through.
307    #[test]
308    fn selects_refuses_lanekeeps_own_directory() {
309        let fixture = Fixture::new("own-directory-selects", &["src/a.ts"]);
310        let discovery = Discovery::new(&fixture.dir, &[], &[]).expect("builds");
311        assert!(!discovery.selects(&FilePath::new(".lanekeep/driver-abc.mjs")));
312        assert!(discovery.selects(&FilePath::new("src/a.ts")));
313    }
314
315    #[test]
316    fn finds_files_matching_include() {
317        let fixture = Fixture::new(
318            "include",
319            &["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
320        );
321        assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
322    }
323
324    #[test]
325    fn no_include_selects_everything_found() {
326        let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
327        let found = fixture.walk(&[], &[]);
328        assert!(found.contains(&"a.ts".to_owned()));
329        assert!(found.contains(&"b.md".to_owned()));
330    }
331
332    #[test]
333    fn exclude_wins_over_include() {
334        // The other order would make `exclude` useless, since anything excluded is by
335        // definition something `include` matched.
336        let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
337        assert_eq!(
338            fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
339            ["src/a.ts"]
340        );
341    }
342
343    #[test]
344    fn respects_gitignore() {
345        let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
346        fixture.write(".gitignore", "dist/\n");
347
348        let found = fixture.walk(&["**/*.ts"], &[]);
349        assert!(found.contains(&"src/a.ts".to_owned()));
350        assert!(
351            !found.contains(&"dist/b.ts".to_owned()),
352            "gitignored files must not be checked: {found:?}"
353        );
354    }
355
356    #[test]
357    fn the_order_is_sorted_and_stable() {
358        // Nothing downstream depends on this order, but feeding workers in filesystem
359        // order would make work distribution vary run to run — which looks like
360        // nondeterminism the moment a run breaches a budget.
361        let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
362        let first = fixture.walk(&["**/*.ts"], &[]);
363        assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
364
365        for _ in 0..5 {
366            assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
367        }
368    }
369
370    #[test]
371    fn reports_a_bad_glob_with_the_field_it_came_from() {
372        let fixture = Fixture::new("bad-glob", &["a.ts"]);
373        let err =
374            Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
375
376        match err {
377            DiscoveryError::InvalidGlob { field, pattern, .. } => {
378                assert_eq!(field, "include");
379                assert_eq!(pattern, "src/[");
380            }
381            DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
382        }
383
384        let err =
385            Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
386        assert!(
387            matches!(
388                err,
389                DiscoveryError::InvalidGlob {
390                    field: "exclude",
391                    ..
392                }
393            ),
394            "{err:?}"
395        );
396    }
397
398    #[test]
399    fn a_missing_root_is_reported() {
400        let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
401        assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
402    }
403
404    #[test]
405    fn selects_can_be_asked_without_walking() {
406        let fixture = Fixture::new("selects", &["a.ts"]);
407        let discovery = Discovery::new(
408            &fixture.dir,
409            &["src/**/*.ts".to_owned()],
410            &["**/*.test.ts".to_owned()],
411        )
412        .expect("builds");
413
414        assert!(discovery.selects(&FilePath::new("src/a.ts")));
415        assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
416        assert!(!discovery.selects(&FilePath::new("other/a.ts")));
417    }
418
419    #[test]
420    fn rejects_names_the_clause_that_would_drop_a_file() {
421        let fixture = Fixture::new("rejects", &["src/a.ts", "vendor/x.ts"]);
422        let discovery = Discovery::new(
423            &fixture.dir,
424            &["src/**/*.ts".to_owned()],
425            &["vendor/**".to_owned()],
426        )
427        .expect("builds");
428
429        assert_eq!(
430            discovery.rejects(&FilePath::new("src/a.ts")),
431            None,
432            "a file the globs select is not rejected"
433        );
434        assert_eq!(
435            discovery.rejects(&FilePath::new("vendor/x.ts")),
436            Some(Rejection::Excluded {
437                pattern: "vendor/**".to_owned()
438            }),
439        );
440        assert_eq!(
441            discovery.rejects(&FilePath::new("other/a.ts")),
442            Some(Rejection::NotIncluded),
443        );
444        assert_eq!(
445            discovery.rejects(&FilePath::new(".lanekeep/driver.mjs")),
446            Some(Rejection::Lanekeep),
447        );
448    }
449}