markdown_prose_hooks/
ignore.rs1use crate::scan::py_splitlines_keepends;
13
14pub const IGNORE_FILE_NAME: &str = ".unwrapignore";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23enum Segment {
24 DoubleStar,
26 Literal(Vec<char>),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Pattern {
33 negated: bool,
34 dir_only: bool,
35 anchored: bool,
36 segments: Vec<Segment>,
37}
38
39fn collapse_segment(segment: &str) -> Segment {
47 if !segment.is_empty() && segment.chars().all(|c| c == '*') {
48 return if segment.chars().count() > 1 {
49 Segment::DoubleStar
50 } else {
51 Segment::Literal(vec!['*'])
52 };
53 }
54 let mut collapsed: Vec<char> = Vec::new();
55 for c in segment.chars() {
56 if c != '*' || collapsed.last() != Some(&'*') {
57 collapsed.push(c);
58 }
59 }
60 Segment::Literal(collapsed)
61}
62
63#[must_use]
65pub fn parse(line: &str) -> Option<Pattern> {
66 let mut line = line;
70 while (line.ends_with(' ') || line.ends_with('\t')) && !line.ends_with("\\ ") {
71 line = &line[..line.len() - 1];
72 }
73 if line.is_empty() || line.starts_with('#') {
74 return None;
75 }
76 let negated = line.starts_with('!');
77 if negated {
78 line = &line[1..];
79 }
80 let unescaped = line
83 .replace("\\#", "#")
84 .replace("\\!", "!")
85 .replace("\\ ", " ");
86 let mut rest = unescaped.as_str();
87 let dir_only = rest.ends_with('/');
88 if dir_only {
89 rest = &rest[..rest.len() - 1];
90 }
91 let anchored = rest.starts_with('/');
92 if anchored {
93 rest = &rest[1..];
94 }
95 let segments: Vec<Segment> = rest
96 .split('/')
97 .filter(|segment| !segment.is_empty() && *segment != ".")
98 .map(collapse_segment)
99 .collect();
100 if segments.is_empty() {
101 return None;
102 }
103 Some(Pattern {
104 negated,
105 dir_only,
106 anchored,
107 segments,
108 })
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct IgnoreRules {
114 patterns: Vec<Pattern>,
115}
116
117impl IgnoreRules {
118 #[must_use]
123 pub fn new<'a, I: IntoIterator<Item = &'a str>>(
124 ignore_text: Option<&'a str>,
125 excludes: I,
126 ) -> Self {
127 let lines: Vec<&str> = ignore_text
128 .map(|text| {
129 py_splitlines_keepends(text)
130 .into_iter()
131 .map(|line| crate::scan::split_eol(line).0)
132 .collect()
133 })
134 .unwrap_or_default();
135 Self {
136 patterns: lines
137 .into_iter()
138 .chain(excludes)
139 .filter_map(parse)
140 .collect(),
141 }
142 }
143
144 #[must_use]
150 pub fn excludes(&self, raw_path: &str) -> bool {
151 let components = split_components(raw_path);
152 let mut excluded = false;
153 for pattern in &self.patterns {
154 if pattern_matches(pattern, &components) {
155 excluded = !pattern.negated;
156 }
157 }
158 excluded
159 }
160
161 #[must_use]
163 pub fn len(&self) -> usize {
164 self.patterns.len()
165 }
166
167 #[must_use]
169 pub fn is_empty(&self) -> bool {
170 self.patterns.is_empty()
171 }
172}
173
174#[must_use]
184pub fn split_components(raw: &str) -> Vec<&str> {
185 let normalized: Vec<&str> = if cfg!(windows) {
186 raw.split(['\\', '/']).collect()
187 } else {
188 raw.split('/').collect()
189 };
190 let mut components: Vec<&str> = Vec::new();
191 for component in normalized {
192 match component {
193 "" | "." => {}
194 ".." => {
195 components.pop();
196 }
197 _ => components.push(component),
198 }
199 }
200 components
201}
202
203fn match_glob_segment(pattern: &[char], text: &[char]) -> bool {
214 let mut star_pattern: Option<usize> = None;
215 let mut star_text = 0;
216 let mut p = 0;
217 let mut t = 0;
218 while t < text.len() {
219 if p < pattern.len() && pattern[p] == '*' {
220 star_pattern = Some(p);
221 star_text = t;
222 p += 1;
223 } else if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
224 p += 1;
225 t += 1;
226 } else if let Some(saved) = star_pattern {
227 star_text += 1;
228 t = star_text;
229 p = saved + 1;
230 } else {
231 return false;
232 }
233 }
234 pattern[p..].iter().all(|c| *c == '*')
235}
236
237fn match_segments(segments: &[Segment], components: &[&str]) -> bool {
239 let Some((head, tail)) = segments.split_first() else {
240 return components.is_empty();
241 };
242 match head {
243 Segment::DoubleStar => {
248 (0..=components.len()).any(|index| match_segments(tail, &components[index..]))
249 }
250 Segment::Literal(glob) => {
251 let Some((first, rest)) = components.split_first() else {
252 return false;
253 };
254 let text: Vec<char> = first.chars().collect();
255 match_glob_segment(glob, &text) && match_segments(tail, rest)
256 }
257 }
258}
259
260fn pattern_matches(pattern: &Pattern, components: &[&str]) -> bool {
262 let starts: Vec<usize> = if pattern.anchored {
263 vec![0]
264 } else {
265 (0..components.len()).collect()
266 };
267 for start in starts {
268 let rest = &components[start..];
269 if !pattern.dir_only {
270 if match_segments(&pattern.segments, rest) {
271 return true;
272 }
273 continue;
274 }
275 if (1..rest.len()).any(|length| match_segments(&pattern.segments, &rest[..length])) {
279 return true;
280 }
281 }
282 false
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn rules(lines: &[&str]) -> IgnoreRules {
290 IgnoreRules::new(None, lines.iter().copied())
291 }
292
293 #[test]
294 fn a_leading_slash_anchors_and_nothing_else_does() {
295 assert!(rules(&["/top.md"]).excludes("top.md"));
296 assert!(!rules(&["/top.md"]).excludes("sub/top.md"));
297 assert!(rules(&["top.md"]).excludes("sub/top.md"));
298 assert!(rules(&["docs/note.md"]).excludes("docs/note.md"));
301 assert!(rules(&["docs/note.md"]).excludes("deep/docs/note.md"));
302 assert!(!rules(&["docs"]).excludes("deep/docs/note.md"));
303 }
304
305 #[test]
306 fn a_double_star_crosses_separators_including_zero_of_them() {
307 assert!(rules(&["a/**/b.md"]).excludes("a/b.md"));
308 assert!(rules(&["a/**/b.md"]).excludes("a/m/n/b.md"));
309 assert!(!rules(&["a/**/b.md"]).excludes("other/b.md"));
310 assert!(rules(&["a/***/b.md"]).excludes("a/b.md"));
312 }
313
314 #[test]
315 fn a_single_star_stays_inside_one_component() {
316 assert!(rules(&["*.md"]).excludes("a.md"));
317 assert!(rules(&["*.md"]).excludes("sub/a.md"));
318 assert!(!rules(&["a*b"]).excludes("a/b"));
319 assert!(!rules(&["a**b"]).excludes("a/b"));
321 assert!(rules(&["a**b"]).excludes("axxb"));
322 }
323
324 #[test]
325 fn a_literal_star_in_a_filename_still_matches() {
326 assert!(rules(&["*x.md"]).excludes("*ax.md"));
329 assert!(rules(&["*x.md"]).excludes("*x.md"));
330 }
331
332 #[test]
333 fn a_question_mark_is_exactly_one_character() {
334 assert!(rules(&["a?.md"]).excludes("ab.md"));
335 assert!(!rules(&["a?.md"]).excludes("ab c.md"));
336 assert!(rules(&["a?.md"]).excludes("a\u{e9}.md"));
338 }
339
340 #[test]
341 fn a_trailing_slash_restricts_to_a_proper_prefix() {
342 assert!(rules(&["fixtures/"]).excludes("fixtures/wrapped.md"));
343 assert!(!rules(&["fixtures/"]).excludes("fixtures"));
344 assert!(rules(&["fixtures/"]).excludes("a/fixtures/b/c.md"));
345 }
346
347 #[test]
348 fn the_last_matching_pattern_wins() {
349 assert!(!rules(&["*.md", "!keep.md"]).excludes("keep.md"));
350 assert!(rules(&["!keep.md", "*.md"]).excludes("keep.md"));
351 assert!(rules(&["*.md", "!keep.md"]).excludes("a.md"));
352 }
353
354 #[test]
355 fn comments_and_blank_lines_describe_no_pattern() {
356 assert!(parse("").is_none());
357 assert!(parse(" ").is_none());
358 assert!(parse("# a comment").is_none());
359 assert!(parse("/").is_none());
360 assert!(parse(".").is_none());
361 assert!(rules(&["\\#a.md"]).excludes("#a.md"));
363 assert!(rules(&["\\!a.md"]).excludes("!a.md"));
364 }
365
366 #[test]
367 fn trailing_whitespace_goes_unless_it_was_escaped() {
368 assert!(rules(&["a.md "]).excludes("a.md"));
369 assert!(rules(&["a.md\\ "]).excludes("a.md "));
370 assert!(!rules(&["a.md\\ "]).excludes("a.md"));
371 }
372
373 #[test]
374 fn a_candidate_resolves_its_dot_segments_and_a_pattern_does_not() {
375 assert_eq!(split_components("a/../b.md"), ["b.md"]);
376 assert_eq!(split_components("./a.md"), ["a.md"]);
377 assert_eq!(split_components("a//b.md"), ["a", "b.md"]);
378 assert_eq!(split_components("../a.md"), ["a.md"]);
379 assert!(split_components("").is_empty());
380 assert!(rules(&["b.md"]).excludes("a/../b.md"));
381 }
382
383 #[test]
384 fn no_patterns_excludes_nothing() {
385 let empty = IgnoreRules::default();
386 assert!(empty.is_empty());
387 assert!(!empty.excludes("anything.md"));
388 }
389
390 #[test]
391 fn an_ignore_file_is_split_on_the_three_boundaries() {
392 let rules = IgnoreRules::new(Some("a.md\r\nb.md\rc.md\n"), std::iter::empty());
393 assert_eq!(rules.len(), 3);
394 assert!(rules.excludes("a.md"));
395 assert!(rules.excludes("b.md"));
396 assert!(rules.excludes("c.md"));
397 }
398}