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}