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