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/// Which files a run considers.
43#[derive(Debug)]
44pub struct Discovery {
45    root: PathBuf,
46    include: GlobSet,
47    exclude: GlobSet,
48    has_include: bool,
49}
50
51impl Discovery {
52    /// Build a discovery over a project root.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`DiscoveryError::InvalidGlob`] for a malformed pattern, with the field it
57    /// came from — an error naming only the pattern leaves the reader searching for it.
58    pub fn new(
59        root: impl AsRef<Path>,
60        include: &[String],
61        exclude: &[String],
62    ) -> Result<Self, DiscoveryError> {
63        let root = root.as_ref();
64        let canonical = root
65            .canonicalize()
66            .map_err(|e| DiscoveryError::Unreadable {
67                path: root.display().to_string(),
68                detail: e.to_string(),
69            })?;
70
71        Ok(Self {
72            root: canonical,
73            include: build_set(include, "include")?,
74            exclude: build_set(exclude, "exclude")?,
75            has_include: !include.is_empty(),
76        })
77    }
78
79    /// The project root, canonicalized.
80    #[must_use]
81    pub fn root(&self) -> &Path {
82        &self.root
83    }
84
85    /// Whether a path relative to the root is selected.
86    ///
87    /// Exclusion wins over inclusion: a project listing a broad `include` and a narrow
88    /// `exclude` means the exclusion, and the other order would make `exclude` useless.
89    ///
90    /// `.lanekeep/` at the root wins over both, and is not configurable: it is lanekeep's own
91    /// directory — the cache, the precompiled components, the `tsc` driver — and nothing in it
92    /// was written by the project. See `in_lanekeep_directory` below for the whole reasoning.
93    #[must_use]
94    pub fn selects(&self, relative: &FilePath) -> bool {
95        let path = relative.as_str();
96        if in_lanekeep_directory(path) {
97            return false;
98        }
99        if self.exclude.is_match(path) {
100            return false;
101        }
102        // No `include` at all means everything the walk turned up, which is the useful
103        // default for `lanekeep check` in a small project.
104        !self.has_include || self.include.is_match(path)
105    }
106
107    /// Every selected file, sorted.
108    ///
109    /// Infallible: the root was canonicalized when this was built, and a single unreadable
110    /// entry is skipped rather than failing a run over a tree that may contain anything.
111    #[must_use]
112    pub fn walk(&self) -> Vec<FilePath> {
113        let mut out = Vec::new();
114
115        for entry in ignore::WalkBuilder::new(&self.root)
116            .hidden(false)
117            .git_ignore(true)
118            .git_global(true)
119            .git_exclude(true)
120            .parents(true)
121            // Honor .gitignore even outside a repository. The walker otherwise treats
122            // ignore files as meaningless without a .git directory, which would make
123            // discovery depend on whether the project happens to be checked out — the
124            // same tree giving different answers in a tarball than in a clone.
125            .require_git(false)
126            .build()
127        {
128            // A single unreadable entry is not a reason to fail the run.
129            let Ok(entry) = entry else { continue };
130            if !entry.file_type().is_some_and(|t| t.is_file()) {
131                continue;
132            }
133            let Ok(relative) = entry.path().strip_prefix(&self.root) else {
134                continue;
135            };
136
137            let relative = FilePath::new(relative);
138            if self.selects(&relative) {
139                out.push(relative);
140            }
141        }
142
143        out.sort();
144        out.dedup();
145        out
146    }
147}
148
149/// Whether a path relative to the root is inside lanekeep's own directory.
150///
151/// The walk sees hidden entries deliberately — a project's `.github/` is code someone may want
152/// checked — and `.lanekeep/` at the root is the one hidden directory that is never a subject.
153/// It is lanekeep's own: the cache, the precompiled components, and the `tsc` driver lanekeep
154/// writes there and then runs. Nothing in it was written by the project, and a rule reporting
155/// on it is reporting on lanekeep. Under `types.provider: 'tsc'` it was worse than noise — the
156/// driver is JavaScript, so with `allowJs` it entered the program listing and put lanekeep's
157/// own version into the key a second time, by a path that only looks like a project file.
158///
159/// Unconditional, and not something `exclude` can turn off: there is no configuration under
160/// which checking it is what someone meant. Matched on the leading path *component*, so
161/// `src/.lanekeep-notes.ts` and a project's own `vendor/.lanekeep/` are untouched.
162fn in_lanekeep_directory(relative: &str) -> bool {
163    relative
164        .split('/')
165        .next()
166        .is_some_and(|first| first == ".lanekeep")
167}
168
169fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
170    let mut builder = GlobSetBuilder::new();
171    for pattern in patterns {
172        let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
173            field,
174            pattern: pattern.clone(),
175            detail: e.to_string(),
176        })?;
177        builder.add(glob);
178    }
179    builder.build().map_err(|e| DiscoveryError::InvalidGlob {
180        field,
181        pattern: patterns.join(", "),
182        detail: e.to_string(),
183    })
184}
185
186#[cfg(test)]
187mod tests {
188    use std::fs;
189
190    use super::*;
191
192    struct Fixture {
193        dir: PathBuf,
194    }
195
196    impl Fixture {
197        fn new(name: &str, files: &[&str]) -> Self {
198            let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
199            let _ = fs::remove_dir_all(&dir);
200            for path in files {
201                let full = dir.join(path);
202                if let Some(parent) = full.parent() {
203                    fs::create_dir_all(parent).expect("creates parent");
204                }
205                fs::write(&full, "const x = 1;\n").expect("writes");
206            }
207            fs::create_dir_all(&dir).expect("creates dir");
208            Self { dir }
209        }
210
211        fn write(&self, path: &str, contents: &str) {
212            let full = self.dir.join(path);
213            if let Some(parent) = full.parent() {
214                fs::create_dir_all(parent).expect("creates parent");
215            }
216            fs::write(full, contents).expect("writes");
217        }
218
219        fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
220            let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
221            let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
222            Discovery::new(&self.dir, &include, &exclude)
223                .expect("builds")
224                .walk()
225                .iter()
226                .map(|p| p.as_str().to_owned())
227                .collect()
228        }
229    }
230
231    impl Drop for Fixture {
232        fn drop(&mut self) {
233            let _ = fs::remove_dir_all(&self.dir);
234        }
235    }
236
237    /// lanekeep's own directory is never a subject, whatever `include` says.
238    ///
239    /// The walk sees hidden entries on purpose, and nothing excluded `.lanekeep/` — so the
240    /// `tsc` driver lanekeep writes there was discovered as a project file, and under `allowJs`
241    /// it entered the compiler's program listing and the run key with it.
242    #[test]
243    fn lanekeeps_own_directory_is_never_selected() {
244        let fixture = Fixture::new(
245            "own-directory",
246            &[
247                "src/a.ts",
248                ".lanekeep/driver-abc.mjs",
249                ".lanekeep/components/x.wasm",
250                // A project's own file that merely starts the same way, and one nested under a
251                // directory of that name somewhere else: neither is lanekeep's.
252                "src/.lanekeep-notes.ts",
253                "vendor/.lanekeep/keep.ts",
254            ],
255        );
256        let found = fixture.walk(&["**/*"], &[]);
257        assert!(
258            !found.iter().any(|p| p.starts_with(".lanekeep/")),
259            "lanekeep's own directory reached the corpus: {found:?}"
260        );
261        assert!(found.contains(&"src/a.ts".to_owned()), "{found:?}");
262        assert!(
263            found.contains(&"src/.lanekeep-notes.ts".to_owned()),
264            "a project file whose name merely begins the same way is a subject: {found:?}"
265        );
266        assert!(
267            found.contains(&"vendor/.lanekeep/keep.ts".to_owned()),
268            "only the directory at the root is lanekeep's: {found:?}"
269        );
270    }
271
272    /// And `selects` agrees, which is the half `--since` and `--staged` go through.
273    #[test]
274    fn selects_refuses_lanekeeps_own_directory() {
275        let fixture = Fixture::new("own-directory-selects", &["src/a.ts"]);
276        let discovery = Discovery::new(&fixture.dir, &[], &[]).expect("builds");
277        assert!(!discovery.selects(&FilePath::new(".lanekeep/driver-abc.mjs")));
278        assert!(discovery.selects(&FilePath::new("src/a.ts")));
279    }
280
281    #[test]
282    fn finds_files_matching_include() {
283        let fixture = Fixture::new(
284            "include",
285            &["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
286        );
287        assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
288    }
289
290    #[test]
291    fn no_include_selects_everything_found() {
292        let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
293        let found = fixture.walk(&[], &[]);
294        assert!(found.contains(&"a.ts".to_owned()));
295        assert!(found.contains(&"b.md".to_owned()));
296    }
297
298    #[test]
299    fn exclude_wins_over_include() {
300        // The other order would make `exclude` useless, since anything excluded is by
301        // definition something `include` matched.
302        let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
303        assert_eq!(
304            fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
305            ["src/a.ts"]
306        );
307    }
308
309    #[test]
310    fn respects_gitignore() {
311        let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
312        fixture.write(".gitignore", "dist/\n");
313
314        let found = fixture.walk(&["**/*.ts"], &[]);
315        assert!(found.contains(&"src/a.ts".to_owned()));
316        assert!(
317            !found.contains(&"dist/b.ts".to_owned()),
318            "gitignored files must not be checked: {found:?}"
319        );
320    }
321
322    #[test]
323    fn the_order_is_sorted_and_stable() {
324        // Nothing downstream depends on this order, but feeding workers in filesystem
325        // order would make work distribution vary run to run — which looks like
326        // nondeterminism the moment a run breaches a budget.
327        let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
328        let first = fixture.walk(&["**/*.ts"], &[]);
329        assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
330
331        for _ in 0..5 {
332            assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
333        }
334    }
335
336    #[test]
337    fn reports_a_bad_glob_with_the_field_it_came_from() {
338        let fixture = Fixture::new("bad-glob", &["a.ts"]);
339        let err =
340            Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
341
342        match err {
343            DiscoveryError::InvalidGlob { field, pattern, .. } => {
344                assert_eq!(field, "include");
345                assert_eq!(pattern, "src/[");
346            }
347            DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
348        }
349
350        let err =
351            Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
352        assert!(
353            matches!(
354                err,
355                DiscoveryError::InvalidGlob {
356                    field: "exclude",
357                    ..
358                }
359            ),
360            "{err:?}"
361        );
362    }
363
364    #[test]
365    fn a_missing_root_is_reported() {
366        let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
367        assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
368    }
369
370    #[test]
371    fn selects_can_be_asked_without_walking() {
372        let fixture = Fixture::new("selects", &["a.ts"]);
373        let discovery = Discovery::new(
374            &fixture.dir,
375            &["src/**/*.ts".to_owned()],
376            &["**/*.test.ts".to_owned()],
377        )
378        .expect("builds");
379
380        assert!(discovery.selects(&FilePath::new("src/a.ts")));
381        assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
382        assert!(!discovery.selects(&FilePath::new("other/a.ts")));
383    }
384}