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