1use 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 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 #[inline]
100 fn is_tag_allowed(&self, tag: &str) -> bool {
101 if self.allowed.is_empty() {
102 return false;
103 }
104 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 #[inline]
115 fn is_tag_disallowed(&self, tag: &str) -> bool {
116 if self.disallowed.is_empty() {
117 return false;
118 }
119 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 #[inline]
130 fn is_disallowed_mode(&self) -> bool {
131 self.config.is_disallowed_mode()
132 }
133
134 #[inline]
136 fn is_html_comment(&self, tag: &str) -> bool {
137 tag.starts_with("<!--") && tag.ends_with("-->")
138 }
139
140 #[inline]
145 fn is_html_element_or_custom(tag_name: &str) -> bool {
146 const HTML_ELEMENTS: &[&str] = &[
147 "html",
149 "head",
150 "body",
151 "title",
152 "base",
153 "link",
154 "meta",
155 "style",
156 "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 "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 "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 "ins",
220 "del",
221 "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 "table",
239 "caption",
240 "colgroup",
241 "col",
242 "tbody",
243 "thead",
244 "tfoot",
245 "tr",
246 "td",
247 "th",
248 "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 "details",
265 "summary",
266 "dialog",
267 "script",
269 "noscript",
270 "template",
271 "slot",
272 "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 tag_name.contains('-')
298 }
299
300 #[inline]
302 fn is_likely_type_annotation(&self, tag: &str) -> bool {
303 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", "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 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 #[inline]
363 fn is_email_address(&self, tag: &str) -> bool {
364 let content = tag.trim_start_matches('<').trim_end_matches('>');
365 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 #[inline]
374 fn has_markdown_attribute(&self, tag: &str) -> bool {
375 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
378 }
379
380 #[inline]
387 fn has_jsx_attributes(tag: &str) -> bool {
388 tag.contains("className")
390 || tag.contains("htmlFor")
391 || tag.contains("dangerouslySetInnerHTML")
392 || 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 || tag.contains("={")
407 }
408
409 #[inline]
411 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
412 let content = tag.trim_start_matches('<').trim_end_matches('>');
413 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 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
424 if inner_content.contains('<') {
426 return None;
427 }
428 if inner_content.contains('&') && inner_content.contains(';') {
431 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 if inner_content.contains('`') {
446 Some(format!("`` {inner_content} ``"))
447 } else {
448 Some(format!("`{inner_content}`"))
449 }
450 }
451 _ => None,
452 }
453 }
454
455 fn convert_self_closing_to_markdown(&self, tag_name: &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 _ => None,
464 }
465 }
466
467 fn has_significant_attributes(opening_tag: &str) -> bool {
469 let tag_content = opening_tag
471 .trim_start_matches('<')
472 .trim_end_matches('>')
473 .trim_end_matches('/');
474
475 let parts: Vec<&str> = tag_content.split_whitespace().collect();
477 parts.len() > 1
478 }
479
480 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
483 if tag_byte_start > 0 {
485 let before = &content[..tag_byte_start];
486 let before_trimmed = before.trim_end();
487 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
488 if let Some(last_lt) = before_trimmed.rfind('<') {
490 let potential_tag = &before_trimmed[last_lt..];
491 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
493 return true;
494 }
495 }
496 }
497 }
498 if tag_byte_end < content.len() {
500 let after = &content[tag_byte_end..];
501 let after_trimmed = after.trim_start();
502 if after_trimmed.starts_with("</") {
503 return true;
504 }
505 }
506 false
507 }
508
509 fn calculate_fix(
520 &self,
521 content: &str,
522 opening_tag: &str,
523 tag_byte_start: usize,
524 in_html_block: bool,
525 ) -> Option<(std::ops::Range<usize>, String)> {
526 let tag_name = opening_tag
528 .trim_start_matches('<')
529 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
530 .next()?
531 .to_lowercase();
532
533 let is_self_closing =
535 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
536
537 if is_self_closing {
538 if self.config.fix
541 && MD033Config::is_safe_fixable_tag(&tag_name)
542 && !in_html_block
543 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name)
544 {
545 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
546 }
547 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), String::new()));
549 }
550
551 let search_start = tag_byte_start + opening_tag.len();
553 let search_slice = &content[search_start..];
554
555 let closing_tag_lower = format!("</{tag_name}>");
557 let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
558
559 if let Some(closing_pos) = closing_pos {
560 let closing_tag_len = closing_tag_lower.len();
562 let closing_byte_start = search_start + closing_pos;
563 let closing_byte_end = closing_byte_start + closing_tag_len;
564
565 let inner_content = &content[search_start..closing_byte_start];
567
568 if in_html_block {
571 return None;
572 }
573
574 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
577 return None;
578 }
579
580 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
583 if Self::has_significant_attributes(opening_tag) {
584 return None;
587 }
588 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
589 return Some((tag_byte_start..closing_byte_end, markdown));
590 }
591 return None;
594 }
595
596 return Some((tag_byte_start..closing_byte_end, inner_content.to_string()));
598 }
599
600 Some((tag_byte_start..tag_byte_start + opening_tag.len(), String::new()))
602 }
603}
604
605impl Rule for MD033NoInlineHtml {
606 fn name(&self) -> &'static str {
607 "MD033"
608 }
609
610 fn description(&self) -> &'static str {
611 "Inline HTML is not allowed"
612 }
613
614 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
615 let content = ctx.content;
616
617 if content.is_empty() || !ctx.likely_has_html() {
619 return Ok(Vec::new());
620 }
621
622 if !HTML_TAG_QUICK_CHECK.is_match(content) {
624 return Ok(Vec::new());
625 }
626
627 let mut warnings = Vec::new();
628 let lines: Vec<&str> = content.lines().collect();
629
630 let mut in_nomarkdown = false;
632 let mut in_comment = false;
633 let mut nomarkdown_ranges: Vec<(usize, usize)> = Vec::new();
634 let mut nomarkdown_start = 0;
635 let mut comment_start = 0;
636
637 for (i, line) in lines.iter().enumerate() {
638 let line_num = i + 1;
639
640 if line.trim() == "{::nomarkdown}" {
642 in_nomarkdown = true;
643 nomarkdown_start = line_num;
644 } else if line.trim() == "{:/nomarkdown}" && in_nomarkdown {
645 in_nomarkdown = false;
646 nomarkdown_ranges.push((nomarkdown_start, line_num));
647 }
648
649 if line.trim() == "{::comment}" {
651 in_comment = true;
652 comment_start = line_num;
653 } else if line.trim() == "{:/comment}" && in_comment {
654 in_comment = false;
655 nomarkdown_ranges.push((comment_start, line_num));
656 }
657 }
658
659 let html_tags = ctx.html_tags();
661
662 for html_tag in html_tags.iter() {
663 if html_tag.is_closing {
665 continue;
666 }
667
668 let line_num = html_tag.line;
669 let tag_byte_start = html_tag.byte_offset;
670
671 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
673
674 if ctx.line_info(line_num).is_some_and(|info| info.in_code_block) {
676 continue;
677 }
678
679 if let Some(line) = lines.get(line_num.saturating_sub(1))
681 && (is_kramdown_extension(line) || is_kramdown_block_attribute(line))
682 {
683 continue;
684 }
685
686 if nomarkdown_ranges
688 .iter()
689 .any(|(start, end)| line_num >= *start && line_num <= *end)
690 {
691 continue;
692 }
693
694 if ctx.is_in_html_comment(tag_byte_start) {
696 continue;
697 }
698
699 if self.is_html_comment(tag) {
701 continue;
702 }
703
704 if ctx.is_in_link_title(tag_byte_start) {
707 continue;
708 }
709
710 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(|c| c.is_uppercase()) {
712 continue;
713 }
714
715 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
717 continue;
718 }
719
720 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
723 continue;
724 }
725
726 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
728 continue;
729 }
730
731 if self.is_likely_type_annotation(tag) {
733 continue;
734 }
735
736 if self.is_email_address(tag) {
738 continue;
739 }
740
741 if self.is_url_in_angle_brackets(tag) {
743 continue;
744 }
745
746 if ctx.is_byte_offset_in_code_span(tag_byte_start) {
748 continue;
749 }
750
751 if self.is_disallowed_mode() {
755 if !self.is_tag_disallowed(tag) {
757 continue;
758 }
759 } else {
760 if self.is_tag_allowed(tag) {
762 continue;
763 }
764 }
765
766 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
768 continue;
769 }
770
771 let in_html_block = ctx.is_in_html_block(line_num);
773
774 let fix = self
776 .calculate_fix(content, tag, tag_byte_start, in_html_block)
777 .map(|(range, replacement)| Fix { range, replacement });
778
779 let (end_line, end_col) = if html_tag.byte_end > 0 {
782 ctx.offset_to_line_col(html_tag.byte_end - 1)
783 } else {
784 (line_num, html_tag.end_col + 1)
785 };
786
787 warnings.push(LintWarning {
789 rule_name: Some(self.name().to_string()),
790 line: line_num,
791 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
795 severity: Severity::Warning,
796 fix,
797 });
798 }
799
800 Ok(warnings)
801 }
802
803 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
804 if !self.config.fix {
806 return Ok(ctx.content.to_string());
807 }
808
809 let warnings = self.check(ctx)?;
811
812 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
814 return Ok(ctx.content.to_string());
815 }
816
817 let mut fixes: Vec<_> = warnings
819 .iter()
820 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
821 .collect();
822 fixes.sort_by(|a, b| b.0.cmp(&a.0));
823
824 let mut result = ctx.content.to_string();
826 for (start, end, replacement) in fixes {
827 if start < result.len() && end <= result.len() && start <= end {
828 result.replace_range(start..end, replacement);
829 }
830 }
831
832 Ok(result)
833 }
834
835 fn fix_capability(&self) -> crate::rule::FixCapability {
836 if self.config.fix {
837 crate::rule::FixCapability::FullyFixable
838 } else {
839 crate::rule::FixCapability::Unfixable
840 }
841 }
842
843 fn category(&self) -> RuleCategory {
845 RuleCategory::Html
846 }
847
848 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
850 ctx.content.is_empty() || !ctx.likely_has_html()
851 }
852
853 fn as_any(&self) -> &dyn std::any::Any {
854 self
855 }
856
857 fn default_config_section(&self) -> Option<(String, toml::Value)> {
858 let json_value = serde_json::to_value(&self.config).ok()?;
859 Some((
860 self.name().to_string(),
861 crate::rule_config_serde::json_to_toml_value(&json_value)?,
862 ))
863 }
864
865 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
866 where
867 Self: Sized,
868 {
869 let rule_config = crate::rule_config_serde::load_rule_config::<MD033Config>(config);
870 Box::new(Self::from_config_struct(rule_config))
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use crate::lint_context::LintContext;
878 use crate::rule::Rule;
879
880 #[test]
881 fn test_md033_basic_html() {
882 let rule = MD033NoInlineHtml::default();
883 let content = "<div>Some content</div>";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
889 }
890
891 #[test]
892 fn test_md033_case_insensitive() {
893 let rule = MD033NoInlineHtml::default();
894 let content = "<DiV>Some <B>content</B></dIv>";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
900 assert_eq!(result[1].message, "Inline HTML found: <B>");
901 }
902
903 #[test]
904 fn test_md033_allowed_tags() {
905 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
906 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert_eq!(result.len(), 1);
911 assert_eq!(result[0].message, "Inline HTML found: <p>");
912
913 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
915 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
916 let result2 = rule.check(&ctx2).unwrap();
917 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
919 }
920
921 #[test]
922 fn test_md033_html_comments() {
923 let rule = MD033NoInlineHtml::default();
924 let content = "<!-- This is a comment --> <p>Not a comment</p>";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
930 }
931
932 #[test]
933 fn test_md033_tags_in_links() {
934 let rule = MD033NoInlineHtml::default();
935 let content = "[Link](http://example.com/<div>)";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938 assert_eq!(result.len(), 1);
940 assert_eq!(result[0].message, "Inline HTML found: <div>");
941
942 let content2 = "[Link <a>text</a>](url)";
943 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
944 let result2 = rule.check(&ctx2).unwrap();
945 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
948 }
949
950 #[test]
951 fn test_md033_fix_escaping() {
952 let rule = MD033NoInlineHtml::default();
953 let content = "Text with <div> and <br/> tags.";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let fixed_content = rule.fix(&ctx).unwrap();
956 assert_eq!(fixed_content, content);
958 }
959
960 #[test]
961 fn test_md033_in_code_blocks() {
962 let rule = MD033NoInlineHtml::default();
963 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
969 }
970
971 #[test]
972 fn test_md033_in_code_spans() {
973 let rule = MD033NoInlineHtml::default();
974 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert_eq!(result.len(), 1);
979 assert_eq!(result[0].message, "Inline HTML found: <br/>");
980 }
981
982 #[test]
983 fn test_md033_issue_90_code_span_with_diff_block() {
984 let rule = MD033NoInlineHtml::default();
986 let content = r#"# Heading
987
988`<env>`
989
990```diff
991- this
992+ that
993```"#;
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
998 }
999
1000 #[test]
1001 fn test_md033_multiple_code_spans_with_angle_brackets() {
1002 let rule = MD033NoInlineHtml::default();
1004 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 let result = rule.check(&ctx).unwrap();
1007 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1008 }
1009
1010 #[test]
1011 fn test_md033_nested_angle_brackets_in_code_span() {
1012 let rule = MD033NoInlineHtml::default();
1014 let content = "Text with `<<nested>>` brackets";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1018 }
1019
1020 #[test]
1021 fn test_md033_code_span_at_end_before_code_block() {
1022 let rule = MD033NoInlineHtml::default();
1024 let content = "Testing `<test>`\n```\ncode here\n```";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert_eq!(result.len(), 0, "Should handle code span before code block");
1028 }
1029
1030 #[test]
1031 fn test_md033_quick_fix_inline_tag() {
1032 let rule = MD033NoInlineHtml::default();
1034 let content = "This has <span>inline text</span> that should keep content.";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037
1038 assert_eq!(result.len(), 1, "Should find one HTML tag");
1039 assert!(result[0].fix.is_some(), "Should have a fix");
1040
1041 let fix = result[0].fix.as_ref().unwrap();
1042 assert_eq!(&content[fix.range.clone()], "<span>inline text</span>");
1043 assert_eq!(fix.replacement, "inline text");
1044 }
1045
1046 #[test]
1047 fn test_md033_quick_fix_multiline_tag() {
1048 let rule = MD033NoInlineHtml::default();
1051 let content = "<div>\nBlock content\n</div>";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054
1055 assert_eq!(result.len(), 1, "Should find one HTML tag");
1056 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1058 }
1059
1060 #[test]
1061 fn test_md033_quick_fix_self_closing_tag() {
1062 let rule = MD033NoInlineHtml::default();
1064 let content = "Self-closing: <br/>";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067
1068 assert_eq!(result.len(), 1, "Should find one HTML tag");
1069 assert!(result[0].fix.is_some(), "Should have a fix");
1070
1071 let fix = result[0].fix.as_ref().unwrap();
1072 assert_eq!(&content[fix.range.clone()], "<br/>");
1073 assert_eq!(fix.replacement, "");
1074 }
1075
1076 #[test]
1077 fn test_md033_quick_fix_multiple_tags() {
1078 let rule = MD033NoInlineHtml::default();
1080 let content = "<span>first</span> and <strong>second</strong>";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083
1084 assert_eq!(result.len(), 2, "Should find two HTML tags");
1085 assert!(result[0].fix.is_some(), "First tag should have a fix");
1086 assert!(result[1].fix.is_some(), "Second tag should have a fix");
1087
1088 let fix1 = result[0].fix.as_ref().unwrap();
1089 assert_eq!(&content[fix1.range.clone()], "<span>first</span>");
1090 assert_eq!(fix1.replacement, "first");
1091
1092 let fix2 = result[1].fix.as_ref().unwrap();
1093 assert_eq!(&content[fix2.range.clone()], "<strong>second</strong>");
1094 assert_eq!(fix2.replacement, "second");
1095 }
1096
1097 #[test]
1098 fn test_md033_skip_angle_brackets_in_link_titles() {
1099 let rule = MD033NoInlineHtml::default();
1101 let content = r#"# Test
1102
1103[example]: <https://example.com> "Title with <Angle Brackets> inside"
1104
1105Regular text with <div>content</div> HTML tag.
1106"#;
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(result.len(), 1, "Should find opening div tag");
1113 assert!(
1114 result[0].message.contains("<div>"),
1115 "Should flag <div>, got: {}",
1116 result[0].message
1117 );
1118 }
1119
1120 #[test]
1121 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1122 let rule = MD033NoInlineHtml::default();
1124 let content = r#"[ref]: url 'Title <Help Wanted> here'
1125
1126<span>text</span> here
1127"#;
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130
1131 assert_eq!(result.len(), 1, "Should find opening span tag");
1134 assert!(
1135 result[0].message.contains("<span>"),
1136 "Should flag <span>, got: {}",
1137 result[0].message
1138 );
1139 }
1140
1141 #[test]
1142 fn test_md033_multiline_tag_end_line_calculation() {
1143 let rule = MD033NoInlineHtml::default();
1145 let content = "<div\n class=\"test\"\n id=\"example\">";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert_eq!(result.len(), 1, "Should find one HTML tag");
1150 assert_eq!(result[0].line, 1, "Start line should be 1");
1152 assert_eq!(result[0].end_line, 3, "End line should be 3");
1154 }
1155
1156 #[test]
1157 fn test_md033_single_line_tag_same_start_end_line() {
1158 let rule = MD033NoInlineHtml::default();
1160 let content = "Some text <div class=\"test\"> more text";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163
1164 assert_eq!(result.len(), 1, "Should find one HTML tag");
1165 assert_eq!(result[0].line, 1, "Start line should be 1");
1166 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1167 }
1168
1169 #[test]
1170 fn test_md033_multiline_tag_with_many_attributes() {
1171 let rule = MD033NoInlineHtml::default();
1173 let content =
1174 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177
1178 assert_eq!(result.len(), 1, "Should find one HTML tag");
1179 assert_eq!(result[0].line, 2, "Start line should be 2");
1181 assert_eq!(result[0].end_line, 5, "End line should be 5");
1183 }
1184
1185 #[test]
1186 fn test_md033_disallowed_mode_basic() {
1187 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1189 let content = "<div>Safe content</div><script>alert('xss')</script>";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1195 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1196 }
1197
1198 #[test]
1199 fn test_md033_disallowed_gfm_security_tags() {
1200 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1202 let content = r#"
1203<div>Safe</div>
1204<title>Bad title</title>
1205<textarea>Bad textarea</textarea>
1206<style>.bad{}</style>
1207<iframe src="evil"></iframe>
1208<script>evil()</script>
1209<plaintext>old tag</plaintext>
1210<span>Safe span</span>
1211"#;
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214
1215 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1218
1219 let flagged_tags: Vec<&str> = result
1220 .iter()
1221 .filter_map(|w| w.message.split("<").nth(1))
1222 .filter_map(|s| s.split(">").next())
1223 .filter_map(|s| s.split_whitespace().next())
1224 .collect();
1225
1226 assert!(flagged_tags.contains(&"title"), "Should flag title");
1227 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1228 assert!(flagged_tags.contains(&"style"), "Should flag style");
1229 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1230 assert!(flagged_tags.contains(&"script"), "Should flag script");
1231 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1232 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1233 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1234 }
1235
1236 #[test]
1237 fn test_md033_disallowed_case_insensitive() {
1238 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1240 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert_eq!(result.len(), 2, "Should flag both case variants");
1246 }
1247
1248 #[test]
1249 fn test_md033_disallowed_with_attributes() {
1250 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1252 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255
1256 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1257 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1258 }
1259
1260 #[test]
1261 fn test_md033_disallowed_all_gfm_tags() {
1262 use md033_config::GFM_DISALLOWED_TAGS;
1264 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1265
1266 for tag in GFM_DISALLOWED_TAGS {
1267 let content = format!("<{tag}>content</{tag}>");
1268 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270
1271 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1272 }
1273 }
1274
1275 #[test]
1276 fn test_md033_disallowed_mixed_with_custom() {
1277 let rule = MD033NoInlineHtml::with_disallowed(vec![
1279 "gfm".to_string(),
1280 "marquee".to_string(), ]);
1282 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285
1286 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1288 }
1289
1290 #[test]
1291 fn test_md033_disallowed_empty_means_default_mode() {
1292 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1294 let content = "<div>content</div>";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let result = rule.check(&ctx).unwrap();
1297
1298 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1300 }
1301
1302 #[test]
1303 fn test_md033_jsx_fragments_in_mdx() {
1304 let rule = MD033NoInlineHtml::default();
1306 let content = r#"# MDX Document
1307
1308<>
1309 <Heading />
1310 <Content />
1311</>
1312
1313<div>Regular HTML should still be flagged</div>
1314"#;
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1316 let result = rule.check(&ctx).unwrap();
1317
1318 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1320 assert!(
1321 result[0].message.contains("<div>"),
1322 "Should flag <div>, not JSX fragments"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_md033_jsx_components_in_mdx() {
1328 let rule = MD033NoInlineHtml::default();
1330 let content = r#"<CustomComponent prop="value">
1331 Content
1332</CustomComponent>
1333
1334<MyButton onClick={handler}>Click</MyButton>
1335"#;
1336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1337 let result = rule.check(&ctx).unwrap();
1338
1339 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1341 }
1342
1343 #[test]
1344 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1345 let rule = MD033NoInlineHtml::default();
1347 let content = "<Script>alert(1)</Script>";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350
1351 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1353 }
1354
1355 #[test]
1356 fn test_md033_jsx_attributes_in_mdx() {
1357 let rule = MD033NoInlineHtml::default();
1359 let content = r#"# MDX with JSX Attributes
1360
1361<div className="card big">Content</div>
1362
1363<button onClick={handleClick}>Click me</button>
1364
1365<label htmlFor="input-id">Label</label>
1366
1367<input onChange={handleChange} />
1368
1369<div class="html-class">Regular HTML should be flagged</div>
1370"#;
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1372 let result = rule.check(&ctx).unwrap();
1373
1374 assert_eq!(
1376 result.len(),
1377 1,
1378 "Should only flag HTML element without JSX attributes, got: {result:?}"
1379 );
1380 assert!(
1381 result[0].message.contains("<div class="),
1382 "Should flag the div with HTML class attribute"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1388 let rule = MD033NoInlineHtml::default();
1390 let content = r#"<div className="card">Content</div>"#;
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393
1394 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1396 }
1397
1398 #[test]
1401 fn test_md033_fix_disabled_by_default() {
1402 let rule = MD033NoInlineHtml::default();
1404 assert!(!rule.config.fix, "Fix should be disabled by default");
1405 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1406 }
1407
1408 #[test]
1409 fn test_md033_fix_enabled_em_to_italic() {
1410 let rule = MD033NoInlineHtml::with_fix(true);
1412 let content = "This has <em>emphasized text</em> here.";
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 let fixed = rule.fix(&ctx).unwrap();
1415 assert_eq!(fixed, "This has *emphasized text* here.");
1416 }
1417
1418 #[test]
1419 fn test_md033_fix_enabled_i_to_italic() {
1420 let rule = MD033NoInlineHtml::with_fix(true);
1422 let content = "This has <i>italic text</i> here.";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let fixed = rule.fix(&ctx).unwrap();
1425 assert_eq!(fixed, "This has *italic text* here.");
1426 }
1427
1428 #[test]
1429 fn test_md033_fix_enabled_strong_to_bold() {
1430 let rule = MD033NoInlineHtml::with_fix(true);
1432 let content = "This has <strong>bold text</strong> here.";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1434 let fixed = rule.fix(&ctx).unwrap();
1435 assert_eq!(fixed, "This has **bold text** here.");
1436 }
1437
1438 #[test]
1439 fn test_md033_fix_enabled_b_to_bold() {
1440 let rule = MD033NoInlineHtml::with_fix(true);
1442 let content = "This has <b>bold text</b> here.";
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 let fixed = rule.fix(&ctx).unwrap();
1445 assert_eq!(fixed, "This has **bold text** here.");
1446 }
1447
1448 #[test]
1449 fn test_md033_fix_enabled_code_to_backticks() {
1450 let rule = MD033NoInlineHtml::with_fix(true);
1452 let content = "This has <code>inline code</code> here.";
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 let fixed = rule.fix(&ctx).unwrap();
1455 assert_eq!(fixed, "This has `inline code` here.");
1456 }
1457
1458 #[test]
1459 fn test_md033_fix_enabled_code_with_backticks() {
1460 let rule = MD033NoInlineHtml::with_fix(true);
1462 let content = "This has <code>text with `backticks`</code> here.";
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464 let fixed = rule.fix(&ctx).unwrap();
1465 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1466 }
1467
1468 #[test]
1469 fn test_md033_fix_enabled_br_trailing_spaces() {
1470 let rule = MD033NoInlineHtml::with_fix(true);
1472 let content = "First line<br>Second line";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let fixed = rule.fix(&ctx).unwrap();
1475 assert_eq!(fixed, "First line \nSecond line");
1476 }
1477
1478 #[test]
1479 fn test_md033_fix_enabled_br_self_closing() {
1480 let rule = MD033NoInlineHtml::with_fix(true);
1482 let content = "First<br/>second<br />third";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let fixed = rule.fix(&ctx).unwrap();
1485 assert_eq!(fixed, "First \nsecond \nthird");
1486 }
1487
1488 #[test]
1489 fn test_md033_fix_enabled_br_backslash_style() {
1490 let config = MD033Config {
1492 allowed: Vec::new(),
1493 disallowed: Vec::new(),
1494 fix: true,
1495 br_style: md033_config::BrStyle::Backslash,
1496 };
1497 let rule = MD033NoInlineHtml::from_config_struct(config);
1498 let content = "First line<br>Second line";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let fixed = rule.fix(&ctx).unwrap();
1501 assert_eq!(fixed, "First line\\\nSecond line");
1502 }
1503
1504 #[test]
1505 fn test_md033_fix_enabled_hr() {
1506 let rule = MD033NoInlineHtml::with_fix(true);
1508 let content = "Above<hr>Below";
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let fixed = rule.fix(&ctx).unwrap();
1511 assert_eq!(fixed, "Above\n---\nBelow");
1512 }
1513
1514 #[test]
1515 fn test_md033_fix_enabled_hr_self_closing() {
1516 let rule = MD033NoInlineHtml::with_fix(true);
1518 let content = "Above<hr/>Below";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let fixed = rule.fix(&ctx).unwrap();
1521 assert_eq!(fixed, "Above\n---\nBelow");
1522 }
1523
1524 #[test]
1525 fn test_md033_fix_skips_nested_tags() {
1526 let rule = MD033NoInlineHtml::with_fix(true);
1529 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let fixed = rule.fix(&ctx).unwrap();
1532 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1535 }
1536
1537 #[test]
1538 fn test_md033_fix_skips_tags_with_attributes() {
1539 let rule = MD033NoInlineHtml::with_fix(true);
1542 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let fixed = rule.fix(&ctx).unwrap();
1545 assert_eq!(fixed, content);
1547 }
1548
1549 #[test]
1550 fn test_md033_fix_disabled_no_changes() {
1551 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let fixed = rule.fix(&ctx).unwrap();
1556 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1557 }
1558
1559 #[test]
1560 fn test_md033_fix_capability_enabled() {
1561 let rule = MD033NoInlineHtml::with_fix(true);
1562 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1563 }
1564
1565 #[test]
1566 fn test_md033_fix_multiple_tags() {
1567 let rule = MD033NoInlineHtml::with_fix(true);
1569 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571 let fixed = rule.fix(&ctx).unwrap();
1572 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1573 }
1574
1575 #[test]
1576 fn test_md033_fix_uppercase_tags() {
1577 let rule = MD033NoInlineHtml::with_fix(true);
1579 let content = "This has <EM>emphasized</EM> text.";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let fixed = rule.fix(&ctx).unwrap();
1582 assert_eq!(fixed, "This has *emphasized* text.");
1583 }
1584
1585 #[test]
1586 fn test_md033_fix_unsafe_tags_removed_not_converted() {
1587 let rule = MD033NoInlineHtml::with_fix(true);
1589 let content = "This has <div>a div</div> content.";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591 let fixed = rule.fix(&ctx).unwrap();
1592 assert_eq!(fixed, "This has a div content.");
1594 }
1595
1596 #[test]
1597 fn test_md033_fix_multiple_tags_same_line() {
1598 let rule = MD033NoInlineHtml::with_fix(true);
1600 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let fixed = rule.fix(&ctx).unwrap();
1603 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
1604 }
1605
1606 #[test]
1607 fn test_md033_fix_multiple_em_tags_same_line() {
1608 let rule = MD033NoInlineHtml::with_fix(true);
1610 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let fixed = rule.fix(&ctx).unwrap();
1613 assert_eq!(fixed, "*first* and **second** and `third`");
1614 }
1615
1616 #[test]
1617 fn test_md033_fix_skips_tags_inside_pre() {
1618 let rule = MD033NoInlineHtml::with_fix(true);
1620 let content = "<pre><code><em>VALUE</em></code></pre>";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let fixed = rule.fix(&ctx).unwrap();
1623 assert!(
1626 !fixed.contains("*VALUE*"),
1627 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
1628 );
1629 }
1630
1631 #[test]
1632 fn test_md033_fix_skips_tags_inside_div() {
1633 let rule = MD033NoInlineHtml::with_fix(true);
1635 let content = "<div>\n<em>emphasized</em>\n</div>";
1636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1637 let fixed = rule.fix(&ctx).unwrap();
1638 assert!(
1640 !fixed.contains("*emphasized*"),
1641 "Tags inside HTML blocks should not be converted. Got: {fixed}"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_md033_fix_outside_html_block() {
1647 let rule = MD033NoInlineHtml::with_fix(true);
1649 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let fixed = rule.fix(&ctx).unwrap();
1652 assert!(
1654 fixed.contains("*emphasized*"),
1655 "Tags outside HTML blocks should be converted. Got: {fixed}"
1656 );
1657 }
1658
1659 #[test]
1660 fn test_md033_fix_with_id_attribute() {
1661 let rule = MD033NoInlineHtml::with_fix(true);
1663 let content = "See <em id=\"important\">this note</em> for details.";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let fixed = rule.fix(&ctx).unwrap();
1666 assert_eq!(fixed, content);
1668 }
1669
1670 #[test]
1671 fn test_md033_fix_with_style_attribute() {
1672 let rule = MD033NoInlineHtml::with_fix(true);
1674 let content = "This is <strong style=\"color: red\">important</strong> text.";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let fixed = rule.fix(&ctx).unwrap();
1677 assert_eq!(fixed, content);
1679 }
1680
1681 #[test]
1682 fn test_md033_fix_mixed_with_and_without_attributes() {
1683 let rule = MD033NoInlineHtml::with_fix(true);
1685 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687 let fixed = rule.fix(&ctx).unwrap();
1688 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
1690 }
1691
1692 #[test]
1693 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
1694 let rule = MD033NoInlineHtml::with_fix(true);
1696 let content = "<em class=\"test\">emphasized</em>";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert_eq!(result.len(), 1, "Should find one HTML tag");
1701 assert!(
1703 result[0].fix.is_none(),
1704 "Should NOT have a fix for tags with attributes"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_md033_fix_skips_html_entities() {
1710 let rule = MD033NoInlineHtml::with_fix(true);
1713 let content = "<code>|</code>";
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1715 let fixed = rule.fix(&ctx).unwrap();
1716 assert_eq!(fixed, content);
1718 }
1719
1720 #[test]
1721 fn test_md033_fix_skips_multiple_html_entities() {
1722 let rule = MD033NoInlineHtml::with_fix(true);
1724 let content = "<code><T></code>";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let fixed = rule.fix(&ctx).unwrap();
1727 assert_eq!(fixed, content);
1729 }
1730
1731 #[test]
1732 fn test_md033_fix_allows_ampersand_without_entity() {
1733 let rule = MD033NoInlineHtml::with_fix(true);
1735 let content = "<code>a & b</code>";
1736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1737 let fixed = rule.fix(&ctx).unwrap();
1738 assert_eq!(fixed, "`a & b`");
1740 }
1741
1742 #[test]
1743 fn test_md033_fix_em_with_entities_skipped() {
1744 let rule = MD033NoInlineHtml::with_fix(true);
1746 let content = "<em> text</em>";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let fixed = rule.fix(&ctx).unwrap();
1749 assert_eq!(fixed, content);
1751 }
1752
1753 #[test]
1754 fn test_md033_fix_skips_nested_em_in_code() {
1755 let rule = MD033NoInlineHtml::with_fix(true);
1758 let content = "<code><em>n</em></code>";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let fixed = rule.fix(&ctx).unwrap();
1761 assert!(
1764 !fixed.contains("*n*"),
1765 "Nested <em> should not be converted to markdown. Got: {fixed}"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_md033_fix_skips_nested_in_table() {
1771 let rule = MD033NoInlineHtml::with_fix(true);
1773 let content = "| <code>><em>n</em></code> | description |";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1775 let fixed = rule.fix(&ctx).unwrap();
1776 assert!(
1778 !fixed.contains("*n*"),
1779 "Nested tags in table should not be converted. Got: {fixed}"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_md033_fix_standalone_em_still_converted() {
1785 let rule = MD033NoInlineHtml::with_fix(true);
1787 let content = "This is <em>emphasized</em> text.";
1788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1789 let fixed = rule.fix(&ctx).unwrap();
1790 assert_eq!(fixed, "This is *emphasized* text.");
1791 }
1792}