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