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;