1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::regex_cache::*;
8use std::collections::HashSet;
9
10mod md033_config;
11use md033_config::{MD033Config, MD033FixMode};
12
13#[derive(Clone)]
14pub struct MD033NoInlineHtml {
15 config: MD033Config,
16 allowed: HashSet<String>,
17 table_allowed: HashSet<String>,
18 disallowed: HashSet<String>,
19 drop_attributes: HashSet<String>,
20 strip_wrapper_elements: HashSet<String>,
21}
22
23impl Default for MD033NoInlineHtml {
24 fn default() -> Self {
25 Self::from_config_struct(MD033Config::default())
26 }
27}
28
29impl MD033NoInlineHtml {
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
35 Self::from_config_struct(MD033Config {
36 allowed: allowed_vec,
37 ..MD033Config::default()
38 })
39 }
40
41 pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
42 Self::from_config_struct(MD033Config {
43 disallowed: disallowed_vec,
44 ..MD033Config::default()
45 })
46 }
47
48 pub fn with_fix(fix: bool) -> Self {
50 Self::from_config_struct(MD033Config {
51 fix,
52 ..MD033Config::default()
53 })
54 }
55
56 pub fn from_config_struct(config: MD033Config) -> Self {
59 let allowed = config.allowed_set();
60 let table_allowed = config.table_allowed_set();
61 let disallowed = config.disallowed_set();
62 let drop_attributes = config.drop_attributes_set();
63 let strip_wrapper_elements = config.strip_wrapper_elements_set();
64 Self {
65 config,
66 allowed,
67 table_allowed,
68 disallowed,
69 drop_attributes,
70 strip_wrapper_elements,
71 }
72 }
73
74 #[inline]
78 fn extract_tag_name(tag: &str) -> String {
79 let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
80 trimmed
81 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
82 .next()
83 .unwrap_or("")
84 .to_lowercase()
85 }
86
87 #[inline]
90 fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
91 if set.is_empty() {
92 return false;
93 }
94 set.contains(&Self::extract_tag_name(tag))
95 }
96
97 #[inline]
99 fn is_tag_allowed(&self, tag: &str) -> bool {
100 Self::tag_in_set(&self.allowed, tag)
101 }
102
103 #[inline]
106 fn is_tag_allowed_in_table(&self, tag: &str) -> bool {
107 Self::tag_in_set(&self.table_allowed, tag)
108 }
109
110 #[inline]
112 fn is_tag_disallowed(&self, tag: &str) -> bool {
113 Self::tag_in_set(&self.disallowed, tag)
114 }
115
116 #[inline]
118 fn is_disallowed_mode(&self) -> bool {
119 self.config.is_disallowed_mode()
120 }
121
122 #[inline]
124 fn is_html_comment(&self, tag: &str) -> bool {
125 tag.starts_with("<!--") && tag.ends_with("-->")
126 }
127
128 #[inline]
133 fn is_html_element_or_custom(tag_name: &str) -> bool {
134 const HTML_ELEMENTS: &[&str] = &[
136 "a",
137 "abbr",
138 "acronym",
139 "address",
140 "applet",
141 "area",
142 "article",
143 "aside",
144 "audio",
145 "b",
146 "base",
147 "basefont",
148 "bdi",
149 "bdo",
150 "big",
151 "blockquote",
152 "body",
153 "br",
154 "button",
155 "canvas",
156 "caption",
157 "center",
158 "cite",
159 "code",
160 "col",
161 "colgroup",
162 "data",
163 "datalist",
164 "dd",
165 "del",
166 "details",
167 "dfn",
168 "dialog",
169 "dir",
170 "div",
171 "dl",
172 "dt",
173 "em",
174 "embed",
175 "fieldset",
176 "figcaption",
177 "figure",
178 "font",
179 "footer",
180 "form",
181 "frame",
182 "frameset",
183 "h1",
184 "h2",
185 "h3",
186 "h4",
187 "h5",
188 "h6",
189 "head",
190 "header",
191 "hgroup",
192 "hr",
193 "html",
194 "i",
195 "iframe",
196 "img",
197 "input",
198 "ins",
199 "isindex",
200 "kbd",
201 "label",
202 "legend",
203 "li",
204 "link",
205 "main",
206 "map",
207 "mark",
208 "marquee",
209 "math",
210 "menu",
211 "meta",
212 "meter",
213 "nav",
214 "noembed",
215 "noframes",
216 "noscript",
217 "object",
218 "ol",
219 "optgroup",
220 "option",
221 "output",
222 "p",
223 "param",
224 "picture",
225 "plaintext",
226 "pre",
227 "progress",
228 "q",
229 "rp",
230 "rt",
231 "ruby",
232 "s",
233 "samp",
234 "script",
235 "search",
236 "section",
237 "select",
238 "slot",
239 "small",
240 "source",
241 "span",
242 "strike",
243 "strong",
244 "style",
245 "sub",
246 "summary",
247 "sup",
248 "svg",
249 "table",
250 "tbody",
251 "td",
252 "template",
253 "textarea",
254 "tfoot",
255 "th",
256 "thead",
257 "time",
258 "title",
259 "tr",
260 "track",
261 "tt",
262 "u",
263 "ul",
264 "var",
265 "video",
266 "wbr",
267 "xmp",
268 ];
269
270 let lower = tag_name.to_ascii_lowercase();
271 if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
272 return true;
273 }
274 tag_name.contains('-')
276 }
277
278 #[inline]
280 fn is_likely_type_annotation(&self, tag: &str) -> bool {
281 const COMMON_TYPES: &[&str] = &[
283 "any",
284 "apiresponse",
285 "array",
286 "bigint",
287 "config",
288 "data",
289 "date",
290 "e",
291 "element",
292 "error",
293 "function",
294 "generator",
295 "item",
296 "iterator",
297 "k",
298 "map",
299 "node",
300 "null",
301 "number",
302 "options",
303 "params",
304 "promise",
305 "regexp",
306 "request",
307 "response",
308 "result",
309 "set",
310 "string",
311 "symbol",
312 "t",
313 "u",
314 "undefined",
315 "userdata",
316 "v",
317 "void",
318 "weakmap",
319 "weakset",
320 ];
321
322 let tag_content = tag
323 .trim_start_matches('<')
324 .trim_end_matches('>')
325 .trim_start_matches('/');
326 let tag_name = tag_content
327 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
328 .next()
329 .unwrap_or("");
330
331 if !tag_content.contains(' ') && !tag_content.contains('=') {
333 let lower = tag_name.to_ascii_lowercase();
334 COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
335 } else {
336 false
337 }
338 }
339
340 #[inline]
342 fn is_email_address(&self, tag: &str) -> bool {
343 let content = tag.trim_start_matches('<').trim_end_matches('>');
344 content.contains('@')
346 && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
347 && content.split('@').count() == 2
348 && content.split('@').all(|part| !part.is_empty())
349 }
350
351 #[inline]
353 fn has_markdown_attribute(&self, tag: &str) -> bool {
354 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
357 }
358
359 #[inline]
366 fn has_jsx_attributes(tag: &str) -> bool {
367 tag.contains("className")
369 || tag.contains("htmlFor")
370 || tag.contains("dangerouslySetInnerHTML")
371 || tag.contains("onClick")
373 || tag.contains("onChange")
374 || tag.contains("onSubmit")
375 || tag.contains("onFocus")
376 || tag.contains("onBlur")
377 || tag.contains("onKeyDown")
378 || tag.contains("onKeyUp")
379 || tag.contains("onKeyPress")
380 || tag.contains("onMouseDown")
381 || tag.contains("onMouseUp")
382 || tag.contains("onMouseEnter")
383 || tag.contains("onMouseLeave")
384 || tag.contains("={")
386 }
387
388 #[inline]
390 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
391 let content = tag.trim_start_matches('<').trim_end_matches('>');
392 content.starts_with("http://")
394 || content.starts_with("https://")
395 || content.starts_with("ftp://")
396 || content.starts_with("ftps://")
397 || content.starts_with("mailto:")
398 }
399
400 #[inline]
401 fn is_relaxed_fix_mode(&self) -> bool {
402 self.config.fix_mode == MD033FixMode::Relaxed
403 }
404
405 #[inline]
406 fn is_droppable_attribute(&self, attr_name: &str) -> bool {
407 if attr_name.starts_with("on") && attr_name.len() > 2 {
410 return false;
411 }
412 self.drop_attributes.contains(attr_name)
413 || (attr_name.starts_with("data-")
414 && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
415 }
416
417 #[inline]
418 fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
419 self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
420 }
421
422 fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
433 if byte_offset == 0 {
434 return false;
435 }
436 let before = content[..byte_offset].trim_end();
437 if !before.ends_with('>') || before.ends_with("->") {
438 return false;
439 }
440 if let Some(last_lt) = before.rfind('<') {
441 let potential_tag = &before[last_lt..];
442 if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
443 return false;
444 }
445 let parent_name = potential_tag
446 .trim_start_matches('<')
447 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
448 .next()
449 .unwrap_or("")
450 .to_lowercase();
451 if !self.strip_wrapper_elements.contains(&parent_name) {
452 return false;
453 }
454 let wrapper_before = before[..last_lt].trim_end();
456 if wrapper_before.ends_with('>')
457 && !wrapper_before.ends_with("->")
458 && let Some(outer_lt) = wrapper_before.rfind('<')
459 && let outer_tag = &wrapper_before[outer_lt..]
460 && !outer_tag.starts_with("</")
461 && !outer_tag.starts_with("<!--")
462 {
463 return false;
464 }
465 return true;
466 }
467 false
468 }
469
470 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
473 if inner_content.contains('<') {
475 return None;
476 }
477 if inner_content.contains('&') && inner_content.contains(';') {
480 let has_entity = inner_content
482 .split('&')
483 .skip(1)
484 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
485 if has_entity {
486 return None;
487 }
488 }
489 match tag_name {
490 "em" | "i" => Some(format!("*{inner_content}*")),
491 "strong" | "b" => Some(format!("**{inner_content}**")),
492 "code" => {
493 if inner_content.contains('`') {
495 Some(format!("`` {inner_content} ``"))
496 } else {
497 Some(format!("`{inner_content}`"))
498 }
499 }
500 _ => None,
501 }
502 }
503
504 fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
506 match tag_name {
507 "br" => match self.config.br_style {
508 md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
509 md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
510 },
511 "hr" => Some("\n---\n".to_string()),
512 "img" => self.convert_img_to_markdown(opening_tag),
513 _ => None,
514 }
515 }
516
517 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
520 let mut attrs = Vec::new();
521
522 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
524
525 let attr_start = tag_content
529 .char_indices()
530 .find(|(_, c)| c.is_whitespace())
531 .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
532
533 if attr_start >= tag_content.len() {
534 return attrs;
535 }
536
537 let attr_str = &tag_content[attr_start..];
538 let mut chars = attr_str.chars().peekable();
539
540 while chars.peek().is_some() {
541 while chars.peek().is_some_and(|c| c.is_whitespace()) {
543 chars.next();
544 }
545
546 if chars.peek().is_none() {
547 break;
548 }
549
550 let mut attr_name = String::new();
552 while let Some(&c) = chars.peek() {
553 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
554 break;
555 }
556 attr_name.push(c);
557 chars.next();
558 }
559
560 if attr_name.is_empty() {
561 break;
562 }
563
564 while chars.peek().is_some_and(|c| c.is_whitespace()) {
566 chars.next();
567 }
568
569 if chars.peek() == Some(&'=') {
571 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
575 chars.next();
576 }
577
578 let mut value = String::new();
580 if let Some("e) = chars.peek() {
581 if quote == '"' || quote == '\'' {
582 chars.next(); for c in chars.by_ref() {
584 if c == quote {
585 break;
586 }
587 value.push(c);
588 }
589 } else {
590 while let Some(&c) = chars.peek() {
592 if c.is_whitespace() || c == '>' || c == '/' {
593 break;
594 }
595 value.push(c);
596 chars.next();
597 }
598 }
599 }
600 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
601 } else {
602 attrs.push((attr_name.to_ascii_lowercase(), None));
604 }
605 }
606
607 attrs
608 }
609
610 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
614 let attrs = Self::parse_attributes(tag);
615 let attr_lower = attr_name.to_ascii_lowercase();
616
617 attrs
618 .into_iter()
619 .find(|(name, _)| name == &attr_lower)
620 .and_then(|(_, value)| value)
621 }
622
623 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
626 let attrs = Self::parse_attributes(tag);
627
628 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
632 "class",
633 "id",
634 "style",
635 "target",
636 "rel",
637 "download",
638 "referrerpolicy",
639 "crossorigin",
640 "loading",
641 "decoding",
642 "fetchpriority",
643 "sizes",
644 "srcset",
645 "usemap",
646 "ismap",
647 "width",
648 "height",
649 "name", "data-*", ];
652
653 for (attr_name, _) in attrs {
654 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
656 continue;
657 }
658
659 if self.is_relaxed_fix_mode() {
660 if self.is_droppable_attribute(&attr_name) {
661 continue;
662 }
663 return true;
664 }
665
666 for prefix in DANGEROUS_ATTR_PREFIXES {
668 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
669 return true;
670 }
671 }
672
673 if attr_name.starts_with("data-") {
675 return true;
676 }
677
678 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
680 return true;
681 }
682 }
683
684 false
685 }
686
687 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
690 let href = Self::extract_attribute(opening_tag, "href")?;
692
693 if !MD033Config::is_safe_url(&href) {
695 return None;
696 }
697
698 if inner_content.contains('<') {
700 return None;
701 }
702
703 if inner_content.contains('&') && inner_content.contains(';') {
705 let has_entity = inner_content
706 .split('&')
707 .skip(1)
708 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
709 if has_entity {
710 return None;
711 }
712 }
713
714 let title = Self::extract_attribute(opening_tag, "title");
716
717 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
719 return None;
720 }
721
722 let trimmed_inner = inner_content.trim();
727 let is_markdown_image =
728 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
729 if let Some(bracket_close) = trimmed_inner.rfind("](") {
732 let after_paren = &trimmed_inner[bracket_close + 2..];
733 after_paren.ends_with(')')
735 && after_paren.chars().filter(|&c| c == ')').count()
736 >= after_paren.chars().filter(|&c| c == '(').count()
737 } else {
738 false
739 }
740 };
741 let escaped_text = if is_markdown_image {
742 trimmed_inner.to_string()
743 } else {
744 inner_content.replace('[', r"\[").replace(']', r"\]")
747 };
748
749 let escaped_url = href.replace('(', "%28").replace(')', "%29");
751
752 if let Some(title_text) = title {
754 let escaped_title = title_text.replace('"', r#"\""#);
756 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
757 } else {
758 Some(format!("[{escaped_text}]({escaped_url})"))
759 }
760 }
761
762 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
765 let src = Self::extract_attribute(tag, "src")?;
767
768 if !MD033Config::is_safe_url(&src) {
770 return None;
771 }
772
773 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
775
776 let title = Self::extract_attribute(tag, "title");
778
779 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
781 return None;
782 }
783
784 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
786
787 let escaped_url = src.replace('(', "%28").replace(')', "%29");
789
790 if let Some(title_text) = title {
792 let escaped_title = title_text.replace('"', r#"\""#);
794 Some(format!(""))
795 } else {
796 Some(format!(""))
797 }
798 }
799
800 fn has_significant_attributes(opening_tag: &str) -> bool {
802 let tag_content = opening_tag
804 .trim_start_matches('<')
805 .trim_end_matches('>')
806 .trim_end_matches('/');
807
808 let parts: Vec<&str> = tag_content.split_whitespace().collect();
810 parts.len() > 1
811 }
812
813 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
816 if tag_byte_start > 0 {
818 let before = &content[..tag_byte_start];
819 let before_trimmed = before.trim_end();
820 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
821 if let Some(last_lt) = before_trimmed.rfind('<') {
823 let potential_tag = &before_trimmed[last_lt..];
824 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
826 return true;
827 }
828 }
829 }
830 }
831 if tag_byte_end < content.len() {
833 let after = &content[tag_byte_end..];
834 let after_trimmed = after.trim_start();
835 if after_trimmed.starts_with("</") {
836 return true;
837 }
838 }
839 false
840 }
841
842 fn calculate_fix(
857 &self,
858 content: &str,
859 opening_tag: &str,
860 tag_byte_start: usize,
861 in_html_block: bool,
862 ) -> Option<(std::ops::Range<usize>, String)> {
863 let tag_name = opening_tag
865 .trim_start_matches('<')
866 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
867 .next()?
868 .to_lowercase();
869
870 let is_self_closing =
872 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
873
874 if is_self_closing {
875 let block_ok = !in_html_block
881 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
882 if self.config.fix
883 && MD033Config::is_safe_fixable_tag(&tag_name)
884 && block_ok
885 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
886 {
887 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
888 }
889 return None;
892 }
893
894 let search_start = tag_byte_start + opening_tag.len();
896 let search_slice = &content[search_start..];
897
898 let closing_tag_lower = format!("</{tag_name}>");
900 let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
901
902 if let Some(closing_pos) = closing_pos {
903 let closing_tag_len = closing_tag_lower.len();
905 let closing_byte_start = search_start + closing_pos;
906 let closing_byte_end = closing_byte_start + closing_tag_len;
907
908 let inner_content = &content[search_start..closing_byte_start];
910
911 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
918 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
919 return None;
920 }
921 if inner_content.contains('<') {
922 return None;
923 }
924 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
925 }
926
927 if in_html_block {
930 return None;
931 }
932
933 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
936 return None;
937 }
938
939 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
941 if tag_name == "a" {
943 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
944 return Some((tag_byte_start..closing_byte_end, markdown));
945 }
946 return None;
948 }
949
950 if Self::has_significant_attributes(opening_tag) {
952 return None;
955 }
956 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
957 return Some((tag_byte_start..closing_byte_end, markdown));
958 }
959 return None;
962 }
963
964 return None;
967 }
968
969 None
971 }
972}
973
974impl Rule for MD033NoInlineHtml {
975 fn name(&self) -> &'static str {
976 "MD033"
977 }
978
979 fn description(&self) -> &'static str {
980 "Inline HTML is not allowed"
981 }
982
983 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
984 let content = ctx.content;
985
986 if content.is_empty() || !ctx.likely_has_html() {
988 return Ok(Vec::new());
989 }
990
991 if !HTML_TAG_QUICK_CHECK.is_match(content) {
993 return Ok(Vec::new());
994 }
995
996 let mut warnings = Vec::new();
997
998 let html_tags = ctx.html_tags();
1000
1001 for html_tag in html_tags.iter() {
1002 if html_tag.is_closing {
1004 continue;
1005 }
1006
1007 let line_num = html_tag.line;
1008 let tag_byte_start = html_tag.byte_offset;
1009
1010 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1012
1013 if ctx
1015 .line_info(line_num)
1016 .is_some_and(|info| info.in_code_block || info.in_pymdown_block || info.is_kramdown_block_ial)
1017 {
1018 continue;
1019 }
1020
1021 if ctx.is_in_html_comment(tag_byte_start) || ctx.is_in_mdx_comment(tag_byte_start) {
1023 continue;
1024 }
1025
1026 if self.is_html_comment(tag) {
1028 continue;
1029 }
1030
1031 if ctx.is_in_link_title(tag_byte_start) {
1034 continue;
1035 }
1036
1037 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1039 continue;
1040 }
1041
1042 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1044 continue;
1045 }
1046
1047 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1050 continue;
1051 }
1052
1053 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1055 continue;
1056 }
1057
1058 if self.is_likely_type_annotation(tag) {
1060 continue;
1061 }
1062
1063 if self.is_email_address(tag) {
1065 continue;
1066 }
1067
1068 if self.is_url_in_angle_brackets(tag) {
1070 continue;
1071 }
1072
1073 if ctx.is_byte_offset_in_code_span(tag_byte_start) {
1075 continue;
1076 }
1077
1078 if self.is_disallowed_mode() {
1083 if !self.is_tag_disallowed(tag) {
1084 continue;
1085 }
1086 } else if ctx.is_in_table_block(line_num) {
1087 if self.is_tag_allowed_in_table(tag) {
1088 continue;
1089 }
1090 } else if self.is_tag_allowed(tag) {
1091 continue;
1092 }
1093
1094 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1096 continue;
1097 }
1098
1099 let in_html_block = ctx.is_in_html_block(line_num);
1101
1102 let fix = self
1104 .calculate_fix(content, tag, tag_byte_start, in_html_block)
1105 .map(|(range, replacement)| Fix::new(range, replacement));
1106
1107 let (end_line, end_col) = if html_tag.byte_end > 0 {
1110 ctx.offset_to_line_col(html_tag.byte_end - 1)
1111 } else {
1112 (line_num, html_tag.end_col + 1)
1113 };
1114
1115 warnings.push(LintWarning {
1117 rule_name: Some(self.name().to_string()),
1118 line: line_num,
1119 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
1123 severity: Severity::Warning,
1124 fix,
1125 });
1126 }
1127
1128 Ok(warnings)
1129 }
1130
1131 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1132 if !self.config.fix {
1134 return Ok(ctx.content.to_string());
1135 }
1136
1137 let warnings = self.check(ctx)?;
1139 let warnings =
1140 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1141
1142 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1144 return Ok(ctx.content.to_string());
1145 }
1146
1147 let mut fixes: Vec<_> = warnings
1149 .iter()
1150 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1151 .collect();
1152 fixes.sort_by(|a, b| b.0.cmp(&a.0));
1153
1154 let mut result = ctx.content.to_string();
1156 for (start, end, replacement) in fixes {
1157 if start < result.len() && end <= result.len() && start <= end {
1158 result.replace_range(start..end, replacement);
1159 }
1160 }
1161
1162 Ok(result)
1163 }
1164
1165 fn fix_capability(&self) -> crate::rule::FixCapability {
1166 if self.config.fix {
1167 crate::rule::FixCapability::FullyFixable
1168 } else {
1169 crate::rule::FixCapability::Unfixable
1170 }
1171 }
1172
1173 fn category(&self) -> RuleCategory {
1175 RuleCategory::Html
1176 }
1177
1178 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1180 ctx.content.is_empty() || !ctx.likely_has_html()
1181 }
1182
1183 fn as_any(&self) -> &dyn std::any::Any {
1184 self
1185 }
1186
1187 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1188 let table = crate::rule_config_serde::config_schema_table(&self.config)?;
1189 Some((self.name().to_string(), toml::Value::Table(table)))
1190 }
1191
1192 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1193 let mut aliases = std::collections::HashMap::new();
1194 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1196 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1197 Some(aliases)
1198 }
1199
1200 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1201 where
1202 Self: Sized,
1203 {
1204 let rule_config = crate::rule_config_serde::load_rule_config::<MD033Config>(config);
1205 Box::new(Self::from_config_struct(rule_config))
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212 use crate::lint_context::LintContext;
1213 use crate::rule::Rule;
1214
1215 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1216 let config = MD033Config {
1217 fix: true,
1218 fix_mode: MD033FixMode::Relaxed,
1219 ..MD033Config::default()
1220 };
1221 MD033NoInlineHtml::from_config_struct(config)
1222 }
1223
1224 #[test]
1225 fn test_md033_basic_html() {
1226 let rule = MD033NoInlineHtml::default();
1227 let content = "<div>Some content</div>";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1233 }
1234
1235 #[test]
1236 fn test_md033_case_insensitive() {
1237 let rule = MD033NoInlineHtml::default();
1238 let content = "<DiV>Some <B>content</B></dIv>";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1244 assert_eq!(result[1].message, "Inline HTML found: <B>");
1245 }
1246
1247 #[test]
1248 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1249 let rule = relaxed_fix_rule();
1252 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let _ = rule.check(&ctx).unwrap();
1256 let _ = rule.fix(&ctx).unwrap();
1257 }
1258
1259 #[test]
1260 fn test_md033_allowed_tags() {
1261 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1262 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265 assert_eq!(result.len(), 1);
1267 assert_eq!(result[0].message, "Inline HTML found: <p>");
1268
1269 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1271 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1272 let result2 = rule.check(&ctx2).unwrap();
1273 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1275 }
1276
1277 #[test]
1278 fn test_md033_html_comments() {
1279 let rule = MD033NoInlineHtml::default();
1280 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 let result = rule.check(&ctx).unwrap();
1283 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1286 }
1287
1288 #[test]
1289 fn test_md033_tags_in_links() {
1290 let rule = MD033NoInlineHtml::default();
1291 let content = "[Link](http://example.com/<div>)";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294 assert_eq!(result.len(), 1);
1296 assert_eq!(result[0].message, "Inline HTML found: <div>");
1297
1298 let content2 = "[Link <a>text</a>](url)";
1299 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1300 let result2 = rule.check(&ctx2).unwrap();
1301 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1304 }
1305
1306 #[test]
1307 fn test_md033_fix_escaping() {
1308 let rule = MD033NoInlineHtml::default();
1309 let content = "Text with <div> and <br/> tags.";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let fixed_content = rule.fix(&ctx).unwrap();
1312 assert_eq!(fixed_content, content);
1314 }
1315
1316 #[test]
1317 fn test_md033_in_code_blocks() {
1318 let rule = MD033NoInlineHtml::default();
1319 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1325 }
1326
1327 #[test]
1328 fn test_md033_in_code_spans() {
1329 let rule = MD033NoInlineHtml::default();
1330 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert_eq!(result.len(), 1);
1335 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1336 }
1337
1338 #[test]
1339 fn test_md033_issue_90_code_span_with_diff_block() {
1340 let rule = MD033NoInlineHtml::default();
1342 let content = r#"# Heading
1343
1344`<env>`
1345
1346```diff
1347- this
1348+ that
1349```"#;
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1354 }
1355
1356 #[test]
1357 fn test_md033_multiple_code_spans_with_angle_brackets() {
1358 let rule = MD033NoInlineHtml::default();
1360 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let result = rule.check(&ctx).unwrap();
1363 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1364 }
1365
1366 #[test]
1367 fn test_md033_nested_angle_brackets_in_code_span() {
1368 let rule = MD033NoInlineHtml::default();
1370 let content = "Text with `<<nested>>` brackets";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372 let result = rule.check(&ctx).unwrap();
1373 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1374 }
1375
1376 #[test]
1377 fn test_md033_code_span_at_end_before_code_block() {
1378 let rule = MD033NoInlineHtml::default();
1380 let content = "Testing `<test>`\n```\ncode here\n```";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383 assert_eq!(result.len(), 0, "Should handle code span before code block");
1384 }
1385
1386 #[test]
1387 fn test_md033_quick_fix_inline_tag() {
1388 let rule = MD033NoInlineHtml::default();
1391 let content = "This has <span>inline text</span> that should keep content.";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let result = rule.check(&ctx).unwrap();
1394
1395 assert_eq!(result.len(), 1, "Should find one HTML tag");
1396 assert!(
1398 result[0].fix.is_none(),
1399 "Non-fixable tags like <span> should not have a fix"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_md033_quick_fix_multiline_tag() {
1405 let rule = MD033NoInlineHtml::default();
1408 let content = "<div>\nBlock content\n</div>";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411
1412 assert_eq!(result.len(), 1, "Should find one HTML tag");
1413 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1415 }
1416
1417 #[test]
1418 fn test_md033_quick_fix_self_closing_tag() {
1419 let rule = MD033NoInlineHtml::default();
1421 let content = "Self-closing: <br/>";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424
1425 assert_eq!(result.len(), 1, "Should find one HTML tag");
1426 assert!(
1428 result[0].fix.is_none(),
1429 "Self-closing tags should not have a fix when fix config is false"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_md033_quick_fix_multiple_tags() {
1435 let rule = MD033NoInlineHtml::default();
1438 let content = "<span>first</span> and <strong>second</strong>";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let result = rule.check(&ctx).unwrap();
1441
1442 assert_eq!(result.len(), 2, "Should find two HTML tags");
1443 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1445 assert!(
1446 result[1].fix.is_none(),
1447 "<strong> should not have a fix when fix config is false"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_md033_skip_angle_brackets_in_link_titles() {
1453 let rule = MD033NoInlineHtml::default();
1455 let content = r#"# Test
1456
1457[example]: <https://example.com> "Title with <Angle Brackets> inside"
1458
1459Regular text with <div>content</div> HTML tag.
1460"#;
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463
1464 assert_eq!(result.len(), 1, "Should find opening div tag");
1467 assert!(
1468 result[0].message.contains("<div>"),
1469 "Should flag <div>, got: {}",
1470 result[0].message
1471 );
1472 }
1473
1474 #[test]
1475 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1476 let rule = MD033NoInlineHtml::default();
1478 let content = r#"[ref]: url 'Title <Help Wanted> here'
1479
1480<span>text</span> here
1481"#;
1482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1483 let result = rule.check(&ctx).unwrap();
1484
1485 assert_eq!(result.len(), 1, "Should find opening span tag");
1488 assert!(
1489 result[0].message.contains("<span>"),
1490 "Should flag <span>, got: {}",
1491 result[0].message
1492 );
1493 }
1494
1495 #[test]
1496 fn test_md033_multiline_tag_end_line_calculation() {
1497 let rule = MD033NoInlineHtml::default();
1499 let content = "<div\n class=\"test\"\n id=\"example\">";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let result = rule.check(&ctx).unwrap();
1502
1503 assert_eq!(result.len(), 1, "Should find one HTML tag");
1504 assert_eq!(result[0].line, 1, "Start line should be 1");
1506 assert_eq!(result[0].end_line, 3, "End line should be 3");
1508 }
1509
1510 #[test]
1511 fn test_md033_single_line_tag_same_start_end_line() {
1512 let rule = MD033NoInlineHtml::default();
1514 let content = "Some text <div class=\"test\"> more text";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let result = rule.check(&ctx).unwrap();
1517
1518 assert_eq!(result.len(), 1, "Should find one HTML tag");
1519 assert_eq!(result[0].line, 1, "Start line should be 1");
1520 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1521 }
1522
1523 #[test]
1524 fn test_md033_multiline_tag_with_many_attributes() {
1525 let rule = MD033NoInlineHtml::default();
1527 let content =
1528 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let result = rule.check(&ctx).unwrap();
1531
1532 assert_eq!(result.len(), 1, "Should find one HTML tag");
1533 assert_eq!(result[0].line, 2, "Start line should be 2");
1535 assert_eq!(result[0].end_line, 5, "End line should be 5");
1537 }
1538
1539 #[test]
1540 fn test_md033_disallowed_mode_basic() {
1541 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1543 let content = "<div>Safe content</div><script>alert('xss')</script>";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let result = rule.check(&ctx).unwrap();
1546
1547 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1549 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1550 }
1551
1552 #[test]
1553 fn test_md033_disallowed_gfm_security_tags() {
1554 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1556 let content = r#"
1557<div>Safe</div>
1558<title>Bad title</title>
1559<textarea>Bad textarea</textarea>
1560<style>.bad{}</style>
1561<iframe src="evil"></iframe>
1562<script>evil()</script>
1563<plaintext>old tag</plaintext>
1564<span>Safe span</span>
1565"#;
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let result = rule.check(&ctx).unwrap();
1568
1569 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1572
1573 let flagged_tags: Vec<&str> = result
1574 .iter()
1575 .filter_map(|w| w.message.split('<').nth(1))
1576 .filter_map(|s| s.split('>').next())
1577 .filter_map(|s| s.split_whitespace().next())
1578 .collect();
1579
1580 assert!(flagged_tags.contains(&"title"), "Should flag title");
1581 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1582 assert!(flagged_tags.contains(&"style"), "Should flag style");
1583 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1584 assert!(flagged_tags.contains(&"script"), "Should flag script");
1585 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1586 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1587 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1588 }
1589
1590 #[test]
1591 fn test_md033_disallowed_case_insensitive() {
1592 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1594 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597
1598 assert_eq!(result.len(), 2, "Should flag both case variants");
1600 }
1601
1602 #[test]
1603 fn test_md033_disallowed_with_attributes() {
1604 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1606 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 let result = rule.check(&ctx).unwrap();
1609
1610 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1611 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1612 }
1613
1614 #[test]
1615 fn test_md033_disallowed_all_gfm_tags() {
1616 use md033_config::GFM_DISALLOWED_TAGS;
1618 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1619
1620 for tag in GFM_DISALLOWED_TAGS {
1621 let content = format!("<{tag}>content</{tag}>");
1622 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1623 let result = rule.check(&ctx).unwrap();
1624
1625 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1626 }
1627 }
1628
1629 #[test]
1630 fn test_md033_disallowed_mixed_with_custom() {
1631 let rule = MD033NoInlineHtml::with_disallowed(vec![
1633 "gfm".to_string(),
1634 "marquee".to_string(), ]);
1636 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let result = rule.check(&ctx).unwrap();
1639
1640 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1642 }
1643
1644 #[test]
1645 fn test_md033_disallowed_empty_means_default_mode() {
1646 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1648 let content = "<div>content</div>";
1649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1650 let result = rule.check(&ctx).unwrap();
1651
1652 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1654 }
1655
1656 #[test]
1657 fn test_md033_jsx_fragments_in_mdx() {
1658 let rule = MD033NoInlineHtml::default();
1660 let content = r#"# MDX Document
1661
1662<>
1663 <Heading />
1664 <Content />
1665</>
1666
1667<div>Regular HTML should still be flagged</div>
1668"#;
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1670 let result = rule.check(&ctx).unwrap();
1671
1672 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1674 assert!(
1675 result[0].message.contains("<div>"),
1676 "Should flag <div>, not JSX fragments"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_md033_jsx_components_in_mdx() {
1682 let rule = MD033NoInlineHtml::default();
1684 let content = r#"<CustomComponent prop="value">
1685 Content
1686</CustomComponent>
1687
1688<MyButton onClick={handler}>Click</MyButton>
1689"#;
1690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1691 let result = rule.check(&ctx).unwrap();
1692
1693 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1695 }
1696
1697 #[test]
1698 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1699 let rule = MD033NoInlineHtml::default();
1701 let content = "<Script>alert(1)</Script>";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704
1705 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1707 }
1708
1709 #[test]
1710 fn test_md033_jsx_attributes_in_mdx() {
1711 let rule = MD033NoInlineHtml::default();
1713 let content = r#"# MDX with JSX Attributes
1714
1715<div className="card big">Content</div>
1716
1717<button onClick={handleClick}>Click me</button>
1718
1719<label htmlFor="input-id">Label</label>
1720
1721<input onChange={handleChange} />
1722
1723<div class="html-class">Regular HTML should be flagged</div>
1724"#;
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1726 let result = rule.check(&ctx).unwrap();
1727
1728 assert_eq!(
1730 result.len(),
1731 1,
1732 "Should only flag HTML element without JSX attributes, got: {result:?}"
1733 );
1734 assert!(
1735 result[0].message.contains("<div class="),
1736 "Should flag the div with HTML class attribute"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1742 let rule = MD033NoInlineHtml::default();
1744 let content = r#"<div className="card">Content</div>"#;
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747
1748 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1750 }
1751
1752 #[test]
1755 fn test_md033_fix_disabled_by_default() {
1756 let rule = MD033NoInlineHtml::default();
1758 assert!(!rule.config.fix, "Fix should be disabled by default");
1759 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1760 }
1761
1762 #[test]
1763 fn test_md033_fix_enabled_em_to_italic() {
1764 let rule = MD033NoInlineHtml::with_fix(true);
1766 let content = "This has <em>emphasized text</em> here.";
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768 let fixed = rule.fix(&ctx).unwrap();
1769 assert_eq!(fixed, "This has *emphasized text* here.");
1770 }
1771
1772 #[test]
1773 fn test_md033_fix_enabled_i_to_italic() {
1774 let rule = MD033NoInlineHtml::with_fix(true);
1776 let content = "This has <i>italic text</i> here.";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let fixed = rule.fix(&ctx).unwrap();
1779 assert_eq!(fixed, "This has *italic text* here.");
1780 }
1781
1782 #[test]
1783 fn test_md033_fix_enabled_strong_to_bold() {
1784 let rule = MD033NoInlineHtml::with_fix(true);
1786 let content = "This has <strong>bold text</strong> here.";
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1788 let fixed = rule.fix(&ctx).unwrap();
1789 assert_eq!(fixed, "This has **bold text** here.");
1790 }
1791
1792 #[test]
1793 fn test_md033_fix_enabled_b_to_bold() {
1794 let rule = MD033NoInlineHtml::with_fix(true);
1796 let content = "This has <b>bold text</b> here.";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798 let fixed = rule.fix(&ctx).unwrap();
1799 assert_eq!(fixed, "This has **bold text** here.");
1800 }
1801
1802 #[test]
1803 fn test_md033_fix_enabled_code_to_backticks() {
1804 let rule = MD033NoInlineHtml::with_fix(true);
1806 let content = "This has <code>inline code</code> here.";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808 let fixed = rule.fix(&ctx).unwrap();
1809 assert_eq!(fixed, "This has `inline code` here.");
1810 }
1811
1812 #[test]
1813 fn test_md033_fix_enabled_code_with_backticks() {
1814 let rule = MD033NoInlineHtml::with_fix(true);
1816 let content = "This has <code>text with `backticks`</code> 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 `` text with `backticks` `` here.");
1820 }
1821
1822 #[test]
1823 fn test_md033_fix_enabled_br_trailing_spaces() {
1824 let rule = MD033NoInlineHtml::with_fix(true);
1826 let content = "First line<br>Second line";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let fixed = rule.fix(&ctx).unwrap();
1829 assert_eq!(fixed, "First line \nSecond line");
1830 }
1831
1832 #[test]
1833 fn test_md033_fix_enabled_br_self_closing() {
1834 let rule = MD033NoInlineHtml::with_fix(true);
1836 let content = "First<br/>second<br />third";
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1838 let fixed = rule.fix(&ctx).unwrap();
1839 assert_eq!(fixed, "First \nsecond \nthird");
1840 }
1841
1842 #[test]
1843 fn test_md033_fix_enabled_br_backslash_style() {
1844 let config = MD033Config {
1846 allowed: Vec::new(),
1847 disallowed: Vec::new(),
1848 fix: true,
1849 br_style: md033_config::BrStyle::Backslash,
1850 ..MD033Config::default()
1851 };
1852 let rule = MD033NoInlineHtml::from_config_struct(config);
1853 let content = "First line<br>Second line";
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 let fixed = rule.fix(&ctx).unwrap();
1856 assert_eq!(fixed, "First line\\\nSecond line");
1857 }
1858
1859 #[test]
1860 fn test_md033_fix_enabled_hr() {
1861 let rule = MD033NoInlineHtml::with_fix(true);
1863 let content = "Above<hr>Below";
1864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865 let fixed = rule.fix(&ctx).unwrap();
1866 assert_eq!(fixed, "Above\n---\nBelow");
1867 }
1868
1869 #[test]
1870 fn test_md033_fix_enabled_hr_self_closing() {
1871 let rule = MD033NoInlineHtml::with_fix(true);
1873 let content = "Above<hr/>Below";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 let fixed = rule.fix(&ctx).unwrap();
1876 assert_eq!(fixed, "Above\n---\nBelow");
1877 }
1878
1879 #[test]
1880 fn test_md033_fix_skips_nested_tags() {
1881 let rule = MD033NoInlineHtml::with_fix(true);
1884 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1886 let fixed = rule.fix(&ctx).unwrap();
1887 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1890 }
1891
1892 #[test]
1893 fn test_md033_fix_skips_tags_with_attributes() {
1894 let rule = MD033NoInlineHtml::with_fix(true);
1897 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1899 let fixed = rule.fix(&ctx).unwrap();
1900 assert_eq!(fixed, content);
1902 }
1903
1904 #[test]
1905 fn test_md033_fix_disabled_no_changes() {
1906 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let fixed = rule.fix(&ctx).unwrap();
1911 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1912 }
1913
1914 #[test]
1915 fn test_md033_fix_capability_enabled() {
1916 let rule = MD033NoInlineHtml::with_fix(true);
1917 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1918 }
1919
1920 #[test]
1921 fn test_md033_fix_multiple_tags() {
1922 let rule = MD033NoInlineHtml::with_fix(true);
1924 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let fixed = rule.fix(&ctx).unwrap();
1927 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1928 }
1929
1930 #[test]
1931 fn test_md033_fix_uppercase_tags() {
1932 let rule = MD033NoInlineHtml::with_fix(true);
1934 let content = "This has <EM>emphasized</EM> text.";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let fixed = rule.fix(&ctx).unwrap();
1937 assert_eq!(fixed, "This has *emphasized* text.");
1938 }
1939
1940 #[test]
1941 fn test_md033_fix_unsafe_tags_not_modified() {
1942 let rule = MD033NoInlineHtml::with_fix(true);
1945 let content = "This has <div>a div</div> content.";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let fixed = rule.fix(&ctx).unwrap();
1948 assert_eq!(fixed, "This has <div>a div</div> content.");
1950 }
1951
1952 #[test]
1953 fn test_md033_fix_img_tag_converted() {
1954 let rule = MD033NoInlineHtml::with_fix(true);
1956 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let fixed = rule.fix(&ctx).unwrap();
1959 assert_eq!(fixed, "Image: ");
1961 }
1962
1963 #[test]
1964 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1965 let rule = MD033NoInlineHtml::with_fix(true);
1967 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969 let fixed = rule.fix(&ctx).unwrap();
1970 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1972 }
1973
1974 #[test]
1975 fn test_md033_fix_relaxed_a_with_target_is_converted() {
1976 let rule = relaxed_fix_rule();
1977 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1979 let fixed = rule.fix(&ctx).unwrap();
1980 assert_eq!(fixed, "Link: [Example](https://example.com)");
1981 }
1982
1983 #[test]
1984 fn test_md033_fix_relaxed_img_with_width_is_converted() {
1985 let rule = relaxed_fix_rule();
1986 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988 let fixed = rule.fix(&ctx).unwrap();
1989 assert_eq!(fixed, "Image: ");
1990 }
1991
1992 #[test]
1993 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
1994 let rule = relaxed_fix_rule();
1995 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
1996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1997 let fixed = rule.fix(&ctx).unwrap();
1998 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
1999 }
2000
2001 #[test]
2002 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2003 let rule = relaxed_fix_rule();
2004 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006 let fixed = rule.fix(&ctx).unwrap();
2007 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2008 }
2009
2010 #[test]
2011 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2012 let rule = relaxed_fix_rule();
2013 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2014 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let fixed_once = rule.fix(&ctx1).unwrap();
2016 assert!(
2017 fixed_once.contains("<p"),
2018 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2019 );
2020 assert!(
2021 fixed_once.contains(""),
2022 "Inner image should be converted on first pass: {fixed_once}"
2023 );
2024
2025 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2026 let fixed_twice = rule.fix(&ctx2).unwrap();
2027 assert!(
2028 !fixed_twice.contains("<p"),
2029 "Second pass should strip configured wrapper: {fixed_twice}"
2030 );
2031 assert!(fixed_twice.contains(""));
2032 }
2033
2034 #[test]
2035 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2036 let rule = relaxed_fix_rule();
2037 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let fixed = rule.fix(&ctx).unwrap();
2040 assert_eq!(fixed, "[Click](https://example.com)");
2041 }
2042
2043 #[test]
2044 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2045 let rule = relaxed_fix_rule();
2046 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 let fixed = rule.fix(&ctx).unwrap();
2049 assert_eq!(fixed, "");
2050 }
2051
2052 #[test]
2053 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2054 let rule = relaxed_fix_rule();
2055 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057 let fixed = rule.fix(&ctx).unwrap();
2058 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2059 }
2060
2061 #[test]
2062 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2063 let config = MD033Config {
2065 fix: true,
2066 fix_mode: MD033FixMode::Relaxed,
2067 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2068 ..MD033Config::default()
2069 };
2070 let rule = MD033NoInlineHtml::from_config_struct(config);
2071 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let fixed = rule.fix(&ctx).unwrap();
2074 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2075 }
2076
2077 #[test]
2078 fn test_md033_fix_relaxed_custom_drop_attributes() {
2079 let config = MD033Config {
2080 fix: true,
2081 fix_mode: MD033FixMode::Relaxed,
2082 drop_attributes: vec!["loading".to_string()],
2083 ..MD033Config::default()
2084 };
2085 let rule = MD033NoInlineHtml::from_config_struct(config);
2086 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089 let fixed = rule.fix(&ctx).unwrap();
2090 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2091
2092 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2093 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2094 let fixed2 = rule.fix(&ctx2).unwrap();
2095 assert_eq!(
2096 fixed2, content2,
2097 "Attributes not in custom list should block conversion"
2098 );
2099 }
2100
2101 #[test]
2102 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2103 let config = MD033Config {
2104 fix: true,
2105 fix_mode: MD033FixMode::Relaxed,
2106 strip_wrapper_elements: vec!["div".to_string()],
2107 ..MD033Config::default()
2108 };
2109 let rule = MD033NoInlineHtml::from_config_struct(config);
2110 let content = "<div>Some text content</div>";
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let fixed = rule.fix(&ctx).unwrap();
2113 assert_eq!(fixed, "Some text content");
2114 }
2115
2116 #[test]
2117 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2118 let rule = relaxed_fix_rule();
2119 let content = "<p align=\"center\">Just some text</p>";
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let fixed = rule.fix(&ctx).unwrap();
2122 assert_eq!(fixed, "Just some text");
2123 }
2124
2125 #[test]
2126 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2127 let config = MD033Config {
2128 fix: true,
2129 fix_mode: MD033FixMode::Relaxed,
2130 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2131 ..MD033Config::default()
2132 };
2133 let rule = MD033NoInlineHtml::from_config_struct(config);
2134 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let fixed = rule.fix(&ctx).unwrap();
2137 assert_eq!(fixed, "[Link](https://example.com)");
2138 }
2139
2140 #[test]
2141 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2142 let rule = relaxed_fix_rule();
2143 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let fixed = rule.fix(&ctx).unwrap();
2147 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2148 }
2149
2150 #[test]
2151 fn test_md033_fix_relaxed_badge_pattern() {
2152 let rule = relaxed_fix_rule();
2154 let content = "<a href=\"https://crates.io/crates/rumdl\" target=\"_blank\"><img src=\"https://img.shields.io/crates/v/rumdl.svg\" alt=\"Crate\" width=\"120\" /></a>";
2155 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2156 let fixed_once = rule.fix(&ctx1).unwrap();
2157 assert!(
2159 fixed_once.contains(""),
2160 "Inner img should be converted: {fixed_once}"
2161 );
2162
2163 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2165 let fixed_twice = rule.fix(&ctx2).unwrap();
2166 assert!(
2167 fixed_twice
2168 .contains("[](https://crates.io/crates/rumdl)"),
2169 "Badge should produce nested markdown image link: {fixed_twice}"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2175 let rule = MD033NoInlineHtml::with_fix(true);
2177 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2179 let fixed = rule.fix(&ctx).unwrap();
2180 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2181 }
2182
2183 #[test]
2184 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2185 let rule = relaxed_fix_rule();
2187 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2189 let fixed = rule.fix(&ctx).unwrap();
2190 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2191 }
2192
2193 #[test]
2194 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2195 let rule = relaxed_fix_rule();
2197 let content = "<div><p>text</p></div>";
2198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2199 let fixed = rule.fix(&ctx).unwrap();
2200 assert!(
2201 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2202 "Nested <p> inside <div> should not be stripped: {fixed}"
2203 );
2204 }
2205
2206 #[test]
2207 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2208 let rule = relaxed_fix_rule();
2212 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214 let fixed = rule.fix(&ctx).unwrap();
2215 assert!(
2216 fixed.contains("<img"),
2217 "img inside nested wrapper must not be converted: {fixed}"
2218 );
2219 }
2220
2221 #[test]
2222 fn test_md033_fix_mixed_safe_tags() {
2223 let rule = MD033NoInlineHtml::with_fix(true);
2225 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2227 let fixed = rule.fix(&ctx).unwrap();
2228 assert_eq!(fixed, "*italic* and  and **bold**");
2230 }
2231
2232 #[test]
2233 fn test_md033_fix_multiple_tags_same_line() {
2234 let rule = MD033NoInlineHtml::with_fix(true);
2236 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2238 let fixed = rule.fix(&ctx).unwrap();
2239 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2240 }
2241
2242 #[test]
2243 fn test_md033_fix_multiple_em_tags_same_line() {
2244 let rule = MD033NoInlineHtml::with_fix(true);
2246 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2248 let fixed = rule.fix(&ctx).unwrap();
2249 assert_eq!(fixed, "*first* and **second** and `third`");
2250 }
2251
2252 #[test]
2253 fn test_md033_fix_skips_tags_inside_pre() {
2254 let rule = MD033NoInlineHtml::with_fix(true);
2256 let content = "<pre><code><em>VALUE</em></code></pre>";
2257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258 let fixed = rule.fix(&ctx).unwrap();
2259 assert!(
2262 !fixed.contains("*VALUE*"),
2263 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_md033_fix_skips_tags_inside_div() {
2269 let rule = MD033NoInlineHtml::with_fix(true);
2271 let content = "<div>\n<em>emphasized</em>\n</div>";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let fixed = rule.fix(&ctx).unwrap();
2274 assert!(
2276 !fixed.contains("*emphasized*"),
2277 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_md033_fix_outside_html_block() {
2283 let rule = MD033NoInlineHtml::with_fix(true);
2285 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287 let fixed = rule.fix(&ctx).unwrap();
2288 assert!(
2290 fixed.contains("*emphasized*"),
2291 "Tags outside HTML blocks should be converted. Got: {fixed}"
2292 );
2293 }
2294
2295 #[test]
2296 fn test_md033_fix_with_id_attribute() {
2297 let rule = MD033NoInlineHtml::with_fix(true);
2299 let content = "See <em id=\"important\">this note</em> for details.";
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301 let fixed = rule.fix(&ctx).unwrap();
2302 assert_eq!(fixed, content);
2304 }
2305
2306 #[test]
2307 fn test_md033_fix_with_style_attribute() {
2308 let rule = MD033NoInlineHtml::with_fix(true);
2310 let content = "This is <strong style=\"color: red\">important</strong> text.";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2312 let fixed = rule.fix(&ctx).unwrap();
2313 assert_eq!(fixed, content);
2315 }
2316
2317 #[test]
2318 fn test_md033_fix_mixed_with_and_without_attributes() {
2319 let rule = MD033NoInlineHtml::with_fix(true);
2321 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let fixed = rule.fix(&ctx).unwrap();
2324 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2326 }
2327
2328 #[test]
2329 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2330 let rule = MD033NoInlineHtml::with_fix(true);
2332 let content = "<em class=\"test\">emphasized</em>";
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334 let result = rule.check(&ctx).unwrap();
2335
2336 assert_eq!(result.len(), 1, "Should find one HTML tag");
2337 assert!(
2339 result[0].fix.is_none(),
2340 "Should NOT have a fix for tags with attributes"
2341 );
2342 }
2343
2344 #[test]
2345 fn test_md033_fix_skips_html_entities() {
2346 let rule = MD033NoInlineHtml::with_fix(true);
2349 let content = "<code>|</code>";
2350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2351 let fixed = rule.fix(&ctx).unwrap();
2352 assert_eq!(fixed, content);
2354 }
2355
2356 #[test]
2357 fn test_md033_fix_skips_multiple_html_entities() {
2358 let rule = MD033NoInlineHtml::with_fix(true);
2360 let content = "<code><T></code>";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362 let fixed = rule.fix(&ctx).unwrap();
2363 assert_eq!(fixed, content);
2365 }
2366
2367 #[test]
2368 fn test_md033_fix_allows_ampersand_without_entity() {
2369 let rule = MD033NoInlineHtml::with_fix(true);
2371 let content = "<code>a & b</code>";
2372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2373 let fixed = rule.fix(&ctx).unwrap();
2374 assert_eq!(fixed, "`a & b`");
2376 }
2377
2378 #[test]
2379 fn test_md033_fix_em_with_entities_skipped() {
2380 let rule = MD033NoInlineHtml::with_fix(true);
2382 let content = "<em> text</em>";
2383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2384 let fixed = rule.fix(&ctx).unwrap();
2385 assert_eq!(fixed, content);
2387 }
2388
2389 #[test]
2390 fn test_md033_fix_skips_nested_em_in_code() {
2391 let rule = MD033NoInlineHtml::with_fix(true);
2394 let content = "<code><em>n</em></code>";
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let fixed = rule.fix(&ctx).unwrap();
2397 assert!(
2400 !fixed.contains("*n*"),
2401 "Nested <em> should not be converted to markdown. Got: {fixed}"
2402 );
2403 }
2404
2405 #[test]
2406 fn test_md033_fix_skips_nested_in_table() {
2407 let rule = MD033NoInlineHtml::with_fix(true);
2409 let content = "| <code>><em>n</em></code> | description |";
2410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2411 let fixed = rule.fix(&ctx).unwrap();
2412 assert!(
2414 !fixed.contains("*n*"),
2415 "Nested tags in table should not be converted. Got: {fixed}"
2416 );
2417 }
2418
2419 #[test]
2420 fn test_md033_fix_standalone_em_still_converted() {
2421 let rule = MD033NoInlineHtml::with_fix(true);
2423 let content = "This is <em>emphasized</em> text.";
2424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2425 let fixed = rule.fix(&ctx).unwrap();
2426 assert_eq!(fixed, "This is *emphasized* text.");
2427 }
2428
2429 #[test]
2441 fn test_md033_templater_basic_interpolation_not_flagged() {
2442 let rule = MD033NoInlineHtml::default();
2445 let content = "Today is <% tp.date.now() %> which is nice.";
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2447 let result = rule.check(&ctx).unwrap();
2448 assert!(
2449 result.is_empty(),
2450 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_md033_templater_file_functions_not_flagged() {
2456 let rule = MD033NoInlineHtml::default();
2458 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2460 let result = rule.check(&ctx).unwrap();
2461 assert!(
2462 result.is_empty(),
2463 "Templater file functions should not be flagged. Got: {result:?}"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_md033_templater_with_arguments_not_flagged() {
2469 let rule = MD033NoInlineHtml::default();
2471 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2473 let result = rule.check(&ctx).unwrap();
2474 assert!(
2475 result.is_empty(),
2476 "Templater with arguments should not be flagged. Got: {result:?}"
2477 );
2478 }
2479
2480 #[test]
2481 fn test_md033_templater_javascript_execution_not_flagged() {
2482 let rule = MD033NoInlineHtml::default();
2484 let content = "<%* const today = tp.date.now(); tR += today; %>";
2485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2486 let result = rule.check(&ctx).unwrap();
2487 assert!(
2488 result.is_empty(),
2489 "Templater JS execution block should not be flagged. Got: {result:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_md033_templater_dynamic_execution_not_flagged() {
2495 let rule = MD033NoInlineHtml::default();
2497 let content = "Dynamic: <%+ tp.date.now() %>";
2498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2499 let result = rule.check(&ctx).unwrap();
2500 assert!(
2501 result.is_empty(),
2502 "Templater dynamic execution should not be flagged. Got: {result:?}"
2503 );
2504 }
2505
2506 #[test]
2507 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2508 let rule = MD033NoInlineHtml::default();
2510 let content = "<%_ tp.date.now() _%>";
2511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2512 let result = rule.check(&ctx).unwrap();
2513 assert!(
2514 result.is_empty(),
2515 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2516 );
2517 }
2518
2519 #[test]
2520 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2521 let rule = MD033NoInlineHtml::default();
2523 let content = "<%- tp.date.now() -%>";
2524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2525 let result = rule.check(&ctx).unwrap();
2526 assert!(
2527 result.is_empty(),
2528 "Templater trim-newline should not be flagged. Got: {result:?}"
2529 );
2530 }
2531
2532 #[test]
2533 fn test_md033_templater_combined_modifiers_not_flagged() {
2534 let rule = MD033NoInlineHtml::default();
2536 let contents = [
2537 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2542 for content in contents {
2543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2544 let result = rule.check(&ctx).unwrap();
2545 assert!(
2546 result.is_empty(),
2547 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2548 );
2549 }
2550 }
2551
2552 #[test]
2553 fn test_md033_templater_multiline_block_not_flagged() {
2554 let rule = MD033NoInlineHtml::default();
2556 let content = r#"<%*
2557const x = 1;
2558const y = 2;
2559tR += x + y;
2560%>"#;
2561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2562 let result = rule.check(&ctx).unwrap();
2563 assert!(
2564 result.is_empty(),
2565 "Templater multi-line block should not be flagged. Got: {result:?}"
2566 );
2567 }
2568
2569 #[test]
2570 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2571 let rule = MD033NoInlineHtml::default();
2574 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2576 let result = rule.check(&ctx).unwrap();
2577 assert!(
2578 result.is_empty(),
2579 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2585 let rule = MD033NoInlineHtml::default();
2587 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2589 let result = rule.check(&ctx).unwrap();
2590 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2591 assert!(
2592 result[0].message.contains("<div>"),
2593 "Should flag <div>, got: {}",
2594 result[0].message
2595 );
2596 }
2597
2598 #[test]
2599 fn test_md033_templater_in_heading_not_flagged() {
2600 let rule = MD033NoInlineHtml::default();
2602 let content = "# <% tp.file.title %>";
2603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2604 let result = rule.check(&ctx).unwrap();
2605 assert!(
2606 result.is_empty(),
2607 "Templater in heading should not be flagged. Got: {result:?}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2613 let rule = MD033NoInlineHtml::default();
2615 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2617 let result = rule.check(&ctx).unwrap();
2618 assert!(
2619 result.is_empty(),
2620 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2621 );
2622 }
2623
2624 #[test]
2625 fn test_md033_templater_in_code_block_not_flagged() {
2626 let rule = MD033NoInlineHtml::default();
2628 let content = "```\n<% tp.date.now() %>\n```";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2630 let result = rule.check(&ctx).unwrap();
2631 assert!(
2632 result.is_empty(),
2633 "Templater in code block should not be flagged. Got: {result:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn test_md033_templater_in_inline_code_not_flagged() {
2639 let rule = MD033NoInlineHtml::default();
2641 let content = "Use `<% tp.date.now() %>` for current date.";
2642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2643 let result = rule.check(&ctx).unwrap();
2644 assert!(
2645 result.is_empty(),
2646 "Templater in inline code should not be flagged. Got: {result:?}"
2647 );
2648 }
2649
2650 #[test]
2651 fn test_md033_templater_also_works_in_standard_flavor() {
2652 let rule = MD033NoInlineHtml::default();
2655 let content = "<% tp.date.now() %> works everywhere.";
2656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2657 let result = rule.check(&ctx).unwrap();
2658 assert!(
2659 result.is_empty(),
2660 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2661 );
2662 }
2663
2664 #[test]
2665 fn test_md033_templater_empty_tag_not_flagged() {
2666 let rule = MD033NoInlineHtml::default();
2668 let content = "<%>";
2669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2670 let result = rule.check(&ctx).unwrap();
2671 assert!(
2672 result.is_empty(),
2673 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2674 );
2675 }
2676
2677 #[test]
2678 fn test_md033_templater_unclosed_not_flagged() {
2679 let rule = MD033NoInlineHtml::default();
2681 let content = "<% tp.date.now() without closing tag";
2682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2683 let result = rule.check(&ctx).unwrap();
2684 assert!(
2685 result.is_empty(),
2686 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_md033_templater_with_newlines_inside_not_flagged() {
2692 let rule = MD033NoInlineHtml::default();
2694 let content = r#"<% tp.date.now("YYYY") +
2695"-" +
2696tp.date.now("MM") %>"#;
2697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2698 let result = rule.check(&ctx).unwrap();
2699 assert!(
2700 result.is_empty(),
2701 "Templater with internal newlines should not be flagged. Got: {result:?}"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_md033_erb_style_tags_not_flagged() {
2707 let rule = MD033NoInlineHtml::default();
2710 let content = "<%= variable %> and <% code %> and <%# comment %>";
2711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2712 let result = rule.check(&ctx).unwrap();
2713 assert!(
2714 result.is_empty(),
2715 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn test_md033_templater_complex_expression_not_flagged() {
2721 let rule = MD033NoInlineHtml::default();
2723 let content = r#"<%*
2724const file = tp.file.title;
2725const date = tp.date.now("YYYY-MM-DD");
2726const folder = tp.file.folder();
2727tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2728%>"#;
2729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2730 let result = rule.check(&ctx).unwrap();
2731 assert!(
2732 result.is_empty(),
2733 "Complex Templater expression should not be flagged. Got: {result:?}"
2734 );
2735 }
2736
2737 #[test]
2738 fn test_md033_percent_sign_variations_not_flagged() {
2739 let rule = MD033NoInlineHtml::default();
2741 let patterns = [
2742 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2749 for pattern in patterns {
2750 let content = format!("{pattern} content %>");
2751 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2752 let result = rule.check(&ctx).unwrap();
2753 assert!(
2754 result.is_empty(),
2755 "Pattern {pattern} should not be flagged. Got: {result:?}"
2756 );
2757 }
2758 }
2759
2760 #[test]
2766 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2767 let rule = MD033NoInlineHtml::with_fix(true);
2770 let content = r#"<a href="https://example.com"></a>"#;
2771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2772 let fixed = rule.fix(&ctx).unwrap();
2773
2774 assert_eq!(fixed, "[](https://example.com)",);
2775 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2776 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2777 }
2778
2779 #[test]
2780 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2781 let rule = MD033NoInlineHtml::with_fix(true);
2783 let content =
2784 r#"<a href="https://github.com/repo"></a>"#;
2785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786 let fixed = rule.fix(&ctx).unwrap();
2787
2788 assert_eq!(
2789 fixed,
2790 "[](https://github.com/repo)"
2791 );
2792 }
2793
2794 #[test]
2795 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2796 let rule = MD033NoInlineHtml::with_fix(true);
2797 let content = r#"<img src="photo.jpg" />"#;
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2799 let fixed = rule.fix(&ctx).unwrap();
2800
2801 assert_eq!(fixed, "");
2802 }
2803
2804 #[test]
2805 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2806 let rule = MD033NoInlineHtml::with_fix(true);
2808 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810 let fixed = rule.fix(&ctx).unwrap();
2811
2812 assert!(
2813 fixed.contains(r"\[brackets\]"),
2814 "Plain text brackets should be escaped: {fixed}"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2820 let rule = MD033NoInlineHtml::with_fix(true);
2823 let content = r#"<a href="/link"> see [docs]</a>"#;
2824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825 let fixed = rule.fix(&ctx).unwrap();
2826
2827 assert!(
2829 fixed.contains(r"\[docs\]"),
2830 "Brackets in mixed image+text content should be escaped: {fixed}"
2831 );
2832 }
2833
2834 #[test]
2835 fn test_md033_fix_img_in_a_end_to_end() {
2836 use crate::config::Config;
2839 use crate::fix_coordinator::FixCoordinator;
2840
2841 let rule = MD033NoInlineHtml::with_fix(true);
2842 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2843
2844 let mut content =
2845 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2846 .to_string();
2847 let config = Config::default();
2848 let coordinator = FixCoordinator::new();
2849
2850 let result = coordinator
2851 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2852 .unwrap();
2853
2854 assert_eq!(
2855 content, "[](https://github.com/org/repo)",
2856 "End-to-end: <a><img></a> should become valid linked image"
2857 );
2858 assert!(result.converged);
2859 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2860 }
2861
2862 #[test]
2863 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2864 use crate::config::Config;
2865 use crate::fix_coordinator::FixCoordinator;
2866
2867 let rule = MD033NoInlineHtml::with_fix(true);
2868 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2869
2870 let mut content =
2871 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2872 .to_string();
2873 let config = Config::default();
2874 let coordinator = FixCoordinator::new();
2875
2876 let result = coordinator
2877 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2878 .unwrap();
2879
2880 assert_eq!(
2881 content,
2882 "[](https://github.com/org/repo)",
2883 );
2884 assert!(result.converged);
2885 }
2886
2887 #[test]
2897 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2898 let config = MD033Config {
2899 allowed: vec!["br".to_string()],
2900 table_allowed_elements: None,
2901 ..MD033Config::default()
2902 };
2903 let rule = MD033NoInlineHtml::from_config_struct(config);
2904 let content = "| col |\n|-----|\n| a<br>b |\n";
2905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2906 let result = rule.check(&ctx).unwrap();
2907 assert!(
2908 result.is_empty(),
2909 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2910 );
2911 }
2912
2913 #[test]
2914 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2915 let config = MD033Config {
2916 allowed: vec!["br".to_string()],
2917 table_allowed_elements: Some(Vec::new()),
2918 ..MD033Config::default()
2919 };
2920 let rule = MD033NoInlineHtml::from_config_struct(config);
2921 let content = "| col |\n|-----|\n| a<br>b |\n";
2922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2923 let result = rule.check(&ctx).unwrap();
2924 assert_eq!(
2925 result.len(),
2926 1,
2927 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2928 );
2929 assert_eq!(result[0].line, 3);
2930 }
2931
2932 #[test]
2933 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2934 let config = MD033Config {
2935 allowed: vec!["br".to_string()],
2936 table_allowed_elements: Some(vec!["img".to_string()]),
2937 ..MD033Config::default()
2938 };
2939 let rule = MD033NoInlineHtml::from_config_struct(config);
2940 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2944 let result = rule.check(&ctx).unwrap();
2945 assert_eq!(
2946 result.len(),
2947 1,
2948 "table_allowed should override `allowed` inside tables, got {result:?}"
2949 );
2950 assert!(
2951 result[0].message.contains("br"),
2952 "expected the flagged tag to be <br>, got {:?}",
2953 result[0].message
2954 );
2955 }
2956
2957 #[test]
2958 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2959 let config = MD033Config {
2960 allowed: vec!["br".to_string()],
2961 table_allowed_elements: Some(Vec::new()),
2962 ..MD033Config::default()
2963 };
2964 let rule = MD033NoInlineHtml::from_config_struct(config);
2965 let content = "Paragraph with <br> tag.\n";
2967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2968 let result = rule.check(&ctx).unwrap();
2969 assert!(
2970 result.is_empty(),
2971 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2972 );
2973 }
2974
2975 #[test]
2976 fn test_md033_table_allowed_kebab_case_parses() {
2977 let toml_str = r#"
2978 allowed-elements = ["br"]
2979 table-allowed-elements = ["img"]
2980 "#;
2981 let config: MD033Config = toml::from_str(toml_str).unwrap();
2982 assert_eq!(config.allowed, vec!["br"]);
2983 assert_eq!(
2984 config.table_allowed_elements.as_deref(),
2985 Some(["img".to_string()].as_slice())
2986 );
2987 }
2988
2989 #[test]
2990 fn test_md033_table_allowed_snake_case_alias_parses() {
2991 let toml_str = r#"
2992 allowed_elements = ["br"]
2993 table_allowed_elements = ["img"]
2994 "#;
2995 let config: MD033Config = toml::from_str(toml_str).unwrap();
2996 assert_eq!(config.allowed, vec!["br"]);
2997 assert_eq!(
2998 config.table_allowed_elements.as_deref(),
2999 Some(["img".to_string()].as_slice())
3000 );
3001 }
3002
3003 #[test]
3004 fn test_md033_table_allowed_default_is_none() {
3005 let cfg = MD033Config::default();
3006 assert!(
3007 cfg.table_allowed_elements.is_none(),
3008 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3009 );
3010 }
3011
3012 #[test]
3013 fn test_md033_table_allowed_case_insensitive() {
3014 let config = MD033Config {
3015 allowed: Vec::new(),
3016 table_allowed_elements: Some(vec!["BR".to_string()]),
3017 ..MD033Config::default()
3018 };
3019 let rule = MD033NoInlineHtml::from_config_struct(config);
3020 let content = "| col |\n|-----|\n| a<br>b |\n";
3021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3022 let result = rule.check(&ctx).unwrap();
3023 assert!(
3024 result.is_empty(),
3025 "table_allowed should be case-insensitive, got {result:?}"
3026 );
3027 }
3028}