git_policy_cli/policy_markdown.rs
1//! What:
2//! The optional `markdown/autofix` policy:
3//! every Markdown candidate the lifecycle
4//! offers is passed through `monochromatic-lint --fix` with the policy's rules;
5//! a
6//! changed source is reported,
7//! and corrected on `git cli-git fix`,
8//! and each finding
9//! the fixes leave is reported as it is.
10//! Why:
11//! The installed wrapper's `markdownLintPolicy` (`package/git-policy/markdown-lint/src`)
12//! decides the behaviour:
13//! deleted files,
14//! symbolic links and submodules are skipped,
15//! and so are excluded paths before any linter starts and contents that are not UTF-8;
16//! candidates are linted one at a time,
17//! in candidate order;
18//! the codes and words of
19//! both findings are kept.
20//! One thing differs by design:
21//! the extension test is the
22//! linter's own (`.md` and `.mdx`,
23//! exact case),
24//! so no file is sent that the linter
25//! refuses.
26//! The autofix finding keeps the installed wrapper's words,
27//! which name its
28//! former linter,
29//! so both wrappers report the same file the same way while both are
30//! in use.
31//!
32//! In TS you'd write (pseudocode):
33//! ```ts
34//! // const findings = await rewriteCandidates({ rules, exclude, repositoryRoot, canApplyPatches, candidates });
35//! ```
36
37/// Import the candidate's mode,
38/// which decides whether it is a file of text.
39use super::candidate_object::CandidateMode;
40/// Import the candidate's change kinds.
41use super::candidate_record::CandidateChange;
42/// Import the candidate and version types.
43use super::candidate_version::{Candidate, CandidateVersion};
44/// Import the policy's validated options and the configuration spelling of a rule.
45use super::config_schema::{MarkdownAutofixOptions, PolicyConfig, markdown_rule_name};
46/// Import the engine-failure codes.
47use super::diagnostics::EngineFailureCode;
48/// Import the event form of a pathname.
49use super::event_path::EventPath;
50/// Import the conversion of Git's pathname bytes to a path.
51use super::git_metadata::path_from_git_bytes;
52/// Import the compiled `exclude` patterns.
53use super::markdown_exclude::{ExcludeMatcher, compile_exclude};
54/// Import what one usable linter run returns.
55use super::markdown_linter_output::{LintRun, RemainingFinding};
56/// Import the lifecycle's candidates and the correction a fix applies.
57use super::policy_content::{ContentState, Correction, LifecycleContent};
58/// Import the finding and outcome types of a check.
59use super::policy_engine::{PolicyFinding, PolicyOutcome};
60/// Import the lifecycle points;
61/// only a direct fix corrects.
62use super::policy_trigger::Trigger;
63/// Import the facts interface for the repository's top level.
64use super::repository_facts::RepositoryFacts;
65/// Import the repository location answer.
66use super::repository_location::RepositoryLocation;
67/// Import the worktree's top level from Git's answer.
68use super::worktree_identity::worktree_root;
69/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
70use std::path::{Path, PathBuf};
71/// `Rc<T>` is a shared,
72/// read-only handle.
73use std::rc::Rc;
74
75/// The policy-local code of a candidate the fixes rewrite.
76pub const MARKDOWN_AUTOFIX_CODE: &str = "markdown-autofix";
77
78/// The policy-local code of a finding the fixes left.
79pub const MARKDOWN_VIOLATION_CODE: &str = "markdown-violation";
80
81/// What the person can do about a candidate the linter could not check.
82pub const UNCHECKED_REMEDY: &str = "The Markdown candidate was not checked and nothing was changed; fix the cause, or pass --no-enforce-markdown/autofix to skip this policy for one command.";
83
84/// What:
85/// One candidate for the linter.
86/// `'a` names how long the borrowed parts last.
87/// Why:
88/// The linter runs from the top level,
89/// so `.lfsconfig`,
90/// `.gitattributes` and linked
91/// images resolve as they do for the person,
92/// and lints `source` as the file at `path`.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// type LintRequest = { topLevel: string; options: MarkdownAutofixOptions; path: Uint8Array; source: Uint8Array };
97/// ```
98#[derive(Clone, Copy, Debug)]
99pub struct LintRequest<'a> {
100 /// The repository's top level,
101 /// the linter's working directory.
102 pub top_level: &'a Path,
103 /// The policy's rules and exclude patterns.
104 pub options: &'a MarkdownAutofixOptions,
105 /// The candidate's pathname,
106 /// relative to the top level,
107 /// as Git's raw bytes.
108 pub path: &'a [u8],
109 /// The candidate's bytes,
110 /// already known to be UTF-8.
111 pub source: &'a [u8],
112}
113
114/// What:
115/// Whatever lints one candidate.
116/// A `trait` is a named set of methods,
117/// like a TS
118/// `interface`;
119/// `&mut self` lends it for writing,
120/// because it may remember the program
121/// and its configuration across candidates.
122/// Why:
123/// The executable starts the real linter;
124/// tests answer from a script,
125/// so every branch
126/// of the policy is reached without a child process.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// interface MarkdownLinter { lint(request: LintRequest): LintRun } // throws string
131/// ```
132pub trait MarkdownLinter {
133 /// Lint one candidate:
134 /// the usable run,
135 /// or why there is none,
136 /// for the person.
137 fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String>;
138}
139
140/// What:
141/// The policy's state for one invocation:
142/// its options,
143/// its linter and the compiled
144/// exclude patterns.
145/// `Box<dyn MarkdownLinter>` owns "any linter",
146/// chosen at run time.
147/// Why:
148/// The lifecycle sets the options once;
149/// the patterns compile on first use,
150/// and a
151/// failure to compile is remembered,
152/// like the scanner's rules.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// type MarkdownState = { options: MarkdownAutofixOptions; linter: MarkdownLinter; exclude?: ExcludeMatcher | string };
157/// ```
158pub struct MarkdownState {
159 /// The policy's validated options;
160 /// defaults until the configuration is loaded.
161 pub options: MarkdownAutofixOptions,
162 /// What lints a candidate.
163 pub linter: Box<dyn MarkdownLinter>,
164 /// The compiled exclude patterns,
165 /// or the reason they did not compile.
166 exclude: Option<Result<ExcludeMatcher, String>>,
167}
168
169/// What:
170/// A state over `linter` with the default options and nothing compiled.
171/// Why:
172/// Every lifecycle builds its checks the same way and then sets the options.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// const markdownState = (linter: MarkdownLinter): MarkdownState => ({ options: defaults.markdownAutofix, linter });
177/// ```
178pub fn markdown_state(linter: Box<dyn MarkdownLinter>) -> MarkdownState {
179 return MarkdownState {
180 options: PolicyConfig::defaults().markdown_autofix,
181 linter,
182 exclude: None,
183 };
184}
185
186/// What:
187/// `impl MarkdownState { ... }` attaches the one question about exclusion.
188/// Why:
189/// The patterns compile at most once per invocation.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// class MarkdownState { excludes(path: Uint8Array): boolean }
194/// ```
195impl MarkdownState {
196 /// What:
197 /// Whether the exclude patterns name `path`;
198 /// `Err` is the compile failure.
199 /// Why:
200 /// An excluded candidate is left alone before its bytes are read.
201 ///
202 /// In TS you'd write (pseudocode):
203 /// ```ts
204 /// excludes(path: Uint8Array): boolean; // throws string
205 /// ```
206 fn excludes(&mut self, path: &[u8]) -> Result<bool, String> {
207 if self.exclude.is_none() {
208 self.exclude = Some(compile_exclude(self.options.exclude.as_slice()));
209 }
210 match &self.exclude {
211 Some(Ok(matcher)) => return Ok(matcher.excludes(path)),
212 Some(Err(reason)) => return Err(reason.clone()),
213 None => {
214 return Err(String::from(
215 "cli-git did not compile the markdown/autofix exclude patterns; this is a defect in cli-git.",
216 ));
217 }
218 }
219 }
220}
221
222/// What:
223/// Whether a pathname ends in `.md` or `.mdx`,
224/// with exactly that case,
225/// by the
226/// platform's rule for a file name's extension.
227/// Why:
228/// The linter decides the language with the same rule (`run_paths.rs`,
229/// `language_of`) and refuses any other name read from standard input;
230/// a name such
231/// as `.md`,
232/// with no extension under that rule,
233/// is therefore not Markdown here.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// const isMarkdownPath = (path: string) => ['md', 'mdx'].includes(extname(path).slice(1));
238/// ```
239pub fn is_markdown_path(path: &[u8]) -> bool {
240 let Some(spelled) = path_from_git_bytes(path) else {
241 return false;
242 };
243 // `.and_then(OsStr::to_str)` keeps only an extension that is text.
244 match spelled.extension().and_then(std::ffi::OsStr::to_str) {
245 Some("md") | Some("mdx") => return true,
246 _ => return false,
247 }
248}
249
250/// What:
251/// Whether the policy inspects a candidate at all.
252/// Why:
253/// Deletions have no content;
254/// symbolic links and submodules are not files of text.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// const isEligible = (c) => c.change !== 'deleted' && (c.mode === 'regular' || c.mode === 'executable') && isMarkdownPath(c.path);
259/// ```
260fn is_eligible(candidate: &Candidate) -> bool {
261 if candidate.change == CandidateChange::Deleted {
262 return false;
263 }
264 if candidate.mode != CandidateMode::Regular && candidate.mode != CandidateMode::Executable {
265 return false;
266 }
267 return is_markdown_path(candidate.path.as_slice());
268}
269
270/// What:
271/// The outcome of a candidate the policy could not check,
272/// naming that candidate.
273/// Why:
274/// The policy's own machinery failed,
275/// which is `policy-incomplete`;
276/// the event names
277/// the file in its own field and the message says what to do.
278///
279/// In TS you'd write (pseudocode):
280/// ```ts
281/// const unchecked = (path, reason): PolicyOutcome => ({ kind: 'failed', code: 'policy-incomplete', message: `${reason}. ${REMEDY}`, path });
282/// ```
283fn unchecked(path: Option<&[u8]>, reason: &str) -> PolicyOutcome {
284 return PolicyOutcome::Failed {
285 code: EngineFailureCode::PolicyIncomplete,
286 message: format!("{reason}. {UNCHECKED_REMEDY}"),
287 // `.map(EventPath::from_git_bytes)` renders the pathname when there is one.
288 path: path.map(EventPath::from_git_bytes),
289 };
290}
291
292/// What:
293/// The repository's top level,
294/// from Git's answer about where the command runs.
295/// Why:
296/// The linter runs there;
297/// a missing answer is a repository fact that could not be read.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// async function topLevel(facts): Promise<string>;
302/// ```
303fn top_level<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
304 let location: RepositoryLocation = match facts.location() {
305 Ok(found) => found,
306 Err(message) => {
307 return Err(PolicyOutcome::Failed {
308 code: EngineFailureCode::ContentUnavailable,
309 message,
310 path: None,
311 });
312 }
313 };
314 match worktree_root(&location.identity) {
315 Some(root) => return Ok(root.to_path_buf()),
316 None => {
317 return Err(PolicyOutcome::Failed {
318 code: EngineFailureCode::ContentUnavailable,
319 message: String::from(
320 "cli-git could not find the repository's top level, where monochromatic-lint must run.",
321 ),
322 path: None,
323 });
324 }
325 }
326}
327
328/// What:
329/// The rules of the options as configured,
330/// joined by `, `.
331/// Why:
332/// The autofix finding lists them,
333/// as the installed wrapper's did.
334///
335/// In TS you'd write (pseudocode):
336/// ```ts
337/// const ruleList = (options) => options.rules.join(', ');
338/// ```
339fn rule_list(options: &MarkdownAutofixOptions) -> String {
340 let mut names: Vec<String> = Vec::new();
341 for rule in &options.rules {
342 names.push(format!("markdown/{}", markdown_rule_name(*rule)));
343 }
344 return names.join(", ");
345}
346
347/// What:
348/// The finding for one remaining linter finding,
349/// in the installed wrapper's words:
350/// `<rule> at <path>:<line>:<column>: <message>`.
351/// Why:
352/// The person sees which rule,
353/// where,
354/// and why,
355/// without the linter's own output.
356///
357/// In TS you'd write (pseudocode):
358/// ```ts
359/// const violation = (path, d) => ({ code: 'markdown-violation', message: `${d.ruleId} at ${path}:${d.line}:${d.column}: ${d.message}`, path });
360/// ```
361fn violation(path: &EventPath, remaining: &RemainingFinding) -> PolicyFinding {
362 return PolicyFinding {
363 code: MARKDOWN_VIOLATION_CODE,
364 message: format!(
365 "{} at {}:{}:{}: {}",
366 markdown_rule_name(remaining.rule),
367 path.text(),
368 remaining.line,
369 remaining.column,
370 remaining.message
371 ),
372 path: Some(path.clone()),
373 location: None,
374 fix_available: false,
375 };
376}
377
378/// What:
379/// The findings of one candidate the linter ran on,
380/// and the correction a fix applies.
381/// Why:
382/// An unchanged source reports only what is left;
383/// a changed one reports the
384/// rewrite first,
385/// with a correction on a direct fix only (`canApplyPatches`).
386///
387/// In TS you'd write (pseudocode):
388/// ```ts
389/// function candidateFindings(content, candidate, bytes, run, options, trigger): PolicyFinding[];
390/// ```
391fn candidate_findings(
392 content: &mut ContentState,
393 candidate: &Candidate,
394 bytes: Rc<[u8]>,
395 run: LintRun,
396 options: &MarkdownAutofixOptions,
397 trigger: Trigger,
398) -> Vec<PolicyFinding> {
399 let path: EventPath = EventPath::from_git_bytes(candidate.path.as_slice());
400 let mut findings: Vec<PolicyFinding> = Vec::new();
401 if run.fixed.as_slice() != &*bytes {
402 let corrects: bool = trigger == Trigger::DirectFix;
403 findings.push(PolicyFinding {
404 code: MARKDOWN_AUTOFIX_CODE,
405 message: format!(
406 "monochromatic-lint --fix ({}) rewrites {}.",
407 rule_list(options),
408 path.text()
409 ),
410 path: Some(path.clone()),
411 location: None,
412 fix_available: corrects,
413 });
414 if corrects {
415 content.propose(Correction {
416 path: candidate.path.clone(),
417 mode: candidate.mode,
418 before: bytes,
419 after: Rc::from(run.fixed),
420 });
421 }
422 }
423 for remaining in &run.remaining {
424 findings.push(violation(&path, remaining));
425 }
426 return findings;
427}
428
429/// What:
430/// Check every candidate of the lifecycle.
431/// `<F: RepositoryFacts>` accepts any facts
432/// provider;
433/// `&mut MarkdownState` lends the policy's linter and patterns.
434/// Why:
435/// See the module comment.
436/// A policy with no rule selected has nothing to run;
437/// the
438/// first candidate the linter cannot check ends the policy with that candidate named.
439///
440/// In TS you'd write (pseudocode):
441/// ```ts
442/// async function checkMarkdown(content, lifecycle, facts, state, trigger): Promise<PolicyOutcome>;
443/// ```
444pub fn check_markdown<F: RepositoryFacts>(
445 content: &mut ContentState,
446 lifecycle: &LifecycleContent,
447 facts: &mut F,
448 state: &mut MarkdownState,
449 trigger: Trigger,
450) -> PolicyOutcome {
451 let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
452 Ok(Some(found)) => found,
453 Ok(None) => return PolicyOutcome::Findings(Vec::new()),
454 Err(outcome) => return outcome,
455 };
456 let mut findings: Vec<PolicyFinding> = Vec::new();
457 if state.options.rules.is_empty() {
458 return PolicyOutcome::Findings(findings);
459 }
460 for candidate in version.candidates() {
461 if !is_eligible(candidate) {
462 continue;
463 }
464 match state.excludes(candidate.path.as_slice()) {
465 Ok(true) => continue,
466 Ok(false) => {}
467 Err(reason) => return unchecked(None, reason.as_str()),
468 }
469 let bytes: Rc<[u8]> = match content.bytes(candidate) {
470 Ok(read) => read,
471 Err(outcome) => return outcome,
472 };
473 // A candidate that is not UTF-8 is not text the linter can read; it is skipped.
474 if std::str::from_utf8(&bytes).is_err() {
475 continue;
476 }
477 // The facts provider remembers the location, so asking again starts no process.
478 let top: PathBuf = match top_level(facts) {
479 Ok(found) => found,
480 Err(outcome) => return outcome,
481 };
482 let request: LintRequest<'_> = LintRequest {
483 top_level: top.as_path(),
484 options: &state.options,
485 path: candidate.path.as_slice(),
486 source: &bytes,
487 };
488 let run: LintRun = match state.linter.lint(&request) {
489 Ok(usable) => usable,
490 Err(reason) => return unchecked(Some(candidate.path.as_slice()), reason.as_str()),
491 };
492 findings.extend(candidate_findings(
493 content,
494 candidate,
495 bytes,
496 run,
497 &state.options,
498 trigger,
499 ));
500 }
501 return PolicyOutcome::Findings(findings);
502}
503
504/// Eligibility,
505/// exclusion,
506/// finding and correction controls stay out of the release executable.
507#[cfg(test)]
508#[path = "policy_markdown_tests.rs"]
509mod tests;