Skip to main content

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;