Skip to main content

mdlint/lint/rules/
md033.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::Event;
5use serde_json::Value;
6
7pub struct MD033;
8
9impl Rule for MD033 {
10    fn name(&self) -> &'static str {
11        "MD033"
12    }
13
14    fn description(&self) -> &'static str {
15        "Inline HTML"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["html"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let allowed_elements: Vec<String> = config
24            .and_then(|c| c.get("allowed_elements"))
25            .and_then(|v| v.as_array())
26            .map(|arr| {
27                arr.iter()
28                    .filter_map(|v| v.as_str().map(str::to_lowercase))
29                    .collect()
30            })
31            .unwrap_or_default();
32
33        let mut violations = Vec::new();
34
35        for (event, range) in parser.parse_with_offsets() {
36            // Check both Html (block) and InlineHtml events
37            let html_str = match event {
38                Event::Html(html) | Event::InlineHtml(html) => html.to_string(),
39                _ => continue,
40            };
41
42            let line = parser.offset_to_line(range.start);
43
44            // Skip closing tags - only report opening tags
45            if html_str.trim().starts_with("</") {
46                continue;
47            }
48
49            // Extract tag name from HTML
50            if let Some(tag_name) = extract_tag_name(&html_str) {
51                let is_disallowed = allowed_elements.is_empty()
52                    || !allowed_elements.contains(&tag_name.to_lowercase());
53
54                if is_disallowed {
55                    violations.push(Violation {
56                        line,
57                        column: Some(1),
58                        rule: self.name().to_owned(),
59                        message: format!("Inline HTML element: <{tag_name}>"),
60                        fix: None,
61                    });
62                }
63            }
64        }
65
66        violations
67    }
68
69    fn fixable(&self) -> bool {
70        false
71    }
72}
73
74fn extract_tag_name(html: &str) -> Option<String> {
75    let trimmed = html.trim();
76    if trimmed.starts_with('<') {
77        // Handle opening tags, closing tags, and self-closing tags
78        let inner = trimmed.trim_start_matches('<').trim_start_matches('/');
79        // Skip comments (<!--), DOCTYPE (<!DOCTYPE), and other `<!` declarations
80        if inner.starts_with('!') {
81            return None;
82        }
83        inner
84            .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
85            .map(|end_pos| inner[..end_pos].to_string())
86    } else {
87        None
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_no_html() {
97        let content = "# Heading\n\nNormal **markdown** text.";
98        let parser = MarkdownParser::new(content);
99        let rule = MD033;
100        let violations = rule.check(&parser, None);
101
102        assert_eq!(violations.len(), 0);
103    }
104
105    #[test]
106    fn test_inline_html() {
107        let content = "Text with <br> tag";
108        let parser = MarkdownParser::new(content);
109        let rule = MD033;
110        let violations = rule.check(&parser, None);
111
112        assert_eq!(violations.len(), 1);
113        assert!(violations[0].message.contains("<br>"));
114    }
115
116    #[test]
117    fn test_allowed_elements() {
118        let content = "Text with <br> tag and <div>content</div>";
119        let parser = MarkdownParser::new(content);
120        let rule = MD033;
121        let config = serde_json::json!({ "allowed_elements": ["br"] });
122        let violations = rule.check(&parser, Some(&config));
123
124        // Only <div> should be flagged, <br> is allowed
125        assert!(!violations.is_empty());
126        assert!(violations.iter().any(|v| v.message.contains("<div>")));
127    }
128
129    #[test]
130    fn test_block_html() {
131        let content = "<div>\nContent\n</div>";
132        let parser = MarkdownParser::new(content);
133        let rule = MD033;
134        let violations = rule.check(&parser, None);
135
136        assert!(!violations.is_empty());
137    }
138}