1use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext};
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::regex_cache::*;
10use std::collections::{HashMap, HashSet};
11use std::ops::Range;
12
13mod md033_config;
14use crate::utils::html_elements::is_void_element;
15use md033_config::{MD033Config, MD033FixMode, is_permitted_without_markdown_equivalent};
16
17#[derive(Default)]
21struct ClosingTagIndex {
22 positions: HashMap<String, Vec<usize>>,
23}
24
25impl ClosingTagIndex {
26 fn new(content: &str) -> Self {
27 let bytes = content.as_bytes();
28 let mut positions: HashMap<String, Vec<usize>> = HashMap::new();
29 let mut from = 0;
30 while let Some(offset) = content[from..].find("</") {
31 let start = from + offset;
32 let name_start = start + 2;
33 let name_len = bytes[name_start..]
36 .iter()
37 .position(|&b| b == b'>' || b == b'<' || b == b'/' || b.is_ascii_whitespace());
38 match name_len {
39 Some(len) if len > 0 && bytes[name_start + len] == b'>' => {
40 let name = content[name_start..name_start + len].to_ascii_lowercase();
41 positions.entry(name).or_default().push(start);
42 from = name_start + len;
43 }
44 Some(len) => from = name_start + len,
45 None => break,
46 }
47 }
48 Self { positions }
49 }
50
51 fn first_at_or_after(&self, tag_name: &str, offset: usize) -> Option<usize> {
53 let positions = self.positions.get(tag_name)?;
54 positions.get(positions.partition_point(|&p| p < offset)).copied()
55 }
56}
57
58#[derive(Clone)]
59pub struct MD033NoInlineHtml {
60 config: MD033Config,
61 allowed: HashSet<String>,
62 allowed_inside: HashSet<String>,
63 table_allowed: HashSet<String>,
64 disallowed: HashSet<String>,
65 drop_attributes: HashSet<String>,
66 strip_wrapper_elements: HashSet<String>,
67 allows_no_markdown_equivalent: bool,
68 table_allows_no_markdown_equivalent: bool,
69}
70
71struct AllowedInsideIndex {
75 ranges: Vec<Range<usize>>,
76}
77
78impl AllowedInsideIndex {
79 fn new(mut ranges: Vec<Range<usize>>) -> Self {
80 ranges.sort_unstable_by_key(|range| range.start);
81 let mut end = 0;
82 for range in &mut ranges {
83 end = end.max(range.end);
84 range.end = end;
85 }
86 Self { ranges }
87 }
88
89 fn contains(&self, offset: usize) -> bool {
90 let past = self.ranges.partition_point(|range| range.start <= offset);
91 past > 0 && offset < self.ranges[past - 1].end
92 }
93}
94
95impl Default for MD033NoInlineHtml {
96 fn default() -> Self {
97 Self::from_config_struct(MD033Config::default())
98 }
99}
100
101impl MD033NoInlineHtml {
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
107 Self::from_config_struct(MD033Config {
108 allowed: allowed_vec,
109 ..MD033Config::default()
110 })
111 }
112
113 pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
114 Self::from_config_struct(MD033Config {
115 disallowed: disallowed_vec,
116 ..MD033Config::default()
117 })
118 }
119
120 pub fn with_fix(fix: bool) -> Self {
122 Self::from_config_struct(MD033Config {
123 fix,
124 ..MD033Config::default()
125 })
126 }
127
128 pub fn from_config_struct(config: MD033Config) -> Self {
131 let allowed = config.allowed_set();
132 let allowed_inside = config.allowed_inside_set();
133 let table_allowed = config.table_allowed_set();
134 let disallowed = config.disallowed_set();
135 let drop_attributes = config.drop_attributes_set();
136 let strip_wrapper_elements = config.strip_wrapper_elements_set();
137 let allows_no_markdown_equivalent = config.allows_no_markdown_equivalent();
138 let table_allows_no_markdown_equivalent = config.table_allows_no_markdown_equivalent();
139 Self {
140 config,
141 allowed,
142 allowed_inside,
143 table_allowed,
144 disallowed,
145 drop_attributes,
146 strip_wrapper_elements,
147 allows_no_markdown_equivalent,
148 table_allows_no_markdown_equivalent,
149 }
150 }
151
152 #[inline]
156 fn extract_tag_name(tag: &str) -> String {
157 let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
158 trimmed
159 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
160 .next()
161 .unwrap_or("")
162 .to_lowercase()
163 }
164
165 #[inline]
168 fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
169 if set.is_empty() {
170 return false;
171 }
172 set.contains(&Self::extract_tag_name(tag))
173 }
174
175 #[inline]
177 fn is_tag_allowed(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
178 if !self.allows_no_markdown_equivalent {
179 return Self::tag_in_set(&self.allowed, tag);
180 }
181 let name = Self::extract_tag_name(tag);
182 self.allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, false)
183 }
184
185 #[inline]
188 fn is_tag_allowed_in_table(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
189 if !self.table_allows_no_markdown_equivalent {
190 return Self::tag_in_set(&self.table_allowed, tag);
191 }
192 let name = Self::extract_tag_name(tag);
193 self.table_allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, true)
194 }
195
196 #[inline]
199 fn is_void_element(tag_name: &str) -> bool {
200 is_void_element(tag_name)
201 }
202
203 fn is_inert_markup(ctx: &crate::lint_context::LintContext, html_tag: &HtmlTag) -> bool {
211 ctx.line_info(html_tag.line).is_some_and(|info| {
212 info.in_code_block
213 || info.in_pymdown_block
214 || info.is_kramdown_block_ial
215 || info.in_front_matter
216 || info.in_math_block
217 }) || ctx.is_in_html_comment(html_tag.byte_offset)
218 || ctx.is_in_mdx_comment(html_tag.byte_offset)
219 || ctx.is_in_link_title(html_tag.byte_offset)
220 || ctx.is_byte_offset_in_code_span(html_tag.byte_offset)
221 || ctx.is_in_shortcode(html_tag.byte_offset)
222 }
223
224 fn allowed_inside_ranges(&self, ctx: &crate::lint_context::LintContext) -> Vec<Range<usize>> {
230 if self.allowed_inside.is_empty() {
231 return Vec::new();
232 }
233
234 let html_tags = ctx.html_tags();
235 let mut ranges = Vec::new();
236 let mut open: Vec<(&str, usize)> = Vec::new();
237
238 for html_tag in html_tags.iter() {
239 if !self.allowed_inside.contains(&html_tag.tag_name) || Self::is_inert_markup(ctx, html_tag) {
240 continue;
241 }
242 if html_tag.is_closing {
243 if let Some(index) = open.iter().rposition(|(name, _)| *name == html_tag.tag_name) {
246 ranges.extend(open.drain(index..).map(|(_, start)| start..html_tag.byte_end));
247 }
248 } else if !html_tag.is_self_closing && !Self::is_void_element(&html_tag.tag_name) {
249 open.push((html_tag.tag_name.as_str(), html_tag.byte_offset));
250 }
251 }
252
253 ranges.extend(open.into_iter().map(|(_, start)| start..ctx.content.len()));
254 ranges
255 }
256
257 #[inline]
259 fn is_tag_disallowed(&self, tag: &str) -> bool {
260 Self::tag_in_set(&self.disallowed, tag)
261 }
262
263 #[inline]
265 fn is_disallowed_mode(&self) -> bool {
266 self.config.is_disallowed_mode()
267 }
268
269 #[inline]
271 fn is_html_comment(&self, tag: &str) -> bool {
272 tag.starts_with("<!--") && tag.ends_with("-->")
273 }
274
275 #[inline]
280 fn is_html_element_or_custom(tag_name: &str) -> bool {
281 const HTML_ELEMENTS: &[&str] = &[
283 "a",
284 "abbr",
285 "acronym",
286 "address",
287 "applet",
288 "area",
289 "article",
290 "aside",
291 "audio",
292 "b",
293 "base",
294 "basefont",
295 "bdi",
296 "bdo",
297 "big",
298 "blockquote",
299 "body",
300 "br",
301 "button",
302 "canvas",
303 "caption",
304 "center",
305 "cite",
306 "code",
307 "col",
308 "colgroup",
309 "data",
310 "datalist",
311 "dd",
312 "del",
313 "details",
314 "dfn",
315 "dialog",
316 "dir",
317 "div",
318 "dl",
319 "dt",
320 "em",
321 "embed",
322 "fieldset",
323 "figcaption",
324 "figure",
325 "font",
326 "footer",
327 "form",
328 "frame",
329 "frameset",
330 "h1",
331 "h2",
332 "h3",
333 "h4",
334 "h5",
335 "h6",
336 "head",
337 "header",
338 "hgroup",
339 "hr",
340 "html",
341 "i",
342 "iframe",
343 "img",
344 "input",
345 "ins",
346 "isindex",
347 "kbd",
348 "label",
349 "legend",
350 "li",
351 "link",
352 "main",
353 "map",
354 "mark",
355 "marquee",
356 "math",
357 "menu",
358 "meta",
359 "meter",
360 "nav",
361 "noembed",
362 "noframes",
363 "noscript",
364 "object",
365 "ol",
366 "optgroup",
367 "option",
368 "output",
369 "p",
370 "param",
371 "picture",
372 "plaintext",
373 "pre",
374 "progress",
375 "q",
376 "rp",
377 "rt",
378 "ruby",
379 "s",
380 "samp",
381 "script",
382 "search",
383 "section",
384 "select",
385 "slot",
386 "small",
387 "source",
388 "span",
389 "strike",
390 "strong",
391 "style",
392 "sub",
393 "summary",
394 "sup",
395 "svg",
396 "table",
397 "tbody",
398 "td",
399 "template",
400 "textarea",
401 "tfoot",
402 "th",
403 "thead",
404 "time",
405 "title",
406 "tr",
407 "track",
408 "tt",
409 "u",
410 "ul",
411 "var",
412 "video",
413 "wbr",
414 "xmp",
415 ];
416
417 let lower = tag_name.to_ascii_lowercase();
418 if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
419 return true;
420 }
421 tag_name.contains('-')
423 }
424
425 #[inline]
427 fn is_likely_type_annotation(&self, tag: &str) -> bool {
428 const COMMON_TYPES: &[&str] = &[
430 "any",
431 "apiresponse",
432 "array",
433 "bigint",
434 "config",
435 "data",
436 "date",
437 "e",
438 "element",
439 "error",
440 "function",
441 "generator",
442 "item",
443 "iterator",
444 "k",
445 "map",
446 "node",
447 "null",
448 "number",
449 "options",
450 "params",
451 "promise",
452 "regexp",
453 "request",
454 "response",
455 "result",
456 "set",
457 "string",
458 "symbol",
459 "t",
460 "u",
461 "undefined",
462 "userdata",
463 "v",
464 "void",
465 "weakmap",
466 "weakset",
467 ];
468
469 let tag_content = tag
470 .trim_start_matches('<')
471 .trim_end_matches('>')
472 .trim_start_matches('/');
473 let tag_name = tag_content
474 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
475 .next()
476 .unwrap_or("");
477
478 if !tag_content.contains(' ') && !tag_content.contains('=') {
480 let lower = tag_name.to_ascii_lowercase();
481 COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
482 } else {
483 false
484 }
485 }
486
487 #[inline]
489 fn is_email_address(&self, tag: &str) -> bool {
490 let content = tag.trim_start_matches('<').trim_end_matches('>');
491 content.contains('@')
493 && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
494 && content.split('@').count() == 2
495 && content.split('@').all(|part| !part.is_empty())
496 }
497
498 #[inline]
500 fn has_markdown_attribute(&self, tag: &str) -> bool {
501 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
504 }
505
506 #[inline]
513 fn has_jsx_attributes(tag: &str) -> bool {
514 tag.contains("className")
516 || tag.contains("htmlFor")
517 || tag.contains("dangerouslySetInnerHTML")
518 || tag.contains("onClick")
520 || tag.contains("onChange")
521 || tag.contains("onSubmit")
522 || tag.contains("onFocus")
523 || tag.contains("onBlur")
524 || tag.contains("onKeyDown")
525 || tag.contains("onKeyUp")
526 || tag.contains("onKeyPress")
527 || tag.contains("onMouseDown")
528 || tag.contains("onMouseUp")
529 || tag.contains("onMouseEnter")
530 || tag.contains("onMouseLeave")
531 || tag.contains("={")
533 }
534
535 #[inline]
537 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
538 let content = tag.trim_start_matches('<').trim_end_matches('>');
539 content.starts_with("http://")
541 || content.starts_with("https://")
542 || content.starts_with("ftp://")
543 || content.starts_with("ftps://")
544 || content.starts_with("mailto:")
545 }
546
547 #[inline]
548 fn is_relaxed_fix_mode(&self) -> bool {
549 self.config.fix_mode == MD033FixMode::Relaxed
550 }
551
552 #[inline]
553 fn is_droppable_attribute(&self, attr_name: &str) -> bool {
554 if attr_name.starts_with("on") && attr_name.len() > 2 {
557 return false;
558 }
559 self.drop_attributes.contains(attr_name)
560 || (attr_name.starts_with("data-")
561 && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
562 }
563
564 #[inline]
565 fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
566 self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
567 }
568
569 fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
580 if byte_offset == 0 {
581 return false;
582 }
583 let before = content[..byte_offset].trim_end();
584 if !before.ends_with('>') || before.ends_with("->") {
585 return false;
586 }
587 if let Some(last_lt) = before.rfind('<') {
588 let potential_tag = &before[last_lt..];
589 if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
590 return false;
591 }
592 let parent_name = potential_tag
593 .trim_start_matches('<')
594 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
595 .next()
596 .unwrap_or("")
597 .to_lowercase();
598 if !self.strip_wrapper_elements.contains(&parent_name) {
599 return false;
600 }
601 let wrapper_before = before[..last_lt].trim_end();
603 if wrapper_before.ends_with('>')
604 && !wrapper_before.ends_with("->")
605 && let Some(outer_lt) = wrapper_before.rfind('<')
606 && let outer_tag = &wrapper_before[outer_lt..]
607 && !outer_tag.starts_with("</")
608 && !outer_tag.starts_with("<!--")
609 {
610 return false;
611 }
612 return true;
613 }
614 false
615 }
616
617 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
620 if inner_content.contains('<') {
622 return None;
623 }
624 if inner_content.contains('&') && inner_content.contains(';') {
627 let has_entity = inner_content
629 .split('&')
630 .skip(1)
631 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
632 if has_entity {
633 return None;
634 }
635 }
636 match tag_name {
637 "em" | "i" => Some(format!("*{inner_content}*")),
638 "strong" | "b" => Some(format!("**{inner_content}**")),
639 "code" => {
640 if inner_content.contains('`') {
642 Some(format!("`` {inner_content} ``"))
643 } else {
644 Some(format!("`{inner_content}`"))
645 }
646 }
647 _ => None,
648 }
649 }
650
651 fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
653 match tag_name {
654 "br" => match self.config.br_style {
655 md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
656 md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
657 },
658 "hr" => Some("\n---\n".to_string()),
659 "img" => self.convert_img_to_markdown(opening_tag),
660 _ => None,
661 }
662 }
663
664 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
667 let mut attrs = Vec::new();
668
669 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
671
672 let attr_start = tag_content
676 .char_indices()
677 .find(|(_, c)| c.is_whitespace())
678 .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
679
680 if attr_start >= tag_content.len() {
681 return attrs;
682 }
683
684 let attr_str = &tag_content[attr_start..];
685 let mut chars = attr_str.chars().peekable();
686
687 while chars.peek().is_some() {
688 while chars.peek().is_some_and(|c| c.is_whitespace()) {
690 chars.next();
691 }
692
693 if chars.peek().is_none() {
694 break;
695 }
696
697 let mut attr_name = String::new();
699 while let Some(&c) = chars.peek() {
700 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
701 break;
702 }
703 attr_name.push(c);
704 chars.next();
705 }
706
707 if attr_name.is_empty() {
708 break;
709 }
710
711 while chars.peek().is_some_and(|c| c.is_whitespace()) {
713 chars.next();
714 }
715
716 if chars.peek() == Some(&'=') {
718 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
722 chars.next();
723 }
724
725 let mut value = String::new();
727 if let Some("e) = chars.peek() {
728 if quote == '"' || quote == '\'' {
729 chars.next(); for c in chars.by_ref() {
731 if c == quote {
732 break;
733 }
734 value.push(c);
735 }
736 } else {
737 while let Some(&c) = chars.peek() {
739 if c.is_whitespace() || c == '>' || c == '/' {
740 break;
741 }
742 value.push(c);
743 chars.next();
744 }
745 }
746 }
747 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
748 } else {
749 attrs.push((attr_name.to_ascii_lowercase(), None));
751 }
752 }
753
754 attrs
755 }
756
757 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
761 let attrs = Self::parse_attributes(tag);
762 let attr_lower = attr_name.to_ascii_lowercase();
763
764 attrs
765 .into_iter()
766 .find(|(name, _)| name == &attr_lower)
767 .and_then(|(_, value)| value)
768 }
769
770 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
773 let attrs = Self::parse_attributes(tag);
774
775 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
779 "class",
780 "id",
781 "style",
782 "target",
783 "rel",
784 "download",
785 "referrerpolicy",
786 "crossorigin",
787 "loading",
788 "decoding",
789 "fetchpriority",
790 "sizes",
791 "srcset",
792 "usemap",
793 "ismap",
794 "width",
795 "height",
796 "name", "data-*", ];
799
800 for (attr_name, _) in attrs {
801 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
803 continue;
804 }
805
806 if self.is_relaxed_fix_mode() {
807 if self.is_droppable_attribute(&attr_name) {
808 continue;
809 }
810 return true;
811 }
812
813 for prefix in DANGEROUS_ATTR_PREFIXES {
815 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
816 return true;
817 }
818 }
819
820 if attr_name.starts_with("data-") {
822 return true;
823 }
824
825 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
827 return true;
828 }
829 }
830
831 false
832 }
833
834 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
837 let href = Self::extract_attribute(opening_tag, "href")?;
839
840 if !MD033Config::is_safe_url(&href) {
842 return None;
843 }
844
845 if inner_content.contains('<') {
847 return None;
848 }
849
850 if inner_content.contains('&') && inner_content.contains(';') {
852 let has_entity = inner_content
853 .split('&')
854 .skip(1)
855 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
856 if has_entity {
857 return None;
858 }
859 }
860
861 let title = Self::extract_attribute(opening_tag, "title");
863
864 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
866 return None;
867 }
868
869 let trimmed_inner = inner_content.trim();
874 let is_markdown_image =
875 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
876 if let Some(bracket_close) = trimmed_inner.rfind("](") {
879 let after_paren = &trimmed_inner[bracket_close + 2..];
880 after_paren.ends_with(')')
882 && after_paren.chars().filter(|&c| c == ')').count()
883 >= after_paren.chars().filter(|&c| c == '(').count()
884 } else {
885 false
886 }
887 };
888 let escaped_text = if is_markdown_image {
889 trimmed_inner.to_string()
890 } else {
891 inner_content.replace('[', r"\[").replace(']', r"\]")
894 };
895
896 let escaped_url = href.replace('(', "%28").replace(')', "%29");
898
899 if let Some(title_text) = title {
901 let escaped_title = title_text.replace('"', r#"\""#);
903 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
904 } else {
905 Some(format!("[{escaped_text}]({escaped_url})"))
906 }
907 }
908
909 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
912 let src = Self::extract_attribute(tag, "src")?;
914
915 if !MD033Config::is_safe_url(&src) {
917 return None;
918 }
919
920 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
922
923 let title = Self::extract_attribute(tag, "title");
925
926 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
928 return None;
929 }
930
931 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
933
934 let escaped_url = src.replace('(', "%28").replace(')', "%29");
936
937 if let Some(title_text) = title {
939 let escaped_title = title_text.replace('"', r#"\""#);
941 Some(format!(""))
942 } else {
943 Some(format!(""))
944 }
945 }
946
947 fn has_significant_attributes(opening_tag: &str) -> bool {
949 let tag_content = opening_tag
951 .trim_start_matches('<')
952 .trim_end_matches('>')
953 .trim_end_matches('/');
954
955 let parts: Vec<&str> = tag_content.split_whitespace().collect();
957 parts.len() > 1
958 }
959
960 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
963 if tag_byte_start > 0 {
965 let before = &content[..tag_byte_start];
966 let before_trimmed = before.trim_end();
967 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
968 if let Some(last_lt) = before_trimmed.rfind('<') {
970 let potential_tag = &before_trimmed[last_lt..];
971 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
973 return true;
974 }
975 }
976 }
977 }
978 if tag_byte_end < content.len() {
980 let after = &content[tag_byte_end..];
981 let after_trimmed = after.trim_start();
982 if after_trimmed.starts_with("</") {
983 return true;
984 }
985 }
986 false
987 }
988
989 fn is_nested_in_html_block(ctx: &LintContext, start: usize, end: usize) -> bool {
992 if let Some((block_start, block_end)) = ctx.html_block_range_at(start) {
993 let block = &ctx.content[block_start..block_end];
994 let trimmed_start = block_start + block.len() - block.trim_start_matches([' ', '\t', '\r', '\n']).len();
995 let trimmed_end = block_end - (block.len() - block.trim_end_matches([' ', '\t', '\r', '\n']).len());
996 start != trimmed_start || end != trimmed_end
997 } else {
998 ctx.is_in_html_block(ctx.offset_to_line_col(start).0)
1001 }
1002 }
1003
1004 fn is_html_block_in_markdown_container(ctx: &LintContext, start: usize) -> bool {
1006 let Some((block_start, _)) = ctx.html_block_range_at(start) else {
1007 return false;
1008 };
1009 let line_num = ctx.offset_to_line_col(block_start).0;
1010 let Some(line) = ctx.line_info(line_num) else {
1011 return true;
1012 };
1013 line.blockquote.is_some()
1014 || line.in_list_block
1015 || line.in_footnote_definition
1016 || ctx.is_in_definition_list(line_num)
1017 || ctx.in_colon_definition(line_num)
1018 || line.in_flavor_container()
1019 || ctx.content[line.byte_offset..block_start]
1020 .bytes()
1021 .any(|b| !b.is_ascii_whitespace())
1022 }
1023
1024 fn block_conversion_preserves_text(markdown: &str, tag_name: &str, expected: &str) -> bool {
1027 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
1028 if matches!(tag_name, "em" | "i" | "strong" | "b") && expected.contains(['[', ']']) {
1031 return false;
1032 }
1033 let mut events = Parser::new_ext(
1034 markdown,
1035 crate::utils::parser_options::rumdl_parser_options_for(markdown),
1036 );
1037 if events.next() != Some(Event::Start(Tag::Paragraph)) {
1038 return false;
1039 }
1040 if tag_name == "code" {
1041 return events
1042 .next()
1043 .is_some_and(|e| matches!(e, Event::Code(text) if text.as_ref() == expected))
1044 && events.next() == Some(Event::End(TagEnd::Paragraph))
1045 && events.next().is_none();
1046 }
1047 let end = match (tag_name, events.next()) {
1048 ("em" | "i", Some(Event::Start(Tag::Emphasis))) => TagEnd::Emphasis,
1049 ("strong" | "b", Some(Event::Start(Tag::Strong))) => TagEnd::Strong,
1050 ("a", Some(Event::Start(Tag::Link { .. }))) => TagEnd::Link,
1051 _ => return false,
1052 };
1053 let mut text = String::new();
1054 for event in events.by_ref() {
1055 match event {
1056 Event::Text(part) => text.push_str(&part),
1057 Event::SoftBreak => text.push('\n'),
1058 Event::End(found) if found == end => {
1059 return text == expected
1060 && events.next() == Some(Event::End(TagEnd::Paragraph))
1061 && events.next().is_none();
1062 }
1063 _ => return false,
1064 }
1065 }
1066 false
1067 }
1068
1069 fn calculate_fix(
1084 &self,
1085 ctx: &LintContext,
1086 closing_tags: &ClosingTagIndex,
1087 opening_tag: &str,
1088 tag_byte_start: usize,
1089 ) -> Option<(std::ops::Range<usize>, String)> {
1090 let fix = self.calculate_fix_candidate(ctx, closing_tags, opening_tag, tag_byte_start)?;
1091 if Self::is_html_block_in_markdown_container(ctx, tag_byte_start) {
1092 let (start, end) = ctx.html_block_range_at(tag_byte_start)?;
1093 if ctx.content[start..end]
1097 .trim_end_matches([' ', '\t', '\r', '\n'])
1098 .contains(['\n', '\r'])
1099 || fix.1.contains(['\n', '\r'])
1100 {
1101 return None;
1102 }
1103 }
1104 Some(fix)
1105 }
1106
1107 fn calculate_fix_candidate(
1108 &self,
1109 ctx: &LintContext,
1110 closing_tags: &ClosingTagIndex,
1111 opening_tag: &str,
1112 tag_byte_start: usize,
1113 ) -> Option<(std::ops::Range<usize>, String)> {
1114 if !self.config.fix {
1116 return None;
1117 }
1118
1119 let content = ctx.content;
1120 let tag_name = opening_tag
1122 .trim_start_matches('<')
1123 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
1124 .next()?
1125 .to_lowercase();
1126
1127 let is_self_closing =
1129 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
1130
1131 if is_self_closing {
1132 let block_ok = !Self::is_nested_in_html_block(ctx, tag_byte_start, tag_byte_start + opening_tag.len())
1138 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
1139 if self.config.fix
1140 && MD033Config::is_safe_fixable_tag(&tag_name)
1141 && block_ok
1142 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
1143 {
1144 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
1145 }
1146 return None;
1149 }
1150
1151 let search_start = tag_byte_start + opening_tag.len();
1153 if let Some(closing_byte_start) = closing_tags.first_at_or_after(&tag_name, search_start) {
1154 let closing_byte_end = closing_byte_start + "</>".len() + tag_name.len();
1155
1156 let inner_content = &content[search_start..closing_byte_start];
1158
1159 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
1166 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1167 return None;
1168 }
1169 if inner_content.contains('<') {
1170 return None;
1171 }
1172 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
1173 }
1174
1175 if Self::is_nested_in_html_block(ctx, tag_byte_start, closing_byte_end) {
1178 return None;
1179 }
1180 let owns_block = ctx.html_block_range_at(tag_byte_start).is_some();
1181 let boundary_text = inner_content.trim_matches([' ', '\t', '\r']);
1182 if !owns_block && (boundary_text.starts_with('\n') || boundary_text.ends_with('\n')) {
1183 return None;
1184 }
1185 let markdown_inner = if owns_block {
1187 inner_content.trim_matches([' ', '\t', '\r', '\n'])
1188 } else {
1189 inner_content
1190 };
1191
1192 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1195 return None;
1196 }
1197
1198 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
1200 if tag_name == "a" {
1202 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, markdown_inner) {
1203 if owns_block && !Self::block_conversion_preserves_text(&markdown, &tag_name, markdown_inner) {
1204 return None;
1205 }
1206 return Some((tag_byte_start..closing_byte_end, markdown));
1207 }
1208 return None;
1210 }
1211
1212 if Self::has_significant_attributes(opening_tag) {
1214 return None;
1217 }
1218 if let Some(markdown) = Self::convert_to_markdown(&tag_name, markdown_inner) {
1219 if owns_block && !Self::block_conversion_preserves_text(&markdown, &tag_name, markdown_inner) {
1220 return None;
1221 }
1222 return Some((tag_byte_start..closing_byte_end, markdown));
1223 }
1224 return None;
1227 }
1228
1229 return None;
1232 }
1233
1234 None
1236 }
1237}
1238
1239impl Rule for MD033NoInlineHtml {
1240 fn name(&self) -> &'static str {
1241 "MD033"
1242 }
1243
1244 fn description(&self) -> &'static str {
1245 "Inline HTML is not allowed"
1246 }
1247
1248 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1249 let content = ctx.content;
1250
1251 if content.is_empty() || !ctx.likely_has_html() {
1253 return Ok(Vec::new());
1254 }
1255
1256 if !HTML_TAG_QUICK_CHECK.is_match(content) {
1258 return Ok(Vec::new());
1259 }
1260
1261 let mut warnings = Vec::new();
1262
1263 let html_tags = ctx.html_tags();
1265
1266 let allowed_inside = AllowedInsideIndex::new(if self.is_disallowed_mode() {
1268 Vec::new()
1269 } else {
1270 self.allowed_inside_ranges(ctx)
1271 });
1272
1273 let closing_tags = if self.config.fix {
1274 ClosingTagIndex::new(content)
1275 } else {
1276 ClosingTagIndex::default()
1277 };
1278
1279 for html_tag in html_tags.iter() {
1280 if html_tag.is_closing {
1282 continue;
1283 }
1284
1285 let line_num = html_tag.line;
1286 let tag_byte_start = html_tag.byte_offset;
1287
1288 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1290
1291 if Self::is_inert_markup(ctx, html_tag) {
1295 continue;
1296 }
1297
1298 if self.is_html_comment(tag) {
1300 continue;
1301 }
1302
1303 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1305 continue;
1306 }
1307
1308 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1310 continue;
1311 }
1312
1313 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1316 continue;
1317 }
1318
1319 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1321 continue;
1322 }
1323
1324 if self.is_likely_type_annotation(tag) {
1326 continue;
1327 }
1328
1329 if self.is_email_address(tag) {
1331 continue;
1332 }
1333
1334 if self.is_url_in_angle_brackets(tag) {
1336 continue;
1337 }
1338
1339 if self.is_disallowed_mode() {
1345 if !self.is_tag_disallowed(tag) {
1346 continue;
1347 }
1348 } else if ctx.is_in_table_block(line_num) {
1349 let inside_allowed_element =
1352 self.config.table_allowed_elements.is_none() && allowed_inside.contains(tag_byte_start);
1353 if self.is_tag_allowed_in_table(tag, ctx.flavor) || inside_allowed_element {
1354 continue;
1355 }
1356 } else if self.is_tag_allowed(tag, ctx.flavor) || allowed_inside.contains(tag_byte_start) {
1357 continue;
1358 }
1359
1360 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1362 continue;
1363 }
1364
1365 let fix = self
1367 .calculate_fix(ctx, &closing_tags, tag, tag_byte_start)
1368 .map(|(range, replacement)| Fix::new(range, replacement));
1369
1370 let mut end_line = line_num;
1374 let mut end_column = html_tag.start_col + 1;
1375 for ch in tag.chars() {
1376 if ch == '\n' {
1377 end_line += 1;
1378 end_column = 1;
1379 } else {
1380 end_column += 1;
1381 }
1382 }
1383
1384 warnings.push(LintWarning {
1386 rule_name: Some(self.name().to_string()),
1387 line: line_num,
1388 column: html_tag.start_col + 1, end_line,
1390 end_column,
1391 message: format!("Inline HTML found: {tag}"),
1392 severity: Severity::Warning,
1393 fix,
1394 });
1395 }
1396
1397 Ok(warnings)
1398 }
1399
1400 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1401 if !self.config.fix {
1403 return Ok(ctx.content.to_string());
1404 }
1405
1406 let warnings = self.check(ctx)?;
1408 let warnings =
1409 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1410
1411 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1413 return Ok(ctx.content.to_string());
1414 }
1415
1416 let mut fixes: Vec<_> = warnings
1418 .iter()
1419 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1420 .collect();
1421 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1422
1423 let mut result = ctx.content.to_string();
1425 for (start, end, replacement) in fixes {
1426 if start < result.len() && end <= result.len() && start <= end {
1427 result.replace_range(start..end, replacement);
1428 }
1429 }
1430
1431 Ok(result)
1432 }
1433
1434 fn fix_capability(&self) -> crate::rule::FixCapability {
1435 if self.config.fix {
1436 crate::rule::FixCapability::FullyFixable
1437 } else {
1438 crate::rule::FixCapability::Unfixable
1439 }
1440 }
1441
1442 fn category(&self) -> RuleCategory {
1444 RuleCategory::Html
1445 }
1446
1447 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1449 ctx.content.is_empty() || !ctx.likely_has_html()
1450 }
1451
1452 fn as_any(&self) -> &dyn std::any::Any {
1453 self
1454 }
1455
1456 crate::impl_rule_config_methods!(MD033Config);
1457
1458 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1459 let mut aliases = std::collections::HashMap::new();
1460 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1462 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1463 aliases.insert("table-allowed".to_string(), "table-allowed-elements".to_string());
1466 aliases.insert("table_allowed".to_string(), "table-allowed-elements".to_string());
1467 Some(aliases)
1468 }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473 use super::*;
1474 use crate::lint_context::LintContext;
1475 use crate::rule::Rule;
1476
1477 #[test]
1478 fn allowed_inside_index_preserves_nested_ranges_and_boundaries() {
1479 for ranges in [
1480 vec![],
1481 vec![5..20, 8..10, 0..2, 5..7, 20..22, 30..40],
1482 vec![0..usize::MAX, 10..11],
1483 ] {
1484 let index = AllowedInsideIndex::new(ranges.clone());
1485 for offset in (0..=45).chain([usize::MAX - 1, usize::MAX]) {
1486 assert_eq!(
1487 index.contains(offset),
1488 ranges.iter().any(|range| range.contains(&offset)),
1489 "offset {offset}, ranges {ranges:?}"
1490 );
1491 }
1492 }
1493 }
1494
1495 proptest::proptest! {
1496 #![proptest_config(proptest::test_runner::Config::with_cases(128))]
1497 #[test]
1498 fn allowed_inside_index_matches_linear_search(
1499 endpoints in proptest::collection::vec((0_usize..256, 0_usize..256), 0..80)
1500 ) {
1501 let ranges: Vec<_> = endpoints.into_iter().map(|(a, b)| a.min(b)..a.max(b)).collect();
1502 let index = AllowedInsideIndex::new(ranges.clone());
1503 for offset in 0..=256 {
1504 proptest::prop_assert_eq!(
1505 index.contains(offset),
1506 ranges.iter().any(|range| range.contains(&offset))
1507 );
1508 }
1509 }
1510 }
1511
1512 #[test]
1513 fn closing_tag_index_finds_what_a_case_insensitive_search_finds() {
1514 let documents = [
1515 "<em>a</em> b </EM> </Em>",
1516 "</</em> <//em> </ em> </em > </em/> </>",
1517 "x</em",
1518 "</em></em></strong></em>",
1519 "</span-x></span></span:y>",
1520 "</é></É></em>",
1521 "",
1522 ];
1523 for content in documents {
1524 let index = ClosingTagIndex::new(content);
1525 for tag_name in ["em", "strong", "span", "span-x", "span:y", "é"] {
1526 let needle = format!("</{tag_name}>");
1527 let lowered = content.to_ascii_lowercase();
1528 for offset in 0..=content.len() {
1529 if !content.is_char_boundary(offset) {
1530 continue;
1531 }
1532 let expected = lowered[offset..].find(&needle).map(|pos| offset + pos);
1533 assert_eq!(
1534 index.first_at_or_after(tag_name, offset),
1535 expected,
1536 "{tag_name:?} from {offset} in {content:?}"
1537 );
1538 }
1539 }
1540 }
1541 }
1542
1543 #[test]
1544 fn diagnostic_ends_match_source_positions() {
1545 let prefix = "é日本🙂e\u{301}\t\r".repeat(80);
1548 for newline in ["\n", "\r\n"] {
1549 for body in [
1550 format!("{prefix}<span title=\"é > 🙂\">x</span> <img src=\"日本\"/>"),
1551 format!("{prefix}<span\n title=\"é >\n🙂\">x</span> <br/>"),
1552 format!("{prefix}<span title=\"é\r🙂\">x</span>"),
1553 format!("<span title=\"first\n{prefix}last\">x</span>"),
1554 ] {
1555 let content = body.replace('\n', newline);
1556 for flavor in crate::format_proof::ALL_FLAVORS {
1557 let ctx = LintContext::new(&content, flavor, None);
1558 let warnings = MD033NoInlineHtml::default().check(&ctx).unwrap();
1559 let tags = ctx.html_tags();
1560 assert!(!warnings.is_empty(), "{flavor:?}: {content:?}");
1561 for warning in warnings {
1562 let tag = tags
1563 .iter()
1564 .find(|tag| tag.line == warning.line && tag.start_col + 1 == warning.column)
1565 .unwrap();
1566 let (end_line, end_col) = ctx.offset_to_line_col(tag.byte_end - 1);
1569 assert_eq!(
1570 (warning.end_line, warning.end_column),
1571 (end_line, end_col + 1),
1572 "{flavor:?}: {content:?}"
1573 );
1574 }
1575 }
1576 }
1577 }
1578 }
1579
1580 #[test]
1581 fn check_without_fix_enabled_offers_no_fixes() {
1582 let content = "Text <em>x</em> and <br/> and <p>y</p>\n";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584 let warnings = MD033NoInlineHtml::default().check(&ctx).unwrap();
1585 assert!(!warnings.is_empty());
1586 assert!(warnings.iter().all(|w| w.fix.is_none()));
1587 }
1588
1589 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1590 let config = MD033Config {
1591 fix: true,
1592 fix_mode: MD033FixMode::Relaxed,
1593 ..MD033Config::default()
1594 };
1595 MD033NoInlineHtml::from_config_struct(config)
1596 }
1597
1598 #[test]
1599 fn test_md033_basic_html() {
1600 let rule = MD033NoInlineHtml::default();
1601 let content = "<div>Some content</div>";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603 let result = rule.check(&ctx).unwrap();
1604 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1607 }
1608
1609 #[test]
1610 fn test_md033_ignores_backslash_escaped_custom_element() {
1611 let rule = MD033NoInlineHtml::default();
1612 let content = r"Press \<x-keyboard> to continue, but flag <x-keyboard>.";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614
1615 let result = rule.check(&ctx).unwrap();
1616 assert_eq!(result.len(), 1);
1617 assert_eq!(result[0].column, 43);
1618 assert_eq!(result[0].message, "Inline HTML found: <x-keyboard>");
1619 }
1620
1621 #[test]
1622 fn test_md033_front_matter() {
1623 let rule = MD033NoInlineHtml::default();
1624 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627 assert_eq!(result.len(), 1);
1629 assert_eq!(result[0].line, 5);
1630 assert_eq!(result[0].message, "Inline HTML found: <div>");
1631 }
1632
1633 #[test]
1634 fn test_md033_math_block() {
1635 let rule = MD033NoInlineHtml::default();
1636 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let result = rule.check(&ctx).unwrap();
1639 assert_eq!(result.len(), 1);
1641 assert_eq!(result[0].line, 4);
1642 }
1643
1644 #[test]
1645 fn test_md033_case_insensitive() {
1646 let rule = MD033NoInlineHtml::default();
1647 let content = "<DiV>Some <B>content</B></dIv>";
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649 let result = rule.check(&ctx).unwrap();
1650 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1653 assert_eq!(result[1].message, "Inline HTML found: <B>");
1654 }
1655
1656 #[test]
1657 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1658 let rule = relaxed_fix_rule();
1661 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663 let _ = rule.check(&ctx).unwrap();
1665 let _ = rule.fix(&ctx).unwrap();
1666 }
1667
1668 #[test]
1669 fn test_md033_allowed_tags() {
1670 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1671 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let result = rule.check(&ctx).unwrap();
1674 assert_eq!(result.len(), 1);
1676 assert_eq!(result[0].message, "Inline HTML found: <p>");
1677
1678 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1680 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1681 let result2 = rule.check(&ctx2).unwrap();
1682 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1684 }
1685
1686 #[test]
1687 fn test_md033_html_comments() {
1688 let rule = MD033NoInlineHtml::default();
1689 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691 let result = rule.check(&ctx).unwrap();
1692 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1695 }
1696
1697 #[test]
1698 fn test_md033_tags_in_links() {
1699 let rule = MD033NoInlineHtml::default();
1700 let content = "[Link](http://example.com/<div>)";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703 assert_eq!(result.len(), 1);
1705 assert_eq!(result[0].message, "Inline HTML found: <div>");
1706
1707 let content2 = "[Link <a>text</a>](url)";
1708 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1709 let result2 = rule.check(&ctx2).unwrap();
1710 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1713 }
1714
1715 #[test]
1716 fn test_md033_fix_escaping() {
1717 let rule = MD033NoInlineHtml::default();
1718 let content = "Text with <div> and <br/> tags.";
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let fixed_content = rule.fix(&ctx).unwrap();
1721 assert_eq!(fixed_content, content);
1723 }
1724
1725 #[test]
1726 fn test_md033_in_code_blocks() {
1727 let rule = MD033NoInlineHtml::default();
1728 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1730 let result = rule.check(&ctx).unwrap();
1731 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1734 }
1735
1736 #[test]
1737 fn test_md033_in_code_spans() {
1738 let rule = MD033NoInlineHtml::default();
1739 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let result = rule.check(&ctx).unwrap();
1742 assert_eq!(result.len(), 1);
1744 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1745 }
1746
1747 #[test]
1748 fn test_md033_issue_90_code_span_with_diff_block() {
1749 let rule = MD033NoInlineHtml::default();
1751 let content = r#"# Heading
1752
1753`<env>`
1754
1755```diff
1756- this
1757+ that
1758```"#;
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let result = rule.check(&ctx).unwrap();
1761 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1763 }
1764
1765 #[test]
1766 fn test_md033_multiple_code_spans_with_angle_brackets() {
1767 let rule = MD033NoInlineHtml::default();
1769 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 let result = rule.check(&ctx).unwrap();
1772 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1773 }
1774
1775 #[test]
1776 fn test_md033_nested_angle_brackets_in_code_span() {
1777 let rule = MD033NoInlineHtml::default();
1779 let content = "Text with `<<nested>>` brackets";
1780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781 let result = rule.check(&ctx).unwrap();
1782 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1783 }
1784
1785 #[test]
1786 fn test_md033_code_span_at_end_before_code_block() {
1787 let rule = MD033NoInlineHtml::default();
1789 let content = "Testing `<test>`\n```\ncode here\n```";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 let result = rule.check(&ctx).unwrap();
1792 assert_eq!(result.len(), 0, "Should handle code span before code block");
1793 }
1794
1795 #[test]
1796 fn test_md033_quick_fix_inline_tag() {
1797 let rule = MD033NoInlineHtml::default();
1800 let content = "This has <span>inline text</span> that should keep content.";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let result = rule.check(&ctx).unwrap();
1803
1804 assert_eq!(result.len(), 1, "Should find one HTML tag");
1805 assert!(
1807 result[0].fix.is_none(),
1808 "Non-fixable tags like <span> should not have a fix"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_md033_quick_fix_multiline_tag() {
1814 let rule = MD033NoInlineHtml::default();
1817 let content = "<div>\nBlock content\n</div>";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 1, "Should find one HTML tag");
1822 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1824 }
1825
1826 #[test]
1827 fn test_md033_quick_fix_self_closing_tag() {
1828 let rule = MD033NoInlineHtml::default();
1830 let content = "Self-closing: <br/>";
1831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832 let result = rule.check(&ctx).unwrap();
1833
1834 assert_eq!(result.len(), 1, "Should find one HTML tag");
1835 assert!(
1837 result[0].fix.is_none(),
1838 "Self-closing tags should not have a fix when fix config is false"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_md033_quick_fix_multiple_tags() {
1844 let rule = MD033NoInlineHtml::default();
1847 let content = "<span>first</span> and <strong>second</strong>";
1848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1849 let result = rule.check(&ctx).unwrap();
1850
1851 assert_eq!(result.len(), 2, "Should find two HTML tags");
1852 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1854 assert!(
1855 result[1].fix.is_none(),
1856 "<strong> should not have a fix when fix config is false"
1857 );
1858 }
1859
1860 #[test]
1861 fn test_md033_skip_angle_brackets_in_link_titles() {
1862 let rule = MD033NoInlineHtml::default();
1864 let content = r#"# Test
1865
1866[example]: <https://example.com> "Title with <Angle Brackets> inside"
1867
1868Regular text with <div>content</div> HTML tag.
1869"#;
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let result = rule.check(&ctx).unwrap();
1872
1873 assert_eq!(result.len(), 1, "Should find opening div tag");
1876 assert!(
1877 result[0].message.contains("<div>"),
1878 "Should flag <div>, got: {}",
1879 result[0].message
1880 );
1881 }
1882
1883 #[test]
1884 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1885 let rule = MD033NoInlineHtml::default();
1887 let content = r#"[ref]: url 'Title <Help Wanted> here'
1888
1889<span>text</span> here
1890"#;
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let result = rule.check(&ctx).unwrap();
1893
1894 assert_eq!(result.len(), 1, "Should find opening span tag");
1897 assert!(
1898 result[0].message.contains("<span>"),
1899 "Should flag <span>, got: {}",
1900 result[0].message
1901 );
1902 }
1903
1904 #[test]
1905 fn test_md033_multiline_tag_end_line_calculation() {
1906 let rule = MD033NoInlineHtml::default();
1908 let content = "<div\n class=\"test\"\n id=\"example\">";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let result = rule.check(&ctx).unwrap();
1911
1912 assert_eq!(result.len(), 1, "Should find one HTML tag");
1913 assert_eq!(result[0].line, 1, "Start line should be 1");
1915 assert_eq!(result[0].end_line, 3, "End line should be 3");
1917 }
1918
1919 #[test]
1920 fn test_md033_single_line_tag_same_start_end_line() {
1921 let rule = MD033NoInlineHtml::default();
1923 let content = "Some text <div class=\"test\"> more text";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let result = rule.check(&ctx).unwrap();
1926
1927 assert_eq!(result.len(), 1, "Should find one HTML tag");
1928 assert_eq!(result[0].line, 1, "Start line should be 1");
1929 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1930 }
1931
1932 #[test]
1933 fn test_md033_multiline_tag_with_many_attributes() {
1934 let rule = MD033NoInlineHtml::default();
1936 let content =
1937 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1939 let result = rule.check(&ctx).unwrap();
1940
1941 assert_eq!(result.len(), 1, "Should find one HTML tag");
1942 assert_eq!(result[0].line, 2, "Start line should be 2");
1944 assert_eq!(result[0].end_line, 5, "End line should be 5");
1946 }
1947
1948 #[test]
1949 fn test_md033_disallowed_mode_basic() {
1950 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1952 let content = "<div>Safe content</div><script>alert('xss')</script>";
1953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954 let result = rule.check(&ctx).unwrap();
1955
1956 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1958 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1959 }
1960
1961 #[test]
1962 fn test_md033_disallowed_gfm_security_tags() {
1963 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1965 let content = r#"
1966<div>Safe</div>
1967<title>Bad title</title>
1968<textarea>Bad textarea</textarea>
1969<style>.bad{}</style>
1970<iframe src="evil"></iframe>
1971<script>evil()</script>
1972<plaintext>old tag</plaintext>
1973<span>Safe span</span>
1974"#;
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976 let result = rule.check(&ctx).unwrap();
1977
1978 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1981
1982 let flagged_tags: Vec<&str> = result
1983 .iter()
1984 .filter_map(|w| w.message.split('<').nth(1))
1985 .filter_map(|s| s.split('>').next())
1986 .filter_map(|s| s.split_whitespace().next())
1987 .collect();
1988
1989 assert!(flagged_tags.contains(&"title"), "Should flag title");
1990 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1991 assert!(flagged_tags.contains(&"style"), "Should flag style");
1992 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1993 assert!(flagged_tags.contains(&"script"), "Should flag script");
1994 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1995 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1996 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1997 }
1998
1999 #[test]
2000 fn test_md033_disallowed_case_insensitive() {
2001 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
2003 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let result = rule.check(&ctx).unwrap();
2006
2007 assert_eq!(result.len(), 2, "Should flag both case variants");
2009 }
2010
2011 #[test]
2012 fn test_md033_disallowed_with_attributes() {
2013 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
2015 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
2016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2017 let result = rule.check(&ctx).unwrap();
2018
2019 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
2020 assert!(result[0].message.contains("iframe"), "Should flag iframe");
2021 }
2022
2023 #[test]
2024 fn test_md033_disallowed_all_gfm_tags() {
2025 use md033_config::GFM_DISALLOWED_TAGS;
2027 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
2028
2029 for tag in GFM_DISALLOWED_TAGS {
2030 let content = format!("<{tag}>content</{tag}>");
2031 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2032 let result = rule.check(&ctx).unwrap();
2033
2034 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
2035 }
2036 }
2037
2038 #[test]
2039 fn test_md033_disallowed_mixed_with_custom() {
2040 let rule = MD033NoInlineHtml::with_disallowed(vec![
2042 "gfm".to_string(),
2043 "marquee".to_string(), ]);
2045 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let result = rule.check(&ctx).unwrap();
2048
2049 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
2051 }
2052
2053 #[test]
2054 fn test_md033_disallowed_empty_means_default_mode() {
2055 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
2057 let content = "<div>content</div>";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 let result = rule.check(&ctx).unwrap();
2060
2061 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
2063 }
2064
2065 #[test]
2066 fn test_md033_jsx_fragments_in_mdx() {
2067 let rule = MD033NoInlineHtml::default();
2069 let content = r#"# MDX Document
2070
2071<>
2072 <Heading />
2073 <Content />
2074</>
2075
2076<div>Regular HTML should still be flagged</div>
2077"#;
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2079 let result = rule.check(&ctx).unwrap();
2080
2081 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
2083 assert!(
2084 result[0].message.contains("<div>"),
2085 "Should flag <div>, not JSX fragments"
2086 );
2087 }
2088
2089 #[test]
2090 fn test_md033_jsx_components_in_mdx() {
2091 let rule = MD033NoInlineHtml::default();
2093 let content = r#"<CustomComponent prop="value">
2094 Content
2095</CustomComponent>
2096
2097<MyButton onClick={handler}>Click</MyButton>
2098"#;
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2100 let result = rule.check(&ctx).unwrap();
2101
2102 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
2104 }
2105
2106 #[test]
2107 fn test_md033_jsx_not_skipped_in_standard_markdown() {
2108 let rule = MD033NoInlineHtml::default();
2110 let content = "<Script>alert(1)</Script>";
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let result = rule.check(&ctx).unwrap();
2113
2114 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
2116 }
2117
2118 #[test]
2119 fn test_md033_jsx_attributes_in_mdx() {
2120 let rule = MD033NoInlineHtml::default();
2122 let content = r#"# MDX with JSX Attributes
2123
2124<div className="card big">Content</div>
2125
2126<button onClick={handleClick}>Click me</button>
2127
2128<label htmlFor="input-id">Label</label>
2129
2130<input onChange={handleChange} />
2131
2132<div class="html-class">Regular HTML should be flagged</div>
2133"#;
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2135 let result = rule.check(&ctx).unwrap();
2136
2137 assert_eq!(
2139 result.len(),
2140 1,
2141 "Should only flag HTML element without JSX attributes, got: {result:?}"
2142 );
2143 assert!(
2144 result[0].message.contains("<div class="),
2145 "Should flag the div with HTML class attribute"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_md033_jsx_attributes_not_skipped_in_standard() {
2151 let rule = MD033NoInlineHtml::default();
2153 let content = r#"<div className="card">Content</div>"#;
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let result = rule.check(&ctx).unwrap();
2156
2157 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
2159 }
2160
2161 #[test]
2164 fn test_md033_fix_disabled_by_default() {
2165 let rule = MD033NoInlineHtml::default();
2167 assert!(!rule.config.fix, "Fix should be disabled by default");
2168 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
2169 }
2170
2171 #[test]
2172 fn test_md033_fix_enabled_em_to_italic() {
2173 let rule = MD033NoInlineHtml::with_fix(true);
2175 let content = "This has <em>emphasized text</em> here.";
2176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177 let fixed = rule.fix(&ctx).unwrap();
2178 assert_eq!(fixed, "This has *emphasized text* here.");
2179 }
2180
2181 #[test]
2182 fn test_md033_fix_enabled_i_to_italic() {
2183 let rule = MD033NoInlineHtml::with_fix(true);
2185 let content = "This has <i>italic text</i> here.";
2186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2187 let fixed = rule.fix(&ctx).unwrap();
2188 assert_eq!(fixed, "This has *italic text* here.");
2189 }
2190
2191 #[test]
2192 fn test_md033_fix_enabled_strong_to_bold() {
2193 let rule = MD033NoInlineHtml::with_fix(true);
2195 let content = "This has <strong>bold text</strong> here.";
2196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2197 let fixed = rule.fix(&ctx).unwrap();
2198 assert_eq!(fixed, "This has **bold text** here.");
2199 }
2200
2201 #[test]
2202 fn test_md033_fix_enabled_b_to_bold() {
2203 let rule = MD033NoInlineHtml::with_fix(true);
2205 let content = "This has <b>bold text</b> here.";
2206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2207 let fixed = rule.fix(&ctx).unwrap();
2208 assert_eq!(fixed, "This has **bold text** here.");
2209 }
2210
2211 #[test]
2212 fn test_md033_fix_enabled_code_to_backticks() {
2213 let rule = MD033NoInlineHtml::with_fix(true);
2215 let content = "This has <code>inline code</code> here.";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217 let fixed = rule.fix(&ctx).unwrap();
2218 assert_eq!(fixed, "This has `inline code` here.");
2219 }
2220
2221 #[test]
2222 fn test_md033_fix_enabled_code_with_backticks() {
2223 let rule = MD033NoInlineHtml::with_fix(true);
2225 let content = "This has <code>text with `backticks`</code> here.";
2226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2227 let fixed = rule.fix(&ctx).unwrap();
2228 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
2229 }
2230
2231 #[test]
2232 fn test_md033_fix_enabled_br_trailing_spaces() {
2233 let rule = MD033NoInlineHtml::with_fix(true);
2235 let content = "First line<br>Second line";
2236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2237 let fixed = rule.fix(&ctx).unwrap();
2238 assert_eq!(fixed, "First line \nSecond line");
2239 }
2240
2241 #[test]
2242 fn test_md033_fix_enabled_br_self_closing() {
2243 let rule = MD033NoInlineHtml::with_fix(true);
2245 let content = "First<br/>second<br />third";
2246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2247 let fixed = rule.fix(&ctx).unwrap();
2248 assert_eq!(fixed, "First \nsecond \nthird");
2249 }
2250
2251 #[test]
2252 fn test_md033_fix_enabled_br_backslash_style() {
2253 let config = MD033Config {
2255 allowed: Vec::new(),
2256 disallowed: Vec::new(),
2257 fix: true,
2258 br_style: md033_config::BrStyle::Backslash,
2259 ..MD033Config::default()
2260 };
2261 let rule = MD033NoInlineHtml::from_config_struct(config);
2262 let content = "First line<br>Second line";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264 let fixed = rule.fix(&ctx).unwrap();
2265 assert_eq!(fixed, "First line\\\nSecond line");
2266 }
2267
2268 #[test]
2269 fn test_md033_fix_enabled_hr() {
2270 let rule = MD033NoInlineHtml::with_fix(true);
2272 let content = "Above<hr>Below";
2273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274 let fixed = rule.fix(&ctx).unwrap();
2275 assert_eq!(fixed, "Above\n---\nBelow");
2276 }
2277
2278 #[test]
2279 fn test_md033_fix_enabled_hr_self_closing() {
2280 let rule = MD033NoInlineHtml::with_fix(true);
2282 let content = "Above<hr/>Below";
2283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284 let fixed = rule.fix(&ctx).unwrap();
2285 assert_eq!(fixed, "Above\n---\nBelow");
2286 }
2287
2288 #[test]
2289 fn test_md033_fix_skips_nested_tags() {
2290 let rule = MD033NoInlineHtml::with_fix(true);
2293 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
2294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295 let fixed = rule.fix(&ctx).unwrap();
2296 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
2299 }
2300
2301 #[test]
2302 fn test_md033_fix_skips_tags_with_attributes() {
2303 let rule = MD033NoInlineHtml::with_fix(true);
2306 let content = "This has <em class=\"highlight\">emphasized</em> text.";
2307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308 let fixed = rule.fix(&ctx).unwrap();
2309 assert_eq!(fixed, content);
2311 }
2312
2313 #[test]
2314 fn test_md033_fix_disabled_no_changes() {
2315 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
2318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2319 let fixed = rule.fix(&ctx).unwrap();
2320 assert_eq!(fixed, content, "Should return original content when fix is disabled");
2321 }
2322
2323 #[test]
2324 fn test_md033_fix_capability_enabled() {
2325 let rule = MD033NoInlineHtml::with_fix(true);
2326 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
2327 }
2328
2329 #[test]
2330 fn test_md033_fix_multiple_tags() {
2331 let rule = MD033NoInlineHtml::with_fix(true);
2333 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
2334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335 let fixed = rule.fix(&ctx).unwrap();
2336 assert_eq!(fixed, "Here is *italic* and **bold** text.");
2337 }
2338
2339 #[test]
2340 fn test_md033_fix_uppercase_tags() {
2341 let rule = MD033NoInlineHtml::with_fix(true);
2343 let content = "This has <EM>emphasized</EM> text.";
2344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345 let fixed = rule.fix(&ctx).unwrap();
2346 assert_eq!(fixed, "This has *emphasized* text.");
2347 }
2348
2349 #[test]
2350 fn test_md033_fix_unsafe_tags_not_modified() {
2351 let rule = MD033NoInlineHtml::with_fix(true);
2354 let content = "This has <div>a div</div> content.";
2355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2356 let fixed = rule.fix(&ctx).unwrap();
2357 assert_eq!(fixed, "This has <div>a div</div> content.");
2359 }
2360
2361 #[test]
2362 fn test_md033_fix_img_tag_converted() {
2363 let rule = MD033NoInlineHtml::with_fix(true);
2365 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
2366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2367 let fixed = rule.fix(&ctx).unwrap();
2368 assert_eq!(fixed, "Image: ");
2370 }
2371
2372 #[test]
2373 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
2374 let rule = MD033NoInlineHtml::with_fix(true);
2376 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378 let fixed = rule.fix(&ctx).unwrap();
2379 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
2381 }
2382
2383 #[test]
2384 fn test_md033_fix_relaxed_a_with_target_is_converted() {
2385 let rule = relaxed_fix_rule();
2386 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
2387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388 let fixed = rule.fix(&ctx).unwrap();
2389 assert_eq!(fixed, "Link: [Example](https://example.com)");
2390 }
2391
2392 #[test]
2393 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2394 let rule = relaxed_fix_rule();
2395 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let fixed = rule.fix(&ctx).unwrap();
2398 assert_eq!(fixed, "Image: ");
2399 }
2400
2401 #[test]
2402 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2403 let rule = relaxed_fix_rule();
2404 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2406 let fixed = rule.fix(&ctx).unwrap();
2407 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2408 }
2409
2410 #[test]
2411 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2412 let rule = relaxed_fix_rule();
2413 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415 let fixed = rule.fix(&ctx).unwrap();
2416 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2417 }
2418
2419 #[test]
2420 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2421 let rule = relaxed_fix_rule();
2422 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2423 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424 let fixed_once = rule.fix(&ctx1).unwrap();
2425 assert!(
2426 fixed_once.contains("<p"),
2427 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2428 );
2429 assert!(
2430 fixed_once.contains(""),
2431 "Inner image should be converted on first pass: {fixed_once}"
2432 );
2433
2434 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2435 let fixed_twice = rule.fix(&ctx2).unwrap();
2436 assert!(
2437 !fixed_twice.contains("<p"),
2438 "Second pass should strip configured wrapper: {fixed_twice}"
2439 );
2440 assert!(fixed_twice.contains(""));
2441 }
2442
2443 #[test]
2444 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2445 let rule = relaxed_fix_rule();
2446 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let fixed = rule.fix(&ctx).unwrap();
2449 assert_eq!(fixed, "[Click](https://example.com)");
2450 }
2451
2452 #[test]
2453 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2454 let rule = relaxed_fix_rule();
2455 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2457 let fixed = rule.fix(&ctx).unwrap();
2458 assert_eq!(fixed, "");
2459 }
2460
2461 #[test]
2462 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2463 let rule = relaxed_fix_rule();
2464 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2466 let fixed = rule.fix(&ctx).unwrap();
2467 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2468 }
2469
2470 #[test]
2471 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2472 let config = MD033Config {
2474 fix: true,
2475 fix_mode: MD033FixMode::Relaxed,
2476 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2477 ..MD033Config::default()
2478 };
2479 let rule = MD033NoInlineHtml::from_config_struct(config);
2480 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482 let fixed = rule.fix(&ctx).unwrap();
2483 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2484 }
2485
2486 #[test]
2487 fn test_md033_fix_relaxed_custom_drop_attributes() {
2488 let config = MD033Config {
2489 fix: true,
2490 fix_mode: MD033FixMode::Relaxed,
2491 drop_attributes: vec!["loading".to_string()],
2492 ..MD033Config::default()
2493 };
2494 let rule = MD033NoInlineHtml::from_config_struct(config);
2495 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2498 let fixed = rule.fix(&ctx).unwrap();
2499 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2500
2501 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2502 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2503 let fixed2 = rule.fix(&ctx2).unwrap();
2504 assert_eq!(
2505 fixed2, content2,
2506 "Attributes not in custom list should block conversion"
2507 );
2508 }
2509
2510 #[test]
2511 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2512 let config = MD033Config {
2513 fix: true,
2514 fix_mode: MD033FixMode::Relaxed,
2515 strip_wrapper_elements: vec!["div".to_string()],
2516 ..MD033Config::default()
2517 };
2518 let rule = MD033NoInlineHtml::from_config_struct(config);
2519 let content = "<div>Some text content</div>";
2520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521 let fixed = rule.fix(&ctx).unwrap();
2522 assert_eq!(fixed, "Some text content");
2523 }
2524
2525 #[test]
2526 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2527 let rule = relaxed_fix_rule();
2528 let content = "<p align=\"center\">Just some text</p>";
2529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2530 let fixed = rule.fix(&ctx).unwrap();
2531 assert_eq!(fixed, "Just some text");
2532 }
2533
2534 #[test]
2535 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2536 let config = MD033Config {
2537 fix: true,
2538 fix_mode: MD033FixMode::Relaxed,
2539 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2540 ..MD033Config::default()
2541 };
2542 let rule = MD033NoInlineHtml::from_config_struct(config);
2543 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545 let fixed = rule.fix(&ctx).unwrap();
2546 assert_eq!(fixed, "[Link](https://example.com)");
2547 }
2548
2549 #[test]
2550 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2551 let rule = relaxed_fix_rule();
2552 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let fixed = rule.fix(&ctx).unwrap();
2556 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2557 }
2558
2559 #[test]
2560 fn test_md033_fix_relaxed_badge_pattern() {
2561 let rule = relaxed_fix_rule();
2563 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>";
2564 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2565 let fixed_once = rule.fix(&ctx1).unwrap();
2566 assert!(
2568 fixed_once.contains(""),
2569 "Inner img should be converted: {fixed_once}"
2570 );
2571
2572 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2574 let fixed_twice = rule.fix(&ctx2).unwrap();
2575 assert!(
2576 fixed_twice
2577 .contains("[](https://crates.io/crates/rumdl)"),
2578 "Badge should produce nested markdown image link: {fixed_twice}"
2579 );
2580 }
2581
2582 #[test]
2583 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2584 let rule = MD033NoInlineHtml::with_fix(true);
2586 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2588 let fixed = rule.fix(&ctx).unwrap();
2589 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2590 }
2591
2592 #[test]
2593 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2594 let rule = relaxed_fix_rule();
2596 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2598 let fixed = rule.fix(&ctx).unwrap();
2599 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2600 }
2601
2602 #[test]
2603 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2604 let rule = relaxed_fix_rule();
2606 let content = "<div><p>text</p></div>";
2607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608 let fixed = rule.fix(&ctx).unwrap();
2609 assert!(
2610 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2611 "Nested <p> inside <div> should not be stripped: {fixed}"
2612 );
2613 }
2614
2615 #[test]
2616 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2617 let rule = relaxed_fix_rule();
2621 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2623 let fixed = rule.fix(&ctx).unwrap();
2624 assert!(
2625 fixed.contains("<img"),
2626 "img inside nested wrapper must not be converted: {fixed}"
2627 );
2628 }
2629
2630 #[test]
2631 fn test_md033_fix_mixed_safe_tags() {
2632 let rule = MD033NoInlineHtml::with_fix(true);
2634 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2636 let fixed = rule.fix(&ctx).unwrap();
2637 assert_eq!(fixed, "*italic* and  and **bold**");
2639 }
2640
2641 #[test]
2642 fn test_md033_fix_multiple_tags_same_line() {
2643 let rule = MD033NoInlineHtml::with_fix(true);
2645 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let fixed = rule.fix(&ctx).unwrap();
2648 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2649 }
2650
2651 #[test]
2652 fn test_md033_fix_multiple_em_tags_same_line() {
2653 let rule = MD033NoInlineHtml::with_fix(true);
2655 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2657 let fixed = rule.fix(&ctx).unwrap();
2658 assert_eq!(fixed, "*first* and **second** and `third`");
2659 }
2660
2661 #[test]
2662 fn test_md033_fix_skips_tags_inside_pre() {
2663 let rule = MD033NoInlineHtml::with_fix(true);
2665 let content = "<pre><code><em>VALUE</em></code></pre>";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let fixed = rule.fix(&ctx).unwrap();
2668 assert!(
2671 !fixed.contains("*VALUE*"),
2672 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_md033_fix_skips_tags_inside_div() {
2678 let rule = MD033NoInlineHtml::with_fix(true);
2680 let content = "<div>\n<em>emphasized</em>\n</div>";
2681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2682 let fixed = rule.fix(&ctx).unwrap();
2683 assert!(
2685 !fixed.contains("*emphasized*"),
2686 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_md033_fix_outside_html_block() {
2692 let rule = MD033NoInlineHtml::with_fix(true);
2694 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696 let fixed = rule.fix(&ctx).unwrap();
2697 assert!(
2699 fixed.contains("*emphasized*"),
2700 "Tags outside HTML blocks should be converted. Got: {fixed}"
2701 );
2702 }
2703
2704 #[test]
2705 fn test_md033_fix_converts_standalone_type_7_html_block() {
2706 let rule = MD033NoInlineHtml::with_fix(true);
2707 let content = "<em>\nstandalone\n</em>\n";
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709
2710 assert!(ctx.is_in_html_block(1), "the parser classifies this as a type-7 block");
2711 assert_eq!(rule.fix(&ctx).unwrap(), "*standalone*\n");
2712 }
2713
2714 #[test]
2715 fn test_md033_fix_converts_root_hr_but_not_nested_hr() {
2716 let rule = MD033NoInlineHtml::with_fix(true);
2717
2718 let root = "<hr>\n";
2719 let root_ctx = LintContext::new(root, crate::config::MarkdownFlavor::Standard, None);
2720 assert!(root_ctx.is_in_html_block(1));
2721 assert_eq!(rule.fix(&root_ctx).unwrap(), "\n---\n\n");
2722
2723 let nested = "<div>\n<hr>\n</div>\n";
2724 let nested_ctx = LintContext::new(nested, crate::config::MarkdownFlavor::Standard, None);
2725 assert_eq!(
2726 rule.fix(&nested_ctx).unwrap(),
2727 nested,
2728 "Markdown syntax inside a raw HTML block would render as literal text"
2729 );
2730 }
2731
2732 #[test]
2733 fn test_md033_fix_skips_block_owned_tags_in_blockquotes() {
2734 let rule = MD033NoInlineHtml::with_fix(true);
2735
2736 for content in ["> <hr>\n", "> <em>\n> standalone\n> </em>\n"] {
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738 assert_eq!(
2739 rule.fix(&ctx).unwrap(),
2740 content,
2741 "a fix must not escape or corrupt its blockquote container"
2742 );
2743 }
2744 }
2745
2746 #[test]
2747 fn test_md033_fix_handles_html_in_list_containers() {
2748 let rule = MD033NoInlineHtml::with_fix(true);
2749
2750 let block_owned = "- <em>\n standalone\n </em>\n";
2751 let block_ctx = LintContext::new(block_owned, crate::config::MarkdownFlavor::Standard, None);
2752 assert_eq!(
2753 rule.fix(&block_ctx).unwrap(),
2754 block_owned,
2755 "a fix must not escape or corrupt its list container"
2756 );
2757
2758 let inline = "- text <em>inside</em>\n";
2759 let inline_ctx = LintContext::new(inline, crate::config::MarkdownFlavor::Standard, None);
2760 assert_eq!(rule.fix(&inline_ctx).unwrap(), "- text *inside*\n");
2761 }
2762
2763 #[test]
2764 fn test_md033_fix_skips_multiline_inline_tags() {
2765 let rule = MD033NoInlineHtml::with_fix(true);
2766
2767 for content in [
2768 "paragraph\n<em>\ninside\n</em>\n",
2769 "> paragraph\n> <em>\n> inside\n> </em>\n",
2770 ] {
2771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2772 assert_eq!(
2773 rule.fix(&ctx).unwrap(),
2774 content,
2775 "delimiter-only lines are not a semantics-preserving replacement"
2776 );
2777 }
2778 }
2779
2780 #[test]
2781 fn test_md033_fix_converts_html_inside_mdx_component() {
2782 let rule = MD033NoInlineHtml::with_fix(true);
2783 let content = "<Component>\nBefore\n<em>inside</em>\nAfter\n</Component>\n";
2784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2785
2786 assert!(ctx.line_info(3).is_some_and(|line| line.in_jsx_block));
2787 assert!(!ctx.is_in_html_block(3));
2788 assert_eq!(
2789 rule.fix(&ctx).unwrap(),
2790 "<Component>\nBefore\n*inside*\nAfter\n</Component>\n"
2791 );
2792 }
2793
2794 #[test]
2795 fn standalone_html_conversion_does_not_activate_reference_links() {
2796 let rule = MD033NoInlineHtml::with_fix(true);
2797 for tag in ["em", "strong", "b", "i"] {
2798 for inner in ["[foo]", "[foo][]", "[x][foo]", "[^n]"] {
2799 let content = format!("<{tag}>\n{inner}\n</{tag}>\n\n[foo]: https://example.com\n\n[^n]: A note.\n");
2800 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2801 assert_eq!(rule.fix(&ctx).unwrap(), content, "literal HTML text: {tag}: {inner}");
2802 }
2803 }
2804 }
2805
2806 #[test]
2807 fn standalone_html_conversion_preserves_literal_punctuation() {
2808 let rule = MD033NoInlineHtml::with_fix(true);
2809 for inner in ["*literal*", "a*", "[link](other)", "", "~~strike~~", "a\\*b"] {
2810 let content = format!("<em>\n{inner}\n</em>\n");
2811 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2812 assert_eq!(rule.fix(&ctx).unwrap(), content, "literal HTML text: {inner}");
2813 }
2814 }
2815
2816 #[test]
2817 fn standalone_html_conversion_does_not_create_markdown_blocks_or_breaks() {
2818 let rule = MD033NoInlineHtml::with_fix(true);
2819 for inner in [
2820 "foo\n# bar",
2821 "foo\n- bar",
2822 "foo \nbar",
2823 "foo\\\nbar",
2824 "foo\n> bar",
2825 "foo\n```\ncode",
2826 ] {
2827 let content = format!("<em>\n{inner}\n</em>\n");
2828 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2829 assert_eq!(rule.fix(&ctx).unwrap(), content);
2830 }
2831 }
2832
2833 #[test]
2834 fn html_block_fixes_preserve_footnote_containers() {
2835 let rule = MD033NoInlineHtml::with_fix(true);
2836 for block in ["<hr>", "<em>\n text\n </em>"] {
2837 let content = format!("[^n]: First paragraph.\n\n {block}\n\n more\n\nref[^n]\n");
2838 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
2839 assert_eq!(rule.fix(&ctx).unwrap(), content);
2840 }
2841 }
2842
2843 #[test]
2844 fn html_block_fixes_preserve_definition_list_and_flavor_containers() {
2845 let rule = MD033NoInlineHtml::with_fix(true);
2846 for (content, flavor) in [
2847 (
2848 "Term\n: description\n\n <hr>\n\n more\n",
2849 MarkdownFlavor::Standard,
2850 ),
2851 ("!!! note\n <hr>\n", MarkdownFlavor::MkDocs),
2852 ("=== \"Tab\"\n <hr>\n", MarkdownFlavor::MkDocs),
2853 ] {
2854 let ctx = LintContext::new(content, flavor, None);
2855 assert_eq!(rule.fix(&ctx).unwrap(), content);
2856 }
2857 }
2858
2859 #[test]
2860 fn relaxed_wrapper_conversion_finishes_before_leaving_markdown_in_html() {
2861 let rule = relaxed_fix_rule();
2862 for content in [
2863 "<p align=\"center\"><img src=\"logo.png\"></p>\n",
2864 "<p>\n<img src=\"a.png\">\n</p>\ntrailing text\n",
2865 "<p align=\"center\">\n <img src=\"logo.png\">\n</p>\n",
2866 ] {
2867 let mut fixed = content.to_string();
2868 for _ in 0..4 {
2869 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2870 let next = rule.fix(&ctx).unwrap();
2871 if next == fixed {
2872 break;
2873 }
2874 fixed = next;
2875 }
2876 assert!(fixed.contains(");
2877 assert!(!fixed.contains("<p"));
2878 let mut html = String::new();
2879 pulldown_cmark::html::push_html(&mut html, pulldown_cmark::Parser::new(&fixed));
2880 assert!(html.contains("<img src="), "image must render: {html}");
2881 }
2882 }
2883
2884 #[test]
2885 fn single_line_images_keep_their_markdown_container_prefixes() {
2886 let rule = MD033NoInlineHtml::with_fix(true);
2887 for (content, expected) in [
2888 ("- <img src=\"a.png\">\n", "- \n"),
2889 ("> <img src=\"a.png\">\n", "> \n"),
2890 ("- item\n\n <img src=\"a.png\">\n", "- item\n\n \n"),
2891 (
2892 "[^n]: First paragraph.\n\n <img src=\"a.png\">\n\nref[^n]\n",
2893 "[^n]: First paragraph.\n\n \n\nref[^n]\n",
2894 ),
2895 (
2896 "Term\n: description\n\n <img src=\"a.png\">\n",
2897 "Term\n: description\n\n \n",
2898 ),
2899 ] {
2900 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2901 let fixed = rule.fix(&ctx).unwrap();
2902 assert_eq!(fixed, expected);
2903 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2904 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2905 }
2906 }
2907
2908 #[test]
2909 fn relaxed_single_line_wrappers_keep_their_markdown_container_prefixes() {
2910 let rule = relaxed_fix_rule();
2911 for prefix in ["- ", "> "] {
2912 let content = format!("{prefix}<p align=\"center\"><img src=\"a.png\"></p>\n");
2913 let mut fixed = content;
2914 for _ in 0..4 {
2915 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2916 let next = rule.fix(&ctx).unwrap();
2917 if next == fixed {
2918 break;
2919 }
2920 fixed = next;
2921 }
2922 assert_eq!(fixed, format!("{prefix}\n"));
2923 }
2924 }
2925
2926 #[test]
2927 fn container_images_do_not_leave_markdown_in_raw_html_tails_or_wrappers() {
2928 for rule in [MD033NoInlineHtml::with_fix(true), relaxed_fix_rule()] {
2929 for content in [
2930 "- <img src=\"a.png\">\n tail *literal*\n",
2931 "> <img src=\"a.png\">\n> tail *literal*\n",
2932 "- <br>\n",
2933 "- <hr>\n",
2934 "- <p>\n <img src=\"a.png\">\n </p>\n",
2935 ] {
2936 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2937 assert_eq!(rule.fix(&ctx).unwrap(), content);
2938 }
2939 }
2940 }
2941
2942 #[test]
2943 fn standalone_html_links_round_trip_text_and_reject_markdown_images() {
2944 let rule = MD033NoInlineHtml::with_fix(true);
2945 for (content, expected) in [
2946 ("<a href=\"https://x.test\">\ntext\n</a>\n", "[text](https://x.test)\n"),
2947 (
2948 "<a href=\"https://x.test\">\na[b]\n</a>\n",
2949 "[a\\[b\\]](https://x.test)\n",
2950 ),
2951 ] {
2952 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2953 let fixed = rule.fix(&ctx).unwrap();
2954 assert_eq!(fixed, expected);
2955 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
2956 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2957 }
2958 for content in [
2959 "<a href=\"https://x.test\">\n\n</a>\n",
2960 "<a href=\"a b\">\ntext\n</a>\n",
2961 ] {
2962 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2963 assert_eq!(rule.fix(&ctx).unwrap(), content);
2964 }
2965 }
2966
2967 #[test]
2968 fn inline_html_in_markdown_enabled_html_container_still_converts() {
2969 let rule = MD033NoInlineHtml::with_fix(true);
2970 let content = "<div markdown=\"1\">\n\ntext <em>inside</em> after\n\n</div>\n";
2971 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2972 assert_eq!(
2973 rule.fix(&ctx).unwrap(),
2974 "<div markdown=\"1\">\n\ntext *inside* after\n\n</div>\n"
2975 );
2976 }
2977
2978 #[test]
2979 fn soft_wrapped_inline_html_still_converts() {
2980 let rule = MD033NoInlineHtml::with_fix(true);
2981 let content = "This is <em>wrapped\ntext</em> here.\n";
2982 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2983 assert_eq!(rule.fix(&ctx).unwrap(), "This is *wrapped\ntext* here.\n");
2984 }
2985
2986 #[test]
2987 fn standalone_html_conversion_preserves_unicode_whitespace() {
2988 let rule = MD033NoInlineHtml::with_fix(true);
2989 for space in ['\u{a0}', '\u{2003}'] {
2990 for (content, expected) in [
2991 (
2992 format!("<em>\n{space}note\n</em>\n"),
2993 format!("<em>\n{space}note\n</em>\n"),
2994 ),
2995 (
2996 format!("<code>\n{space}x{space}\n</code>\n"),
2997 format!("`{space}x{space}`\n"),
2998 ),
2999 (
3000 format!("<a href=\"https://x.test\">\ntext{space}\n</a>\n"),
3001 format!("[text{space}](https://x.test)\n"),
3002 ),
3003 (format!("<hr>{space}\n"), format!("<hr>{space}\n")),
3004 (
3005 format!("<em>\ntext\n</em>{space}\n"),
3006 format!("<em>\ntext\n</em>{space}\n"),
3007 ),
3008 ] {
3009 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
3010 let fixed = rule.fix(&ctx).unwrap();
3011 assert_eq!(fixed, expected);
3012 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
3013 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
3014 }
3015 }
3016 }
3017
3018 #[test]
3019 fn standalone_html_conversion_is_idempotent_and_unicode_safe() {
3020 let rule = MD033NoInlineHtml::with_fix(true);
3021 for (content, expected) in [
3022 ("<em>\néén zin\n</em>\n", "*één zin*\n"),
3023 ("<strong>\nhello\n</strong>\n", "**hello**\n"),
3024 ("<code>\nplain\n</code>\n", "`plain`\n"),
3025 ("<em>\r\nhello\r\n</em>\r\n", "*hello*\r\n"),
3026 ] {
3027 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
3028 let fixed = rule.fix(&ctx).unwrap();
3029 assert_eq!(fixed, expected);
3030 let next = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
3031 assert_eq!(rule.fix(&next).unwrap(), fixed);
3032 assert!(rule.check(&next).unwrap().is_empty());
3033 }
3034 }
3035
3036 #[test]
3037 fn html_block_tail_and_nested_content_are_not_reinterpreted() {
3038 let rule = MD033NoInlineHtml::with_fix(true);
3039 for content in [
3040 "<em>\none\n</em>\n*literal tail*\n",
3041 "<div>\ntext <em>literal</em>\n</div>\n",
3042 "<pre>\ntext <em>literal</em>\n</pre>\n",
3043 "<em>\n\ntext\n</em>\n",
3044 "- text\n\n <hr>\n",
3045 ] {
3046 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
3047 assert_eq!(rule.fix(&ctx).unwrap(), content);
3048 }
3049 }
3050
3051 #[test]
3052 fn test_md033_fix_with_id_attribute() {
3053 let rule = MD033NoInlineHtml::with_fix(true);
3055 let content = "See <em id=\"important\">this note</em> for details.";
3056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3057 let fixed = rule.fix(&ctx).unwrap();
3058 assert_eq!(fixed, content);
3060 }
3061
3062 #[test]
3063 fn test_md033_fix_with_style_attribute() {
3064 let rule = MD033NoInlineHtml::with_fix(true);
3066 let content = "This is <strong style=\"color: red\">important</strong> text.";
3067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3068 let fixed = rule.fix(&ctx).unwrap();
3069 assert_eq!(fixed, content);
3071 }
3072
3073 #[test]
3074 fn test_md033_fix_mixed_with_and_without_attributes() {
3075 let rule = MD033NoInlineHtml::with_fix(true);
3077 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
3078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3079 let fixed = rule.fix(&ctx).unwrap();
3080 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
3082 }
3083
3084 #[test]
3085 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
3086 let rule = MD033NoInlineHtml::with_fix(true);
3088 let content = "<em class=\"test\">emphasized</em>";
3089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3090 let result = rule.check(&ctx).unwrap();
3091
3092 assert_eq!(result.len(), 1, "Should find one HTML tag");
3093 assert!(
3095 result[0].fix.is_none(),
3096 "Should NOT have a fix for tags with attributes"
3097 );
3098 }
3099
3100 #[test]
3101 fn test_md033_fix_skips_html_entities() {
3102 let rule = MD033NoInlineHtml::with_fix(true);
3105 let content = "<code>|</code>";
3106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3107 let fixed = rule.fix(&ctx).unwrap();
3108 assert_eq!(fixed, content);
3110 }
3111
3112 #[test]
3113 fn test_md033_fix_skips_multiple_html_entities() {
3114 let rule = MD033NoInlineHtml::with_fix(true);
3116 let content = "<code><T></code>";
3117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3118 let fixed = rule.fix(&ctx).unwrap();
3119 assert_eq!(fixed, content);
3121 }
3122
3123 #[test]
3124 fn test_md033_fix_allows_ampersand_without_entity() {
3125 let rule = MD033NoInlineHtml::with_fix(true);
3127 let content = "<code>a & b</code>";
3128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3129 let fixed = rule.fix(&ctx).unwrap();
3130 assert_eq!(fixed, "`a & b`");
3132 }
3133
3134 #[test]
3135 fn test_md033_fix_em_with_entities_skipped() {
3136 let rule = MD033NoInlineHtml::with_fix(true);
3138 let content = "<em> text</em>";
3139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3140 let fixed = rule.fix(&ctx).unwrap();
3141 assert_eq!(fixed, content);
3143 }
3144
3145 #[test]
3146 fn test_md033_fix_skips_nested_em_in_code() {
3147 let rule = MD033NoInlineHtml::with_fix(true);
3150 let content = "<code><em>n</em></code>";
3151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3152 let fixed = rule.fix(&ctx).unwrap();
3153 assert!(
3156 !fixed.contains("*n*"),
3157 "Nested <em> should not be converted to markdown. Got: {fixed}"
3158 );
3159 }
3160
3161 #[test]
3162 fn test_md033_fix_skips_nested_in_table() {
3163 let rule = MD033NoInlineHtml::with_fix(true);
3165 let content = "| <code>><em>n</em></code> | description |";
3166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3167 let fixed = rule.fix(&ctx).unwrap();
3168 assert!(
3170 !fixed.contains("*n*"),
3171 "Nested tags in table should not be converted. Got: {fixed}"
3172 );
3173 }
3174
3175 #[test]
3176 fn test_md033_fix_standalone_em_still_converted() {
3177 let rule = MD033NoInlineHtml::with_fix(true);
3179 let content = "This is <em>emphasized</em> text.";
3180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3181 let fixed = rule.fix(&ctx).unwrap();
3182 assert_eq!(fixed, "This is *emphasized* text.");
3183 }
3184
3185 #[test]
3197 fn test_md033_templater_basic_interpolation_not_flagged() {
3198 let rule = MD033NoInlineHtml::default();
3201 let content = "Today is <% tp.date.now() %> which is nice.";
3202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3203 let result = rule.check(&ctx).unwrap();
3204 assert!(
3205 result.is_empty(),
3206 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
3207 );
3208 }
3209
3210 #[test]
3211 fn test_md033_templater_file_functions_not_flagged() {
3212 let rule = MD033NoInlineHtml::default();
3214 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
3215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3216 let result = rule.check(&ctx).unwrap();
3217 assert!(
3218 result.is_empty(),
3219 "Templater file functions should not be flagged. Got: {result:?}"
3220 );
3221 }
3222
3223 #[test]
3224 fn test_md033_templater_with_arguments_not_flagged() {
3225 let rule = MD033NoInlineHtml::default();
3227 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
3228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3229 let result = rule.check(&ctx).unwrap();
3230 assert!(
3231 result.is_empty(),
3232 "Templater with arguments should not be flagged. Got: {result:?}"
3233 );
3234 }
3235
3236 #[test]
3237 fn test_md033_templater_javascript_execution_not_flagged() {
3238 let rule = MD033NoInlineHtml::default();
3240 let content = "<%* const today = tp.date.now(); tR += today; %>";
3241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3242 let result = rule.check(&ctx).unwrap();
3243 assert!(
3244 result.is_empty(),
3245 "Templater JS execution block should not be flagged. Got: {result:?}"
3246 );
3247 }
3248
3249 #[test]
3250 fn test_md033_templater_dynamic_execution_not_flagged() {
3251 let rule = MD033NoInlineHtml::default();
3253 let content = "Dynamic: <%+ tp.date.now() %>";
3254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3255 let result = rule.check(&ctx).unwrap();
3256 assert!(
3257 result.is_empty(),
3258 "Templater dynamic execution should not be flagged. Got: {result:?}"
3259 );
3260 }
3261
3262 #[test]
3263 fn test_md033_templater_whitespace_trim_all_not_flagged() {
3264 let rule = MD033NoInlineHtml::default();
3266 let content = "<%_ tp.date.now() _%>";
3267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3268 let result = rule.check(&ctx).unwrap();
3269 assert!(
3270 result.is_empty(),
3271 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
3272 );
3273 }
3274
3275 #[test]
3276 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
3277 let rule = MD033NoInlineHtml::default();
3279 let content = "<%- tp.date.now() -%>";
3280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3281 let result = rule.check(&ctx).unwrap();
3282 assert!(
3283 result.is_empty(),
3284 "Templater trim-newline should not be flagged. Got: {result:?}"
3285 );
3286 }
3287
3288 #[test]
3289 fn test_md033_templater_combined_modifiers_not_flagged() {
3290 let rule = MD033NoInlineHtml::default();
3292 let contents = [
3293 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
3298 for content in contents {
3299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3300 let result = rule.check(&ctx).unwrap();
3301 assert!(
3302 result.is_empty(),
3303 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
3304 );
3305 }
3306 }
3307
3308 #[test]
3309 fn test_md033_templater_multiline_block_not_flagged() {
3310 let rule = MD033NoInlineHtml::default();
3312 let content = r#"<%*
3313const x = 1;
3314const y = 2;
3315tR += x + y;
3316%>"#;
3317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3318 let result = rule.check(&ctx).unwrap();
3319 assert!(
3320 result.is_empty(),
3321 "Templater multi-line block should not be flagged. Got: {result:?}"
3322 );
3323 }
3324
3325 #[test]
3326 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
3327 let rule = MD033NoInlineHtml::default();
3330 let content = "<%* if (x < 5) { tR += 'small'; } %>";
3331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3332 let result = rule.check(&ctx).unwrap();
3333 assert!(
3334 result.is_empty(),
3335 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
3336 );
3337 }
3338
3339 #[test]
3340 fn test_md033_templater_mixed_with_html_only_html_flagged() {
3341 let rule = MD033NoInlineHtml::default();
3343 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
3344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3345 let result = rule.check(&ctx).unwrap();
3346 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
3347 assert!(
3348 result[0].message.contains("<div>"),
3349 "Should flag <div>, got: {}",
3350 result[0].message
3351 );
3352 }
3353
3354 #[test]
3355 fn test_md033_templater_in_heading_not_flagged() {
3356 let rule = MD033NoInlineHtml::default();
3358 let content = "# <% tp.file.title %>";
3359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3360 let result = rule.check(&ctx).unwrap();
3361 assert!(
3362 result.is_empty(),
3363 "Templater in heading should not be flagged. Got: {result:?}"
3364 );
3365 }
3366
3367 #[test]
3368 fn test_md033_templater_multiple_on_same_line_not_flagged() {
3369 let rule = MD033NoInlineHtml::default();
3371 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
3372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3373 let result = rule.check(&ctx).unwrap();
3374 assert!(
3375 result.is_empty(),
3376 "Multiple Templater blocks should not be flagged. Got: {result:?}"
3377 );
3378 }
3379
3380 #[test]
3381 fn test_md033_templater_in_code_block_not_flagged() {
3382 let rule = MD033NoInlineHtml::default();
3384 let content = "```\n<% tp.date.now() %>\n```";
3385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3386 let result = rule.check(&ctx).unwrap();
3387 assert!(
3388 result.is_empty(),
3389 "Templater in code block should not be flagged. Got: {result:?}"
3390 );
3391 }
3392
3393 #[test]
3394 fn test_md033_templater_in_inline_code_not_flagged() {
3395 let rule = MD033NoInlineHtml::default();
3397 let content = "Use `<% tp.date.now() %>` for current date.";
3398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3399 let result = rule.check(&ctx).unwrap();
3400 assert!(
3401 result.is_empty(),
3402 "Templater in inline code should not be flagged. Got: {result:?}"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_md033_templater_also_works_in_standard_flavor() {
3408 let rule = MD033NoInlineHtml::default();
3411 let content = "<% tp.date.now() %> works everywhere.";
3412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3413 let result = rule.check(&ctx).unwrap();
3414 assert!(
3415 result.is_empty(),
3416 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
3417 );
3418 }
3419
3420 #[test]
3421 fn test_md033_templater_empty_tag_not_flagged() {
3422 let rule = MD033NoInlineHtml::default();
3424 let content = "<%>";
3425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3426 let result = rule.check(&ctx).unwrap();
3427 assert!(
3428 result.is_empty(),
3429 "Empty Templater-like tag should not be flagged. Got: {result:?}"
3430 );
3431 }
3432
3433 #[test]
3434 fn test_md033_templater_unclosed_not_flagged() {
3435 let rule = MD033NoInlineHtml::default();
3437 let content = "<% tp.date.now() without closing tag";
3438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3439 let result = rule.check(&ctx).unwrap();
3440 assert!(
3441 result.is_empty(),
3442 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
3443 );
3444 }
3445
3446 #[test]
3447 fn test_md033_templater_with_newlines_inside_not_flagged() {
3448 let rule = MD033NoInlineHtml::default();
3450 let content = r#"<% tp.date.now("YYYY") +
3451"-" +
3452tp.date.now("MM") %>"#;
3453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3454 let result = rule.check(&ctx).unwrap();
3455 assert!(
3456 result.is_empty(),
3457 "Templater with internal newlines should not be flagged. Got: {result:?}"
3458 );
3459 }
3460
3461 #[test]
3462 fn test_md033_erb_style_tags_not_flagged() {
3463 let rule = MD033NoInlineHtml::default();
3466 let content = "<%= variable %> and <% code %> and <%# comment %>";
3467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3468 let result = rule.check(&ctx).unwrap();
3469 assert!(
3470 result.is_empty(),
3471 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
3472 );
3473 }
3474
3475 #[test]
3476 fn test_md033_templater_complex_expression_not_flagged() {
3477 let rule = MD033NoInlineHtml::default();
3479 let content = r#"<%*
3480const file = tp.file.title;
3481const date = tp.date.now("YYYY-MM-DD");
3482const folder = tp.file.folder();
3483tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
3484%>"#;
3485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
3486 let result = rule.check(&ctx).unwrap();
3487 assert!(
3488 result.is_empty(),
3489 "Complex Templater expression should not be flagged. Got: {result:?}"
3490 );
3491 }
3492
3493 #[test]
3494 fn test_md033_percent_sign_variations_not_flagged() {
3495 let rule = MD033NoInlineHtml::default();
3497 let patterns = [
3498 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
3505 for pattern in patterns {
3506 let content = format!("{pattern} content %>");
3507 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3508 let result = rule.check(&ctx).unwrap();
3509 assert!(
3510 result.is_empty(),
3511 "Pattern {pattern} should not be flagged. Got: {result:?}"
3512 );
3513 }
3514 }
3515
3516 #[test]
3522 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
3523 let rule = MD033NoInlineHtml::with_fix(true);
3526 let content = r#"<a href="https://example.com"></a>"#;
3527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3528 let fixed = rule.fix(&ctx).unwrap();
3529
3530 assert_eq!(fixed, "[](https://example.com)",);
3531 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
3532 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
3533 }
3534
3535 #[test]
3536 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
3537 let rule = MD033NoInlineHtml::with_fix(true);
3539 let content =
3540 r#"<a href="https://github.com/repo"></a>"#;
3541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3542 let fixed = rule.fix(&ctx).unwrap();
3543
3544 assert_eq!(
3545 fixed,
3546 "[](https://github.com/repo)"
3547 );
3548 }
3549
3550 #[test]
3551 fn test_md033_fix_img_without_alt_produces_empty_alt() {
3552 let rule = MD033NoInlineHtml::with_fix(true);
3553 let content = r#"<img src="photo.jpg" />"#;
3554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3555 let fixed = rule.fix(&ctx).unwrap();
3556
3557 assert_eq!(fixed, "");
3558 }
3559
3560 #[test]
3561 fn test_md033_fix_leaves_a_tag_inside_an_unnamed_html_block() {
3562 let rule = MD033NoInlineHtml::with_fix(true);
3567 let content = "<span>\n<img src=\"a.png\" />\n\n<img src=\"b.png\" />\n";
3568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3569 assert_eq!(rule.fix(&ctx).unwrap(), "<span>\n<img src=\"a.png\" />\n\n\n");
3570 }
3571
3572 #[test]
3573 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
3574 let rule = MD033NoInlineHtml::with_fix(true);
3576 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
3577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3578 let fixed = rule.fix(&ctx).unwrap();
3579
3580 assert!(
3581 fixed.contains(r"\[brackets\]"),
3582 "Plain text brackets should be escaped: {fixed}"
3583 );
3584 }
3585
3586 #[test]
3587 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
3588 let rule = MD033NoInlineHtml::with_fix(true);
3591 let content = r#"<a href="/link"> see [docs]</a>"#;
3592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3593 let fixed = rule.fix(&ctx).unwrap();
3594
3595 assert!(
3597 fixed.contains(r"\[docs\]"),
3598 "Brackets in mixed image+text content should be escaped: {fixed}"
3599 );
3600 }
3601
3602 #[test]
3603 fn test_md033_fix_img_in_a_end_to_end() {
3604 use crate::config::Config;
3607 use crate::fix_coordinator::FixCoordinator;
3608
3609 let rule = MD033NoInlineHtml::with_fix(true);
3610 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3611
3612 let mut content =
3613 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
3614 .to_string();
3615 let config = Config::default();
3616 let coordinator = FixCoordinator::new();
3617
3618 let result = coordinator
3619 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3620 .unwrap();
3621
3622 assert_eq!(
3623 content, "[](https://github.com/org/repo)",
3624 "End-to-end: <a><img></a> should become valid linked image"
3625 );
3626 assert!(result.converged);
3627 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
3628 }
3629
3630 #[test]
3631 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
3632 use crate::config::Config;
3633 use crate::fix_coordinator::FixCoordinator;
3634
3635 let rule = MD033NoInlineHtml::with_fix(true);
3636 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3637
3638 let mut content =
3639 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
3640 .to_string();
3641 let config = Config::default();
3642 let coordinator = FixCoordinator::new();
3643
3644 let result = coordinator
3645 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3646 .unwrap();
3647
3648 assert_eq!(
3649 content,
3650 "[](https://github.com/org/repo)",
3651 );
3652 assert!(result.converged);
3653 }
3654
3655 #[test]
3665 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
3666 let config = MD033Config {
3667 allowed: vec!["br".to_string()],
3668 table_allowed_elements: None,
3669 ..MD033Config::default()
3670 };
3671 let rule = MD033NoInlineHtml::from_config_struct(config);
3672 let content = "| col |\n|-----|\n| a<br>b |\n";
3673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3674 let result = rule.check(&ctx).unwrap();
3675 assert!(
3676 result.is_empty(),
3677 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
3678 );
3679 }
3680
3681 #[test]
3682 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
3683 let config = MD033Config {
3684 allowed: vec!["br".to_string()],
3685 table_allowed_elements: Some(Vec::new()),
3686 ..MD033Config::default()
3687 };
3688 let rule = MD033NoInlineHtml::from_config_struct(config);
3689 let content = "| col |\n|-----|\n| a<br>b |\n";
3690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3691 let result = rule.check(&ctx).unwrap();
3692 assert_eq!(
3693 result.len(),
3694 1,
3695 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
3696 );
3697 assert_eq!(result[0].line, 3);
3698 }
3699
3700 #[test]
3701 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
3702 let config = MD033Config {
3703 allowed: vec!["br".to_string()],
3704 table_allowed_elements: Some(vec!["img".to_string()]),
3705 ..MD033Config::default()
3706 };
3707 let rule = MD033NoInlineHtml::from_config_struct(config);
3708 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
3711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3712 let result = rule.check(&ctx).unwrap();
3713 assert_eq!(
3714 result.len(),
3715 1,
3716 "table_allowed should override `allowed` inside tables, got {result:?}"
3717 );
3718 assert!(
3719 result[0].message.contains("br"),
3720 "expected the flagged tag to be <br>, got {:?}",
3721 result[0].message
3722 );
3723 }
3724
3725 #[test]
3726 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
3727 let config = MD033Config {
3728 allowed: vec!["br".to_string()],
3729 table_allowed_elements: Some(Vec::new()),
3730 ..MD033Config::default()
3731 };
3732 let rule = MD033NoInlineHtml::from_config_struct(config);
3733 let content = "Paragraph with <br> tag.\n";
3735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3736 let result = rule.check(&ctx).unwrap();
3737 assert!(
3738 result.is_empty(),
3739 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
3740 );
3741 }
3742
3743 #[test]
3744 fn test_md033_table_allowed_kebab_case_parses() {
3745 let toml_str = r#"
3746 allowed-elements = ["br"]
3747 table-allowed-elements = ["img"]
3748 "#;
3749 let config: MD033Config = toml::from_str(toml_str).unwrap();
3750 assert_eq!(config.allowed, vec!["br"]);
3751 assert_eq!(
3752 config.table_allowed_elements.as_deref(),
3753 Some(["img".to_string()].as_slice())
3754 );
3755 }
3756
3757 #[test]
3758 fn test_md033_table_allowed_snake_case_alias_parses() {
3759 let toml_str = r#"
3760 allowed_elements = ["br"]
3761 table_allowed_elements = ["img"]
3762 "#;
3763 let config: MD033Config = toml::from_str(toml_str).unwrap();
3764 assert_eq!(config.allowed, vec!["br"]);
3765 assert_eq!(
3766 config.table_allowed_elements.as_deref(),
3767 Some(["img".to_string()].as_slice())
3768 );
3769 }
3770
3771 #[test]
3772 fn test_md033_table_allowed_default_is_none() {
3773 let cfg = MD033Config::default();
3774 assert!(
3775 cfg.table_allowed_elements.is_none(),
3776 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3777 );
3778 }
3779
3780 #[test]
3781 fn test_md033_table_allowed_case_insensitive() {
3782 let config = MD033Config {
3783 allowed: Vec::new(),
3784 table_allowed_elements: Some(vec!["BR".to_string()]),
3785 ..MD033Config::default()
3786 };
3787 let rule = MD033NoInlineHtml::from_config_struct(config);
3788 let content = "| col |\n|-----|\n| a<br>b |\n";
3789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3790 let result = rule.check(&ctx).unwrap();
3791 assert!(
3792 result.is_empty(),
3793 "table_allowed should be case-insensitive, got {result:?}"
3794 );
3795 }
3796
3797 fn reported_tags_in(rule: &MD033NoInlineHtml, content: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
3806 let ctx = LintContext::new(content, flavor, None);
3807 rule.check(&ctx)
3808 .unwrap()
3809 .into_iter()
3810 .map(|warning| {
3811 warning
3812 .message
3813 .strip_prefix("Inline HTML found: ")
3814 .expect("MD033 reports the tag it found")
3815 .to_string()
3816 })
3817 .collect()
3818 }
3819
3820 fn reported_tags(rule: &MD033NoInlineHtml, content: &str) -> Vec<String> {
3821 reported_tags_in(rule, content, crate::config::MarkdownFlavor::Standard)
3822 }
3823
3824 fn rule_allowing_inside(elements: &[&str]) -> MD033NoInlineHtml {
3825 MD033NoInlineHtml::from_config_struct(MD033Config {
3826 allowed_inside: elements.iter().map(ToString::to_string).collect(),
3827 ..MD033Config::default()
3828 })
3829 }
3830
3831 #[test]
3832 fn test_md033_allowed_inside_permits_the_element_and_its_contents() {
3833 let content = "<details>\n<summary>\n<a href=\"./other.md\">Go there</a>\n</summary>\n<b>bold</b>\n</details>\n\nOutside <b>bold</b>.\n";
3834
3835 let without = reported_tags(&MD033NoInlineHtml::default(), content);
3836 assert_eq!(
3837 without,
3838 vec!["<details>", "<summary>", "<a href=\"./other.md\">", "<b>", "<b>"],
3839 "control: every tag is reported without the option"
3840 );
3841
3842 let with = reported_tags(&rule_allowing_inside(&["details"]), content);
3843 assert_eq!(
3844 with,
3845 vec!["<b>"],
3846 "only the tag outside the details element is left, got {with:?}"
3847 );
3848 }
3849
3850 #[test]
3851 fn test_md033_allowed_inside_ends_at_the_matching_closing_tag() {
3852 let content = "<details>\n<details>\n<b>x</b>\n</details>\n<i>y</i>\n</details>\n<b>z</b>\n";
3854 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3855 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3856
3857 let unclosed = "<details>\n\n<b>x</b>\n\n<i>y</i>\n";
3859 assert!(
3860 reported_tags(&rule_allowing_inside(&["details"]), unclosed).is_empty(),
3861 "an unclosed element reaches the end of the document"
3862 );
3863 }
3864
3865 #[test]
3866 fn test_md033_allowed_inside_mismatched_and_unclosed_elements() {
3867 let content = "<details><section><b>in</b></details><i>out</i></section><b>out</b>\
3868 <details><section/><i>in</i>";
3869 assert_eq!(
3870 reported_tags(&rule_allowing_inside(&["details", "section"]), content),
3871 vec!["<i>", "<b>"]
3872 );
3873 }
3874
3875 #[test]
3876 fn test_md033_allowed_inside_ignores_an_element_quoted_in_a_code_block() {
3877 let content = "```html\n<details>\n```\n\n<b>x</b>\n";
3878 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3879 assert_eq!(
3880 reported,
3881 vec!["<b>"],
3882 "a code block quotes the element, it does not open one: {reported:?}"
3883 );
3884 }
3885
3886 #[test]
3887 fn test_md033_allowed_inside_a_void_element_holds_nothing() {
3888 let content = "a<br>b <b>x</b>\n";
3890 let reported = reported_tags(&rule_allowing_inside(&["br"]), content);
3891 assert_eq!(
3892 reported,
3893 vec!["<br>", "<b>"],
3894 "a void element must not swallow the rest of the document: {reported:?}"
3895 );
3896 }
3897
3898 #[test]
3899 fn test_md033_allowed_inside_is_case_insensitive() {
3900 let content = "<DETAILS>\n<b>x</b>\n</DETAILS>\n<i>y</i>\n";
3901 let reported = reported_tags(&rule_allowing_inside(&["Details"]), content);
3902 assert_eq!(reported, vec!["<i>"], "got {reported:?}");
3903 }
3904
3905 #[test]
3906 fn test_md033_allowed_inside_takes_no_part_in_disallowed_mode() {
3907 let config = MD033Config {
3908 allowed_inside: vec!["details".to_string()],
3909 disallowed: vec!["b".to_string()],
3910 ..MD033Config::default()
3911 };
3912 let rule = MD033NoInlineHtml::from_config_struct(config);
3913 let content = "<details>\n<b>x</b>\n<kbd>k</kbd>\n</details>\n";
3914 let reported = reported_tags(&rule, content);
3915 assert_eq!(
3916 reported,
3917 vec!["<b>"],
3918 "a denylist names what is wrong wherever it appears: {reported:?}"
3919 );
3920 }
3921
3922 #[test]
3923 fn test_md033_allowed_inside_yields_to_an_explicit_table_allowlist() {
3924 let content = "| col |\n|-----|\n| <details><b>x</b></details> |\n";
3925
3926 let unset = reported_tags(&rule_allowing_inside(&["details"]), content);
3927 assert!(
3928 unset.is_empty(),
3929 "without a table allowlist the element applies inside a cell too: {unset:?}"
3930 );
3931
3932 let config = MD033Config {
3933 allowed_inside: vec!["details".to_string()],
3934 table_allowed_elements: Some(Vec::new()),
3935 ..MD033Config::default()
3936 };
3937 let rule = MD033NoInlineHtml::from_config_struct(config);
3938 let reported = reported_tags(&rule, content);
3939 assert_eq!(
3940 reported,
3941 vec!["<details>", "<b>"],
3942 "an explicit table allowlist decides inside a cell: {reported:?}"
3943 );
3944 }
3945
3946 fn rule_allowing(elements: &[&str]) -> MD033NoInlineHtml {
3954 MD033NoInlineHtml::with_allowed(elements.iter().map(ToString::to_string).collect())
3955 }
3956
3957 #[test]
3958 fn test_md033_no_markdown_equivalent_reports_only_what_markdown_can_write() {
3959 let content = "Press <kbd>Ctrl</kbd>, <b>bold</b>, <mark>hi</mark>, <abbr title=\"x\">A</abbr>, <em>i</em>, <details>d</details>\n";
3960
3961 let control = reported_tags(&MD033NoInlineHtml::default(), content);
3962 assert_eq!(control.len(), 6, "control: every tag is reported: {control:?}");
3963
3964 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3965 assert_eq!(
3966 reported,
3967 vec!["<b>", "<em>"],
3968 "only the elements with Markdown syntax are left: {reported:?}"
3969 );
3970 }
3971
3972 #[test]
3973 fn test_md033_no_markdown_equivalent_keeps_reporting_gfm_filtered_tags() {
3974 let content = "<kbd>k</kbd>\n\n<script>alert(1)</script>\n\n<iframe src=\"x\"></iframe>\n";
3976 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3977 assert_eq!(reported, vec!["<script>", "<iframe src=\"x\">"], "got {reported:?}");
3978 }
3979
3980 #[test]
3981 fn test_md033_no_markdown_equivalent_follows_the_flavor() {
3982 let content = "H<sub>2</sub>O, x<sup>2</sup>, <mark>hi</mark>, <kbd>k</kbd>\n";
3983 let rule = rule_allowing(&["no-markdown-equivalent"]);
3984
3985 assert!(
3986 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Standard).is_empty(),
3987 "standard Markdown writes none of these"
3988 );
3989 assert_eq!(
3990 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Pandoc),
3991 vec!["<sub>", "<sup>"],
3992 "Pandoc writes ~sub~ and ^sup^"
3993 );
3994 assert_eq!(
3995 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Obsidian),
3996 vec!["<mark>"],
3997 "Obsidian writes ==highlight=="
3998 );
3999 }
4000
4001 #[test]
4002 fn test_md033_no_markdown_equivalent_permits_a_line_break_inside_a_table_cell() {
4003 let content = "| col |\n|-----|\n| a<br>b |\n\nOutside a<br>b.\n";
4006 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
4007 assert_eq!(reported, vec!["<br>"], "got {reported:?}");
4008 }
4009
4010 #[test]
4011 fn test_md033_no_markdown_equivalent_composes_with_named_elements() {
4012 let content = "<kbd>k</kbd> <b>bold</b> <em>italic</em>\n";
4013 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent", "b"]), content);
4014 assert_eq!(
4015 reported,
4016 vec!["<em>"],
4017 "a named element joins the ones the sentinel permits: {reported:?}"
4018 );
4019 }
4020
4021 #[test]
4022 fn test_md033_no_markdown_equivalent_reads_the_snake_case_spelling() {
4023 let content = "<kbd>k</kbd> <b>bold</b>\n";
4025 let reported = reported_tags(&rule_allowing(&["no_markdown_equivalent"]), content);
4026 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
4027 }
4028
4029 #[test]
4030 fn test_md033_no_markdown_equivalent_takes_no_part_in_disallowed_mode() {
4031 let config = MD033Config {
4032 allowed: vec!["no-markdown-equivalent".to_string()],
4033 disallowed: vec!["kbd".to_string()],
4034 ..MD033Config::default()
4035 };
4036 let rule = MD033NoInlineHtml::from_config_struct(config);
4037 let reported = reported_tags(&rule, "<kbd>k</kbd> <b>bold</b>\n");
4038 assert_eq!(
4039 reported,
4040 vec!["<kbd>"],
4041 "a denylist names what is wrong wherever it appears: {reported:?}"
4042 );
4043 }
4044}