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
527 .find(|c: char| c.is_whitespace())
528 .map_or(tag_content.len(), |i| i + 1);
529
530 if attr_start >= tag_content.len() {
531 return attrs;
532 }
533
534 let attr_str = &tag_content[attr_start..];
535 let mut chars = attr_str.chars().peekable();
536
537 while chars.peek().is_some() {
538 while chars.peek().is_some_and(|c| c.is_whitespace()) {
540 chars.next();
541 }
542
543 if chars.peek().is_none() {
544 break;
545 }
546
547 let mut attr_name = String::new();
549 while let Some(&c) = chars.peek() {
550 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
551 break;
552 }
553 attr_name.push(c);
554 chars.next();
555 }
556
557 if attr_name.is_empty() {
558 break;
559 }
560
561 while chars.peek().is_some_and(|c| c.is_whitespace()) {
563 chars.next();
564 }
565
566 if chars.peek() == Some(&'=') {
568 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
572 chars.next();
573 }
574
575 let mut value = String::new();
577 if let Some("e) = chars.peek() {
578 if quote == '"' || quote == '\'' {
579 chars.next(); for c in chars.by_ref() {
581 if c == quote {
582 break;
583 }
584 value.push(c);
585 }
586 } else {
587 while let Some(&c) = chars.peek() {
589 if c.is_whitespace() || c == '>' || c == '/' {
590 break;
591 }
592 value.push(c);
593 chars.next();
594 }
595 }
596 }
597 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
598 } else {
599 attrs.push((attr_name.to_ascii_lowercase(), None));
601 }
602 }
603
604 attrs
605 }
606
607 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
611 let attrs = Self::parse_attributes(tag);
612 let attr_lower = attr_name.to_ascii_lowercase();
613
614 attrs
615 .into_iter()
616 .find(|(name, _)| name == &attr_lower)
617 .and_then(|(_, value)| value)
618 }
619
620 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
623 let attrs = Self::parse_attributes(tag);
624
625 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
629 "class",
630 "id",
631 "style",
632 "target",
633 "rel",
634 "download",
635 "referrerpolicy",
636 "crossorigin",
637 "loading",
638 "decoding",
639 "fetchpriority",
640 "sizes",
641 "srcset",
642 "usemap",
643 "ismap",
644 "width",
645 "height",
646 "name", "data-*", ];
649
650 for (attr_name, _) in attrs {
651 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
653 continue;
654 }
655
656 if self.is_relaxed_fix_mode() {
657 if self.is_droppable_attribute(&attr_name) {
658 continue;
659 }
660 return true;
661 }
662
663 for prefix in DANGEROUS_ATTR_PREFIXES {
665 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
666 return true;
667 }
668 }
669
670 if attr_name.starts_with("data-") {
672 return true;
673 }
674
675 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
677 return true;
678 }
679 }
680
681 false
682 }
683
684 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
687 let href = Self::extract_attribute(opening_tag, "href")?;
689
690 if !MD033Config::is_safe_url(&href) {
692 return None;
693 }
694
695 if inner_content.contains('<') {
697 return None;
698 }
699
700 if inner_content.contains('&') && inner_content.contains(';') {
702 let has_entity = inner_content
703 .split('&')
704 .skip(1)
705 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
706 if has_entity {
707 return None;
708 }
709 }
710
711 let title = Self::extract_attribute(opening_tag, "title");
713
714 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
716 return None;
717 }
718
719 let trimmed_inner = inner_content.trim();
724 let is_markdown_image =
725 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
726 if let Some(bracket_close) = trimmed_inner.rfind("](") {
729 let after_paren = &trimmed_inner[bracket_close + 2..];
730 after_paren.ends_with(')')
732 && after_paren.chars().filter(|&c| c == ')').count()
733 >= after_paren.chars().filter(|&c| c == '(').count()
734 } else {
735 false
736 }
737 };
738 let escaped_text = if is_markdown_image {
739 trimmed_inner.to_string()
740 } else {
741 inner_content.replace('[', r"\[").replace(']', r"\]")
744 };
745
746 let escaped_url = href.replace('(', "%28").replace(')', "%29");
748
749 if let Some(title_text) = title {
751 let escaped_title = title_text.replace('"', r#"\""#);
753 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
754 } else {
755 Some(format!("[{escaped_text}]({escaped_url})"))
756 }
757 }
758
759 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
762 let src = Self::extract_attribute(tag, "src")?;
764
765 if !MD033Config::is_safe_url(&src) {
767 return None;
768 }
769
770 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
772
773 let title = Self::extract_attribute(tag, "title");
775
776 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
778 return None;
779 }
780
781 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
783
784 let escaped_url = src.replace('(', "%28").replace(')', "%29");
786
787 if let Some(title_text) = title {
789 let escaped_title = title_text.replace('"', r#"\""#);
791 Some(format!(""))
792 } else {
793 Some(format!(""))
794 }
795 }
796
797 fn has_significant_attributes(opening_tag: &str) -> bool {
799 let tag_content = opening_tag
801 .trim_start_matches('<')
802 .trim_end_matches('>')
803 .trim_end_matches('/');
804
805 let parts: Vec<&str> = tag_content.split_whitespace().collect();
807 parts.len() > 1
808 }
809
810 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
813 if tag_byte_start > 0 {
815 let before = &content[..tag_byte_start];
816 let before_trimmed = before.trim_end();
817 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
818 if let Some(last_lt) = before_trimmed.rfind('<') {
820 let potential_tag = &before_trimmed[last_lt..];
821 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
823 return true;
824 }
825 }
826 }
827 }
828 if tag_byte_end < content.len() {
830 let after = &content[tag_byte_end..];
831 let after_trimmed = after.trim_start();
832 if after_trimmed.starts_with("</") {
833 return true;
834 }
835 }
836 false
837 }
838
839 fn calculate_fix(
854 &self,
855 content: &str,
856 opening_tag: &str,
857 tag_byte_start: usize,
858 in_html_block: bool,
859 ) -> Option<(std::ops::Range<usize>, String)> {
860 let tag_name = opening_tag
862 .trim_start_matches('<')
863 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
864 .next()?
865 .to_lowercase();
866
867 let is_self_closing =
869 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
870
871 if is_self_closing {
872 let block_ok = !in_html_block
878 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
879 if self.config.fix
880 && MD033Config::is_safe_fixable_tag(&tag_name)
881 && block_ok
882 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
883 {
884 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
885 }
886 return None;
889 }
890
891 let search_start = tag_byte_start + opening_tag.len();
893 let search_slice = &content[search_start..];
894
895 let closing_tag_lower = format!("</{tag_name}>");
897 let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
898
899 if let Some(closing_pos) = closing_pos {
900 let closing_tag_len = closing_tag_lower.len();
902 let closing_byte_start = search_start + closing_pos;
903 let closing_byte_end = closing_byte_start + closing_tag_len;
904
905 let inner_content = &content[search_start..closing_byte_start];
907
908 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
915 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
916 return None;
917 }
918 if inner_content.contains('<') {
919 return None;
920 }
921 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
922 }
923
924 if in_html_block {
927 return None;
928 }
929
930 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
933 return None;
934 }
935
936 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
938 if tag_name == "a" {
940 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
941 return Some((tag_byte_start..closing_byte_end, markdown));
942 }
943 return None;
945 }
946
947 if Self::has_significant_attributes(opening_tag) {
949 return None;
952 }
953 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
954 return Some((tag_byte_start..closing_byte_end, markdown));
955 }
956 return None;
959 }
960
961 return None;
964 }
965
966 None
968 }
969}
970
971impl Rule for MD033NoInlineHtml {
972 fn name(&self) -> &'static str {
973 "MD033"
974 }
975
976 fn description(&self) -> &'static str {
977 "Inline HTML is not allowed"
978 }
979
980 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
981 let content = ctx.content;
982
983 if content.is_empty() || !ctx.likely_has_html() {
985 return Ok(Vec::new());
986 }
987
988 if !HTML_TAG_QUICK_CHECK.is_match(content) {
990 return Ok(Vec::new());
991 }
992
993 let mut warnings = Vec::new();
994
995 let html_tags = ctx.html_tags();
997
998 for html_tag in html_tags.iter() {
999 if html_tag.is_closing {
1001 continue;
1002 }
1003
1004 let line_num = html_tag.line;
1005 let tag_byte_start = html_tag.byte_offset;
1006
1007 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1009
1010 if ctx
1012 .line_info(line_num)
1013 .is_some_and(|info| info.in_code_block || info.in_pymdown_block || info.is_kramdown_block_ial)
1014 {
1015 continue;
1016 }
1017
1018 if ctx.is_in_html_comment(tag_byte_start) || ctx.is_in_mdx_comment(tag_byte_start) {
1020 continue;
1021 }
1022
1023 if self.is_html_comment(tag) {
1025 continue;
1026 }
1027
1028 if ctx.is_in_link_title(tag_byte_start) {
1031 continue;
1032 }
1033
1034 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1036 continue;
1037 }
1038
1039 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1041 continue;
1042 }
1043
1044 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1047 continue;
1048 }
1049
1050 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1052 continue;
1053 }
1054
1055 if self.is_likely_type_annotation(tag) {
1057 continue;
1058 }
1059
1060 if self.is_email_address(tag) {
1062 continue;
1063 }
1064
1065 if self.is_url_in_angle_brackets(tag) {
1067 continue;
1068 }
1069
1070 if ctx.is_byte_offset_in_code_span(tag_byte_start) {
1072 continue;
1073 }
1074
1075 if self.is_disallowed_mode() {
1080 if !self.is_tag_disallowed(tag) {
1081 continue;
1082 }
1083 } else if ctx.is_in_table_block(line_num) {
1084 if self.is_tag_allowed_in_table(tag) {
1085 continue;
1086 }
1087 } else if self.is_tag_allowed(tag) {
1088 continue;
1089 }
1090
1091 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1093 continue;
1094 }
1095
1096 let in_html_block = ctx.is_in_html_block(line_num);
1098
1099 let fix = self
1101 .calculate_fix(content, tag, tag_byte_start, in_html_block)
1102 .map(|(range, replacement)| Fix::new(range, replacement));
1103
1104 let (end_line, end_col) = if html_tag.byte_end > 0 {
1107 ctx.offset_to_line_col(html_tag.byte_end - 1)
1108 } else {
1109 (line_num, html_tag.end_col + 1)
1110 };
1111
1112 warnings.push(LintWarning {
1114 rule_name: Some(self.name().to_string()),
1115 line: line_num,
1116 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
1120 severity: Severity::Warning,
1121 fix,
1122 });
1123 }
1124
1125 Ok(warnings)
1126 }
1127
1128 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1129 if !self.config.fix {
1131 return Ok(ctx.content.to_string());
1132 }
1133
1134 let warnings = self.check(ctx)?;
1136 let warnings =
1137 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1138
1139 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1141 return Ok(ctx.content.to_string());
1142 }
1143
1144 let mut fixes: Vec<_> = warnings
1146 .iter()
1147 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1148 .collect();
1149 fixes.sort_by(|a, b| b.0.cmp(&a.0));
1150
1151 let mut result = ctx.content.to_string();
1153 for (start, end, replacement) in fixes {
1154 if start < result.len() && end <= result.len() && start <= end {
1155 result.replace_range(start..end, replacement);
1156 }
1157 }
1158
1159 Ok(result)
1160 }
1161
1162 fn fix_capability(&self) -> crate::rule::FixCapability {
1163 if self.config.fix {
1164 crate::rule::FixCapability::FullyFixable
1165 } else {
1166 crate::rule::FixCapability::Unfixable
1167 }
1168 }
1169
1170 fn category(&self) -> RuleCategory {
1172 RuleCategory::Html
1173 }
1174
1175 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1177 ctx.content.is_empty() || !ctx.likely_has_html()
1178 }
1179
1180 fn as_any(&self) -> &dyn std::any::Any {
1181 self
1182 }
1183
1184 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1185 let table = crate::rule_config_serde::config_schema_table(&self.config)?;
1186 Some((self.name().to_string(), toml::Value::Table(table)))
1187 }
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 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1198 where
1199 Self: Sized,
1200 {
1201 let rule_config = crate::rule_config_serde::load_rule_config::<MD033Config>(config);
1202 Box::new(Self::from_config_struct(rule_config))
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 use crate::lint_context::LintContext;
1210 use crate::rule::Rule;
1211
1212 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1213 let config = MD033Config {
1214 fix: true,
1215 fix_mode: MD033FixMode::Relaxed,
1216 ..MD033Config::default()
1217 };
1218 MD033NoInlineHtml::from_config_struct(config)
1219 }
1220
1221 #[test]
1222 fn test_md033_basic_html() {
1223 let rule = MD033NoInlineHtml::default();
1224 let content = "<div>Some content</div>";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1230 }
1231
1232 #[test]
1233 fn test_md033_case_insensitive() {
1234 let rule = MD033NoInlineHtml::default();
1235 let content = "<DiV>Some <B>content</B></dIv>";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1241 assert_eq!(result[1].message, "Inline HTML found: <B>");
1242 }
1243
1244 #[test]
1245 fn test_md033_allowed_tags() {
1246 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1247 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250 assert_eq!(result.len(), 1);
1252 assert_eq!(result[0].message, "Inline HTML found: <p>");
1253
1254 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1256 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1257 let result2 = rule.check(&ctx2).unwrap();
1258 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1260 }
1261
1262 #[test]
1263 fn test_md033_html_comments() {
1264 let rule = MD033NoInlineHtml::default();
1265 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1271 }
1272
1273 #[test]
1274 fn test_md033_tags_in_links() {
1275 let rule = MD033NoInlineHtml::default();
1276 let content = "[Link](http://example.com/<div>)";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279 assert_eq!(result.len(), 1);
1281 assert_eq!(result[0].message, "Inline HTML found: <div>");
1282
1283 let content2 = "[Link <a>text</a>](url)";
1284 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1285 let result2 = rule.check(&ctx2).unwrap();
1286 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1289 }
1290
1291 #[test]
1292 fn test_md033_fix_escaping() {
1293 let rule = MD033NoInlineHtml::default();
1294 let content = "Text with <div> and <br/> tags.";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let fixed_content = rule.fix(&ctx).unwrap();
1297 assert_eq!(fixed_content, content);
1299 }
1300
1301 #[test]
1302 fn test_md033_in_code_blocks() {
1303 let rule = MD033NoInlineHtml::default();
1304 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1310 }
1311
1312 #[test]
1313 fn test_md033_in_code_spans() {
1314 let rule = MD033NoInlineHtml::default();
1315 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1317 let result = rule.check(&ctx).unwrap();
1318 assert_eq!(result.len(), 1);
1320 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1321 }
1322
1323 #[test]
1324 fn test_md033_issue_90_code_span_with_diff_block() {
1325 let rule = MD033NoInlineHtml::default();
1327 let content = r#"# Heading
1328
1329`<env>`
1330
1331```diff
1332- this
1333+ that
1334```"#;
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1339 }
1340
1341 #[test]
1342 fn test_md033_multiple_code_spans_with_angle_brackets() {
1343 let rule = MD033NoInlineHtml::default();
1345 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1349 }
1350
1351 #[test]
1352 fn test_md033_nested_angle_brackets_in_code_span() {
1353 let rule = MD033NoInlineHtml::default();
1355 let content = "Text with `<<nested>>` brackets";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1359 }
1360
1361 #[test]
1362 fn test_md033_code_span_at_end_before_code_block() {
1363 let rule = MD033NoInlineHtml::default();
1365 let content = "Testing `<test>`\n```\ncode here\n```";
1366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367 let result = rule.check(&ctx).unwrap();
1368 assert_eq!(result.len(), 0, "Should handle code span before code block");
1369 }
1370
1371 #[test]
1372 fn test_md033_quick_fix_inline_tag() {
1373 let rule = MD033NoInlineHtml::default();
1376 let content = "This has <span>inline text</span> that should keep content.";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let result = rule.check(&ctx).unwrap();
1379
1380 assert_eq!(result.len(), 1, "Should find one HTML tag");
1381 assert!(
1383 result[0].fix.is_none(),
1384 "Non-fixable tags like <span> should not have a fix"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_md033_quick_fix_multiline_tag() {
1390 let rule = MD033NoInlineHtml::default();
1393 let content = "<div>\nBlock content\n</div>";
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let result = rule.check(&ctx).unwrap();
1396
1397 assert_eq!(result.len(), 1, "Should find one HTML tag");
1398 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1400 }
1401
1402 #[test]
1403 fn test_md033_quick_fix_self_closing_tag() {
1404 let rule = MD033NoInlineHtml::default();
1406 let content = "Self-closing: <br/>";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409
1410 assert_eq!(result.len(), 1, "Should find one HTML tag");
1411 assert!(
1413 result[0].fix.is_none(),
1414 "Self-closing tags should not have a fix when fix config is false"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_md033_quick_fix_multiple_tags() {
1420 let rule = MD033NoInlineHtml::default();
1423 let content = "<span>first</span> and <strong>second</strong>";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426
1427 assert_eq!(result.len(), 2, "Should find two HTML tags");
1428 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1430 assert!(
1431 result[1].fix.is_none(),
1432 "<strong> should not have a fix when fix config is false"
1433 );
1434 }
1435
1436 #[test]
1437 fn test_md033_skip_angle_brackets_in_link_titles() {
1438 let rule = MD033NoInlineHtml::default();
1440 let content = r#"# Test
1441
1442[example]: <https://example.com> "Title with <Angle Brackets> inside"
1443
1444Regular text with <div>content</div> HTML tag.
1445"#;
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1447 let result = rule.check(&ctx).unwrap();
1448
1449 assert_eq!(result.len(), 1, "Should find opening div tag");
1452 assert!(
1453 result[0].message.contains("<div>"),
1454 "Should flag <div>, got: {}",
1455 result[0].message
1456 );
1457 }
1458
1459 #[test]
1460 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1461 let rule = MD033NoInlineHtml::default();
1463 let content = r#"[ref]: url 'Title <Help Wanted> here'
1464
1465<span>text</span> here
1466"#;
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let result = rule.check(&ctx).unwrap();
1469
1470 assert_eq!(result.len(), 1, "Should find opening span tag");
1473 assert!(
1474 result[0].message.contains("<span>"),
1475 "Should flag <span>, got: {}",
1476 result[0].message
1477 );
1478 }
1479
1480 #[test]
1481 fn test_md033_multiline_tag_end_line_calculation() {
1482 let rule = MD033NoInlineHtml::default();
1484 let content = "<div\n class=\"test\"\n id=\"example\">";
1485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1486 let result = rule.check(&ctx).unwrap();
1487
1488 assert_eq!(result.len(), 1, "Should find one HTML tag");
1489 assert_eq!(result[0].line, 1, "Start line should be 1");
1491 assert_eq!(result[0].end_line, 3, "End line should be 3");
1493 }
1494
1495 #[test]
1496 fn test_md033_single_line_tag_same_start_end_line() {
1497 let rule = MD033NoInlineHtml::default();
1499 let content = "Some text <div class=\"test\"> more text";
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");
1505 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1506 }
1507
1508 #[test]
1509 fn test_md033_multiline_tag_with_many_attributes() {
1510 let rule = MD033NoInlineHtml::default();
1512 let content =
1513 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515 let result = rule.check(&ctx).unwrap();
1516
1517 assert_eq!(result.len(), 1, "Should find one HTML tag");
1518 assert_eq!(result[0].line, 2, "Start line should be 2");
1520 assert_eq!(result[0].end_line, 5, "End line should be 5");
1522 }
1523
1524 #[test]
1525 fn test_md033_disallowed_mode_basic() {
1526 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1528 let content = "<div>Safe content</div><script>alert('xss')</script>";
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 only flag disallowed tags");
1534 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1535 }
1536
1537 #[test]
1538 fn test_md033_disallowed_gfm_security_tags() {
1539 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1541 let content = r#"
1542<div>Safe</div>
1543<title>Bad title</title>
1544<textarea>Bad textarea</textarea>
1545<style>.bad{}</style>
1546<iframe src="evil"></iframe>
1547<script>evil()</script>
1548<plaintext>old tag</plaintext>
1549<span>Safe span</span>
1550"#;
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553
1554 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1557
1558 let flagged_tags: Vec<&str> = result
1559 .iter()
1560 .filter_map(|w| w.message.split('<').nth(1))
1561 .filter_map(|s| s.split('>').next())
1562 .filter_map(|s| s.split_whitespace().next())
1563 .collect();
1564
1565 assert!(flagged_tags.contains(&"title"), "Should flag title");
1566 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1567 assert!(flagged_tags.contains(&"style"), "Should flag style");
1568 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1569 assert!(flagged_tags.contains(&"script"), "Should flag script");
1570 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1571 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1572 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1573 }
1574
1575 #[test]
1576 fn test_md033_disallowed_case_insensitive() {
1577 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1579 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let result = rule.check(&ctx).unwrap();
1582
1583 assert_eq!(result.len(), 2, "Should flag both case variants");
1585 }
1586
1587 #[test]
1588 fn test_md033_disallowed_with_attributes() {
1589 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1591 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 let result = rule.check(&ctx).unwrap();
1594
1595 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1596 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1597 }
1598
1599 #[test]
1600 fn test_md033_disallowed_all_gfm_tags() {
1601 use md033_config::GFM_DISALLOWED_TAGS;
1603 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1604
1605 for tag in GFM_DISALLOWED_TAGS {
1606 let content = format!("<{tag}>content</{tag}>");
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, "GFM tag <{tag}> should be flagged");
1611 }
1612 }
1613
1614 #[test]
1615 fn test_md033_disallowed_mixed_with_custom() {
1616 let rule = MD033NoInlineHtml::with_disallowed(vec![
1618 "gfm".to_string(),
1619 "marquee".to_string(), ]);
1621 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let result = rule.check(&ctx).unwrap();
1624
1625 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1627 }
1628
1629 #[test]
1630 fn test_md033_disallowed_empty_means_default_mode() {
1631 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1633 let content = "<div>content</div>";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let result = rule.check(&ctx).unwrap();
1636
1637 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1639 }
1640
1641 #[test]
1642 fn test_md033_jsx_fragments_in_mdx() {
1643 let rule = MD033NoInlineHtml::default();
1645 let content = r#"# MDX Document
1646
1647<>
1648 <Heading />
1649 <Content />
1650</>
1651
1652<div>Regular HTML should still be flagged</div>
1653"#;
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1655 let result = rule.check(&ctx).unwrap();
1656
1657 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1659 assert!(
1660 result[0].message.contains("<div>"),
1661 "Should flag <div>, not JSX fragments"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_md033_jsx_components_in_mdx() {
1667 let rule = MD033NoInlineHtml::default();
1669 let content = r#"<CustomComponent prop="value">
1670 Content
1671</CustomComponent>
1672
1673<MyButton onClick={handler}>Click</MyButton>
1674"#;
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1680 }
1681
1682 #[test]
1683 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1684 let rule = MD033NoInlineHtml::default();
1686 let content = "<Script>alert(1)</Script>";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let result = rule.check(&ctx).unwrap();
1689
1690 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1692 }
1693
1694 #[test]
1695 fn test_md033_jsx_attributes_in_mdx() {
1696 let rule = MD033NoInlineHtml::default();
1698 let content = r#"# MDX with JSX Attributes
1699
1700<div className="card big">Content</div>
1701
1702<button onClick={handleClick}>Click me</button>
1703
1704<label htmlFor="input-id">Label</label>
1705
1706<input onChange={handleChange} />
1707
1708<div class="html-class">Regular HTML should be flagged</div>
1709"#;
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1711 let result = rule.check(&ctx).unwrap();
1712
1713 assert_eq!(
1715 result.len(),
1716 1,
1717 "Should only flag HTML element without JSX attributes, got: {result:?}"
1718 );
1719 assert!(
1720 result[0].message.contains("<div class="),
1721 "Should flag the div with HTML class attribute"
1722 );
1723 }
1724
1725 #[test]
1726 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1727 let rule = MD033NoInlineHtml::default();
1729 let content = r#"<div className="card">Content</div>"#;
1730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731 let result = rule.check(&ctx).unwrap();
1732
1733 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1735 }
1736
1737 #[test]
1740 fn test_md033_fix_disabled_by_default() {
1741 let rule = MD033NoInlineHtml::default();
1743 assert!(!rule.config.fix, "Fix should be disabled by default");
1744 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1745 }
1746
1747 #[test]
1748 fn test_md033_fix_enabled_em_to_italic() {
1749 let rule = MD033NoInlineHtml::with_fix(true);
1751 let content = "This has <em>emphasized text</em> here.";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753 let fixed = rule.fix(&ctx).unwrap();
1754 assert_eq!(fixed, "This has *emphasized text* here.");
1755 }
1756
1757 #[test]
1758 fn test_md033_fix_enabled_i_to_italic() {
1759 let rule = MD033NoInlineHtml::with_fix(true);
1761 let content = "This has <i>italic text</i> here.";
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763 let fixed = rule.fix(&ctx).unwrap();
1764 assert_eq!(fixed, "This has *italic text* here.");
1765 }
1766
1767 #[test]
1768 fn test_md033_fix_enabled_strong_to_bold() {
1769 let rule = MD033NoInlineHtml::with_fix(true);
1771 let content = "This has <strong>bold text</strong> here.";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let fixed = rule.fix(&ctx).unwrap();
1774 assert_eq!(fixed, "This has **bold text** here.");
1775 }
1776
1777 #[test]
1778 fn test_md033_fix_enabled_b_to_bold() {
1779 let rule = MD033NoInlineHtml::with_fix(true);
1781 let content = "This has <b>bold text</b> here.";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let fixed = rule.fix(&ctx).unwrap();
1784 assert_eq!(fixed, "This has **bold text** here.");
1785 }
1786
1787 #[test]
1788 fn test_md033_fix_enabled_code_to_backticks() {
1789 let rule = MD033NoInlineHtml::with_fix(true);
1791 let content = "This has <code>inline code</code> here.";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let fixed = rule.fix(&ctx).unwrap();
1794 assert_eq!(fixed, "This has `inline code` here.");
1795 }
1796
1797 #[test]
1798 fn test_md033_fix_enabled_code_with_backticks() {
1799 let rule = MD033NoInlineHtml::with_fix(true);
1801 let content = "This has <code>text with `backticks`</code> here.";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let fixed = rule.fix(&ctx).unwrap();
1804 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
1805 }
1806
1807 #[test]
1808 fn test_md033_fix_enabled_br_trailing_spaces() {
1809 let rule = MD033NoInlineHtml::with_fix(true);
1811 let content = "First line<br>Second line";
1812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1813 let fixed = rule.fix(&ctx).unwrap();
1814 assert_eq!(fixed, "First line \nSecond line");
1815 }
1816
1817 #[test]
1818 fn test_md033_fix_enabled_br_self_closing() {
1819 let rule = MD033NoInlineHtml::with_fix(true);
1821 let content = "First<br/>second<br />third";
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823 let fixed = rule.fix(&ctx).unwrap();
1824 assert_eq!(fixed, "First \nsecond \nthird");
1825 }
1826
1827 #[test]
1828 fn test_md033_fix_enabled_br_backslash_style() {
1829 let config = MD033Config {
1831 allowed: Vec::new(),
1832 disallowed: Vec::new(),
1833 fix: true,
1834 br_style: md033_config::BrStyle::Backslash,
1835 ..MD033Config::default()
1836 };
1837 let rule = MD033NoInlineHtml::from_config_struct(config);
1838 let content = "First line<br>Second line";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let fixed = rule.fix(&ctx).unwrap();
1841 assert_eq!(fixed, "First line\\\nSecond line");
1842 }
1843
1844 #[test]
1845 fn test_md033_fix_enabled_hr() {
1846 let rule = MD033NoInlineHtml::with_fix(true);
1848 let content = "Above<hr>Below";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let fixed = rule.fix(&ctx).unwrap();
1851 assert_eq!(fixed, "Above\n---\nBelow");
1852 }
1853
1854 #[test]
1855 fn test_md033_fix_enabled_hr_self_closing() {
1856 let rule = MD033NoInlineHtml::with_fix(true);
1858 let content = "Above<hr/>Below";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let fixed = rule.fix(&ctx).unwrap();
1861 assert_eq!(fixed, "Above\n---\nBelow");
1862 }
1863
1864 #[test]
1865 fn test_md033_fix_skips_nested_tags() {
1866 let rule = MD033NoInlineHtml::with_fix(true);
1869 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let fixed = rule.fix(&ctx).unwrap();
1872 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
1875 }
1876
1877 #[test]
1878 fn test_md033_fix_skips_tags_with_attributes() {
1879 let rule = MD033NoInlineHtml::with_fix(true);
1882 let content = "This has <em class=\"highlight\">emphasized</em> text.";
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let fixed = rule.fix(&ctx).unwrap();
1885 assert_eq!(fixed, content);
1887 }
1888
1889 #[test]
1890 fn test_md033_fix_disabled_no_changes() {
1891 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let fixed = rule.fix(&ctx).unwrap();
1896 assert_eq!(fixed, content, "Should return original content when fix is disabled");
1897 }
1898
1899 #[test]
1900 fn test_md033_fix_capability_enabled() {
1901 let rule = MD033NoInlineHtml::with_fix(true);
1902 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
1903 }
1904
1905 #[test]
1906 fn test_md033_fix_multiple_tags() {
1907 let rule = MD033NoInlineHtml::with_fix(true);
1909 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911 let fixed = rule.fix(&ctx).unwrap();
1912 assert_eq!(fixed, "Here is *italic* and **bold** text.");
1913 }
1914
1915 #[test]
1916 fn test_md033_fix_uppercase_tags() {
1917 let rule = MD033NoInlineHtml::with_fix(true);
1919 let content = "This has <EM>emphasized</EM> text.";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921 let fixed = rule.fix(&ctx).unwrap();
1922 assert_eq!(fixed, "This has *emphasized* text.");
1923 }
1924
1925 #[test]
1926 fn test_md033_fix_unsafe_tags_not_modified() {
1927 let rule = MD033NoInlineHtml::with_fix(true);
1930 let content = "This has <div>a div</div> content.";
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932 let fixed = rule.fix(&ctx).unwrap();
1933 assert_eq!(fixed, "This has <div>a div</div> content.");
1935 }
1936
1937 #[test]
1938 fn test_md033_fix_img_tag_converted() {
1939 let rule = MD033NoInlineHtml::with_fix(true);
1941 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
1942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1943 let fixed = rule.fix(&ctx).unwrap();
1944 assert_eq!(fixed, "Image: ");
1946 }
1947
1948 #[test]
1949 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
1950 let rule = MD033NoInlineHtml::with_fix(true);
1952 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954 let fixed = rule.fix(&ctx).unwrap();
1955 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
1957 }
1958
1959 #[test]
1960 fn test_md033_fix_relaxed_a_with_target_is_converted() {
1961 let rule = relaxed_fix_rule();
1962 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
1963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1964 let fixed = rule.fix(&ctx).unwrap();
1965 assert_eq!(fixed, "Link: [Example](https://example.com)");
1966 }
1967
1968 #[test]
1969 fn test_md033_fix_relaxed_img_with_width_is_converted() {
1970 let rule = relaxed_fix_rule();
1971 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
1972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let fixed = rule.fix(&ctx).unwrap();
1974 assert_eq!(fixed, "Image: ");
1975 }
1976
1977 #[test]
1978 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
1979 let rule = relaxed_fix_rule();
1980 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
1981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982 let fixed = rule.fix(&ctx).unwrap();
1983 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
1984 }
1985
1986 #[test]
1987 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
1988 let rule = relaxed_fix_rule();
1989 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 let fixed = rule.fix(&ctx).unwrap();
1992 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
1993 }
1994
1995 #[test]
1996 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
1997 let rule = relaxed_fix_rule();
1998 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
1999 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000 let fixed_once = rule.fix(&ctx1).unwrap();
2001 assert!(
2002 fixed_once.contains("<p"),
2003 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2004 );
2005 assert!(
2006 fixed_once.contains(""),
2007 "Inner image should be converted on first pass: {fixed_once}"
2008 );
2009
2010 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2011 let fixed_twice = rule.fix(&ctx2).unwrap();
2012 assert!(
2013 !fixed_twice.contains("<p"),
2014 "Second pass should strip configured wrapper: {fixed_twice}"
2015 );
2016 assert!(fixed_twice.contains(""));
2017 }
2018
2019 #[test]
2020 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2021 let rule = relaxed_fix_rule();
2022 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let fixed = rule.fix(&ctx).unwrap();
2025 assert_eq!(fixed, "[Click](https://example.com)");
2026 }
2027
2028 #[test]
2029 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2030 let rule = relaxed_fix_rule();
2031 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2033 let fixed = rule.fix(&ctx).unwrap();
2034 assert_eq!(fixed, "");
2035 }
2036
2037 #[test]
2038 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2039 let rule = relaxed_fix_rule();
2040 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042 let fixed = rule.fix(&ctx).unwrap();
2043 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2044 }
2045
2046 #[test]
2047 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2048 let config = MD033Config {
2050 fix: true,
2051 fix_mode: MD033FixMode::Relaxed,
2052 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2053 ..MD033Config::default()
2054 };
2055 let rule = MD033NoInlineHtml::from_config_struct(config);
2056 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let fixed = rule.fix(&ctx).unwrap();
2059 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2060 }
2061
2062 #[test]
2063 fn test_md033_fix_relaxed_custom_drop_attributes() {
2064 let config = MD033Config {
2065 fix: true,
2066 fix_mode: MD033FixMode::Relaxed,
2067 drop_attributes: vec!["loading".to_string()],
2068 ..MD033Config::default()
2069 };
2070 let rule = MD033NoInlineHtml::from_config_struct(config);
2071 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074 let fixed = rule.fix(&ctx).unwrap();
2075 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2076
2077 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2078 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2079 let fixed2 = rule.fix(&ctx2).unwrap();
2080 assert_eq!(
2081 fixed2, content2,
2082 "Attributes not in custom list should block conversion"
2083 );
2084 }
2085
2086 #[test]
2087 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2088 let config = MD033Config {
2089 fix: true,
2090 fix_mode: MD033FixMode::Relaxed,
2091 strip_wrapper_elements: vec!["div".to_string()],
2092 ..MD033Config::default()
2093 };
2094 let rule = MD033NoInlineHtml::from_config_struct(config);
2095 let content = "<div>Some text content</div>";
2096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2097 let fixed = rule.fix(&ctx).unwrap();
2098 assert_eq!(fixed, "Some text content");
2099 }
2100
2101 #[test]
2102 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2103 let rule = relaxed_fix_rule();
2104 let content = "<p align=\"center\">Just some text</p>";
2105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106 let fixed = rule.fix(&ctx).unwrap();
2107 assert_eq!(fixed, "Just some text");
2108 }
2109
2110 #[test]
2111 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2112 let config = MD033Config {
2113 fix: true,
2114 fix_mode: MD033FixMode::Relaxed,
2115 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2116 ..MD033Config::default()
2117 };
2118 let rule = MD033NoInlineHtml::from_config_struct(config);
2119 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let fixed = rule.fix(&ctx).unwrap();
2122 assert_eq!(fixed, "[Link](https://example.com)");
2123 }
2124
2125 #[test]
2126 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2127 let rule = relaxed_fix_rule();
2128 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2131 let fixed = rule.fix(&ctx).unwrap();
2132 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2133 }
2134
2135 #[test]
2136 fn test_md033_fix_relaxed_badge_pattern() {
2137 let rule = relaxed_fix_rule();
2139 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>";
2140 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2141 let fixed_once = rule.fix(&ctx1).unwrap();
2142 assert!(
2144 fixed_once.contains(""),
2145 "Inner img should be converted: {fixed_once}"
2146 );
2147
2148 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2150 let fixed_twice = rule.fix(&ctx2).unwrap();
2151 assert!(
2152 fixed_twice
2153 .contains("[](https://crates.io/crates/rumdl)"),
2154 "Badge should produce nested markdown image link: {fixed_twice}"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2160 let rule = MD033NoInlineHtml::with_fix(true);
2162 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2164 let fixed = rule.fix(&ctx).unwrap();
2165 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2166 }
2167
2168 #[test]
2169 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2170 let rule = relaxed_fix_rule();
2172 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174 let fixed = rule.fix(&ctx).unwrap();
2175 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2176 }
2177
2178 #[test]
2179 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2180 let rule = relaxed_fix_rule();
2182 let content = "<div><p>text</p></div>";
2183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2184 let fixed = rule.fix(&ctx).unwrap();
2185 assert!(
2186 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2187 "Nested <p> inside <div> should not be stripped: {fixed}"
2188 );
2189 }
2190
2191 #[test]
2192 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2193 let rule = relaxed_fix_rule();
2197 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></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("<img"),
2202 "img inside nested wrapper must not be converted: {fixed}"
2203 );
2204 }
2205
2206 #[test]
2207 fn test_md033_fix_mixed_safe_tags() {
2208 let rule = MD033NoInlineHtml::with_fix(true);
2210 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212 let fixed = rule.fix(&ctx).unwrap();
2213 assert_eq!(fixed, "*italic* and  and **bold**");
2215 }
2216
2217 #[test]
2218 fn test_md033_fix_multiple_tags_same_line() {
2219 let rule = MD033NoInlineHtml::with_fix(true);
2221 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2223 let fixed = rule.fix(&ctx).unwrap();
2224 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2225 }
2226
2227 #[test]
2228 fn test_md033_fix_multiple_em_tags_same_line() {
2229 let rule = MD033NoInlineHtml::with_fix(true);
2231 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233 let fixed = rule.fix(&ctx).unwrap();
2234 assert_eq!(fixed, "*first* and **second** and `third`");
2235 }
2236
2237 #[test]
2238 fn test_md033_fix_skips_tags_inside_pre() {
2239 let rule = MD033NoInlineHtml::with_fix(true);
2241 let content = "<pre><code><em>VALUE</em></code></pre>";
2242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243 let fixed = rule.fix(&ctx).unwrap();
2244 assert!(
2247 !fixed.contains("*VALUE*"),
2248 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2249 );
2250 }
2251
2252 #[test]
2253 fn test_md033_fix_skips_tags_inside_div() {
2254 let rule = MD033NoInlineHtml::with_fix(true);
2256 let content = "<div>\n<em>emphasized</em>\n</div>";
2257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258 let fixed = rule.fix(&ctx).unwrap();
2259 assert!(
2261 !fixed.contains("*emphasized*"),
2262 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_md033_fix_outside_html_block() {
2268 let rule = MD033NoInlineHtml::with_fix(true);
2270 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2272 let fixed = rule.fix(&ctx).unwrap();
2273 assert!(
2275 fixed.contains("*emphasized*"),
2276 "Tags outside HTML blocks should be converted. Got: {fixed}"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_md033_fix_with_id_attribute() {
2282 let rule = MD033NoInlineHtml::with_fix(true);
2284 let content = "See <em id=\"important\">this note</em> for details.";
2285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 let fixed = rule.fix(&ctx).unwrap();
2287 assert_eq!(fixed, content);
2289 }
2290
2291 #[test]
2292 fn test_md033_fix_with_style_attribute() {
2293 let rule = MD033NoInlineHtml::with_fix(true);
2295 let content = "This is <strong style=\"color: red\">important</strong> text.";
2296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2297 let fixed = rule.fix(&ctx).unwrap();
2298 assert_eq!(fixed, content);
2300 }
2301
2302 #[test]
2303 fn test_md033_fix_mixed_with_and_without_attributes() {
2304 let rule = MD033NoInlineHtml::with_fix(true);
2306 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308 let fixed = rule.fix(&ctx).unwrap();
2309 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2311 }
2312
2313 #[test]
2314 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2315 let rule = MD033NoInlineHtml::with_fix(true);
2317 let content = "<em class=\"test\">emphasized</em>";
2318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2319 let result = rule.check(&ctx).unwrap();
2320
2321 assert_eq!(result.len(), 1, "Should find one HTML tag");
2322 assert!(
2324 result[0].fix.is_none(),
2325 "Should NOT have a fix for tags with attributes"
2326 );
2327 }
2328
2329 #[test]
2330 fn test_md033_fix_skips_html_entities() {
2331 let rule = MD033NoInlineHtml::with_fix(true);
2334 let content = "<code>|</code>";
2335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2336 let fixed = rule.fix(&ctx).unwrap();
2337 assert_eq!(fixed, content);
2339 }
2340
2341 #[test]
2342 fn test_md033_fix_skips_multiple_html_entities() {
2343 let rule = MD033NoInlineHtml::with_fix(true);
2345 let content = "<code><T></code>";
2346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2347 let fixed = rule.fix(&ctx).unwrap();
2348 assert_eq!(fixed, content);
2350 }
2351
2352 #[test]
2353 fn test_md033_fix_allows_ampersand_without_entity() {
2354 let rule = MD033NoInlineHtml::with_fix(true);
2356 let content = "<code>a & b</code>";
2357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2358 let fixed = rule.fix(&ctx).unwrap();
2359 assert_eq!(fixed, "`a & b`");
2361 }
2362
2363 #[test]
2364 fn test_md033_fix_em_with_entities_skipped() {
2365 let rule = MD033NoInlineHtml::with_fix(true);
2367 let content = "<em> text</em>";
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369 let fixed = rule.fix(&ctx).unwrap();
2370 assert_eq!(fixed, content);
2372 }
2373
2374 #[test]
2375 fn test_md033_fix_skips_nested_em_in_code() {
2376 let rule = MD033NoInlineHtml::with_fix(true);
2379 let content = "<code><em>n</em></code>";
2380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2381 let fixed = rule.fix(&ctx).unwrap();
2382 assert!(
2385 !fixed.contains("*n*"),
2386 "Nested <em> should not be converted to markdown. Got: {fixed}"
2387 );
2388 }
2389
2390 #[test]
2391 fn test_md033_fix_skips_nested_in_table() {
2392 let rule = MD033NoInlineHtml::with_fix(true);
2394 let content = "| <code>><em>n</em></code> | description |";
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let fixed = rule.fix(&ctx).unwrap();
2397 assert!(
2399 !fixed.contains("*n*"),
2400 "Nested tags in table should not be converted. Got: {fixed}"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_md033_fix_standalone_em_still_converted() {
2406 let rule = MD033NoInlineHtml::with_fix(true);
2408 let content = "This is <em>emphasized</em> text.";
2409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410 let fixed = rule.fix(&ctx).unwrap();
2411 assert_eq!(fixed, "This is *emphasized* text.");
2412 }
2413
2414 #[test]
2426 fn test_md033_templater_basic_interpolation_not_flagged() {
2427 let rule = MD033NoInlineHtml::default();
2430 let content = "Today is <% tp.date.now() %> which is nice.";
2431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2432 let result = rule.check(&ctx).unwrap();
2433 assert!(
2434 result.is_empty(),
2435 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2436 );
2437 }
2438
2439 #[test]
2440 fn test_md033_templater_file_functions_not_flagged() {
2441 let rule = MD033NoInlineHtml::default();
2443 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2445 let result = rule.check(&ctx).unwrap();
2446 assert!(
2447 result.is_empty(),
2448 "Templater file functions should not be flagged. Got: {result:?}"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_md033_templater_with_arguments_not_flagged() {
2454 let rule = MD033NoInlineHtml::default();
2456 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2458 let result = rule.check(&ctx).unwrap();
2459 assert!(
2460 result.is_empty(),
2461 "Templater with arguments should not be flagged. Got: {result:?}"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_md033_templater_javascript_execution_not_flagged() {
2467 let rule = MD033NoInlineHtml::default();
2469 let content = "<%* const today = tp.date.now(); tR += today; %>";
2470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2471 let result = rule.check(&ctx).unwrap();
2472 assert!(
2473 result.is_empty(),
2474 "Templater JS execution block should not be flagged. Got: {result:?}"
2475 );
2476 }
2477
2478 #[test]
2479 fn test_md033_templater_dynamic_execution_not_flagged() {
2480 let rule = MD033NoInlineHtml::default();
2482 let content = "Dynamic: <%+ tp.date.now() %>";
2483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2484 let result = rule.check(&ctx).unwrap();
2485 assert!(
2486 result.is_empty(),
2487 "Templater dynamic execution should not be flagged. Got: {result:?}"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2493 let rule = MD033NoInlineHtml::default();
2495 let content = "<%_ tp.date.now() _%>";
2496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2497 let result = rule.check(&ctx).unwrap();
2498 assert!(
2499 result.is_empty(),
2500 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2506 let rule = MD033NoInlineHtml::default();
2508 let content = "<%- tp.date.now() -%>";
2509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2510 let result = rule.check(&ctx).unwrap();
2511 assert!(
2512 result.is_empty(),
2513 "Templater trim-newline should not be flagged. Got: {result:?}"
2514 );
2515 }
2516
2517 #[test]
2518 fn test_md033_templater_combined_modifiers_not_flagged() {
2519 let rule = MD033NoInlineHtml::default();
2521 let contents = [
2522 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2527 for content in contents {
2528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2529 let result = rule.check(&ctx).unwrap();
2530 assert!(
2531 result.is_empty(),
2532 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2533 );
2534 }
2535 }
2536
2537 #[test]
2538 fn test_md033_templater_multiline_block_not_flagged() {
2539 let rule = MD033NoInlineHtml::default();
2541 let content = r#"<%*
2542const x = 1;
2543const y = 2;
2544tR += x + y;
2545%>"#;
2546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2547 let result = rule.check(&ctx).unwrap();
2548 assert!(
2549 result.is_empty(),
2550 "Templater multi-line block should not be flagged. Got: {result:?}"
2551 );
2552 }
2553
2554 #[test]
2555 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2556 let rule = MD033NoInlineHtml::default();
2559 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2561 let result = rule.check(&ctx).unwrap();
2562 assert!(
2563 result.is_empty(),
2564 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2565 );
2566 }
2567
2568 #[test]
2569 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2570 let rule = MD033NoInlineHtml::default();
2572 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2574 let result = rule.check(&ctx).unwrap();
2575 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2576 assert!(
2577 result[0].message.contains("<div>"),
2578 "Should flag <div>, got: {}",
2579 result[0].message
2580 );
2581 }
2582
2583 #[test]
2584 fn test_md033_templater_in_heading_not_flagged() {
2585 let rule = MD033NoInlineHtml::default();
2587 let content = "# <% tp.file.title %>";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2589 let result = rule.check(&ctx).unwrap();
2590 assert!(
2591 result.is_empty(),
2592 "Templater in heading should not be flagged. Got: {result:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2598 let rule = MD033NoInlineHtml::default();
2600 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2602 let result = rule.check(&ctx).unwrap();
2603 assert!(
2604 result.is_empty(),
2605 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn test_md033_templater_in_code_block_not_flagged() {
2611 let rule = MD033NoInlineHtml::default();
2613 let content = "```\n<% tp.date.now() %>\n```";
2614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2615 let result = rule.check(&ctx).unwrap();
2616 assert!(
2617 result.is_empty(),
2618 "Templater in code block should not be flagged. Got: {result:?}"
2619 );
2620 }
2621
2622 #[test]
2623 fn test_md033_templater_in_inline_code_not_flagged() {
2624 let rule = MD033NoInlineHtml::default();
2626 let content = "Use `<% tp.date.now() %>` for current date.";
2627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2628 let result = rule.check(&ctx).unwrap();
2629 assert!(
2630 result.is_empty(),
2631 "Templater in inline code should not be flagged. Got: {result:?}"
2632 );
2633 }
2634
2635 #[test]
2636 fn test_md033_templater_also_works_in_standard_flavor() {
2637 let rule = MD033NoInlineHtml::default();
2640 let content = "<% tp.date.now() %> works everywhere.";
2641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2642 let result = rule.check(&ctx).unwrap();
2643 assert!(
2644 result.is_empty(),
2645 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2646 );
2647 }
2648
2649 #[test]
2650 fn test_md033_templater_empty_tag_not_flagged() {
2651 let rule = MD033NoInlineHtml::default();
2653 let content = "<%>";
2654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2655 let result = rule.check(&ctx).unwrap();
2656 assert!(
2657 result.is_empty(),
2658 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2659 );
2660 }
2661
2662 #[test]
2663 fn test_md033_templater_unclosed_not_flagged() {
2664 let rule = MD033NoInlineHtml::default();
2666 let content = "<% tp.date.now() without closing tag";
2667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2668 let result = rule.check(&ctx).unwrap();
2669 assert!(
2670 result.is_empty(),
2671 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2672 );
2673 }
2674
2675 #[test]
2676 fn test_md033_templater_with_newlines_inside_not_flagged() {
2677 let rule = MD033NoInlineHtml::default();
2679 let content = r#"<% tp.date.now("YYYY") +
2680"-" +
2681tp.date.now("MM") %>"#;
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 "Templater with internal newlines should not be flagged. Got: {result:?}"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_md033_erb_style_tags_not_flagged() {
2692 let rule = MD033NoInlineHtml::default();
2695 let content = "<%= variable %> and <% code %> and <%# comment %>";
2696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2697 let result = rule.check(&ctx).unwrap();
2698 assert!(
2699 result.is_empty(),
2700 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2701 );
2702 }
2703
2704 #[test]
2705 fn test_md033_templater_complex_expression_not_flagged() {
2706 let rule = MD033NoInlineHtml::default();
2708 let content = r#"<%*
2709const file = tp.file.title;
2710const date = tp.date.now("YYYY-MM-DD");
2711const folder = tp.file.folder();
2712tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2713%>"#;
2714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2715 let result = rule.check(&ctx).unwrap();
2716 assert!(
2717 result.is_empty(),
2718 "Complex Templater expression should not be flagged. Got: {result:?}"
2719 );
2720 }
2721
2722 #[test]
2723 fn test_md033_percent_sign_variations_not_flagged() {
2724 let rule = MD033NoInlineHtml::default();
2726 let patterns = [
2727 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2734 for pattern in patterns {
2735 let content = format!("{pattern} content %>");
2736 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2737 let result = rule.check(&ctx).unwrap();
2738 assert!(
2739 result.is_empty(),
2740 "Pattern {pattern} should not be flagged. Got: {result:?}"
2741 );
2742 }
2743 }
2744
2745 #[test]
2751 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2752 let rule = MD033NoInlineHtml::with_fix(true);
2755 let content = r#"<a href="https://example.com"></a>"#;
2756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2757 let fixed = rule.fix(&ctx).unwrap();
2758
2759 assert_eq!(fixed, "[](https://example.com)",);
2760 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2761 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2762 }
2763
2764 #[test]
2765 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2766 let rule = MD033NoInlineHtml::with_fix(true);
2768 let content =
2769 r#"<a href="https://github.com/repo"></a>"#;
2770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2771 let fixed = rule.fix(&ctx).unwrap();
2772
2773 assert_eq!(
2774 fixed,
2775 "[](https://github.com/repo)"
2776 );
2777 }
2778
2779 #[test]
2780 fn test_md033_fix_img_without_alt_produces_empty_alt() {
2781 let rule = MD033NoInlineHtml::with_fix(true);
2782 let content = r#"<img src="photo.jpg" />"#;
2783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2784 let fixed = rule.fix(&ctx).unwrap();
2785
2786 assert_eq!(fixed, "");
2787 }
2788
2789 #[test]
2790 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
2791 let rule = MD033NoInlineHtml::with_fix(true);
2793 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
2794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2795 let fixed = rule.fix(&ctx).unwrap();
2796
2797 assert!(
2798 fixed.contains(r"\[brackets\]"),
2799 "Plain text brackets should be escaped: {fixed}"
2800 );
2801 }
2802
2803 #[test]
2804 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
2805 let rule = MD033NoInlineHtml::with_fix(true);
2808 let content = r#"<a href="/link"> see [docs]</a>"#;
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810 let fixed = rule.fix(&ctx).unwrap();
2811
2812 assert!(
2814 fixed.contains(r"\[docs\]"),
2815 "Brackets in mixed image+text content should be escaped: {fixed}"
2816 );
2817 }
2818
2819 #[test]
2820 fn test_md033_fix_img_in_a_end_to_end() {
2821 use crate::config::Config;
2824 use crate::fix_coordinator::FixCoordinator;
2825
2826 let rule = MD033NoInlineHtml::with_fix(true);
2827 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2828
2829 let mut content =
2830 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
2831 .to_string();
2832 let config = Config::default();
2833 let coordinator = FixCoordinator::new();
2834
2835 let result = coordinator
2836 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2837 .unwrap();
2838
2839 assert_eq!(
2840 content, "[](https://github.com/org/repo)",
2841 "End-to-end: <a><img></a> should become valid linked image"
2842 );
2843 assert!(result.converged);
2844 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
2845 }
2846
2847 #[test]
2848 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
2849 use crate::config::Config;
2850 use crate::fix_coordinator::FixCoordinator;
2851
2852 let rule = MD033NoInlineHtml::with_fix(true);
2853 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
2854
2855 let mut content =
2856 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
2857 .to_string();
2858 let config = Config::default();
2859 let coordinator = FixCoordinator::new();
2860
2861 let result = coordinator
2862 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
2863 .unwrap();
2864
2865 assert_eq!(
2866 content,
2867 "[](https://github.com/org/repo)",
2868 );
2869 assert!(result.converged);
2870 }
2871
2872 #[test]
2882 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
2883 let config = MD033Config {
2884 allowed: vec!["br".to_string()],
2885 table_allowed_elements: None,
2886 ..MD033Config::default()
2887 };
2888 let rule = MD033NoInlineHtml::from_config_struct(config);
2889 let content = "| col |\n|-----|\n| a<br>b |\n";
2890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2891 let result = rule.check(&ctx).unwrap();
2892 assert!(
2893 result.is_empty(),
2894 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
2895 );
2896 }
2897
2898 #[test]
2899 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
2900 let config = MD033Config {
2901 allowed: vec!["br".to_string()],
2902 table_allowed_elements: Some(Vec::new()),
2903 ..MD033Config::default()
2904 };
2905 let rule = MD033NoInlineHtml::from_config_struct(config);
2906 let content = "| col |\n|-----|\n| a<br>b |\n";
2907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2908 let result = rule.check(&ctx).unwrap();
2909 assert_eq!(
2910 result.len(),
2911 1,
2912 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
2913 );
2914 assert_eq!(result[0].line, 3);
2915 }
2916
2917 #[test]
2918 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
2919 let config = MD033Config {
2920 allowed: vec!["br".to_string()],
2921 table_allowed_elements: Some(vec!["img".to_string()]),
2922 ..MD033Config::default()
2923 };
2924 let rule = MD033NoInlineHtml::from_config_struct(config);
2925 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
2928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2929 let result = rule.check(&ctx).unwrap();
2930 assert_eq!(
2931 result.len(),
2932 1,
2933 "table_allowed should override `allowed` inside tables, got {result:?}"
2934 );
2935 assert!(
2936 result[0].message.contains("br"),
2937 "expected the flagged tag to be <br>, got {:?}",
2938 result[0].message
2939 );
2940 }
2941
2942 #[test]
2943 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
2944 let config = MD033Config {
2945 allowed: vec!["br".to_string()],
2946 table_allowed_elements: Some(Vec::new()),
2947 ..MD033Config::default()
2948 };
2949 let rule = MD033NoInlineHtml::from_config_struct(config);
2950 let content = "Paragraph with <br> tag.\n";
2952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2953 let result = rule.check(&ctx).unwrap();
2954 assert!(
2955 result.is_empty(),
2956 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_md033_table_allowed_kebab_case_parses() {
2962 let toml_str = r#"
2963 allowed-elements = ["br"]
2964 table-allowed-elements = ["img"]
2965 "#;
2966 let config: MD033Config = toml::from_str(toml_str).unwrap();
2967 assert_eq!(config.allowed, vec!["br"]);
2968 assert_eq!(
2969 config.table_allowed_elements.as_deref(),
2970 Some(["img".to_string()].as_slice())
2971 );
2972 }
2973
2974 #[test]
2975 fn test_md033_table_allowed_snake_case_alias_parses() {
2976 let toml_str = r#"
2977 allowed_elements = ["br"]
2978 table_allowed_elements = ["img"]
2979 "#;
2980 let config: MD033Config = toml::from_str(toml_str).unwrap();
2981 assert_eq!(config.allowed, vec!["br"]);
2982 assert_eq!(
2983 config.table_allowed_elements.as_deref(),
2984 Some(["img".to_string()].as_slice())
2985 );
2986 }
2987
2988 #[test]
2989 fn test_md033_table_allowed_default_is_none() {
2990 let cfg = MD033Config::default();
2991 assert!(
2992 cfg.table_allowed_elements.is_none(),
2993 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_md033_table_allowed_case_insensitive() {
2999 let config = MD033Config {
3000 allowed: Vec::new(),
3001 table_allowed_elements: Some(vec!["BR".to_string()]),
3002 ..MD033Config::default()
3003 };
3004 let rule = MD033NoInlineHtml::from_config_struct(config);
3005 let content = "| col |\n|-----|\n| a<br>b |\n";
3006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3007 let result = rule.check(&ctx).unwrap();
3008 assert!(
3009 result.is_empty(),
3010 "table_allowed should be case-insensitive, got {result:?}"
3011 );
3012 }
3013}