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