Skip to main content

rumdl_lib/rules/
md091_no_markdown_in_html.rs

1//! Rule MD091: Markdown syntax inside an HTML block renders as literal text.
2//!
3//! CommonMark stops parsing markdown inside an HTML *block*, so a link written
4//! there is published as its own source. `<details><summary>[Docs](/docs)</summary>`
5//! puts the characters `[Docs](/docs)` on the page instead of a link, and nothing
6//! else reports it: the document lints clean, the HTML is valid, and the page
7//! renders without an error. The only symptom is markdown source visible to
8//! readers, which an author usually discovers long after publishing.
9//!
10//! The dividing line is the block, not the tag. Markdown is fully alive inside
11//! *inline* HTML, so `Intro <details><summary>[Docs](/docs)</summary>` (the tag
12//! preceded by text on the same line) parses the link normally and is not
13//! reported. Within one `<details>` a link on the line after the opener can be
14//! dead while emphasis four lines later is live, because a blank line ends the
15//! type-6 block and everything after it is markdown again. The rule therefore
16//! asks only what the parser already decided: is this line inside an HTML block.
17//!
18//! The test for a real finding is whether the construct would have rendered
19//! differently outside the block. Two exclusions follow from that directly:
20//!
21//! - **Undefined reference labels.** `[text][ref]` renders as a link only when
22//!   `ref` is defined somewhere in the document. With no definition it is literal
23//!   text *in both contexts*, so there is nothing the HTML block broke. This is
24//!   what separates a dead link from `arr[i][j]`, `[tab][tab]` and the CSS and
25//!   regex grammar (`[ a | b ]`, `[a-z][0-9]`) that fills HTML tables in
26//!   reference documentation. Definedness is not a heuristic for that
27//!   distinction, it is exactly the condition that makes the two differ.
28//! - **Inside a tag.** `<div title="see [docs](/docs)">` puts the construct in an
29//!   attribute value, which no markdown parser processes in any context.
30//!
31//! Two more are about intent rather than rendering, and the difference matters
32//! because these constructs genuinely do render differently. An inline `<code>`
33//! element does not stop markdown, so `<code>[a](b)</code>` outside a block
34//! really does produce a link inside the code element; a backtick span outside a
35//! block really does become `<code>`. Both are nevertheless silent here:
36//!
37//! - **`<code>` elements** and **backtick spans.** An author who wraps a
38//!   construct in either is asking for it to be shown, not followed, and inside
39//!   the block that is what they get. Reporting a broken *link* there would name
40//!   the wrong problem.
41//!
42//! Three exclusions are about reachability:
43//!
44//! - **Raw-text elements.** `pre`, `script`, `style` and `textarea` hold literal
45//!   text by design, so markdown-looking characters there are content.
46//! - **`markdown="1"` containers.** kramdown, Python-Markdown and MkDocs parse
47//!   those bodies, so the markdown really is markdown for those users.
48//! - **HTML comments.** Nothing inside them reaches the page at all.
49//!
50//! Detection only. Rewriting `[text](url)` to `<a href="url">text</a>` is a
51//! content transform in a domain rumdl cannot arbitrate: the right answer is
52//! often to add a blank line instead, which moves the markdown out of the block
53//! and changes the rendered structure.
54
55use crate::lint_context::{LintContext, image_pattern, link_pattern};
56use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
57use crate::utils::html_block::TYPE_1_BLOCK_ELEMENTS;
58use crate::utils::range_utils::byte_to_char_count;
59use regex::Regex;
60use std::sync::LazyLock;
61
62/// Opening or closing tag of a CommonMark type-1 raw-text element, anywhere on a
63/// line. A nested `<pre>` inside a `<table>` shares the enclosing block, so the
64/// line-start classifier cannot see it.
65static TYPE_1_TAG: LazyLock<Regex> =
66    LazyLock::new(|| Regex::new(&format!(r"(?i)<(/?)({})\b", TYPE_1_BLOCK_ELEMENTS.join("|"))).unwrap());
67
68/// A `<code>` element's span on one line, including an unclosed opener running
69/// to the end of the line.
70static CODE_ELEMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)<code\b[^>]*>.*?(?:</code\s*>|$)").unwrap());
71
72/// A backtick code span. Inside an HTML block the backticks are literal, but the
73/// author still wrote them to mean "show this", so the span's content is not a
74/// construct the block broke.
75static BACKTICK_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`").unwrap());
76
77/// An HTML tag. Everything between `<` and `>` is markup, so a construct there
78/// sits in an attribute value and is never parsed as markdown anywhere. The
79/// leading letter keeps prose like `a < b and c > d` from swallowing a line.
80static HTML_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)</?[A-Za-z][^>]*>").unwrap());
81
82/// What a match is, for the message.
83#[derive(Clone, Copy, PartialEq, Eq, Debug)]
84enum Construct {
85    Link,
86    Image,
87}
88
89impl Construct {
90    fn noun(self) -> &'static str {
91        match self {
92            Construct::Link => "link",
93            Construct::Image => "image",
94        }
95    }
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct MD091NoMarkdownInHtml;
100
101impl MD091NoMarkdownInHtml {
102    pub fn new() -> Self {
103        Self
104    }
105
106    /// Byte ranges on `line` that hold no reportable construct: `<code>`
107    /// elements, backtick spans, and the inside of any tag.
108    fn shielded_ranges(line: &str) -> Vec<(usize, usize)> {
109        CODE_ELEMENT
110            .find_iter(line)
111            .chain(BACKTICK_SPAN.find_iter(line))
112            .chain(HTML_TAG.find_iter(line))
113            .map(|m| (m.start(), m.end()))
114            .collect()
115    }
116
117    fn in_any(ranges: &[(usize, usize)], start: usize) -> bool {
118        ranges.iter().any(|(s, e)| start >= *s && start < *e)
119    }
120
121    /// Whether a match in reference form would have been a link outside the
122    /// block. `caps` group 7 is the explicit label; an empty one is the collapsed
123    /// form `[text][]`, whose label is the text itself. An inline `[a](b)` has no
124    /// group 7 and always resolves, so it is always reportable.
125    fn reference_resolves(ctx: &LintContext, caps: &regex::Captures<'_>) -> bool {
126        let Some(explicit) = caps.get(7) else {
127            return true;
128        };
129        let label = if explicit.as_str().is_empty() {
130            caps.get(1).map_or("", |g| g.as_str())
131        } else {
132            explicit.as_str()
133        };
134        ctx.reference_definition(label).is_some()
135    }
136
137    /// Whether this line's content is raw text, updating the nesting depth of
138    /// type-1 elements as it goes. A line carrying the opener is itself raw text
139    /// from that point on, and a line carrying the closer is still inside.
140    fn update_raw_text_depth(line: &str, depth: &mut i32) -> bool {
141        let was_inside = *depth > 0;
142        let mut opened_here = false;
143        for cap in TYPE_1_TAG.captures_iter(line) {
144            if cap.get(1).is_some_and(|s| s.as_str() == "/") {
145                *depth -= 1;
146            } else {
147                *depth += 1;
148                opened_here = true;
149            }
150        }
151        if *depth < 0 {
152            *depth = 0;
153        }
154        was_inside || opened_here
155    }
156
157    fn warning(
158        &self,
159        line_num: usize,
160        line: &str,
161        range: (usize, usize),
162        construct: Construct,
163        opener: usize,
164    ) -> LintWarning {
165        // `byte_to_char_count` is already 1-indexed, and `end_column` is
166        // 1-indexed exclusive, so the end is the start plus the char length.
167        let column = byte_to_char_count(line, range.0);
168        let end_column = column + line[range.0..range.1].chars().count();
169        LintWarning {
170            rule_name: Some(self.name().to_string()),
171            severity: Severity::Warning,
172            line: line_num,
173            column,
174            end_line: line_num,
175            end_column,
176            message: format!(
177                "Markdown {} renders as literal text: this line is inside the HTML block opened at line {}",
178                construct.noun(),
179                opener
180            ),
181            fix: None,
182        }
183    }
184}
185
186impl Rule for MD091NoMarkdownInHtml {
187    fn name(&self) -> &'static str {
188        "MD091"
189    }
190
191    fn description(&self) -> &'static str {
192        "Markdown inside an HTML block renders as literal text"
193    }
194
195    fn category(&self) -> RuleCategory {
196        RuleCategory::Html
197    }
198
199    fn should_skip(&self, ctx: &LintContext) -> bool {
200        // Every reportable construct needs both a tag and a bracket.
201        !ctx.content.contains('<') || !ctx.content.contains('[')
202    }
203
204    fn check(&self, ctx: &LintContext) -> LintResult {
205        let mut warnings = Vec::new();
206        let mut raw_text_depth = 0i32;
207        let mut opener_line = 0usize;
208        let mut prev_in_block = false;
209
210        for (idx, line) in ctx.content.lines().enumerate() {
211            let line_num = idx + 1;
212
213            if !ctx.is_in_html_block(line_num) {
214                prev_in_block = false;
215                raw_text_depth = 0;
216                continue;
217            }
218            // A run of consecutive block lines is one block; its first line is
219            // the opener the message points at.
220            if !prev_in_block {
221                opener_line = line_num;
222                raw_text_depth = 0;
223            }
224            prev_in_block = true;
225
226            if Self::update_raw_text_depth(line, &mut raw_text_depth) {
227                continue;
228            }
229
230            let Some(info) = ctx.line_info(line_num) else {
231                continue;
232            };
233            // A `markdown="1"` container really is markdown for kramdown,
234            // Python-Markdown and MkDocs users; a comment reaches no reader.
235            if info.in_mkdocs_container() || info.in_html_comment {
236                continue;
237            }
238
239            let shielded = Self::shielded_ranges(line);
240            let mut image_ranges: Vec<(usize, usize)> = Vec::new();
241
242            for caps in image_pattern().captures_iter(line) {
243                let m = caps.get(0).expect("group 0 always matches");
244                // Every image occupies its span whether or not it is reported,
245                // so a link nested in one is never reported twice.
246                image_ranges.push((m.start(), m.end()));
247                if Self::in_any(&shielded, m.start()) || !Self::reference_resolves(ctx, &caps) {
248                    continue;
249                }
250                warnings.push(self.warning(line_num, line, (m.start(), m.end()), Construct::Image, opener_line));
251            }
252
253            for caps in link_pattern().captures_iter(line) {
254                let m = caps.get(0).expect("group 0 always matches");
255                // An image is `!` plus a link; report it once, as an image.
256                if image_ranges.iter().any(|(s, e)| m.start() >= *s && m.end() <= *e) {
257                    continue;
258                }
259                if Self::in_any(&shielded, m.start()) || !Self::reference_resolves(ctx, &caps) {
260                    continue;
261                }
262                warnings.push(self.warning(line_num, line, (m.start(), m.end()), Construct::Link, opener_line));
263            }
264        }
265
266        warnings.sort_by_key(|w| (w.line, w.column));
267        Ok(warnings)
268    }
269
270    fn fix_capability(&self) -> FixCapability {
271        FixCapability::Unfixable
272    }
273
274    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
275        // Detection only: converting the construct to HTML and adding a blank
276        // line to end the block are different documents, and which one the
277        // author meant is not derivable from the source.
278        Ok(ctx.content.to_string())
279    }
280
281    fn as_any(&self) -> &dyn std::any::Any {
282        self
283    }
284
285    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
286    where
287        Self: Sized,
288    {
289        Box::new(Self)
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::config::MarkdownFlavor;
297
298    fn check_with(content: &str, flavor: MarkdownFlavor) -> Vec<LintWarning> {
299        let ctx = LintContext::new(content, flavor, None);
300        MD091NoMarkdownInHtml::new().check(&ctx).unwrap()
301    }
302
303    fn check(content: &str) -> Vec<LintWarning> {
304        check_with(content, MarkdownFlavor::Standard)
305    }
306
307    #[test]
308    fn reports_the_reported_shape() {
309        let content = "<div align=\"center\">\n[Docs](/docs)\n</div>\n";
310        let warnings = check(content);
311        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
312        assert_eq!((warnings[0].line, warnings[0].column), (2, 1));
313        // end_column is 1-indexed exclusive, so it is one past the final `)`.
314        assert_eq!(warnings[0].end_column, 14);
315        assert_eq!(
316            warnings[0].message,
317            "Markdown link renders as literal text: this line is inside the HTML block opened at line 1"
318        );
319        assert!(warnings[0].fix.is_none());
320    }
321
322    #[test]
323    fn reports_an_image() {
324        let warnings = check("<div align=\"center\">\n![Screenshot](shot.png)\n</div>\n");
325        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
326        assert_eq!(warnings[0].end_column, 24, "the extent covers the whole image");
327        assert!(warnings[0].message.contains("Markdown image"));
328    }
329
330    #[test]
331    fn reports_an_image_once_not_also_as_its_inner_link() {
332        // `![alt](url)` contains `[alt](url)`; only the image is reported.
333        let warnings = check("<div>\n![a](b.png)\n</div>\n");
334        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
335        assert!(warnings[0].message.contains("image"));
336    }
337
338    #[test]
339    fn a_blank_line_ends_the_block_so_the_link_is_live() {
340        // The dividing line is the block, not the tag: a blank line closes the
341        // type-6 block and everything after it is markdown again.
342        assert!(check("<div align=\"center\">\n\n[Docs](/docs)\n\n</div>\n").is_empty());
343    }
344
345    #[test]
346    fn inline_html_does_not_open_a_block() {
347        // Text before the tag on the same line keeps it inline, where markdown
348        // is fully alive.
349        assert!(check("Intro <details><summary>[Docs](/docs)</summary></details>\n").is_empty());
350    }
351
352    #[test]
353    fn reports_a_reference_link_whose_label_is_defined() {
354        // Defined outside the block: it would have been a link, so the block
355        // broke it.
356        let warnings = check("<div>\n[text][ref]\n</div>\n\n[ref]: https://example.com\n");
357        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
358        assert_eq!((warnings[0].line, warnings[0].column), (2, 1));
359    }
360
361    #[test]
362    fn accepts_a_reference_link_whose_label_is_undefined() {
363        // With no definition this is literal text inside the block AND outside
364        // it, so there is no defect to report.
365        assert!(check("<div>\n[text][nope]\n</div>\n").is_empty());
366    }
367
368    #[test]
369    fn accepts_array_indexing() {
370        // The whole `arr[i][j]` class falls out of the definedness gate.
371        for content in [
372            "<div>\narr[i][j]\n</div>\n",
373            "<div>\nmatrix[0][1]\n</div>\n",
374            "<div>\npress [tab][tab] to complete\n</div>\n",
375            "<div>\n<td>[a-z][0-9]</td>\n</div>\n",
376        ] {
377            assert!(check(content).is_empty(), "flagged: {content:?}");
378        }
379    }
380
381    #[test]
382    fn collapsed_references_follow_the_same_gate() {
383        // `[text][]` takes its label from the text itself.
384        let defined = check("<div>\n[text][]\n</div>\n\n[text]: https://example.com\n");
385        assert_eq!(defined.len(), 1, "got: {defined:?}");
386        assert!(check("<div>\n[text][]\n</div>\n").is_empty());
387    }
388
389    #[test]
390    fn reference_images_follow_the_same_gate() {
391        let defined = check("<div>\n![alt][img]\n</div>\n\n[img]: shot.png\n");
392        assert_eq!(defined.len(), 1, "got: {defined:?}");
393        assert!(check("<div>\n![alt][missing]\n</div>\n").is_empty());
394    }
395
396    #[test]
397    fn accepts_a_construct_inside_a_tag() {
398        // An attribute value is markup, never parsed as markdown in any context.
399        assert!(check("<div title=\"see [docs](/docs)\">\nbody\n</div>\n").is_empty());
400        assert!(check("<div data-x=\"![a](b.png)\">\nbody\n</div>\n").is_empty());
401    }
402
403    #[test]
404    fn accepts_a_construct_inside_a_code_element_or_backticks() {
405        assert!(check("<div>\n<code>[a](b.md)</code>\n</div>\n").is_empty());
406        assert!(check("<div>\n`[a](b.md)`\n</div>\n").is_empty());
407        assert!(check("<div>\n`[a-z0-9]([a-z0-9-]{0,61})`\n</div>\n").is_empty());
408    }
409
410    #[test]
411    fn accepts_raw_text_elements() {
412        assert!(check("<pre>\n[a](b.md)\n</pre>\n").is_empty());
413        assert!(check("<div>\n<pre>\n[a](b.md)\n</pre>\n</div>\n").is_empty());
414        assert!(check("<script>\nvar x = [a](b);\n</script>\n").is_empty());
415    }
416
417    #[test]
418    fn accepts_a_markdown_container() {
419        // kramdown, Python-Markdown and MkDocs parse this body as markdown.
420        assert!(check("<div markdown=\"1\">\n[Docs](/docs)\n</div>\n").is_empty());
421    }
422
423    #[test]
424    fn accepts_an_html_comment() {
425        assert!(check("<!-- [Docs](/docs) -->\n").is_empty());
426        assert!(check("<!--\n[Docs](/docs)\n-->\n").is_empty());
427        assert!(check("<div>\n<!-- [Docs](/docs) -->\n</div>\n").is_empty());
428    }
429
430    #[test]
431    fn each_block_names_its_own_opener() {
432        // `<span>` would not do here: it is not a CommonMark type-6 tag, so it
433        // opens no block at all.
434        let content = "<div>\n[a](1)\n</div>\n\ntext\n\n<section>\n[b](2)\n</section>\n";
435        let warnings = check(content);
436        assert_eq!(warnings.len(), 2, "got: {warnings:?}");
437        assert!(warnings[0].message.ends_with("opened at line 1"));
438        assert!(warnings[1].message.ends_with("opened at line 7"));
439    }
440
441    #[test]
442    fn is_detection_only() {
443        let rule = MD091NoMarkdownInHtml::new();
444        assert!(matches!(rule.fix_capability(), FixCapability::Unfixable));
445        let content = "<div>\n[Docs](/docs)\n</div>\n";
446        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
447        assert_eq!(rule.fix(&ctx).unwrap(), content);
448    }
449
450    #[test]
451    fn skips_documents_that_cannot_contain_a_finding() {
452        let ctx = LintContext::new("# Just a heading\n", MarkdownFlavor::Standard, None);
453        assert!(MD091NoMarkdownInHtml::new().should_skip(&ctx));
454    }
455}