grove
at @6
1//! `.groveignore`: which paths in a working directory aren't part of the project.
2//!
3//! One pattern per line; `#` starts a comment. Like the familiar ignore files:
4//! - `name` matches that name at any depth; `dir/name` (or `/name`) is anchored at the root
5//! - a trailing `/` matches directories only
6//! - `*` matches within one path segment, `?` one character, `**` any number of segments
7//! - `!pattern` re-includes something an earlier pattern excluded; the last match wins
8//!
9//! `.grove/` (the working directory's own state) is always ignored.
10
11#[derive(Debug)]
12struct Pattern {
13 glob: Vec<u8>,
14 anchored: bool,
15 dir_only: bool,
16 negate: bool,
17}
18
19#[derive(Debug, Default)]
20pub struct Ignore {
21 patterns: Vec<Pattern>,
22}
23
24impl Ignore {
25 pub fn parse(text: &str) -> Ignore {
26 let mut patterns = Vec::new();
27 for line in text.lines() {
28 let line = line.trim_end();
29 if line.is_empty() || line.starts_with('#') {
30 continue;
31 }
32 let (negate, line) = match line.strip_prefix('!') {
33 Some(rest) => (true, rest),
34 None => (false, line),
35 };
36 let (dir_only, line) = match line.strip_suffix('/') {
37 Some(rest) => (true, rest),
38 None => (false, line),
39 };
40 let anchored = line.contains('/');
41 let line = line.strip_prefix('/').unwrap_or(line);
42 if line.is_empty() {
43 continue;
44 }
45 patterns.push(Pattern { glob: line.as_bytes().to_vec(), anchored, dir_only, negate });
46 }
47 Ignore { patterns }
48 }
49
50 /// Is `path` (relative, `/`-separated) ignored? `is_dir` says whether it's a directory.
51 pub fn ignored(&self, path: &str, is_dir: bool) -> bool {
52 if path == ".grove" || path.starts_with(".grove/") {
53 return true;
54 }
55 let name = path.rsplit('/').next().unwrap_or(path);
56 let mut result = false;
57 for p in &self.patterns {
58 if p.dir_only && !is_dir {
59 continue;
60 }
61 let subject = if p.anchored { path } else { name };
62 if glob(&p.glob, subject.as_bytes()) {
63 result = !p.negate;
64 }
65 }
66 result
67 }
68}
69
70/// Glob match where `*` and `?` stay within a segment and `**` spans segments.
71fn glob(p: &[u8], s: &[u8]) -> bool {
72 if p.is_empty() {
73 return s.is_empty();
74 }
75 if p.starts_with(b"**") {
76 let rest = p[2..].strip_prefix(b"/").unwrap_or(&p[2..]);
77 // `**` matches zero or more whole segments.
78 if glob(rest, s) {
79 return true;
80 }
81 for (i, &c) in s.iter().enumerate() {
82 if c == b'/' && glob(rest, &s[i + 1..]) {
83 return true;
84 }
85 }
86 return rest.is_empty();
87 }
88 match p[0] {
89 b'*' => {
90 for i in 0..=s.len() {
91 if glob(&p[1..], &s[i..]) {
92 return true;
93 }
94 if i < s.len() && s[i] == b'/' {
95 break;
96 }
97 }
98 false
99 }
100 b'?' => !s.is_empty() && s[0] != b'/' && glob(&p[1..], &s[1..]),
101 c => !s.is_empty() && s[0] == c && glob(&p[1..], &s[1..]),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn patterns() {
111 let ig = Ignore::parse("# build\ntarget/\n*.log\n/root-only\ndocs/*.tmp\n**/cache\n!keep.log\n");
112 assert!(ig.ignored("target", true));
113 assert!(ig.ignored("sub/target", true));
114 assert!(!ig.ignored("target", false), "dir-only pattern");
115 assert!(ig.ignored("a.log", false));
116 assert!(ig.ignored("deep/b.log", false));
117 assert!(!ig.ignored("keep.log", false), "negation");
118 assert!(ig.ignored("root-only", false));
119 assert!(!ig.ignored("sub/root-only", false), "anchored");
120 assert!(ig.ignored("docs/x.tmp", false));
121 assert!(!ig.ignored("docs/sub/x.tmp", false), "* stays in one segment");
122 assert!(ig.ignored("cache", true));
123 assert!(ig.ignored("a/b/cache", true));
124 assert!(ig.ignored(".grove", true));
125 assert!(!ig.ignored("src/main.rs", false));
126 }
127}