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