Skip to main content

shared/
ignore_list.rs

1use std::{path::Path, sync::Arc};
2
3#[derive(Clone)]
4pub struct IgnoreList {
5    matcher: ignore::gitignore::Gitignore,
6    descend: Arc<[DescendRule]>,
7    descend_all: bool,
8}
9
10impl IgnoreList {
11    pub fn builder() -> IgnoreListBuilder {
12        let mut builder = ignore::gitignore::GitignoreBuilder::new("");
13        builder.allow_unclosed_class(false);
14
15        IgnoreListBuilder {
16            builder,
17            descend: Vec::new(),
18            descend_all: false,
19        }
20    }
21
22    pub fn try_from_lines<S: AsRef<str>>(
23        lines: impl IntoIterator<Item = S>,
24    ) -> Result<Self, ignore::Error> {
25        let mut builder = Self::builder();
26        for line in lines {
27            builder.try_push_line(line.as_ref())?;
28        }
29
30        builder.build()
31    }
32
33    fn should_descend(&self, path: &Path) -> bool {
34        if self.descend_all {
35            return true;
36        }
37
38        let path = path.to_string_lossy();
39        let path = path.trim_start_matches("./").trim_start_matches('/');
40
41        self.descend.iter().any(|rule| rule.covers(path))
42    }
43
44    fn excluded(&self, path: &Path, is_dir: bool) -> bool {
45        if path.as_os_str().is_empty() || path == Path::new(".") {
46            return false;
47        }
48
49        self.matcher.matched(path, is_dir).is_ignore()
50    }
51
52    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
53        self.excluded(path, is_dir)
54    }
55
56    pub fn is_ignored_subtree(&self, path: &Path, is_dir: bool) -> bool {
57        self.excluded(path, is_dir) && !(is_dir && self.should_descend(path))
58    }
59}
60
61pub struct IgnoreListBuilder {
62    builder: ignore::gitignore::GitignoreBuilder,
63    descend: Vec<DescendRule>,
64    descend_all: bool,
65}
66
67impl IgnoreListBuilder {
68    pub fn try_push_line(&mut self, line: &str) -> Result<(), ignore::Error> {
69        let trimmed = line.trim();
70        if trimmed.is_empty() || trimmed.starts_with('#') {
71            return Ok(());
72        }
73
74        let (negated, body) = match trimmed.strip_prefix('!') {
75            Some(body) => ("!", body),
76            None => ("", trimmed),
77        };
78        if body.is_empty() {
79            return Ok(());
80        }
81
82        self.builder.add_line(None, trimmed)?;
83
84        let dir_only = body.ends_with('/');
85        let stem = body.trim_end_matches('/');
86        let mut needs_descend = !negated.is_empty();
87        let companion = match stem.strip_suffix("/**") {
88            Some("") => None,
89            Some(_) if dir_only => {
90                needs_descend = true;
91                None
92            }
93            Some(parent) if parent.contains('/') => Some(format!("{negated}{parent}")),
94            Some(parent) => Some(format!("{negated}/{parent}")),
95            None if stem.is_empty() => None,
96            None if stem.contains('/') => Some(format!("{negated}{stem}/**")),
97            None => Some(format!("{negated}**/{stem}/**")),
98        };
99        if let Some(companion) = companion {
100            self.builder.add_line(None, &companion).ok();
101        }
102
103        if needs_descend {
104            match DescendRule::parse(body) {
105                Some(rule) => self.descend.push(rule),
106                None => self.descend_all = true,
107            }
108        }
109
110        Ok(())
111    }
112
113    pub fn build(self) -> Result<IgnoreList, ignore::Error> {
114        Ok(IgnoreList {
115            matcher: self.builder.build()?,
116            descend: self.descend.into(),
117            descend_all: self.descend_all,
118        })
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123struct DescendRule {
124    prefix: compact_str::CompactString,
125    tail: Option<usize>,
126}
127
128impl DescendRule {
129    fn parse(pattern: &str) -> Option<Self> {
130        let pattern = pattern.trim_end_matches('/');
131        if !pattern.contains('/') {
132            return None;
133        }
134
135        let pattern = pattern.trim_start_matches('/');
136        let mut segments = pattern.split('/').peekable();
137        let mut prefix = compact_str::CompactString::default();
138
139        while let Some(segment) = segments.next_if(|segment| !segment.contains(['*', '?', '['])) {
140            if !prefix.is_empty() {
141                prefix.push('/');
142            }
143            prefix.push_str(segment);
144        }
145
146        if prefix.is_empty() {
147            return None;
148        }
149
150        let tail = segments.try_fold(0, |tail, segment| {
151            (!segment.contains("**")).then_some(tail + 1)
152        });
153
154        Some(Self { prefix, tail })
155    }
156
157    fn covers(&self, path: &str) -> bool {
158        if path == self.prefix {
159            return true;
160        }
161
162        if let Some(rest) = self.prefix.strip_prefix(path) {
163            return rest.starts_with('/');
164        }
165
166        match path.strip_prefix(self.prefix.as_str()) {
167            Some(rest) if rest.starts_with('/') => self
168                .tail
169                .is_none_or(|tail| rest.matches('/').count() < tail),
170            _ => false,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // IgnoreList
180    #[test]
181    fn point_checks_deny_the_whole_subtree_of_a_denied_name() -> Result<(), anyhow::Error> {
182        let ignore = IgnoreList::try_from_lines(["secrets", "/config.yml"])?;
183
184        assert!(ignore.is_ignored(Path::new("secrets"), true));
185        assert!(ignore.is_ignored(Path::new("secrets/deep/token.txt"), false));
186        assert!(ignore.is_ignored(Path::new("game/secrets/token.txt"), false));
187        assert!(ignore.is_ignored(Path::new("config.yml"), false));
188        assert!(!ignore.is_ignored(Path::new("game/config.yml"), false));
189        assert!(!ignore.is_ignored(Path::new("secrets.txt"), false));
190
191        Ok(())
192    }
193
194    #[test]
195    fn a_descended_directory_is_denied_itself_but_not_as_a_subtree() -> Result<(), anyhow::Error> {
196        let ignore = IgnoreList::try_from_lines(["*", "!game/csgo/cfg"])?;
197
198        assert!(ignore.is_ignored(Path::new("game"), true));
199        assert!(!ignore.is_ignored_subtree(Path::new("game"), true));
200        assert!(!ignore.is_ignored_subtree(Path::new("game/csgo"), true));
201        assert!(ignore.is_ignored_subtree(Path::new("other"), true));
202        assert!(ignore.is_ignored_subtree(Path::new("game/x.txt"), false));
203        assert!(!ignore.is_ignored(Path::new("game/csgo/cfg"), true));
204        assert!(!ignore.is_ignored(Path::new("game/csgo/cfg/server.cfg"), false));
205
206        Ok(())
207    }
208
209    #[test]
210    fn a_slash_free_reinclude_keeps_every_directory_enterable() -> Result<(), anyhow::Error> {
211        let ignore = IgnoreList::try_from_lines(["*", "!.pteroignore"])?;
212
213        assert!(ignore.is_ignored(Path::new("game"), true));
214        assert!(!ignore.is_ignored_subtree(Path::new("game/csgo"), true));
215        assert!(!ignore.is_ignored(Path::new("game/csgo/.pteroignore"), false));
216
217        Ok(())
218    }
219
220    #[test]
221    fn a_later_exclude_outranks_an_earlier_reinclude() -> Result<(), anyhow::Error> {
222        let ignore = IgnoreList::try_from_lines(["*", "!config", "config/secret.key"])?;
223
224        assert!(!ignore.is_ignored(Path::new("config"), true));
225        assert!(!ignore.is_ignored(Path::new("config/server.yml"), false));
226        assert!(ignore.is_ignored(Path::new("config/secret.key"), false));
227
228        Ok(())
229    }
230
231    #[test]
232    fn the_root_is_never_an_entry_of_its_own_list() -> Result<(), anyhow::Error> {
233        let ignore = IgnoreList::try_from_lines(["*"])?;
234
235        for root in ["", "."] {
236            assert!(!ignore.is_ignored(Path::new(root), true));
237            assert!(!ignore.is_ignored_subtree(Path::new(root), true));
238        }
239        assert!(ignore.is_ignored(Path::new("x"), false));
240
241        Ok(())
242    }
243
244    #[test]
245    fn an_unparsable_line_is_refused() {
246        assert!(IgnoreList::try_from_lines(["[abc", "*.log"]).is_err());
247        assert!(IgnoreList::try_from_lines(["*.log", "!keep.log", "logs/", "# note", ""]).is_ok());
248    }
249
250    // DescendRule
251    #[test]
252    fn descend_rule_bounds_the_wildcard_tail() {
253        let rule = |prefix: &str, tail| DescendRule {
254            prefix: prefix.into(),
255            tail,
256        };
257
258        for (pattern, expected) in [
259            ("game/csgo/addons", Some(rule("game/csgo/addons", Some(0)))),
260            ("/game/csgo/", Some(rule("game/csgo", Some(0)))),
261            ("game/*/data", Some(rule("game", Some(2)))),
262            ("game/**/cfg", Some(rule("game", None))),
263            (".pteroignore", None),
264            ("*", None),
265            ("[abc]/x", None),
266        ] {
267            assert_eq!(DescendRule::parse(pattern), expected, "{pattern}");
268        }
269    }
270
271    #[test]
272    fn descend_rule_covers_ancestors_and_the_bounded_tail_only() {
273        let literal = DescendRule::parse("game/csgo/addons").unwrap();
274        assert!(literal.covers("game"));
275        assert!(literal.covers("game/csgo/addons"));
276        assert!(!literal.covers("game/csgo/addons/x"));
277        assert!(!literal.covers("game/csgo/other"));
278        assert!(!literal.covers("gam"));
279
280        let bounded = DescendRule::parse("game/*/cfg").unwrap();
281        assert!(bounded.covers("game/csgo"));
282        assert!(!bounded.covers("game/csgo/cfg/nested"));
283
284        let unbounded = DescendRule::parse("game/**/cfg").unwrap();
285        assert!(unbounded.covers("game/a/b/c/d"));
286    }
287}