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;