Skip to main content

git_policy_cli/
markdown_exclude.rs

1//! What:
2//!  The `exclude` option of `markdown/autofix`:
3//!  gitignore-syntax patterns,
4//!  relative to
5//!       the repository's top level,
6//!  naming candidates the policy leaves alone.
7//! Why:
8//!  The installed wrapper filtered candidates with the `ignore` package before starting
9//!      the linter (`rewrite-candidates.ts`,
10//!  `rewriteCandidates`),
11//!  so an excluded file never
12//!      starts a linter and never fails for one.
13//!  The linter evaluates its own copy of the
14//!      option with the `ignore` crate's gitignore matcher and a walk over ancestor
15//!      directories (`package/linter/monochromatic-lint/src/markdown_lfs_patterns.rs`);
16//!  this
17//!      module uses the same matcher and the same walk,
18//!  so both agree on every path.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // const excluded = ignore().add(exclude); if (excluded.ignores(candidate.path)) continue;
23//! ```
24
25/// Import the conversion of Git's pathname bytes to a path.
26use super::git_metadata::path_from_git_bytes;
27/// What:
28///  `Gitignore` is a compiled pattern list;
29///  `GitignoreBuilder` compiles one.
30/// Why:
31///   The same compiler the linter and the scanner use.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import ignore from 'ignore';
36/// ```
37use ignore::gitignore::{Gitignore, GitignoreBuilder};
38
39/// What:
40///  The compiled patterns.
41///  The field is private:
42///  build one with `compile_exclude`.
43/// Why:
44///   Compiled once per invocation,
45///  then asked once per Markdown candidate.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// type ExcludeMatcher = { ignores(path: string): boolean };
50/// ```
51#[derive(Clone, Debug)]
52pub struct ExcludeMatcher {
53    /// The patterns in authored order;
54    ///  a later match overrides an earlier one.
55    matcher: Gitignore,
56}
57
58/// What:
59///  Compile `patterns` in order.
60///  `Result<ExcludeMatcher, String>` is the matcher or
61///       the reason a pattern was refused,
62///  naming it.
63/// Why:
64///   A pattern that cannot compile must not silently match nothing.
65///  The empty root
66///       keeps candidate paths as Git prints them:
67///  relative to the top level.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// function compileExclude(patterns: string[]): ExcludeMatcher;
72/// ```
73pub fn compile_exclude(patterns: &[String]) -> Result<ExcludeMatcher, String> {
74    let mut builder: GitignoreBuilder = GitignoreBuilder::new("");
75    for pattern in patterns {
76        // The first argument names a source file for diagnostics; these patterns have none.
77        if let Err(error) = builder.add_line(None, pattern.as_str()) {
78            return Err(format!(
79                "cli-git could not compile the markdown/autofix exclude pattern {pattern:?}: {error}"
80            ));
81        }
82    }
83    match builder.build() {
84        Ok(matcher) => return Ok(ExcludeMatcher { matcher }),
85        Err(error) => {
86            return Err(format!(
87                "cli-git could not compile the markdown/autofix exclude patterns: {error}"
88            ));
89        }
90    }
91}
92
93/// What:
94///  `impl ExcludeMatcher { ... }` attaches the one question it answers.
95/// Why:
96///   Callers never see the walk over ancestor directories.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// class ExcludeMatcher { excludes(path: Uint8Array): boolean }
101/// ```
102impl ExcludeMatcher {
103    /// What:
104    ///  Whether the patterns name the candidate at `path`,
105    ///  Git's pathname bytes.
106    /// Why:
107    ///   In gitignore semantics a file below an excluded directory stays excluded
108    ///       even when a later pattern negates the file,
109    ///  so each ancestor directory is
110    ///       asked first,
111    ///  from the top;
112    ///  only when none is excluded does the file's own
113    ///       last match decide.
114    ///  A pathname this platform cannot spell is not excluded.
115    ///
116    /// In TS you'd write (pseudocode):
117    /// ```ts
118    /// excludes(path: Uint8Array): boolean
119    /// ```
120    pub fn excludes(&self, path: &[u8]) -> bool {
121        let segments: Vec<&[u8]> = path.split(is_slash).collect();
122        let mut prefix: Vec<u8> = Vec::new();
123        for (index, segment) in segments.iter().enumerate() {
124            prefix.extend_from_slice(segment);
125            let Some(spelled) = path_from_git_bytes(prefix.as_slice()) else {
126                return false;
127            };
128            // The last segment is the file itself; every earlier prefix is a directory.
129            let is_file: bool = index + 1 == segments.len();
130            let excluded: bool = self
131                .matcher
132                .matched(spelled.as_path(), !is_file)
133                .is_ignore();
134            if is_file || excluded {
135                return excluded;
136            }
137            // The separator before the next segment.
138            prefix.push(b'/');
139        }
140        return false;
141    }
142}
143
144/// What:
145///  Whether one byte is `/`,
146///  the separator of Git pathnames.
147/// Why:
148///   A named predicate for `split`,
149///  because the repository bans anonymous functions.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// const isSlash = (byte: number) => byte === 0x2f;
154/// ```
155fn is_slash(byte: &u8) -> bool {
156    return *byte == b'/';
157}
158
159/// Pattern and ancestor-walk controls stay out of the release executable.
160#[cfg(test)]
161#[path = "markdown_exclude_tests.rs"]
162mod tests;