Skip to main content

rumdl_lib/rules/
md033_no_inline_html.rs

1//!
2//! Rule MD033: No HTML tags
3//!
4//! See [docs/md033.md](../../docs/md033.md) for full documentation, configuration, and examples.
5
6use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext};
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::regex_cache::*;
10use std::collections::{HashMap, HashSet};
11use std::ops::Range;
12
13mod md033_config;
14use crate::utils::html_elements::is_void_element;
15use md033_config::{MD033Config, MD033FixMode, is_permitted_without_markdown_equivalent};
16
17/// Byte offsets of every literal closing tag (`</name>`) in a document, by
18/// ASCII-lowercased tag name, so pairing an opening tag with its closing tag is a
19/// binary search rather than a scan of the rest of the document.
20#[derive(Default)]
21struct ClosingTagIndex {
22    positions: HashMap<String, Vec<usize>>,
23}
24
25impl ClosingTagIndex {
26    fn new(content: &str) -> Self {
27        let bytes = content.as_bytes();
28        let mut positions: HashMap<String, Vec<usize>> = HashMap::new();
29        let mut from = 0;
30        while let Some(offset) = content[from..].find("</") {
31            let start = from + offset;
32            let name_start = start + 2;
33            // A name ends at `>`; anything that cannot be part of one ends the
34            // candidate. Stopping at `<` keeps every byte in at most one window.
35            let name_len = bytes[name_start..]
36                .iter()
37                .position(|&b| b == b'>' || b == b'<' || b == b'/' || b.is_ascii_whitespace());
38            match name_len {
39                Some(len) if len > 0 && bytes[name_start + len] == b'>' => {
40                    let name = content[name_start..name_start + len].to_ascii_lowercase();
41                    positions.entry(name).or_default().push(start);
42                    from = name_start + len;
43                }
44                Some(len) => from = name_start + len,
45                None => break,
46            }
47        }
48        Self { positions }
49    }
50
51    /// The offset of the first `</tag_name>` starting at or after `offset`.
52    fn first_at_or_after(&self, tag_name: &str, offset: usize) -> Option<usize> {
53        let positions = self.positions.get(tag_name)?;
54        positions.get(positions.partition_point(|&p| p < offset)).copied()
55    }
56}
57
58#[derive(Clone)]
59pub struct MD033NoInlineHtml {
60    config: MD033Config,
61    allowed: HashSet<String>,
62    allowed_inside: HashSet<String>,
63    table_allowed: HashSet<String>,
64    disallowed: HashSet<String>,
65    drop_attributes: HashSet<String>,
66    strip_wrapper_elements: HashSet<String>,
67    allows_no_markdown_equivalent: bool,
68    table_allows_no_markdown_equivalent: bool,
69}
70
71/// Point membership in allowed regions, including overlapping and nested ones.
72/// Each sorted start stores the greatest end seen so far, so the predecessor
73/// answers a query without scanning all regions or losing an enclosing one.
74struct AllowedInsideIndex {
75    ranges: Vec<Range<usize>>,
76}
77
78impl AllowedInsideIndex {
79    fn new(mut ranges: Vec<Range<usize>>) -> Self {
80        ranges.sort_unstable_by_key(|range| range.start);
81        let mut end = 0;
82        for range in &mut ranges {
83            end = end.max(range.end);
84            range.end = end;
85        }
86        Self { ranges }
87    }
88
89    fn contains(&self, offset: usize) -> bool {
90        let past = self.ranges.partition_point(|range| range.start <= offset);
91        past > 0 && offset < self.ranges[past - 1].end
92    }
93}
94
95impl Default for MD033NoInlineHtml {
96    fn default() -> Self {
97        Self::from_config_struct(MD033Config::default())
98    }
99}
100
101impl MD033NoInlineHtml {
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
107        Self::from_config_struct(MD033Config {
108            allowed: allowed_vec,
109            ..MD033Config::default()
110        })
111    }
112
113    pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
114        Self::from_config_struct(MD033Config {
115            disallowed: disallowed_vec,
116            ..MD033Config::default()
117        })
118    }
119
120    /// Create a new rule with auto-fix enabled
121    pub fn with_fix(fix: bool) -> Self {
122        Self::from_config_struct(MD033Config {
123            fix,
124            ..MD033Config::default()
125        })
126    }
127
128    /// Single source of truth for building an `MD033NoInlineHtml` from config.
129    /// Pre-computes all lowercase HashSets so per-line lookups are O(1).
130    pub fn from_config_struct(config: MD033Config) -> Self {
131        let allowed = config.allowed_set();
132        let allowed_inside = config.allowed_inside_set();
133        let table_allowed = config.table_allowed_set();
134        let disallowed = config.disallowed_set();
135        let drop_attributes = config.drop_attributes_set();
136        let strip_wrapper_elements = config.strip_wrapper_elements_set();
137        let allows_no_markdown_equivalent = config.allows_no_markdown_equivalent();
138        let table_allows_no_markdown_equivalent = config.table_allows_no_markdown_equivalent();
139        Self {
140            config,
141            allowed,
142            allowed_inside,
143            table_allowed,
144            disallowed,
145            drop_attributes,
146            strip_wrapper_elements,
147            allows_no_markdown_equivalent,
148            table_allows_no_markdown_equivalent,
149        }
150    }
151
152    /// Extract the lowercase tag name from a raw tag string like `<br/>` or
153    /// `</div >`. Strips angle brackets, leading slash, and stops at the first
154    /// whitespace, `>`, or `/`. Returns an empty string if no name is present.
155    #[inline]
156    fn extract_tag_name(tag: &str) -> String {
157        let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
158        trimmed
159            .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
160            .next()
161            .unwrap_or("")
162            .to_lowercase()
163    }
164
165    /// Membership check against a precomputed lowercase set, returning false
166    /// fast when the set is empty.
167    #[inline]
168    fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
169        if set.is_empty() {
170            return false;
171        }
172        set.contains(&Self::extract_tag_name(tag))
173    }
174
175    /// Check whether the tag is permitted by the general `allowed_elements` list.
176    #[inline]
177    fn is_tag_allowed(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
178        if !self.allows_no_markdown_equivalent {
179            return Self::tag_in_set(&self.allowed, tag);
180        }
181        let name = Self::extract_tag_name(tag);
182        self.allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, false)
183    }
184
185    /// Check whether the tag is permitted inside a GFM table cell.
186    /// Uses `table_allowed_elements` if configured, falling back to `allowed`.
187    #[inline]
188    fn is_tag_allowed_in_table(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
189        if !self.table_allows_no_markdown_equivalent {
190            return Self::tag_in_set(&self.table_allowed, tag);
191        }
192        let name = Self::extract_tag_name(tag);
193        self.table_allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, true)
194    }
195
196    /// Whether an element holds no content, so naming it in `allowed_inside`
197    /// describes nothing.
198    #[inline]
199    fn is_void_element(tag_name: &str) -> bool {
200        is_void_element(tag_name)
201    }
202
203    /// Whether this tag sits where its text is not markup: a code or math block,
204    /// front matter, a comment, a link title, or a template shortcode tag.
205    ///
206    /// A shortcode tag is an argument list a template reads, so `<b>` written in
207    /// one is a string the template receives rather than HTML the document emits.
208    /// The range covers the tag only, so real HTML in the body between paired
209    /// shortcode tags is still reported.
210    fn is_inert_markup(ctx: &crate::lint_context::LintContext, html_tag: &HtmlTag) -> bool {
211        ctx.line_info(html_tag.line).is_some_and(|info| {
212            info.in_code_block
213                || info.in_pymdown_block
214                || info.is_kramdown_block_ial
215                || info.in_front_matter
216                || info.in_math_block
217        }) || ctx.is_in_html_comment(html_tag.byte_offset)
218            || ctx.is_in_mdx_comment(html_tag.byte_offset)
219            || ctx.is_in_link_title(html_tag.byte_offset)
220            || ctx.is_byte_offset_in_code_span(html_tag.byte_offset)
221            || ctx.is_in_shortcode(html_tag.byte_offset)
222    }
223
224    /// Byte ranges spanned by the elements named in `allowed_inside`, each running
225    /// from its opening tag through its matching closing tag.
226    ///
227    /// An element left unclosed reaches the end of the document, which is where a
228    /// reader ends its content too.
229    fn allowed_inside_ranges(&self, ctx: &crate::lint_context::LintContext) -> Vec<Range<usize>> {
230        if self.allowed_inside.is_empty() {
231            return Vec::new();
232        }
233
234        let html_tags = ctx.html_tags();
235        let mut ranges = Vec::new();
236        let mut open: Vec<(&str, usize)> = Vec::new();
237
238        for html_tag in html_tags.iter() {
239            if !self.allowed_inside.contains(&html_tag.tag_name) || Self::is_inert_markup(ctx, html_tag) {
240                continue;
241            }
242            if html_tag.is_closing {
243                // A closing tag ends the innermost element of that name, and with it
244                // any element left open inside that one.
245                if let Some(index) = open.iter().rposition(|(name, _)| *name == html_tag.tag_name) {
246                    ranges.extend(open.drain(index..).map(|(_, start)| start..html_tag.byte_end));
247                }
248            } else if !html_tag.is_self_closing && !Self::is_void_element(&html_tag.tag_name) {
249                open.push((html_tag.tag_name.as_str(), html_tag.byte_offset));
250            }
251        }
252
253        ranges.extend(open.into_iter().map(|(_, start)| start..ctx.content.len()));
254        ranges
255    }
256
257    /// Check if a tag is in the disallowed set (for disallowed-only mode).
258    #[inline]
259    fn is_tag_disallowed(&self, tag: &str) -> bool {
260        Self::tag_in_set(&self.disallowed, tag)
261    }
262
263    /// Check if operating in disallowed-only mode
264    #[inline]
265    fn is_disallowed_mode(&self) -> bool {
266        self.config.is_disallowed_mode()
267    }
268
269    // Check if a tag is an HTML comment
270    #[inline]
271    fn is_html_comment(&self, tag: &str) -> bool {
272        tag.starts_with("<!--") && tag.ends_with("-->")
273    }
274
275    /// Check if a tag name is a valid HTML element or custom element.
276    /// Returns false for placeholder syntax like `<NAME>`, `<resource>`, `<actual>`.
277    ///
278    /// Per HTML spec, custom elements must contain a hyphen (e.g., `<my-component>`).
279    #[inline]
280    fn is_html_element_or_custom(tag_name: &str) -> bool {
281        // Sorted for binary search — must remain sorted when adding elements
282        const HTML_ELEMENTS: &[&str] = &[
283            "a",
284            "abbr",
285            "acronym",
286            "address",
287            "applet",
288            "area",
289            "article",
290            "aside",
291            "audio",
292            "b",
293            "base",
294            "basefont",
295            "bdi",
296            "bdo",
297            "big",
298            "blockquote",
299            "body",
300            "br",
301            "button",
302            "canvas",
303            "caption",
304            "center",
305            "cite",
306            "code",
307            "col",
308            "colgroup",
309            "data",
310            "datalist",
311            "dd",
312            "del",
313            "details",
314            "dfn",
315            "dialog",
316            "dir",
317            "div",
318            "dl",
319            "dt",
320            "em",
321            "embed",
322            "fieldset",
323            "figcaption",
324            "figure",
325            "font",
326            "footer",
327            "form",
328            "frame",
329            "frameset",
330            "h1",
331            "h2",
332            "h3",
333            "h4",
334            "h5",
335            "h6",
336            "head",
337            "header",
338            "hgroup",
339            "hr",
340            "html",
341            "i",
342            "iframe",
343            "img",
344            "input",
345            "ins",
346            "isindex",
347            "kbd",
348            "label",
349            "legend",
350            "li",
351            "link",
352            "main",
353            "map",
354            "mark",
355            "marquee",
356            "math",
357            "menu",
358            "meta",
359            "meter",
360            "nav",
361            "noembed",
362            "noframes",
363            "noscript",
364            "object",
365            "ol",
366            "optgroup",
367            "option",
368            "output",
369            "p",
370            "param",
371            "picture",
372            "plaintext",
373            "pre",
374            "progress",
375            "q",
376            "rp",
377            "rt",
378            "ruby",
379            "s",
380            "samp",
381            "script",
382            "search",
383            "section",
384            "select",
385            "slot",
386            "small",
387            "source",
388            "span",
389            "strike",
390            "strong",
391            "style",
392            "sub",
393            "summary",
394            "sup",
395            "svg",
396            "table",
397            "tbody",
398            "td",
399            "template",
400            "textarea",
401            "tfoot",
402            "th",
403            "thead",
404            "time",
405            "title",
406            "tr",
407            "track",
408            "tt",
409            "u",
410            "ul",
411            "var",
412            "video",
413            "wbr",
414            "xmp",
415        ];
416
417        let lower = tag_name.to_ascii_lowercase();
418        if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
419            return true;
420        }
421        // Custom elements must contain a hyphen per HTML spec
422        tag_name.contains('-')
423    }
424
425    // Check if a tag is likely a programming type annotation rather than HTML
426    #[inline]
427    fn is_likely_type_annotation(&self, tag: &str) -> bool {
428        // Sorted for binary search — must remain sorted when adding elements
429        const COMMON_TYPES: &[&str] = &[
430            "any",
431            "apiresponse",
432            "array",
433            "bigint",
434            "config",
435            "data",
436            "date",
437            "e",
438            "element",
439            "error",
440            "function",
441            "generator",
442            "item",
443            "iterator",
444            "k",
445            "map",
446            "node",
447            "null",
448            "number",
449            "options",
450            "params",
451            "promise",
452            "regexp",
453            "request",
454            "response",
455            "result",
456            "set",
457            "string",
458            "symbol",
459            "t",
460            "u",
461            "undefined",
462            "userdata",
463            "v",
464            "void",
465            "weakmap",
466            "weakset",
467        ];
468
469        let tag_content = tag
470            .trim_start_matches('<')
471            .trim_end_matches('>')
472            .trim_start_matches('/');
473        let tag_name = tag_content
474            .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
475            .next()
476            .unwrap_or("");
477
478        // Check if it's a simple tag (no attributes) with a common type name
479        if !tag_content.contains(' ') && !tag_content.contains('=') {
480            let lower = tag_name.to_ascii_lowercase();
481            COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
482        } else {
483            false
484        }
485    }
486
487    // Check if a tag is actually an email address in angle brackets
488    #[inline]
489    fn is_email_address(&self, tag: &str) -> bool {
490        let content = tag.trim_start_matches('<').trim_end_matches('>');
491        // Simple email pattern: contains @ and has reasonable structure
492        content.contains('@')
493            && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
494            && content.split('@').count() == 2
495            && content.split('@').all(|part| !part.is_empty())
496    }
497
498    // Check if a tag has the markdown attribute (MkDocs/Material for MkDocs)
499    #[inline]
500    fn has_markdown_attribute(&self, tag: &str) -> bool {
501        // Check for various forms of markdown attribute
502        // Examples: <div markdown>, <div markdown="1">, <div class="result" markdown>
503        tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
504    }
505
506    /// Check if a tag contains JSX-specific attributes that indicate it's JSX, not HTML
507    /// JSX uses different attribute names than HTML:
508    /// - `className` instead of `class`
509    /// - `htmlFor` instead of `for`
510    /// - camelCase event handlers (`onClick`, `onChange`, `onSubmit`, etc.)
511    /// - JSX expression syntax `={...}` for dynamic values
512    #[inline]
513    fn has_jsx_attributes(tag: &str) -> bool {
514        // JSX-specific attribute names (HTML uses class, for, onclick, etc.)
515        tag.contains("className")
516            || tag.contains("htmlFor")
517            || tag.contains("dangerouslySetInnerHTML")
518            // camelCase event handlers (JSX uses onClick, HTML uses onclick)
519            || tag.contains("onClick")
520            || tag.contains("onChange")
521            || tag.contains("onSubmit")
522            || tag.contains("onFocus")
523            || tag.contains("onBlur")
524            || tag.contains("onKeyDown")
525            || tag.contains("onKeyUp")
526            || tag.contains("onKeyPress")
527            || tag.contains("onMouseDown")
528            || tag.contains("onMouseUp")
529            || tag.contains("onMouseEnter")
530            || tag.contains("onMouseLeave")
531            // JSX expression syntax: ={expression} or ={ expression }
532            || tag.contains("={")
533    }
534
535    // Check if a tag is actually a URL in angle brackets
536    #[inline]
537    fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
538        let content = tag.trim_start_matches('<').trim_end_matches('>');
539        // Check for common URL schemes
540        content.starts_with("http://")
541            || content.starts_with("https://")
542            || content.starts_with("ftp://")
543            || content.starts_with("ftps://")
544            || content.starts_with("mailto:")
545    }
546
547    #[inline]
548    fn is_relaxed_fix_mode(&self) -> bool {
549        self.config.fix_mode == MD033FixMode::Relaxed
550    }
551
552    #[inline]
553    fn is_droppable_attribute(&self, attr_name: &str) -> bool {
554        // Event handler attributes (onclick, onload, etc.) are never droppable
555        // because they can execute arbitrary JavaScript.
556        if attr_name.starts_with("on") && attr_name.len() > 2 {
557            return false;
558        }
559        self.drop_attributes.contains(attr_name)
560            || (attr_name.starts_with("data-")
561                && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
562    }
563
564    #[inline]
565    fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
566        self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
567    }
568
569    /// Check whether `byte_offset` sits directly inside a top-level strippable
570    /// wrapper element (e.g. `<p>`).  Returns `true` only when:
571    ///  1. The nearest unclosed opening tag before the offset is a configured
572    ///     wrapper element, AND
573    ///  2. That wrapper is itself NOT nested inside another HTML element.
574    ///
575    /// Condition 2 prevents converting inner content when the wrapper cannot
576    /// be stripped (e.g. `<div><p><img/></p></div>` -- stripping `<p>` is
577    /// blocked because it is nested, so converting `<img>` would leave
578    /// markdown inside an HTML block where it won't render).
579    fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
580        if byte_offset == 0 {
581            return false;
582        }
583        let before = content[..byte_offset].trim_end();
584        if !before.ends_with('>') || before.ends_with("->") {
585            return false;
586        }
587        if let Some(last_lt) = before.rfind('<') {
588            let potential_tag = &before[last_lt..];
589            if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
590                return false;
591            }
592            let parent_name = potential_tag
593                .trim_start_matches('<')
594                .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
595                .next()
596                .unwrap_or("")
597                .to_lowercase();
598            if !self.strip_wrapper_elements.contains(&parent_name) {
599                return false;
600            }
601            // Verify the wrapper itself is not nested inside another element.
602            let wrapper_before = before[..last_lt].trim_end();
603            if wrapper_before.ends_with('>')
604                && !wrapper_before.ends_with("->")
605                && let Some(outer_lt) = wrapper_before.rfind('<')
606                && let outer_tag = &wrapper_before[outer_lt..]
607                && !outer_tag.starts_with("</")
608                && !outer_tag.starts_with("<!--")
609            {
610                return false;
611            }
612            return true;
613        }
614        false
615    }
616
617    /// Convert paired HTML tags to their Markdown equivalents.
618    /// Returns None if the tag cannot be safely converted (has nested tags, HTML entities, etc.)
619    fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
620        // Skip if content contains nested HTML tags
621        if inner_content.contains('<') {
622            return None;
623        }
624        // Skip if content contains HTML entities (e.g., &vert;, &amp;, &lt;)
625        // These need HTML context to render correctly; markdown won't process them
626        if inner_content.contains('&') && inner_content.contains(';') {
627            // Check for common HTML entity patterns
628            let has_entity = inner_content
629                .split('&')
630                .skip(1)
631                .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
632            if has_entity {
633                return None;
634            }
635        }
636        match tag_name {
637            "em" | "i" => Some(format!("*{inner_content}*")),
638            "strong" | "b" => Some(format!("**{inner_content}**")),
639            "code" => {
640                // Handle backticks in content by using double backticks with padding
641                if inner_content.contains('`') {
642                    Some(format!("`` {inner_content} ``"))
643                } else {
644                    Some(format!("`{inner_content}`"))
645                }
646            }
647            _ => None,
648        }
649    }
650
651    /// Convert self-closing HTML tags to their Markdown equivalents.
652    fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
653        match tag_name {
654            "br" => match self.config.br_style {
655                md033_config::BrStyle::TrailingSpaces => Some("  \n".to_string()),
656                md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
657            },
658            "hr" => Some("\n---\n".to_string()),
659            "img" => self.convert_img_to_markdown(opening_tag),
660            _ => None,
661        }
662    }
663
664    /// Parse all attributes from an HTML tag into a list of (name, value) pairs.
665    /// This provides proper attribute parsing instead of naive string matching.
666    fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
667        let mut attrs = Vec::new();
668
669        // Remove < and > and tag name
670        let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
671
672        // Find first whitespace to skip tag name. Advance by the full UTF-8 width
673        // of the whitespace char so multi-byte whitespace (e.g. U+00A0) does not
674        // leave attr_start in the middle of a codepoint.
675        let attr_start = tag_content
676            .char_indices()
677            .find(|(_, c)| c.is_whitespace())
678            .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
679
680        if attr_start >= tag_content.len() {
681            return attrs;
682        }
683
684        let attr_str = &tag_content[attr_start..];
685        let mut chars = attr_str.chars().peekable();
686
687        while chars.peek().is_some() {
688            // Skip whitespace
689            while chars.peek().is_some_and(|c| c.is_whitespace()) {
690                chars.next();
691            }
692
693            if chars.peek().is_none() {
694                break;
695            }
696
697            // Read attribute name
698            let mut attr_name = String::new();
699            while let Some(&c) = chars.peek() {
700                if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
701                    break;
702                }
703                attr_name.push(c);
704                chars.next();
705            }
706
707            if attr_name.is_empty() {
708                break;
709            }
710
711            // Skip whitespace before =
712            while chars.peek().is_some_and(|c| c.is_whitespace()) {
713                chars.next();
714            }
715
716            // Check for = and value
717            if chars.peek() == Some(&'=') {
718                chars.next(); // consume =
719
720                // Skip whitespace after =
721                while chars.peek().is_some_and(|c| c.is_whitespace()) {
722                    chars.next();
723                }
724
725                // Read value
726                let mut value = String::new();
727                if let Some(&quote) = chars.peek() {
728                    if quote == '"' || quote == '\'' {
729                        chars.next(); // consume opening quote
730                        for c in chars.by_ref() {
731                            if c == quote {
732                                break;
733                            }
734                            value.push(c);
735                        }
736                    } else {
737                        // Unquoted value
738                        while let Some(&c) = chars.peek() {
739                            if c.is_whitespace() || c == '>' || c == '/' {
740                                break;
741                            }
742                            value.push(c);
743                            chars.next();
744                        }
745                    }
746                }
747                attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
748            } else {
749                // Boolean attribute (no value)
750                attrs.push((attr_name.to_ascii_lowercase(), None));
751            }
752        }
753
754        attrs
755    }
756
757    /// Extract an HTML attribute value from a tag string.
758    /// Handles double quotes, single quotes, and unquoted values.
759    /// Returns None if the attribute is not found.
760    fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
761        let attrs = Self::parse_attributes(tag);
762        let attr_lower = attr_name.to_ascii_lowercase();
763
764        attrs
765            .into_iter()
766            .find(|(name, _)| name == &attr_lower)
767            .and_then(|(_, value)| value)
768    }
769
770    /// Check if an HTML tag has extra attributes beyond the specified allowed ones.
771    /// Uses proper attribute parsing to avoid false positives from string matching.
772    fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
773        let attrs = Self::parse_attributes(tag);
774
775        // All event handlers (on*) are dangerous
776        // Plus common attributes that would be lost in markdown conversion
777        const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; // onclick, onload, onerror, etc.
778        const DANGEROUS_ATTRS: &[&str] = &[
779            "class",
780            "id",
781            "style",
782            "target",
783            "rel",
784            "download",
785            "referrerpolicy",
786            "crossorigin",
787            "loading",
788            "decoding",
789            "fetchpriority",
790            "sizes",
791            "srcset",
792            "usemap",
793            "ismap",
794            "width",
795            "height",
796            "name",   // anchor names
797            "data-*", // data attributes (checked separately)
798        ];
799
800        for (attr_name, _) in attrs {
801            // Skip allowed attributes (list is small, linear scan is efficient)
802            if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
803                continue;
804            }
805
806            if self.is_relaxed_fix_mode() {
807                if self.is_droppable_attribute(&attr_name) {
808                    continue;
809                }
810                return true;
811            }
812
813            // Check for event handlers (on*)
814            for prefix in DANGEROUS_ATTR_PREFIXES {
815                if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
816                    return true;
817                }
818            }
819
820            // Check for data-* attributes
821            if attr_name.starts_with("data-") {
822                return true;
823            }
824
825            // Check for other dangerous attributes
826            if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
827                return true;
828            }
829        }
830
831        false
832    }
833
834    /// Convert `<a href="url">text</a>` to `[text](url)` or `[text](url "title")`
835    /// Returns None if conversion is not safe.
836    fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
837        // Extract href attribute
838        let href = Self::extract_attribute(opening_tag, "href")?;
839
840        // Check URL is safe
841        if !MD033Config::is_safe_url(&href) {
842            return None;
843        }
844
845        // Check for nested HTML tags in content
846        if inner_content.contains('<') {
847            return None;
848        }
849
850        // Check for HTML entities that wouldn't render correctly in markdown
851        if inner_content.contains('&') && inner_content.contains(';') {
852            let has_entity = inner_content
853                .split('&')
854                .skip(1)
855                .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
856            if has_entity {
857                return None;
858            }
859        }
860
861        // Extract optional title attribute
862        let title = Self::extract_attribute(opening_tag, "title");
863
864        // Check for extra dangerous attributes (title is allowed)
865        if self.has_extra_attributes(opening_tag, &["href", "title"]) {
866            return None;
867        }
868
869        // If inner content is exactly a markdown image (from a prior <img> fix),
870        // use it directly without bracket escaping to produce valid [![alt](src)](href).
871        // Must verify the entire content is a single image — not mixed content like
872        // "![](url) extra [text]" where trailing brackets still need escaping.
873        let trimmed_inner = inner_content.trim();
874        let is_markdown_image =
875            trimmed_inner.starts_with("![") && trimmed_inner.contains("](") && trimmed_inner.ends_with(')') && {
876                // Verify the closing ](url) accounts for the rest of the content
877                // by finding the image's ]( and checking nothing follows the final )
878                if let Some(bracket_close) = trimmed_inner.rfind("](") {
879                    let after_paren = &trimmed_inner[bracket_close + 2..];
880                    // The rest should be just "url)" — find the matching close paren
881                    after_paren.ends_with(')')
882                        && after_paren.chars().filter(|&c| c == ')').count()
883                            >= after_paren.chars().filter(|&c| c == '(').count()
884                } else {
885                    false
886                }
887            };
888        let escaped_text = if is_markdown_image {
889            trimmed_inner.to_string()
890        } else {
891            // Escape special markdown characters in link text
892            // Brackets need escaping to avoid breaking the link syntax
893            inner_content.replace('[', r"\[").replace(']', r"\]")
894        };
895
896        // Escape parentheses in URL
897        let escaped_url = href.replace('(', "%28").replace(')', "%29");
898
899        // Format with or without title
900        if let Some(title_text) = title {
901            // Escape quotes in title
902            let escaped_title = title_text.replace('"', r#"\""#);
903            Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
904        } else {
905            Some(format!("[{escaped_text}]({escaped_url})"))
906        }
907    }
908
909    /// Convert `<img src="url" alt="text">` to `![alt](src)` or `![alt](src "title")`
910    /// Returns None if conversion is not safe.
911    fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
912        // Extract src attribute (required)
913        let src = Self::extract_attribute(tag, "src")?;
914
915        // Check URL is safe
916        if !MD033Config::is_safe_url(&src) {
917            return None;
918        }
919
920        // Extract alt attribute (optional, default to empty)
921        let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
922
923        // Extract optional title attribute
924        let title = Self::extract_attribute(tag, "title");
925
926        // Check for extra dangerous attributes (title is allowed)
927        if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
928            return None;
929        }
930
931        // Escape special markdown characters in alt text
932        let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
933
934        // Escape parentheses in URL
935        let escaped_url = src.replace('(', "%28").replace(')', "%29");
936
937        // Format with or without title
938        if let Some(title_text) = title {
939            // Escape quotes in title
940            let escaped_title = title_text.replace('"', r#"\""#);
941            Some(format!("![{escaped_alt}]({escaped_url} \"{escaped_title}\")"))
942        } else {
943            Some(format!("![{escaped_alt}]({escaped_url})"))
944        }
945    }
946
947    /// Check if an HTML tag has attributes that would make conversion unsafe
948    fn has_significant_attributes(opening_tag: &str) -> bool {
949        // Tags with just whitespace or empty are fine
950        let tag_content = opening_tag
951            .trim_start_matches('<')
952            .trim_end_matches('>')
953            .trim_end_matches('/');
954
955        // Split by whitespace; if there's more than the tag name, it has attributes
956        let parts: Vec<&str> = tag_content.split_whitespace().collect();
957        parts.len() > 1
958    }
959
960    /// Check if a tag appears to be nested inside another HTML element
961    /// by looking at the surrounding context (e.g., `<code><em>text</em></code>`)
962    fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
963        // Check if there's a `>` immediately before this tag (indicating inside another element)
964        if tag_byte_start > 0 {
965            let before = &content[..tag_byte_start];
966            let before_trimmed = before.trim_end();
967            if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
968                // Check it's not a closing tag or comment
969                if let Some(last_lt) = before_trimmed.rfind('<') {
970                    let potential_tag = &before_trimmed[last_lt..];
971                    // Skip if it's a closing tag (</...>) or comment (<!--)
972                    if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
973                        return true;
974                    }
975                }
976            }
977        }
978        // Check if there's a `<` immediately after the closing tag (indicating inside another element)
979        if tag_byte_end < content.len() {
980            let after = &content[tag_byte_end..];
981            let after_trimmed = after.trim_start();
982            if after_trimmed.starts_with("</") {
983                return true;
984            }
985        }
986        false
987    }
988
989    /// A conversion may replace a complete raw HTML block, but must not leave
990    /// Markdown delimiters inside the remainder of a raw HTML block.
991    fn is_nested_in_html_block(ctx: &LintContext, start: usize, end: usize) -> bool {
992        if let Some((block_start, block_end)) = ctx.html_block_range_at(start) {
993            let block = &ctx.content[block_start..block_end];
994            let trimmed_start = block_start + block.len() - block.trim_start_matches([' ', '\t', '\r', '\n']).len();
995            let trimmed_end = block_end - (block.len() - block.trim_end_matches([' ', '\t', '\r', '\n']).len());
996            start != trimmed_start || end != trimmed_end
997        } else {
998            // Some flavor-specific blocks are marked outside the CommonMark
999            // parser. Retain their conservative fix policy.
1000            ctx.is_in_html_block(ctx.offset_to_line_col(start).0)
1001        }
1002    }
1003
1004    /// Identify blocks whose replacements must retain Markdown container prefixes.
1005    fn is_html_block_in_markdown_container(ctx: &LintContext, start: usize) -> bool {
1006        let Some((block_start, _)) = ctx.html_block_range_at(start) else {
1007            return false;
1008        };
1009        let line_num = ctx.offset_to_line_col(block_start).0;
1010        let Some(line) = ctx.line_info(line_num) else {
1011            return true;
1012        };
1013        line.blockquote.is_some()
1014            || line.in_list_block
1015            || line.in_footnote_definition
1016            || ctx.is_in_definition_list(line_num)
1017            || ctx.in_colon_definition(line_num)
1018            || line.in_flavor_container()
1019            || ctx.content[line.byte_offset..block_start]
1020                .bytes()
1021                .any(|b| !b.is_ascii_whitespace())
1022    }
1023
1024    /// Raw block bodies contain literal text. Only accept a replacement whose
1025    /// parsed inline structure is exactly the intended element and that text.
1026    fn block_conversion_preserves_text(markdown: &str, tag_name: &str, expected: &str) -> bool {
1027        use pulldown_cmark::{Event, Parser, Tag, TagEnd};
1028        // An isolated parse cannot resolve reference links or footnotes defined
1029        // elsewhere in the document. Keep their literal HTML text untouched.
1030        if matches!(tag_name, "em" | "i" | "strong" | "b") && expected.contains(['[', ']']) {
1031            return false;
1032        }
1033        let mut events = Parser::new_ext(
1034            markdown,
1035            crate::utils::parser_options::rumdl_parser_options_for(markdown),
1036        );
1037        if events.next() != Some(Event::Start(Tag::Paragraph)) {
1038            return false;
1039        }
1040        if tag_name == "code" {
1041            return events
1042                .next()
1043                .is_some_and(|e| matches!(e, Event::Code(text) if text.as_ref() == expected))
1044                && events.next() == Some(Event::End(TagEnd::Paragraph))
1045                && events.next().is_none();
1046        }
1047        let end = match (tag_name, events.next()) {
1048            ("em" | "i", Some(Event::Start(Tag::Emphasis))) => TagEnd::Emphasis,
1049            ("strong" | "b", Some(Event::Start(Tag::Strong))) => TagEnd::Strong,
1050            ("a", Some(Event::Start(Tag::Link { .. }))) => TagEnd::Link,
1051            _ => return false,
1052        };
1053        let mut text = String::new();
1054        for event in events.by_ref() {
1055            match event {
1056                Event::Text(part) => text.push_str(&part),
1057                Event::SoftBreak => text.push('\n'),
1058                Event::End(found) if found == end => {
1059                    return text == expected
1060                        && events.next() == Some(Event::End(TagEnd::Paragraph))
1061                        && events.next().is_none();
1062                }
1063                _ => return false,
1064            }
1065        }
1066        false
1067    }
1068
1069    /// Calculate fix to remove HTML tags while keeping content.
1070    ///
1071    /// For self-closing tags like `<br/>`, returns a single fix to remove the tag.
1072    /// For paired tags like `<span>text</span>`, returns the replacement text (just the content).
1073    ///
1074    /// Returns (range, replacement_text) where range is the bytes to replace
1075    /// and replacement_text is what to put there (content without tags, or empty for self-closing).
1076    ///
1077    /// Tags nested in a raw HTML block are left alone in conservative mode. In
1078    /// relaxed mode two exceptions apply:
1079    /// - Strippable wrapper elements (e.g. `<p>`) bypass the block guard so
1080    ///   they can be stripped even though they ARE the HTML block.
1081    /// - Self-closing tags whose direct parent is a strippable wrapper also
1082    ///   bypass the guard so inner content can be converted first.
1083    fn calculate_fix(
1084        &self,
1085        ctx: &LintContext,
1086        closing_tags: &ClosingTagIndex,
1087        opening_tag: &str,
1088        tag_byte_start: usize,
1089    ) -> Option<(std::ops::Range<usize>, String)> {
1090        let fix = self.calculate_fix_candidate(ctx, closing_tags, opening_tag, tag_byte_start)?;
1091        if Self::is_html_block_in_markdown_container(ctx, tag_byte_start) {
1092            let (start, end) = ctx.html_block_range_at(tag_byte_start)?;
1093            // A single-line image or wrapper can retain its existing prefix.
1094            // Multi-line blocks and replacements need prefix reconstruction,
1095            // including a wrapper that would be stripped on a later pass.
1096            if ctx.content[start..end]
1097                .trim_end_matches([' ', '\t', '\r', '\n'])
1098                .contains(['\n', '\r'])
1099                || fix.1.contains(['\n', '\r'])
1100            {
1101                return None;
1102            }
1103        }
1104        Some(fix)
1105    }
1106
1107    fn calculate_fix_candidate(
1108        &self,
1109        ctx: &LintContext,
1110        closing_tags: &ClosingTagIndex,
1111        opening_tag: &str,
1112        tag_byte_start: usize,
1113    ) -> Option<(std::ops::Range<usize>, String)> {
1114        // Every fix below is opt-in.
1115        if !self.config.fix {
1116            return None;
1117        }
1118
1119        let content = ctx.content;
1120        // Extract tag name from opening tag
1121        let tag_name = opening_tag
1122            .trim_start_matches('<')
1123            .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
1124            .next()?
1125            .to_lowercase();
1126
1127        // Check if it's a self-closing tag (ends with /> or is a void element like <br>)
1128        let is_self_closing =
1129            opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
1130
1131        if is_self_closing {
1132            // When fix is enabled, try to convert to Markdown equivalent.
1133            // Skip tags inside HTML blocks (would break structure), UNLESS we
1134            // are in relaxed mode and the containing block is a strippable
1135            // wrapper -- this lets the inner element be converted first so the
1136            // wrapper can be stripped on a subsequent pass.
1137            let block_ok = !Self::is_nested_in_html_block(ctx, tag_byte_start, tag_byte_start + opening_tag.len())
1138                || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
1139            if self.config.fix
1140                && MD033Config::is_safe_fixable_tag(&tag_name)
1141                && block_ok
1142                && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
1143            {
1144                return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
1145            }
1146            // Can't convert this self-closing tag to Markdown, don't provide a fix
1147            // (e.g., <input>, <meta> - these have no Markdown equivalent without the new img support)
1148            return None;
1149        }
1150
1151        // The first closing tag after the opening tag (case-insensitive)
1152        let search_start = tag_byte_start + opening_tag.len();
1153        if let Some(closing_byte_start) = closing_tags.first_at_or_after(&tag_name, search_start) {
1154            let closing_byte_end = closing_byte_start + "</>".len() + tag_name.len();
1155
1156            // Extract the content between tags
1157            let inner_content = &content[search_start..closing_byte_start];
1158
1159            // In relaxed mode, check wrapper stripping before is_nested_in_html_block's
1160            // guard because the wrapper element itself IS the HTML block. We only
1161            // strip when:
1162            //  - the wrapper is not nested inside another HTML element
1163            //  - the inner content no longer contains HTML tags (prevents
1164            //    overlapping byte-range replacements within a single fix pass)
1165            if self.config.fix && self.is_strippable_wrapper(&tag_name) {
1166                if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1167                    return None;
1168                }
1169                if inner_content.contains('<') {
1170                    return None;
1171                }
1172                return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
1173            }
1174
1175            // Skip auto-fix if inside an HTML block (like <pre>, <div>, etc.)
1176            // Converting tags inside HTML blocks would break the intended structure
1177            if Self::is_nested_in_html_block(ctx, tag_byte_start, closing_byte_end) {
1178                return None;
1179            }
1180            let owns_block = ctx.html_block_range_at(tag_byte_start).is_some();
1181            let boundary_text = inner_content.trim_matches([' ', '\t', '\r']);
1182            if !owns_block && (boundary_text.starts_with('\n') || boundary_text.ends_with('\n')) {
1183                return None;
1184            }
1185            // Block boundary line breaks must not be copied into emphasis delimiters.
1186            let markdown_inner = if owns_block {
1187                inner_content.trim_matches([' ', '\t', '\r', '\n'])
1188            } else {
1189                inner_content
1190            };
1191
1192            // Skip auto-fix if this tag is nested inside another HTML element
1193            // e.g., <code><em>text</em></code> - don't convert the inner <em>
1194            if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1195                return None;
1196            }
1197
1198            // When fix is enabled and tag is safe to convert, try markdown conversion
1199            if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
1200                // Handle <a> tags specially - they require attribute extraction
1201                if tag_name == "a" {
1202                    if let Some(markdown) = self.convert_a_to_markdown(opening_tag, markdown_inner) {
1203                        if owns_block && !Self::block_conversion_preserves_text(&markdown, &tag_name, markdown_inner) {
1204                            return None;
1205                        }
1206                        return Some((tag_byte_start..closing_byte_end, markdown));
1207                    }
1208                    // convert_a_to_markdown returned None - unsafe URL, nested HTML, etc.
1209                    return None;
1210                }
1211
1212                // For simple tags (em, strong, code, etc.) - no attributes allowed
1213                if Self::has_significant_attributes(opening_tag) {
1214                    // Don't provide a fix for tags with attributes
1215                    // User may want to keep the attributes, so leave as-is
1216                    return None;
1217                }
1218                if let Some(markdown) = Self::convert_to_markdown(&tag_name, markdown_inner) {
1219                    if owns_block && !Self::block_conversion_preserves_text(&markdown, &tag_name, markdown_inner) {
1220                        return None;
1221                    }
1222                    return Some((tag_byte_start..closing_byte_end, markdown));
1223                }
1224                // convert_to_markdown returned None, meaning content has nested tags or
1225                // HTML entities that shouldn't be converted - leave as-is
1226                return None;
1227            }
1228
1229            // For non-fixable tags, don't provide a fix
1230            // (e.g., <div>content</div>, <span>text</span>)
1231            return None;
1232        }
1233
1234        // If no closing tag found, don't provide a fix (malformed HTML)
1235        None
1236    }
1237}
1238
1239impl Rule for MD033NoInlineHtml {
1240    fn name(&self) -> &'static str {
1241        "MD033"
1242    }
1243
1244    fn description(&self) -> &'static str {
1245        "Inline HTML is not allowed"
1246    }
1247
1248    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1249        let content = ctx.content;
1250
1251        // Early return: if no HTML tags at all, skip processing
1252        if content.is_empty() || !ctx.likely_has_html() {
1253            return Ok(Vec::new());
1254        }
1255
1256        // Quick check for HTML tag pattern before expensive processing
1257        if !HTML_TAG_QUICK_CHECK.is_match(content) {
1258            return Ok(Vec::new());
1259        }
1260
1261        let mut warnings = Vec::new();
1262
1263        // Use centralized HTML parser to get all HTML tags (including multiline)
1264        let html_tags = ctx.html_tags();
1265
1266        // Disallowed-only mode is a denylist, which the allowlists take no part in.
1267        let allowed_inside = AllowedInsideIndex::new(if self.is_disallowed_mode() {
1268            Vec::new()
1269        } else {
1270            self.allowed_inside_ranges(ctx)
1271        });
1272
1273        let closing_tags = if self.config.fix {
1274            ClosingTagIndex::new(content)
1275        } else {
1276            ClosingTagIndex::default()
1277        };
1278
1279        for html_tag in html_tags.iter() {
1280            // Skip closing tags (only warn on opening tags)
1281            if html_tag.is_closing {
1282                continue;
1283            }
1284
1285            let line_num = html_tag.line;
1286            let tag_byte_start = html_tag.byte_offset;
1287
1288            // Reconstruct tag string from byte offsets
1289            let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1290
1291            // Skip tags whose text is not markup: code and math blocks, PyMdown
1292            // blocks, block IALs, front matter, comments, code spans, and the titles
1293            // of link reference definitions.
1294            if Self::is_inert_markup(ctx, html_tag) {
1295                continue;
1296            }
1297
1298            // Skip HTML comments themselves
1299            if self.is_html_comment(tag) {
1300                continue;
1301            }
1302
1303            // Skip JSX components in MDX files (e.g., <Chart />, <MyComponent>)
1304            if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1305                continue;
1306            }
1307
1308            // Skip JSX fragments in MDX files (<> and </>)
1309            if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1310                continue;
1311            }
1312
1313            // Skip elements with JSX-specific attributes in MDX files
1314            // e.g., <div className="...">, <button onClick={handler}>
1315            if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1316                continue;
1317            }
1318
1319            // Skip non-HTML elements (placeholder syntax like <NAME>, <resource>)
1320            if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1321                continue;
1322            }
1323
1324            // Skip likely programming type annotations
1325            if self.is_likely_type_annotation(tag) {
1326                continue;
1327            }
1328
1329            // Skip email addresses in angle brackets
1330            if self.is_email_address(tag) {
1331                continue;
1332            }
1333
1334            // Skip URLs in angle brackets
1335            if self.is_url_in_angle_brackets(tag) {
1336                continue;
1337            }
1338
1339            // Determine whether to report this tag based on mode:
1340            // - Disallowed mode: only report tags in the disallowed list
1341            // - Default mode: report all tags except those in the allowed list or
1342            //   inside an element named by `allowed_inside`, with `table_allowed`
1343            //   taking precedence inside GFM table cells.
1344            if self.is_disallowed_mode() {
1345                if !self.is_tag_disallowed(tag) {
1346                    continue;
1347                }
1348            } else if ctx.is_in_table_block(line_num) {
1349                // An explicit table allowlist is the whole answer for a table cell,
1350                // so it decides there instead of the surrounding element.
1351                let inside_allowed_element =
1352                    self.config.table_allowed_elements.is_none() && allowed_inside.contains(tag_byte_start);
1353                if self.is_tag_allowed_in_table(tag, ctx.flavor) || inside_allowed_element {
1354                    continue;
1355                }
1356            } else if self.is_tag_allowed(tag, ctx.flavor) || allowed_inside.contains(tag_byte_start) {
1357                continue;
1358            }
1359
1360            // Skip tags with markdown attribute in MkDocs mode
1361            if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1362                continue;
1363            }
1364
1365            // Calculate fix to remove HTML tags but keep content
1366            let fix = self
1367                .calculate_fix(ctx, &closing_tags, tag, tag_byte_start)
1368                .map(|(range, replacement)| Fix::new(range, replacement));
1369
1370            // Advance from the known start instead of rescanning the line
1371            // prefix for every warning. Columns count Unicode scalar values;
1372            // only LF advances a source line, including within CRLF.
1373            let mut end_line = line_num;
1374            let mut end_column = html_tag.start_col + 1;
1375            for ch in tag.chars() {
1376                if ch == '\n' {
1377                    end_line += 1;
1378                    end_column = 1;
1379                } else {
1380                    end_column += 1;
1381                }
1382            }
1383
1384            // Report the HTML tag
1385            warnings.push(LintWarning {
1386                rule_name: Some(self.name().to_string()),
1387                line: line_num,
1388                column: html_tag.start_col + 1, // Convert to 1-indexed
1389                end_line,
1390                end_column,
1391                message: format!("Inline HTML found: {tag}"),
1392                severity: Severity::Warning,
1393                fix,
1394            });
1395        }
1396
1397        Ok(warnings)
1398    }
1399
1400    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1401        // Auto-fix is opt-in: only apply if explicitly enabled in config
1402        if !self.config.fix {
1403            return Ok(ctx.content.to_string());
1404        }
1405
1406        // Get warnings with their inline fixes
1407        let warnings = self.check(ctx)?;
1408        let warnings =
1409            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1410
1411        // If no warnings with fixes, return original content
1412        if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1413            return Ok(ctx.content.to_string());
1414        }
1415
1416        // Collect all fixes and sort by range start (descending) to apply from end to beginning
1417        let mut fixes: Vec<_> = warnings
1418            .iter()
1419            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1420            .collect();
1421        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1422
1423        // Apply fixes from end to beginning to preserve byte offsets
1424        let mut result = ctx.content.to_string();
1425        for (start, end, replacement) in fixes {
1426            if start < result.len() && end <= result.len() && start <= end {
1427                result.replace_range(start..end, replacement);
1428            }
1429        }
1430
1431        Ok(result)
1432    }
1433
1434    fn fix_capability(&self) -> crate::rule::FixCapability {
1435        if self.config.fix {
1436            crate::rule::FixCapability::FullyFixable
1437        } else {
1438            crate::rule::FixCapability::Unfixable
1439        }
1440    }
1441
1442    /// Get the category of this rule for selective processing
1443    fn category(&self) -> RuleCategory {
1444        RuleCategory::Html
1445    }
1446
1447    /// Check if this rule should be skipped
1448    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1449        ctx.content.is_empty() || !ctx.likely_has_html()
1450    }
1451
1452    fn as_any(&self) -> &dyn std::any::Any {
1453        self
1454    }
1455
1456    crate::impl_rule_config_methods!(MD033Config);
1457
1458    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1459        let mut aliases = std::collections::HashMap::new();
1460        // Shorthand aliases for allowed-elements/disallowed-elements
1461        aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1462        aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1463        // Documented in docs/md033.md; kept in step with the `alias` attributes on
1464        // `MD033Config::table_allowed_elements`.
1465        aliases.insert("table-allowed".to_string(), "table-allowed-elements".to_string());
1466        aliases.insert("table_allowed".to_string(), "table-allowed-elements".to_string());
1467        Some(aliases)
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use crate::lint_context::LintContext;
1475    use crate::rule::Rule;
1476
1477    #[test]
1478    fn allowed_inside_index_preserves_nested_ranges_and_boundaries() {
1479        for ranges in [
1480            vec![],
1481            vec![5..20, 8..10, 0..2, 5..7, 20..22, 30..40],
1482            vec![0..usize::MAX, 10..11],
1483        ] {
1484            let index = AllowedInsideIndex::new(ranges.clone());
1485            for offset in (0..=45).chain([usize::MAX - 1, usize::MAX]) {
1486                assert_eq!(
1487                    index.contains(offset),
1488                    ranges.iter().any(|range| range.contains(&offset)),
1489                    "offset {offset}, ranges {ranges:?}"
1490                );
1491            }
1492        }
1493    }
1494
1495    proptest::proptest! {
1496        #![proptest_config(proptest::test_runner::Config::with_cases(128))]
1497        #[test]
1498        fn allowed_inside_index_matches_linear_search(
1499            endpoints in proptest::collection::vec((0_usize..256, 0_usize..256), 0..80)
1500        ) {
1501            let ranges: Vec<_> = endpoints.into_iter().map(|(a, b)| a.min(b)..a.max(b)).collect();
1502            let index = AllowedInsideIndex::new(ranges.clone());
1503            for offset in 0..=256 {
1504                proptest::prop_assert_eq!(
1505                    index.contains(offset),
1506                    ranges.iter().any(|range| range.contains(&offset))
1507                );
1508            }
1509        }
1510    }
1511
1512    #[test]
1513    fn closing_tag_index_finds_what_a_case_insensitive_search_finds() {
1514        let documents = [
1515            "<em>a</em> b </EM> </Em>",
1516            "</</em> <//em> </ em> </em > </em/> </>",
1517            "x</em",
1518            "</em></em></strong></em>",
1519            "</span-x></span></span:y>",
1520            "</é></É></em>",
1521            "",
1522        ];
1523        for content in documents {
1524            let index = ClosingTagIndex::new(content);
1525            for tag_name in ["em", "strong", "span", "span-x", "span:y", "é"] {
1526                let needle = format!("</{tag_name}>");
1527                let lowered = content.to_ascii_lowercase();
1528                for offset in 0..=content.len() {
1529                    if !content.is_char_boundary(offset) {
1530                        continue;
1531                    }
1532                    let expected = lowered[offset..].find(&needle).map(|pos| offset + pos);
1533                    assert_eq!(
1534                        index.first_at_or_after(tag_name, offset),
1535                        expected,
1536                        "{tag_name:?} from {offset} in {content:?}"
1537                    );
1538                }
1539            }
1540        }
1541    }
1542
1543    #[test]
1544    fn diagnostic_ends_match_source_positions() {
1545        // Include source characters with different UTF-8 widths, combining
1546        // marks, tabs, and a lone CR (only LF advances the source line).
1547        let prefix = "é日本🙂e\u{301}\t\r".repeat(80);
1548        for newline in ["\n", "\r\n"] {
1549            for body in [
1550                format!("{prefix}<span title=\"é > 🙂\">x</span> <img src=\"日本\"/>"),
1551                format!("{prefix}<span\n title=\"é >\n🙂\">x</span> <br/>"),
1552                format!("{prefix}<span title=\"é\r🙂\">x</span>"),
1553                format!("<span title=\"first\n{prefix}last\">x</span>"),
1554            ] {
1555                let content = body.replace('\n', newline);
1556                for flavor in crate::format_proof::ALL_FLAVORS {
1557                    let ctx = LintContext::new(&content, flavor, None);
1558                    let warnings = MD033NoInlineHtml::default().check(&ctx).unwrap();
1559                    let tags = ctx.html_tags();
1560                    assert!(!warnings.is_empty(), "{flavor:?}: {content:?}");
1561                    for warning in warnings {
1562                        let tag = tags
1563                            .iter()
1564                            .find(|tag| tag.line == warning.line && tag.start_col + 1 == warning.column)
1565                            .unwrap();
1566                        // Independent oracle: calculate the legacy position
1567                        // from the full source rather than from the tag.
1568                        let (end_line, end_col) = ctx.offset_to_line_col(tag.byte_end - 1);
1569                        assert_eq!(
1570                            (warning.end_line, warning.end_column),
1571                            (end_line, end_col + 1),
1572                            "{flavor:?}: {content:?}"
1573                        );
1574                    }
1575                }
1576            }
1577        }
1578    }
1579
1580    #[test]
1581    fn check_without_fix_enabled_offers_no_fixes() {
1582        let content = "Text <em>x</em> and <br/> and <p>y</p>\n";
1583        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584        let warnings = MD033NoInlineHtml::default().check(&ctx).unwrap();
1585        assert!(!warnings.is_empty());
1586        assert!(warnings.iter().all(|w| w.fix.is_none()));
1587    }
1588
1589    fn relaxed_fix_rule() -> MD033NoInlineHtml {
1590        let config = MD033Config {
1591            fix: true,
1592            fix_mode: MD033FixMode::Relaxed,
1593            ..MD033Config::default()
1594        };
1595        MD033NoInlineHtml::from_config_struct(config)
1596    }
1597
1598    #[test]
1599    fn test_md033_basic_html() {
1600        let rule = MD033NoInlineHtml::default();
1601        let content = "<div>Some content</div>";
1602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603        let result = rule.check(&ctx).unwrap();
1604        // Only reports opening tags, not closing tags
1605        assert_eq!(result.len(), 1); // Only <div>, not </div>
1606        assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1607    }
1608
1609    #[test]
1610    fn test_md033_ignores_backslash_escaped_custom_element() {
1611        let rule = MD033NoInlineHtml::default();
1612        let content = r"Press \<x-keyboard> to continue, but flag <x-keyboard>.";
1613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614
1615        let result = rule.check(&ctx).unwrap();
1616        assert_eq!(result.len(), 1);
1617        assert_eq!(result[0].column, 43);
1618        assert_eq!(result[0].message, "Inline HTML found: <x-keyboard>");
1619    }
1620
1621    #[test]
1622    fn test_md033_front_matter() {
1623        let rule = MD033NoInlineHtml::default();
1624        let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626        let result = rule.check(&ctx).unwrap();
1627        // Should only report <div>body</div> (line 5), not <div class="test"> (line 2)
1628        assert_eq!(result.len(), 1);
1629        assert_eq!(result[0].line, 5);
1630        assert_eq!(result[0].message, "Inline HTML found: <div>");
1631    }
1632
1633    #[test]
1634    fn test_md033_math_block() {
1635        let rule = MD033NoInlineHtml::default();
1636        let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638        let result = rule.check(&ctx).unwrap();
1639        // Should only report <div>body</div> (line 4), not the fake tag <y> in math (line 2)
1640        assert_eq!(result.len(), 1);
1641        assert_eq!(result[0].line, 4);
1642    }
1643
1644    #[test]
1645    fn test_md033_case_insensitive() {
1646        let rule = MD033NoInlineHtml::default();
1647        let content = "<DiV>Some <B>content</B></dIv>";
1648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649        let result = rule.check(&ctx).unwrap();
1650        // Only reports opening tags, not closing tags
1651        assert_eq!(result.len(), 2); // <DiV>, <B> (not </B>, </dIv>)
1652        assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1653        assert_eq!(result[1].message, "Inline HTML found: <B>");
1654    }
1655
1656    #[test]
1657    fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1658        // A non-ASCII whitespace (U+00A0 NO-BREAK SPACE) before the attributes
1659        // must not cause a non-char-boundary slice panic while parsing attributes.
1660        let rule = relaxed_fix_rule();
1661        let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663        // check() and fix() both reach parse_attributes; neither may panic.
1664        let _ = rule.check(&ctx).unwrap();
1665        let _ = rule.fix(&ctx).unwrap();
1666    }
1667
1668    #[test]
1669    fn test_md033_allowed_tags() {
1670        let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1671        let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673        let result = rule.check(&ctx).unwrap();
1674        // Only warnings for non-allowed opening tags (<p> only, div and br are allowed)
1675        assert_eq!(result.len(), 1);
1676        assert_eq!(result[0].message, "Inline HTML found: <p>");
1677
1678        // Test case-insensitivity of allowed tags
1679        let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1680        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1681        let result2 = rule.check(&ctx2).unwrap();
1682        assert_eq!(result2.len(), 1); // Only <P> flagged
1683        assert_eq!(result2[0].message, "Inline HTML found: <P>");
1684    }
1685
1686    #[test]
1687    fn test_md033_html_comments() {
1688        let rule = MD033NoInlineHtml::default();
1689        let content = "<!-- This is a comment --> <p>Not a comment</p>";
1690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691        let result = rule.check(&ctx).unwrap();
1692        // Should detect warnings for HTML opening tags (comments are skipped, closing tags not reported)
1693        assert_eq!(result.len(), 1); // Only <p>
1694        assert_eq!(result[0].message, "Inline HTML found: <p>");
1695    }
1696
1697    #[test]
1698    fn test_md033_tags_in_links() {
1699        let rule = MD033NoInlineHtml::default();
1700        let content = "[Link](http://example.com/<div>)";
1701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702        let result = rule.check(&ctx).unwrap();
1703        // The <div> in the URL should be detected as HTML (not skipped)
1704        assert_eq!(result.len(), 1);
1705        assert_eq!(result[0].message, "Inline HTML found: <div>");
1706
1707        let content2 = "[Link <a>text</a>](url)";
1708        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1709        let result2 = rule.check(&ctx2).unwrap();
1710        // Only reports opening tags
1711        assert_eq!(result2.len(), 1); // Only <a>
1712        assert_eq!(result2[0].message, "Inline HTML found: <a>");
1713    }
1714
1715    #[test]
1716    fn test_md033_fix_escaping() {
1717        let rule = MD033NoInlineHtml::default();
1718        let content = "Text with <div> and <br/> tags.";
1719        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720        let fixed_content = rule.fix(&ctx).unwrap();
1721        // No fix for HTML tags; output should be unchanged
1722        assert_eq!(fixed_content, content);
1723    }
1724
1725    #[test]
1726    fn test_md033_in_code_blocks() {
1727        let rule = MD033NoInlineHtml::default();
1728        let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1730        let result = rule.check(&ctx).unwrap();
1731        // Only reports opening tags outside code block
1732        assert_eq!(result.len(), 1); // Only <div> outside code block
1733        assert_eq!(result[0].message, "Inline HTML found: <div>");
1734    }
1735
1736    #[test]
1737    fn test_md033_in_code_spans() {
1738        let rule = MD033NoInlineHtml::default();
1739        let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741        let result = rule.check(&ctx).unwrap();
1742        // Should detect <br/> outside code span, but not tags inside code span
1743        assert_eq!(result.len(), 1);
1744        assert_eq!(result[0].message, "Inline HTML found: <br/>");
1745    }
1746
1747    #[test]
1748    fn test_md033_issue_90_code_span_with_diff_block() {
1749        // Test for issue #90: inline code span followed by diff code block
1750        let rule = MD033NoInlineHtml::default();
1751        let content = r#"# Heading
1752
1753`<env>`
1754
1755```diff
1756- this
1757+ that
1758```"#;
1759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760        let result = rule.check(&ctx).unwrap();
1761        // Should NOT detect <env> as HTML since it's inside backticks
1762        assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1763    }
1764
1765    #[test]
1766    fn test_md033_multiple_code_spans_with_angle_brackets() {
1767        // Test multiple code spans on same line
1768        let rule = MD033NoInlineHtml::default();
1769        let content = "`<one>` and `<two>` and `<three>` are all code spans";
1770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771        let result = rule.check(&ctx).unwrap();
1772        assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1773    }
1774
1775    #[test]
1776    fn test_md033_nested_angle_brackets_in_code_span() {
1777        // Test nested angle brackets
1778        let rule = MD033NoInlineHtml::default();
1779        let content = "Text with `<<nested>>` brackets";
1780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781        let result = rule.check(&ctx).unwrap();
1782        assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1783    }
1784
1785    #[test]
1786    fn test_md033_code_span_at_end_before_code_block() {
1787        // Test code span at end of line before code block
1788        let rule = MD033NoInlineHtml::default();
1789        let content = "Testing `<test>`\n```\ncode here\n```";
1790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791        let result = rule.check(&ctx).unwrap();
1792        assert_eq!(result.len(), 0, "Should handle code span before code block");
1793    }
1794
1795    #[test]
1796    fn test_md033_quick_fix_inline_tag() {
1797        // Test that non-fixable tags (like <span>) do NOT get a fix
1798        // Only safe fixable tags (em, i, strong, b, code, br, hr) with fix=true get fixes
1799        let rule = MD033NoInlineHtml::default();
1800        let content = "This has <span>inline text</span> that should keep content.";
1801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802        let result = rule.check(&ctx).unwrap();
1803
1804        assert_eq!(result.len(), 1, "Should find one HTML tag");
1805        // <span> is NOT a safe fixable tag, so no fix should be provided
1806        assert!(
1807            result[0].fix.is_none(),
1808            "Non-fixable tags like <span> should not have a fix"
1809        );
1810    }
1811
1812    #[test]
1813    fn test_md033_quick_fix_multiline_tag() {
1814        // HTML block elements like <div> are intentionally NOT auto-fixed
1815        // Removing them would change document structure significantly
1816        let rule = MD033NoInlineHtml::default();
1817        let content = "<div>\nBlock content\n</div>";
1818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819        let result = rule.check(&ctx).unwrap();
1820
1821        assert_eq!(result.len(), 1, "Should find one HTML tag");
1822        // HTML block elements should NOT have auto-fix
1823        assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1824    }
1825
1826    #[test]
1827    fn test_md033_quick_fix_self_closing_tag() {
1828        // Test that self-closing tags with fix=false (default) do NOT get a fix
1829        let rule = MD033NoInlineHtml::default();
1830        let content = "Self-closing: <br/>";
1831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832        let result = rule.check(&ctx).unwrap();
1833
1834        assert_eq!(result.len(), 1, "Should find one HTML tag");
1835        // Default config has fix=false, so no fix should be provided
1836        assert!(
1837            result[0].fix.is_none(),
1838            "Self-closing tags should not have a fix when fix config is false"
1839        );
1840    }
1841
1842    #[test]
1843    fn test_md033_quick_fix_multiple_tags() {
1844        // Test that multiple tags without fix=true do NOT get fixes
1845        // <span> is not a safe fixable tag, <strong> is but fix=false by default
1846        let rule = MD033NoInlineHtml::default();
1847        let content = "<span>first</span> and <strong>second</strong>";
1848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1849        let result = rule.check(&ctx).unwrap();
1850
1851        assert_eq!(result.len(), 2, "Should find two HTML tags");
1852        // Neither should have a fix: <span> is not fixable, <strong> is but fix=false
1853        assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1854        assert!(
1855            result[1].fix.is_none(),
1856            "<strong> should not have a fix when fix config is false"
1857        );
1858    }
1859
1860    #[test]
1861    fn test_md033_skip_angle_brackets_in_link_titles() {
1862        // Angle brackets inside link reference definition titles should not be flagged as HTML
1863        let rule = MD033NoInlineHtml::default();
1864        let content = r#"# Test
1865
1866[example]: <https://example.com> "Title with <Angle Brackets> inside"
1867
1868Regular text with <div>content</div> HTML tag.
1869"#;
1870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871        let result = rule.check(&ctx).unwrap();
1872
1873        // Should only flag <div>, not <Angle Brackets> in the title (not a valid HTML element)
1874        // Opening tag only (markdownlint behavior)
1875        assert_eq!(result.len(), 1, "Should find opening div tag");
1876        assert!(
1877            result[0].message.contains("<div>"),
1878            "Should flag <div>, got: {}",
1879            result[0].message
1880        );
1881    }
1882
1883    #[test]
1884    fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1885        // Test with single-quoted title
1886        let rule = MD033NoInlineHtml::default();
1887        let content = r#"[ref]: url 'Title <Help Wanted> here'
1888
1889<span>text</span> here
1890"#;
1891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892        let result = rule.check(&ctx).unwrap();
1893
1894        // <Help Wanted> is not a valid HTML element, so only <span> is flagged
1895        // Opening tag only (markdownlint behavior)
1896        assert_eq!(result.len(), 1, "Should find opening span tag");
1897        assert!(
1898            result[0].message.contains("<span>"),
1899            "Should flag <span>, got: {}",
1900            result[0].message
1901        );
1902    }
1903
1904    #[test]
1905    fn test_md033_multiline_tag_end_line_calculation() {
1906        // Test that multiline HTML tags report correct end_line
1907        let rule = MD033NoInlineHtml::default();
1908        let content = "<div\n  class=\"test\"\n  id=\"example\">";
1909        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910        let result = rule.check(&ctx).unwrap();
1911
1912        assert_eq!(result.len(), 1, "Should find one HTML tag");
1913        // Tag starts on line 1
1914        assert_eq!(result[0].line, 1, "Start line should be 1");
1915        // Tag ends on line 3 (where the closing > is)
1916        assert_eq!(result[0].end_line, 3, "End line should be 3");
1917    }
1918
1919    #[test]
1920    fn test_md033_single_line_tag_same_start_end_line() {
1921        // Test that single-line HTML tags have same start and end line
1922        let rule = MD033NoInlineHtml::default();
1923        let content = "Some text <div class=\"test\"> more text";
1924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925        let result = rule.check(&ctx).unwrap();
1926
1927        assert_eq!(result.len(), 1, "Should find one HTML tag");
1928        assert_eq!(result[0].line, 1, "Start line should be 1");
1929        assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1930    }
1931
1932    #[test]
1933    fn test_md033_multiline_tag_with_many_attributes() {
1934        // Test multiline tag spanning multiple lines
1935        let rule = MD033NoInlineHtml::default();
1936        let content =
1937            "Text\n<div\n  data-attr1=\"value1\"\n  data-attr2=\"value2\"\n  data-attr3=\"value3\">\nMore text";
1938        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1939        let result = rule.check(&ctx).unwrap();
1940
1941        assert_eq!(result.len(), 1, "Should find one HTML tag");
1942        // Tag starts on line 2 (first line is "Text")
1943        assert_eq!(result[0].line, 2, "Start line should be 2");
1944        // Tag ends on line 5 (where the closing > is)
1945        assert_eq!(result[0].end_line, 5, "End line should be 5");
1946    }
1947
1948    #[test]
1949    fn test_md033_disallowed_mode_basic() {
1950        // Test disallowed mode: only flags tags in the disallowed list
1951        let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1952        let content = "<div>Safe content</div><script>alert('xss')</script>";
1953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954        let result = rule.check(&ctx).unwrap();
1955
1956        // Should only flag <script>, not <div>
1957        assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1958        assert!(result[0].message.contains("<script>"), "Should flag script tag");
1959    }
1960
1961    #[test]
1962    fn test_md033_disallowed_gfm_security_tags() {
1963        // Test GFM security tags expansion
1964        let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1965        let content = r#"
1966<div>Safe</div>
1967<title>Bad title</title>
1968<textarea>Bad textarea</textarea>
1969<style>.bad{}</style>
1970<iframe src="evil"></iframe>
1971<script>evil()</script>
1972<plaintext>old tag</plaintext>
1973<span>Safe span</span>
1974"#;
1975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976        let result = rule.check(&ctx).unwrap();
1977
1978        // Should flag: title, textarea, style, iframe, script, plaintext
1979        // Should NOT flag: div, span
1980        assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1981
1982        let flagged_tags: Vec<&str> = result
1983            .iter()
1984            .filter_map(|w| w.message.split('<').nth(1))
1985            .filter_map(|s| s.split('>').next())
1986            .filter_map(|s| s.split_whitespace().next())
1987            .collect();
1988
1989        assert!(flagged_tags.contains(&"title"), "Should flag title");
1990        assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1991        assert!(flagged_tags.contains(&"style"), "Should flag style");
1992        assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1993        assert!(flagged_tags.contains(&"script"), "Should flag script");
1994        assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1995        assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1996        assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1997    }
1998
1999    #[test]
2000    fn test_md033_disallowed_case_insensitive() {
2001        // Test that disallowed check is case-insensitive
2002        let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
2003        let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
2004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005        let result = rule.check(&ctx).unwrap();
2006
2007        // Should flag both <SCRIPT> and <Script>
2008        assert_eq!(result.len(), 2, "Should flag both case variants");
2009    }
2010
2011    #[test]
2012    fn test_md033_disallowed_with_attributes() {
2013        // Test that disallowed mode works with tags that have attributes
2014        let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
2015        let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
2016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2017        let result = rule.check(&ctx).unwrap();
2018
2019        assert_eq!(result.len(), 1, "Should flag iframe with attributes");
2020        assert!(result[0].message.contains("iframe"), "Should flag iframe");
2021    }
2022
2023    #[test]
2024    fn test_md033_disallowed_all_gfm_tags() {
2025        // Verify all GFM disallowed tags are covered
2026        use md033_config::GFM_DISALLOWED_TAGS;
2027        let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
2028
2029        for tag in GFM_DISALLOWED_TAGS {
2030            let content = format!("<{tag}>content</{tag}>");
2031            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2032            let result = rule.check(&ctx).unwrap();
2033
2034            assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
2035        }
2036    }
2037
2038    #[test]
2039    fn test_md033_disallowed_mixed_with_custom() {
2040        // Test mixing "gfm" with custom disallowed tags
2041        let rule = MD033NoInlineHtml::with_disallowed(vec![
2042            "gfm".to_string(),
2043            "marquee".to_string(), // Custom disallowed tag
2044        ]);
2045        let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
2046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047        let result = rule.check(&ctx).unwrap();
2048
2049        // Should flag script (gfm) and marquee (custom)
2050        assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
2051    }
2052
2053    #[test]
2054    fn test_md033_disallowed_empty_means_default_mode() {
2055        // Empty disallowed list means default mode (flag all HTML)
2056        let rule = MD033NoInlineHtml::with_disallowed(vec![]);
2057        let content = "<div>content</div>";
2058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059        let result = rule.check(&ctx).unwrap();
2060
2061        // Should flag <div> in default mode
2062        assert_eq!(result.len(), 1, "Empty disallowed = default mode");
2063    }
2064
2065    #[test]
2066    fn test_md033_jsx_fragments_in_mdx() {
2067        // JSX fragments (<> and </>) should not trigger warnings in MDX
2068        let rule = MD033NoInlineHtml::default();
2069        let content = r#"# MDX Document
2070
2071<>
2072  <Heading />
2073  <Content />
2074</>
2075
2076<div>Regular HTML should still be flagged</div>
2077"#;
2078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2079        let result = rule.check(&ctx).unwrap();
2080
2081        // Should only flag <div>, not the fragments or JSX components
2082        assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
2083        assert!(
2084            result[0].message.contains("<div>"),
2085            "Should flag <div>, not JSX fragments"
2086        );
2087    }
2088
2089    #[test]
2090    fn test_md033_jsx_components_in_mdx() {
2091        // JSX components (capitalized) should not trigger warnings in MDX
2092        let rule = MD033NoInlineHtml::default();
2093        let content = r#"<CustomComponent prop="value">
2094  Content
2095</CustomComponent>
2096
2097<MyButton onClick={handler}>Click</MyButton>
2098"#;
2099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2100        let result = rule.check(&ctx).unwrap();
2101
2102        // No warnings - all are JSX components
2103        assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
2104    }
2105
2106    #[test]
2107    fn test_md033_jsx_not_skipped_in_standard_markdown() {
2108        // In standard markdown, capitalized tags should still be flagged if they're valid HTML
2109        let rule = MD033NoInlineHtml::default();
2110        let content = "<Script>alert(1)</Script>";
2111        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112        let result = rule.check(&ctx).unwrap();
2113
2114        // Should flag <Script> in standard markdown (it's a valid HTML element)
2115        assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
2116    }
2117
2118    #[test]
2119    fn test_md033_jsx_attributes_in_mdx() {
2120        // Elements with JSX-specific attributes should not trigger warnings in MDX
2121        let rule = MD033NoInlineHtml::default();
2122        let content = r#"# MDX with JSX Attributes
2123
2124<div className="card big">Content</div>
2125
2126<button onClick={handleClick}>Click me</button>
2127
2128<label htmlFor="input-id">Label</label>
2129
2130<input onChange={handleChange} />
2131
2132<div class="html-class">Regular HTML should be flagged</div>
2133"#;
2134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2135        let result = rule.check(&ctx).unwrap();
2136
2137        // Should only flag the div with regular HTML "class" attribute
2138        assert_eq!(
2139            result.len(),
2140            1,
2141            "Should only flag HTML element without JSX attributes, got: {result:?}"
2142        );
2143        assert!(
2144            result[0].message.contains("<div class="),
2145            "Should flag the div with HTML class attribute"
2146        );
2147    }
2148
2149    #[test]
2150    fn test_md033_jsx_attributes_not_skipped_in_standard() {
2151        // In standard markdown, JSX attributes should still be flagged
2152        let rule = MD033NoInlineHtml::default();
2153        let content = r#"<div className="card">Content</div>"#;
2154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155        let result = rule.check(&ctx).unwrap();
2156
2157        // Should flag in standard markdown
2158        assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
2159    }
2160
2161    // Auto-fix tests for MD033
2162
2163    #[test]
2164    fn test_md033_fix_disabled_by_default() {
2165        // Auto-fix should be disabled by default
2166        let rule = MD033NoInlineHtml::default();
2167        assert!(!rule.config.fix, "Fix should be disabled by default");
2168        assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
2169    }
2170
2171    #[test]
2172    fn test_md033_fix_enabled_em_to_italic() {
2173        // When fix is enabled, <em>text</em> should convert to *text*
2174        let rule = MD033NoInlineHtml::with_fix(true);
2175        let content = "This has <em>emphasized text</em> here.";
2176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177        let fixed = rule.fix(&ctx).unwrap();
2178        assert_eq!(fixed, "This has *emphasized text* here.");
2179    }
2180
2181    #[test]
2182    fn test_md033_fix_enabled_i_to_italic() {
2183        // <i>text</i> should convert to *text*
2184        let rule = MD033NoInlineHtml::with_fix(true);
2185        let content = "This has <i>italic text</i> here.";
2186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2187        let fixed = rule.fix(&ctx).unwrap();
2188        assert_eq!(fixed, "This has *italic text* here.");
2189    }
2190
2191    #[test]
2192    fn test_md033_fix_enabled_strong_to_bold() {
2193        // <strong>text</strong> should convert to **text**
2194        let rule = MD033NoInlineHtml::with_fix(true);
2195        let content = "This has <strong>bold text</strong> here.";
2196        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2197        let fixed = rule.fix(&ctx).unwrap();
2198        assert_eq!(fixed, "This has **bold text** here.");
2199    }
2200
2201    #[test]
2202    fn test_md033_fix_enabled_b_to_bold() {
2203        // <b>text</b> should convert to **text**
2204        let rule = MD033NoInlineHtml::with_fix(true);
2205        let content = "This has <b>bold text</b> here.";
2206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2207        let fixed = rule.fix(&ctx).unwrap();
2208        assert_eq!(fixed, "This has **bold text** here.");
2209    }
2210
2211    #[test]
2212    fn test_md033_fix_enabled_code_to_backticks() {
2213        // <code>text</code> should convert to `text`
2214        let rule = MD033NoInlineHtml::with_fix(true);
2215        let content = "This has <code>inline code</code> here.";
2216        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217        let fixed = rule.fix(&ctx).unwrap();
2218        assert_eq!(fixed, "This has `inline code` here.");
2219    }
2220
2221    #[test]
2222    fn test_md033_fix_enabled_code_with_backticks() {
2223        // <code>text with `backticks`</code> should use double backticks
2224        let rule = MD033NoInlineHtml::with_fix(true);
2225        let content = "This has <code>text with `backticks`</code> here.";
2226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2227        let fixed = rule.fix(&ctx).unwrap();
2228        assert_eq!(fixed, "This has `` text with `backticks` `` here.");
2229    }
2230
2231    #[test]
2232    fn test_md033_fix_enabled_br_trailing_spaces() {
2233        // <br> should convert to two trailing spaces + newline (default)
2234        let rule = MD033NoInlineHtml::with_fix(true);
2235        let content = "First line<br>Second line";
2236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2237        let fixed = rule.fix(&ctx).unwrap();
2238        assert_eq!(fixed, "First line  \nSecond line");
2239    }
2240
2241    #[test]
2242    fn test_md033_fix_enabled_br_self_closing() {
2243        // <br/> and <br /> should also convert
2244        let rule = MD033NoInlineHtml::with_fix(true);
2245        let content = "First<br/>second<br />third";
2246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2247        let fixed = rule.fix(&ctx).unwrap();
2248        assert_eq!(fixed, "First  \nsecond  \nthird");
2249    }
2250
2251    #[test]
2252    fn test_md033_fix_enabled_br_backslash_style() {
2253        // With br_style = backslash, <br> should convert to backslash + newline
2254        let config = MD033Config {
2255            allowed: Vec::new(),
2256            disallowed: Vec::new(),
2257            fix: true,
2258            br_style: md033_config::BrStyle::Backslash,
2259            ..MD033Config::default()
2260        };
2261        let rule = MD033NoInlineHtml::from_config_struct(config);
2262        let content = "First line<br>Second line";
2263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264        let fixed = rule.fix(&ctx).unwrap();
2265        assert_eq!(fixed, "First line\\\nSecond line");
2266    }
2267
2268    #[test]
2269    fn test_md033_fix_enabled_hr() {
2270        // <hr> should convert to horizontal rule
2271        let rule = MD033NoInlineHtml::with_fix(true);
2272        let content = "Above<hr>Below";
2273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274        let fixed = rule.fix(&ctx).unwrap();
2275        assert_eq!(fixed, "Above\n---\nBelow");
2276    }
2277
2278    #[test]
2279    fn test_md033_fix_enabled_hr_self_closing() {
2280        // <hr/> should also convert
2281        let rule = MD033NoInlineHtml::with_fix(true);
2282        let content = "Above<hr/>Below";
2283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284        let fixed = rule.fix(&ctx).unwrap();
2285        assert_eq!(fixed, "Above\n---\nBelow");
2286    }
2287
2288    #[test]
2289    fn test_md033_fix_skips_nested_tags() {
2290        // Tags with nested HTML - outer tags may not be fully fixed due to overlapping ranges
2291        // The inner tags are processed first, which can invalidate outer tag ranges
2292        let rule = MD033NoInlineHtml::with_fix(true);
2293        let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
2294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295        let fixed = rule.fix(&ctx).unwrap();
2296        // Inner <strong> is converted to markdown, outer <em> range becomes invalid
2297        // This is expected behavior - user should run fix multiple times for nested tags
2298        assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
2299    }
2300
2301    #[test]
2302    fn test_md033_fix_skips_tags_with_attributes() {
2303        // Tags with attributes should NOT be fixed at all - leave as-is
2304        // User may want to keep the attributes (e.g., class="highlight" for styling)
2305        let rule = MD033NoInlineHtml::with_fix(true);
2306        let content = "This has <em class=\"highlight\">emphasized</em> text.";
2307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308        let fixed = rule.fix(&ctx).unwrap();
2309        // Content should remain unchanged - we don't know if attributes matter
2310        assert_eq!(fixed, content);
2311    }
2312
2313    #[test]
2314    fn test_md033_fix_disabled_no_changes() {
2315        // When fix is disabled, original content should be returned
2316        let rule = MD033NoInlineHtml::default(); // fix is false by default
2317        let content = "This has <em>emphasized text</em> here.";
2318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2319        let fixed = rule.fix(&ctx).unwrap();
2320        assert_eq!(fixed, content, "Should return original content when fix is disabled");
2321    }
2322
2323    #[test]
2324    fn test_md033_fix_capability_enabled() {
2325        let rule = MD033NoInlineHtml::with_fix(true);
2326        assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
2327    }
2328
2329    #[test]
2330    fn test_md033_fix_multiple_tags() {
2331        // Test fixing multiple HTML tags in one document
2332        let rule = MD033NoInlineHtml::with_fix(true);
2333        let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
2334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335        let fixed = rule.fix(&ctx).unwrap();
2336        assert_eq!(fixed, "Here is *italic* and **bold** text.");
2337    }
2338
2339    #[test]
2340    fn test_md033_fix_uppercase_tags() {
2341        // HTML tags are case-insensitive
2342        let rule = MD033NoInlineHtml::with_fix(true);
2343        let content = "This has <EM>emphasized</EM> text.";
2344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345        let fixed = rule.fix(&ctx).unwrap();
2346        assert_eq!(fixed, "This has *emphasized* text.");
2347    }
2348
2349    #[test]
2350    fn test_md033_fix_unsafe_tags_not_modified() {
2351        // Tags without safe markdown equivalents should NOT be modified
2352        // Only safe fixable tags (em, i, strong, b, code, br, hr) get converted
2353        let rule = MD033NoInlineHtml::with_fix(true);
2354        let content = "This has <div>a div</div> content.";
2355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2356        let fixed = rule.fix(&ctx).unwrap();
2357        // <div> is not a safe fixable tag, so content should be unchanged
2358        assert_eq!(fixed, "This has <div>a div</div> content.");
2359    }
2360
2361    #[test]
2362    fn test_md033_fix_img_tag_converted() {
2363        // <img> tags with simple src/alt attributes are converted to markdown images
2364        let rule = MD033NoInlineHtml::with_fix(true);
2365        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
2366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2367        let fixed = rule.fix(&ctx).unwrap();
2368        // <img> is converted to ![alt](src) format
2369        assert_eq!(fixed, "Image: ![My Photo](photo.jpg)");
2370    }
2371
2372    #[test]
2373    fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
2374        // <img> tags with width/height/style attributes are NOT converted
2375        let rule = MD033NoInlineHtml::with_fix(true);
2376        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378        let fixed = rule.fix(&ctx).unwrap();
2379        // Has width attribute - not safe to convert
2380        assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
2381    }
2382
2383    #[test]
2384    fn test_md033_fix_relaxed_a_with_target_is_converted() {
2385        let rule = relaxed_fix_rule();
2386        let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
2387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388        let fixed = rule.fix(&ctx).unwrap();
2389        assert_eq!(fixed, "Link: [Example](https://example.com)");
2390    }
2391
2392    #[test]
2393    fn test_md033_fix_relaxed_img_with_width_is_converted() {
2394        let rule = relaxed_fix_rule();
2395        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397        let fixed = rule.fix(&ctx).unwrap();
2398        assert_eq!(fixed, "Image: ![My Photo](photo.jpg)");
2399    }
2400
2401    #[test]
2402    fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2403        let rule = relaxed_fix_rule();
2404        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2406        let fixed = rule.fix(&ctx).unwrap();
2407        assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2408    }
2409
2410    #[test]
2411    fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2412        let rule = relaxed_fix_rule();
2413        let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415        let fixed = rule.fix(&ctx).unwrap();
2416        assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2417    }
2418
2419    #[test]
2420    fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2421        let rule = relaxed_fix_rule();
2422        let content = "<p align=\"center\">\n  <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2423        let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424        let fixed_once = rule.fix(&ctx1).unwrap();
2425        assert!(
2426            fixed_once.contains("<p"),
2427            "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2428        );
2429        assert!(
2430            fixed_once.contains("![Logo](logo.svg)"),
2431            "Inner image should be converted on first pass: {fixed_once}"
2432        );
2433
2434        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2435        let fixed_twice = rule.fix(&ctx2).unwrap();
2436        assert!(
2437            !fixed_twice.contains("<p"),
2438            "Second pass should strip configured wrapper: {fixed_twice}"
2439        );
2440        assert!(fixed_twice.contains("![Logo](logo.svg)"));
2441    }
2442
2443    #[test]
2444    fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2445        let rule = relaxed_fix_rule();
2446        let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448        let fixed = rule.fix(&ctx).unwrap();
2449        assert_eq!(fixed, "[Click](https://example.com)");
2450    }
2451
2452    #[test]
2453    fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2454        let rule = relaxed_fix_rule();
2455        let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2457        let fixed = rule.fix(&ctx).unwrap();
2458        assert_eq!(fixed, "![Logo](logo.png)");
2459    }
2460
2461    #[test]
2462    fn test_md033_fix_relaxed_event_handler_never_dropped() {
2463        let rule = relaxed_fix_rule();
2464        let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2466        let fixed = rule.fix(&ctx).unwrap();
2467        assert_eq!(fixed, content, "Event handler attributes must block conversion");
2468    }
2469
2470    #[test]
2471    fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2472        // Even if someone adds on* to drop-attributes, event handlers must be rejected
2473        let config = MD033Config {
2474            fix: true,
2475            fix_mode: MD033FixMode::Relaxed,
2476            drop_attributes: vec!["on*".to_string(), "target".to_string()],
2477            ..MD033Config::default()
2478        };
2479        let rule = MD033NoInlineHtml::from_config_struct(config);
2480        let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482        let fixed = rule.fix(&ctx).unwrap();
2483        assert_eq!(fixed, content, "on* event handlers must never be dropped");
2484    }
2485
2486    #[test]
2487    fn test_md033_fix_relaxed_custom_drop_attributes() {
2488        let config = MD033Config {
2489            fix: true,
2490            fix_mode: MD033FixMode::Relaxed,
2491            drop_attributes: vec!["loading".to_string()],
2492            ..MD033Config::default()
2493        };
2494        let rule = MD033NoInlineHtml::from_config_struct(config);
2495        // "loading" is in the custom list, "width" is NOT
2496        let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2498        let fixed = rule.fix(&ctx).unwrap();
2499        assert_eq!(fixed, "![](x.jpg)", "Custom drop-attributes should be respected");
2500
2501        let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2502        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2503        let fixed2 = rule.fix(&ctx2).unwrap();
2504        assert_eq!(
2505            fixed2, content2,
2506            "Attributes not in custom list should block conversion"
2507        );
2508    }
2509
2510    #[test]
2511    fn test_md033_fix_relaxed_custom_strip_wrapper() {
2512        let config = MD033Config {
2513            fix: true,
2514            fix_mode: MD033FixMode::Relaxed,
2515            strip_wrapper_elements: vec!["div".to_string()],
2516            ..MD033Config::default()
2517        };
2518        let rule = MD033NoInlineHtml::from_config_struct(config);
2519        let content = "<div>Some text content</div>";
2520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521        let fixed = rule.fix(&ctx).unwrap();
2522        assert_eq!(fixed, "Some text content");
2523    }
2524
2525    #[test]
2526    fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2527        let rule = relaxed_fix_rule();
2528        let content = "<p align=\"center\">Just some text</p>";
2529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2530        let fixed = rule.fix(&ctx).unwrap();
2531        assert_eq!(fixed, "Just some text");
2532    }
2533
2534    #[test]
2535    fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2536        let config = MD033Config {
2537            fix: true,
2538            fix_mode: MD033FixMode::Relaxed,
2539            drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2540            ..MD033Config::default()
2541        };
2542        let rule = MD033NoInlineHtml::from_config_struct(config);
2543        let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545        let fixed = rule.fix(&ctx).unwrap();
2546        assert_eq!(fixed, "[Link](https://example.com)");
2547    }
2548
2549    #[test]
2550    fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2551        let rule = relaxed_fix_rule();
2552        // "target" is droppable, "aria-label" is not in the default list
2553        let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555        let fixed = rule.fix(&ctx).unwrap();
2556        assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2557    }
2558
2559    #[test]
2560    fn test_md033_fix_relaxed_badge_pattern() {
2561        // Common GitHub README badge pattern
2562        let rule = relaxed_fix_rule();
2563        let content = "<a href=\"https://crates.io/crates/rumdl\" target=\"_blank\"><img src=\"https://img.shields.io/crates/v/rumdl.svg\" alt=\"Crate\" width=\"120\" /></a>";
2564        let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2565        let fixed_once = rule.fix(&ctx1).unwrap();
2566        // First pass should convert the inner <img>
2567        assert!(
2568            fixed_once.contains("![Crate](https://img.shields.io/crates/v/rumdl.svg)"),
2569            "Inner img should be converted: {fixed_once}"
2570        );
2571
2572        // Second pass converts the <a> wrapper
2573        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2574        let fixed_twice = rule.fix(&ctx2).unwrap();
2575        assert!(
2576            fixed_twice
2577                .contains("[![Crate](https://img.shields.io/crates/v/rumdl.svg)](https://crates.io/crates/rumdl)"),
2578            "Badge should produce nested markdown image link: {fixed_twice}"
2579        );
2580    }
2581
2582    #[test]
2583    fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2584        // Verify conservative mode (default) is unaffected by the relaxed logic
2585        let rule = MD033NoInlineHtml::with_fix(true);
2586        let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2588        let fixed = rule.fix(&ctx).unwrap();
2589        assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2590    }
2591
2592    #[test]
2593    fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2594        // <img> inside <pre> must NOT be converted, even in relaxed mode
2595        let rule = relaxed_fix_rule();
2596        let content = "<pre>\n  <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2598        let fixed = rule.fix(&ctx).unwrap();
2599        assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2600    }
2601
2602    #[test]
2603    fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2604        // <p> nested inside <div> should not be stripped
2605        let rule = relaxed_fix_rule();
2606        let content = "<div><p>text</p></div>";
2607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608        let fixed = rule.fix(&ctx).unwrap();
2609        assert!(
2610            fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2611            "Nested <p> inside <div> should not be stripped: {fixed}"
2612        );
2613    }
2614
2615    #[test]
2616    fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2617        // <img> inside <div><p>...</p></div> must NOT be converted because the
2618        // <p> wrapper can't be stripped (it's nested), so the markdown would be
2619        // stuck inside an HTML block where it won't render.
2620        let rule = relaxed_fix_rule();
2621        let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2623        let fixed = rule.fix(&ctx).unwrap();
2624        assert!(
2625            fixed.contains("<img"),
2626            "img inside nested wrapper must not be converted: {fixed}"
2627        );
2628    }
2629
2630    #[test]
2631    fn test_md033_fix_mixed_safe_tags() {
2632        // All tags are now safe fixable (em, img, strong)
2633        let rule = MD033NoInlineHtml::with_fix(true);
2634        let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2635        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2636        let fixed = rule.fix(&ctx).unwrap();
2637        // All are converted
2638        assert_eq!(fixed, "*italic* and ![](x.jpg) and **bold**");
2639    }
2640
2641    #[test]
2642    fn test_md033_fix_multiple_tags_same_line() {
2643        // Multiple tags on the same line should all be fixed correctly
2644        let rule = MD033NoInlineHtml::with_fix(true);
2645        let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647        let fixed = rule.fix(&ctx).unwrap();
2648        assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2649    }
2650
2651    #[test]
2652    fn test_md033_fix_multiple_em_tags_same_line() {
2653        // Multiple em/strong tags on the same line
2654        let rule = MD033NoInlineHtml::with_fix(true);
2655        let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2657        let fixed = rule.fix(&ctx).unwrap();
2658        assert_eq!(fixed, "*first* and **second** and `third`");
2659    }
2660
2661    #[test]
2662    fn test_md033_fix_skips_tags_inside_pre() {
2663        // Tags inside <pre> blocks should NOT be fixed (would break structure)
2664        let rule = MD033NoInlineHtml::with_fix(true);
2665        let content = "<pre><code><em>VALUE</em></code></pre>";
2666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667        let fixed = rule.fix(&ctx).unwrap();
2668        // The <em> inside <pre><code> should NOT be converted
2669        // Only the outer structure might be changed
2670        assert!(
2671            !fixed.contains("*VALUE*"),
2672            "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2673        );
2674    }
2675
2676    #[test]
2677    fn test_md033_fix_skips_tags_inside_div() {
2678        // Tags inside HTML block elements should not be fixed
2679        let rule = MD033NoInlineHtml::with_fix(true);
2680        let content = "<div>\n<em>emphasized</em>\n</div>";
2681        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2682        let fixed = rule.fix(&ctx).unwrap();
2683        // The <em> inside <div> should not be converted to *emphasized*
2684        assert!(
2685            !fixed.contains("*emphasized*"),
2686            "Tags inside HTML blocks should not be converted. Got: {fixed}"
2687        );
2688    }
2689
2690    #[test]
2691    fn test_md033_fix_outside_html_block() {
2692        // Tags outside HTML blocks should still be fixed
2693        let rule = MD033NoInlineHtml::with_fix(true);
2694        let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696        let fixed = rule.fix(&ctx).unwrap();
2697        // The <em> outside the div should be converted
2698        assert!(
2699            fixed.contains("*emphasized*"),
2700            "Tags outside HTML blocks should be converted. Got: {fixed}"
2701        );
2702    }
2703
2704    #[test]
2705    fn test_md033_fix_converts_standalone_type_7_html_block() {
2706        let rule = MD033NoInlineHtml::with_fix(true);
2707        let content = "<em>\nstandalone\n</em>\n";
2708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709
2710        assert!(ctx.is_in_html_block(1), "the parser classifies this as a type-7 block");
2711        assert_eq!(rule.fix(&ctx).unwrap(), "*standalone*\n");
2712    }
2713
2714    #[test]
2715    fn test_md033_fix_converts_root_hr_but_not_nested_hr() {
2716        let rule = MD033NoInlineHtml::with_fix(true);
2717
2718        let root = "<hr>\n";
2719        let root_ctx = LintContext::new(root, crate::config::MarkdownFlavor::Standard, None);
2720        assert!(root_ctx.is_in_html_block(1));
2721        assert_eq!(rule.fix(&root_ctx).unwrap(), "\n---\n\n");
2722
2723        let nested = "<div>\n<hr>\n</div>\n";
2724        let nested_ctx = LintContext::new(nested, crate::config::MarkdownFlavor::Standard, None);
2725        assert_eq!(
2726            rule.fix(&nested_ctx).unwrap(),
2727            nested,
2728            "Markdown syntax inside a raw HTML block would render as literal text"
2729        );
2730    }
2731
2732    #[test]
2733    fn test_md033_fix_skips_block_owned_tags_in_blockquotes() {
2734        let rule = MD033NoInlineHtml::with_fix(true);
2735
2736        for content in ["> <hr>\n", "> <em>\n> standalone\n> </em>\n"] {
2737            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738            assert_eq!(
2739                rule.fix(&ctx).unwrap(),
2740                content,
2741                "a fix must not escape or corrupt its blockquote container"
2742            );
2743        }
2744    }
2745
2746    #[test]
2747    fn test_md033_fix_handles_html_in_list_containers() {
2748        let rule = MD033NoInlineHtml::with_fix(true);
2749
2750        let block_owned = "- <em>\n  standalone\n  </em>\n";
2751        let block_ctx = LintContext::new(block_owned, crate::config::MarkdownFlavor::Standard, None);
2752        assert_eq!(
2753            rule.fix(&block_ctx).unwrap(),
2754            block_owned,
2755            "a fix must not escape or corrupt its list container"
2756        );
2757
2758        let inline = "- text <em>inside</em>\n";
2759        let inline_ctx = LintContext::new(inline, crate::config::MarkdownFlavor::Standard, None);
2760        assert_eq!(rule.fix(&inline_ctx).unwrap(), "- text *inside*\n");
2761    }
2762
2763    #[test]
2764    fn test_md033_fix_skips_multiline_inline_tags() {
2765        let rule = MD033NoInlineHtml::with_fix(true);
2766
2767        for content in [
2768            "paragraph\n<em>\ninside\n</em>\n",
2769            "> paragraph\n> <em>\n> inside\n> </em>\n",
2770        ] {
2771            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2772            assert_eq!(
2773                rule.fix(&ctx).unwrap(),
2774                content,
2775                "delimiter-only lines are not a semantics-preserving replacement"
2776            );
2777        }
2778    }
2779
2780    #[test]
2781    fn test_md033_fix_converts_html_inside_mdx_component() {
2782        let rule = MD033NoInlineHtml::with_fix(true);
2783        let content = "<Component>\nBefore\n<em>inside</em>\nAfter\n</Component>\n";
2784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2785
2786        assert!(ctx.line_info(3).is_some_and(|line| line.in_jsx_block));
2787        assert!(!ctx.is_in_html_block(3));
2788        assert_eq!(
2789            rule.fix(&ctx).unwrap(),
2790            "<Component>\nBefore\n*inside*\nAfter\n</Component>\n"
2791        );
2792    }
2793
2794    #[test]
2795    fn standalone_html_conversion_does_not_activate_reference_links() {
2796        let rule = MD033NoInlineHtml::with_fix(true);
2797        for tag in ["em", "strong", "b", "i"] {
2798            for inner in ["[foo]", "[foo][]", "[x][foo]", "[^n]"] {
2799                let content = format!("<{tag}>\n{inner}\n</{tag}>\n\n[foo]: https://example.com\n\n[^n]: A note.\n");
2800                let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2801                assert_eq!(rule.fix(&ctx).unwrap(), content, "literal HTML text: {tag}: {inner}");
2802            }
2803        }
2804    }
2805
2806    #[test]
2807    fn standalone_html_conversion_preserves_literal_punctuation() {
2808        let rule = MD033NoInlineHtml::with_fix(true);
2809        for inner in ["*literal*", "a*", "[link](other)", "![image](x)", "~~strike~~", "a\\*b"] {
2810            let content = format!("<em>\n{inner}\n</em>\n");
2811            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2812            assert_eq!(rule.fix(&ctx).unwrap(), content, "literal HTML text: {inner}");
2813        }
2814    }
2815
2816    #[test]
2817    fn standalone_html_conversion_does_not_create_markdown_blocks_or_breaks() {
2818        let rule = MD033NoInlineHtml::with_fix(true);
2819        for inner in [
2820            "foo\n# bar",
2821            "foo\n- bar",
2822            "foo  \nbar",
2823            "foo\\\nbar",
2824            "foo\n> bar",
2825            "foo\n```\ncode",
2826        ] {
2827            let content = format!("<em>\n{inner}\n</em>\n");
2828            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2829            assert_eq!(rule.fix(&ctx).unwrap(), content);
2830        }
2831    }
2832
2833    #[test]
2834    fn html_block_fixes_preserve_footnote_containers() {
2835        let rule = MD033NoInlineHtml::with_fix(true);
2836        for block in ["<hr>", "<em>\n    text\n    </em>"] {
2837            let content = format!("[^n]: First paragraph.\n\n    {block}\n\n    more\n\nref[^n]\n");
2838            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2839            assert_eq!(rule.fix(&ctx).unwrap(), content);
2840        }
2841    }
2842
2843    #[test]
2844    fn html_block_fixes_preserve_definition_list_and_flavor_containers() {
2845        let rule = MD033NoInlineHtml::with_fix(true);
2846        for (content, flavor) in [
2847            (
2848                "Term\n:   description\n\n    <hr>\n\n    more\n",
2849                MarkdownFlavor::Standard,
2850            ),
2851            ("!!! note\n    <hr>\n", MarkdownFlavor::MkDocs),
2852            ("=== \"Tab\"\n    <hr>\n", MarkdownFlavor::MkDocs),
2853        ] {
2854            let ctx = LintContext::new(content, flavor, None);
2855            assert_eq!(rule.fix(&ctx).unwrap(), content);
2856        }
2857    }
2858
2859    #[test]
2860    fn relaxed_wrapper_conversion_finishes_before_leaving_markdown_in_html() {
2861        let rule = relaxed_fix_rule();
2862        for content in [
2863            "<p align=\"center\"><img src=\"logo.png\"></p>\n",
2864            "<p>\n<img src=\"a.png\">\n</p>\ntrailing text\n",
2865            "<p align=\"center\">\n  <img src=\"logo.png\">\n</p>\n",
2866        ] {
2867            let mut fixed = content.to_string();
2868            for _ in 0..4 {
2869                let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2870                let next = rule.fix(&ctx).unwrap();
2871                if next == fixed {
2872                    break;
2873                }
2874                fixed = next;
2875            }
2876            assert!(fixed.contains("![]("));
2877            assert!(!fixed.contains("<p"));
2878            let mut html = String::new();
2879            pulldown_cmark::html::push_html(&mut html, pulldown_cmark::Parser::new(&fixed));
2880            assert!(html.contains("<img src="), "image must render: {html}");
2881        }
2882    }
2883
2884    #[test]
2885    fn single_line_images_keep_their_markdown_container_prefixes() {
2886        let rule = MD033NoInlineHtml::with_fix(true);
2887        for (content, expected) in [
2888            ("- <img src=\"a.png\">\n", "- ![](a.png)\n"),
2889            ("> <img src=\"a.png\">\n", "> ![](a.png)\n"),
2890            ("- item\n\n  <img src=\"a.png\">\n", "- item\n\n  ![](a.png)\n"),
2891            (
2892                "[^n]: First paragraph.\n\n    <img src=\"a.png\">\n\nref[^n]\n",
2893                "[^n]: First paragraph.\n\n    ![](a.png)\n\nref[^n]\n",
2894            ),
2895            (
2896                "Term\n:   description\n\n    <img src=\"a.png\">\n",
2897                "Term\n:   description\n\n    ![](a.png)\n",
2898            ),
2899        ] {
2900            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2901            let fixed = rule.fix(&ctx).unwrap();
2902            assert_eq!(fixed, expected);
2903            let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2904            assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2905        }
2906    }
2907
2908    #[test]
2909    fn relaxed_single_line_wrappers_keep_their_markdown_container_prefixes() {
2910        let rule = relaxed_fix_rule();
2911        for prefix in ["- ", "> "] {
2912            let content = format!("{prefix}<p align=\"center\"><img src=\"a.png\"></p>\n");
2913            let mut fixed = content;
2914            for _ in 0..4 {
2915                let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2916                let next = rule.fix(&ctx).unwrap();
2917                if next == fixed {
2918                    break;
2919                }
2920                fixed = next;
2921            }
2922            assert_eq!(fixed, format!("{prefix}![](a.png)\n"));
2923        }
2924    }
2925
2926    #[test]
2927    fn container_images_do_not_leave_markdown_in_raw_html_tails_or_wrappers() {
2928        for rule in [MD033NoInlineHtml::with_fix(true), relaxed_fix_rule()] {
2929            for content in [
2930                "- <img src=\"a.png\">\n  tail *literal*\n",
2931                "> <img src=\"a.png\">\n> tail *literal*\n",
2932                "- <br>\n",
2933                "- <hr>\n",
2934                "- <p>\n  <img src=\"a.png\">\n  </p>\n",
2935            ] {
2936                let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2937                assert_eq!(rule.fix(&ctx).unwrap(), content);
2938            }
2939        }
2940    }
2941
2942    #[test]
2943    fn standalone_html_links_round_trip_text_and_reject_markdown_images() {
2944        let rule = MD033NoInlineHtml::with_fix(true);
2945        for (content, expected) in [
2946            ("<a href=\"https://x.test\">\ntext\n</a>\n", "[text](https://x.test)\n"),
2947            (
2948                "<a href=\"https://x.test\">\na[b]\n</a>\n",
2949                "[a\\[b\\]](https://x.test)\n",
2950            ),
2951        ] {
2952            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2953            let fixed = rule.fix(&ctx).unwrap();
2954            assert_eq!(fixed, expected);
2955            let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2956            assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2957        }
2958        for content in [
2959            "<a href=\"https://x.test\">\n![](i.png)\n</a>\n",
2960            "<a href=\"a b\">\ntext\n</a>\n",
2961        ] {
2962            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2963            assert_eq!(rule.fix(&ctx).unwrap(), content);
2964        }
2965    }
2966
2967    #[test]
2968    fn inline_html_in_markdown_enabled_html_container_still_converts() {
2969        let rule = MD033NoInlineHtml::with_fix(true);
2970        let content = "<div markdown=\"1\">\n\ntext <em>inside</em> after\n\n</div>\n";
2971        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2972        assert_eq!(
2973            rule.fix(&ctx).unwrap(),
2974            "<div markdown=\"1\">\n\ntext *inside* after\n\n</div>\n"
2975        );
2976    }
2977
2978    #[test]
2979    fn soft_wrapped_inline_html_still_converts() {
2980        let rule = MD033NoInlineHtml::with_fix(true);
2981        let content = "This is <em>wrapped\ntext</em> here.\n";
2982        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2983        assert_eq!(rule.fix(&ctx).unwrap(), "This is *wrapped\ntext* here.\n");
2984    }
2985
2986    #[test]
2987    fn standalone_html_conversion_preserves_unicode_whitespace() {
2988        let rule = MD033NoInlineHtml::with_fix(true);
2989        for space in ['\u{a0}', '\u{2003}'] {
2990            for (content, expected) in [
2991                (
2992                    format!("<em>\n{space}note\n</em>\n"),
2993                    format!("<em>\n{space}note\n</em>\n"),
2994                ),
2995                (
2996                    format!("<code>\n{space}x{space}\n</code>\n"),
2997                    format!("`{space}x{space}`\n"),
2998                ),
2999                (
3000                    format!("<a href=\"https://x.test\">\ntext{space}\n</a>\n"),
3001                    format!("[text{space}](https://x.test)\n"),
3002                ),
3003                (format!("<hr>{space}\n"), format!("<hr>{space}\n")),
3004                (
3005                    format!("<em>\ntext\n</em>{space}\n"),
3006                    format!("<em>\ntext\n</em>{space}\n"),
3007                ),
3008            ] {
3009                let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
3010                let fixed = rule.fix(&ctx).unwrap();
3011                assert_eq!(fixed, expected);
3012                let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
3013                assert_eq!(rule.fix(&ctx).unwrap(), fixed);
3014            }
3015        }
3016    }
3017
3018    #[test]
3019    fn standalone_html_conversion_is_idempotent_and_unicode_safe() {
3020        let rule = MD033NoInlineHtml::with_fix(true);
3021        for (content, expected) in [
3022            ("<em>\néén zin\n</em>\n", "*één zin*\n"),
3023            ("<strong>\nhello\n</strong>\n", "**hello**\n"),
3024            ("<code>\nplain\n</code>\n", "`plain`\n"),
3025            ("<em>\r\nhello\r\n</em>\r\n", "*hello*\r\n"),
3026        ] {
3027            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
3028            let fixed = rule.fix(&ctx).unwrap();
3029            assert_eq!(fixed, expected);
3030            let next = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
3031            assert_eq!(rule.fix(&next).unwrap(), fixed);
3032            assert!(rule.check(&next).unwrap().is_empty());
3033        }
3034    }
3035
3036    #[test]
3037    fn html_block_tail_and_nested_content_are_not_reinterpreted() {
3038        let rule = MD033NoInlineHtml::with_fix(true);
3039        for content in [
3040            "<em>\none\n</em>\n*literal tail*\n",
3041            "<div>\ntext <em>literal</em>\n</div>\n",
3042            "<pre>\ntext <em>literal</em>\n</pre>\n",
3043            "<em>\n\ntext\n</em>\n",
3044            "- text\n\n  <hr>\n",
3045        ] {
3046            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
3047            assert_eq!(rule.fix(&ctx).unwrap(), content);
3048        }
3049    }
3050
3051    #[test]
3052    fn test_md033_fix_with_id_attribute() {
3053        // Tags with id attributes should not be fixed (id might be used for anchors)
3054        let rule = MD033NoInlineHtml::with_fix(true);
3055        let content = "See <em id=\"important\">this note</em> for details.";
3056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3057        let fixed = rule.fix(&ctx).unwrap();
3058        // Should remain unchanged - id attribute matters for linking
3059        assert_eq!(fixed, content);
3060    }
3061
3062    #[test]
3063    fn test_md033_fix_with_style_attribute() {
3064        // Tags with style attributes should not be fixed
3065        let rule = MD033NoInlineHtml::with_fix(true);
3066        let content = "This is <strong style=\"color: red\">important</strong> text.";
3067        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3068        let fixed = rule.fix(&ctx).unwrap();
3069        // Should remain unchanged - style attribute provides formatting
3070        assert_eq!(fixed, content);
3071    }
3072
3073    #[test]
3074    fn test_md033_fix_mixed_with_and_without_attributes() {
3075        // Mix of tags with and without attributes
3076        let rule = MD033NoInlineHtml::with_fix(true);
3077        let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
3078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3079        let fixed = rule.fix(&ctx).unwrap();
3080        // Only the tag without attributes should be fixed
3081        assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
3082    }
3083
3084    #[test]
3085    fn test_md033_quick_fix_tag_with_attributes_no_fix() {
3086        // Quick fix should not be provided for tags with attributes
3087        let rule = MD033NoInlineHtml::with_fix(true);
3088        let content = "<em class=\"test\">emphasized</em>";
3089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3090        let result = rule.check(&ctx).unwrap();
3091
3092        assert_eq!(result.len(), 1, "Should find one HTML tag");
3093        // No fix should be provided for tags with attributes
3094        assert!(
3095            result[0].fix.is_none(),
3096            "Should NOT have a fix for tags with attributes"
3097        );
3098    }
3099
3100    #[test]
3101    fn test_md033_fix_skips_html_entities() {
3102        // Tags containing HTML entities should NOT be fixed
3103        // HTML entities need HTML context to render; markdown won't process them
3104        let rule = MD033NoInlineHtml::with_fix(true);
3105        let content = "<code>&vert;</code>";
3106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3107        let fixed = rule.fix(&ctx).unwrap();
3108        // Should remain unchanged - converting would break rendering
3109        assert_eq!(fixed, content);
3110    }
3111
3112    #[test]
3113    fn test_md033_fix_skips_multiple_html_entities() {
3114        // Multiple HTML entities should also be skipped
3115        let rule = MD033NoInlineHtml::with_fix(true);
3116        let content = "<code>&lt;T&gt;</code>";
3117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3118        let fixed = rule.fix(&ctx).unwrap();
3119        // Should remain unchanged
3120        assert_eq!(fixed, content);
3121    }
3122
3123    #[test]
3124    fn test_md033_fix_allows_ampersand_without_entity() {
3125        // Content with & but no semicolon should still be fixed
3126        let rule = MD033NoInlineHtml::with_fix(true);
3127        let content = "<code>a & b</code>";
3128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3129        let fixed = rule.fix(&ctx).unwrap();
3130        // Should be converted since & is not part of an entity
3131        assert_eq!(fixed, "`a & b`");
3132    }
3133
3134    #[test]
3135    fn test_md033_fix_em_with_entities_skipped() {
3136        // <em> with entities should also be skipped
3137        let rule = MD033NoInlineHtml::with_fix(true);
3138        let content = "<em>&nbsp;text</em>";
3139        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3140        let fixed = rule.fix(&ctx).unwrap();
3141        // Should remain unchanged
3142        assert_eq!(fixed, content);
3143    }
3144
3145    #[test]
3146    fn test_md033_fix_skips_nested_em_in_code() {
3147        // Tags nested inside other HTML elements should NOT be fixed
3148        // e.g., <code><em>n</em></code> - the <em> should not be converted
3149        let rule = MD033NoInlineHtml::with_fix(true);
3150        let content = "<code><em>n</em></code>";
3151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3152        let fixed = rule.fix(&ctx).unwrap();
3153        // The inner <em> should NOT be converted to *n* because it's nested
3154        // The whole structure should be left as-is (or outer code converted, but not inner)
3155        assert!(
3156            !fixed.contains("*n*"),
3157            "Nested <em> should not be converted to markdown. Got: {fixed}"
3158        );
3159    }
3160
3161    #[test]
3162    fn test_md033_fix_skips_nested_in_table() {
3163        // Tags nested in HTML structures in tables should not be fixed
3164        let rule = MD033NoInlineHtml::with_fix(true);
3165        let content = "| <code>><em>n</em></code> | description |";
3166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3167        let fixed = rule.fix(&ctx).unwrap();
3168        // Should not convert nested <em> to *n*
3169        assert!(
3170            !fixed.contains("*n*"),
3171            "Nested tags in table should not be converted. Got: {fixed}"
3172        );
3173    }
3174
3175    #[test]
3176    fn test_md033_fix_standalone_em_still_converted() {
3177        // Standalone (non-nested) <em> should still be converted
3178        let rule = MD033NoInlineHtml::with_fix(true);
3179        let content = "This is <em>emphasized</em> text.";
3180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3181        let fixed = rule.fix(&ctx).unwrap();
3182        assert_eq!(fixed, "This is *emphasized* text.");
3183    }
3184
3185    // ==========================================================================
3186    // Obsidian Templater Plugin Syntax Tests
3187    //
3188    // Templater is a popular Obsidian plugin that uses `<% ... %>` syntax for
3189    // template interpolation. The `<%` pattern is NOT captured by the HTML tag
3190    // parser because `%` is not a valid HTML tag name character (tags must start
3191    // with a letter). This behavior is documented here with comprehensive tests.
3192    //
3193    // Reference: https://silentvoid13.github.io/Templater/
3194    // ==========================================================================
3195
3196    #[test]
3197    fn test_md033_templater_basic_interpolation_not_flagged() {
3198        // Basic Templater interpolation: <% expr %>
3199        // Should NOT be flagged because `%` is not a valid HTML tag character
3200        let rule = MD033NoInlineHtml::default();
3201        let content = "Today is <% tp.date.now() %> which is nice.";
3202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3203        let result = rule.check(&ctx).unwrap();
3204        assert!(
3205            result.is_empty(),
3206            "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
3207        );
3208    }
3209
3210    #[test]
3211    fn test_md033_templater_file_functions_not_flagged() {
3212        // Templater file functions: <% tp.file.* %>
3213        let rule = MD033NoInlineHtml::default();
3214        let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
3215        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3216        let result = rule.check(&ctx).unwrap();
3217        assert!(
3218            result.is_empty(),
3219            "Templater file functions should not be flagged. Got: {result:?}"
3220        );
3221    }
3222
3223    #[test]
3224    fn test_md033_templater_with_arguments_not_flagged() {
3225        // Templater with function arguments
3226        let rule = MD033NoInlineHtml::default();
3227        let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
3228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3229        let result = rule.check(&ctx).unwrap();
3230        assert!(
3231            result.is_empty(),
3232            "Templater with arguments should not be flagged. Got: {result:?}"
3233        );
3234    }
3235
3236    #[test]
3237    fn test_md033_templater_javascript_execution_not_flagged() {
3238        // Templater JavaScript execution block: <%* code %>
3239        let rule = MD033NoInlineHtml::default();
3240        let content = "<%* const today = tp.date.now(); tR += today; %>";
3241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3242        let result = rule.check(&ctx).unwrap();
3243        assert!(
3244            result.is_empty(),
3245            "Templater JS execution block should not be flagged. Got: {result:?}"
3246        );
3247    }
3248
3249    #[test]
3250    fn test_md033_templater_dynamic_execution_not_flagged() {
3251        // Templater dynamic/preview execution: <%+ expr %>
3252        let rule = MD033NoInlineHtml::default();
3253        let content = "Dynamic: <%+ tp.date.now() %>";
3254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3255        let result = rule.check(&ctx).unwrap();
3256        assert!(
3257            result.is_empty(),
3258            "Templater dynamic execution should not be flagged. Got: {result:?}"
3259        );
3260    }
3261
3262    #[test]
3263    fn test_md033_templater_whitespace_trim_all_not_flagged() {
3264        // Templater whitespace control - trim all: <%_ expr _%>
3265        let rule = MD033NoInlineHtml::default();
3266        let content = "<%_ tp.date.now() _%>";
3267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3268        let result = rule.check(&ctx).unwrap();
3269        assert!(
3270            result.is_empty(),
3271            "Templater trim-all whitespace should not be flagged. Got: {result:?}"
3272        );
3273    }
3274
3275    #[test]
3276    fn test_md033_templater_whitespace_trim_newline_not_flagged() {
3277        // Templater whitespace control - trim newline: <%- expr -%>
3278        let rule = MD033NoInlineHtml::default();
3279        let content = "<%- tp.date.now() -%>";
3280        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3281        let result = rule.check(&ctx).unwrap();
3282        assert!(
3283            result.is_empty(),
3284            "Templater trim-newline should not be flagged. Got: {result:?}"
3285        );
3286    }
3287
3288    #[test]
3289    fn test_md033_templater_combined_modifiers_not_flagged() {
3290        // Templater combined whitespace and execution modifiers
3291        let rule = MD033NoInlineHtml::default();
3292        let contents = [
3293            "<%-* const x = 1; -%>",  // trim + JS execution
3294            "<%_+ tp.date.now() _%>", // trim-all + dynamic
3295            "<%- tp.file.title -%>",  // trim-newline only
3296            "<%_ tp.file.title _%>",  // trim-all only
3297        ];
3298        for content in contents {
3299            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3300            let result = rule.check(&ctx).unwrap();
3301            assert!(
3302                result.is_empty(),
3303                "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
3304            );
3305        }
3306    }
3307
3308    #[test]
3309    fn test_md033_templater_multiline_block_not_flagged() {
3310        // Multi-line Templater JavaScript block
3311        let rule = MD033NoInlineHtml::default();
3312        let content = r#"<%*
3313const x = 1;
3314const y = 2;
3315tR += x + y;
3316%>"#;
3317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3318        let result = rule.check(&ctx).unwrap();
3319        assert!(
3320            result.is_empty(),
3321            "Templater multi-line block should not be flagged. Got: {result:?}"
3322        );
3323    }
3324
3325    #[test]
3326    fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
3327        // Templater with angle brackets in JavaScript condition
3328        // This is a key edge case: `<` inside Templater should not trigger HTML detection
3329        let rule = MD033NoInlineHtml::default();
3330        let content = "<%* if (x < 5) { tR += 'small'; } %>";
3331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3332        let result = rule.check(&ctx).unwrap();
3333        assert!(
3334            result.is_empty(),
3335            "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
3336        );
3337    }
3338
3339    #[test]
3340    fn test_md033_templater_mixed_with_html_only_html_flagged() {
3341        // Templater syntax mixed with actual HTML - only HTML should be flagged
3342        let rule = MD033NoInlineHtml::default();
3343        let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
3344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3345        let result = rule.check(&ctx).unwrap();
3346        assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
3347        assert!(
3348            result[0].message.contains("<div>"),
3349            "Should flag <div>, got: {}",
3350            result[0].message
3351        );
3352    }
3353
3354    #[test]
3355    fn test_md033_templater_in_heading_not_flagged() {
3356        // Templater in markdown heading
3357        let rule = MD033NoInlineHtml::default();
3358        let content = "# <% tp.file.title %>";
3359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3360        let result = rule.check(&ctx).unwrap();
3361        assert!(
3362            result.is_empty(),
3363            "Templater in heading should not be flagged. Got: {result:?}"
3364        );
3365    }
3366
3367    #[test]
3368    fn test_md033_templater_multiple_on_same_line_not_flagged() {
3369        // Multiple Templater blocks on same line
3370        let rule = MD033NoInlineHtml::default();
3371        let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
3372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3373        let result = rule.check(&ctx).unwrap();
3374        assert!(
3375            result.is_empty(),
3376            "Multiple Templater blocks should not be flagged. Got: {result:?}"
3377        );
3378    }
3379
3380    #[test]
3381    fn test_md033_templater_in_code_block_not_flagged() {
3382        // Templater syntax in code blocks should not be flagged (code blocks are skipped)
3383        let rule = MD033NoInlineHtml::default();
3384        let content = "```\n<% tp.date.now() %>\n```";
3385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3386        let result = rule.check(&ctx).unwrap();
3387        assert!(
3388            result.is_empty(),
3389            "Templater in code block should not be flagged. Got: {result:?}"
3390        );
3391    }
3392
3393    #[test]
3394    fn test_md033_templater_in_inline_code_not_flagged() {
3395        // Templater syntax in inline code span should not be flagged
3396        let rule = MD033NoInlineHtml::default();
3397        let content = "Use `<% tp.date.now() %>` for current date.";
3398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3399        let result = rule.check(&ctx).unwrap();
3400        assert!(
3401            result.is_empty(),
3402            "Templater in inline code should not be flagged. Got: {result:?}"
3403        );
3404    }
3405
3406    #[test]
3407    fn test_md033_templater_also_works_in_standard_flavor() {
3408        // Templater syntax should also not be flagged in Standard flavor
3409        // because the HTML parser doesn't recognize `<%` as a valid tag
3410        let rule = MD033NoInlineHtml::default();
3411        let content = "<% tp.date.now() %> works everywhere.";
3412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3413        let result = rule.check(&ctx).unwrap();
3414        assert!(
3415            result.is_empty(),
3416            "Templater should not be flagged even in Standard flavor. Got: {result:?}"
3417        );
3418    }
3419
3420    #[test]
3421    fn test_md033_templater_empty_tag_not_flagged() {
3422        // Empty Templater tags
3423        let rule = MD033NoInlineHtml::default();
3424        let content = "<%>";
3425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3426        let result = rule.check(&ctx).unwrap();
3427        assert!(
3428            result.is_empty(),
3429            "Empty Templater-like tag should not be flagged. Got: {result:?}"
3430        );
3431    }
3432
3433    #[test]
3434    fn test_md033_templater_unclosed_not_flagged() {
3435        // Unclosed Templater tags - these are template errors, not HTML
3436        let rule = MD033NoInlineHtml::default();
3437        let content = "<% tp.date.now() without closing tag";
3438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3439        let result = rule.check(&ctx).unwrap();
3440        assert!(
3441            result.is_empty(),
3442            "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
3443        );
3444    }
3445
3446    #[test]
3447    fn test_md033_templater_with_newlines_inside_not_flagged() {
3448        // Templater with newlines inside the expression
3449        let rule = MD033NoInlineHtml::default();
3450        let content = r#"<% tp.date.now("YYYY") +
3451"-" +
3452tp.date.now("MM") %>"#;
3453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3454        let result = rule.check(&ctx).unwrap();
3455        assert!(
3456            result.is_empty(),
3457            "Templater with internal newlines should not be flagged. Got: {result:?}"
3458        );
3459    }
3460
3461    #[test]
3462    fn test_md033_erb_style_tags_not_flagged() {
3463        // ERB/EJS style tags (similar to Templater) are also not HTML
3464        // This documents the general principle that `<%` is not valid HTML
3465        let rule = MD033NoInlineHtml::default();
3466        let content = "<%= variable %> and <% code %> and <%# comment %>";
3467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3468        let result = rule.check(&ctx).unwrap();
3469        assert!(
3470            result.is_empty(),
3471            "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
3472        );
3473    }
3474
3475    #[test]
3476    fn test_md033_templater_complex_expression_not_flagged() {
3477        // Complex Templater expression with multiple function calls
3478        let rule = MD033NoInlineHtml::default();
3479        let content = r#"<%*
3480const file = tp.file.title;
3481const date = tp.date.now("YYYY-MM-DD");
3482const folder = tp.file.folder();
3483tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
3484%>"#;
3485        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3486        let result = rule.check(&ctx).unwrap();
3487        assert!(
3488            result.is_empty(),
3489            "Complex Templater expression should not be flagged. Got: {result:?}"
3490        );
3491    }
3492
3493    #[test]
3494    fn test_md033_percent_sign_variations_not_flagged() {
3495        // Various patterns starting with <% that should all be safe
3496        let rule = MD033NoInlineHtml::default();
3497        let patterns = [
3498            "<%=",  // ERB output
3499            "<%#",  // ERB comment
3500            "<%%",  // Double percent
3501            "<%!",  // Some template engines
3502            "<%@",  // JSP directive
3503            "<%--", // JSP comment
3504        ];
3505        for pattern in patterns {
3506            let content = format!("{pattern} content %>");
3507            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3508            let result = rule.check(&ctx).unwrap();
3509            assert!(
3510                result.is_empty(),
3511                "Pattern {pattern} should not be flagged. Got: {result:?}"
3512            );
3513        }
3514    }
3515
3516    // ───── Bug #3: Bracket escaping in image-inside-link conversion ─────
3517    //
3518    // When <a> wraps already-converted markdown image text, the bracket escaping
3519    // must be skipped to produce valid [![alt](url)](href) instead of !\[\](url)
3520
3521    #[test]
3522    fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
3523        // When <a> wraps a markdown image (from a prior fix iteration),
3524        // the result should be [![](url)](href) — no escaped brackets
3525        let rule = MD033NoInlineHtml::with_fix(true);
3526        let content = r#"<a href="https://example.com">![](https://example.com/image.png)</a>"#;
3527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3528        let fixed = rule.fix(&ctx).unwrap();
3529
3530        assert_eq!(fixed, "[![](https://example.com/image.png)](https://example.com)",);
3531        assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
3532        assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
3533    }
3534
3535    #[test]
3536    fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
3537        // <a> wrapping ![alt](url) preserves alt text in linked image
3538        let rule = MD033NoInlineHtml::with_fix(true);
3539        let content =
3540            r#"<a href="https://github.com/repo">![Contributors](https://contrib.rocks/image?repo=org/repo)</a>"#;
3541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3542        let fixed = rule.fix(&ctx).unwrap();
3543
3544        assert_eq!(
3545            fixed,
3546            "[![Contributors](https://contrib.rocks/image?repo=org/repo)](https://github.com/repo)"
3547        );
3548    }
3549
3550    #[test]
3551    fn test_md033_fix_img_without_alt_produces_empty_alt() {
3552        let rule = MD033NoInlineHtml::with_fix(true);
3553        let content = r#"<img src="photo.jpg" />"#;
3554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3555        let fixed = rule.fix(&ctx).unwrap();
3556
3557        assert_eq!(fixed, "![](photo.jpg)");
3558    }
3559
3560    #[test]
3561    fn test_md033_fix_leaves_a_tag_inside_an_unnamed_html_block() {
3562        // `<span>` alone on its line opens an HTML block running to the next
3563        // blank line, so the `<img>` below it is raw HTML, where Markdown image
3564        // syntax would render as literal text. The block's own opening tag, and
3565        // an image after the blank line, are still converted.
3566        let rule = MD033NoInlineHtml::with_fix(true);
3567        let content = "<span>\n<img src=\"a.png\" />\n\n<img src=\"b.png\" />\n";
3568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3569        assert_eq!(rule.fix(&ctx).unwrap(), "<span>\n<img src=\"a.png\" />\n\n![](b.png)\n");
3570    }
3571
3572    #[test]
3573    fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
3574        // Plain text brackets inside <a> SHOULD be escaped
3575        let rule = MD033NoInlineHtml::with_fix(true);
3576        let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
3577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3578        let fixed = rule.fix(&ctx).unwrap();
3579
3580        assert!(
3581            fixed.contains(r"\[brackets\]"),
3582            "Plain text brackets should be escaped: {fixed}"
3583        );
3584    }
3585
3586    #[test]
3587    fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
3588        // Mixed content: image followed by bracketed text — brackets must be escaped
3589        // The image detection must NOT match partial content
3590        let rule = MD033NoInlineHtml::with_fix(true);
3591        let content = r#"<a href="/link">![](img.png) see [docs]</a>"#;
3592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3593        let fixed = rule.fix(&ctx).unwrap();
3594
3595        // "see [docs]" brackets should be escaped since inner content is mixed
3596        assert!(
3597            fixed.contains(r"\[docs\]"),
3598            "Brackets in mixed image+text content should be escaped: {fixed}"
3599        );
3600    }
3601
3602    #[test]
3603    fn test_md033_fix_img_in_a_end_to_end() {
3604        // End-to-end: verify that iterative fixing of <a><img></a>
3605        // produces the correct final result through the fix coordinator
3606        use crate::config::Config;
3607        use crate::fix_coordinator::FixCoordinator;
3608
3609        let rule = MD033NoInlineHtml::with_fix(true);
3610        let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3611
3612        let mut content =
3613            r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
3614                .to_string();
3615        let config = Config::default();
3616        let coordinator = FixCoordinator::new();
3617
3618        let result = coordinator
3619            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3620            .unwrap();
3621
3622        assert_eq!(
3623            content, "[![](https://contrib.rocks/image?repo=org/repo)](https://github.com/org/repo)",
3624            "End-to-end: <a><img></a> should become valid linked image"
3625        );
3626        assert!(result.converged);
3627        assert!(!content.contains(r"\["), "No escaped brackets: {content}");
3628    }
3629
3630    #[test]
3631    fn test_md033_fix_img_in_a_with_alt_end_to_end() {
3632        use crate::config::Config;
3633        use crate::fix_coordinator::FixCoordinator;
3634
3635        let rule = MD033NoInlineHtml::with_fix(true);
3636        let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3637
3638        let mut content =
3639            r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
3640                .to_string();
3641        let config = Config::default();
3642        let coordinator = FixCoordinator::new();
3643
3644        let result = coordinator
3645            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3646            .unwrap();
3647
3648        assert_eq!(
3649            content,
3650            "[![Contributors](https://contrib.rocks/image)](https://github.com/org/repo)",
3651        );
3652        assert!(result.converged);
3653    }
3654
3655    // =========================================================================
3656    // table_allowed_elements config option tests
3657    //
3658    // Mirrors markdownlint's `table_allowed_elements`: when unset, the in-table
3659    // allowlist falls back to `allowed_elements`; when explicitly set (even to
3660    // []), it overrides for tags inside GFM table cells. Out-of-table tags are
3661    // never affected by this option.
3662    // =========================================================================
3663
3664    #[test]
3665    fn test_md033_table_allowed_unset_falls_back_to_allowed() {
3666        let config = MD033Config {
3667            allowed: vec!["br".to_string()],
3668            table_allowed_elements: None,
3669            ..MD033Config::default()
3670        };
3671        let rule = MD033NoInlineHtml::from_config_struct(config);
3672        let content = "| col |\n|-----|\n| a<br>b |\n";
3673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3674        let result = rule.check(&ctx).unwrap();
3675        assert!(
3676            result.is_empty(),
3677            "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
3678        );
3679    }
3680
3681    #[test]
3682    fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
3683        let config = MD033Config {
3684            allowed: vec!["br".to_string()],
3685            table_allowed_elements: Some(Vec::new()),
3686            ..MD033Config::default()
3687        };
3688        let rule = MD033NoInlineHtml::from_config_struct(config);
3689        let content = "| col |\n|-----|\n| a<br>b |\n";
3690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3691        let result = rule.check(&ctx).unwrap();
3692        assert_eq!(
3693            result.len(),
3694            1,
3695            "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
3696        );
3697        assert_eq!(result[0].line, 3);
3698    }
3699
3700    #[test]
3701    fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
3702        let config = MD033Config {
3703            allowed: vec!["br".to_string()],
3704            table_allowed_elements: Some(vec!["img".to_string()]),
3705            ..MD033Config::default()
3706        };
3707        let rule = MD033NoInlineHtml::from_config_struct(config);
3708        // <br> is in allowed but NOT in table_allowed, so it should be flagged in table.
3709        // <img> is in table_allowed only, so it should be permitted in table.
3710        let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
3711        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3712        let result = rule.check(&ctx).unwrap();
3713        assert_eq!(
3714            result.len(),
3715            1,
3716            "table_allowed should override `allowed` inside tables, got {result:?}"
3717        );
3718        assert!(
3719            result[0].message.contains("br"),
3720            "expected the flagged tag to be <br>, got {:?}",
3721            result[0].message
3722        );
3723    }
3724
3725    #[test]
3726    fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
3727        let config = MD033Config {
3728            allowed: vec!["br".to_string()],
3729            table_allowed_elements: Some(Vec::new()),
3730            ..MD033Config::default()
3731        };
3732        let rule = MD033NoInlineHtml::from_config_struct(config);
3733        // <br> outside a table — should still be allowed via `allowed`.
3734        let content = "Paragraph with <br> tag.\n";
3735        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3736        let result = rule.check(&ctx).unwrap();
3737        assert!(
3738            result.is_empty(),
3739            "<br> outside tables must still be allowed by `allowed`, got {result:?}"
3740        );
3741    }
3742
3743    #[test]
3744    fn test_md033_table_allowed_kebab_case_parses() {
3745        let toml_str = r#"
3746            allowed-elements = ["br"]
3747            table-allowed-elements = ["img"]
3748        "#;
3749        let config: MD033Config = toml::from_str(toml_str).unwrap();
3750        assert_eq!(config.allowed, vec!["br"]);
3751        assert_eq!(
3752            config.table_allowed_elements.as_deref(),
3753            Some(["img".to_string()].as_slice())
3754        );
3755    }
3756
3757    #[test]
3758    fn test_md033_table_allowed_snake_case_alias_parses() {
3759        let toml_str = r#"
3760            allowed_elements = ["br"]
3761            table_allowed_elements = ["img"]
3762        "#;
3763        let config: MD033Config = toml::from_str(toml_str).unwrap();
3764        assert_eq!(config.allowed, vec!["br"]);
3765        assert_eq!(
3766            config.table_allowed_elements.as_deref(),
3767            Some(["img".to_string()].as_slice())
3768        );
3769    }
3770
3771    #[test]
3772    fn test_md033_table_allowed_default_is_none() {
3773        let cfg = MD033Config::default();
3774        assert!(
3775            cfg.table_allowed_elements.is_none(),
3776            "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3777        );
3778    }
3779
3780    #[test]
3781    fn test_md033_table_allowed_case_insensitive() {
3782        let config = MD033Config {
3783            allowed: Vec::new(),
3784            table_allowed_elements: Some(vec!["BR".to_string()]),
3785            ..MD033Config::default()
3786        };
3787        let rule = MD033NoInlineHtml::from_config_struct(config);
3788        let content = "| col |\n|-----|\n| a<br>b |\n";
3789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3790        let result = rule.check(&ctx).unwrap();
3791        assert!(
3792            result.is_empty(),
3793            "table_allowed should be case-insensitive, got {result:?}"
3794        );
3795    }
3796
3797    // =========================================================================
3798    // allowed_inside config option tests
3799    //
3800    // An element named here is permitted, and so is everything between its
3801    // opening and closing tag.
3802    // =========================================================================
3803
3804    /// The raw tags MD033 reports, in document order.
3805    fn reported_tags_in(rule: &MD033NoInlineHtml, content: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
3806        let ctx = LintContext::new(content, flavor, None);
3807        rule.check(&ctx)
3808            .unwrap()
3809            .into_iter()
3810            .map(|warning| {
3811                warning
3812                    .message
3813                    .strip_prefix("Inline HTML found: ")
3814                    .expect("MD033 reports the tag it found")
3815                    .to_string()
3816            })
3817            .collect()
3818    }
3819
3820    fn reported_tags(rule: &MD033NoInlineHtml, content: &str) -> Vec<String> {
3821        reported_tags_in(rule, content, crate::config::MarkdownFlavor::Standard)
3822    }
3823
3824    fn rule_allowing_inside(elements: &[&str]) -> MD033NoInlineHtml {
3825        MD033NoInlineHtml::from_config_struct(MD033Config {
3826            allowed_inside: elements.iter().map(ToString::to_string).collect(),
3827            ..MD033Config::default()
3828        })
3829    }
3830
3831    #[test]
3832    fn test_md033_allowed_inside_permits_the_element_and_its_contents() {
3833        let content = "<details>\n<summary>\n<a href=\"./other.md\">Go there</a>\n</summary>\n<b>bold</b>\n</details>\n\nOutside <b>bold</b>.\n";
3834
3835        let without = reported_tags(&MD033NoInlineHtml::default(), content);
3836        assert_eq!(
3837            without,
3838            vec!["<details>", "<summary>", "<a href=\"./other.md\">", "<b>", "<b>"],
3839            "control: every tag is reported without the option"
3840        );
3841
3842        let with = reported_tags(&rule_allowing_inside(&["details"]), content);
3843        assert_eq!(
3844            with,
3845            vec!["<b>"],
3846            "only the tag outside the details element is left, got {with:?}"
3847        );
3848    }
3849
3850    #[test]
3851    fn test_md033_allowed_inside_ends_at_the_matching_closing_tag() {
3852        // The closing tag of the inner element does not end the outer one.
3853        let content = "<details>\n<details>\n<b>x</b>\n</details>\n<i>y</i>\n</details>\n<b>z</b>\n";
3854        let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3855        assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3856
3857        // An element left open covers the rest of the document.
3858        let unclosed = "<details>\n\n<b>x</b>\n\n<i>y</i>\n";
3859        assert!(
3860            reported_tags(&rule_allowing_inside(&["details"]), unclosed).is_empty(),
3861            "an unclosed element reaches the end of the document"
3862        );
3863    }
3864
3865    #[test]
3866    fn test_md033_allowed_inside_mismatched_and_unclosed_elements() {
3867        let content = "<details><section><b>in</b></details><i>out</i></section><b>out</b>\
3868                       <details><section/><i>in</i>";
3869        assert_eq!(
3870            reported_tags(&rule_allowing_inside(&["details", "section"]), content),
3871            vec!["<i>", "<b>"]
3872        );
3873    }
3874
3875    #[test]
3876    fn test_md033_allowed_inside_ignores_an_element_quoted_in_a_code_block() {
3877        let content = "```html\n<details>\n```\n\n<b>x</b>\n";
3878        let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3879        assert_eq!(
3880            reported,
3881            vec!["<b>"],
3882            "a code block quotes the element, it does not open one: {reported:?}"
3883        );
3884    }
3885
3886    #[test]
3887    fn test_md033_allowed_inside_a_void_element_holds_nothing() {
3888        // <br> has no contents, so naming it permits the <br> itself and nothing else.
3889        let content = "a<br>b <b>x</b>\n";
3890        let reported = reported_tags(&rule_allowing_inside(&["br"]), content);
3891        assert_eq!(
3892            reported,
3893            vec!["<br>", "<b>"],
3894            "a void element must not swallow the rest of the document: {reported:?}"
3895        );
3896    }
3897
3898    #[test]
3899    fn test_md033_allowed_inside_is_case_insensitive() {
3900        let content = "<DETAILS>\n<b>x</b>\n</DETAILS>\n<i>y</i>\n";
3901        let reported = reported_tags(&rule_allowing_inside(&["Details"]), content);
3902        assert_eq!(reported, vec!["<i>"], "got {reported:?}");
3903    }
3904
3905    #[test]
3906    fn test_md033_allowed_inside_takes_no_part_in_disallowed_mode() {
3907        let config = MD033Config {
3908            allowed_inside: vec!["details".to_string()],
3909            disallowed: vec!["b".to_string()],
3910            ..MD033Config::default()
3911        };
3912        let rule = MD033NoInlineHtml::from_config_struct(config);
3913        let content = "<details>\n<b>x</b>\n<kbd>k</kbd>\n</details>\n";
3914        let reported = reported_tags(&rule, content);
3915        assert_eq!(
3916            reported,
3917            vec!["<b>"],
3918            "a denylist names what is wrong wherever it appears: {reported:?}"
3919        );
3920    }
3921
3922    #[test]
3923    fn test_md033_allowed_inside_yields_to_an_explicit_table_allowlist() {
3924        let content = "| col |\n|-----|\n| <details><b>x</b></details> |\n";
3925
3926        let unset = reported_tags(&rule_allowing_inside(&["details"]), content);
3927        assert!(
3928            unset.is_empty(),
3929            "without a table allowlist the element applies inside a cell too: {unset:?}"
3930        );
3931
3932        let config = MD033Config {
3933            allowed_inside: vec!["details".to_string()],
3934            table_allowed_elements: Some(Vec::new()),
3935            ..MD033Config::default()
3936        };
3937        let rule = MD033NoInlineHtml::from_config_struct(config);
3938        let reported = reported_tags(&rule, content);
3939        assert_eq!(
3940            reported,
3941            vec!["<details>", "<b>"],
3942            "an explicit table allowlist decides inside a cell: {reported:?}"
3943        );
3944    }
3945
3946    // =========================================================================
3947    // no-markdown-equivalent sentinel tests
3948    //
3949    // Permits every element Markdown has no syntax for, so only elements a
3950    // reader could have written in Markdown are reported.
3951    // =========================================================================
3952
3953    fn rule_allowing(elements: &[&str]) -> MD033NoInlineHtml {
3954        MD033NoInlineHtml::with_allowed(elements.iter().map(ToString::to_string).collect())
3955    }
3956
3957    #[test]
3958    fn test_md033_no_markdown_equivalent_reports_only_what_markdown_can_write() {
3959        let content = "Press <kbd>Ctrl</kbd>, <b>bold</b>, <mark>hi</mark>, <abbr title=\"x\">A</abbr>, <em>i</em>, <details>d</details>\n";
3960
3961        let control = reported_tags(&MD033NoInlineHtml::default(), content);
3962        assert_eq!(control.len(), 6, "control: every tag is reported: {control:?}");
3963
3964        let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3965        assert_eq!(
3966            reported,
3967            vec!["<b>", "<em>"],
3968            "only the elements with Markdown syntax are left: {reported:?}"
3969        );
3970    }
3971
3972    #[test]
3973    fn test_md033_no_markdown_equivalent_keeps_reporting_gfm_filtered_tags() {
3974        // Nothing renders these, so permitting them is never about expressiveness.
3975        let content = "<kbd>k</kbd>\n\n<script>alert(1)</script>\n\n<iframe src=\"x\"></iframe>\n";
3976        let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3977        assert_eq!(reported, vec!["<script>", "<iframe src=\"x\">"], "got {reported:?}");
3978    }
3979
3980    #[test]
3981    fn test_md033_no_markdown_equivalent_follows_the_flavor() {
3982        let content = "H<sub>2</sub>O, x<sup>2</sup>, <mark>hi</mark>, <kbd>k</kbd>\n";
3983        let rule = rule_allowing(&["no-markdown-equivalent"]);
3984
3985        assert!(
3986            reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Standard).is_empty(),
3987            "standard Markdown writes none of these"
3988        );
3989        assert_eq!(
3990            reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Pandoc),
3991            vec!["<sub>", "<sup>"],
3992            "Pandoc writes ~sub~ and ^sup^"
3993        );
3994        assert_eq!(
3995            reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Obsidian),
3996            vec!["<mark>"],
3997            "Obsidian writes ==highlight=="
3998        );
3999    }
4000
4001    #[test]
4002    fn test_md033_no_markdown_equivalent_permits_a_line_break_inside_a_table_cell() {
4003        // Two trailing spaces do not survive inside a cell, so <br> has no
4004        // equivalent there and every other one it does.
4005        let content = "| col |\n|-----|\n| a<br>b |\n\nOutside a<br>b.\n";
4006        let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
4007        assert_eq!(reported, vec!["<br>"], "got {reported:?}");
4008    }
4009
4010    #[test]
4011    fn test_md033_no_markdown_equivalent_composes_with_named_elements() {
4012        let content = "<kbd>k</kbd> <b>bold</b> <em>italic</em>\n";
4013        let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent", "b"]), content);
4014        assert_eq!(
4015            reported,
4016            vec!["<em>"],
4017            "a named element joins the ones the sentinel permits: {reported:?}"
4018        );
4019    }
4020
4021    #[test]
4022    fn test_md033_no_markdown_equivalent_reads_the_snake_case_spelling() {
4023        // Config values are not normalized on the way in, unlike config keys.
4024        let content = "<kbd>k</kbd> <b>bold</b>\n";
4025        let reported = reported_tags(&rule_allowing(&["no_markdown_equivalent"]), content);
4026        assert_eq!(reported, vec!["<b>"], "got {reported:?}");
4027    }
4028
4029    #[test]
4030    fn test_md033_no_markdown_equivalent_takes_no_part_in_disallowed_mode() {
4031        let config = MD033Config {
4032            allowed: vec!["no-markdown-equivalent".to_string()],
4033            disallowed: vec!["kbd".to_string()],
4034            ..MD033Config::default()
4035        };
4036        let rule = MD033NoInlineHtml::from_config_struct(config);
4037        let reported = reported_tags(&rule, "<kbd>k</kbd> <b>bold</b>\n");
4038        assert_eq!(
4039            reported,
4040            vec!["<kbd>"],
4041            "a denylist names what is wrong wherever it appears: {reported:?}"
4042        );
4043    }
4044}