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