Skip to main content

rumdl_lib/rules/
md033_no_inline_html.rs

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