Skip to main content

f00_core/
ignore.rs

1//! Lightweight `.gitignore` / `.f00ignore` pattern loading and matching.
2//!
3//! Not a full gitignore engine: supports `#` comments, blank lines, leading `/`
4//! (anchored to the directory containing the ignore file), trailing `/`
5//! (directories only), `!` negation, and shell-style `*` / `?` globs via
6//! [`crate::filter::glob_match`].
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::entry::Entry;
12use crate::filter::glob_match;
13
14/// Names of ignore files consulted when [`crate::ListOptions::use_ignore_files`] is set.
15pub const IGNORE_FILE_NAMES: &[&str] = &[".f00ignore", ".gitignore"];
16
17#[derive(Debug, Clone)]
18struct IgnorePattern {
19    /// Glob text without leading `/` or trailing `/`.
20    pattern: String,
21    /// When true, pattern only matches from the start of the relative path.
22    anchored: bool,
23    /// When true, only directory entries match.
24    dir_only: bool,
25    /// When true, a match *un-ignores* (git `!` rules).
26    negated: bool,
27}
28
29/// Compiled ignore rules for one directory tree root (the dir that held the file).
30#[derive(Debug, Clone, Default)]
31pub struct IgnoreSet {
32    /// Directory that contained the ignore file(s); patterns are relative to this.
33    pub base: PathBuf,
34    patterns: Vec<IgnorePattern>,
35}
36
37impl IgnoreSet {
38    /// Load `.f00ignore` then `.gitignore` from `dir` (later files append; last match wins
39    /// for negation, same as layered gitignore sources in one directory).
40    pub fn load_for_dir(dir: &Path) -> Self {
41        let mut set = Self {
42            base: dir.to_path_buf(),
43            patterns: Vec::new(),
44        };
45        for name in IGNORE_FILE_NAMES {
46            let path = dir.join(name);
47            if let Ok(text) = fs::read_to_string(&path) {
48                set.patterns.extend(parse_ignore_text(&text));
49            }
50        }
51        set
52    }
53
54    pub fn is_empty(&self) -> bool {
55        self.patterns.is_empty()
56    }
57
58    /// Return true if `entry` should be hidden by this ignore set.
59    ///
60    /// Matching is performed against the path relative to [`Self::base`], using
61    /// the entry name when the entry lives directly under `base`.
62    pub fn ignores(&self, entry: &Entry) -> bool {
63        if self.patterns.is_empty() {
64            return false;
65        }
66        // Never hide the ignore files' parent synthetic `.` / `..`.
67        if entry.name == "." || entry.name == ".." {
68            return false;
69        }
70
71        let rel = relative_path(&self.base, &entry.path).unwrap_or_else(|| entry.name.clone());
72        let rel = rel.replace('\\', "/");
73        let rel = rel.trim_start_matches("./");
74        if rel.is_empty() {
75            return false;
76        }
77
78        let is_dir = entry.is_dir();
79        let mut ignored = false;
80        for pat in &self.patterns {
81            if pat.dir_only && !is_dir {
82                continue;
83            }
84            if pattern_matches(pat, rel) {
85                ignored = !pat.negated;
86            }
87        }
88        ignored
89    }
90}
91
92/// Load ignore sets for `dir` (nearest directory only — one level).
93pub fn load_ignore_set(dir: &Path) -> IgnoreSet {
94    IgnoreSet::load_for_dir(dir)
95}
96
97fn parse_ignore_text(text: &str) -> Vec<IgnorePattern> {
98    let mut out = Vec::new();
99    for line in text.lines() {
100        // Trim BOM/CRLF leftovers for Windows-friendly ignore files.
101        let line = line.trim().trim_end_matches('\r');
102        if line.is_empty() || line.starts_with('#') {
103            continue;
104        }
105        let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
106            (true, r)
107        } else {
108            (false, line)
109        };
110        if rest.is_empty() {
111            continue;
112        }
113        let (anchored, rest) = if let Some(r) = rest.strip_prefix('/') {
114            (true, r)
115        } else {
116            (false, rest)
117        };
118        let (dir_only, pattern) = if let Some(r) = rest.strip_suffix('/') {
119            (true, r.to_string())
120        } else {
121            (false, rest.to_string())
122        };
123        if pattern.is_empty() {
124            continue;
125        }
126        out.push(IgnorePattern {
127            pattern,
128            anchored,
129            dir_only,
130            negated,
131        });
132    }
133    out
134}
135
136fn pattern_matches(pat: &IgnorePattern, rel: &str) -> bool {
137    let pattern = pat.pattern.as_str();
138    // Basename-only convenience: `*.o` matches anywhere in the relative path's final component
139    // and also the full relative path (git-like for unanchored patterns without `/`).
140    if pat.anchored || pattern.contains('/') {
141        return glob_match(pattern, rel)
142            || (rel.ends_with('/') && glob_match(pattern, rel.trim_end_matches('/')));
143    }
144
145    // Unanchored, no slash: match against any path segment and full rel.
146    if glob_match(pattern, rel) {
147        return true;
148    }
149    for segment in rel.split('/') {
150        if glob_match(pattern, segment) {
151            return true;
152        }
153    }
154    false
155}
156
157fn relative_path(base: &Path, path: &Path) -> Option<String> {
158    if let Ok(p) = path.strip_prefix(base) {
159        return Some(p.to_string_lossy().replace('\\', "/"));
160    }
161    // Windows can fail strip_prefix across different path prefixes; fall back to
162    // file name when both live under the same parent.
163    if path.parent() == Some(base) {
164        return path.file_name().map(|n| n.to_string_lossy().into_owned());
165    }
166    None
167}
168
169/// Filter entries in place using an optional ignore set.
170pub fn apply_ignore_set(entries: &mut Vec<Entry>, set: &IgnoreSet) {
171    if set.is_empty() {
172        return;
173    }
174    entries.retain(|e| !set.ignores(e));
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::entry::{Entry, EntryKind, GitStatus};
181    use std::time::{SystemTime, UNIX_EPOCH};
182
183    fn temp_dir() -> PathBuf {
184        let base = std::env::temp_dir().join(format!(
185            "f00-core-ignore-{}-{}",
186            std::process::id(),
187            SystemTime::now()
188                .duration_since(UNIX_EPOCH)
189                .map(|d| d.as_nanos())
190                .unwrap_or(0)
191        ));
192        fs::create_dir_all(&base).unwrap();
193        base
194    }
195
196    fn entry_in(dir: &Path, name: &str, is_dir: bool) -> Entry {
197        Entry {
198            path: dir.join(name),
199            name: name.to_string(),
200            kind: if is_dir {
201                EntryKind::Directory
202            } else {
203                EntryKind::File
204            },
205            size: 0,
206            modified: None,
207            created: None,
208            accessed: None,
209            changed: None,
210            mode: 0,
211            readonly: false,
212            symlink_target: None,
213            depth: 0,
214            git_status: GitStatus::Clean,
215            is_dir_header: false,
216            nlink: 1,
217            uid: 0,
218            gid: 0,
219            inode: 0,
220            blocks: 0,
221            dev: 0,
222            rdev: 0,
223            blksize: 0,
224            owner: "u".into(),
225            group: "g".into(),
226            author: "u".into(),
227            context: String::new(),
228        }
229    }
230
231    #[test]
232    fn parse_and_match_basic_globs() {
233        let dir = temp_dir();
234        fs::write(
235            dir.join(".gitignore"),
236            "*.o\n# comment\n/build\ntmp/\n!keep.o\n",
237        )
238        .unwrap();
239        let set = IgnoreSet::load_for_dir(&dir);
240        assert!(set.ignores(&entry_in(&dir, "foo.o", false)));
241        assert!(!set.ignores(&entry_in(&dir, "foo.rs", false)));
242        assert!(
243            set.ignores(&entry_in(&dir, "build", false)),
244            "anchored /build should match name build"
245        );
246        assert!(set.ignores(&entry_in(&dir, "tmp", true)));
247        assert!(!set.ignores(&entry_in(&dir, "tmp", false))); // dir_only
248                                                              // Negation: last matching rule wins
249        assert!(!set.ignores(&entry_in(&dir, "keep.o", false)));
250        let _ = fs::remove_dir_all(&dir);
251    }
252
253    #[test]
254    fn f00ignore_and_gitignore_both_loaded() {
255        let dir = temp_dir();
256        fs::write(dir.join(".f00ignore"), "secret.txt\n").unwrap();
257        fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
258        let set = IgnoreSet::load_for_dir(&dir);
259        assert!(set.ignores(&entry_in(&dir, "secret.txt", false)));
260        assert!(set.ignores(&entry_in(&dir, "a.log", false)));
261        assert!(!set.ignores(&entry_in(&dir, "a.txt", false)));
262        let _ = fs::remove_dir_all(&dir);
263    }
264
265    #[test]
266    fn nested_path_unanchored_glob() {
267        let dir = temp_dir();
268        fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
269        let set = IgnoreSet::load_for_dir(&dir);
270        let mut e = entry_in(&dir, "sub/x.o", false);
271        e.path = dir.join("sub").join("x.o");
272        e.name = "x.o".into();
273        assert!(set.ignores(&e));
274        let _ = fs::remove_dir_all(&dir);
275    }
276
277    #[test]
278    fn apply_ignore_set_filters() {
279        let dir = temp_dir();
280        fs::write(dir.join(".gitignore"), "hide_me\n").unwrap();
281        let set = IgnoreSet::load_for_dir(&dir);
282        let mut entries = vec![
283            entry_in(&dir, "keep", false),
284            entry_in(&dir, "hide_me", false),
285        ];
286        apply_ignore_set(&mut entries, &set);
287        assert_eq!(entries.len(), 1);
288        assert_eq!(entries[0].name, "keep");
289        let _ = fs::remove_dir_all(&dir);
290    }
291}