Skip to main content

git_policy_cli/
markdown_linter_output.rs

1//! What:
2//!  Read what one in-process `monochromatic-lint` fix run produced:
3//!  the fixed source and the findings it could not fix,
4//!  or the reason the run cannot be trusted.
5//! Why:
6//!  The linter's own contract (`package/linter/monochromatic-lint/src/run_embedded.rs`) is the executable's
7//!  `--stdin --fix` run with its one-rule configuration: status 0 with the fixed source and warning findings,
8//!  because the policy's configuration gives the rule severity `warn`;
9//!  1 only for error findings or a warning limit,
10//!  neither of which that configuration can produce;
11//!  and 2 for a run that could not finish.
12//!  Anything else,
13//!  and any finding that is not a warning of a selected rule with a position,
14//!  means the run cannot be trusted,
15//!  so the policy fails instead of passing an unchecked file.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! // const run = interpretEmbeddedRun({ input, rules, outcome }); // throws string
20//! ```
21
22/// Import the selectable rules and their configuration spelling.
23use super::config_schema::{MarkdownRule, markdown_rule_name};
24/// Import the linter's in-process result types and finding model.
25use monochromatic_lint::{
26    diagnostic::{Diagnostic, Severity},
27    run_embedded::{EmbeddedFailure, EmbeddedRun},
28};
29
30/// The longest explanation quoted from the linter's own words,
31///  in characters.
32pub const EXPLANATION_LIMIT: usize = 400;
33
34/// The most bytes of fixed source the policy accepts from one run:
35///  the bound the former child process had on its standard output,
36///  which allows a large file whose every link grows into an object URL.
37pub const MAX_FIXED_BYTES: usize = 64 << 20;
38
39/// The most bytes of findings the policy accepts from one run:
40///  the bound the former child process had on its standard error.
41///  A finding counts its message, code and file name.
42pub const MAX_FINDINGS_BYTES: usize = 4 << 20;
43
44/// The status the linter reports for a run that could not finish.
45pub const INCOMPLETE_STATUS: u8 = 2;
46
47/// What:
48///  One finding the linter reported and could not fix.
49///  `u64` holds the one-based
50///       line and column the linter gives.
51/// Why:
52///   The policy reports it with the incumbent's words,
53///  naming the rule as configured.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type RemainingFinding = { rule: MarkdownRule; line: number; column: number; message: string };
58/// ```
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RemainingFinding {
61    /// The rule that reported it.
62    pub rule: MarkdownRule,
63    /// One-based line of the finding's first label.
64    pub line: u64,
65    /// One-based column of that label,
66    ///  in the linter's units.
67    pub column: u64,
68    /// The linter's explanation.
69    pub message: String,
70}
71
72/// What:
73///  A run the policy can use:
74///  the source after every fix,
75///  and what is left.
76/// Why:
77///   The fixed bytes equal the input exactly when nothing changed.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// type LintRun = { fixed: Uint8Array; remaining: RemainingFinding[] };
82/// ```
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct LintRun {
85    /// The fixed source,
86    ///  byte for byte as the linter produced it.
87    pub fixed: Vec<u8>,
88    /// Findings the fixes did not remove,
89    ///  in the linter's order.
90    pub remaining: Vec<RemainingFinding>,
91}
92
93/// What:
94///  The selected rule a finding code names.
95///  `&[MarkdownRule]` borrows the rules the
96///       configuration selected.
97/// Why:
98///   The linter prefixes every Markdown rule with `markdown/`;
99///  a code of a rule the
100///       one-rule configuration did not select means the linter ran something else.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// const selectedRule = (code, rules) => rules.find(rule => code === `markdown/${ruleName(rule)}`);
105/// ```
106fn selected_rule(code: &str, rules: &[MarkdownRule]) -> Option<MarkdownRule> {
107    for rule in rules {
108        if code.strip_prefix("markdown/") == Some(markdown_rule_name(*rule)) {
109            return Some(*rule);
110        }
111    }
112    return None;
113}
114
115/// What:
116///  A one-based line or column as the whole number the policy reports.
117/// Why:
118///  A count too large for the type is not a position, so the run is refused.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function position(name: string, value: number): number; // throws string
123/// ```
124fn position(name: &str, value: usize) -> Result<u64, String> {
125    match u64::try_from(value) {
126        Ok(count) => return Ok(count),
127        Err(_) => return Err(format!("its {name} is not a position")),
128    }
129}
130
131/// What:
132///  Read one finding of a successful run.
133/// Why:
134///   Every finding must be a warning of a selected rule with a position;
135///  anything else is a contract the policy cannot rely on.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// function remainingFinding(finding: Diagnostic, rules: MarkdownRule[]): RemainingFinding; // throws string
140/// ```
141fn remaining_finding(finding: &Diagnostic, rules: &[MarkdownRule]) -> Result<RemainingFinding, String> {
142    let Some(rule) = selected_rule(finding.code.as_str(), rules) else {
143        return Err(format!("its code {:?} is not a selected rule", finding.code));
144    };
145    if finding.severity != Severity::Warn {
146        return Err(String::from("its severity is not the configured \"warn\""));
147    }
148    let Some(label) = finding.labels.first() else {
149        return Err(String::from("it has no label with a position"));
150    };
151    return Ok(RemainingFinding {
152        rule,
153        line: position("line", label.span.line)?,
154        column: position("column", label.span.column)?,
155        message: finding.message.clone(),
156    });
157}
158
159/// What:
160///  The linter's own explanation of a failed run,
161///  cut at `EXPLANATION_LIMIT` characters.
162/// Why:
163///  The person needs the linter's reason;
164///  the file name is left out,
165///  so the explanation does not repeat the candidate's pathname the event already names.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// function explanation(text: string): string;
170/// ```
171pub fn explanation(text: &str) -> String {
172    if text.chars().count() <= EXPLANATION_LIMIT {
173        return String::from(text);
174    }
175    let kept: String = text.chars().take(EXPLANATION_LIMIT).collect();
176    return format!("{kept}...");
177}
178
179/// The running size of the findings after adding `finding`.
180fn add_finding_bytes(total: usize, finding: &Diagnostic) -> usize {
181    return total
182        .saturating_add(finding.message.len())
183        .saturating_add(finding.code.len())
184        .saturating_add(finding.filename.len());
185}
186
187/// What:
188///  Interpret one finished in-process run over `input`.
189///  `Result<LintRun, String>` is the
190///       usable run,
191///  or the reason it cannot be used,
192///  for the person who ran the command.
193/// Why:
194///   See the module comment.
195///  A successful run must produce text,
196///  and must produce some for a non-empty input:
197///  the linter refuses to empty a file,
198///  so an empty fixed source would mean it never read the input,
199///  and using it would empty the file.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// function interpretEmbeddedRun({ input, rules, outcome }): LintRun; // throws string
204/// ```
205pub fn interpret_embedded_run(
206    input: &[u8],
207    rules: &[MarkdownRule],
208    outcome: Result<EmbeddedRun, EmbeddedFailure>,
209) -> Result<LintRun, String> {
210    let run: EmbeddedRun = match outcome {
211        Ok(usable) => usable,
212        Err(failure) if failure.exit_status == INCOMPLETE_STATUS => {
213            return Err(format!(
214                "monochromatic-lint could not finish (exit status {INCOMPLETE_STATUS}): {}",
215                explanation(failure.explanation.as_str())
216            ));
217        }
218        Err(failure) => {
219            return Err(format!(
220                "monochromatic-lint exited with status {}, which its one-rule configuration never produces: {}",
221                failure.exit_status,
222                explanation(failure.explanation.as_str())
223            ));
224        }
225    };
226    if run.fixed.len() > MAX_FIXED_BYTES {
227        return Err(format!(
228            "monochromatic-lint produced a fixed source of {} bytes, over the {MAX_FIXED_BYTES} bytes cli-git accepts",
229            run.fixed.len()
230        ));
231    }
232    let findings_bytes: usize = run.findings.iter().fold(0, add_finding_bytes);
233    if findings_bytes > MAX_FINDINGS_BYTES {
234        return Err(format!(
235            "monochromatic-lint produced findings of {findings_bytes} bytes, over the {MAX_FINDINGS_BYTES} bytes cli-git accepts"
236        ));
237    }
238    if run.fixed.is_empty() && !input.is_empty() {
239        return Err(String::from(
240            "monochromatic-lint produced an empty fixed source for a file that is not empty",
241        ));
242    }
243    let mut remaining: Vec<RemainingFinding> = Vec::new();
244    for (index, finding) in run.findings.iter().enumerate() {
245        match remaining_finding(finding, rules) {
246            Ok(found) => remaining.push(found),
247            Err(reason) => {
248                return Err(format!(
249                    "monochromatic-lint returned finding {} that cli-git cannot use: {reason}",
250                    index + 1
251                ));
252            }
253        }
254    }
255    return Ok(LintRun {
256        fixed: run.fixed.into_bytes(),
257        remaining,
258    });
259}
260
261/// Contract and explanation controls stay out of the release executable.
262#[cfg(test)]
263#[path = "markdown_linter_output_tests.rs"]
264mod tests;