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 #[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 #[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}