Skip to main content

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;