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