Skip to main content

mdwright_lint/
diagnostic.rs

1//! Diagnostics emitted by lint rules.
2
3use std::borrow::Cow;
4use std::ops::Range;
5
6use mdwright_document::Document;
7use mdwright_document::LineIndex;
8
9/// Published base URL of the mdwright documentation site.
10///
11/// Used by the JSON v2 emitter to fill the `rule.url` field and by
12/// `mdwright explain` to print a `See: <url>` cross-link. Override
13/// with the `MDWRIGHT_DOCS_URL` environment variable when previewing
14/// the site locally (e.g. `mdbook serve` on `http://localhost:3000`).
15pub const DOCS_URL_DEFAULT: &str = "https://jcreinhold.github.io/mdwright";
16
17/// Resolve the documentation site's base URL. Honours
18/// `MDWRIGHT_DOCS_URL` if set; otherwise returns [`DOCS_URL_DEFAULT`].
19/// The returned value has no trailing slash.
20#[must_use]
21pub fn docs_url() -> Cow<'static, str> {
22    match std::env::var("MDWRIGHT_DOCS_URL") {
23        Ok(s) => Cow::Owned(s.trim_end_matches('/').to_owned()),
24        Err(_) => Cow::Borrowed(DOCS_URL_DEFAULT),
25    }
26}
27
28/// Build the published URL of a rule's documentation page. Math rules
29/// preserve their `math/` prefix; the rendered file is `<name>.html`.
30#[must_use]
31pub fn rule_doc_url(rule_name: &str) -> String {
32    format!("{}/rules/{rule_name}.html", docs_url())
33}
34
35/// Diagnostic severity for serialised output.
36///
37/// `Error` is the default for non-advisory rules; `Advisory` reflects
38/// [`Diagnostic::advisory`]. `Warning` is reserved for future use
39/// (no current rule emits it) and is included so the JSON Lines
40/// schema can name all three levels up front.
41#[derive(Copy, Clone, Debug, PartialEq, Eq)]
42pub enum Severity {
43    Error,
44    Warning,
45    Advisory,
46}
47
48impl Severity {
49    /// Lowercase string form used by the v2 JSON Lines emitter and
50    /// the rustc-style pretty header.
51    #[must_use]
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Self::Error => "error",
55            Self::Warning => "warning",
56            Self::Advisory => "advisory",
57        }
58    }
59}
60
61/// Source-snippet view shared by the pretty and JSON renderers:
62/// the one line of source covering a diagnostic's span, plus the
63/// column range of the underlined region.
64///
65/// For multi-line spans the underlined region is clamped to the
66/// first line — both renderers point at the start of the offence.
67#[derive(Clone, Debug)]
68pub struct Snippet<'a> {
69    /// 1-indexed line number.
70    pub line_no: u32,
71    /// 1-indexed codepoint column of the span's first character.
72    pub col_start: u32,
73    /// 1-indexed codepoint column one past the span's last character
74    /// on this line (so `col_end - col_start` codepoints are
75    /// underlined). Always ≥ `col_start + 1` so the caret is visible.
76    pub col_end: u32,
77    /// The line text, without the trailing `\n`.
78    pub line_text: &'a str,
79}
80
81impl<'a> Snippet<'a> {
82    /// Build a snippet for `span` inside `source`. Returns `None` if
83    /// `span.start` lies outside `source` or off a UTF-8 boundary —
84    /// the same fallback as [`Diagnostic::at`].
85    #[must_use]
86    pub fn from_span(line_index: &LineIndex, source: &'a str, span: &Range<usize>) -> Option<Self> {
87        let (line_no_usize, col_start_usize) = line_index.locate(source, span.start).ok()?;
88        let line_no = u32::try_from(line_no_usize).ok()?;
89        let col_start = u32::try_from(col_start_usize).ok()?;
90        let bounds = line_index.line_bounds(source, span.start)?;
91        let line_text = source.get(bounds.clone())?;
92        let end_on_line = span.end.min(bounds.end);
93        let after = source.get(bounds.start..end_on_line)?;
94        let after_cols = u32::try_from(after.chars().count().saturating_add(1)).ok()?;
95        let col_end = after_cols.max(col_start.saturating_add(1));
96        Some(Self {
97            line_no,
98            col_start,
99            col_end,
100            line_text,
101        })
102    }
103}
104
105/// One issue at one source location, optionally with an automatic
106/// [`Fix`]. Spans are byte ranges into the original source string.
107#[derive(Clone, Debug)]
108pub struct Diagnostic {
109    /// Kebab-case identifier of the rule that produced this
110    /// diagnostic. `Cow` so stdlib rules can borrow `&'static str`
111    /// names while user rules with runtime-built names own the buffer.
112    /// The dispatcher stamps this field after each rule's `check`
113    /// returns, so rule implementations do not set it.
114    pub rule: Cow<'static, str>,
115    /// 1-indexed line number of the diagnostic's first byte.
116    pub line: usize,
117    /// 1-indexed codepoint column.
118    pub column: usize,
119    /// Byte span within the source. `source.get(span.clone())` is the
120    /// substring the diagnostic refers to.
121    pub span: Range<usize>,
122    /// One-line human-readable message.
123    pub message: String,
124    /// Optional replacement covering `span`.
125    pub fix: Option<Fix>,
126    /// Whether this diagnostic is advisory (informational; does not
127    /// fail `--check`). Set by the dispatcher from the rule's
128    /// `is_advisory()`.
129    pub advisory: bool,
130}
131
132#[derive(Clone, Debug)]
133pub struct Fix {
134    pub replacement: String,
135    /// Whether the fix can be applied without manual review. `false`
136    /// fixes are surfaced as suggestions only, never under `--fix`.
137    pub safe: bool,
138}
139
140impl Diagnostic {
141    /// Build a diagnostic at a position within a borrowed source
142    /// slice. `byte_offset` is the absolute offset of the slice's
143    /// first byte; `local` is the match range within that slice.
144    ///
145    /// Returns `None` if the line-index lookup fails — never observed
146    /// for offsets produced by pulldown-cmark, but the safe-fallback
147    /// behaviour is to drop the diagnostic rather than panic.
148    /// The dispatcher fills in `rule` and `advisory` after the
149    /// containing rule's `check` returns.
150    #[must_use]
151    pub fn at(
152        doc: &Document,
153        byte_offset: usize,
154        local: Range<usize>,
155        message: String,
156        fix: Option<Fix>,
157    ) -> Option<Self> {
158        let start = byte_offset.saturating_add(local.start);
159        let end = byte_offset.saturating_add(local.end);
160        let (line, column) = doc.line_index().locate(doc.source(), start).ok()?;
161        Some(Self {
162            rule: Cow::Borrowed(""),
163            line,
164            column,
165            span: start..end,
166            message,
167            fix,
168            advisory: false,
169        })
170    }
171
172    /// Suppression marker text. The Markdown comment for muting this
173    /// diagnostic on the next block is
174    /// `<!-- mdwright: allow rule-name -->`.
175    #[must_use]
176    pub fn suppress_via(&self) -> String {
177        format!("mdwright: allow {}", self.rule)
178    }
179
180    /// Diagnostic severity derived from [`Self::advisory`]. Used by
181    /// the v2 JSON Lines emitter and the rustc-style pretty header.
182    #[must_use]
183    pub fn severity(&self) -> Severity {
184        if self.advisory {
185            Severity::Advisory
186        } else {
187            Severity::Error
188        }
189    }
190}