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;