Skip to main content

termesh_filesystem/
ignore_rules.rs

1//! Ignore semantics for the explorer (ADR-0005 §4).
2//!
3//! Uses ripgrep's `ignore` crate for the matching itself — `.gitignore` precedence is a
4//! known tar pit, and the explorer and the content search share this matcher so the two
5//! agree on what exists. A file the tree hides but search finds is a bug report.
6//!
7//! The ignore *files* are read through [`FileSystemService`] rather than by the crate's
8//! own I/O, which keeps the service boundary intact (CONTRIBUTING.md invariants) and — more
9//! usefully — makes all of this testable against the in-memory fake.
10
11use std::path::{Path, PathBuf};
12
13use ignore::gitignore::{Gitignore, GitignoreBuilder};
14
15use crate::service::{DirEntryInfo, EntryKind, FileSystemService};
16
17/// What the explorer shows. Both default to off: the default view should look like the
18/// project, and the agent's context should not be full of `target/` and `node_modules/`.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub struct IgnoreOptions {
21    /// Show entries matched by an ignore file (rendered dimmed by the widget).
22    pub show_ignored: bool,
23    /// Show dotfiles.
24    pub show_hidden: bool,
25}
26
27impl IgnoreOptions {
28    /// Show everything — useful for "reveal in explorer" and for tests.
29    pub fn show_all() -> Self {
30        Self { show_ignored: true, show_hidden: true }
31    }
32}
33
34/// The ignore-file names we honour, in the order git itself applies them.
35const IGNORE_FILES: &[&str] = &[".gitignore", ".ignore"];
36
37/// A chain of matchers, one per directory that contributed rules. Deeper directories win,
38/// which is what `.gitignore` nesting means.
39pub struct IgnoreRules {
40    matchers: Vec<(PathBuf, Gitignore)>,
41    options: IgnoreOptions,
42    root: PathBuf,
43}
44
45impl IgnoreRules {
46    /// Build the root-level rules, reading ignore files through `fs`.
47    pub fn for_root(fs: &dyn FileSystemService, root: &Path, options: IgnoreOptions) -> Self {
48        let mut rules = Self { matchers: Vec::new(), options, root: root.to_path_buf() };
49        rules.load_dir(fs, root);
50        // git's own repo-local excludes, which live outside the working tree.
51        rules.load_file(fs, root, &root.join(".git/info/exclude"));
52        rules
53    }
54
55    /// No rules at all — every entry is visible.
56    pub fn disabled() -> Self {
57        Self { matchers: Vec::new(), options: IgnoreOptions::show_all(), root: PathBuf::new() }
58    }
59
60    pub fn options(&self) -> IgnoreOptions {
61        self.options
62    }
63
64    /// Read `dir`'s own ignore files, if it has any we have not already loaded.
65    ///
66    /// Called before listing each directory, so nesting is honoured lazily: we only pay
67    /// for the rules of directories the user actually opened.
68    pub fn load_dir(&mut self, fs: &dyn FileSystemService, dir: &Path) {
69        if self.matchers.iter().any(|(d, _)| d == dir) {
70            return;
71        }
72        for name in IGNORE_FILES {
73            let path = dir.join(name);
74            self.load_file(fs, dir, &path);
75        }
76    }
77
78    fn load_file(&mut self, fs: &dyn FileSystemService, anchor: &Path, path: &Path) {
79        // A missing ignore file is the normal case, not an error worth surfacing.
80        let Ok(bytes) = fs.read_file(path) else { return };
81        let Ok(text) = String::from_utf8(bytes) else { return };
82
83        let mut builder = GitignoreBuilder::new(anchor);
84        let mut added = false;
85        for line in text.lines() {
86            // A malformed glob should cost us that line, not the whole file.
87            if builder.add_line(None, line).is_ok() {
88                added = true;
89            }
90        }
91        if !added {
92            return;
93        }
94        if let Ok(matcher) = builder.build() {
95            self.matchers.push((anchor.to_path_buf(), matcher));
96        }
97    }
98
99    /// Whether `path` should be hidden from the explorer.
100    pub fn is_hidden(&self, path: &Path, is_dir: bool) -> bool {
101        let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
102
103        // `.git` is machinery, never content. Hidden unless dotfiles are shown.
104        if !self.options.show_hidden && name.starts_with('.') {
105            return true;
106        }
107        if self.options.show_ignored {
108            return false;
109        }
110        self.is_ignored(path, is_dir)
111    }
112
113    /// Whether an ignore file matches `path`. Deepest matcher wins; an explicit
114    /// whitelist (`!pattern`) at that depth un-ignores it.
115    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
116        // Deepest anchor first, so a nested .gitignore overrides the root's.
117        let mut candidates: Vec<&(PathBuf, Gitignore)> =
118            self.matchers.iter().filter(|(dir, _)| path.starts_with(dir)).collect();
119        candidates.sort_by_key(|(dir, _)| std::cmp::Reverse(dir.components().count()));
120
121        for (_, matcher) in candidates {
122            // `matched` tests only the path itself, so a rule naming a directory —
123            // `target` — hid `target` and nothing beneath it. The tree never noticed,
124            // because it asks about a directory before descending and stops there. The
125            // watcher did: the OS hands it deep paths, so every file cargo wrote under
126            // `target` looked like a real change, reached the language server as a
127            // watched-file notification, and made rust-analyzer re-analyse — which runs
128            // cargo check, which writes to `target` again. The `starts_with` filter above
129            // guarantees the path is under this matcher's root, which this call requires.
130            let m = matcher.matched_path_or_any_parents(path, is_dir);
131            if m.is_ignore() {
132                return true;
133            }
134            if m.is_whitelist() {
135                return false;
136            }
137        }
138        false
139    }
140
141    /// Drop the entries the explorer should not show.
142    pub fn filter(&self, entries: Vec<DirEntryInfo>) -> Vec<DirEntryInfo> {
143        entries.into_iter().filter(|e| !self.is_hidden(&e.path, e.kind == EntryKind::Dir)).collect()
144    }
145
146    pub fn root(&self) -> &Path {
147        &self.root
148    }
149}
150
151/// Whether `path` matches one of `patterns` — literal globs from `config.toml`'s
152/// `exclusions` key (ADR-0014 Task 3), never a `.gitignore` file.
153///
154/// A one-off matcher, not a field on [`IgnoreRules`]: the pattern list is short and
155/// changes only when the user edits their config, so recompiling per call trades a
156/// negligible cost for never going stale after a `config.reload` (Task 5) — no cache to
157/// invalidate. `patterns` is anchored at `root`, exactly as a root-level `.gitignore`
158/// would be.
159pub fn matches_exclusion(root: &Path, patterns: &[String], path: &Path, is_dir: bool) -> bool {
160    if patterns.is_empty() {
161        return false;
162    }
163    let mut builder = GitignoreBuilder::new(root);
164    for pattern in patterns {
165        // A malformed glob should cost the user that one pattern, not the whole list.
166        let _ = builder.add_line(None, pattern);
167    }
168    let Ok(matcher) = builder.build() else { return false };
169    matcher.matched(path, is_dir).is_ignore()
170}
171
172impl std::fmt::Debug for IgnoreRules {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.debug_struct("IgnoreRules")
175            .field("root", &self.root)
176            .field("options", &self.options)
177            .field("matchers", &self.matchers.len())
178            .finish()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::path::PathBuf;
186
187    use termesh_core::{FsError, FsResult};
188
189    /// Minimal in-crate fake: `test-support`'s richer one depends on this crate.
190    #[derive(Default)]
191    struct Files(Vec<(PathBuf, Vec<u8>)>);
192
193    impl Files {
194        fn with(pairs: &[(&str, &str)]) -> Self {
195            Self(pairs.iter().map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())).collect())
196        }
197    }
198
199    impl FileSystemService for Files {
200        fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
201            self.0
202                .iter()
203                .find(|(p, _)| p == path)
204                .map(|(_, c)| c.clone())
205                .ok_or_else(|| FsError::NotFound(path.to_path_buf()))
206        }
207        fn read_dir(&self, _: &Path) -> FsResult<Vec<DirEntryInfo>> {
208            Ok(Vec::new())
209        }
210        fn create_file(&self, _: &Path) -> FsResult<()> {
211            Ok(())
212        }
213        fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
214            Ok(())
215        }
216        fn create_dir(&self, _: &Path) -> FsResult<()> {
217            Ok(())
218        }
219        fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
220            Ok(())
221        }
222        fn remove_file(&self, _: &Path) -> FsResult<()> {
223            Ok(())
224        }
225        fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
226            Ok(())
227        }
228        fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
229            Ok(p.to_path_buf())
230        }
231    }
232
233    fn rules(files: &[(&str, &str)], options: IgnoreOptions) -> IgnoreRules {
234        IgnoreRules::for_root(&Files::with(files), Path::new("/r"), options)
235    }
236
237    fn default_rules(files: &[(&str, &str)]) -> IgnoreRules {
238        rules(files, IgnoreOptions::default())
239    }
240
241    /// A `.gitignore` naming a directory has to hide everything under it, not just the
242    /// directory entry. The tree never noticed, because it asks about `target` before
243    /// descending and stops there — but the file watcher is handed deep paths straight
244    /// from the OS, so `target/debug/deps/x.rlib` was reported as a real change. On a
245    /// Rust project that is thousands of events per build, forwarded to the language
246    /// server as watched-file changes, which makes rust-analyzer re-analyse, which runs
247    /// cargo check, which writes to `target` again.
248    #[test]
249    fn an_ignored_directory_hides_the_files_underneath_it() {
250        let rules = default_rules(&[("/r/.gitignore", "target\n")]);
251
252        assert!(rules.is_hidden(Path::new("/r/target"), true), "the directory itself");
253        assert!(rules.is_hidden(Path::new("/r/target/debug"), true), "a directory inside it");
254        assert!(
255            rules.is_hidden(Path::new("/r/target/debug/deps/orders.rlib"), false),
256            "a file several levels down"
257        );
258        assert!(!rules.is_hidden(Path::new("/r/src/main.rs"), false), "and nothing else");
259    }
260
261    #[test]
262    fn a_configured_exclusion_matches_like_a_gitignore_pattern() {
263        assert!(matches_exclusion(
264            Path::new("/r"),
265            &["*.log".to_string()],
266            Path::new("/r/debug.log"),
267            false,
268        ));
269        assert!(!matches_exclusion(
270            Path::new("/r"),
271            &["*.log".to_string()],
272            Path::new("/r/src"),
273            true,
274        ));
275    }
276
277    #[test]
278    fn no_patterns_excludes_nothing() {
279        assert!(!matches_exclusion(Path::new("/r"), &[], Path::new("/r/anything"), false));
280    }
281
282    #[test]
283    fn a_malformed_pattern_costs_only_itself() {
284        let patterns = vec!["[".to_string(), "*.log".to_string()];
285        assert!(matches_exclusion(Path::new("/r"), &patterns, Path::new("/r/debug.log"), false));
286    }
287
288    #[test]
289    fn gitignore_patterns_hide_matching_entries() {
290        let r = default_rules(&[("/r/.gitignore", "target\nnode_modules\n*.log\n")]);
291        assert!(r.is_hidden(Path::new("/r/target"), true));
292        assert!(r.is_hidden(Path::new("/r/node_modules"), true));
293        assert!(r.is_hidden(Path::new("/r/debug.log"), false));
294        assert!(!r.is_hidden(Path::new("/r/src"), true));
295    }
296
297    #[test]
298    fn dotfiles_are_hidden_by_default() {
299        let r = default_rules(&[]);
300        assert!(r.is_hidden(Path::new("/r/.git"), true));
301        assert!(r.is_hidden(Path::new("/r/.env"), false));
302        assert!(!r.is_hidden(Path::new("/r/README.md"), false));
303    }
304
305    #[test]
306    fn show_hidden_reveals_dotfiles_but_still_honours_ignore_files() {
307        let r = rules(
308            &[("/r/.gitignore", "target\n")],
309            IgnoreOptions { show_hidden: true, show_ignored: false },
310        );
311        assert!(!r.is_hidden(Path::new("/r/.env"), false), "dotfile now visible");
312        assert!(r.is_hidden(Path::new("/r/target"), true), "ignore rules still apply");
313    }
314
315    #[test]
316    fn show_ignored_reveals_ignored_entries() {
317        let r = rules(
318            &[("/r/.gitignore", "target\n")],
319            IgnoreOptions { show_ignored: true, show_hidden: true },
320        );
321        assert!(!r.is_hidden(Path::new("/r/target"), true));
322    }
323
324    #[test]
325    fn whitelist_patterns_un_ignore() {
326        let r = default_rules(&[("/r/.gitignore", "*.log\n!keep.log\n")]);
327        assert!(r.is_hidden(Path::new("/r/debug.log"), false));
328        assert!(!r.is_hidden(Path::new("/r/keep.log"), false), "! should win");
329    }
330
331    #[test]
332    fn dot_ignore_files_are_honoured_alongside_gitignore() {
333        let r = default_rules(&[("/r/.ignore", "secrets\n")]);
334        assert!(r.is_hidden(Path::new("/r/secrets"), true));
335    }
336
337    #[test]
338    fn git_info_exclude_is_honoured() {
339        let r = default_rules(&[("/r/.git/info/exclude", "scratch\n")]);
340        assert!(r.is_hidden(Path::new("/r/scratch"), true));
341    }
342
343    #[test]
344    fn a_nested_gitignore_overrides_the_root() {
345        let fs = Files::with(&[("/r/.gitignore", "*.log\n"), ("/r/logs/.gitignore", "!*.log\n")]);
346        let mut r = IgnoreRules::for_root(&fs, Path::new("/r"), IgnoreOptions::default());
347        r.load_dir(&fs, Path::new("/r/logs"));
348
349        assert!(r.is_hidden(Path::new("/r/debug.log"), false), "root rule still applies");
350        assert!(!r.is_hidden(Path::new("/r/logs/debug.log"), false), "the deeper .gitignore wins");
351    }
352
353    #[test]
354    fn a_missing_gitignore_is_not_an_error() {
355        let r = default_rules(&[]);
356        assert!(!r.is_hidden(Path::new("/r/anything.txt"), false));
357    }
358
359    #[test]
360    fn comments_and_blank_lines_are_ignored() {
361        let r = default_rules(&[("/r/.gitignore", "# a comment\n\n  \ntarget\n")]);
362        assert!(r.is_hidden(Path::new("/r/target"), true));
363        assert!(!r.is_hidden(Path::new("/r/src"), true));
364    }
365
366    #[test]
367    fn filter_drops_hidden_entries_and_keeps_the_rest() {
368        let r = default_rules(&[("/r/.gitignore", "target\n")]);
369        let entries = vec![
370            DirEntryInfo { name: "src".into(), path: "/r/src".into(), kind: EntryKind::Dir },
371            DirEntryInfo { name: "target".into(), path: "/r/target".into(), kind: EntryKind::Dir },
372            DirEntryInfo { name: ".git".into(), path: "/r/.git".into(), kind: EntryKind::Dir },
373            DirEntryInfo {
374                name: "README.md".into(),
375                path: "/r/README.md".into(),
376                kind: EntryKind::File,
377            },
378        ];
379        let kept: Vec<String> =
380            r.filter(entries).iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
381        assert_eq!(kept, ["src", "README.md"]);
382    }
383
384    #[test]
385    fn disabled_rules_show_everything() {
386        let r = IgnoreRules::disabled();
387        assert!(!r.is_hidden(Path::new("/r/.git"), true));
388        assert!(!r.is_hidden(Path::new("/r/target"), true));
389    }
390
391    #[test]
392    fn a_directory_only_pattern_does_not_hide_a_file_of_the_same_name() {
393        let r = default_rules(&[("/r/.gitignore", "build/\n")]);
394        assert!(r.is_hidden(Path::new("/r/build"), true), "the directory is ignored");
395        assert!(!r.is_hidden(Path::new("/r/build"), false), "a file named build is not");
396    }
397}