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, 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 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
471 let mut attrs = Vec::new();
472
473 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
475
476 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 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 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 while chars.peek().is_some_and(|c| c.is_whitespace()) {
515 chars.next();
516 }
517
518 if chars.peek() == Some(&'=') {
520 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
524 chars.next();
525 }
526
527 let mut value = String::new();
529 if let Some("e) = chars.peek() {
530 if quote == '"' || quote == '\'' {
531 chars.next(); for c in chars.by_ref() {
533 if c == quote {
534 break;
535 }
536 value.push(c);
537 }
538 } else {
539 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 attrs.push((attr_name.to_ascii_lowercase(), None));
553 }
554 }
555
556 attrs
557 }
558
559 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 fn has_extra_attributes(tag: &str, allowed_attrs: &[&str]) -> bool {
575 let attrs = Self::parse_attributes(tag);
576
577 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; 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", "data-*", ];
601
602 for (attr_name, _) in attrs {
603 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
605 continue;
606 }
607
608 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 if attr_name.starts_with("data-") {
617 return true;
618 }
619
620 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
622 return true;
623 }
624 }
625
626 false
627 }
628
629 fn convert_a_to_markdown(opening_tag: &str, inner_content: &str) -> Option<String> {
632 let href = Self::extract_attribute(opening_tag, "href")?;
634
635 if !MD033Config::is_safe_url(&href) {
637 return None;
638 }
639
640 if inner_content.contains('<') {
642 return None;
643 }
644
645 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 let title = Self::extract_attribute(opening_tag, "title");
658
659 if Self::has_extra_attributes(opening_tag, &["href", "title"]) {
661 return None;
662 }
663
664 let trimmed_inner = inner_content.trim();
669 let is_markdown_image =
670 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
671 if let Some(bracket_close) = trimmed_inner.rfind("](") {
674 let after_paren = &trimmed_inner[bracket_close + 2..];
675 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 inner_content.replace('[', r"\[").replace(']', r"\]")
689 };
690
691 let escaped_url = href.replace('(', "%28").replace(')', "%29");
693
694 if let Some(title_text) = title {
696 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 fn convert_img_to_markdown(tag: &str) -> Option<String> {
707 let src = Self::extract_attribute(tag, "src")?;
709
710 if !MD033Config::is_safe_url(&src) {
712 return None;
713 }
714
715 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
717
718 let title = Self::extract_attribute(tag, "title");
720
721 if Self::has_extra_attributes(tag, &["src", "alt", "title"]) {
723 return None;
724 }
725
726 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
728
729 let escaped_url = src.replace('(', "%28").replace(')', "%29");
731
732 if let Some(title_text) = title {
734 let escaped_title = title_text.replace('"', r#"\""#);
736 Some(format!(""))
737 } else {
738 Some(format!(""))
739 }
740 }
741
742 fn has_significant_attributes(opening_tag: &str) -> bool {
744 let tag_content = opening_tag
746 .trim_start_matches('<')
747 .trim_end_matches('>')
748 .trim_end_matches('/');
749
750 let parts: Vec<&str> = tag_content.split_whitespace().collect();
752 parts.len() > 1
753 }
754
755 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
758 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 if let Some(last_lt) = before_trimmed.rfind('<') {
765 let potential_tag = &before_trimmed[last_lt..];
766 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
768 return true;
769 }
770 }
771 }
772 }
773 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 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 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 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 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 return None;
825 }
826
827 let search_start = tag_byte_start + opening_tag.len();
829 let search_slice = &content[search_start..];
830
831 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 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 let inner_content = &content[search_start..closing_byte_start];
843
844 if in_html_block {
847 return None;
848 }
849
850 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
853 return None;
854 }
855
856 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
858 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 return None;
865 }
866
867 if Self::has_significant_attributes(opening_tag) {
869 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 return None;
879 }
880
881 return None;
884 }
885
886 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 if content.is_empty() || !ctx.likely_has_html() {
905 return Ok(Vec::new());
906 }
907
908 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 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 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 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 let html_tags = ctx.html_tags();
947
948 for html_tag in html_tags.iter() {
949 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 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
959
960 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 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 if nomarkdown_ranges
977 .iter()
978 .any(|(start, end)| line_num >= *start && line_num <= *end)
979 {
980 continue;
981 }
982
983 if ctx.is_in_html_comment(tag_byte_start) {
985 continue;
986 }
987
988 if self.is_html_comment(tag) {
990 continue;
991 }
992
993 if ctx.is_in_link_title(tag_byte_start) {
996 continue;
997 }
998
999 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(|c| c.is_uppercase()) {
1001 continue;
1002 }
1003
1004 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1006 continue;
1007 }
1008
1009 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1012 continue;
1013 }
1014
1015 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1017 continue;
1018 }
1019
1020 if self.is_likely_type_annotation(tag) {
1022 continue;
1023 }
1024
1025 if self.is_email_address(tag) {
1027 continue;
1028 }
1029
1030 if self.is_url_in_angle_brackets(tag) {
1032 continue;
1033 }
1034
1035 if ctx.is_byte_offset_in_code_span(tag_byte_start) {
1037 continue;
1038 }
1039
1040 if self.is_disallowed_mode() {
1044 if !self.is_tag_disallowed(tag) {
1046 continue;
1047 }
1048 } else {
1049 if self.is_tag_allowed(tag) {
1051 continue;
1052 }
1053 }
1054
1055 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1057 continue;
1058 }
1059
1060 let in_html_block = ctx.is_in_html_block(line_num);
1062
1063 let fix = self
1065 .calculate_fix(content, tag, tag_byte_start, in_html_block)
1066 .map(|(range, replacement)| Fix { range, replacement });
1067
1068 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 warnings.push(LintWarning {
1078 rule_name: Some(self.name().to_string()),
1079 line: line_num,
1080 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, 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 if !self.config.fix {
1095 return Ok(ctx.content.to_string());
1096 }
1097
1098 let warnings = self.check(ctx)?;
1100
1101 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1103 return Ok(ctx.content.to_string());
1104 }
1105
1106 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 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 fn category(&self) -> RuleCategory {
1134 RuleCategory::Html
1135 }
1136
1137 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 assert_eq!(result.len(), 1); 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 assert_eq!(result.len(), 2); 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 assert_eq!(result.len(), 1);
1200 assert_eq!(result[0].message, "Inline HTML found: <p>");
1201
1202 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); 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 assert_eq!(result.len(), 1); 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 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 assert_eq!(result2.len(), 1); 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 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 assert_eq!(result.len(), 1); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(result[0].line, 1, "Start line should be 1");
1439 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 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 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 assert_eq!(result[0].line, 2, "Start line should be 2");
1468 assert_eq!(result[0].end_line, 5, "End line should be 5");
1470 }
1471
1472 #[test]
1473 fn test_md033_disallowed_mode_basic() {
1474 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 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 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 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 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 assert_eq!(result.len(), 2, "Should flag both case variants");
1533 }
1534
1535 #[test]
1536 fn test_md033_disallowed_with_attributes() {
1537 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 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 let rule = MD033NoInlineHtml::with_disallowed(vec![
1566 "gfm".to_string(),
1567 "marquee".to_string(), ]);
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 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 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 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1587 }
1588
1589 #[test]
1590 fn test_md033_jsx_fragments_in_mdx() {
1591 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 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 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 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 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 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1640 }
1641
1642 #[test]
1643 fn test_md033_jsx_attributes_in_mdx() {
1644 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 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 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 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1683 }
1684
1685 #[test]
1688 fn test_md033_fix_disabled_by_default() {
1689 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(fixed, content);
1834 }
1835
1836 #[test]
1837 fn test_md033_fix_disabled_no_changes() {
1838 let rule = MD033NoInlineHtml::default(); 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 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 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 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 assert_eq!(fixed, "This has <div>a div</div> content.");
1882 }
1883
1884 #[test]
1885 fn test_md033_fix_img_tag_converted() {
1886 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 assert_eq!(fixed, "Image: ");
1893 }
1894
1895 #[test]
1896 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1897 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 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 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 assert_eq!(fixed, "*italic* and  and **bold**");
1915 }
1916
1917 #[test]
1918 fn test_md033_fix_multiple_tags_same_line() {
1919 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 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 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 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 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 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 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 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 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 assert_eq!(fixed, content);
1989 }
1990
1991 #[test]
1992 fn test_md033_fix_with_style_attribute() {
1993 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 assert_eq!(fixed, content);
2000 }
2001
2002 #[test]
2003 fn test_md033_fix_mixed_with_and_without_attributes() {
2004 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 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 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 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 let rule = MD033NoInlineHtml::with_fix(true);
2034 let content = "<code>|</code>";
2035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036 let fixed = rule.fix(&ctx).unwrap();
2037 assert_eq!(fixed, content);
2039 }
2040
2041 #[test]
2042 fn test_md033_fix_skips_multiple_html_entities() {
2043 let rule = MD033NoInlineHtml::with_fix(true);
2045 let content = "<code><T></code>";
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let fixed = rule.fix(&ctx).unwrap();
2048 assert_eq!(fixed, content);
2050 }
2051
2052 #[test]
2053 fn test_md033_fix_allows_ampersand_without_entity() {
2054 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 assert_eq!(fixed, "`a & b`");
2061 }
2062
2063 #[test]
2064 fn test_md033_fix_em_with_entities_skipped() {
2065 let rule = MD033NoInlineHtml::with_fix(true);
2067 let content = "<em> text</em>";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let fixed = rule.fix(&ctx).unwrap();
2070 assert_eq!(fixed, content);
2072 }
2073
2074 #[test]
2075 fn test_md033_fix_skips_nested_em_in_code() {
2076 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 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 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 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 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 #[test]
2126 fn test_md033_templater_basic_interpolation_not_flagged() {
2127 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 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 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 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 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 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 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 let rule = MD033NoInlineHtml::default();
2221 let contents = [
2222 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
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 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 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 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 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 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 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 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 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 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 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 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 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 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 let rule = MD033NoInlineHtml::default();
2426 let patterns = [
2427 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
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 #[test]
2451 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2452 let rule = MD033NoInlineHtml::with_fix(true);
2455 let content = r#"<a href="https://example.com"></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)",);
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 let rule = MD033NoInlineHtml::with_fix(true);
2468 let content =
2469 r#"<a href="https://github.com/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 "[](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, "");
2487 }
2488
2489 #[test]
2490 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2491 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 let rule = MD033NoInlineHtml::with_fix(true);
2508 let content = r#"<a href="/link"> see [docs]</a>"#;
2509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2510 let fixed = rule.fix(&ctx).unwrap();
2511
2512 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 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://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 "[](https://github.com/org/repo)",
2568 );
2569 assert!(result.converged);
2570 }
2571}