Skip to main content

markdown_prose_hooks/
ignore.rs

1//! `.unwrapignore` patterns and `--exclude` globs.
2//!
3//! A deliberate subset of gitignore rather than a reimplementation of it. Only a
4//! leading slash anchors; gitignore also anchors any pattern holding a
5//! non-trailing slash, which is two rules for one question, and full fidelity
6//! across two hand-written implementations is a parity liability rather than a
7//! feature.
8//!
9//! Every expected value in the tests below came from running the Python, and
10//! `corpus/cli/` judges the whole of it.
11
12use crate::scan::py_splitlines_keepends;
13
14/// `_IGNORE_FILE`: read from the working directory, never from an install root.
15///
16/// The working directory is the repository in every channel that matters:
17/// `pre-commit` runs a hook there, the composite action runs there, and a person
18/// runs the CLI there.
19pub const IGNORE_FILE_NAME: &str = ".unwrapignore";
20
21/// One path segment of a pattern.
22#[derive(Debug, Clone, PartialEq, Eq)]
23enum Segment {
24    /// `**`, the only segment that crosses separators.
25    DoubleStar,
26    /// Anything else, with its star runs already collapsed.
27    Literal(Vec<char>),
28}
29
30/// One parsed line of a `.unwrapignore` file, or one `--exclude` glob.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Pattern {
33    negated: bool,
34    dir_only: bool,
35    anchored: bool,
36    segments: Vec<Segment>,
37}
38
39/// `_collapse_segment`: read one segment as `**`, or collapse its star runs.
40///
41/// A segment made of nothing but stars is `**` however many were typed, which
42/// keeps `***` from being a third thing nobody can predict. Stars adjacent to
43/// other characters cannot cross a separator whatever their number, so a run
44/// inside a segment collapses to one — `a**b` and `a*b` are the same pattern,
45/// and writing them differently must not make them behave differently.
46fn 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/// `_parse_ignore_pattern`: the pattern a line describes, or none.
64#[must_use]
65pub fn parse(line: &str) -> Option<Pattern> {
66    // Trailing whitespace goes, because it is almost always an editing accident
67    // and a pattern that silently depends on an invisible character is a bad
68    // trade. `\ ` keeps a genuinely intended one.
69    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    // `\#` and `\!` keep a leading character that would otherwise be a comment
81    // marker or a negation.
82    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/// Every pattern in force, in the order that decides a tie.
112#[derive(Debug, Clone, Default)]
113pub struct IgnoreRules {
114    patterns: Vec<Pattern>,
115}
116
117impl IgnoreRules {
118    /// Build the rules from an ignore file's text and the `--exclude` globs.
119    ///
120    /// The file's lines come first and the globs after, which is what makes
121    /// `--exclude` able to narrow what the file widened.
122    #[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    /// Is `raw_path` out of scope?
145    ///
146    /// Last match wins, so a broad pattern can be narrowed by a later negation.
147    /// Without that a pattern could only ever be widened, and the file would
148    /// have to be written in an order nobody expects.
149    #[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    /// How many patterns are in force.
162    #[must_use]
163    pub fn len(&self) -> usize {
164        self.patterns.len()
165    }
166
167    /// Whether no pattern is in force, in which case nothing is excluded.
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.patterns.is_empty()
171    }
172}
173
174/// `_split_components`: a candidate path split up, with `.` and `..` resolved.
175///
176/// Never routed through a path type first: `Path('a/../b.md').as_posix()` keeps
177/// the `..`, so a pattern spelled like the resolved path would not match it.
178///
179/// Candidate separators are normalized on Windows and nowhere else, so a pattern
180/// stays one spelling across platforms while a path written the local way still
181/// matches. A backslash in a *pattern* is an escape everywhere, never a
182/// separator, which is why this governs only the candidate side.
183#[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
203/// `_match_glob_segment`: one path component against one glob segment.
204///
205/// The classic single-saved-star backtracking walk. The wildcard branch is
206/// tested **before** the literal branch, and that order is load-bearing rather
207/// than stylistic: with the literal branch first, a text character that happens
208/// to be `*` matches the pattern's `*` as a literal, no backtrack point is
209/// recorded, and `*x.md` stops matching a file genuinely named `*ax.md`.
210///
211/// Characters and not bytes, because `?` is a counted quantifier of exactly one
212/// and Python counts characters.
213fn 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
237/// `_match_segments`: do these segments match these components exactly?
238fn 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        // Zero or more components, stated once and applied everywhere rather
244        // than one rule for a leading `**`, another for a trailing one, and a
245        // third in the middle. The zero case is the one a naive implementation
246        // misses: `a/**/b.md` has to match `a/b.md`.
247        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
260/// `_pattern_matches`: does this pattern select this candidate?
261fn 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        // A trailing slash restricts to directories, and the tool is handed
276        // files, so it matches when a *proper* prefix does — `fixtures/` covers
277        // `fixtures/wrapped.md` and not a file named `fixtures`.
278        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        // A multi-segment pattern is still unanchored, and the whole run of
299        // segments has to match, ending at the candidate's last component.
300        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        // Any run of stars alone is `**`, so `***` is not a third thing.
311        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        // Collapsed: `a**b` inside a segment is `a*b`, not a separator crosser.
320        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        // The wildcard branch has to be tried before the literal branch, or the
327        // text's own `*` is consumed as a literal and no backtrack is recorded.
328        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        // One character, not one byte.
337        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        // An escaped marker is a pattern, not a comment or a negation.
362        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}