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 crate::impl_rule_config_methods!(MD033Config, nullable);
1188
1189 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1190 let mut aliases = std::collections::HashMap::new();
1191 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1193 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1194 Some(aliases)
1195 }
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use super::*;
1201 use crate::lint_context::LintContext;
1202 use crate::rule::Rule;
1203
1204 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1205 let config = MD033Config {
1206 fix: true,
1207 fix_mode: MD033FixMode::Relaxed,
1208 ..MD033Config::default()
1209 };
1210 MD033NoInlineHtml::from_config_struct(config)
1211 }
1212
1213 #[test]
1214 fn test_md033_basic_html() {
1215 let rule = MD033NoInlineHtml::default();
1216 let content = "<div>Some content</div>";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1222 }
1223
1224 #[test]
1225 fn test_md033_case_insensitive() {
1226 let rule = MD033NoInlineHtml::default();
1227 let content = "<DiV>Some <B>content</B></dIv>";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1233 assert_eq!(result[1].message, "Inline HTML found: <B>");
1234 }
1235
1236 #[test]
1237 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1238 let rule = relaxed_fix_rule();
1241 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243 let _ = rule.check(&ctx).unwrap();
1245 let _ = rule.fix(&ctx).unwrap();
1246 }
1247
1248 #[test]
1249 fn test_md033_allowed_tags() {
1250 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1251 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254 assert_eq!(result.len(), 1);
1256 assert_eq!(result[0].message, "Inline HTML found: <p>");
1257
1258 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1260 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1261 let result2 = rule.check(&ctx2).unwrap();
1262 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1264 }
1265
1266 #[test]
1267 fn test_md033_html_comments() {
1268 let rule = MD033NoInlineHtml::default();
1269 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1275 }
1276
1277 #[test]
1278 fn test_md033_tags_in_links() {
1279 let rule = MD033NoInlineHtml::default();
1280 let content = "[Link](http://example.com/<div>)";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 let result = rule.check(&ctx).unwrap();
1283 assert_eq!(result.len(), 1);
1285 assert_eq!(result[0].message, "Inline HTML found: <div>");
1286
1287 let content2 = "[Link <a>text</a>](url)";
1288 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1289 let result2 = rule.check(&ctx2).unwrap();
1290 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1293 }
1294
1295 #[test]
1296 fn test_md033_fix_escaping() {
1297 let rule = MD033NoInlineHtml::default();
1298 let content = "Text with <div> and <br/> tags.";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let fixed_content = rule.fix(&ctx).unwrap();
1301 assert_eq!(fixed_content, content);
1303 }
1304
1305 #[test]
1306 fn test_md033_in_code_blocks() {
1307 let rule = MD033NoInlineHtml::default();
1308 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1314 }
1315
1316 #[test]
1317 fn test_md033_in_code_spans() {
1318 let rule = MD033NoInlineHtml::default();
1319 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert_eq!(result.len(), 1);
1324 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1325 }
1326
1327 #[test]
1328 fn test_md033_issue_90_code_span_with_diff_block() {
1329 let rule = MD033NoInlineHtml::default();
1331 let content = r#"# Heading
1332
1333`<env>`
1334
1335```diff
1336- this
1337+ that
1338```"#;
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1343 }
1344
1345 #[test]
1346 fn test_md033_multiple_code_spans_with_angle_brackets() {
1347 let rule = MD033NoInlineHtml::default();
1349 let content = "`<one>` and `<two>` and `<three>` are all code spans";
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 any code spans");
1353 }
1354
1355 #[test]
1356 fn test_md033_nested_angle_brackets_in_code_span() {
1357 let rule = MD033NoInlineHtml::default();
1359 let content = "Text with `<<nested>>` brackets";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1363 }
1364
1365 #[test]
1366 fn test_md033_code_span_at_end_before_code_block() {
1367 let rule = MD033NoInlineHtml::default();
1369 let content = "Testing `<test>`\n```\ncode here\n```";
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372 assert_eq!(result.len(), 0, "Should handle code span before code block");
1373 }
1374
1375 #[test]
1376 fn test_md033_quick_fix_inline_tag() {
1377 let rule = MD033NoInlineHtml::default();
1380 let content = "This has <span>inline text</span> that should keep content.";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383
1384 assert_eq!(result.len(), 1, "Should find one HTML tag");
1385 assert!(
1387 result[0].fix.is_none(),
1388 "Non-fixable tags like <span> should not have a fix"
1389 );
1390 }
1391
1392 #[test]
1393 fn test_md033_quick_fix_multiline_tag() {
1394 let rule = MD033NoInlineHtml::default();
1397 let content = "<div>\nBlock content\n</div>";
1398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 let result = rule.check(&ctx).unwrap();
1400
1401 assert_eq!(result.len(), 1, "Should find one HTML tag");
1402 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1404 }
1405
1406 #[test]
1407 fn test_md033_quick_fix_self_closing_tag() {
1408 let rule = MD033NoInlineHtml::default();
1410 let content = "Self-closing: <br/>";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 let result = rule.check(&ctx).unwrap();
1413
1414 assert_eq!(result.len(), 1, "Should find one HTML tag");
1415 assert!(
1417 result[0].fix.is_none(),
1418 "Self-closing tags should not have a fix when fix config is false"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_md033_quick_fix_multiple_tags() {
1424 let rule = MD033NoInlineHtml::default();
1427 let content = "<span>first</span> and <strong>second</strong>";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let result = rule.check(&ctx).unwrap();
1430
1431 assert_eq!(result.len(), 2, "Should find two HTML tags");
1432 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1434 assert!(
1435 result[1].fix.is_none(),
1436 "<strong> should not have a fix when fix config is false"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_md033_skip_angle_brackets_in_link_titles() {
1442 let rule = MD033NoInlineHtml::default();
1444 let content = r#"# Test
1445
1446[example]: <https://example.com> "Title with <Angle Brackets> inside"
1447
1448Regular text with <div>content</div> HTML tag.
1449"#;
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452
1453 assert_eq!(result.len(), 1, "Should find opening div tag");
1456 assert!(
1457 result[0].message.contains("<div>"),
1458 "Should flag <div>, got: {}",
1459 result[0].message
1460 );
1461 }
1462
1463 #[test]
1464 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1465 let rule = MD033NoInlineHtml::default();
1467 let content = r#"[ref]: url 'Title <Help Wanted> here'
1468
1469<span>text</span> here
1470"#;
1471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472 let result = rule.check(&ctx).unwrap();
1473
1474 assert_eq!(result.len(), 1, "Should find opening span tag");
1477 assert!(
1478 result[0].message.contains("<span>"),
1479 "Should flag <span>, got: {}",
1480 result[0].message
1481 );
1482 }
1483
1484 #[test]
1485 fn test_md033_multiline_tag_end_line_calculation() {
1486 let rule = MD033NoInlineHtml::default();
1488 let content = "<div\n class=\"test\"\n id=\"example\">";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let result = rule.check(&ctx).unwrap();
1491
1492 assert_eq!(result.len(), 1, "Should find one HTML tag");
1493 assert_eq!(result[0].line, 1, "Start line should be 1");
1495 assert_eq!(result[0].end_line, 3, "End line should be 3");
1497 }
1498
1499 #[test]
1500 fn test_md033_single_line_tag_same_start_end_line() {
1501 let rule = MD033NoInlineHtml::default();
1503 let content = "Some text <div class=\"test\"> more text";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert_eq!(result.len(), 1, "Should find one HTML tag");
1508 assert_eq!(result[0].line, 1, "Start line should be 1");
1509 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1510 }
1511
1512 #[test]
1513 fn test_md033_multiline_tag_with_many_attributes() {
1514 let rule = MD033NoInlineHtml::default();
1516 let content =
1517 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let result = rule.check(&ctx).unwrap();
1520
1521 assert_eq!(result.len(), 1, "Should find one HTML tag");
1522 assert_eq!(result[0].line, 2, "Start line should be 2");
1524 assert_eq!(result[0].end_line, 5, "End line should be 5");
1526 }
1527
1528 #[test]
1529 fn test_md033_disallowed_mode_basic() {
1530 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1532 let content = "<div>Safe content</div><script>alert('xss')</script>";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1538 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1539 }
1540
1541 #[test]
1542 fn test_md033_disallowed_gfm_security_tags() {
1543 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1545 let content = r#"
1546<div>Safe</div>
1547<title>Bad title</title>
1548<textarea>Bad textarea</textarea>
1549<style>.bad{}</style>
1550<iframe src="evil"></iframe>
1551<script>evil()</script>
1552<plaintext>old tag</plaintext>
1553<span>Safe span</span>
1554"#;
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let result = rule.check(&ctx).unwrap();
1557
1558 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1561
1562 let flagged_tags: Vec<&str> = result
1563 .iter()
1564 .filter_map(|w| w.message.split('<').nth(1))
1565 .filter_map(|s| s.split('>').next())
1566 .filter_map(|s| s.split_whitespace().next())
1567 .collect();
1568
1569 assert!(flagged_tags.contains(&"title"), "Should flag title");
1570 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1571 assert!(flagged_tags.contains(&"style"), "Should flag style");
1572 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1573 assert!(flagged_tags.contains(&"script"), "Should flag script");
1574 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1575 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1576 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1577 }
1578
1579 #[test]
1580 fn test_md033_disallowed_case_insensitive() {
1581 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1583 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 2, "Should flag both case variants");
1589 }
1590
1591 #[test]
1592 fn test_md033_disallowed_with_attributes() {
1593 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1595 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let result = rule.check(&ctx).unwrap();
1598
1599 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1600 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1601 }
1602
1603 #[test]
1604 fn test_md033_disallowed_all_gfm_tags() {
1605 use md033_config::GFM_DISALLOWED_TAGS;
1607 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1608
1609 for tag in GFM_DISALLOWED_TAGS {
1610 let content = format!("<{tag}>content</{tag}>");
1611 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613
1614 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1615 }
1616 }
1617
1618 #[test]
1619 fn test_md033_disallowed_mixed_with_custom() {
1620 let rule = MD033NoInlineHtml::with_disallowed(vec![
1622 "gfm".to_string(),
1623 "marquee".to_string(), ]);
1625 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let result = rule.check(&ctx).unwrap();
1628
1629 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1631 }
1632
1633 #[test]
1634 fn test_md033_disallowed_empty_means_default_mode() {
1635 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1637 let content = "<div>content</div>";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1643 }
1644
1645 #[test]
1646 fn test_md033_jsx_fragments_in_mdx() {
1647 let rule = MD033NoInlineHtml::default();
1649 let content = r#"# MDX Document
1650
1651<>
1652 <Heading />
1653 <Content />
1654</>
1655
1656<div>Regular HTML should still 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!(result.len(), 1, "Should only find one HTML tag (the div)");
1663 assert!(
1664 result[0].message.contains("<div>"),
1665 "Should flag <div>, not JSX fragments"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_md033_jsx_components_in_mdx() {
1671 let rule = MD033NoInlineHtml::default();
1673 let content = r#"<CustomComponent prop="value">
1674 Content
1675</CustomComponent>
1676
1677<MyButton onClick={handler}>Click</MyButton>
1678"#;
1679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1680 let result = rule.check(&ctx).unwrap();
1681
1682 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1684 }
1685
1686 #[test]
1687 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1688 let rule = MD033NoInlineHtml::default();
1690 let content = "<Script>alert(1)</Script>";
1691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692 let result = rule.check(&ctx).unwrap();
1693
1694 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1696 }
1697
1698 #[test]
1699 fn test_md033_jsx_attributes_in_mdx() {
1700 let rule = MD033NoInlineHtml::default();
1702 let content = r#"# MDX with JSX Attributes
1703
1704<div className="card big">Content</div>
1705
1706<button onClick={handleClick}>Click me</button>
1707
1708<label htmlFor="input-id">Label</label>
1709
1710<input onChange={handleChange} />
1711
1712<div class="html-class">Regular HTML should be flagged</div>
1713"#;
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert_eq!(
1719 result.len(),
1720 1,
1721 "Should only flag HTML element without JSX attributes, got: {result:?}"
1722 );
1723 assert!(
1724 result[0].message.contains("<div class="),
1725 "Should flag the div with HTML class attribute"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1731 let rule = MD033NoInlineHtml::default();
1733 let content = r#"<div className="card">Content</div>"#;
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735 let result = rule.check(&ctx).unwrap();
1736
1737 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1739 }
1740
1741 #[test]
1744 fn test_md033_fix_disabled_by_default() {
1745 let rule = MD033NoInlineHtml::default();
1747 assert!(!rule.config.fix, "Fix should be disabled by default");
1748 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1749 }
1750
1751 #[test]
1752 fn test_md033_fix_enabled_em_to_italic() {
1753 let rule = MD033NoInlineHtml::with_fix(true);
1755 let content = "This has <em>emphasized text</em> here.";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let fixed = rule.fix(&ctx).unwrap();
1758 assert_eq!(fixed, "This has *emphasized text* here.");
1759 }
1760
1761 #[test]
1762 fn test_md033_fix_enabled_i_to_italic() {
1763 let rule = MD033NoInlineHtml::with_fix(true);
1765 let content = "This has <i>italic text</i> here.";
1766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1767 let fixed = rule.fix(&ctx).unwrap();
1768 assert_eq!(fixed, "This has *italic text* here.");
1769 }
1770
1771 #[test]
1772 fn test_md033_fix_enabled_strong_to_bold() {
1773 let rule = MD033NoInlineHtml::with_fix(true);
1775 let content = "This has <strong>bold text</strong> here.";
1776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let fixed = rule.fix(&ctx).unwrap();
1778 assert_eq!(fixed, "This has **bold text** here.");
1779 }
1780
1781 #[test]
1782 fn test_md033_fix_enabled_b_to_bold() {
1783 let rule = MD033NoInlineHtml::with_fix(true);
1785 let content = "This has <b>bold text</b> here.";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let fixed = rule.fix(&ctx).unwrap();
1788 assert_eq!(fixed, "This has **bold text** here.");
1789 }
1790
1791 #[test]
1792 fn test_md033_fix_enabled_code_to_backticks() {
1793 let rule = MD033NoInlineHtml::with_fix(true);
1795 let content = "This has <code>inline code</code> here.";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let fixed = rule.fix(&ctx).unwrap();
1798 assert_eq!(fixed, "This has `inline code` here.");
1799 }
1800
1801 #[test]
1802 fn test_md033_fix_enabled_code_with_backticks() {
1803 let rule = MD033NoInlineHtml::with_fix(true);
1805 let content = "This has <code>text with `backticks`</code> here.";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let fixed = rule.fix(&ctx).unwrap();
1808 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1809 }
1810
1811 #[test]
1812 fn test_md033_fix_enabled_br_trailing_spaces() {
1813 let rule = MD033NoInlineHtml::with_fix(true);
1815 let content = "First line<br>Second line";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let fixed = rule.fix(&ctx).unwrap();
1818 assert_eq!(fixed, "First line \nSecond line");
1819 }
1820
1821 #[test]
1822 fn test_md033_fix_enabled_br_self_closing() {
1823 let rule = MD033NoInlineHtml::with_fix(true);
1825 let content = "First<br/>second<br />third";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 let fixed = rule.fix(&ctx).unwrap();
1828 assert_eq!(fixed, "First \nsecond \nthird");
1829 }
1830
1831 #[test]
1832 fn test_md033_fix_enabled_br_backslash_style() {
1833 let config = MD033Config {
1835 allowed: Vec::new(),
1836 disallowed: Vec::new(),
1837 fix: true,
1838 br_style: md033_config::BrStyle::Backslash,
1839 ..MD033Config::default()
1840 };
1841 let rule = MD033NoInlineHtml::from_config_struct(config);
1842 let content = "First line<br>Second line";
1843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844 let fixed = rule.fix(&ctx).unwrap();
1845 assert_eq!(fixed, "First line\\\nSecond line");
1846 }
1847
1848 #[test]
1849 fn test_md033_fix_enabled_hr() {
1850 let rule = MD033NoInlineHtml::with_fix(true);
1852 let content = "Above<hr>Below";
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let fixed = rule.fix(&ctx).unwrap();
1855 assert_eq!(fixed, "Above\n---\nBelow");
1856 }
1857
1858 #[test]
1859 fn test_md033_fix_enabled_hr_self_closing() {
1860 let rule = MD033NoInlineHtml::with_fix(true);
1862 let content = "Above<hr/>Below";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 let fixed = rule.fix(&ctx).unwrap();
1865 assert_eq!(fixed, "Above\n---\nBelow");
1866 }
1867
1868 #[test]
1869 fn test_md033_fix_skips_nested_tags() {
1870 let rule = MD033NoInlineHtml::with_fix(true);
1873 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 let fixed = rule.fix(&ctx).unwrap();
1876 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1879 }
1880
1881 #[test]
1882 fn test_md033_fix_skips_tags_with_attributes() {
1883 let rule = MD033NoInlineHtml::with_fix(true);
1886 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let fixed = rule.fix(&ctx).unwrap();
1889 assert_eq!(fixed, content);
1891 }
1892
1893 #[test]
1894 fn test_md033_fix_disabled_no_changes() {
1895 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1899 let fixed = rule.fix(&ctx).unwrap();
1900 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1901 }
1902
1903 #[test]
1904 fn test_md033_fix_capability_enabled() {
1905 let rule = MD033NoInlineHtml::with_fix(true);
1906 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1907 }
1908
1909 #[test]
1910 fn test_md033_fix_multiple_tags() {
1911 let rule = MD033NoInlineHtml::with_fix(true);
1913 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let fixed = rule.fix(&ctx).unwrap();
1916 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1917 }
1918
1919 #[test]
1920 fn test_md033_fix_uppercase_tags() {
1921 let rule = MD033NoInlineHtml::with_fix(true);
1923 let content = "This has <EM>emphasized</EM> text.";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926 assert_eq!(fixed, "This has *emphasized* text.");
1927 }
1928
1929 #[test]
1930 fn test_md033_fix_unsafe_tags_not_modified() {
1931 let rule = MD033NoInlineHtml::with_fix(true);
1934 let content = "This has <div>a div</div> content.";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let fixed = rule.fix(&ctx).unwrap();
1937 assert_eq!(fixed, "This has <div>a div</div> content.");
1939 }
1940
1941 #[test]
1942 fn test_md033_fix_img_tag_converted() {
1943 let rule = MD033NoInlineHtml::with_fix(true);
1945 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let fixed = rule.fix(&ctx).unwrap();
1948 assert_eq!(fixed, "Image: ");
1950 }
1951
1952 #[test]
1953 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1954 let rule = MD033NoInlineHtml::with_fix(true);
1956 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let fixed = rule.fix(&ctx).unwrap();
1959 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1961 }
1962
1963 #[test]
1964 fn test_md033_fix_relaxed_a_with_target_is_converted() {
1965 let rule = relaxed_fix_rule();
1966 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let fixed = rule.fix(&ctx).unwrap();
1969 assert_eq!(fixed, "Link: [Example](https://example.com)");
1970 }
1971
1972 #[test]
1973 fn test_md033_fix_relaxed_img_with_width_is_converted() {
1974 let rule = relaxed_fix_rule();
1975 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1977 let fixed = rule.fix(&ctx).unwrap();
1978 assert_eq!(fixed, "Image: ");
1979 }
1980
1981 #[test]
1982 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
1983 let rule = relaxed_fix_rule();
1984 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
1985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986 let fixed = rule.fix(&ctx).unwrap();
1987 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
1988 }
1989
1990 #[test]
1991 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
1992 let rule = relaxed_fix_rule();
1993 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
1994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1995 let fixed = rule.fix(&ctx).unwrap();
1996 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
1997 }
1998
1999 #[test]
2000 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2001 let rule = relaxed_fix_rule();
2002 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2003 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let fixed_once = rule.fix(&ctx1).unwrap();
2005 assert!(
2006 fixed_once.contains("<p"),
2007 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2008 );
2009 assert!(
2010 fixed_once.contains(""),
2011 "Inner image should be converted on first pass: {fixed_once}"
2012 );
2013
2014 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2015 let fixed_twice = rule.fix(&ctx2).unwrap();
2016 assert!(
2017 !fixed_twice.contains("<p"),
2018 "Second pass should strip configured wrapper: {fixed_twice}"
2019 );
2020 assert!(fixed_twice.contains(""));
2021 }
2022
2023 #[test]
2024 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2025 let rule = relaxed_fix_rule();
2026 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2028 let fixed = rule.fix(&ctx).unwrap();
2029 assert_eq!(fixed, "[Click](https://example.com)");
2030 }
2031
2032 #[test]
2033 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2034 let rule = relaxed_fix_rule();
2035 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let fixed = rule.fix(&ctx).unwrap();
2038 assert_eq!(fixed, "");
2039 }
2040
2041 #[test]
2042 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2043 let rule = relaxed_fix_rule();
2044 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046 let fixed = rule.fix(&ctx).unwrap();
2047 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2048 }
2049
2050 #[test]
2051 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2052 let config = MD033Config {
2054 fix: true,
2055 fix_mode: MD033FixMode::Relaxed,
2056 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2057 ..MD033Config::default()
2058 };
2059 let rule = MD033NoInlineHtml::from_config_struct(config);
2060 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2062 let fixed = rule.fix(&ctx).unwrap();
2063 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2064 }
2065
2066 #[test]
2067 fn test_md033_fix_relaxed_custom_drop_attributes() {
2068 let config = MD033Config {
2069 fix: true,
2070 fix_mode: MD033FixMode::Relaxed,
2071 drop_attributes: vec!["loading".to_string()],
2072 ..MD033Config::default()
2073 };
2074 let rule = MD033NoInlineHtml::from_config_struct(config);
2075 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2078 let fixed = rule.fix(&ctx).unwrap();
2079 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2080
2081 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2082 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2083 let fixed2 = rule.fix(&ctx2).unwrap();
2084 assert_eq!(
2085 fixed2, content2,
2086 "Attributes not in custom list should block conversion"
2087 );
2088 }
2089
2090 #[test]
2091 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2092 let config = MD033Config {
2093 fix: true,
2094 fix_mode: MD033FixMode::Relaxed,
2095 strip_wrapper_elements: vec!["div".to_string()],
2096 ..MD033Config::default()
2097 };
2098 let rule = MD033NoInlineHtml::from_config_struct(config);
2099 let content = "<div>Some text content</div>";
2100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2101 let fixed = rule.fix(&ctx).unwrap();
2102 assert_eq!(fixed, "Some text content");
2103 }
2104
2105 #[test]
2106 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2107 let rule = relaxed_fix_rule();
2108 let content = "<p align=\"center\">Just some text</p>";
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110 let fixed = rule.fix(&ctx).unwrap();
2111 assert_eq!(fixed, "Just some text");
2112 }
2113
2114 #[test]
2115 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2116 let config = MD033Config {
2117 fix: true,
2118 fix_mode: MD033FixMode::Relaxed,
2119 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2120 ..MD033Config::default()
2121 };
2122 let rule = MD033NoInlineHtml::from_config_struct(config);
2123 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2125 let fixed = rule.fix(&ctx).unwrap();
2126 assert_eq!(fixed, "[Link](https://example.com)");
2127 }
2128
2129 #[test]
2130 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2131 let rule = relaxed_fix_rule();
2132 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135 let fixed = rule.fix(&ctx).unwrap();
2136 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2137 }
2138
2139 #[test]
2140 fn test_md033_fix_relaxed_badge_pattern() {
2141 let rule = relaxed_fix_rule();
2143 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>";
2144 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145 let fixed_once = rule.fix(&ctx1).unwrap();
2146 assert!(
2148 fixed_once.contains(""),
2149 "Inner img should be converted: {fixed_once}"
2150 );
2151
2152 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2154 let fixed_twice = rule.fix(&ctx2).unwrap();
2155 assert!(
2156 fixed_twice
2157 .contains("[](https://crates.io/crates/rumdl)"),
2158 "Badge should produce nested markdown image link: {fixed_twice}"
2159 );
2160 }
2161
2162 #[test]
2163 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2164 let rule = MD033NoInlineHtml::with_fix(true);
2166 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2168 let fixed = rule.fix(&ctx).unwrap();
2169 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2170 }
2171
2172 #[test]
2173 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2174 let rule = relaxed_fix_rule();
2176 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2178 let fixed = rule.fix(&ctx).unwrap();
2179 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2180 }
2181
2182 #[test]
2183 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2184 let rule = relaxed_fix_rule();
2186 let content = "<div><p>text</p></div>";
2187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2188 let fixed = rule.fix(&ctx).unwrap();
2189 assert!(
2190 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2191 "Nested <p> inside <div> should not be stripped: {fixed}"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2197 let rule = relaxed_fix_rule();
2201 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2203 let fixed = rule.fix(&ctx).unwrap();
2204 assert!(
2205 fixed.contains("<img"),
2206 "img inside nested wrapper must not be converted: {fixed}"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_md033_fix_mixed_safe_tags() {
2212 let rule = MD033NoInlineHtml::with_fix(true);
2214 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2216 let fixed = rule.fix(&ctx).unwrap();
2217 assert_eq!(fixed, "*italic* and  and **bold**");
2219 }
2220
2221 #[test]
2222 fn test_md033_fix_multiple_tags_same_line() {
2223 let rule = MD033NoInlineHtml::with_fix(true);
2225 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2227 let fixed = rule.fix(&ctx).unwrap();
2228 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2229 }
2230
2231 #[test]
2232 fn test_md033_fix_multiple_em_tags_same_line() {
2233 let rule = MD033NoInlineHtml::with_fix(true);
2235 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2237 let fixed = rule.fix(&ctx).unwrap();
2238 assert_eq!(fixed, "*first* and **second** and `third`");
2239 }
2240
2241 #[test]
2242 fn test_md033_fix_skips_tags_inside_pre() {
2243 let rule = MD033NoInlineHtml::with_fix(true);
2245 let content = "<pre><code><em>VALUE</em></code></pre>";
2246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2247 let fixed = rule.fix(&ctx).unwrap();
2248 assert!(
2251 !fixed.contains("*VALUE*"),
2252 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2253 );
2254 }
2255
2256 #[test]
2257 fn test_md033_fix_skips_tags_inside_div() {
2258 let rule = MD033NoInlineHtml::with_fix(true);
2260 let content = "<div>\n<em>emphasized</em>\n</div>";
2261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2262 let fixed = rule.fix(&ctx).unwrap();
2263 assert!(
2265 !fixed.contains("*emphasized*"),
2266 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2267 );
2268 }
2269
2270 #[test]
2271 fn test_md033_fix_outside_html_block() {
2272 let rule = MD033NoInlineHtml::with_fix(true);
2274 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2276 let fixed = rule.fix(&ctx).unwrap();
2277 assert!(
2279 fixed.contains("*emphasized*"),
2280 "Tags outside HTML blocks should be converted. Got: {fixed}"
2281 );
2282 }
2283
2284 #[test]
2285 fn test_md033_fix_with_id_attribute() {
2286 let rule = MD033NoInlineHtml::with_fix(true);
2288 let content = "See <em id=\"important\">this note</em> for details.";
2289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2290 let fixed = rule.fix(&ctx).unwrap();
2291 assert_eq!(fixed, content);
2293 }
2294
2295 #[test]
2296 fn test_md033_fix_with_style_attribute() {
2297 let rule = MD033NoInlineHtml::with_fix(true);
2299 let content = "This is <strong style=\"color: red\">important</strong> text.";
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_mixed_with_and_without_attributes() {
2308 let rule = MD033NoInlineHtml::with_fix(true);
2310 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2312 let fixed = rule.fix(&ctx).unwrap();
2313 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2315 }
2316
2317 #[test]
2318 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2319 let rule = MD033NoInlineHtml::with_fix(true);
2321 let content = "<em class=\"test\">emphasized</em>";
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let result = rule.check(&ctx).unwrap();
2324
2325 assert_eq!(result.len(), 1, "Should find one HTML tag");
2326 assert!(
2328 result[0].fix.is_none(),
2329 "Should NOT have a fix for tags with attributes"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_md033_fix_skips_html_entities() {
2335 let rule = MD033NoInlineHtml::with_fix(true);
2338 let content = "<code>|</code>";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 let fixed = rule.fix(&ctx).unwrap();
2341 assert_eq!(fixed, content);
2343 }
2344
2345 #[test]
2346 fn test_md033_fix_skips_multiple_html_entities() {
2347 let rule = MD033NoInlineHtml::with_fix(true);
2349 let content = "<code><T></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_allows_ampersand_without_entity() {
2358 let rule = MD033NoInlineHtml::with_fix(true);
2360 let content = "<code>a & b</code>";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362 let fixed = rule.fix(&ctx).unwrap();
2363 assert_eq!(fixed, "`a & b`");
2365 }
2366
2367 #[test]
2368 fn test_md033_fix_em_with_entities_skipped() {
2369 let rule = MD033NoInlineHtml::with_fix(true);
2371 let content = "<em> text</em>";
2372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2373 let fixed = rule.fix(&ctx).unwrap();
2374 assert_eq!(fixed, content);
2376 }
2377
2378 #[test]
2379 fn test_md033_fix_skips_nested_em_in_code() {
2380 let rule = MD033NoInlineHtml::with_fix(true);
2383 let content = "<code><em>n</em></code>";
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let fixed = rule.fix(&ctx).unwrap();
2386 assert!(
2389 !fixed.contains("*n*"),
2390 "Nested <em> should not be converted to markdown. Got: {fixed}"
2391 );
2392 }
2393
2394 #[test]
2395 fn test_md033_fix_skips_nested_in_table() {
2396 let rule = MD033NoInlineHtml::with_fix(true);
2398 let content = "| <code>><em>n</em></code> | description |";
2399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2400 let fixed = rule.fix(&ctx).unwrap();
2401 assert!(
2403 !fixed.contains("*n*"),
2404 "Nested tags in table should not be converted. Got: {fixed}"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_md033_fix_standalone_em_still_converted() {
2410 let rule = MD033NoInlineHtml::with_fix(true);
2412 let content = "This is <em>emphasized</em> text.";
2413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2414 let fixed = rule.fix(&ctx).unwrap();
2415 assert_eq!(fixed, "This is *emphasized* text.");
2416 }
2417
2418 #[test]
2430 fn test_md033_templater_basic_interpolation_not_flagged() {
2431 let rule = MD033NoInlineHtml::default();
2434 let content = "Today is <% tp.date.now() %> which is nice.";
2435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2436 let result = rule.check(&ctx).unwrap();
2437 assert!(
2438 result.is_empty(),
2439 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2440 );
2441 }
2442
2443 #[test]
2444 fn test_md033_templater_file_functions_not_flagged() {
2445 let rule = MD033NoInlineHtml::default();
2447 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2449 let result = rule.check(&ctx).unwrap();
2450 assert!(
2451 result.is_empty(),
2452 "Templater file functions should not be flagged. Got: {result:?}"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_md033_templater_with_arguments_not_flagged() {
2458 let rule = MD033NoInlineHtml::default();
2460 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2462 let result = rule.check(&ctx).unwrap();
2463 assert!(
2464 result.is_empty(),
2465 "Templater with arguments should not be flagged. Got: {result:?}"
2466 );
2467 }
2468
2469 #[test]
2470 fn test_md033_templater_javascript_execution_not_flagged() {
2471 let rule = MD033NoInlineHtml::default();
2473 let content = "<%* const today = tp.date.now(); tR += today; %>";
2474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2475 let result = rule.check(&ctx).unwrap();
2476 assert!(
2477 result.is_empty(),
2478 "Templater JS execution block should not be flagged. Got: {result:?}"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_md033_templater_dynamic_execution_not_flagged() {
2484 let rule = MD033NoInlineHtml::default();
2486 let content = "Dynamic: <%+ tp.date.now() %>";
2487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2488 let result = rule.check(&ctx).unwrap();
2489 assert!(
2490 result.is_empty(),
2491 "Templater dynamic execution should not be flagged. Got: {result:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2497 let rule = MD033NoInlineHtml::default();
2499 let content = "<%_ tp.date.now() _%>";
2500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2501 let result = rule.check(&ctx).unwrap();
2502 assert!(
2503 result.is_empty(),
2504 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2510 let rule = MD033NoInlineHtml::default();
2512 let content = "<%- tp.date.now() -%>";
2513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2514 let result = rule.check(&ctx).unwrap();
2515 assert!(
2516 result.is_empty(),
2517 "Templater trim-newline should not be flagged. Got: {result:?}"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_md033_templater_combined_modifiers_not_flagged() {
2523 let rule = MD033NoInlineHtml::default();
2525 let contents = [
2526 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2531 for content in contents {
2532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2533 let result = rule.check(&ctx).unwrap();
2534 assert!(
2535 result.is_empty(),
2536 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2537 );
2538 }
2539 }
2540
2541 #[test]
2542 fn test_md033_templater_multiline_block_not_flagged() {
2543 let rule = MD033NoInlineHtml::default();
2545 let content = r#"<%*
2546const x = 1;
2547const y = 2;
2548tR += x + y;
2549%>"#;
2550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2551 let result = rule.check(&ctx).unwrap();
2552 assert!(
2553 result.is_empty(),
2554 "Templater multi-line block should not be flagged. Got: {result:?}"
2555 );
2556 }
2557
2558 #[test]
2559 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2560 let rule = MD033NoInlineHtml::default();
2563 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2565 let result = rule.check(&ctx).unwrap();
2566 assert!(
2567 result.is_empty(),
2568 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2574 let rule = MD033NoInlineHtml::default();
2576 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2578 let result = rule.check(&ctx).unwrap();
2579 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2580 assert!(
2581 result[0].message.contains("<div>"),
2582 "Should flag <div>, got: {}",
2583 result[0].message
2584 );
2585 }
2586
2587 #[test]
2588 fn test_md033_templater_in_heading_not_flagged() {
2589 let rule = MD033NoInlineHtml::default();
2591 let content = "# <% tp.file.title %>";
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2593 let result = rule.check(&ctx).unwrap();
2594 assert!(
2595 result.is_empty(),
2596 "Templater in heading should not be flagged. Got: {result:?}"
2597 );
2598 }
2599
2600 #[test]
2601 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2602 let rule = MD033NoInlineHtml::default();
2604 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2606 let result = rule.check(&ctx).unwrap();
2607 assert!(
2608 result.is_empty(),
2609 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2610 );
2611 }
2612
2613 #[test]
2614 fn test_md033_templater_in_code_block_not_flagged() {
2615 let rule = MD033NoInlineHtml::default();
2617 let content = "```\n<% tp.date.now() %>\n```";
2618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2619 let result = rule.check(&ctx).unwrap();
2620 assert!(
2621 result.is_empty(),
2622 "Templater in code block should not be flagged. Got: {result:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_md033_templater_in_inline_code_not_flagged() {
2628 let rule = MD033NoInlineHtml::default();
2630 let content = "Use `<% tp.date.now() %>` for current date.";
2631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2632 let result = rule.check(&ctx).unwrap();
2633 assert!(
2634 result.is_empty(),
2635 "Templater in inline code should not be flagged. Got: {result:?}"
2636 );
2637 }
2638
2639 #[test]
2640 fn test_md033_templater_also_works_in_standard_flavor() {
2641 let rule = MD033NoInlineHtml::default();
2644 let content = "<% tp.date.now() %> works everywhere.";
2645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2646 let result = rule.check(&ctx).unwrap();
2647 assert!(
2648 result.is_empty(),
2649 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_md033_templater_empty_tag_not_flagged() {
2655 let rule = MD033NoInlineHtml::default();
2657 let content = "<%>";
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2659 let result = rule.check(&ctx).unwrap();
2660 assert!(
2661 result.is_empty(),
2662 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2663 );
2664 }
2665
2666 #[test]
2667 fn test_md033_templater_unclosed_not_flagged() {
2668 let rule = MD033NoInlineHtml::default();
2670 let content = "<% tp.date.now() without closing tag";
2671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2672 let result = rule.check(&ctx).unwrap();
2673 assert!(
2674 result.is_empty(),
2675 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_md033_templater_with_newlines_inside_not_flagged() {
2681 let rule = MD033NoInlineHtml::default();
2683 let content = r#"<% tp.date.now("YYYY") +
2684"-" +
2685tp.date.now("MM") %>"#;
2686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2687 let result = rule.check(&ctx).unwrap();
2688 assert!(
2689 result.is_empty(),
2690 "Templater with internal newlines should not be flagged. Got: {result:?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn test_md033_erb_style_tags_not_flagged() {
2696 let rule = MD033NoInlineHtml::default();
2699 let content = "<%= variable %> and <% code %> and <%# comment %>";
2700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2701 let result = rule.check(&ctx).unwrap();
2702 assert!(
2703 result.is_empty(),
2704 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_md033_templater_complex_expression_not_flagged() {
2710 let rule = MD033NoInlineHtml::default();
2712 let content = r#"<%*
2713const file = tp.file.title;
2714const date = tp.date.now("YYYY-MM-DD");
2715const folder = tp.file.folder();
2716tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2717%>"#;
2718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2719 let result = rule.check(&ctx).unwrap();
2720 assert!(
2721 result.is_empty(),
2722 "Complex Templater expression should not be flagged. Got: {result:?}"
2723 );
2724 }
2725
2726 #[test]
2727 fn test_md033_percent_sign_variations_not_flagged() {
2728 let rule = MD033NoInlineHtml::default();
2730 let patterns = [
2731 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2738 for pattern in patterns {
2739 let content = format!("{pattern} content %>");
2740 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2741 let result = rule.check(&ctx).unwrap();
2742 assert!(
2743 result.is_empty(),
2744 "Pattern {pattern} should not be flagged. Got: {result:?}"
2745 );
2746 }
2747 }
2748
2749 #[test]
2755 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2756 let rule = MD033NoInlineHtml::with_fix(true);
2759 let content = r#"<a href="https://example.com"></a>"#;
2760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2761 let fixed = rule.fix(&ctx).unwrap();
2762
2763 assert_eq!(fixed, "[](https://example.com)",);
2764 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2765 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2766 }
2767
2768 #[test]
2769 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2770 let rule = MD033NoInlineHtml::with_fix(true);
2772 let content =
2773 r#"<a href="https://github.com/repo"></a>"#;
2774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2775 let fixed = rule.fix(&ctx).unwrap();
2776
2777 assert_eq!(
2778 fixed,
2779 "[](https://github.com/repo)"
2780 );
2781 }
2782
2783 #[test]
2784 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2785 let rule = MD033NoInlineHtml::with_fix(true);
2786 let content = r#"<img src="photo.jpg" />"#;
2787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2788 let fixed = rule.fix(&ctx).unwrap();
2789
2790 assert_eq!(fixed, "");
2791 }
2792
2793 #[test]
2794 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2795 let rule = MD033NoInlineHtml::with_fix(true);
2797 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2799 let fixed = rule.fix(&ctx).unwrap();
2800
2801 assert!(
2802 fixed.contains(r"\[brackets\]"),
2803 "Plain text brackets should be escaped: {fixed}"
2804 );
2805 }
2806
2807 #[test]
2808 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2809 let rule = MD033NoInlineHtml::with_fix(true);
2812 let content = r#"<a href="/link"> see [docs]</a>"#;
2813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814 let fixed = rule.fix(&ctx).unwrap();
2815
2816 assert!(
2818 fixed.contains(r"\[docs\]"),
2819 "Brackets in mixed image+text content should be escaped: {fixed}"
2820 );
2821 }
2822
2823 #[test]
2824 fn test_md033_fix_img_in_a_end_to_end() {
2825 use crate::config::Config;
2828 use crate::fix_coordinator::FixCoordinator;
2829
2830 let rule = MD033NoInlineHtml::with_fix(true);
2831 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2832
2833 let mut content =
2834 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2835 .to_string();
2836 let config = Config::default();
2837 let coordinator = FixCoordinator::new();
2838
2839 let result = coordinator
2840 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2841 .unwrap();
2842
2843 assert_eq!(
2844 content, "[](https://github.com/org/repo)",
2845 "End-to-end: <a><img></a> should become valid linked image"
2846 );
2847 assert!(result.converged);
2848 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2849 }
2850
2851 #[test]
2852 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2853 use crate::config::Config;
2854 use crate::fix_coordinator::FixCoordinator;
2855
2856 let rule = MD033NoInlineHtml::with_fix(true);
2857 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2858
2859 let mut content =
2860 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2861 .to_string();
2862 let config = Config::default();
2863 let coordinator = FixCoordinator::new();
2864
2865 let result = coordinator
2866 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2867 .unwrap();
2868
2869 assert_eq!(
2870 content,
2871 "[](https://github.com/org/repo)",
2872 );
2873 assert!(result.converged);
2874 }
2875
2876 #[test]
2886 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2887 let config = MD033Config {
2888 allowed: vec!["br".to_string()],
2889 table_allowed_elements: None,
2890 ..MD033Config::default()
2891 };
2892 let rule = MD033NoInlineHtml::from_config_struct(config);
2893 let content = "| col |\n|-----|\n| a<br>b |\n";
2894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2895 let result = rule.check(&ctx).unwrap();
2896 assert!(
2897 result.is_empty(),
2898 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2899 );
2900 }
2901
2902 #[test]
2903 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2904 let config = MD033Config {
2905 allowed: vec!["br".to_string()],
2906 table_allowed_elements: Some(Vec::new()),
2907 ..MD033Config::default()
2908 };
2909 let rule = MD033NoInlineHtml::from_config_struct(config);
2910 let content = "| col |\n|-----|\n| a<br>b |\n";
2911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2912 let result = rule.check(&ctx).unwrap();
2913 assert_eq!(
2914 result.len(),
2915 1,
2916 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2917 );
2918 assert_eq!(result[0].line, 3);
2919 }
2920
2921 #[test]
2922 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2923 let config = MD033Config {
2924 allowed: vec!["br".to_string()],
2925 table_allowed_elements: Some(vec!["img".to_string()]),
2926 ..MD033Config::default()
2927 };
2928 let rule = MD033NoInlineHtml::from_config_struct(config);
2929 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2933 let result = rule.check(&ctx).unwrap();
2934 assert_eq!(
2935 result.len(),
2936 1,
2937 "table_allowed should override `allowed` inside tables, got {result:?}"
2938 );
2939 assert!(
2940 result[0].message.contains("br"),
2941 "expected the flagged tag to be <br>, got {:?}",
2942 result[0].message
2943 );
2944 }
2945
2946 #[test]
2947 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2948 let config = MD033Config {
2949 allowed: vec!["br".to_string()],
2950 table_allowed_elements: Some(Vec::new()),
2951 ..MD033Config::default()
2952 };
2953 let rule = MD033NoInlineHtml::from_config_struct(config);
2954 let content = "Paragraph with <br> tag.\n";
2956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2957 let result = rule.check(&ctx).unwrap();
2958 assert!(
2959 result.is_empty(),
2960 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2961 );
2962 }
2963
2964 #[test]
2965 fn test_md033_table_allowed_kebab_case_parses() {
2966 let toml_str = r#"
2967 allowed-elements = ["br"]
2968 table-allowed-elements = ["img"]
2969 "#;
2970 let config: MD033Config = toml::from_str(toml_str).unwrap();
2971 assert_eq!(config.allowed, vec!["br"]);
2972 assert_eq!(
2973 config.table_allowed_elements.as_deref(),
2974 Some(["img".to_string()].as_slice())
2975 );
2976 }
2977
2978 #[test]
2979 fn test_md033_table_allowed_snake_case_alias_parses() {
2980 let toml_str = r#"
2981 allowed_elements = ["br"]
2982 table_allowed_elements = ["img"]
2983 "#;
2984 let config: MD033Config = toml::from_str(toml_str).unwrap();
2985 assert_eq!(config.allowed, vec!["br"]);
2986 assert_eq!(
2987 config.table_allowed_elements.as_deref(),
2988 Some(["img".to_string()].as_slice())
2989 );
2990 }
2991
2992 #[test]
2993 fn test_md033_table_allowed_default_is_none() {
2994 let cfg = MD033Config::default();
2995 assert!(
2996 cfg.table_allowed_elements.is_none(),
2997 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_md033_table_allowed_case_insensitive() {
3003 let config = MD033Config {
3004 allowed: Vec::new(),
3005 table_allowed_elements: Some(vec!["BR".to_string()]),
3006 ..MD033Config::default()
3007 };
3008 let rule = MD033NoInlineHtml::from_config_struct(config);
3009 let content = "| col |\n|-----|\n| a<br>b |\n";
3010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3011 let result = rule.check(&ctx).unwrap();
3012 assert!(
3013 result.is_empty(),
3014 "table_allowed should be case-insensitive, got {result:?}"
3015 );
3016 }
3017}