| 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)] |
| 12 | struct Pattern { |
| 13 | glob: Vec<u8>, |
| 14 | anchored: bool, |
| 15 | dir_only: bool, |
| 16 | negate: bool, |
| 17 | } |
| 18 | |
| 19 | #[derive(Debug, Default)] |
| 20 | pub struct Ignore { |
| 21 | patterns: Vec<Pattern>, |
| 22 | } |
| 23 | |
| 24 | impl 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. |
| 71 | fn 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)] |
| 106 | mod 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 | } |