isoloom_manifest/ignore.rs
1//! `.isoloomignore`: the project's paths the machines don't get when the project is copied into
2//! them (Vagrant's `project` step, Proxmox's cloud-init, the cloud modules' archive, and the
3//! runner's `project.archive`). Part of the contract: a runner archiving the project leaves out
4//! exactly what the generated files do. Gitignore-style, at the project's root:
5//!
6//! - one pattern per line; blank lines and `#` comments are skipped, spaces around a pattern
7//! too;
8//! - `!pattern` brings back what an earlier pattern left out;
9//! - a trailing `/` (or `/**`) matches folders only (and so everything in them);
10//! - a `/` at the start or in the middle anchors the pattern at the project's root; otherwise it
11//! matches at any depth (`*.log`, `.env`);
12//! - `*` is any run of characters but `/`, `?` one character but `/`, `[abc]` / `[a-z]` /
13//! `[!abc]` a set, `**` any number of folders (`a/**/b`, `**/x`);
14//! - a pattern matching a folder matches everything in it, and the last pattern matching a path
15//! (or a folder it is in) decides: unlike git, `!` can bring back a file of an ignored folder.
16//!
17//! The generated files read the file when they run, each translating a pattern into the same
18//! regular expression (`^<glob>(/|$)`, or `^<glob>/` for folders only); this matcher follows the
19//! same rules, for a copy made outside those tools (the runner's), so it can't disagree with
20//! them. It reads text only: walking the project is the caller's.
21//!
22//! ```
23//! use isoloom_manifest::ignore::Rules;
24//!
25//! let rules = Rules::parse("*.log\n!keep.log\nbuild/\n");
26//! assert!(rules.ignored("logs/x.log") && !rules.ignored("keep.log"));
27//! assert!(rules.ignored("src/build/") && !rules.ignored("src/app.py"));
28//! ```
29
30use std::collections::BTreeSet;
31
32/// The file's name, at the project's root.
33pub const FILE: &str = ".isoloomignore";
34
35/// One piece of a pattern, as matched.
36#[derive(Debug, Clone, PartialEq, Eq)]
37enum Token {
38 /// A character as it is.
39 Char(char),
40 /// `*`: any run of characters but `/`.
41 Star,
42 /// `?`: one character but `/`.
43 Any,
44 /// `[...]`: one character in (or, negated, not in) the ranges.
45 Set {
46 /// `[!...]`.
47 negated: bool,
48 /// Each member as a range (`a-z`, or `x-x` for one character).
49 ranges: Vec<(char, char)>,
50 },
51 /// `**/`: nothing, or anything ending with `/`.
52 Folders,
53 /// `**` elsewhere: anything.
54 Everything,
55}
56
57/// One line of the file, parsed.
58#[derive(Debug, Clone, PartialEq, Eq)]
59struct Rule {
60 tokens: Vec<Token>,
61 /// A trailing `/` (or `/**`): it matches folders only.
62 folders_only: bool,
63 /// `!pattern`: it brings back what it matches.
64 keep: bool,
65}
66
67/// The rules of an `.isoloomignore`, in order.
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct Rules(Vec<Rule>);
70
71impl Rules {
72 /// The rules of a file's text (every line is a rule or nothing: none is an error).
73 #[must_use]
74 pub fn parse(text: &str) -> Rules {
75 Rules(text.split('\n').filter_map(rule).collect())
76 }
77
78 /// Whether the rules leave a path out (relative to the project, with `/`; a folder's path
79 /// ends with `/`): the last rule matching it, or a folder it is in, decides.
80 #[must_use]
81 pub fn ignored(&self, path: &str) -> bool {
82 let chars: Vec<char> = path.chars().collect();
83 self.0.iter().fold(false, |out, r| {
84 let hit = ends(&r.tokens, &chars, 0).into_iter().any(|e| match chars.get(e) {
85 // Stopping on a `/`: a folder that holds the path (or the path itself, a folder).
86 Some(c) => *c == '/',
87 // The whole path: a file, unless the rule matches folders only.
88 None => !r.folders_only,
89 });
90 if hit { !r.keep } else { out }
91 })
92 }
93
94 /// Whether a `!` pattern may bring back something in an ignored folder.
95 #[must_use]
96 pub fn brings_back(&self) -> bool {
97 self.0.iter().any(|r| r.keep)
98 }
99
100 /// Whether there are no rules at all (no `.isoloomignore`, or an empty one).
101 #[must_use]
102 pub fn is_empty(&self) -> bool {
103 self.0.is_empty()
104 }
105}
106
107/// One line's rule; `None` for a blank line, a comment or a pattern of nothing (`/`).
108fn rule(line: &str) -> Option<Rule> {
109 let line = line.trim();
110 if line.is_empty() || line.starts_with('#') {
111 return None;
112 }
113 let (keep, pat) = line.strip_prefix('!').map_or((false, line), |p| (true, p));
114 let s = pat.strip_suffix('/').unwrap_or(pat);
115 let s = s.strip_suffix("/**").unwrap_or(s);
116 let glob = s.strip_suffix('/').unwrap_or(s);
117 if glob.is_empty() {
118 return None;
119 }
120 // A `/` at the start or in the middle anchors the pattern at the root.
121 let anchored = if glob.contains('/') {
122 glob.strip_prefix('/').unwrap_or(glob).to_owned()
123 } else {
124 format!("**/{glob}")
125 };
126 Some(Rule {
127 tokens: tokens(&anchored.chars().collect::<Vec<char>>()),
128 folders_only: glob != pat,
129 keep,
130 })
131}
132
133/// A glob's tokens, read left to right: each step takes at least one character.
134fn tokens(glob: &[char]) -> Vec<Token> {
135 let mut out = Vec::new();
136 let mut rest = glob;
137 while let Some((token, after)) = token(rest) {
138 out.push(token);
139 rest = after;
140 }
141 out
142}
143
144/// The first token of `glob` and what follows it; `None` at its end.
145fn token(glob: &[char]) -> Option<(Token, &[char])> {
146 Some(match glob {
147 [] => return None,
148 ['*', '*', '/', rest @ ..] => (Token::Folders, rest),
149 ['*', '*', rest @ ..] => (Token::Everything, rest),
150 ['*', rest @ ..] => (Token::Star, rest),
151 ['?', rest @ ..] => (Token::Any, rest),
152 ['[', rest @ ..] => set(rest).unwrap_or((Token::Char('['), rest)),
153 [c, rest @ ..] => (Token::Char(*c), rest),
154 })
155}
156
157/// A set after its `[`, up to the next `]` (one right after `[` or `[!` is a member), and what
158/// follows it; `None` without a closing `]` (the `[` is then a plain character).
159fn set(after: &[char]) -> Option<(Token, &[char])> {
160 let (negated, body) = match after {
161 ['!', rest @ ..] => (true, rest),
162 _ => (false, after),
163 };
164 // The first member may be a `]`; the set ends at the next one.
165 let from = usize::from(body.first() == Some(&']'));
166 let close = body.iter().skip(from).position(|c| *c == ']')? + from;
167 let (members, rest) = body.split_at_checked(close)?;
168 Some((
169 Token::Set {
170 negated,
171 ranges: ranges(members),
172 },
173 rest.get(1..).unwrap_or_default(),
174 ))
175}
176
177/// A set's members as ranges: `a-z`, or one character.
178fn ranges(members: &[char]) -> Vec<(char, char)> {
179 let mut out = Vec::new();
180 let mut rest = members;
181 loop {
182 rest = match rest {
183 [a, '-', b, tail @ ..] => {
184 out.push((*a, *b));
185 tail
186 }
187 [c, tail @ ..] => {
188 out.push((*c, *c));
189 tail
190 }
191 [] => return out,
192 };
193 }
194}
195
196/// Every position where `tokens` can stop matching `s` from `at`, anchored at `at`.
197fn ends(tokens: &[Token], s: &[char], at: usize) -> BTreeSet<usize> {
198 let Some((first, rest)) = tokens.split_first() else {
199 return BTreeSet::from([at]);
200 };
201 let next = s.get(at);
202 let one = |matched: bool| if matched { vec![at + 1] } else { Vec::new() };
203 let positions: Vec<usize> = match first {
204 Token::Char(ch) => one(next == Some(ch)),
205 Token::Any => one(next.is_some_and(|c| *c != '/')),
206 Token::Set { negated, ranges } => one(next.is_some_and(|c| ranges.iter().any(|(a, b)| a <= c && c <= b) != *negated)),
207 // Any run of characters up to the next `/` (or the end).
208 Token::Star => {
209 let run = s.get(at..).unwrap_or_default().iter().take_while(|c| **c != '/').count();
210 (at..=at + run).collect()
211 }
212 // Here, or right after any `/` from here on.
213 Token::Folders => std::iter::once(at)
214 .chain((at..s.len()).filter(|p| s.get(*p) == Some(&'/')).map(|p| p + 1))
215 .collect(),
216 Token::Everything => (at..=s.len()).collect(),
217 };
218 positions.into_iter().flat_map(|p| ends(rest, s, p)).collect()
219}
220
221#[cfg(test)]
222mod tests;