Skip to main content

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;