git_policy_cli/markdown_linter.rs
1//! What:
2//! The linter the executable gives `markdown/autofix`:
3//! `monochromatic-lint`,
4//! found
5//! on the invocation's PATH and started once per candidate as
6//! `monochromatic-lint --config=<file> --stdin --stdin-filename=<path> --fix`
7//! from the repository's top level,
8//! with a time bound and output caps.
9//! Why:
10//! This is the plan's stream contract (`doc/planning/unified-linter.md`,
11//! section
12//! "Distribution and consumers";
13//! `doc/planning/cli-git-rust-implementation.md`,
14//! lines 181 to 189).
15//! The program and its one-rule configuration are found and
16//! written once per invocation,
17//! when the first candidate needs them,
18//! so a command
19//! with no Markdown candidate never needs the linter.
20//! Every option is written as one
21//! `--name=value` argument,
22//! so a pathname that starts with `-` stays a value.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! // const run = await processLinter(env).lint({ topLevel, options, path, source });
27//! ```
28
29/// Import the bounded child runner.
30use super::bounded_child::{
31 ChildFailure, ChildLimits, ChildRequest, FinishedChild, run_bounded_child,
32};
33/// Import the reading of the environment as name and value pairs.
34use super::child_environment::environment_value;
35/// Import the conversion of Git's pathname bytes to a path.
36use super::git_metadata::path_from_git_bytes;
37/// Import the one-rule configuration and its file.
38use super::markdown_linter_config::{one_rule_configuration, write_configuration};
39/// Import the PATH lookup.
40use super::markdown_linter_lookup::find_linter;
41/// Import the reading of one run.
42use super::markdown_linter_output::{LintRun, interpret_linter_run};
43/// Import the interface this linter provides.
44use super::policy_markdown::{LintRequest, MarkdownLinter};
45/// `OsString` is owned operating-system text.
46use std::ffi::OsString;
47/// `PathBuf` is an owned filesystem path.
48use std::path::PathBuf;
49/// `Duration` is a span of time.
50use std::time::Duration;
51/// The owner-only temporary file that holds the configuration while the invocation runs.
52use tempfile::NamedTempFile;
53
54/// What:
55/// The bounds of one linter run in the executable.
56/// Why:
57/// Measured on the host,
58/// one run takes about 11 milliseconds warm and 227 cold;
59/// the
60/// LFS rule also hashes every image a file links,
61/// which for a large image takes
62/// seconds,
63/// and the host has run at load averages of 40 to 90.
64/// A minute leaves
65/// room for all of that and still ends a hung linter.
66/// Standard output is the fixed
67/// source:
68/// the cap allows a large file whose every link grows into an object URL.
69/// Standard error carries only the findings the fixes leave.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// const LINTER_LIMITS = { timeoutMs: 60_000, maxStdout: 64 * 2 ** 20, maxStderr: 4 * 2 ** 20 };
74/// ```
75pub const LINTER_LIMITS: ChildLimits = ChildLimits {
76 timeout: Duration::from_secs(60),
77 max_stdout: 64 << 20,
78 max_stderr: 4 << 20,
79};
80
81/// What:
82/// The linter of one invocation:
83/// the environment it runs with,
84/// its bounds,
85/// where
86/// its files go,
87/// and,
88/// once known,
89/// its program and configuration file.
90/// Why:
91/// The lookup and the file are made at most once;
92/// their failures are remembered and
93/// reported to every later candidate the same way.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// class ProcessLinter implements MarkdownLinter { executable?: string | null; configuration?: TemporaryFile | string }
98/// ```
99pub struct ProcessLinter {
100 /// The invocation's whole environment,
101 /// which the linter runs with.
102 environment: Vec<(OsString, OsString)>,
103 /// The bounds of each run.
104 limits: ChildLimits,
105 /// The directory for the configuration file and the stream files.
106 scratch: PathBuf,
107 /// The program,
108 /// once looked up;
109 /// `Some(None)` remembers that it was not found.
110 executable: Option<Option<PathBuf>>,
111 /// The configuration file,
112 /// once written,
113 /// or the reason it could not be.
114 configuration: Option<Result<NamedTempFile, String>>,
115}
116
117/// What:
118/// The linter for an invocation with this environment,
119/// bounds and scratch directory.
120/// `&[(OsString, OsString)]` borrows the environment.
121/// Why:
122/// The executable passes `LINTER_LIMITS` and the system's temporary directory;
123/// tests
124/// pass small bounds and a fixture.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// const processLinter = (environment, limits, scratch) => new ProcessLinter(environment, limits, scratch);
129/// ```
130pub fn process_linter(
131 environment: &[(OsString, OsString)],
132 limits: ChildLimits,
133 scratch: PathBuf,
134) -> ProcessLinter {
135 return ProcessLinter {
136 environment: environment.to_vec(),
137 limits,
138 scratch,
139 executable: None,
140 configuration: None,
141 };
142}
143
144/// What:
145/// The linter of the running executable:
146/// `LINTER_LIMITS` and the system's temporary
147/// directory,
148/// made absolute.
149/// `std::path::absolute` resolves a relative directory
150/// against the current one without touching the filesystem.
151/// Why:
152/// The linter runs from the top level,
153/// so a relative configuration path would name
154/// another file there.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// const executableLinter = (environment) => processLinter(environment, LINTER_LIMITS, resolve(tmpdir()));
159/// ```
160pub fn executable_linter(environment: &[(OsString, OsString)]) -> ProcessLinter {
161 let temporary: PathBuf = std::env::temp_dir();
162 let scratch: PathBuf = std::path::absolute(&temporary).unwrap_or(temporary);
163 return process_linter(environment, LINTER_LIMITS, scratch);
164}
165
166/// What:
167/// The sentence for a child that produced no usable run.
168/// Why:
169/// Each cause tells the person what happened in the linter's terms.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// function childFailureReason(failure: ChildFailure, program: string): string;
174/// ```
175fn child_failure_reason(failure: &ChildFailure, program: &std::path::Path) -> String {
176 match failure {
177 ChildFailure::Files(reason) => {
178 return format!(
179 "cli-git could not create the temporary files for monochromatic-lint's input and output: {reason}"
180 );
181 }
182 ChildFailure::Start(reason) => {
183 return format!(
184 "cli-git could not start monochromatic-lint at {}: {reason}",
185 program.display()
186 );
187 }
188 ChildFailure::Wait(reason) => {
189 return format!("cli-git could not wait for monochromatic-lint: {reason}");
190 }
191 ChildFailure::TimedOut(bound) => {
192 return format!(
193 "monochromatic-lint did not finish within {} milliseconds and was stopped",
194 bound.as_millis()
195 );
196 }
197 ChildFailure::TooLarge { stream, limit } => {
198 return format!(
199 "monochromatic-lint wrote more than {limit} bytes to its {stream} and was stopped"
200 );
201 }
202 }
203}
204
205/// What:
206/// `impl ProcessLinter { ... }` attaches the two remembered preparations.
207/// Why:
208/// They are made on first use and never twice.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// class ProcessLinter { program(): string; configurationPath(options): string }
213/// ```
214impl ProcessLinter {
215 /// What:
216 /// The program,
217 /// looked up on the environment's PATH on first use.
218 /// Why:
219 /// A missing linter is a broken installation,
220 /// reported as the reason every
221 /// Markdown candidate stays unchecked (`doc/planning/cli-git-rust-open-decisions.md`,
222 /// "Markdown policy without the linter").
223 ///
224 /// In TS you'd write (pseudocode):
225 /// ```ts
226 /// program(): string; // throws string
227 /// ```
228 fn program(&mut self) -> Result<PathBuf, String> {
229 if self.executable.is_none() {
230 self.executable = Some(find_linter(
231 environment_value(self.environment.as_slice(), "PATH").as_deref(),
232 ));
233 }
234 match &self.executable {
235 Some(Some(found)) => return Ok(found.clone()),
236 Some(None) | None => {
237 return Err(String::from(
238 "cli-git could not find monochromatic-lint in any absolute directory on PATH, and the policy markdown/autofix runs it for every Markdown candidate; install it so that it is on PATH",
239 ));
240 }
241 }
242 }
243
244 /// What:
245 /// The configuration file's path,
246 /// written for the request's options on first use.
247 /// Why:
248 /// The options are the invocation's,
249 /// the same for every candidate.
250 ///
251 /// In TS you'd write (pseudocode):
252 /// ```ts
253 /// configurationPath(request: LintRequest): string; // throws string
254 /// ```
255 fn configuration_path(&mut self, request: &LintRequest<'_>) -> Result<PathBuf, String> {
256 if self.configuration.is_none() {
257 let document: String = one_rule_configuration(
258 request.options.rules.as_slice(),
259 request.options.exclude.as_slice(),
260 );
261 self.configuration = Some(write_configuration(
262 document.as_str(),
263 self.scratch.as_path(),
264 ));
265 }
266 match &self.configuration {
267 Some(Ok(file)) => return Ok(file.path().to_path_buf()),
268 Some(Err(reason)) => return Err(reason.clone()),
269 None => {
270 return Err(String::from(
271 "cli-git did not write the linter configuration; this is a defect in cli-git.",
272 ));
273 }
274 }
275 }
276}
277
278/// What:
279/// One `--name=value` argument.
280/// `&std::ffi::OsStr` borrows the value's raw bytes.
281/// Why:
282/// Joined to its option,
283/// a value that starts with `-` can never read as an option.
284///
285/// In TS you'd write (pseudocode):
286/// ```ts
287/// const option = (name: string, value: string) => `${name}=${value}`;
288/// ```
289fn option(name: &str, value: &std::ffi::OsStr) -> OsString {
290 let mut argument: OsString = OsString::from(name);
291 argument.push("=");
292 argument.push(value);
293 return argument;
294}
295
296/// What:
297/// `impl MarkdownLinter for ProcessLinter` starts the real linter for each request.
298/// Why:
299/// See the module comment.
300///
301/// In TS you'd write (pseudocode):
302/// ```ts
303/// class ProcessLinter implements MarkdownLinter {}
304/// ```
305impl MarkdownLinter for ProcessLinter {
306 /// What:
307 /// Lint one candidate through the linter's standard input.
308 /// Why:
309 /// The linter reads `.lfsconfig` and linked images from the worktree,
310 /// so it runs
311 /// from the top level and is told the candidate's name relative to it.
312 ///
313 /// In TS you'd write (pseudocode):
314 /// ```ts
315 /// lint(request: LintRequest): LintRun; // throws string
316 /// ```
317 fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String> {
318 let program: PathBuf = self.program()?;
319 let configuration: PathBuf = self.configuration_path(request)?;
320 let Some(name) = path_from_git_bytes(request.path) else {
321 return Err(String::from(
322 "cli-git cannot spell this candidate's pathname for monochromatic-lint on this platform",
323 ));
324 };
325 let arguments: Vec<OsString> = vec![
326 option("--config", configuration.as_os_str()),
327 OsString::from("--stdin"),
328 option("--stdin-filename", name.as_os_str()),
329 OsString::from("--fix"),
330 ];
331 let finished: FinishedChild = match run_bounded_child(&ChildRequest {
332 executable: program.as_path(),
333 arguments: arguments.as_slice(),
334 directory: request.top_level,
335 environment: self.environment.as_slice(),
336 input: request.source,
337 scratch: self.scratch.as_path(),
338 limits: self.limits,
339 }) {
340 Ok(done) => done,
341 Err(failure) => return Err(child_failure_reason(&failure, program.as_path())),
342 };
343 return interpret_linter_run(request.source, request.options.rules.as_slice(), &finished);
344 }
345}
346
347/// Lookup,
348/// configuration,
349/// argument and failure controls stay out of the release executable.
350#[cfg(test)]
351#[path = "markdown_linter_tests.rs"]
352mod tests;