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