Skip to main content

cadvm_core/
ignore.rs

1//! `.cadvmignore` support: a small, dependency-free pattern matcher.
2//!
3//! The file lives at the repository root, one pattern per line. Syntax is a
4//! deliberately small subset of the usual ignore-file conventions:
5//!
6//! * blank lines and lines starting with `#` are ignored;
7//! * `*` matches any run of characters, `?` matches a single character;
8//! * a pattern ending in `/` matches a directory and everything beneath it;
9//! * a pattern containing `/` is matched against the whole repo-relative path,
10//!   otherwise it is matched against the file name only;
11//! * a leading `/` anchors the pattern to the repository root.
12//!
13//! The `.cadvm` directory is always ignored, independently of this file.
14
15use std::path::Path;
16
17use crate::error::{CoreError, Result};
18use crate::repo::Repository;
19
20/// Name of the ignore file at the repository root.
21pub const IGNORE_FILE: &str = ".cadvmignore";
22
23/// A compiled set of ignore patterns.
24#[derive(Debug, Default, Clone)]
25pub struct IgnoreList {
26    patterns: Vec<Pattern>,
27}
28
29#[derive(Debug, Clone)]
30struct Pattern {
31    /// Glob text (without a trailing `/`).
32    glob: String,
33    /// Match against the whole relative path rather than just the file name.
34    full_path: bool,
35    /// Directory pattern (ends with `/`): also matches everything beneath it.
36    dir: bool,
37}
38
39impl IgnoreList {
40    /// Load the ignore list from the repository root, if a `.cadvmignore` exists.
41    pub fn load(repo: &Repository) -> Result<IgnoreList> {
42        let path = repo.workdir().join(IGNORE_FILE);
43        let text = match std::fs::read_to_string(&path) {
44            Ok(t) => t,
45            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(IgnoreList::default()),
46            Err(e) => return Err(CoreError::io(&path, e)),
47        };
48        Ok(IgnoreList::parse(&text))
49    }
50
51    /// Parse ignore patterns from raw text.
52    pub fn parse(text: &str) -> IgnoreList {
53        let mut patterns = Vec::new();
54        for raw in text.lines() {
55            let line = raw.trim();
56            if line.is_empty() || line.starts_with('#') {
57                continue;
58            }
59            let dir = line.ends_with('/');
60            let body = line.trim_end_matches('/');
61            // A leading `/` only anchors to root; we already match full paths
62            // from the root, so simply strip it.
63            let body = body.strip_prefix('/').unwrap_or(body);
64            let full_path = body.contains('/');
65            patterns.push(Pattern {
66                glob: body.to_string(),
67                full_path,
68                dir,
69            });
70        }
71        IgnoreList { patterns }
72    }
73
74    /// Whether a repo-relative path should be ignored.
75    pub fn is_ignored(&self, rel: &Path) -> bool {
76        let rel_str = normalize(rel);
77        let file_name = rel
78            .file_name()
79            .map(|n| n.to_string_lossy().into_owned())
80            .unwrap_or_default();
81
82        self.patterns.iter().any(|p| {
83            if p.dir {
84                // Match the directory itself or anything beneath it.
85                rel_str == p.glob || rel_str.starts_with(&format!("{}/", p.glob))
86            } else if p.full_path {
87                glob_match(&p.glob, &rel_str)
88            } else {
89                glob_match(&p.glob, &file_name)
90            }
91        })
92    }
93}
94
95/// Normalize a path to forward-slash separated string.
96fn normalize(path: &Path) -> String {
97    path.to_string_lossy().replace('\\', "/")
98}
99
100/// Match `text` against a glob `pattern` supporting `*` (any run) and `?` (one
101/// char). Iterative backtracking; treats all characters (including `/`)
102/// uniformly, which is sufficient for our small pattern set.
103fn glob_match(pattern: &str, text: &str) -> bool {
104    let p: Vec<char> = pattern.chars().collect();
105    let t: Vec<char> = text.chars().collect();
106    let (mut pi, mut ti) = (0usize, 0usize);
107    let (mut star, mut mark) = (None, 0usize);
108
109    while ti < t.len() {
110        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
111            pi += 1;
112            ti += 1;
113        } else if pi < p.len() && p[pi] == '*' {
114            star = Some(pi);
115            mark = ti;
116            pi += 1;
117        } else if let Some(s) = star {
118            pi = s + 1;
119            mark += 1;
120            ti = mark;
121        } else {
122            return false;
123        }
124    }
125    while pi < p.len() && p[pi] == '*' {
126        pi += 1;
127    }
128    pi == p.len()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::path::PathBuf;
135
136    fn ign(s: &str) -> IgnoreList {
137        IgnoreList::parse(s)
138    }
139
140    #[test]
141    fn basename_glob() {
142        let l = ign("*.bak\ndraft_*.step\n");
143        assert!(l.is_ignored(&PathBuf::from("a/b/old.bak")));
144        assert!(l.is_ignored(&PathBuf::from("draft_v1.step")));
145        assert!(!l.is_ignored(&PathBuf::from("final.step")));
146    }
147
148    #[test]
149    fn directory_pattern() {
150        let l = ign("build/\n");
151        assert!(l.is_ignored(&PathBuf::from("build/part.step")));
152        assert!(l.is_ignored(&PathBuf::from("build")));
153        assert!(!l.is_ignored(&PathBuf::from("builder/part.step")));
154    }
155
156    #[test]
157    fn anchored_full_path() {
158        let l = ign("/secret/old.step\n");
159        assert!(l.is_ignored(&PathBuf::from("secret/old.step")));
160        assert!(!l.is_ignored(&PathBuf::from("sub/secret/old.step")));
161    }
162
163    #[test]
164    fn comments_and_blanks_skipped() {
165        let l = ign("# a comment\n\n  \n*.tmp\n");
166        assert_eq!(l.patterns.len(), 1);
167    }
168}