1use 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
62static TYPE_1_TAG: LazyLock<Regex> =
66 LazyLock::new(|| Regex::new(&format!(r"(?i)<(/?)({})\b", TYPE_1_BLOCK_ELEMENTS.join("|"))).unwrap());
67
68static CODE_ELEMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)<code\b[^>]*>.*?(?:</code\s*>|$)").unwrap());
71
72static BACKTICK_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`").unwrap());
76
77static HTML_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)</?[A-Za-z][^>]*>").unwrap());
81
82#[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 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 fn reference_resolves(ctx: &LintContext, caps: ®ex::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 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 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 !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 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 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 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 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 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 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\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 let warnings = check("<div>\n\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 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 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 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 assert!(check("<div>\n[text][nope]\n</div>\n").is_empty());
366 }
367
368 #[test]
369 fn accepts_array_indexing() {
370 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 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 assert!(check("<div title=\"see [docs](/docs)\">\nbody\n</div>\n").is_empty());
400 assert!(check("<div data-x=\"\">\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 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 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}