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, block IALs, front-matter, and math blocks
1014            if ctx.line_info(line_num).is_some_and(|info| {
1015                info.in_code_block
1016                    || info.in_pymdown_block
1017                    || info.is_kramdown_block_ial
1018                    || info.in_front_matter
1019                    || info.in_math_block
1020            }) {
1021                continue;
1022            }
1023
1024            // Skip HTML tags inside HTML comments
1025            if ctx.is_in_html_comment(tag_byte_start) || ctx.is_in_mdx_comment(tag_byte_start) {
1026                continue;
1027            }
1028
1029            // Skip HTML comments themselves
1030            if self.is_html_comment(tag) {
1031                continue;
1032            }
1033
1034            // Skip angle brackets inside link reference definition titles
1035            // e.g., [ref]: url "Title with <angle brackets>"
1036            if ctx.is_in_link_title(tag_byte_start) {
1037                continue;
1038            }
1039
1040            // Skip JSX components in MDX files (e.g., <Chart />, <MyComponent>)
1041            if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1042                continue;
1043            }
1044
1045            // Skip JSX fragments in MDX files (<> and </>)
1046            if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1047                continue;
1048            }
1049
1050            // Skip elements with JSX-specific attributes in MDX files
1051            // e.g., <div className="...">, <button onClick={handler}>
1052            if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1053                continue;
1054            }
1055
1056            // Skip non-HTML elements (placeholder syntax like <NAME>, <resource>)
1057            if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1058                continue;
1059            }
1060
1061            // Skip likely programming type annotations
1062            if self.is_likely_type_annotation(tag) {
1063                continue;
1064            }
1065
1066            // Skip email addresses in angle brackets
1067            if self.is_email_address(tag) {
1068                continue;
1069            }
1070
1071            // Skip URLs in angle brackets
1072            if self.is_url_in_angle_brackets(tag) {
1073                continue;
1074            }
1075
1076            // Skip tags inside code spans (use byte offset for reliable multi-line span detection)
1077            if ctx.is_byte_offset_in_code_span(tag_byte_start) {
1078                continue;
1079            }
1080
1081            // Determine whether to report this tag based on mode:
1082            // - Disallowed mode: only report tags in the disallowed list
1083            // - Default mode: report all tags except those in the allowed list,
1084            //   with `table_allowed` taking precedence inside GFM table cells.
1085            if self.is_disallowed_mode() {
1086                if !self.is_tag_disallowed(tag) {
1087                    continue;
1088                }
1089            } else if ctx.is_in_table_block(line_num) {
1090                if self.is_tag_allowed_in_table(tag) {
1091                    continue;
1092                }
1093            } else if self.is_tag_allowed(tag) {
1094                continue;
1095            }
1096
1097            // Skip tags with markdown attribute in MkDocs mode
1098            if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1099                continue;
1100            }
1101
1102            // Check if we're inside an HTML block (like <pre>, <div>, etc.)
1103            let in_html_block = ctx.is_in_html_block(line_num);
1104
1105            // Calculate fix to remove HTML tags but keep content
1106            let fix = self
1107                .calculate_fix(content, tag, tag_byte_start, in_html_block)
1108                .map(|(range, replacement)| Fix::new(range, replacement));
1109
1110            // Calculate actual end line and column for multiline tags
1111            // Use byte_end - 1 to get the last character position of the tag
1112            let (end_line, end_col) = if html_tag.byte_end > 0 {
1113                ctx.offset_to_line_col(html_tag.byte_end - 1)
1114            } else {
1115                (line_num, html_tag.end_col + 1)
1116            };
1117
1118            // Report the HTML tag
1119            warnings.push(LintWarning {
1120                rule_name: Some(self.name().to_string()),
1121                line: line_num,
1122                column: html_tag.start_col + 1, // Convert to 1-indexed
1123                end_line,                       // Actual end line for multiline tags
1124                end_column: end_col + 1,        // Actual end column
1125                message: format!("Inline HTML found: {tag}"),
1126                severity: Severity::Warning,
1127                fix,
1128            });
1129        }
1130
1131        Ok(warnings)
1132    }
1133
1134    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1135        // Auto-fix is opt-in: only apply if explicitly enabled in config
1136        if !self.config.fix {
1137            return Ok(ctx.content.to_string());
1138        }
1139
1140        // Get warnings with their inline fixes
1141        let warnings = self.check(ctx)?;
1142        let warnings =
1143            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1144
1145        // If no warnings with fixes, return original content
1146        if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1147            return Ok(ctx.content.to_string());
1148        }
1149
1150        // Collect all fixes and sort by range start (descending) to apply from end to beginning
1151        let mut fixes: Vec<_> = warnings
1152            .iter()
1153            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1154            .collect();
1155        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1156
1157        // Apply fixes from end to beginning to preserve byte offsets
1158        let mut result = ctx.content.to_string();
1159        for (start, end, replacement) in fixes {
1160            if start < result.len() && end <= result.len() && start <= end {
1161                result.replace_range(start..end, replacement);
1162            }
1163        }
1164
1165        Ok(result)
1166    }
1167
1168    fn fix_capability(&self) -> crate::rule::FixCapability {
1169        if self.config.fix {
1170            crate::rule::FixCapability::FullyFixable
1171        } else {
1172            crate::rule::FixCapability::Unfixable
1173        }
1174    }
1175
1176    /// Get the category of this rule for selective processing
1177    fn category(&self) -> RuleCategory {
1178        RuleCategory::Html
1179    }
1180
1181    /// Check if this rule should be skipped
1182    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1183        ctx.content.is_empty() || !ctx.likely_has_html()
1184    }
1185
1186    fn as_any(&self) -> &dyn std::any::Any {
1187        self
1188    }
1189
1190    crate::impl_rule_config_methods!(MD033Config, nullable);
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
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use crate::lint_context::LintContext;
1205    use crate::rule::Rule;
1206
1207    fn relaxed_fix_rule() -> MD033NoInlineHtml {
1208        let config = MD033Config {
1209            fix: true,
1210            fix_mode: MD033FixMode::Relaxed,
1211            ..MD033Config::default()
1212        };
1213        MD033NoInlineHtml::from_config_struct(config)
1214    }
1215
1216    #[test]
1217    fn test_md033_basic_html() {
1218        let rule = MD033NoInlineHtml::default();
1219        let content = "<div>Some content</div>";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222        // Only reports opening tags, not closing tags
1223        assert_eq!(result.len(), 1); // Only <div>, not </div>
1224        assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1225    }
1226
1227    #[test]
1228    fn test_md033_front_matter() {
1229        let rule = MD033NoInlineHtml::default();
1230        let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232        let result = rule.check(&ctx).unwrap();
1233        // Should only report <div>body</div> (line 5), not <div class="test"> (line 2)
1234        assert_eq!(result.len(), 1);
1235        assert_eq!(result[0].line, 5);
1236        assert_eq!(result[0].message, "Inline HTML found: <div>");
1237    }
1238
1239    #[test]
1240    fn test_md033_math_block() {
1241        let rule = MD033NoInlineHtml::default();
1242        let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245        // Should only report <div>body</div> (line 4), not the fake tag <y> in math (line 2)
1246        assert_eq!(result.len(), 1);
1247        assert_eq!(result[0].line, 4);
1248    }
1249
1250    #[test]
1251    fn test_md033_case_insensitive() {
1252        let rule = MD033NoInlineHtml::default();
1253        let content = "<DiV>Some <B>content</B></dIv>";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255        let result = rule.check(&ctx).unwrap();
1256        // Only reports opening tags, not closing tags
1257        assert_eq!(result.len(), 2); // <DiV>, <B> (not </B>, </dIv>)
1258        assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1259        assert_eq!(result[1].message, "Inline HTML found: <B>");
1260    }
1261
1262    #[test]
1263    fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1264        // A non-ASCII whitespace (U+00A0 NO-BREAK SPACE) before the attributes
1265        // must not cause a non-char-boundary slice panic while parsing attributes.
1266        let rule = relaxed_fix_rule();
1267        let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269        // check() and fix() both reach parse_attributes; neither may panic.
1270        let _ = rule.check(&ctx).unwrap();
1271        let _ = rule.fix(&ctx).unwrap();
1272    }
1273
1274    #[test]
1275    fn test_md033_allowed_tags() {
1276        let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1277        let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279        let result = rule.check(&ctx).unwrap();
1280        // Only warnings for non-allowed opening tags (<p> only, div and br are allowed)
1281        assert_eq!(result.len(), 1);
1282        assert_eq!(result[0].message, "Inline HTML found: <p>");
1283
1284        // Test case-insensitivity of allowed tags
1285        let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1286        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1287        let result2 = rule.check(&ctx2).unwrap();
1288        assert_eq!(result2.len(), 1); // Only <P> flagged
1289        assert_eq!(result2[0].message, "Inline HTML found: <P>");
1290    }
1291
1292    #[test]
1293    fn test_md033_html_comments() {
1294        let rule = MD033NoInlineHtml::default();
1295        let content = "<!-- This is a comment --> <p>Not a comment</p>";
1296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297        let result = rule.check(&ctx).unwrap();
1298        // Should detect warnings for HTML opening tags (comments are skipped, closing tags not reported)
1299        assert_eq!(result.len(), 1); // Only <p>
1300        assert_eq!(result[0].message, "Inline HTML found: <p>");
1301    }
1302
1303    #[test]
1304    fn test_md033_tags_in_links() {
1305        let rule = MD033NoInlineHtml::default();
1306        let content = "[Link](http://example.com/<div>)";
1307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308        let result = rule.check(&ctx).unwrap();
1309        // The <div> in the URL should be detected as HTML (not skipped)
1310        assert_eq!(result.len(), 1);
1311        assert_eq!(result[0].message, "Inline HTML found: <div>");
1312
1313        let content2 = "[Link <a>text</a>](url)";
1314        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1315        let result2 = rule.check(&ctx2).unwrap();
1316        // Only reports opening tags
1317        assert_eq!(result2.len(), 1); // Only <a>
1318        assert_eq!(result2[0].message, "Inline HTML found: <a>");
1319    }
1320
1321    #[test]
1322    fn test_md033_fix_escaping() {
1323        let rule = MD033NoInlineHtml::default();
1324        let content = "Text with <div> and <br/> tags.";
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326        let fixed_content = rule.fix(&ctx).unwrap();
1327        // No fix for HTML tags; output should be unchanged
1328        assert_eq!(fixed_content, content);
1329    }
1330
1331    #[test]
1332    fn test_md033_in_code_blocks() {
1333        let rule = MD033NoInlineHtml::default();
1334        let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336        let result = rule.check(&ctx).unwrap();
1337        // Only reports opening tags outside code block
1338        assert_eq!(result.len(), 1); // Only <div> outside code block
1339        assert_eq!(result[0].message, "Inline HTML found: <div>");
1340    }
1341
1342    #[test]
1343    fn test_md033_in_code_spans() {
1344        let rule = MD033NoInlineHtml::default();
1345        let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        let result = rule.check(&ctx).unwrap();
1348        // Should detect <br/> outside code span, but not tags inside code span
1349        assert_eq!(result.len(), 1);
1350        assert_eq!(result[0].message, "Inline HTML found: <br/>");
1351    }
1352
1353    #[test]
1354    fn test_md033_issue_90_code_span_with_diff_block() {
1355        // Test for issue #90: inline code span followed by diff code block
1356        let rule = MD033NoInlineHtml::default();
1357        let content = r#"# Heading
1358
1359`<env>`
1360
1361```diff
1362- this
1363+ that
1364```"#;
1365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366        let result = rule.check(&ctx).unwrap();
1367        // Should NOT detect <env> as HTML since it's inside backticks
1368        assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1369    }
1370
1371    #[test]
1372    fn test_md033_multiple_code_spans_with_angle_brackets() {
1373        // Test multiple code spans on same line
1374        let rule = MD033NoInlineHtml::default();
1375        let content = "`<one>` and `<two>` and `<three>` are all code spans";
1376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377        let result = rule.check(&ctx).unwrap();
1378        assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1379    }
1380
1381    #[test]
1382    fn test_md033_nested_angle_brackets_in_code_span() {
1383        // Test nested angle brackets
1384        let rule = MD033NoInlineHtml::default();
1385        let content = "Text with `<<nested>>` brackets";
1386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387        let result = rule.check(&ctx).unwrap();
1388        assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1389    }
1390
1391    #[test]
1392    fn test_md033_code_span_at_end_before_code_block() {
1393        // Test code span at end of line before code block
1394        let rule = MD033NoInlineHtml::default();
1395        let content = "Testing `<test>`\n```\ncode here\n```";
1396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397        let result = rule.check(&ctx).unwrap();
1398        assert_eq!(result.len(), 0, "Should handle code span before code block");
1399    }
1400
1401    #[test]
1402    fn test_md033_quick_fix_inline_tag() {
1403        // Test that non-fixable tags (like <span>) do NOT get a fix
1404        // Only safe fixable tags (em, i, strong, b, code, br, hr) with fix=true get fixes
1405        let rule = MD033NoInlineHtml::default();
1406        let content = "This has <span>inline text</span> that should keep content.";
1407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408        let result = rule.check(&ctx).unwrap();
1409
1410        assert_eq!(result.len(), 1, "Should find one HTML tag");
1411        // <span> is NOT a safe fixable tag, so no fix should be provided
1412        assert!(
1413            result[0].fix.is_none(),
1414            "Non-fixable tags like <span> should not have a fix"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_md033_quick_fix_multiline_tag() {
1420        // HTML block elements like <div> are intentionally NOT auto-fixed
1421        // Removing them would change document structure significantly
1422        let rule = MD033NoInlineHtml::default();
1423        let content = "<div>\nBlock content\n</div>";
1424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425        let result = rule.check(&ctx).unwrap();
1426
1427        assert_eq!(result.len(), 1, "Should find one HTML tag");
1428        // HTML block elements should NOT have auto-fix
1429        assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1430    }
1431
1432    #[test]
1433    fn test_md033_quick_fix_self_closing_tag() {
1434        // Test that self-closing tags with fix=false (default) do NOT get a fix
1435        let rule = MD033NoInlineHtml::default();
1436        let content = "Self-closing: <br/>";
1437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438        let result = rule.check(&ctx).unwrap();
1439
1440        assert_eq!(result.len(), 1, "Should find one HTML tag");
1441        // Default config has fix=false, so no fix should be provided
1442        assert!(
1443            result[0].fix.is_none(),
1444            "Self-closing tags should not have a fix when fix config is false"
1445        );
1446    }
1447
1448    #[test]
1449    fn test_md033_quick_fix_multiple_tags() {
1450        // Test that multiple tags without fix=true do NOT get fixes
1451        // <span> is not a safe fixable tag, <strong> is but fix=false by default
1452        let rule = MD033NoInlineHtml::default();
1453        let content = "<span>first</span> and <strong>second</strong>";
1454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1455        let result = rule.check(&ctx).unwrap();
1456
1457        assert_eq!(result.len(), 2, "Should find two HTML tags");
1458        // Neither should have a fix: <span> is not fixable, <strong> is but fix=false
1459        assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1460        assert!(
1461            result[1].fix.is_none(),
1462            "<strong> should not have a fix when fix config is false"
1463        );
1464    }
1465
1466    #[test]
1467    fn test_md033_skip_angle_brackets_in_link_titles() {
1468        // Angle brackets inside link reference definition titles should not be flagged as HTML
1469        let rule = MD033NoInlineHtml::default();
1470        let content = r#"# Test
1471
1472[example]: <https://example.com> "Title with <Angle Brackets> inside"
1473
1474Regular text with <div>content</div> HTML tag.
1475"#;
1476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477        let result = rule.check(&ctx).unwrap();
1478
1479        // Should only flag <div>, not <Angle Brackets> in the title (not a valid HTML element)
1480        // Opening tag only (markdownlint behavior)
1481        assert_eq!(result.len(), 1, "Should find opening div tag");
1482        assert!(
1483            result[0].message.contains("<div>"),
1484            "Should flag <div>, got: {}",
1485            result[0].message
1486        );
1487    }
1488
1489    #[test]
1490    fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1491        // Test with single-quoted title
1492        let rule = MD033NoInlineHtml::default();
1493        let content = r#"[ref]: url 'Title <Help Wanted> here'
1494
1495<span>text</span> here
1496"#;
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498        let result = rule.check(&ctx).unwrap();
1499
1500        // <Help Wanted> is not a valid HTML element, so only <span> is flagged
1501        // Opening tag only (markdownlint behavior)
1502        assert_eq!(result.len(), 1, "Should find opening span tag");
1503        assert!(
1504            result[0].message.contains("<span>"),
1505            "Should flag <span>, got: {}",
1506            result[0].message
1507        );
1508    }
1509
1510    #[test]
1511    fn test_md033_multiline_tag_end_line_calculation() {
1512        // Test that multiline HTML tags report correct end_line
1513        let rule = MD033NoInlineHtml::default();
1514        let content = "<div\n  class=\"test\"\n  id=\"example\">";
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        // Tag starts on line 1
1520        assert_eq!(result[0].line, 1, "Start line should be 1");
1521        // Tag ends on line 3 (where the closing > is)
1522        assert_eq!(result[0].end_line, 3, "End line should be 3");
1523    }
1524
1525    #[test]
1526    fn test_md033_single_line_tag_same_start_end_line() {
1527        // Test that single-line HTML tags have same start and end line
1528        let rule = MD033NoInlineHtml::default();
1529        let content = "Some text <div class=\"test\"> more text";
1530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531        let result = rule.check(&ctx).unwrap();
1532
1533        assert_eq!(result.len(), 1, "Should find one HTML tag");
1534        assert_eq!(result[0].line, 1, "Start line should be 1");
1535        assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1536    }
1537
1538    #[test]
1539    fn test_md033_multiline_tag_with_many_attributes() {
1540        // Test multiline tag spanning multiple lines
1541        let rule = MD033NoInlineHtml::default();
1542        let content =
1543            "Text\n<div\n  data-attr1=\"value1\"\n  data-attr2=\"value2\"\n  data-attr3=\"value3\">\nMore text";
1544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545        let result = rule.check(&ctx).unwrap();
1546
1547        assert_eq!(result.len(), 1, "Should find one HTML tag");
1548        // Tag starts on line 2 (first line is "Text")
1549        assert_eq!(result[0].line, 2, "Start line should be 2");
1550        // Tag ends on line 5 (where the closing > is)
1551        assert_eq!(result[0].end_line, 5, "End line should be 5");
1552    }
1553
1554    #[test]
1555    fn test_md033_disallowed_mode_basic() {
1556        // Test disallowed mode: only flags tags in the disallowed list
1557        let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1558        let content = "<div>Safe content</div><script>alert('xss')</script>";
1559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560        let result = rule.check(&ctx).unwrap();
1561
1562        // Should only flag <script>, not <div>
1563        assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1564        assert!(result[0].message.contains("<script>"), "Should flag script tag");
1565    }
1566
1567    #[test]
1568    fn test_md033_disallowed_gfm_security_tags() {
1569        // Test GFM security tags expansion
1570        let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1571        let content = r#"
1572<div>Safe</div>
1573<title>Bad title</title>
1574<textarea>Bad textarea</textarea>
1575<style>.bad{}</style>
1576<iframe src="evil"></iframe>
1577<script>evil()</script>
1578<plaintext>old tag</plaintext>
1579<span>Safe span</span>
1580"#;
1581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582        let result = rule.check(&ctx).unwrap();
1583
1584        // Should flag: title, textarea, style, iframe, script, plaintext
1585        // Should NOT flag: div, span
1586        assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1587
1588        let flagged_tags: Vec<&str> = result
1589            .iter()
1590            .filter_map(|w| w.message.split('<').nth(1))
1591            .filter_map(|s| s.split('>').next())
1592            .filter_map(|s| s.split_whitespace().next())
1593            .collect();
1594
1595        assert!(flagged_tags.contains(&"title"), "Should flag title");
1596        assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1597        assert!(flagged_tags.contains(&"style"), "Should flag style");
1598        assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1599        assert!(flagged_tags.contains(&"script"), "Should flag script");
1600        assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1601        assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1602        assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1603    }
1604
1605    #[test]
1606    fn test_md033_disallowed_case_insensitive() {
1607        // Test that disallowed check is case-insensitive
1608        let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1609        let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611        let result = rule.check(&ctx).unwrap();
1612
1613        // Should flag both <SCRIPT> and <Script>
1614        assert_eq!(result.len(), 2, "Should flag both case variants");
1615    }
1616
1617    #[test]
1618    fn test_md033_disallowed_with_attributes() {
1619        // Test that disallowed mode works with tags that have attributes
1620        let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1621        let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
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, "Should flag iframe with attributes");
1626        assert!(result[0].message.contains("iframe"), "Should flag iframe");
1627    }
1628
1629    #[test]
1630    fn test_md033_disallowed_all_gfm_tags() {
1631        // Verify all GFM disallowed tags are covered
1632        use md033_config::GFM_DISALLOWED_TAGS;
1633        let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1634
1635        for tag in GFM_DISALLOWED_TAGS {
1636            let content = format!("<{tag}>content</{tag}>");
1637            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1638            let result = rule.check(&ctx).unwrap();
1639
1640            assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1641        }
1642    }
1643
1644    #[test]
1645    fn test_md033_disallowed_mixed_with_custom() {
1646        // Test mixing "gfm" with custom disallowed tags
1647        let rule = MD033NoInlineHtml::with_disallowed(vec![
1648            "gfm".to_string(),
1649            "marquee".to_string(), // Custom disallowed tag
1650        ]);
1651        let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653        let result = rule.check(&ctx).unwrap();
1654
1655        // Should flag script (gfm) and marquee (custom)
1656        assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1657    }
1658
1659    #[test]
1660    fn test_md033_disallowed_empty_means_default_mode() {
1661        // Empty disallowed list means default mode (flag all HTML)
1662        let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1663        let content = "<div>content</div>";
1664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665        let result = rule.check(&ctx).unwrap();
1666
1667        // Should flag <div> in default mode
1668        assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1669    }
1670
1671    #[test]
1672    fn test_md033_jsx_fragments_in_mdx() {
1673        // JSX fragments (<> and </>) should not trigger warnings in MDX
1674        let rule = MD033NoInlineHtml::default();
1675        let content = r#"# MDX Document
1676
1677<>
1678  <Heading />
1679  <Content />
1680</>
1681
1682<div>Regular HTML should still be flagged</div>
1683"#;
1684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1685        let result = rule.check(&ctx).unwrap();
1686
1687        // Should only flag <div>, not the fragments or JSX components
1688        assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1689        assert!(
1690            result[0].message.contains("<div>"),
1691            "Should flag <div>, not JSX fragments"
1692        );
1693    }
1694
1695    #[test]
1696    fn test_md033_jsx_components_in_mdx() {
1697        // JSX components (capitalized) should not trigger warnings in MDX
1698        let rule = MD033NoInlineHtml::default();
1699        let content = r#"<CustomComponent prop="value">
1700  Content
1701</CustomComponent>
1702
1703<MyButton onClick={handler}>Click</MyButton>
1704"#;
1705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1706        let result = rule.check(&ctx).unwrap();
1707
1708        // No warnings - all are JSX components
1709        assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1710    }
1711
1712    #[test]
1713    fn test_md033_jsx_not_skipped_in_standard_markdown() {
1714        // In standard markdown, capitalized tags should still be flagged if they're valid HTML
1715        let rule = MD033NoInlineHtml::default();
1716        let content = "<Script>alert(1)</Script>";
1717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718        let result = rule.check(&ctx).unwrap();
1719
1720        // Should flag <Script> in standard markdown (it's a valid HTML element)
1721        assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1722    }
1723
1724    #[test]
1725    fn test_md033_jsx_attributes_in_mdx() {
1726        // Elements with JSX-specific attributes should not trigger warnings in MDX
1727        let rule = MD033NoInlineHtml::default();
1728        let content = r#"# MDX with JSX Attributes
1729
1730<div className="card big">Content</div>
1731
1732<button onClick={handleClick}>Click me</button>
1733
1734<label htmlFor="input-id">Label</label>
1735
1736<input onChange={handleChange} />
1737
1738<div class="html-class">Regular HTML should be flagged</div>
1739"#;
1740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1741        let result = rule.check(&ctx).unwrap();
1742
1743        // Should only flag the div with regular HTML "class" attribute
1744        assert_eq!(
1745            result.len(),
1746            1,
1747            "Should only flag HTML element without JSX attributes, got: {result:?}"
1748        );
1749        assert!(
1750            result[0].message.contains("<div class="),
1751            "Should flag the div with HTML class attribute"
1752        );
1753    }
1754
1755    #[test]
1756    fn test_md033_jsx_attributes_not_skipped_in_standard() {
1757        // In standard markdown, JSX attributes should still be flagged
1758        let rule = MD033NoInlineHtml::default();
1759        let content = r#"<div className="card">Content</div>"#;
1760        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761        let result = rule.check(&ctx).unwrap();
1762
1763        // Should flag in standard markdown
1764        assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1765    }
1766
1767    // Auto-fix tests for MD033
1768
1769    #[test]
1770    fn test_md033_fix_disabled_by_default() {
1771        // Auto-fix should be disabled by default
1772        let rule = MD033NoInlineHtml::default();
1773        assert!(!rule.config.fix, "Fix should be disabled by default");
1774        assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1775    }
1776
1777    #[test]
1778    fn test_md033_fix_enabled_em_to_italic() {
1779        // When fix is enabled, <em>text</em> should convert to *text*
1780        let rule = MD033NoInlineHtml::with_fix(true);
1781        let content = "This has <em>emphasized text</em> here.";
1782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783        let fixed = rule.fix(&ctx).unwrap();
1784        assert_eq!(fixed, "This has *emphasized text* here.");
1785    }
1786
1787    #[test]
1788    fn test_md033_fix_enabled_i_to_italic() {
1789        // <i>text</i> should convert to *text*
1790        let rule = MD033NoInlineHtml::with_fix(true);
1791        let content = "This has <i>italic text</i> here.";
1792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793        let fixed = rule.fix(&ctx).unwrap();
1794        assert_eq!(fixed, "This has *italic text* here.");
1795    }
1796
1797    #[test]
1798    fn test_md033_fix_enabled_strong_to_bold() {
1799        // <strong>text</strong> should convert to **text**
1800        let rule = MD033NoInlineHtml::with_fix(true);
1801        let content = "This has <strong>bold text</strong> here.";
1802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803        let fixed = rule.fix(&ctx).unwrap();
1804        assert_eq!(fixed, "This has **bold text** here.");
1805    }
1806
1807    #[test]
1808    fn test_md033_fix_enabled_b_to_bold() {
1809        // <b>text</b> should convert to **text**
1810        let rule = MD033NoInlineHtml::with_fix(true);
1811        let content = "This has <b>bold text</b> here.";
1812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1813        let fixed = rule.fix(&ctx).unwrap();
1814        assert_eq!(fixed, "This has **bold text** here.");
1815    }
1816
1817    #[test]
1818    fn test_md033_fix_enabled_code_to_backticks() {
1819        // <code>text</code> should convert to `text`
1820        let rule = MD033NoInlineHtml::with_fix(true);
1821        let content = "This has <code>inline code</code> here.";
1822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823        let fixed = rule.fix(&ctx).unwrap();
1824        assert_eq!(fixed, "This has `inline code` here.");
1825    }
1826
1827    #[test]
1828    fn test_md033_fix_enabled_code_with_backticks() {
1829        // <code>text with `backticks`</code> should use double backticks
1830        let rule = MD033NoInlineHtml::with_fix(true);
1831        let content = "This has <code>text with `backticks`</code> here.";
1832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833        let fixed = rule.fix(&ctx).unwrap();
1834        assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1835    }
1836
1837    #[test]
1838    fn test_md033_fix_enabled_br_trailing_spaces() {
1839        // <br> should convert to two trailing spaces + newline (default)
1840        let rule = MD033NoInlineHtml::with_fix(true);
1841        let content = "First line<br>Second line";
1842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843        let fixed = rule.fix(&ctx).unwrap();
1844        assert_eq!(fixed, "First line  \nSecond line");
1845    }
1846
1847    #[test]
1848    fn test_md033_fix_enabled_br_self_closing() {
1849        // <br/> and <br /> should also convert
1850        let rule = MD033NoInlineHtml::with_fix(true);
1851        let content = "First<br/>second<br />third";
1852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853        let fixed = rule.fix(&ctx).unwrap();
1854        assert_eq!(fixed, "First  \nsecond  \nthird");
1855    }
1856
1857    #[test]
1858    fn test_md033_fix_enabled_br_backslash_style() {
1859        // With br_style = backslash, <br> should convert to backslash + newline
1860        let config = MD033Config {
1861            allowed: Vec::new(),
1862            disallowed: Vec::new(),
1863            fix: true,
1864            br_style: md033_config::BrStyle::Backslash,
1865            ..MD033Config::default()
1866        };
1867        let rule = MD033NoInlineHtml::from_config_struct(config);
1868        let content = "First line<br>Second line";
1869        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870        let fixed = rule.fix(&ctx).unwrap();
1871        assert_eq!(fixed, "First line\\\nSecond line");
1872    }
1873
1874    #[test]
1875    fn test_md033_fix_enabled_hr() {
1876        // <hr> should convert to horizontal rule
1877        let rule = MD033NoInlineHtml::with_fix(true);
1878        let content = "Above<hr>Below";
1879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880        let fixed = rule.fix(&ctx).unwrap();
1881        assert_eq!(fixed, "Above\n---\nBelow");
1882    }
1883
1884    #[test]
1885    fn test_md033_fix_enabled_hr_self_closing() {
1886        // <hr/> should also convert
1887        let rule = MD033NoInlineHtml::with_fix(true);
1888        let content = "Above<hr/>Below";
1889        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890        let fixed = rule.fix(&ctx).unwrap();
1891        assert_eq!(fixed, "Above\n---\nBelow");
1892    }
1893
1894    #[test]
1895    fn test_md033_fix_skips_nested_tags() {
1896        // Tags with nested HTML - outer tags may not be fully fixed due to overlapping ranges
1897        // The inner tags are processed first, which can invalidate outer tag ranges
1898        let rule = MD033NoInlineHtml::with_fix(true);
1899        let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901        let fixed = rule.fix(&ctx).unwrap();
1902        // Inner <strong> is converted to markdown, outer <em> range becomes invalid
1903        // This is expected behavior - user should run fix multiple times for nested tags
1904        assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1905    }
1906
1907    #[test]
1908    fn test_md033_fix_skips_tags_with_attributes() {
1909        // Tags with attributes should NOT be fixed at all - leave as-is
1910        // User may want to keep the attributes (e.g., class="highlight" for styling)
1911        let rule = MD033NoInlineHtml::with_fix(true);
1912        let content = "This has <em class=\"highlight\">emphasized</em> text.";
1913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1914        let fixed = rule.fix(&ctx).unwrap();
1915        // Content should remain unchanged - we don't know if attributes matter
1916        assert_eq!(fixed, content);
1917    }
1918
1919    #[test]
1920    fn test_md033_fix_disabled_no_changes() {
1921        // When fix is disabled, original content should be returned
1922        let rule = MD033NoInlineHtml::default(); // fix is false by default
1923        let content = "This has <em>emphasized text</em> here.";
1924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925        let fixed = rule.fix(&ctx).unwrap();
1926        assert_eq!(fixed, content, "Should return original content when fix is disabled");
1927    }
1928
1929    #[test]
1930    fn test_md033_fix_capability_enabled() {
1931        let rule = MD033NoInlineHtml::with_fix(true);
1932        assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1933    }
1934
1935    #[test]
1936    fn test_md033_fix_multiple_tags() {
1937        // Test fixing multiple HTML tags in one document
1938        let rule = MD033NoInlineHtml::with_fix(true);
1939        let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941        let fixed = rule.fix(&ctx).unwrap();
1942        assert_eq!(fixed, "Here is *italic* and **bold** text.");
1943    }
1944
1945    #[test]
1946    fn test_md033_fix_uppercase_tags() {
1947        // HTML tags are case-insensitive
1948        let rule = MD033NoInlineHtml::with_fix(true);
1949        let content = "This has <EM>emphasized</EM> text.";
1950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951        let fixed = rule.fix(&ctx).unwrap();
1952        assert_eq!(fixed, "This has *emphasized* text.");
1953    }
1954
1955    #[test]
1956    fn test_md033_fix_unsafe_tags_not_modified() {
1957        // Tags without safe markdown equivalents should NOT be modified
1958        // Only safe fixable tags (em, i, strong, b, code, br, hr) get converted
1959        let rule = MD033NoInlineHtml::with_fix(true);
1960        let content = "This has <div>a div</div> content.";
1961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962        let fixed = rule.fix(&ctx).unwrap();
1963        // <div> is not a safe fixable tag, so content should be unchanged
1964        assert_eq!(fixed, "This has <div>a div</div> content.");
1965    }
1966
1967    #[test]
1968    fn test_md033_fix_img_tag_converted() {
1969        // <img> tags with simple src/alt attributes are converted to markdown images
1970        let rule = MD033NoInlineHtml::with_fix(true);
1971        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973        let fixed = rule.fix(&ctx).unwrap();
1974        // <img> is converted to ![alt](src) format
1975        assert_eq!(fixed, "Image: ![My Photo](photo.jpg)");
1976    }
1977
1978    #[test]
1979    fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1980        // <img> tags with width/height/style attributes are NOT converted
1981        let rule = MD033NoInlineHtml::with_fix(true);
1982        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984        let fixed = rule.fix(&ctx).unwrap();
1985        // Has width attribute - not safe to convert
1986        assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1987    }
1988
1989    #[test]
1990    fn test_md033_fix_relaxed_a_with_target_is_converted() {
1991        let rule = relaxed_fix_rule();
1992        let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994        let fixed = rule.fix(&ctx).unwrap();
1995        assert_eq!(fixed, "Link: [Example](https://example.com)");
1996    }
1997
1998    #[test]
1999    fn test_md033_fix_relaxed_img_with_width_is_converted() {
2000        let rule = relaxed_fix_rule();
2001        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2003        let fixed = rule.fix(&ctx).unwrap();
2004        assert_eq!(fixed, "Image: ![My Photo](photo.jpg)");
2005    }
2006
2007    #[test]
2008    fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2009        let rule = relaxed_fix_rule();
2010        let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012        let fixed = rule.fix(&ctx).unwrap();
2013        assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2014    }
2015
2016    #[test]
2017    fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2018        let rule = relaxed_fix_rule();
2019        let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021        let fixed = rule.fix(&ctx).unwrap();
2022        assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2023    }
2024
2025    #[test]
2026    fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2027        let rule = relaxed_fix_rule();
2028        let content = "<p align=\"center\">\n  <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2029        let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030        let fixed_once = rule.fix(&ctx1).unwrap();
2031        assert!(
2032            fixed_once.contains("<p"),
2033            "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2034        );
2035        assert!(
2036            fixed_once.contains("![Logo](logo.svg)"),
2037            "Inner image should be converted on first pass: {fixed_once}"
2038        );
2039
2040        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2041        let fixed_twice = rule.fix(&ctx2).unwrap();
2042        assert!(
2043            !fixed_twice.contains("<p"),
2044            "Second pass should strip configured wrapper: {fixed_twice}"
2045        );
2046        assert!(fixed_twice.contains("![Logo](logo.svg)"));
2047    }
2048
2049    #[test]
2050    fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2051        let rule = relaxed_fix_rule();
2052        let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054        let fixed = rule.fix(&ctx).unwrap();
2055        assert_eq!(fixed, "[Click](https://example.com)");
2056    }
2057
2058    #[test]
2059    fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2060        let rule = relaxed_fix_rule();
2061        let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063        let fixed = rule.fix(&ctx).unwrap();
2064        assert_eq!(fixed, "![Logo](logo.png)");
2065    }
2066
2067    #[test]
2068    fn test_md033_fix_relaxed_event_handler_never_dropped() {
2069        let rule = relaxed_fix_rule();
2070        let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072        let fixed = rule.fix(&ctx).unwrap();
2073        assert_eq!(fixed, content, "Event handler attributes must block conversion");
2074    }
2075
2076    #[test]
2077    fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2078        // Even if someone adds on* to drop-attributes, event handlers must be rejected
2079        let config = MD033Config {
2080            fix: true,
2081            fix_mode: MD033FixMode::Relaxed,
2082            drop_attributes: vec!["on*".to_string(), "target".to_string()],
2083            ..MD033Config::default()
2084        };
2085        let rule = MD033NoInlineHtml::from_config_struct(config);
2086        let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2087        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2088        let fixed = rule.fix(&ctx).unwrap();
2089        assert_eq!(fixed, content, "on* event handlers must never be dropped");
2090    }
2091
2092    #[test]
2093    fn test_md033_fix_relaxed_custom_drop_attributes() {
2094        let config = MD033Config {
2095            fix: true,
2096            fix_mode: MD033FixMode::Relaxed,
2097            drop_attributes: vec!["loading".to_string()],
2098            ..MD033Config::default()
2099        };
2100        let rule = MD033NoInlineHtml::from_config_struct(config);
2101        // "loading" is in the custom list, "width" is NOT
2102        let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104        let fixed = rule.fix(&ctx).unwrap();
2105        assert_eq!(fixed, "![](x.jpg)", "Custom drop-attributes should be respected");
2106
2107        let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2108        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2109        let fixed2 = rule.fix(&ctx2).unwrap();
2110        assert_eq!(
2111            fixed2, content2,
2112            "Attributes not in custom list should block conversion"
2113        );
2114    }
2115
2116    #[test]
2117    fn test_md033_fix_relaxed_custom_strip_wrapper() {
2118        let config = MD033Config {
2119            fix: true,
2120            fix_mode: MD033FixMode::Relaxed,
2121            strip_wrapper_elements: vec!["div".to_string()],
2122            ..MD033Config::default()
2123        };
2124        let rule = MD033NoInlineHtml::from_config_struct(config);
2125        let content = "<div>Some text content</div>";
2126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127        let fixed = rule.fix(&ctx).unwrap();
2128        assert_eq!(fixed, "Some text content");
2129    }
2130
2131    #[test]
2132    fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2133        let rule = relaxed_fix_rule();
2134        let content = "<p align=\"center\">Just some text</p>";
2135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136        let fixed = rule.fix(&ctx).unwrap();
2137        assert_eq!(fixed, "Just some text");
2138    }
2139
2140    #[test]
2141    fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2142        let config = MD033Config {
2143            fix: true,
2144            fix_mode: MD033FixMode::Relaxed,
2145            drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2146            ..MD033Config::default()
2147        };
2148        let rule = MD033NoInlineHtml::from_config_struct(config);
2149        let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2151        let fixed = rule.fix(&ctx).unwrap();
2152        assert_eq!(fixed, "[Link](https://example.com)");
2153    }
2154
2155    #[test]
2156    fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2157        let rule = relaxed_fix_rule();
2158        // "target" is droppable, "aria-label" is not in the default list
2159        let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2160        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161        let fixed = rule.fix(&ctx).unwrap();
2162        assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2163    }
2164
2165    #[test]
2166    fn test_md033_fix_relaxed_badge_pattern() {
2167        // Common GitHub README badge pattern
2168        let rule = relaxed_fix_rule();
2169        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>";
2170        let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2171        let fixed_once = rule.fix(&ctx1).unwrap();
2172        // First pass should convert the inner <img>
2173        assert!(
2174            fixed_once.contains("![Crate](https://img.shields.io/crates/v/rumdl.svg)"),
2175            "Inner img should be converted: {fixed_once}"
2176        );
2177
2178        // Second pass converts the <a> wrapper
2179        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2180        let fixed_twice = rule.fix(&ctx2).unwrap();
2181        assert!(
2182            fixed_twice
2183                .contains("[![Crate](https://img.shields.io/crates/v/rumdl.svg)](https://crates.io/crates/rumdl)"),
2184            "Badge should produce nested markdown image link: {fixed_twice}"
2185        );
2186    }
2187
2188    #[test]
2189    fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2190        // Verify conservative mode (default) is unaffected by the relaxed logic
2191        let rule = MD033NoInlineHtml::with_fix(true);
2192        let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2194        let fixed = rule.fix(&ctx).unwrap();
2195        assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2196    }
2197
2198    #[test]
2199    fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2200        // <img> inside <pre> must NOT be converted, even in relaxed mode
2201        let rule = relaxed_fix_rule();
2202        let content = "<pre>\n  <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2204        let fixed = rule.fix(&ctx).unwrap();
2205        assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2206    }
2207
2208    #[test]
2209    fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2210        // <p> nested inside <div> should not be stripped
2211        let rule = relaxed_fix_rule();
2212        let content = "<div><p>text</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("<p>text</p>") || fixed.contains("<p>"),
2217            "Nested <p> inside <div> should not be stripped: {fixed}"
2218        );
2219    }
2220
2221    #[test]
2222    fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2223        // <img> inside <div><p>...</p></div> must NOT be converted because the
2224        // <p> wrapper can't be stripped (it's nested), so the markdown would be
2225        // stuck inside an HTML block where it won't render.
2226        let rule = relaxed_fix_rule();
2227        let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2229        let fixed = rule.fix(&ctx).unwrap();
2230        assert!(
2231            fixed.contains("<img"),
2232            "img inside nested wrapper must not be converted: {fixed}"
2233        );
2234    }
2235
2236    #[test]
2237    fn test_md033_fix_mixed_safe_tags() {
2238        // All tags are now safe fixable (em, img, strong)
2239        let rule = MD033NoInlineHtml::with_fix(true);
2240        let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2242        let fixed = rule.fix(&ctx).unwrap();
2243        // All are converted
2244        assert_eq!(fixed, "*italic* and ![](x.jpg) and **bold**");
2245    }
2246
2247    #[test]
2248    fn test_md033_fix_multiple_tags_same_line() {
2249        // Multiple tags on the same line should all be fixed correctly
2250        let rule = MD033NoInlineHtml::with_fix(true);
2251        let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253        let fixed = rule.fix(&ctx).unwrap();
2254        assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2255    }
2256
2257    #[test]
2258    fn test_md033_fix_multiple_em_tags_same_line() {
2259        // Multiple em/strong tags on the same line
2260        let rule = MD033NoInlineHtml::with_fix(true);
2261        let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2263        let fixed = rule.fix(&ctx).unwrap();
2264        assert_eq!(fixed, "*first* and **second** and `third`");
2265    }
2266
2267    #[test]
2268    fn test_md033_fix_skips_tags_inside_pre() {
2269        // Tags inside <pre> blocks should NOT be fixed (would break structure)
2270        let rule = MD033NoInlineHtml::with_fix(true);
2271        let content = "<pre><code><em>VALUE</em></code></pre>";
2272        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273        let fixed = rule.fix(&ctx).unwrap();
2274        // The <em> inside <pre><code> should NOT be converted
2275        // Only the outer structure might be changed
2276        assert!(
2277            !fixed.contains("*VALUE*"),
2278            "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2279        );
2280    }
2281
2282    #[test]
2283    fn test_md033_fix_skips_tags_inside_div() {
2284        // Tags inside HTML block elements should not be fixed
2285        let rule = MD033NoInlineHtml::with_fix(true);
2286        let content = "<div>\n<em>emphasized</em>\n</div>";
2287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288        let fixed = rule.fix(&ctx).unwrap();
2289        // The <em> inside <div> should not be converted to *emphasized*
2290        assert!(
2291            !fixed.contains("*emphasized*"),
2292            "Tags inside HTML blocks should not be converted. Got: {fixed}"
2293        );
2294    }
2295
2296    #[test]
2297    fn test_md033_fix_outside_html_block() {
2298        // Tags outside HTML blocks should still be fixed
2299        let rule = MD033NoInlineHtml::with_fix(true);
2300        let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2301        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302        let fixed = rule.fix(&ctx).unwrap();
2303        // The <em> outside the div should be converted
2304        assert!(
2305            fixed.contains("*emphasized*"),
2306            "Tags outside HTML blocks should be converted. Got: {fixed}"
2307        );
2308    }
2309
2310    #[test]
2311    fn test_md033_fix_with_id_attribute() {
2312        // Tags with id attributes should not be fixed (id might be used for anchors)
2313        let rule = MD033NoInlineHtml::with_fix(true);
2314        let content = "See <em id=\"important\">this note</em> for details.";
2315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316        let fixed = rule.fix(&ctx).unwrap();
2317        // Should remain unchanged - id attribute matters for linking
2318        assert_eq!(fixed, content);
2319    }
2320
2321    #[test]
2322    fn test_md033_fix_with_style_attribute() {
2323        // Tags with style attributes should not be fixed
2324        let rule = MD033NoInlineHtml::with_fix(true);
2325        let content = "This is <strong style=\"color: red\">important</strong> text.";
2326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327        let fixed = rule.fix(&ctx).unwrap();
2328        // Should remain unchanged - style attribute provides formatting
2329        assert_eq!(fixed, content);
2330    }
2331
2332    #[test]
2333    fn test_md033_fix_mixed_with_and_without_attributes() {
2334        // Mix of tags with and without attributes
2335        let rule = MD033NoInlineHtml::with_fix(true);
2336        let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2337        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2338        let fixed = rule.fix(&ctx).unwrap();
2339        // Only the tag without attributes should be fixed
2340        assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2341    }
2342
2343    #[test]
2344    fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2345        // Quick fix should not be provided for tags with attributes
2346        let rule = MD033NoInlineHtml::with_fix(true);
2347        let content = "<em class=\"test\">emphasized</em>";
2348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349        let result = rule.check(&ctx).unwrap();
2350
2351        assert_eq!(result.len(), 1, "Should find one HTML tag");
2352        // No fix should be provided for tags with attributes
2353        assert!(
2354            result[0].fix.is_none(),
2355            "Should NOT have a fix for tags with attributes"
2356        );
2357    }
2358
2359    #[test]
2360    fn test_md033_fix_skips_html_entities() {
2361        // Tags containing HTML entities should NOT be fixed
2362        // HTML entities need HTML context to render; markdown won't process them
2363        let rule = MD033NoInlineHtml::with_fix(true);
2364        let content = "<code>&vert;</code>";
2365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366        let fixed = rule.fix(&ctx).unwrap();
2367        // Should remain unchanged - converting would break rendering
2368        assert_eq!(fixed, content);
2369    }
2370
2371    #[test]
2372    fn test_md033_fix_skips_multiple_html_entities() {
2373        // Multiple HTML entities should also be skipped
2374        let rule = MD033NoInlineHtml::with_fix(true);
2375        let content = "<code>&lt;T&gt;</code>";
2376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377        let fixed = rule.fix(&ctx).unwrap();
2378        // Should remain unchanged
2379        assert_eq!(fixed, content);
2380    }
2381
2382    #[test]
2383    fn test_md033_fix_allows_ampersand_without_entity() {
2384        // Content with & but no semicolon should still be fixed
2385        let rule = MD033NoInlineHtml::with_fix(true);
2386        let content = "<code>a & b</code>";
2387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388        let fixed = rule.fix(&ctx).unwrap();
2389        // Should be converted since & is not part of an entity
2390        assert_eq!(fixed, "`a & b`");
2391    }
2392
2393    #[test]
2394    fn test_md033_fix_em_with_entities_skipped() {
2395        // <em> with entities should also be skipped
2396        let rule = MD033NoInlineHtml::with_fix(true);
2397        let content = "<em>&nbsp;text</em>";
2398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399        let fixed = rule.fix(&ctx).unwrap();
2400        // Should remain unchanged
2401        assert_eq!(fixed, content);
2402    }
2403
2404    #[test]
2405    fn test_md033_fix_skips_nested_em_in_code() {
2406        // Tags nested inside other HTML elements should NOT be fixed
2407        // e.g., <code><em>n</em></code> - the <em> should not be converted
2408        let rule = MD033NoInlineHtml::with_fix(true);
2409        let content = "<code><em>n</em></code>";
2410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2411        let fixed = rule.fix(&ctx).unwrap();
2412        // The inner <em> should NOT be converted to *n* because it's nested
2413        // The whole structure should be left as-is (or outer code converted, but not inner)
2414        assert!(
2415            !fixed.contains("*n*"),
2416            "Nested <em> should not be converted to markdown. Got: {fixed}"
2417        );
2418    }
2419
2420    #[test]
2421    fn test_md033_fix_skips_nested_in_table() {
2422        // Tags nested in HTML structures in tables should not be fixed
2423        let rule = MD033NoInlineHtml::with_fix(true);
2424        let content = "| <code>><em>n</em></code> | description |";
2425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426        let fixed = rule.fix(&ctx).unwrap();
2427        // Should not convert nested <em> to *n*
2428        assert!(
2429            !fixed.contains("*n*"),
2430            "Nested tags in table should not be converted. Got: {fixed}"
2431        );
2432    }
2433
2434    #[test]
2435    fn test_md033_fix_standalone_em_still_converted() {
2436        // Standalone (non-nested) <em> should still be converted
2437        let rule = MD033NoInlineHtml::with_fix(true);
2438        let content = "This is <em>emphasized</em> text.";
2439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2440        let fixed = rule.fix(&ctx).unwrap();
2441        assert_eq!(fixed, "This is *emphasized* text.");
2442    }
2443
2444    // ==========================================================================
2445    // Obsidian Templater Plugin Syntax Tests
2446    //
2447    // Templater is a popular Obsidian plugin that uses `<% ... %>` syntax for
2448    // template interpolation. The `<%` pattern is NOT captured by the HTML tag
2449    // parser because `%` is not a valid HTML tag name character (tags must start
2450    // with a letter). This behavior is documented here with comprehensive tests.
2451    //
2452    // Reference: https://silentvoid13.github.io/Templater/
2453    // ==========================================================================
2454
2455    #[test]
2456    fn test_md033_templater_basic_interpolation_not_flagged() {
2457        // Basic Templater interpolation: <% expr %>
2458        // Should NOT be flagged because `%` is not a valid HTML tag character
2459        let rule = MD033NoInlineHtml::default();
2460        let content = "Today is <% tp.date.now() %> which is nice.";
2461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2462        let result = rule.check(&ctx).unwrap();
2463        assert!(
2464            result.is_empty(),
2465            "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2466        );
2467    }
2468
2469    #[test]
2470    fn test_md033_templater_file_functions_not_flagged() {
2471        // Templater file functions: <% tp.file.* %>
2472        let rule = MD033NoInlineHtml::default();
2473        let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2475        let result = rule.check(&ctx).unwrap();
2476        assert!(
2477            result.is_empty(),
2478            "Templater file functions should not be flagged. Got: {result:?}"
2479        );
2480    }
2481
2482    #[test]
2483    fn test_md033_templater_with_arguments_not_flagged() {
2484        // Templater with function arguments
2485        let rule = MD033NoInlineHtml::default();
2486        let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2488        let result = rule.check(&ctx).unwrap();
2489        assert!(
2490            result.is_empty(),
2491            "Templater with arguments should not be flagged. Got: {result:?}"
2492        );
2493    }
2494
2495    #[test]
2496    fn test_md033_templater_javascript_execution_not_flagged() {
2497        // Templater JavaScript execution block: <%* code %>
2498        let rule = MD033NoInlineHtml::default();
2499        let content = "<%* const today = tp.date.now(); tR += today; %>";
2500        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2501        let result = rule.check(&ctx).unwrap();
2502        assert!(
2503            result.is_empty(),
2504            "Templater JS execution block should not be flagged. Got: {result:?}"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_md033_templater_dynamic_execution_not_flagged() {
2510        // Templater dynamic/preview execution: <%+ expr %>
2511        let rule = MD033NoInlineHtml::default();
2512        let content = "Dynamic: <%+ tp.date.now() %>";
2513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2514        let result = rule.check(&ctx).unwrap();
2515        assert!(
2516            result.is_empty(),
2517            "Templater dynamic execution should not be flagged. Got: {result:?}"
2518        );
2519    }
2520
2521    #[test]
2522    fn test_md033_templater_whitespace_trim_all_not_flagged() {
2523        // Templater whitespace control - trim all: <%_ expr _%>
2524        let rule = MD033NoInlineHtml::default();
2525        let content = "<%_ tp.date.now() _%>";
2526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2527        let result = rule.check(&ctx).unwrap();
2528        assert!(
2529            result.is_empty(),
2530            "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2531        );
2532    }
2533
2534    #[test]
2535    fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2536        // Templater whitespace control - trim newline: <%- expr -%>
2537        let rule = MD033NoInlineHtml::default();
2538        let content = "<%- tp.date.now() -%>";
2539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2540        let result = rule.check(&ctx).unwrap();
2541        assert!(
2542            result.is_empty(),
2543            "Templater trim-newline should not be flagged. Got: {result:?}"
2544        );
2545    }
2546
2547    #[test]
2548    fn test_md033_templater_combined_modifiers_not_flagged() {
2549        // Templater combined whitespace and execution modifiers
2550        let rule = MD033NoInlineHtml::default();
2551        let contents = [
2552            "<%-* const x = 1; -%>",  // trim + JS execution
2553            "<%_+ tp.date.now() _%>", // trim-all + dynamic
2554            "<%- tp.file.title -%>",  // trim-newline only
2555            "<%_ tp.file.title _%>",  // trim-all only
2556        ];
2557        for content in contents {
2558            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2559            let result = rule.check(&ctx).unwrap();
2560            assert!(
2561                result.is_empty(),
2562                "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2563            );
2564        }
2565    }
2566
2567    #[test]
2568    fn test_md033_templater_multiline_block_not_flagged() {
2569        // Multi-line Templater JavaScript block
2570        let rule = MD033NoInlineHtml::default();
2571        let content = r#"<%*
2572const x = 1;
2573const y = 2;
2574tR += x + y;
2575%>"#;
2576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2577        let result = rule.check(&ctx).unwrap();
2578        assert!(
2579            result.is_empty(),
2580            "Templater multi-line block should not be flagged. Got: {result:?}"
2581        );
2582    }
2583
2584    #[test]
2585    fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2586        // Templater with angle brackets in JavaScript condition
2587        // This is a key edge case: `<` inside Templater should not trigger HTML detection
2588        let rule = MD033NoInlineHtml::default();
2589        let content = "<%* if (x < 5) { tR += 'small'; } %>";
2590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2591        let result = rule.check(&ctx).unwrap();
2592        assert!(
2593            result.is_empty(),
2594            "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2595        );
2596    }
2597
2598    #[test]
2599    fn test_md033_templater_mixed_with_html_only_html_flagged() {
2600        // Templater syntax mixed with actual HTML - only HTML should be flagged
2601        let rule = MD033NoInlineHtml::default();
2602        let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2604        let result = rule.check(&ctx).unwrap();
2605        assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2606        assert!(
2607            result[0].message.contains("<div>"),
2608            "Should flag <div>, got: {}",
2609            result[0].message
2610        );
2611    }
2612
2613    #[test]
2614    fn test_md033_templater_in_heading_not_flagged() {
2615        // Templater in markdown heading
2616        let rule = MD033NoInlineHtml::default();
2617        let content = "# <% tp.file.title %>";
2618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2619        let result = rule.check(&ctx).unwrap();
2620        assert!(
2621            result.is_empty(),
2622            "Templater in heading should not be flagged. Got: {result:?}"
2623        );
2624    }
2625
2626    #[test]
2627    fn test_md033_templater_multiple_on_same_line_not_flagged() {
2628        // Multiple Templater blocks on same line
2629        let rule = MD033NoInlineHtml::default();
2630        let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2632        let result = rule.check(&ctx).unwrap();
2633        assert!(
2634            result.is_empty(),
2635            "Multiple Templater blocks should not be flagged. Got: {result:?}"
2636        );
2637    }
2638
2639    #[test]
2640    fn test_md033_templater_in_code_block_not_flagged() {
2641        // Templater syntax in code blocks should not be flagged (code blocks are skipped)
2642        let rule = MD033NoInlineHtml::default();
2643        let content = "```\n<% tp.date.now() %>\n```";
2644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2645        let result = rule.check(&ctx).unwrap();
2646        assert!(
2647            result.is_empty(),
2648            "Templater in code block should not be flagged. Got: {result:?}"
2649        );
2650    }
2651
2652    #[test]
2653    fn test_md033_templater_in_inline_code_not_flagged() {
2654        // Templater syntax in inline code span should not be flagged
2655        let rule = MD033NoInlineHtml::default();
2656        let content = "Use `<% tp.date.now() %>` for current date.";
2657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2658        let result = rule.check(&ctx).unwrap();
2659        assert!(
2660            result.is_empty(),
2661            "Templater in inline code should not be flagged. Got: {result:?}"
2662        );
2663    }
2664
2665    #[test]
2666    fn test_md033_templater_also_works_in_standard_flavor() {
2667        // Templater syntax should also not be flagged in Standard flavor
2668        // because the HTML parser doesn't recognize `<%` as a valid tag
2669        let rule = MD033NoInlineHtml::default();
2670        let content = "<% tp.date.now() %> works everywhere.";
2671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2672        let result = rule.check(&ctx).unwrap();
2673        assert!(
2674            result.is_empty(),
2675            "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_md033_templater_empty_tag_not_flagged() {
2681        // Empty Templater tags
2682        let rule = MD033NoInlineHtml::default();
2683        let content = "<%>";
2684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2685        let result = rule.check(&ctx).unwrap();
2686        assert!(
2687            result.is_empty(),
2688            "Empty Templater-like tag should not be flagged. Got: {result:?}"
2689        );
2690    }
2691
2692    #[test]
2693    fn test_md033_templater_unclosed_not_flagged() {
2694        // Unclosed Templater tags - these are template errors, not HTML
2695        let rule = MD033NoInlineHtml::default();
2696        let content = "<% tp.date.now() without closing tag";
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            "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2702        );
2703    }
2704
2705    #[test]
2706    fn test_md033_templater_with_newlines_inside_not_flagged() {
2707        // Templater with newlines inside the expression
2708        let rule = MD033NoInlineHtml::default();
2709        let content = r#"<% tp.date.now("YYYY") +
2710"-" +
2711tp.date.now("MM") %>"#;
2712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2713        let result = rule.check(&ctx).unwrap();
2714        assert!(
2715            result.is_empty(),
2716            "Templater with internal newlines should not be flagged. Got: {result:?}"
2717        );
2718    }
2719
2720    #[test]
2721    fn test_md033_erb_style_tags_not_flagged() {
2722        // ERB/EJS style tags (similar to Templater) are also not HTML
2723        // This documents the general principle that `<%` is not valid HTML
2724        let rule = MD033NoInlineHtml::default();
2725        let content = "<%= variable %> and <% code %> and <%# comment %>";
2726        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2727        let result = rule.check(&ctx).unwrap();
2728        assert!(
2729            result.is_empty(),
2730            "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2731        );
2732    }
2733
2734    #[test]
2735    fn test_md033_templater_complex_expression_not_flagged() {
2736        // Complex Templater expression with multiple function calls
2737        let rule = MD033NoInlineHtml::default();
2738        let content = r#"<%*
2739const file = tp.file.title;
2740const date = tp.date.now("YYYY-MM-DD");
2741const folder = tp.file.folder();
2742tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2743%>"#;
2744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2745        let result = rule.check(&ctx).unwrap();
2746        assert!(
2747            result.is_empty(),
2748            "Complex Templater expression should not be flagged. Got: {result:?}"
2749        );
2750    }
2751
2752    #[test]
2753    fn test_md033_percent_sign_variations_not_flagged() {
2754        // Various patterns starting with <% that should all be safe
2755        let rule = MD033NoInlineHtml::default();
2756        let patterns = [
2757            "<%=",  // ERB output
2758            "<%#",  // ERB comment
2759            "<%%",  // Double percent
2760            "<%!",  // Some template engines
2761            "<%@",  // JSP directive
2762            "<%--", // JSP comment
2763        ];
2764        for pattern in patterns {
2765            let content = format!("{pattern} content %>");
2766            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2767            let result = rule.check(&ctx).unwrap();
2768            assert!(
2769                result.is_empty(),
2770                "Pattern {pattern} should not be flagged. Got: {result:?}"
2771            );
2772        }
2773    }
2774
2775    // ───── Bug #3: Bracket escaping in image-inside-link conversion ─────
2776    //
2777    // When <a> wraps already-converted markdown image text, the bracket escaping
2778    // must be skipped to produce valid [![alt](url)](href) instead of !\[\](url)
2779
2780    #[test]
2781    fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2782        // When <a> wraps a markdown image (from a prior fix iteration),
2783        // the result should be [![](url)](href) — no escaped brackets
2784        let rule = MD033NoInlineHtml::with_fix(true);
2785        let content = r#"<a href="https://example.com">![](https://example.com/image.png)</a>"#;
2786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2787        let fixed = rule.fix(&ctx).unwrap();
2788
2789        assert_eq!(fixed, "[![](https://example.com/image.png)](https://example.com)",);
2790        assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2791        assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2792    }
2793
2794    #[test]
2795    fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2796        // <a> wrapping ![alt](url) preserves alt text in linked image
2797        let rule = MD033NoInlineHtml::with_fix(true);
2798        let content =
2799            r#"<a href="https://github.com/repo">![Contributors](https://contrib.rocks/image?repo=org/repo)</a>"#;
2800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801        let fixed = rule.fix(&ctx).unwrap();
2802
2803        assert_eq!(
2804            fixed,
2805            "[![Contributors](https://contrib.rocks/image?repo=org/repo)](https://github.com/repo)"
2806        );
2807    }
2808
2809    #[test]
2810    fn test_md033_fix_img_without_alt_produces_empty_alt() {
2811        let rule = MD033NoInlineHtml::with_fix(true);
2812        let content = r#"<img src="photo.jpg" />"#;
2813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814        let fixed = rule.fix(&ctx).unwrap();
2815
2816        assert_eq!(fixed, "![](photo.jpg)");
2817    }
2818
2819    #[test]
2820    fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2821        // Plain text brackets inside <a> SHOULD be escaped
2822        let rule = MD033NoInlineHtml::with_fix(true);
2823        let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825        let fixed = rule.fix(&ctx).unwrap();
2826
2827        assert!(
2828            fixed.contains(r"\[brackets\]"),
2829            "Plain text brackets should be escaped: {fixed}"
2830        );
2831    }
2832
2833    #[test]
2834    fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2835        // Mixed content: image followed by bracketed text — brackets must be escaped
2836        // The image detection must NOT match partial content
2837        let rule = MD033NoInlineHtml::with_fix(true);
2838        let content = r#"<a href="/link">![](img.png) see [docs]</a>"#;
2839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2840        let fixed = rule.fix(&ctx).unwrap();
2841
2842        // "see [docs]" brackets should be escaped since inner content is mixed
2843        assert!(
2844            fixed.contains(r"\[docs\]"),
2845            "Brackets in mixed image+text content should be escaped: {fixed}"
2846        );
2847    }
2848
2849    #[test]
2850    fn test_md033_fix_img_in_a_end_to_end() {
2851        // End-to-end: verify that iterative fixing of <a><img></a>
2852        // produces the correct final result through the fix coordinator
2853        use crate::config::Config;
2854        use crate::fix_coordinator::FixCoordinator;
2855
2856        let rule = MD033NoInlineHtml::with_fix(true);
2857        let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2858
2859        let mut content =
2860            r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2861                .to_string();
2862        let config = Config::default();
2863        let coordinator = FixCoordinator::new();
2864
2865        let result = coordinator
2866            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2867            .unwrap();
2868
2869        assert_eq!(
2870            content, "[![](https://contrib.rocks/image?repo=org/repo)](https://github.com/org/repo)",
2871            "End-to-end: <a><img></a> should become valid linked image"
2872        );
2873        assert!(result.converged);
2874        assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2875    }
2876
2877    #[test]
2878    fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2879        use crate::config::Config;
2880        use crate::fix_coordinator::FixCoordinator;
2881
2882        let rule = MD033NoInlineHtml::with_fix(true);
2883        let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2884
2885        let mut content =
2886            r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2887                .to_string();
2888        let config = Config::default();
2889        let coordinator = FixCoordinator::new();
2890
2891        let result = coordinator
2892            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2893            .unwrap();
2894
2895        assert_eq!(
2896            content,
2897            "[![Contributors](https://contrib.rocks/image)](https://github.com/org/repo)",
2898        );
2899        assert!(result.converged);
2900    }
2901
2902    // =========================================================================
2903    // table_allowed_elements config option tests
2904    //
2905    // Mirrors markdownlint's `table_allowed_elements`: when unset, the in-table
2906    // allowlist falls back to `allowed_elements`; when explicitly set (even to
2907    // []), it overrides for tags inside GFM table cells. Out-of-table tags are
2908    // never affected by this option.
2909    // =========================================================================
2910
2911    #[test]
2912    fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2913        let config = MD033Config {
2914            allowed: vec!["br".to_string()],
2915            table_allowed_elements: None,
2916            ..MD033Config::default()
2917        };
2918        let rule = MD033NoInlineHtml::from_config_struct(config);
2919        let content = "| col |\n|-----|\n| a<br>b |\n";
2920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921        let result = rule.check(&ctx).unwrap();
2922        assert!(
2923            result.is_empty(),
2924            "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2925        );
2926    }
2927
2928    #[test]
2929    fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2930        let config = MD033Config {
2931            allowed: vec!["br".to_string()],
2932            table_allowed_elements: Some(Vec::new()),
2933            ..MD033Config::default()
2934        };
2935        let rule = MD033NoInlineHtml::from_config_struct(config);
2936        let content = "| col |\n|-----|\n| a<br>b |\n";
2937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2938        let result = rule.check(&ctx).unwrap();
2939        assert_eq!(
2940            result.len(),
2941            1,
2942            "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2943        );
2944        assert_eq!(result[0].line, 3);
2945    }
2946
2947    #[test]
2948    fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2949        let config = MD033Config {
2950            allowed: vec!["br".to_string()],
2951            table_allowed_elements: Some(vec!["img".to_string()]),
2952            ..MD033Config::default()
2953        };
2954        let rule = MD033NoInlineHtml::from_config_struct(config);
2955        // <br> is in allowed but NOT in table_allowed, so it should be flagged in table.
2956        // <img> is in table_allowed only, so it should be permitted in table.
2957        let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2959        let result = rule.check(&ctx).unwrap();
2960        assert_eq!(
2961            result.len(),
2962            1,
2963            "table_allowed should override `allowed` inside tables, got {result:?}"
2964        );
2965        assert!(
2966            result[0].message.contains("br"),
2967            "expected the flagged tag to be <br>, got {:?}",
2968            result[0].message
2969        );
2970    }
2971
2972    #[test]
2973    fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2974        let config = MD033Config {
2975            allowed: vec!["br".to_string()],
2976            table_allowed_elements: Some(Vec::new()),
2977            ..MD033Config::default()
2978        };
2979        let rule = MD033NoInlineHtml::from_config_struct(config);
2980        // <br> outside a table — should still be allowed via `allowed`.
2981        let content = "Paragraph with <br> tag.\n";
2982        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2983        let result = rule.check(&ctx).unwrap();
2984        assert!(
2985            result.is_empty(),
2986            "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2987        );
2988    }
2989
2990    #[test]
2991    fn test_md033_table_allowed_kebab_case_parses() {
2992        let toml_str = r#"
2993            allowed-elements = ["br"]
2994            table-allowed-elements = ["img"]
2995        "#;
2996        let config: MD033Config = toml::from_str(toml_str).unwrap();
2997        assert_eq!(config.allowed, vec!["br"]);
2998        assert_eq!(
2999            config.table_allowed_elements.as_deref(),
3000            Some(["img".to_string()].as_slice())
3001        );
3002    }
3003
3004    #[test]
3005    fn test_md033_table_allowed_snake_case_alias_parses() {
3006        let toml_str = r#"
3007            allowed_elements = ["br"]
3008            table_allowed_elements = ["img"]
3009        "#;
3010        let config: MD033Config = toml::from_str(toml_str).unwrap();
3011        assert_eq!(config.allowed, vec!["br"]);
3012        assert_eq!(
3013            config.table_allowed_elements.as_deref(),
3014            Some(["img".to_string()].as_slice())
3015        );
3016    }
3017
3018    #[test]
3019    fn test_md033_table_allowed_default_is_none() {
3020        let cfg = MD033Config::default();
3021        assert!(
3022            cfg.table_allowed_elements.is_none(),
3023            "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3024        );
3025    }
3026
3027    #[test]
3028    fn test_md033_table_allowed_case_insensitive() {
3029        let config = MD033Config {
3030            allowed: Vec::new(),
3031            table_allowed_elements: Some(vec!["BR".to_string()]),
3032            ..MD033Config::default()
3033        };
3034        let rule = MD033NoInlineHtml::from_config_struct(config);
3035        let content = "| col |\n|-----|\n| a<br>b |\n";
3036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3037        let result = rule.check(&ctx).unwrap();
3038        assert!(
3039            result.is_empty(),
3040            "table_allowed should be case-insensitive, got {result:?}"
3041        );
3042    }
3043}