Skip to main content

rumdl_lib/rules/
md033_no_inline_html.rs

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