Skip to main content

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;