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_noticing,
32};
33/// When a long run prints its notices.
34use super::wait_progress::Cadence;
35/// The sink of those notices.
36use std::io::Write;
37/// Import the reading of the environment as name and value pairs.
38use super::child_environment::environment_value;
39/// Import the conversion of Git's pathname bytes to a path.
40use super::git_metadata::path_from_git_bytes;
41/// Import the one-rule configuration and its file.
42use super::markdown_linter_config::{one_rule_configuration, write_configuration};
43/// Import the PATH lookup.
44use super::markdown_linter_lookup::find_linter;
45/// Import the reading of one run.
46use super::markdown_linter_output::{LintRun, interpret_linter_run};
47/// Import the interface this linter provides.
48use super::policy_markdown::{LintRequest, MarkdownLinter};
49use super::scratch_location::prepare_user_scratch;
50/// `OsString` is owned operating-system text.
51use std::ffi::OsString;
52/// `PathBuf` is an owned filesystem path.
53use std::path::{Path, PathBuf};
54/// `Duration` is a span of time.
55use std::time::Duration;
56/// The owner-only temporary file that holds the configuration while the invocation runs.
57use tempfile::NamedTempFile;
58
59/// What:
60///  The bounds of one linter run in the executable.
61/// Why:
62///   Measured on the host,
63///  one run takes about 11 milliseconds warm and 227 cold;
64///  the
65///       LFS rule also hashes every image a file links,
66///  which for a large image takes
67///       seconds,
68///  and the host has run at load averages of 40 to 90.
69///  A minute leaves
70///       room for all of that and still ends a hung linter.
71///  Standard output is the fixed
72///       source:
73///  the cap allows a large file whose every link grows into an object URL.
74///       Standard error carries only the findings the fixes leave.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// const LINTER_LIMITS = { timeoutMs: 60_000, maxStdout: 64 * 2 ** 20, maxStderr: 4 * 2 ** 20 };
79/// ```
80pub const LINTER_LIMITS: ChildLimits = ChildLimits {
81    timeout: Duration::from_secs(60),
82    max_stdout: 64 << 20,
83    max_stderr: 4 << 20,
84};
85
86/// What:
87///  The linter of one invocation:
88///  the environment it runs with,
89///  its bounds,
90///  where
91///       its files go,
92///  and,
93///  once known,
94///  its program and configuration file.
95/// Why:
96///   The lookup and the file are made at most once;
97///  their failures are remembered and
98///       reported to every later candidate the same way.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// class ProcessLinter implements MarkdownLinter { executable?: string | null; configuration?: TemporaryFile | string }
103/// ```
104pub struct ProcessLinter {
105    /// The invocation's whole environment,
106    ///  which the linter runs with.
107    environment: Vec<(OsString, OsString)>,
108    /// The bounds of each run.
109    limits: ChildLimits,
110    /// The directory for the configuration file and the stream files when one is given;
111    ///  `None` means the per-user scratch directory of the environment,
112    ///  made on first use.
113    scratch: Option<PathBuf>,
114    /// The directory in use,
115    ///  once decided,
116    ///  or the reason it could not be.
117    prepared_scratch: Option<Result<PathBuf, String>>,
118    /// The program,
119    ///  once looked up;
120    ///  `Some(None)` remembers that it was not found.
121    executable: Option<Option<PathBuf>>,
122    /// The configuration file,
123    ///  once written,
124    ///  or the reason it could not be.
125    configuration: Option<Result<NamedTempFile, String>>,
126    /// When a run that takes long prints its notices.
127    cadence: Cadence,
128    /// Where those notices go.
129    notices: Box<dyn Write>,
130}
131
132/// What:
133///  The linter for an invocation with this environment,
134///  bounds and scratch directory.
135///       `&[(OsString, OsString)]` borrows the environment.
136/// Why:
137///   Tests pass small bounds and a fixture directory;
138///  `executable_linter` leaves the directory to the environment.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// const processLinter = (environment, limits, scratch) => new ProcessLinter(environment, limits, scratch);
143/// ```
144pub fn process_linter(
145    environment: &[(OsString, OsString)],
146    limits: ChildLimits,
147    scratch: PathBuf,
148) -> ProcessLinter {
149    return ProcessLinter {
150        environment: environment.to_vec(),
151        limits,
152        scratch: Some(scratch),
153        prepared_scratch: None,
154        executable: None,
155        configuration: None,
156        cadence: Cadence::from_environment(),
157        notices: Box::new(std::io::stderr()),
158    };
159}
160
161/// What:
162///  The linter of the running executable:
163///  `LINTER_LIMITS` and the per-user scratch directory of the environment,
164///  made on the first lint (`<state>/cli-git/scratch`, mode `0700`, never the system temporary directory).
165/// Why:
166///   The placeholder linters of checks built before an environment is known are made with an empty environment,
167///  and a command with no Markdown candidate must not touch the disk,
168///  so a missing state directory or `HOME` is reported when a candidate needs the linter,
169///  not when it is made.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// const executableLinter = (environment) => new ProcessLinter(environment, LINTER_LIMITS, undefined);
174/// ```
175pub fn executable_linter(environment: &[(OsString, OsString)]) -> ProcessLinter {
176    return ProcessLinter {
177        environment: environment.to_vec(),
178        limits: LINTER_LIMITS,
179        scratch: None,
180        prepared_scratch: None,
181        executable: None,
182        configuration: None,
183        cadence: Cadence::from_environment(),
184        notices: Box::new(std::io::stderr()),
185    };
186}
187
188/// What:
189///  The sentence for a child that produced no usable run.
190/// Why:
191///   Each cause tells the person what happened in the linter's terms.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// function childFailureReason(failure: ChildFailure, program: string): string;
196/// ```
197fn child_failure_reason(failure: &ChildFailure, program: &std::path::Path) -> String {
198    match failure {
199        ChildFailure::Files(reason) => {
200            return format!(
201                "cli-git could not create the temporary files for monochromatic-lint's input and output: {reason}"
202            );
203        }
204        ChildFailure::Start(reason) => {
205            return format!(
206                "cli-git could not start monochromatic-lint at {}: {reason}",
207                program.display()
208            );
209        }
210        ChildFailure::Wait(reason) => {
211            return format!("cli-git could not wait for monochromatic-lint: {reason}");
212        }
213        ChildFailure::TimedOut(bound) => {
214            return format!(
215                "monochromatic-lint did not finish within {} milliseconds and was stopped; run the command again, \
216                 run monochromatic-lint by hand on the file to see why it stalls, or set the policy markdown/autofix \
217                 to off to skip the check",
218                bound.as_millis()
219            );
220        }
221        ChildFailure::TooLarge { stream, limit } => {
222            return format!(
223                "monochromatic-lint wrote more than {limit} bytes to its {stream} and was stopped"
224            );
225        }
226    }
227}
228
229/// What:
230///  `impl ProcessLinter { ... }` attaches the two remembered preparations.
231/// Why:
232///   They are made on first use and never twice.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// class ProcessLinter { program(): string; configurationPath(options): string }
237/// ```
238impl ProcessLinter {
239    /// The same linter with another notice cadence and sink;
240    ///  tests choose a short cadence and read the notices from a buffer.
241    pub fn with_notices(mut self, cadence: Cadence, notices: Box<dyn Write>) -> Self {
242        self.cadence = cadence;
243        self.notices = notices;
244        return self;
245    }
246
247    /// What:
248    ///  The program,
249    ///  looked up on the environment's PATH on first use.
250    /// Why:
251    ///   A missing linter is a broken installation,
252    ///  reported as the reason every
253    ///       Markdown candidate stays unchecked (`doc/planning/cli-git-rust-open-decisions.md`,
254    ///       "Markdown policy without the linter").
255    ///
256    /// In TS you'd write (pseudocode):
257    /// ```ts
258    /// program(): string; // throws string
259    /// ```
260    fn program(&mut self) -> Result<PathBuf, String> {
261        if self.executable.is_none() {
262            self.executable = Some(find_linter(
263                environment_value(self.environment.as_slice(), "PATH").as_deref(),
264            ));
265        }
266        match &self.executable {
267            Some(Some(found)) => return Ok(found.clone()),
268            Some(None) | None => {
269                return Err(String::from(
270                    "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",
271                ));
272            }
273        }
274    }
275
276    /// What:
277    ///  The directory for the configuration file and the stream files,
278    ///  made private and swept of stale entries on first use.
279    /// Why:
280    ///   A command with no Markdown candidate never reaches here,
281    ///  so it never touches the disk;
282    ///  the per-user location is the environment's,
283    ///  never the system temporary directory.
284    ///
285    /// In TS you'd write (pseudocode):
286    /// ```ts
287    /// scratchDirectory(): string; // throws string
288    /// ```
289    fn scratch_directory(&mut self) -> Result<PathBuf, String> {
290        if self.prepared_scratch.is_none() {
291            self.prepared_scratch = Some(match &self.scratch {
292                Some(given) => Ok(given.clone()),
293                None => prepare_user_scratch(self.environment.as_slice()),
294            });
295        }
296        match &self.prepared_scratch {
297            Some(decided) => return decided.clone(),
298            None => {
299                return Err(String::from(
300                    "cli-git did not decide the linter's scratch directory; this is a defect in cli-git.",
301                ));
302            }
303        }
304    }
305
306    /// What:
307    ///  The configuration file's path,
308    ///  written for the request's options on first use.
309    /// Why:
310    ///   The options are the invocation's,
311    ///  the same for every candidate.
312    ///
313    /// In TS you'd write (pseudocode):
314    /// ```ts
315    /// configurationPath(request: LintRequest): string; // throws string
316    /// ```
317    fn configuration_path(&mut self, request: &LintRequest<'_>, scratch: &Path) -> Result<PathBuf, String> {
318        if self.configuration.is_none() {
319            let document: String = one_rule_configuration(
320                request.options.rules.as_slice(),
321                request.options.exclude.as_slice(),
322            );
323            self.configuration = Some(write_configuration(document.as_str(), scratch));
324        }
325        match &self.configuration {
326            Some(Ok(file)) => return Ok(file.path().to_path_buf()),
327            Some(Err(reason)) => return Err(reason.clone()),
328            None => {
329                return Err(String::from(
330                    "cli-git did not write the linter configuration; this is a defect in cli-git.",
331                ));
332            }
333        }
334    }
335}
336
337/// What:
338///  One `--name=value` argument.
339///  `&std::ffi::OsStr` borrows the value's raw bytes.
340/// Why:
341///   Joined to its option,
342///  a value that starts with `-` can never read as an option.
343///
344/// In TS you'd write (pseudocode):
345/// ```ts
346/// const option = (name: string, value: string) => `${name}=${value}`;
347/// ```
348fn option(name: &str, value: &std::ffi::OsStr) -> OsString {
349    let mut argument: OsString = OsString::from(name);
350    argument.push("=");
351    argument.push(value);
352    return argument;
353}
354
355/// What:
356///  `impl MarkdownLinter for ProcessLinter` starts the real linter for each request.
357/// Why:
358///   See the module comment.
359///
360/// In TS you'd write (pseudocode):
361/// ```ts
362/// class ProcessLinter implements MarkdownLinter {}
363/// ```
364impl MarkdownLinter for ProcessLinter {
365    /// What:
366    ///  Lint one candidate through the linter's standard input.
367    /// Why:
368    ///   The linter reads `.lfsconfig` and linked images from the worktree,
369    ///  so it runs
370    ///       from the top level and is told the candidate's name relative to it.
371    ///
372    /// In TS you'd write (pseudocode):
373    /// ```ts
374    /// lint(request: LintRequest): LintRun; // throws string
375    /// ```
376    fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String> {
377        let program: PathBuf = self.program()?;
378        let scratch: PathBuf = self.scratch_directory()?;
379        let configuration: PathBuf = self.configuration_path(request, scratch.as_path())?;
380        let Some(name) = path_from_git_bytes(request.path) else {
381            return Err(String::from(
382                "cli-git cannot spell this candidate's pathname for monochromatic-lint on this platform",
383            ));
384        };
385        let arguments: Vec<OsString> = vec![
386            option("--config", configuration.as_os_str()),
387            OsString::from("--stdin"),
388            option("--stdin-filename", name.as_os_str()),
389            OsString::from("--fix"),
390        ];
391        let waiting_for: String = format!("monochromatic-lint to check {}", name.display());
392        let finished: FinishedChild = match run_bounded_child_noticing(
393            &ChildRequest {
394                executable: program.as_path(),
395                arguments: arguments.as_slice(),
396                directory: request.top_level,
397                environment: self.environment.as_slice(),
398                input: request.source,
399                scratch: scratch.as_path(),
400                limits: self.limits,
401                waiting_for: waiting_for.as_str(),
402                cadence: self.cadence,
403            },
404            &mut self.notices,
405        ) {
406            Ok(done) => done,
407            Err(failure) => return Err(child_failure_reason(&failure, program.as_path())),
408        };
409        return interpret_linter_run(request.source, request.options.rules.as_slice(), &finished);
410    }
411}
412
413/// Lookup,
414///  configuration,
415///  argument and failure controls stay out of the release executable.
416#[cfg(test)]
417#[path = "markdown_linter_tests.rs"]
418mod tests;