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