git_policy_cli/markdown_linter_output.rs
1//! What:
2//! Read what one `monochromatic-lint --stdin --fix` run produced:
3//! the fixed source on
4//! standard output,
5//! findings it could not fix as JSON Lines on standard error,
6//! and its
7//! exit status.
8//! The result is the fixed bytes and the remaining findings,
9//! or the reason
10//! the run cannot be trusted.
11//! Why:
12//! The linter is a child process whose output is untrusted text.
13//! Its contract
14//! (`package/linter/monochromatic-lint/README.md`,
15//! `src/run_stdin.rs`,
16//! `src/run_output.rs`)
17//! is exit status 0 with the fixed source and warning records,
18//! because the policy's
19//! one-rule configuration gives the rule severity `warn`;
20//! 1 only for error findings or a
21//! warning limit,
22//! neither of which that configuration can produce;
23//! and 2 for a run that
24//! could not finish.
25//! Anything else,
26//! and any record that is not a finding of a selected
27//! rule,
28//! means the run cannot be trusted,
29//! so the policy fails instead of passing an
30//! unchecked file.
31//!
32//! In TS you'd write (pseudocode):
33//! ```ts
34//! // const run = interpretLinterRun({ input, rules, finished }); // throws LinterOutputError
35//! ```
36
37/// Import how a bounded child ended and what it wrote.
38use super::bounded_child::{ChildExit, FinishedChild};
39/// Import the selectable rules and their configuration spelling.
40use super::config_schema::{MarkdownRule, markdown_rule_name};
41/// What:
42/// Import the JSONC parser,
43/// its value model and the decoder of its text units.
44/// Why:
45/// Each JSON Lines record is one JSON value;
46/// JSON is a subset of what it reads.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// import { parseJsonc } from '@monochromatic-dev/jsonc-edit';
51/// ```
52use monochromatic_jsonc_edit::{JsoncEntry, JsoncValue, parse_jsonc, units_to_string};
53
54/// The prefix of every line the linter writes to standard error that is not a record.
55pub const LINTER_MESSAGE_PREFIX: &str = "monochromatic-lint: ";
56
57/// The longest explanation quoted from the linter's standard error,
58/// in characters.
59pub const EXPLANATION_LIMIT: usize = 400;
60
61/// What:
62/// One finding the linter reported and could not fix.
63/// `u64` holds the one-based
64/// line and column the linter gives.
65/// Why:
66/// The policy reports it with the incumbent's words,
67/// naming the rule as configured.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// type RemainingFinding = { rule: MarkdownRule; line: number; column: number; message: string };
72/// ```
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct RemainingFinding {
75 /// The rule that reported it.
76 pub rule: MarkdownRule,
77 /// One-based line of the finding's first label.
78 pub line: u64,
79 /// One-based column of that label,
80 /// in the linter's units.
81 pub column: u64,
82 /// The linter's explanation.
83 pub message: String,
84}
85
86/// What:
87/// A run the policy can use:
88/// the source after every fix,
89/// and what is left.
90/// Why:
91/// The fixed bytes equal the input exactly when nothing changed.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// type LintRun = { fixed: Uint8Array; remaining: RemainingFinding[] };
96/// ```
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct LintRun {
99 /// The fixed source,
100 /// byte for byte as the linter printed it.
101 pub fixed: Vec<u8>,
102 /// Findings the fixes did not remove,
103 /// in the linter's order.
104 pub remaining: Vec<RemainingFinding>,
105}
106
107/// What:
108/// The one member named `key` of a record,
109/// or nothing.
110/// `&'a [JsoncEntry]` borrows
111/// the members;
112/// the result borrows the value for as long.
113/// Why:
114/// A record that names a field twice is ambiguous and is refused.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// function member(entries: JsoncEntry[], key: string): JsoncValue | undefined; // throws on a repeat
119/// ```
120fn member<'a>(entries: &'a [JsoncEntry], key: &str) -> Result<Option<&'a JsoncValue>, String> {
121 let mut found: Option<&'a JsoncValue> = None;
122 for entry in entries {
123 // A key whose units are not valid text cannot be the key sought.
124 let matches: bool = match units_to_string(&entry.key.units) {
125 Ok(text) => text == key,
126 Err(_) => false,
127 };
128 if !matches {
129 continue;
130 }
131 if found.is_some() {
132 return Err(format!("it names \"{key}\" twice"));
133 }
134 found = Some(&entry.value);
135 }
136 return Ok(found);
137}
138
139/// What:
140/// The text of the required member `key`.
141/// Why:
142/// `code`,
143/// `message` and `severity` must be strings.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// function requiredText(entries: JsoncEntry[], key: string): string;
148/// ```
149fn required_text(entries: &[JsoncEntry], key: &str) -> Result<String, String> {
150 let Some(value) = member(entries, key)? else {
151 return Err(format!("it has no \"{key}\""));
152 };
153 let Some(units) = value.text_units() else {
154 return Err(format!("its \"{key}\" is not a string"));
155 };
156 match units_to_string(units) {
157 Ok(text) => return Ok(text),
158 Err(_) => return Err(format!("its \"{key}\" is not valid text")),
159 }
160}
161
162/// What:
163/// The whole number of the required member `key`.
164/// `str::parse::<u64>` accepts only
165/// plain decimal digits.
166/// Why:
167/// A line or column that is negative,
168/// fractional or exponential is not a position.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function requiredCount(entries: JsoncEntry[], key: string): number;
173/// ```
174fn required_count(entries: &[JsoncEntry], key: &str) -> Result<u64, String> {
175 let Some(value) = member(entries, key)? else {
176 return Err(format!("its span has no \"{key}\""));
177 };
178 let Some(token) = value.number_token() else {
179 return Err(format!("its span's \"{key}\" is not a number"));
180 };
181 match token.parse::<u64>() {
182 Ok(count) => return Ok(count),
183 Err(_) => return Err(format!("its span's \"{key}\" is not a whole number")),
184 }
185}
186
187/// What:
188/// The members of the span of a record's first label.
189/// Why:
190/// The line and column of a finding come from there,
191/// as the incumbent took them.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// function firstSpan(entries: JsoncEntry[]): JsoncEntry[];
196/// ```
197fn first_span(entries: &[JsoncEntry]) -> Result<&[JsoncEntry], String> {
198 let Some(labels) = member(entries, "labels")? else {
199 return Err(String::from("it has no \"labels\""));
200 };
201 let Some(first) = labels.elements().and_then(<[JsoncValue]>::first) else {
202 return Err(String::from("its \"labels\" is not a list with a label"));
203 };
204 let Some(label) = first.entries() else {
205 return Err(String::from("its first label is not an object"));
206 };
207 let Some(span) = member(label, "span")?.and_then(JsoncValue::entries) else {
208 return Err(String::from("its first label has no \"span\" object"));
209 };
210 return Ok(span);
211}
212
213/// What:
214/// The selected rule a finding code names.
215/// `&[MarkdownRule]` borrows the rules the
216/// configuration selected.
217/// Why:
218/// The linter prefixes every Markdown rule with `markdown/`;
219/// a code of a rule the
220/// one-rule configuration did not select means the linter ran something else.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// const selectedRule = (code, rules) => rules.find(rule => code === `markdown/${ruleName(rule)}`);
225/// ```
226fn selected_rule(code: &str, rules: &[MarkdownRule]) -> Option<MarkdownRule> {
227 for rule in rules {
228 if code.strip_prefix("markdown/") == Some(markdown_rule_name(*rule)) {
229 return Some(*rule);
230 }
231 }
232 return None;
233}
234
235/// What:
236/// Read one JSON Lines record of a successful run as a finding.
237/// Why:
238/// Every record must be a warning of a selected rule with a position;
239/// anything else
240/// is a contract the policy cannot rely on.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// function remainingFinding(line: string, rules: MarkdownRule[]): RemainingFinding;
245/// ```
246fn remaining_finding(line: &str, rules: &[MarkdownRule]) -> Result<RemainingFinding, String> {
247 let record: JsoncValue = match parse_jsonc(line) {
248 Ok(parsed) => parsed,
249 Err(_) => return Err(String::from("it is not JSON")),
250 };
251 let Some(entries) = record.entries() else {
252 return Err(String::from("it is not a JSON object"));
253 };
254 let code: String = required_text(entries, "code")?;
255 let Some(rule) = selected_rule(code.as_str(), rules) else {
256 return Err(format!("its code {code:?} is not a selected rule"));
257 };
258 if required_text(entries, "severity")? != "warn" {
259 return Err(String::from("its severity is not the configured \"warn\""));
260 }
261 let message: String = required_text(entries, "message")?;
262 let span: &[JsoncEntry] = first_span(entries)?;
263 return Ok(RemainingFinding {
264 rule,
265 line: required_count(span, "line")?,
266 column: required_count(span, "column")?,
267 message,
268 });
269}
270
271/// What:
272/// Read the standard error of a successful run as findings.
273/// `&[u8]` borrows the bytes.
274/// Why:
275/// JSON Lines:
276/// one record per LF-terminated line;
277/// an empty stream is no findings.
278///
279/// In TS you'd write (pseudocode):
280/// ```ts
281/// function remainingFindings(stderr: Uint8Array, rules: MarkdownRule[]): RemainingFinding[];
282/// ```
283fn remaining_findings(
284 stderr: &[u8],
285 rules: &[MarkdownRule],
286) -> Result<Vec<RemainingFinding>, String> {
287 let Ok(text) = std::str::from_utf8(stderr) else {
288 return Err(String::from(
289 "monochromatic-lint wrote standard error that is not UTF-8 text",
290 ));
291 };
292 let mut findings: Vec<RemainingFinding> = Vec::new();
293 if text.is_empty() {
294 return Ok(findings);
295 }
296 // Every record ends with LF; a lone LF is an empty line, which is not a record.
297 let Some(body) = text.strip_suffix('\n') else {
298 return Err(String::from(
299 "monochromatic-lint ended its standard error without a line feed after the last record",
300 ));
301 };
302 for (index, line) in body.split('\n').enumerate() {
303 match remaining_finding(line, rules) {
304 Ok(finding) => findings.push(finding),
305 Err(reason) => {
306 return Err(format!(
307 "monochromatic-lint wrote a record cli-git cannot use on line {} of its standard error: {reason}",
308 index + 1
309 ));
310 }
311 }
312 }
313 return Ok(findings);
314}
315
316/// What:
317/// The linter's own explanation of a failed run:
318/// its prefixed message lines and the
319/// messages of its records,
320/// joined,
321/// at most `EXPLANATION_LIMIT` characters.
322/// Why:
323/// The person needs the linter's reason;
324/// the records' file names are left out,
325/// so the
326/// explanation does not repeat the candidate's pathname the event already names.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// function explanation(stderr: Uint8Array): string;
331/// ```
332pub fn explanation(stderr: &[u8]) -> String {
333 let text: String = String::from_utf8_lossy(stderr).into_owned();
334 let mut parts: Vec<String> = Vec::new();
335 for line in text.lines() {
336 if let Some(message) = line.strip_prefix(LINTER_MESSAGE_PREFIX) {
337 parts.push(String::from(message));
338 } else if let Ok(record) = parse_jsonc(line)
339 && let Some(entries) = record.entries()
340 && let Ok(message) = required_text(entries, "message")
341 {
342 parts.push(message);
343 }
344 }
345 if parts.is_empty() {
346 return String::from("it printed no explanation");
347 }
348 let joined: String = parts.join("; ");
349 if joined.chars().count() <= EXPLANATION_LIMIT {
350 return joined;
351 }
352 let kept: String = joined.chars().take(EXPLANATION_LIMIT).collect();
353 return format!("{kept}...");
354}
355
356/// What:
357/// Interpret one finished linter run over `input`.
358/// `Result<LintRun, String>` is the
359/// usable run,
360/// or the reason it cannot be used,
361/// for the person who ran the command.
362/// Why:
363/// See the module comment.
364/// A successful run must print text,
365/// and must print some for
366/// a non-empty input:
367/// the linter refuses to empty a file,
368/// so an empty fixed source
369/// would mean it never read the input,
370/// and using it would empty the file.
371///
372/// In TS you'd write (pseudocode):
373/// ```ts
374/// function interpretLinterRun({ input, rules, finished }): LintRun; // throws string
375/// ```
376pub fn interpret_linter_run(
377 input: &[u8],
378 rules: &[MarkdownRule],
379 finished: &FinishedChild,
380) -> Result<LintRun, String> {
381 match finished.exit {
382 ChildExit::Code(0) => {}
383 ChildExit::Code(2) => {
384 return Err(format!(
385 "monochromatic-lint could not finish (exit status 2): {}",
386 explanation(&finished.stderr)
387 ));
388 }
389 ChildExit::Code(code) => {
390 return Err(format!(
391 "monochromatic-lint exited with status {code}, which its one-rule configuration never produces: {}",
392 explanation(&finished.stderr)
393 ));
394 }
395 ChildExit::Signal(signal) => {
396 return Err(format!("monochromatic-lint was ended by signal {signal}"));
397 }
398 ChildExit::Unknown => {
399 return Err(String::from(
400 "monochromatic-lint ended without an exit status",
401 ));
402 }
403 }
404 if std::str::from_utf8(&finished.stdout).is_err() {
405 return Err(String::from(
406 "monochromatic-lint printed a fixed source that is not UTF-8",
407 ));
408 }
409 if finished.stdout.is_empty() && !input.is_empty() {
410 return Err(String::from(
411 "monochromatic-lint printed an empty fixed source for a file that is not empty",
412 ));
413 }
414 return Ok(LintRun {
415 fixed: finished.stdout.clone(),
416 remaining: remaining_findings(&finished.stderr, rules)?,
417 });
418}
419
420/// Contract,
421/// record and explanation controls stay out of the release executable.
422#[cfg(test)]
423#[path = "markdown_linter_output_tests.rs"]
424mod tests;