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            owner: "u".into(),
222            group: "g".into(),
223            author: "u".into(),
224            context: String::new(),
225        }
226    }
227
228    #[test]
229    fn parse_and_match_basic_globs() {
230        let dir = temp_dir();
231        fs::write(
232            dir.join(".gitignore"),
233            "*.o\n# comment\n/build\ntmp/\n!keep.o\n",
234        )
235        .unwrap();
236        let set = IgnoreSet::load_for_dir(&dir);
237        assert!(set.ignores(&entry_in(&dir, "foo.o", false)));
238        assert!(!set.ignores(&entry_in(&dir, "foo.rs", false)));
239        assert!(
240            set.ignores(&entry_in(&dir, "build", false)),
241            "anchored /build should match name build"
242        );
243        assert!(set.ignores(&entry_in(&dir, "tmp", true)));
244        assert!(!set.ignores(&entry_in(&dir, "tmp", false))); // dir_only
245                                                              // Negation: last matching rule wins
246        assert!(!set.ignores(&entry_in(&dir, "keep.o", false)));
247        let _ = fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn f00ignore_and_gitignore_both_loaded() {
252        let dir = temp_dir();
253        fs::write(dir.join(".f00ignore"), "secret.txt\n").unwrap();
254        fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
255        let set = IgnoreSet::load_for_dir(&dir);
256        assert!(set.ignores(&entry_in(&dir, "secret.txt", false)));
257        assert!(set.ignores(&entry_in(&dir, "a.log", false)));
258        assert!(!set.ignores(&entry_in(&dir, "a.txt", false)));
259        let _ = fs::remove_dir_all(&dir);
260    }
261
262    #[test]
263    fn nested_path_unanchored_glob() {
264        let dir = temp_dir();
265        fs::write(dir.join(".gitignore"), "*.o\n").unwrap();
266        let set = IgnoreSet::load_for_dir(&dir);
267        let mut e = entry_in(&dir, "sub/x.o", false);
268        e.path = dir.join("sub").join("x.o");
269        e.name = "x.o".into();
270        assert!(set.ignores(&e));
271        let _ = fs::remove_dir_all(&dir);
272    }
273
274    #[test]
275    fn apply_ignore_set_filters() {
276        let dir = temp_dir();
277        fs::write(dir.join(".gitignore"), "hide_me\n").unwrap();
278        let set = IgnoreSet::load_for_dir(&dir);
279        let mut entries = vec![
280            entry_in(&dir, "keep", false),
281            entry_in(&dir, "hide_me", false),
282        ];
283        apply_ignore_set(&mut entries, &set);
284        assert_eq!(entries.len(), 1);
285        assert_eq!(entries[0].name, "keep");
286        let _ = fs::remove_dir_all(&dir);
287    }
288}