1use crate::config::MarkdownFlavor;
7use crate::lint_context::HtmlTag;
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
71impl Default for MD033NoInlineHtml {
72 fn default() -> Self {
73 Self::from_config_struct(MD033Config::default())
74 }
75}
76
77impl MD033NoInlineHtml {
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
83 Self::from_config_struct(MD033Config {
84 allowed: allowed_vec,
85 ..MD033Config::default()
86 })
87 }
88
89 pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
90 Self::from_config_struct(MD033Config {
91 disallowed: disallowed_vec,
92 ..MD033Config::default()
93 })
94 }
95
96 pub fn with_fix(fix: bool) -> Self {
98 Self::from_config_struct(MD033Config {
99 fix,
100 ..MD033Config::default()
101 })
102 }
103
104 pub fn from_config_struct(config: MD033Config) -> Self {
107 let allowed = config.allowed_set();
108 let allowed_inside = config.allowed_inside_set();
109 let table_allowed = config.table_allowed_set();
110 let disallowed = config.disallowed_set();
111 let drop_attributes = config.drop_attributes_set();
112 let strip_wrapper_elements = config.strip_wrapper_elements_set();
113 let allows_no_markdown_equivalent = config.allows_no_markdown_equivalent();
114 let table_allows_no_markdown_equivalent = config.table_allows_no_markdown_equivalent();
115 Self {
116 config,
117 allowed,
118 allowed_inside,
119 table_allowed,
120 disallowed,
121 drop_attributes,
122 strip_wrapper_elements,
123 allows_no_markdown_equivalent,
124 table_allows_no_markdown_equivalent,
125 }
126 }
127
128 #[inline]
132 fn extract_tag_name(tag: &str) -> String {
133 let trimmed = tag.trim_start_matches('<').trim_start_matches('/');
134 trimmed
135 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
136 .next()
137 .unwrap_or("")
138 .to_lowercase()
139 }
140
141 #[inline]
144 fn tag_in_set(set: &HashSet<String>, tag: &str) -> bool {
145 if set.is_empty() {
146 return false;
147 }
148 set.contains(&Self::extract_tag_name(tag))
149 }
150
151 #[inline]
153 fn is_tag_allowed(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
154 if !self.allows_no_markdown_equivalent {
155 return Self::tag_in_set(&self.allowed, tag);
156 }
157 let name = Self::extract_tag_name(tag);
158 self.allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, false)
159 }
160
161 #[inline]
164 fn is_tag_allowed_in_table(&self, tag: &str, flavor: MarkdownFlavor) -> bool {
165 if !self.table_allows_no_markdown_equivalent {
166 return Self::tag_in_set(&self.table_allowed, tag);
167 }
168 let name = Self::extract_tag_name(tag);
169 self.table_allowed.contains(&name) || is_permitted_without_markdown_equivalent(&name, flavor, true)
170 }
171
172 #[inline]
175 fn is_void_element(tag_name: &str) -> bool {
176 is_void_element(tag_name)
177 }
178
179 fn is_inert_markup(ctx: &crate::lint_context::LintContext, html_tag: &HtmlTag) -> bool {
187 ctx.line_info(html_tag.line).is_some_and(|info| {
188 info.in_code_block
189 || info.in_pymdown_block
190 || info.is_kramdown_block_ial
191 || info.in_front_matter
192 || info.in_math_block
193 }) || ctx.is_in_html_comment(html_tag.byte_offset)
194 || ctx.is_in_mdx_comment(html_tag.byte_offset)
195 || ctx.is_in_link_title(html_tag.byte_offset)
196 || ctx.is_byte_offset_in_code_span(html_tag.byte_offset)
197 || ctx.is_in_shortcode(html_tag.byte_offset)
198 }
199
200 fn allowed_inside_ranges(&self, ctx: &crate::lint_context::LintContext) -> Vec<Range<usize>> {
206 if self.allowed_inside.is_empty() {
207 return Vec::new();
208 }
209
210 let html_tags = ctx.html_tags();
211 let mut ranges = Vec::new();
212 let mut open: Vec<(&str, usize)> = Vec::new();
213
214 for html_tag in html_tags.iter() {
215 if !self.allowed_inside.contains(&html_tag.tag_name) || Self::is_inert_markup(ctx, html_tag) {
216 continue;
217 }
218 if html_tag.is_closing {
219 if let Some(index) = open.iter().rposition(|(name, _)| *name == html_tag.tag_name) {
222 ranges.extend(open.drain(index..).map(|(_, start)| start..html_tag.byte_end));
223 }
224 } else if !html_tag.is_self_closing && !Self::is_void_element(&html_tag.tag_name) {
225 open.push((html_tag.tag_name.as_str(), html_tag.byte_offset));
226 }
227 }
228
229 ranges.extend(open.into_iter().map(|(_, start)| start..ctx.content.len()));
230 ranges
231 }
232
233 #[inline]
235 fn is_inside_allowed_element(ranges: &[Range<usize>], byte_offset: usize) -> bool {
236 ranges.iter().any(|range| range.contains(&byte_offset))
237 }
238
239 #[inline]
241 fn is_tag_disallowed(&self, tag: &str) -> bool {
242 Self::tag_in_set(&self.disallowed, tag)
243 }
244
245 #[inline]
247 fn is_disallowed_mode(&self) -> bool {
248 self.config.is_disallowed_mode()
249 }
250
251 #[inline]
253 fn is_html_comment(&self, tag: &str) -> bool {
254 tag.starts_with("<!--") && tag.ends_with("-->")
255 }
256
257 #[inline]
262 fn is_html_element_or_custom(tag_name: &str) -> bool {
263 const HTML_ELEMENTS: &[&str] = &[
265 "a",
266 "abbr",
267 "acronym",
268 "address",
269 "applet",
270 "area",
271 "article",
272 "aside",
273 "audio",
274 "b",
275 "base",
276 "basefont",
277 "bdi",
278 "bdo",
279 "big",
280 "blockquote",
281 "body",
282 "br",
283 "button",
284 "canvas",
285 "caption",
286 "center",
287 "cite",
288 "code",
289 "col",
290 "colgroup",
291 "data",
292 "datalist",
293 "dd",
294 "del",
295 "details",
296 "dfn",
297 "dialog",
298 "dir",
299 "div",
300 "dl",
301 "dt",
302 "em",
303 "embed",
304 "fieldset",
305 "figcaption",
306 "figure",
307 "font",
308 "footer",
309 "form",
310 "frame",
311 "frameset",
312 "h1",
313 "h2",
314 "h3",
315 "h4",
316 "h5",
317 "h6",
318 "head",
319 "header",
320 "hgroup",
321 "hr",
322 "html",
323 "i",
324 "iframe",
325 "img",
326 "input",
327 "ins",
328 "isindex",
329 "kbd",
330 "label",
331 "legend",
332 "li",
333 "link",
334 "main",
335 "map",
336 "mark",
337 "marquee",
338 "math",
339 "menu",
340 "meta",
341 "meter",
342 "nav",
343 "noembed",
344 "noframes",
345 "noscript",
346 "object",
347 "ol",
348 "optgroup",
349 "option",
350 "output",
351 "p",
352 "param",
353 "picture",
354 "plaintext",
355 "pre",
356 "progress",
357 "q",
358 "rp",
359 "rt",
360 "ruby",
361 "s",
362 "samp",
363 "script",
364 "search",
365 "section",
366 "select",
367 "slot",
368 "small",
369 "source",
370 "span",
371 "strike",
372 "strong",
373 "style",
374 "sub",
375 "summary",
376 "sup",
377 "svg",
378 "table",
379 "tbody",
380 "td",
381 "template",
382 "textarea",
383 "tfoot",
384 "th",
385 "thead",
386 "time",
387 "title",
388 "tr",
389 "track",
390 "tt",
391 "u",
392 "ul",
393 "var",
394 "video",
395 "wbr",
396 "xmp",
397 ];
398
399 let lower = tag_name.to_ascii_lowercase();
400 if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
401 return true;
402 }
403 tag_name.contains('-')
405 }
406
407 #[inline]
409 fn is_likely_type_annotation(&self, tag: &str) -> bool {
410 const COMMON_TYPES: &[&str] = &[
412 "any",
413 "apiresponse",
414 "array",
415 "bigint",
416 "config",
417 "data",
418 "date",
419 "e",
420 "element",
421 "error",
422 "function",
423 "generator",
424 "item",
425 "iterator",
426 "k",
427 "map",
428 "node",
429 "null",
430 "number",
431 "options",
432 "params",
433 "promise",
434 "regexp",
435 "request",
436 "response",
437 "result",
438 "set",
439 "string",
440 "symbol",
441 "t",
442 "u",
443 "undefined",
444 "userdata",
445 "v",
446 "void",
447 "weakmap",
448 "weakset",
449 ];
450
451 let tag_content = tag
452 .trim_start_matches('<')
453 .trim_end_matches('>')
454 .trim_start_matches('/');
455 let tag_name = tag_content
456 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
457 .next()
458 .unwrap_or("");
459
460 if !tag_content.contains(' ') && !tag_content.contains('=') {
462 let lower = tag_name.to_ascii_lowercase();
463 COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
464 } else {
465 false
466 }
467 }
468
469 #[inline]
471 fn is_email_address(&self, tag: &str) -> bool {
472 let content = tag.trim_start_matches('<').trim_end_matches('>');
473 content.contains('@')
475 && content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
476 && content.split('@').count() == 2
477 && content.split('@').all(|part| !part.is_empty())
478 }
479
480 #[inline]
482 fn has_markdown_attribute(&self, tag: &str) -> bool {
483 tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
486 }
487
488 #[inline]
495 fn has_jsx_attributes(tag: &str) -> bool {
496 tag.contains("className")
498 || tag.contains("htmlFor")
499 || tag.contains("dangerouslySetInnerHTML")
500 || tag.contains("onClick")
502 || tag.contains("onChange")
503 || tag.contains("onSubmit")
504 || tag.contains("onFocus")
505 || tag.contains("onBlur")
506 || tag.contains("onKeyDown")
507 || tag.contains("onKeyUp")
508 || tag.contains("onKeyPress")
509 || tag.contains("onMouseDown")
510 || tag.contains("onMouseUp")
511 || tag.contains("onMouseEnter")
512 || tag.contains("onMouseLeave")
513 || tag.contains("={")
515 }
516
517 #[inline]
519 fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
520 let content = tag.trim_start_matches('<').trim_end_matches('>');
521 content.starts_with("http://")
523 || content.starts_with("https://")
524 || content.starts_with("ftp://")
525 || content.starts_with("ftps://")
526 || content.starts_with("mailto:")
527 }
528
529 #[inline]
530 fn is_relaxed_fix_mode(&self) -> bool {
531 self.config.fix_mode == MD033FixMode::Relaxed
532 }
533
534 #[inline]
535 fn is_droppable_attribute(&self, attr_name: &str) -> bool {
536 if attr_name.starts_with("on") && attr_name.len() > 2 {
539 return false;
540 }
541 self.drop_attributes.contains(attr_name)
542 || (attr_name.starts_with("data-")
543 && (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
544 }
545
546 #[inline]
547 fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
548 self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
549 }
550
551 fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
562 if byte_offset == 0 {
563 return false;
564 }
565 let before = content[..byte_offset].trim_end();
566 if !before.ends_with('>') || before.ends_with("->") {
567 return false;
568 }
569 if let Some(last_lt) = before.rfind('<') {
570 let potential_tag = &before[last_lt..];
571 if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
572 return false;
573 }
574 let parent_name = potential_tag
575 .trim_start_matches('<')
576 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
577 .next()
578 .unwrap_or("")
579 .to_lowercase();
580 if !self.strip_wrapper_elements.contains(&parent_name) {
581 return false;
582 }
583 let wrapper_before = before[..last_lt].trim_end();
585 if wrapper_before.ends_with('>')
586 && !wrapper_before.ends_with("->")
587 && let Some(outer_lt) = wrapper_before.rfind('<')
588 && let outer_tag = &wrapper_before[outer_lt..]
589 && !outer_tag.starts_with("</")
590 && !outer_tag.starts_with("<!--")
591 {
592 return false;
593 }
594 return true;
595 }
596 false
597 }
598
599 fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
602 if inner_content.contains('<') {
604 return None;
605 }
606 if inner_content.contains('&') && inner_content.contains(';') {
609 let has_entity = inner_content
611 .split('&')
612 .skip(1)
613 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
614 if has_entity {
615 return None;
616 }
617 }
618 match tag_name {
619 "em" | "i" => Some(format!("*{inner_content}*")),
620 "strong" | "b" => Some(format!("**{inner_content}**")),
621 "code" => {
622 if inner_content.contains('`') {
624 Some(format!("`` {inner_content} ``"))
625 } else {
626 Some(format!("`{inner_content}`"))
627 }
628 }
629 _ => None,
630 }
631 }
632
633 fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
635 match tag_name {
636 "br" => match self.config.br_style {
637 md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
638 md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
639 },
640 "hr" => Some("\n---\n".to_string()),
641 "img" => self.convert_img_to_markdown(opening_tag),
642 _ => None,
643 }
644 }
645
646 fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
649 let mut attrs = Vec::new();
650
651 let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
653
654 let attr_start = tag_content
658 .char_indices()
659 .find(|(_, c)| c.is_whitespace())
660 .map_or(tag_content.len(), |(i, c)| i + c.len_utf8());
661
662 if attr_start >= tag_content.len() {
663 return attrs;
664 }
665
666 let attr_str = &tag_content[attr_start..];
667 let mut chars = attr_str.chars().peekable();
668
669 while chars.peek().is_some() {
670 while chars.peek().is_some_and(|c| c.is_whitespace()) {
672 chars.next();
673 }
674
675 if chars.peek().is_none() {
676 break;
677 }
678
679 let mut attr_name = String::new();
681 while let Some(&c) = chars.peek() {
682 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
683 break;
684 }
685 attr_name.push(c);
686 chars.next();
687 }
688
689 if attr_name.is_empty() {
690 break;
691 }
692
693 while chars.peek().is_some_and(|c| c.is_whitespace()) {
695 chars.next();
696 }
697
698 if chars.peek() == Some(&'=') {
700 chars.next(); while chars.peek().is_some_and(|c| c.is_whitespace()) {
704 chars.next();
705 }
706
707 let mut value = String::new();
709 if let Some("e) = chars.peek() {
710 if quote == '"' || quote == '\'' {
711 chars.next(); for c in chars.by_ref() {
713 if c == quote {
714 break;
715 }
716 value.push(c);
717 }
718 } else {
719 while let Some(&c) = chars.peek() {
721 if c.is_whitespace() || c == '>' || c == '/' {
722 break;
723 }
724 value.push(c);
725 chars.next();
726 }
727 }
728 }
729 attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
730 } else {
731 attrs.push((attr_name.to_ascii_lowercase(), None));
733 }
734 }
735
736 attrs
737 }
738
739 fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
743 let attrs = Self::parse_attributes(tag);
744 let attr_lower = attr_name.to_ascii_lowercase();
745
746 attrs
747 .into_iter()
748 .find(|(name, _)| name == &attr_lower)
749 .and_then(|(_, value)| value)
750 }
751
752 fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
755 let attrs = Self::parse_attributes(tag);
756
757 const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
761 "class",
762 "id",
763 "style",
764 "target",
765 "rel",
766 "download",
767 "referrerpolicy",
768 "crossorigin",
769 "loading",
770 "decoding",
771 "fetchpriority",
772 "sizes",
773 "srcset",
774 "usemap",
775 "ismap",
776 "width",
777 "height",
778 "name", "data-*", ];
781
782 for (attr_name, _) in attrs {
783 if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
785 continue;
786 }
787
788 if self.is_relaxed_fix_mode() {
789 if self.is_droppable_attribute(&attr_name) {
790 continue;
791 }
792 return true;
793 }
794
795 for prefix in DANGEROUS_ATTR_PREFIXES {
797 if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
798 return true;
799 }
800 }
801
802 if attr_name.starts_with("data-") {
804 return true;
805 }
806
807 if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
809 return true;
810 }
811 }
812
813 false
814 }
815
816 fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
819 let href = Self::extract_attribute(opening_tag, "href")?;
821
822 if !MD033Config::is_safe_url(&href) {
824 return None;
825 }
826
827 if inner_content.contains('<') {
829 return None;
830 }
831
832 if inner_content.contains('&') && inner_content.contains(';') {
834 let has_entity = inner_content
835 .split('&')
836 .skip(1)
837 .any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
838 if has_entity {
839 return None;
840 }
841 }
842
843 let title = Self::extract_attribute(opening_tag, "title");
845
846 if self.has_extra_attributes(opening_tag, &["href", "title"]) {
848 return None;
849 }
850
851 let trimmed_inner = inner_content.trim();
856 let is_markdown_image =
857 trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
858 if let Some(bracket_close) = trimmed_inner.rfind("](") {
861 let after_paren = &trimmed_inner[bracket_close + 2..];
862 after_paren.ends_with(')')
864 && after_paren.chars().filter(|&c| c == ')').count()
865 >= after_paren.chars().filter(|&c| c == '(').count()
866 } else {
867 false
868 }
869 };
870 let escaped_text = if is_markdown_image {
871 trimmed_inner.to_string()
872 } else {
873 inner_content.replace('[', r"\[").replace(']', r"\]")
876 };
877
878 let escaped_url = href.replace('(', "%28").replace(')', "%29");
880
881 if let Some(title_text) = title {
883 let escaped_title = title_text.replace('"', r#"\""#);
885 Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
886 } else {
887 Some(format!("[{escaped_text}]({escaped_url})"))
888 }
889 }
890
891 fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
894 let src = Self::extract_attribute(tag, "src")?;
896
897 if !MD033Config::is_safe_url(&src) {
899 return None;
900 }
901
902 let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
904
905 let title = Self::extract_attribute(tag, "title");
907
908 if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
910 return None;
911 }
912
913 let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
915
916 let escaped_url = src.replace('(', "%28").replace(')', "%29");
918
919 if let Some(title_text) = title {
921 let escaped_title = title_text.replace('"', r#"\""#);
923 Some(format!(""))
924 } else {
925 Some(format!(""))
926 }
927 }
928
929 fn has_significant_attributes(opening_tag: &str) -> bool {
931 let tag_content = opening_tag
933 .trim_start_matches('<')
934 .trim_end_matches('>')
935 .trim_end_matches('/');
936
937 let parts: Vec<&str> = tag_content.split_whitespace().collect();
939 parts.len() > 1
940 }
941
942 fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
945 if tag_byte_start > 0 {
947 let before = &content[..tag_byte_start];
948 let before_trimmed = before.trim_end();
949 if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
950 if let Some(last_lt) = before_trimmed.rfind('<') {
952 let potential_tag = &before_trimmed[last_lt..];
953 if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
955 return true;
956 }
957 }
958 }
959 }
960 if tag_byte_end < content.len() {
962 let after = &content[tag_byte_end..];
963 let after_trimmed = after.trim_start();
964 if after_trimmed.starts_with("</") {
965 return true;
966 }
967 }
968 false
969 }
970
971 fn opens_an_unnamed_block(ctx: &crate::lint_context::LintContext, line_num: usize, tag_byte_start: usize) -> bool {
976 let Some(info) = ctx.line_info(line_num) else {
977 return false;
978 };
979 let line = info.content(ctx.content);
980 let trimmed = line.trim_start();
981 info.byte_offset + (line.len() - trimmed.len()) == tag_byte_start
982 && !(line_num > 1 && ctx.is_in_html_block(line_num - 1))
983 && crate::utils::html_block::parse_html_block_start(trimmed).is_none()
984 }
985
986 fn calculate_fix(
1001 &self,
1002 content: &str,
1003 closing_tags: &ClosingTagIndex,
1004 opening_tag: &str,
1005 tag_byte_start: usize,
1006 in_html_block: bool,
1007 ) -> Option<(std::ops::Range<usize>, String)> {
1008 if !self.config.fix {
1010 return None;
1011 }
1012
1013 let tag_name = opening_tag
1015 .trim_start_matches('<')
1016 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
1017 .next()?
1018 .to_lowercase();
1019
1020 let is_self_closing =
1022 opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
1023
1024 if is_self_closing {
1025 let block_ok = !in_html_block
1031 || (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
1032 if self.config.fix
1033 && MD033Config::is_safe_fixable_tag(&tag_name)
1034 && block_ok
1035 && let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
1036 {
1037 return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
1038 }
1039 return None;
1042 }
1043
1044 let search_start = tag_byte_start + opening_tag.len();
1046 if let Some(closing_byte_start) = closing_tags.first_at_or_after(&tag_name, search_start) {
1047 let closing_byte_end = closing_byte_start + "</>".len() + tag_name.len();
1048
1049 let inner_content = &content[search_start..closing_byte_start];
1051
1052 if self.config.fix && self.is_strippable_wrapper(&tag_name) {
1059 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1060 return None;
1061 }
1062 if inner_content.contains('<') {
1063 return None;
1064 }
1065 return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
1066 }
1067
1068 if in_html_block {
1071 return None;
1072 }
1073
1074 if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
1077 return None;
1078 }
1079
1080 if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
1082 if tag_name == "a" {
1084 if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
1085 return Some((tag_byte_start..closing_byte_end, markdown));
1086 }
1087 return None;
1089 }
1090
1091 if Self::has_significant_attributes(opening_tag) {
1093 return None;
1096 }
1097 if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
1098 return Some((tag_byte_start..closing_byte_end, markdown));
1099 }
1100 return None;
1103 }
1104
1105 return None;
1108 }
1109
1110 None
1112 }
1113}
1114
1115impl Rule for MD033NoInlineHtml {
1116 fn name(&self) -> &'static str {
1117 "MD033"
1118 }
1119
1120 fn description(&self) -> &'static str {
1121 "Inline HTML is not allowed"
1122 }
1123
1124 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1125 let content = ctx.content;
1126
1127 if content.is_empty() || !ctx.likely_has_html() {
1129 return Ok(Vec::new());
1130 }
1131
1132 if !HTML_TAG_QUICK_CHECK.is_match(content) {
1134 return Ok(Vec::new());
1135 }
1136
1137 let mut warnings = Vec::new();
1138
1139 let html_tags = ctx.html_tags();
1141
1142 let allowed_inside_ranges = if self.is_disallowed_mode() {
1144 Vec::new()
1145 } else {
1146 self.allowed_inside_ranges(ctx)
1147 };
1148
1149 let closing_tags = if self.config.fix {
1150 ClosingTagIndex::new(content)
1151 } else {
1152 ClosingTagIndex::default()
1153 };
1154
1155 for html_tag in html_tags.iter() {
1156 if html_tag.is_closing {
1158 continue;
1159 }
1160
1161 let line_num = html_tag.line;
1162 let tag_byte_start = html_tag.byte_offset;
1163
1164 let tag = &content[html_tag.byte_offset..html_tag.byte_end];
1166
1167 if Self::is_inert_markup(ctx, html_tag) {
1171 continue;
1172 }
1173
1174 if self.is_html_comment(tag) {
1176 continue;
1177 }
1178
1179 if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(char::is_uppercase) {
1181 continue;
1182 }
1183
1184 if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
1186 continue;
1187 }
1188
1189 if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
1192 continue;
1193 }
1194
1195 if !Self::is_html_element_or_custom(&html_tag.tag_name) {
1197 continue;
1198 }
1199
1200 if self.is_likely_type_annotation(tag) {
1202 continue;
1203 }
1204
1205 if self.is_email_address(tag) {
1207 continue;
1208 }
1209
1210 if self.is_url_in_angle_brackets(tag) {
1212 continue;
1213 }
1214
1215 if self.is_disallowed_mode() {
1221 if !self.is_tag_disallowed(tag) {
1222 continue;
1223 }
1224 } else if ctx.is_in_table_block(line_num) {
1225 let inside_allowed_element = self.config.table_allowed_elements.is_none()
1228 && Self::is_inside_allowed_element(&allowed_inside_ranges, tag_byte_start);
1229 if self.is_tag_allowed_in_table(tag, ctx.flavor) || inside_allowed_element {
1230 continue;
1231 }
1232 } else if self.is_tag_allowed(tag, ctx.flavor)
1233 || Self::is_inside_allowed_element(&allowed_inside_ranges, tag_byte_start)
1234 {
1235 continue;
1236 }
1237
1238 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
1240 continue;
1241 }
1242
1243 let in_html_block =
1248 ctx.is_in_html_block(line_num) && !Self::opens_an_unnamed_block(ctx, line_num, tag_byte_start);
1249
1250 let fix = self
1252 .calculate_fix(content, &closing_tags, tag, tag_byte_start, in_html_block)
1253 .map(|(range, replacement)| Fix::new(range, replacement));
1254
1255 let (end_line, end_col) = if html_tag.byte_end > 0 {
1258 ctx.offset_to_line_col(html_tag.byte_end - 1)
1259 } else {
1260 (line_num, html_tag.end_col + 1)
1261 };
1262
1263 warnings.push(LintWarning {
1265 rule_name: Some(self.name().to_string()),
1266 line: line_num,
1267 column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
1271 severity: Severity::Warning,
1272 fix,
1273 });
1274 }
1275
1276 Ok(warnings)
1277 }
1278
1279 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1280 if !self.config.fix {
1282 return Ok(ctx.content.to_string());
1283 }
1284
1285 let warnings = self.check(ctx)?;
1287 let warnings =
1288 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1289
1290 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
1292 return Ok(ctx.content.to_string());
1293 }
1294
1295 let mut fixes: Vec<_> = warnings
1297 .iter()
1298 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
1299 .collect();
1300 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
1301
1302 let mut result = ctx.content.to_string();
1304 for (start, end, replacement) in fixes {
1305 if start < result.len() && end <= result.len() && start <= end {
1306 result.replace_range(start..end, replacement);
1307 }
1308 }
1309
1310 Ok(result)
1311 }
1312
1313 fn fix_capability(&self) -> crate::rule::FixCapability {
1314 if self.config.fix {
1315 crate::rule::FixCapability::FullyFixable
1316 } else {
1317 crate::rule::FixCapability::Unfixable
1318 }
1319 }
1320
1321 fn category(&self) -> RuleCategory {
1323 RuleCategory::Html
1324 }
1325
1326 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1328 ctx.content.is_empty() || !ctx.likely_has_html()
1329 }
1330
1331 fn as_any(&self) -> &dyn std::any::Any {
1332 self
1333 }
1334
1335 crate::impl_rule_config_methods!(MD033Config);
1336
1337 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1338 let mut aliases = std::collections::HashMap::new();
1339 aliases.insert("allowed".to_string(), "allowed-elements".to_string());
1341 aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
1342 aliases.insert("table-allowed".to_string(), "table-allowed-elements".to_string());
1345 aliases.insert("table_allowed".to_string(), "table-allowed-elements".to_string());
1346 Some(aliases)
1347 }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use crate::lint_context::LintContext;
1354 use crate::rule::Rule;
1355
1356 #[test]
1357 fn closing_tag_index_finds_what_a_case_insensitive_search_finds() {
1358 let documents = [
1359 "<em>a</em> b </EM> </Em>",
1360 "</</em> <//em> </ em> </em > </em/> </>",
1361 "x</em",
1362 "</em></em></strong></em>",
1363 "</span-x></span></span:y>",
1364 "</é></É></em>",
1365 "",
1366 ];
1367 for content in documents {
1368 let index = ClosingTagIndex::new(content);
1369 for tag_name in ["em", "strong", "span", "span-x", "span:y", "é"] {
1370 let needle = format!("</{tag_name}>");
1371 let lowered = content.to_ascii_lowercase();
1372 for offset in 0..=content.len() {
1373 if !content.is_char_boundary(offset) {
1374 continue;
1375 }
1376 let expected = lowered[offset..].find(&needle).map(|pos| offset + pos);
1377 assert_eq!(
1378 index.first_at_or_after(tag_name, offset),
1379 expected,
1380 "{tag_name:?} from {offset} in {content:?}"
1381 );
1382 }
1383 }
1384 }
1385 }
1386
1387 #[test]
1388 fn check_without_fix_enabled_offers_no_fixes() {
1389 let content = "Text <em>x</em> and <br/> and <p>y</p>\n";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let warnings = MD033NoInlineHtml::default().check(&ctx).unwrap();
1392 assert!(!warnings.is_empty());
1393 assert!(warnings.iter().all(|w| w.fix.is_none()));
1394 }
1395
1396 fn relaxed_fix_rule() -> MD033NoInlineHtml {
1397 let config = MD033Config {
1398 fix: true,
1399 fix_mode: MD033FixMode::Relaxed,
1400 ..MD033Config::default()
1401 };
1402 MD033NoInlineHtml::from_config_struct(config)
1403 }
1404
1405 #[test]
1406 fn test_md033_basic_html() {
1407 let rule = MD033NoInlineHtml::default();
1408 let content = "<div>Some content</div>";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
1414 }
1415
1416 #[test]
1417 fn test_md033_ignores_backslash_escaped_custom_element() {
1418 let rule = MD033NoInlineHtml::default();
1419 let content = r"Press \<x-keyboard> to continue, but flag <x-keyboard>.";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421
1422 let result = rule.check(&ctx).unwrap();
1423 assert_eq!(result.len(), 1);
1424 assert_eq!(result[0].column, 43);
1425 assert_eq!(result[0].message, "Inline HTML found: <x-keyboard>");
1426 }
1427
1428 #[test]
1429 fn test_md033_front_matter() {
1430 let rule = MD033NoInlineHtml::default();
1431 let content = "---\ndescription: <div class=\"test\">hello</div>\n---\n# Title\n<div>body</div>";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let result = rule.check(&ctx).unwrap();
1434 assert_eq!(result.len(), 1);
1436 assert_eq!(result[0].line, 5);
1437 assert_eq!(result[0].message, "Inline HTML found: <div>");
1438 }
1439
1440 #[test]
1441 fn test_md033_math_block() {
1442 let rule = MD033NoInlineHtml::default();
1443 let content = "$$\nx < y && y > z\n$$\n<div>body</div>";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert_eq!(result.len(), 1);
1448 assert_eq!(result[0].line, 4);
1449 }
1450
1451 #[test]
1452 fn test_md033_case_insensitive() {
1453 let rule = MD033NoInlineHtml::default();
1454 let content = "<DiV>Some <B>content</B></dIv>";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let result = rule.check(&ctx).unwrap();
1457 assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
1460 assert_eq!(result[1].message, "Inline HTML found: <B>");
1461 }
1462
1463 #[test]
1464 fn test_md033_multibyte_whitespace_in_tag_does_not_panic() {
1465 let rule = relaxed_fix_rule();
1468 let content = "<img\u{00A0}src=\"test.png\" alt=\"x\">";
1469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1470 let _ = rule.check(&ctx).unwrap();
1472 let _ = rule.fix(&ctx).unwrap();
1473 }
1474
1475 #[test]
1476 fn test_md033_allowed_tags() {
1477 let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
1478 let content = "<div>Allowed</div><p>Not allowed</p><br/>";
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480 let result = rule.check(&ctx).unwrap();
1481 assert_eq!(result.len(), 1);
1483 assert_eq!(result[0].message, "Inline HTML found: <p>");
1484
1485 let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
1487 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1488 let result2 = rule.check(&ctx2).unwrap();
1489 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
1491 }
1492
1493 #[test]
1494 fn test_md033_html_comments() {
1495 let rule = MD033NoInlineHtml::default();
1496 let content = "<!-- This is a comment --> <p>Not a comment</p>";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let result = rule.check(&ctx).unwrap();
1499 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
1502 }
1503
1504 #[test]
1505 fn test_md033_tags_in_links() {
1506 let rule = MD033NoInlineHtml::default();
1507 let content = "[Link](http://example.com/<div>)";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509 let result = rule.check(&ctx).unwrap();
1510 assert_eq!(result.len(), 1);
1512 assert_eq!(result[0].message, "Inline HTML found: <div>");
1513
1514 let content2 = "[Link <a>text</a>](url)";
1515 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1516 let result2 = rule.check(&ctx2).unwrap();
1517 assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
1520 }
1521
1522 #[test]
1523 fn test_md033_fix_escaping() {
1524 let rule = MD033NoInlineHtml::default();
1525 let content = "Text with <div> and <br/> tags.";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527 let fixed_content = rule.fix(&ctx).unwrap();
1528 assert_eq!(fixed_content, content);
1530 }
1531
1532 #[test]
1533 fn test_md033_in_code_blocks() {
1534 let rule = MD033NoInlineHtml::default();
1535 let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let result = rule.check(&ctx).unwrap();
1538 assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
1541 }
1542
1543 #[test]
1544 fn test_md033_in_code_spans() {
1545 let rule = MD033NoInlineHtml::default();
1546 let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548 let result = rule.check(&ctx).unwrap();
1549 assert_eq!(result.len(), 1);
1551 assert_eq!(result[0].message, "Inline HTML found: <br/>");
1552 }
1553
1554 #[test]
1555 fn test_md033_issue_90_code_span_with_diff_block() {
1556 let rule = MD033NoInlineHtml::default();
1558 let content = r#"# Heading
1559
1560`<env>`
1561
1562```diff
1563- this
1564+ that
1565```"#;
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let result = rule.check(&ctx).unwrap();
1568 assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
1570 }
1571
1572 #[test]
1573 fn test_md033_multiple_code_spans_with_angle_brackets() {
1574 let rule = MD033NoInlineHtml::default();
1576 let content = "`<one>` and `<two>` and `<three>` are all code spans";
1577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578 let result = rule.check(&ctx).unwrap();
1579 assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
1580 }
1581
1582 #[test]
1583 fn test_md033_nested_angle_brackets_in_code_span() {
1584 let rule = MD033NoInlineHtml::default();
1586 let content = "Text with `<<nested>>` brackets";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let result = rule.check(&ctx).unwrap();
1589 assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
1590 }
1591
1592 #[test]
1593 fn test_md033_code_span_at_end_before_code_block() {
1594 let rule = MD033NoInlineHtml::default();
1596 let content = "Testing `<test>`\n```\ncode here\n```";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let result = rule.check(&ctx).unwrap();
1599 assert_eq!(result.len(), 0, "Should handle code span before code block");
1600 }
1601
1602 #[test]
1603 fn test_md033_quick_fix_inline_tag() {
1604 let rule = MD033NoInlineHtml::default();
1607 let content = "This has <span>inline text</span> that should keep content.";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let result = rule.check(&ctx).unwrap();
1610
1611 assert_eq!(result.len(), 1, "Should find one HTML tag");
1612 assert!(
1614 result[0].fix.is_none(),
1615 "Non-fixable tags like <span> should not have a fix"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_md033_quick_fix_multiline_tag() {
1621 let rule = MD033NoInlineHtml::default();
1624 let content = "<div>\nBlock content\n</div>";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627
1628 assert_eq!(result.len(), 1, "Should find one HTML tag");
1629 assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
1631 }
1632
1633 #[test]
1634 fn test_md033_quick_fix_self_closing_tag() {
1635 let rule = MD033NoInlineHtml::default();
1637 let content = "Self-closing: <br/>";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(result.len(), 1, "Should find one HTML tag");
1642 assert!(
1644 result[0].fix.is_none(),
1645 "Self-closing tags should not have a fix when fix config is false"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_md033_quick_fix_multiple_tags() {
1651 let rule = MD033NoInlineHtml::default();
1654 let content = "<span>first</span> and <strong>second</strong>";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let result = rule.check(&ctx).unwrap();
1657
1658 assert_eq!(result.len(), 2, "Should find two HTML tags");
1659 assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
1661 assert!(
1662 result[1].fix.is_none(),
1663 "<strong> should not have a fix when fix config is false"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_md033_skip_angle_brackets_in_link_titles() {
1669 let rule = MD033NoInlineHtml::default();
1671 let content = r#"# Test
1672
1673[example]: <https://example.com> "Title with <Angle Brackets> inside"
1674
1675Regular text with <div>content</div> HTML tag.
1676"#;
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 let result = rule.check(&ctx).unwrap();
1679
1680 assert_eq!(result.len(), 1, "Should find opening div tag");
1683 assert!(
1684 result[0].message.contains("<div>"),
1685 "Should flag <div>, got: {}",
1686 result[0].message
1687 );
1688 }
1689
1690 #[test]
1691 fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
1692 let rule = MD033NoInlineHtml::default();
1694 let content = r#"[ref]: url 'Title <Help Wanted> here'
1695
1696<span>text</span> here
1697"#;
1698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1699 let result = rule.check(&ctx).unwrap();
1700
1701 assert_eq!(result.len(), 1, "Should find opening span tag");
1704 assert!(
1705 result[0].message.contains("<span>"),
1706 "Should flag <span>, got: {}",
1707 result[0].message
1708 );
1709 }
1710
1711 #[test]
1712 fn test_md033_multiline_tag_end_line_calculation() {
1713 let rule = MD033NoInlineHtml::default();
1715 let content = "<div\n class=\"test\"\n id=\"example\">";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let result = rule.check(&ctx).unwrap();
1718
1719 assert_eq!(result.len(), 1, "Should find one HTML tag");
1720 assert_eq!(result[0].line, 1, "Start line should be 1");
1722 assert_eq!(result[0].end_line, 3, "End line should be 3");
1724 }
1725
1726 #[test]
1727 fn test_md033_single_line_tag_same_start_end_line() {
1728 let rule = MD033NoInlineHtml::default();
1730 let content = "Some text <div class=\"test\"> more text";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let result = rule.check(&ctx).unwrap();
1733
1734 assert_eq!(result.len(), 1, "Should find one HTML tag");
1735 assert_eq!(result[0].line, 1, "Start line should be 1");
1736 assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
1737 }
1738
1739 #[test]
1740 fn test_md033_multiline_tag_with_many_attributes() {
1741 let rule = MD033NoInlineHtml::default();
1743 let content =
1744 "Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747
1748 assert_eq!(result.len(), 1, "Should find one HTML tag");
1749 assert_eq!(result[0].line, 2, "Start line should be 2");
1751 assert_eq!(result[0].end_line, 5, "End line should be 5");
1753 }
1754
1755 #[test]
1756 fn test_md033_disallowed_mode_basic() {
1757 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
1759 let content = "<div>Safe content</div><script>alert('xss')</script>";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let result = rule.check(&ctx).unwrap();
1762
1763 assert_eq!(result.len(), 1, "Should only flag disallowed tags");
1765 assert!(result[0].message.contains("<script>"), "Should flag script tag");
1766 }
1767
1768 #[test]
1769 fn test_md033_disallowed_gfm_security_tags() {
1770 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1772 let content = r#"
1773<div>Safe</div>
1774<title>Bad title</title>
1775<textarea>Bad textarea</textarea>
1776<style>.bad{}</style>
1777<iframe src="evil"></iframe>
1778<script>evil()</script>
1779<plaintext>old tag</plaintext>
1780<span>Safe span</span>
1781"#;
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let result = rule.check(&ctx).unwrap();
1784
1785 assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
1788
1789 let flagged_tags: Vec<&str> = result
1790 .iter()
1791 .filter_map(|w| w.message.split('<').nth(1))
1792 .filter_map(|s| s.split('>').next())
1793 .filter_map(|s| s.split_whitespace().next())
1794 .collect();
1795
1796 assert!(flagged_tags.contains(&"title"), "Should flag title");
1797 assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
1798 assert!(flagged_tags.contains(&"style"), "Should flag style");
1799 assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
1800 assert!(flagged_tags.contains(&"script"), "Should flag script");
1801 assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
1802 assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
1803 assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
1804 }
1805
1806 #[test]
1807 fn test_md033_disallowed_case_insensitive() {
1808 let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
1810 let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
1811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let result = rule.check(&ctx).unwrap();
1813
1814 assert_eq!(result.len(), 2, "Should flag both case variants");
1816 }
1817
1818 #[test]
1819 fn test_md033_disallowed_with_attributes() {
1820 let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
1822 let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1824 let result = rule.check(&ctx).unwrap();
1825
1826 assert_eq!(result.len(), 1, "Should flag iframe with attributes");
1827 assert!(result[0].message.contains("iframe"), "Should flag iframe");
1828 }
1829
1830 #[test]
1831 fn test_md033_disallowed_all_gfm_tags() {
1832 use md033_config::GFM_DISALLOWED_TAGS;
1834 let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
1835
1836 for tag in GFM_DISALLOWED_TAGS {
1837 let content = format!("<{tag}>content</{tag}>");
1838 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840
1841 assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
1842 }
1843 }
1844
1845 #[test]
1846 fn test_md033_disallowed_mixed_with_custom() {
1847 let rule = MD033NoInlineHtml::with_disallowed(vec![
1849 "gfm".to_string(),
1850 "marquee".to_string(), ]);
1852 let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let result = rule.check(&ctx).unwrap();
1855
1856 assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
1858 }
1859
1860 #[test]
1861 fn test_md033_disallowed_empty_means_default_mode() {
1862 let rule = MD033NoInlineHtml::with_disallowed(vec![]);
1864 let content = "<div>content</div>";
1865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866 let result = rule.check(&ctx).unwrap();
1867
1868 assert_eq!(result.len(), 1, "Empty disallowed = default mode");
1870 }
1871
1872 #[test]
1873 fn test_md033_jsx_fragments_in_mdx() {
1874 let rule = MD033NoInlineHtml::default();
1876 let content = r#"# MDX Document
1877
1878<>
1879 <Heading />
1880 <Content />
1881</>
1882
1883<div>Regular HTML should still be flagged</div>
1884"#;
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1886 let result = rule.check(&ctx).unwrap();
1887
1888 assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
1890 assert!(
1891 result[0].message.contains("<div>"),
1892 "Should flag <div>, not JSX fragments"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_md033_jsx_components_in_mdx() {
1898 let rule = MD033NoInlineHtml::default();
1900 let content = r#"<CustomComponent prop="value">
1901 Content
1902</CustomComponent>
1903
1904<MyButton onClick={handler}>Click</MyButton>
1905"#;
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1907 let result = rule.check(&ctx).unwrap();
1908
1909 assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
1911 }
1912
1913 #[test]
1914 fn test_md033_jsx_not_skipped_in_standard_markdown() {
1915 let rule = MD033NoInlineHtml::default();
1917 let content = "<Script>alert(1)</Script>";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 let result = rule.check(&ctx).unwrap();
1920
1921 assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
1923 }
1924
1925 #[test]
1926 fn test_md033_jsx_attributes_in_mdx() {
1927 let rule = MD033NoInlineHtml::default();
1929 let content = r#"# MDX with JSX Attributes
1930
1931<div className="card big">Content</div>
1932
1933<button onClick={handleClick}>Click me</button>
1934
1935<label htmlFor="input-id">Label</label>
1936
1937<input onChange={handleChange} />
1938
1939<div class="html-class">Regular HTML should be flagged</div>
1940"#;
1941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1942 let result = rule.check(&ctx).unwrap();
1943
1944 assert_eq!(
1946 result.len(),
1947 1,
1948 "Should only flag HTML element without JSX attributes, got: {result:?}"
1949 );
1950 assert!(
1951 result[0].message.contains("<div class="),
1952 "Should flag the div with HTML class attribute"
1953 );
1954 }
1955
1956 #[test]
1957 fn test_md033_jsx_attributes_not_skipped_in_standard() {
1958 let rule = MD033NoInlineHtml::default();
1960 let content = r#"<div className="card">Content</div>"#;
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let result = rule.check(&ctx).unwrap();
1963
1964 assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
1966 }
1967
1968 #[test]
1971 fn test_md033_fix_disabled_by_default() {
1972 let rule = MD033NoInlineHtml::default();
1974 assert!(!rule.config.fix, "Fix should be disabled by default");
1975 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
1976 }
1977
1978 #[test]
1979 fn test_md033_fix_enabled_em_to_italic() {
1980 let rule = MD033NoInlineHtml::with_fix(true);
1982 let content = "This has <em>emphasized text</em> here.";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let fixed = rule.fix(&ctx).unwrap();
1985 assert_eq!(fixed, "This has *emphasized text* here.");
1986 }
1987
1988 #[test]
1989 fn test_md033_fix_enabled_i_to_italic() {
1990 let rule = MD033NoInlineHtml::with_fix(true);
1992 let content = "This has <i>italic text</i> here.";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 let fixed = rule.fix(&ctx).unwrap();
1995 assert_eq!(fixed, "This has *italic text* here.");
1996 }
1997
1998 #[test]
1999 fn test_md033_fix_enabled_strong_to_bold() {
2000 let rule = MD033NoInlineHtml::with_fix(true);
2002 let content = "This has <strong>bold text</strong> here.";
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let fixed = rule.fix(&ctx).unwrap();
2005 assert_eq!(fixed, "This has **bold text** here.");
2006 }
2007
2008 #[test]
2009 fn test_md033_fix_enabled_b_to_bold() {
2010 let rule = MD033NoInlineHtml::with_fix(true);
2012 let content = "This has <b>bold text</b> here.";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let fixed = rule.fix(&ctx).unwrap();
2015 assert_eq!(fixed, "This has **bold text** here.");
2016 }
2017
2018 #[test]
2019 fn test_md033_fix_enabled_code_to_backticks() {
2020 let rule = MD033NoInlineHtml::with_fix(true);
2022 let content = "This has <code>inline code</code> here.";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let fixed = rule.fix(&ctx).unwrap();
2025 assert_eq!(fixed, "This has `inline code` here.");
2026 }
2027
2028 #[test]
2029 fn test_md033_fix_enabled_code_with_backticks() {
2030 let rule = MD033NoInlineHtml::with_fix(true);
2032 let content = "This has <code>text with `backticks`</code> here.";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert_eq!(fixed, "This has `` text with `backticks` `` here.");
2036 }
2037
2038 #[test]
2039 fn test_md033_fix_enabled_br_trailing_spaces() {
2040 let rule = MD033NoInlineHtml::with_fix(true);
2042 let content = "First line<br>Second line";
2043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2044 let fixed = rule.fix(&ctx).unwrap();
2045 assert_eq!(fixed, "First line \nSecond line");
2046 }
2047
2048 #[test]
2049 fn test_md033_fix_enabled_br_self_closing() {
2050 let rule = MD033NoInlineHtml::with_fix(true);
2052 let content = "First<br/>second<br />third";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let fixed = rule.fix(&ctx).unwrap();
2055 assert_eq!(fixed, "First \nsecond \nthird");
2056 }
2057
2058 #[test]
2059 fn test_md033_fix_enabled_br_backslash_style() {
2060 let config = MD033Config {
2062 allowed: Vec::new(),
2063 disallowed: Vec::new(),
2064 fix: true,
2065 br_style: md033_config::BrStyle::Backslash,
2066 ..MD033Config::default()
2067 };
2068 let rule = MD033NoInlineHtml::from_config_struct(config);
2069 let content = "First line<br>Second line";
2070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2071 let fixed = rule.fix(&ctx).unwrap();
2072 assert_eq!(fixed, "First line\\\nSecond line");
2073 }
2074
2075 #[test]
2076 fn test_md033_fix_enabled_hr() {
2077 let rule = MD033NoInlineHtml::with_fix(true);
2079 let content = "Above<hr>Below";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081 let fixed = rule.fix(&ctx).unwrap();
2082 assert_eq!(fixed, "Above\n---\nBelow");
2083 }
2084
2085 #[test]
2086 fn test_md033_fix_enabled_hr_self_closing() {
2087 let rule = MD033NoInlineHtml::with_fix(true);
2089 let content = "Above<hr/>Below";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let fixed = rule.fix(&ctx).unwrap();
2092 assert_eq!(fixed, "Above\n---\nBelow");
2093 }
2094
2095 #[test]
2096 fn test_md033_fix_skips_nested_tags() {
2097 let rule = MD033NoInlineHtml::with_fix(true);
2100 let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
2101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102 let fixed = rule.fix(&ctx).unwrap();
2103 assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
2106 }
2107
2108 #[test]
2109 fn test_md033_fix_skips_tags_with_attributes() {
2110 let rule = MD033NoInlineHtml::with_fix(true);
2113 let content = "This has <em class=\"highlight\">emphasized</em> text.";
2114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2115 let fixed = rule.fix(&ctx).unwrap();
2116 assert_eq!(fixed, content);
2118 }
2119
2120 #[test]
2121 fn test_md033_fix_disabled_no_changes() {
2122 let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
2125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2126 let fixed = rule.fix(&ctx).unwrap();
2127 assert_eq!(fixed, content, "Should return original content when fix is disabled");
2128 }
2129
2130 #[test]
2131 fn test_md033_fix_capability_enabled() {
2132 let rule = MD033NoInlineHtml::with_fix(true);
2133 assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
2134 }
2135
2136 #[test]
2137 fn test_md033_fix_multiple_tags() {
2138 let rule = MD033NoInlineHtml::with_fix(true);
2140 let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
2141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142 let fixed = rule.fix(&ctx).unwrap();
2143 assert_eq!(fixed, "Here is *italic* and **bold** text.");
2144 }
2145
2146 #[test]
2147 fn test_md033_fix_uppercase_tags() {
2148 let rule = MD033NoInlineHtml::with_fix(true);
2150 let content = "This has <EM>emphasized</EM> text.";
2151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152 let fixed = rule.fix(&ctx).unwrap();
2153 assert_eq!(fixed, "This has *emphasized* text.");
2154 }
2155
2156 #[test]
2157 fn test_md033_fix_unsafe_tags_not_modified() {
2158 let rule = MD033NoInlineHtml::with_fix(true);
2161 let content = "This has <div>a div</div> content.";
2162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 let fixed = rule.fix(&ctx).unwrap();
2164 assert_eq!(fixed, "This has <div>a div</div> content.");
2166 }
2167
2168 #[test]
2169 fn test_md033_fix_img_tag_converted() {
2170 let rule = MD033NoInlineHtml::with_fix(true);
2172 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174 let fixed = rule.fix(&ctx).unwrap();
2175 assert_eq!(fixed, "Image: ");
2177 }
2178
2179 #[test]
2180 fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
2181 let rule = MD033NoInlineHtml::with_fix(true);
2183 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2185 let fixed = rule.fix(&ctx).unwrap();
2186 assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
2188 }
2189
2190 #[test]
2191 fn test_md033_fix_relaxed_a_with_target_is_converted() {
2192 let rule = relaxed_fix_rule();
2193 let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
2194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let fixed = rule.fix(&ctx).unwrap();
2196 assert_eq!(fixed, "Link: [Example](https://example.com)");
2197 }
2198
2199 #[test]
2200 fn test_md033_fix_relaxed_img_with_width_is_converted() {
2201 let rule = relaxed_fix_rule();
2202 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
2203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2204 let fixed = rule.fix(&ctx).unwrap();
2205 assert_eq!(fixed, "Image: ");
2206 }
2207
2208 #[test]
2209 fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
2210 let rule = relaxed_fix_rule();
2211 let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
2212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2213 let fixed = rule.fix(&ctx).unwrap();
2214 assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
2215 }
2216
2217 #[test]
2218 fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
2219 let rule = relaxed_fix_rule();
2220 let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
2221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2222 let fixed = rule.fix(&ctx).unwrap();
2223 assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
2224 }
2225
2226 #[test]
2227 fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
2228 let rule = relaxed_fix_rule();
2229 let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
2230 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2231 let fixed_once = rule.fix(&ctx1).unwrap();
2232 assert!(
2233 fixed_once.contains("<p"),
2234 "First pass should keep wrapper when inner HTML is still present: {fixed_once}"
2235 );
2236 assert!(
2237 fixed_once.contains(""),
2238 "Inner image should be converted on first pass: {fixed_once}"
2239 );
2240
2241 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2242 let fixed_twice = rule.fix(&ctx2).unwrap();
2243 assert!(
2244 !fixed_twice.contains("<p"),
2245 "Second pass should strip configured wrapper: {fixed_twice}"
2246 );
2247 assert!(fixed_twice.contains(""));
2248 }
2249
2250 #[test]
2251 fn test_md033_fix_relaxed_multiple_droppable_attrs() {
2252 let rule = relaxed_fix_rule();
2253 let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
2254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255 let fixed = rule.fix(&ctx).unwrap();
2256 assert_eq!(fixed, "[Click](https://example.com)");
2257 }
2258
2259 #[test]
2260 fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
2261 let rule = relaxed_fix_rule();
2262 let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264 let fixed = rule.fix(&ctx).unwrap();
2265 assert_eq!(fixed, "");
2266 }
2267
2268 #[test]
2269 fn test_md033_fix_relaxed_event_handler_never_dropped() {
2270 let rule = relaxed_fix_rule();
2271 let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let fixed = rule.fix(&ctx).unwrap();
2274 assert_eq!(fixed, content, "Event handler attributes must block conversion");
2275 }
2276
2277 #[test]
2278 fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
2279 let config = MD033Config {
2281 fix: true,
2282 fix_mode: MD033FixMode::Relaxed,
2283 drop_attributes: vec!["on*".to_string(), "target".to_string()],
2284 ..MD033Config::default()
2285 };
2286 let rule = MD033NoInlineHtml::from_config_struct(config);
2287 let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
2288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2289 let fixed = rule.fix(&ctx).unwrap();
2290 assert_eq!(fixed, content, "on* event handlers must never be dropped");
2291 }
2292
2293 #[test]
2294 fn test_md033_fix_relaxed_custom_drop_attributes() {
2295 let config = MD033Config {
2296 fix: true,
2297 fix_mode: MD033FixMode::Relaxed,
2298 drop_attributes: vec!["loading".to_string()],
2299 ..MD033Config::default()
2300 };
2301 let rule = MD033NoInlineHtml::from_config_struct(config);
2302 let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
2304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305 let fixed = rule.fix(&ctx).unwrap();
2306 assert_eq!(fixed, "", "Custom drop-attributes should be respected");
2307
2308 let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
2309 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2310 let fixed2 = rule.fix(&ctx2).unwrap();
2311 assert_eq!(
2312 fixed2, content2,
2313 "Attributes not in custom list should block conversion"
2314 );
2315 }
2316
2317 #[test]
2318 fn test_md033_fix_relaxed_custom_strip_wrapper() {
2319 let config = MD033Config {
2320 fix: true,
2321 fix_mode: MD033FixMode::Relaxed,
2322 strip_wrapper_elements: vec!["div".to_string()],
2323 ..MD033Config::default()
2324 };
2325 let rule = MD033NoInlineHtml::from_config_struct(config);
2326 let content = "<div>Some text content</div>";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328 let fixed = rule.fix(&ctx).unwrap();
2329 assert_eq!(fixed, "Some text content");
2330 }
2331
2332 #[test]
2333 fn test_md033_fix_relaxed_wrapper_with_plain_text() {
2334 let rule = relaxed_fix_rule();
2335 let content = "<p align=\"center\">Just some text</p>";
2336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2337 let fixed = rule.fix(&ctx).unwrap();
2338 assert_eq!(fixed, "Just some text");
2339 }
2340
2341 #[test]
2342 fn test_md033_fix_relaxed_data_attr_with_wildcard() {
2343 let config = MD033Config {
2344 fix: true,
2345 fix_mode: MD033FixMode::Relaxed,
2346 drop_attributes: vec!["data-*".to_string(), "target".to_string()],
2347 ..MD033Config::default()
2348 };
2349 let rule = MD033NoInlineHtml::from_config_struct(config);
2350 let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let fixed = rule.fix(&ctx).unwrap();
2353 assert_eq!(fixed, "[Link](https://example.com)");
2354 }
2355
2356 #[test]
2357 fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
2358 let rule = relaxed_fix_rule();
2359 let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362 let fixed = rule.fix(&ctx).unwrap();
2363 assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
2364 }
2365
2366 #[test]
2367 fn test_md033_fix_relaxed_badge_pattern() {
2368 let rule = relaxed_fix_rule();
2370 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>";
2371 let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2372 let fixed_once = rule.fix(&ctx1).unwrap();
2373 assert!(
2375 fixed_once.contains(""),
2376 "Inner img should be converted: {fixed_once}"
2377 );
2378
2379 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2381 let fixed_twice = rule.fix(&ctx2).unwrap();
2382 assert!(
2383 fixed_twice
2384 .contains("[](https://crates.io/crates/rumdl)"),
2385 "Badge should produce nested markdown image link: {fixed_twice}"
2386 );
2387 }
2388
2389 #[test]
2390 fn test_md033_fix_relaxed_conservative_mode_unchanged() {
2391 let rule = MD033NoInlineHtml::with_fix(true);
2393 let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let fixed = rule.fix(&ctx).unwrap();
2396 assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
2397 }
2398
2399 #[test]
2400 fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
2401 let rule = relaxed_fix_rule();
2403 let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2405 let fixed = rule.fix(&ctx).unwrap();
2406 assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
2407 }
2408
2409 #[test]
2410 fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
2411 let rule = relaxed_fix_rule();
2413 let content = "<div><p>text</p></div>";
2414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415 let fixed = rule.fix(&ctx).unwrap();
2416 assert!(
2417 fixed.contains("<p>text</p>") || fixed.contains("<p>"),
2418 "Nested <p> inside <div> should not be stripped: {fixed}"
2419 );
2420 }
2421
2422 #[test]
2423 fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
2424 let rule = relaxed_fix_rule();
2428 let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
2429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2430 let fixed = rule.fix(&ctx).unwrap();
2431 assert!(
2432 fixed.contains("<img"),
2433 "img inside nested wrapper must not be converted: {fixed}"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_md033_fix_mixed_safe_tags() {
2439 let rule = MD033NoInlineHtml::with_fix(true);
2441 let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
2442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2443 let fixed = rule.fix(&ctx).unwrap();
2444 assert_eq!(fixed, "*italic* and  and **bold**");
2446 }
2447
2448 #[test]
2449 fn test_md033_fix_multiple_tags_same_line() {
2450 let rule = MD033NoInlineHtml::with_fix(true);
2452 let content = "Regular text <i>italic</i> and <b>bold</b> here.";
2453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2454 let fixed = rule.fix(&ctx).unwrap();
2455 assert_eq!(fixed, "Regular text *italic* and **bold** here.");
2456 }
2457
2458 #[test]
2459 fn test_md033_fix_multiple_em_tags_same_line() {
2460 let rule = MD033NoInlineHtml::with_fix(true);
2462 let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
2463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2464 let fixed = rule.fix(&ctx).unwrap();
2465 assert_eq!(fixed, "*first* and **second** and `third`");
2466 }
2467
2468 #[test]
2469 fn test_md033_fix_skips_tags_inside_pre() {
2470 let rule = MD033NoInlineHtml::with_fix(true);
2472 let content = "<pre><code><em>VALUE</em></code></pre>";
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let fixed = rule.fix(&ctx).unwrap();
2475 assert!(
2478 !fixed.contains("*VALUE*"),
2479 "Tags inside <pre> should not be converted to markdown. Got: {fixed}"
2480 );
2481 }
2482
2483 #[test]
2484 fn test_md033_fix_skips_tags_inside_div() {
2485 let rule = MD033NoInlineHtml::with_fix(true);
2487 let content = "<div>\n<em>emphasized</em>\n</div>";
2488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2489 let fixed = rule.fix(&ctx).unwrap();
2490 assert!(
2492 !fixed.contains("*emphasized*"),
2493 "Tags inside HTML blocks should not be converted. Got: {fixed}"
2494 );
2495 }
2496
2497 #[test]
2498 fn test_md033_fix_outside_html_block() {
2499 let rule = MD033NoInlineHtml::with_fix(true);
2501 let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
2502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2503 let fixed = rule.fix(&ctx).unwrap();
2504 assert!(
2506 fixed.contains("*emphasized*"),
2507 "Tags outside HTML blocks should be converted. Got: {fixed}"
2508 );
2509 }
2510
2511 #[test]
2512 fn test_md033_fix_with_id_attribute() {
2513 let rule = MD033NoInlineHtml::with_fix(true);
2515 let content = "See <em id=\"important\">this note</em> for details.";
2516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2517 let fixed = rule.fix(&ctx).unwrap();
2518 assert_eq!(fixed, content);
2520 }
2521
2522 #[test]
2523 fn test_md033_fix_with_style_attribute() {
2524 let rule = MD033NoInlineHtml::with_fix(true);
2526 let content = "This is <strong style=\"color: red\">important</strong> text.";
2527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2528 let fixed = rule.fix(&ctx).unwrap();
2529 assert_eq!(fixed, content);
2531 }
2532
2533 #[test]
2534 fn test_md033_fix_mixed_with_and_without_attributes() {
2535 let rule = MD033NoInlineHtml::with_fix(true);
2537 let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
2538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2539 let fixed = rule.fix(&ctx).unwrap();
2540 assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
2542 }
2543
2544 #[test]
2545 fn test_md033_quick_fix_tag_with_attributes_no_fix() {
2546 let rule = MD033NoInlineHtml::with_fix(true);
2548 let content = "<em class=\"test\">emphasized</em>";
2549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2550 let result = rule.check(&ctx).unwrap();
2551
2552 assert_eq!(result.len(), 1, "Should find one HTML tag");
2553 assert!(
2555 result[0].fix.is_none(),
2556 "Should NOT have a fix for tags with attributes"
2557 );
2558 }
2559
2560 #[test]
2561 fn test_md033_fix_skips_html_entities() {
2562 let rule = MD033NoInlineHtml::with_fix(true);
2565 let content = "<code>|</code>";
2566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2567 let fixed = rule.fix(&ctx).unwrap();
2568 assert_eq!(fixed, content);
2570 }
2571
2572 #[test]
2573 fn test_md033_fix_skips_multiple_html_entities() {
2574 let rule = MD033NoInlineHtml::with_fix(true);
2576 let content = "<code><T></code>";
2577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2578 let fixed = rule.fix(&ctx).unwrap();
2579 assert_eq!(fixed, content);
2581 }
2582
2583 #[test]
2584 fn test_md033_fix_allows_ampersand_without_entity() {
2585 let rule = MD033NoInlineHtml::with_fix(true);
2587 let content = "<code>a & b</code>";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2589 let fixed = rule.fix(&ctx).unwrap();
2590 assert_eq!(fixed, "`a & b`");
2592 }
2593
2594 #[test]
2595 fn test_md033_fix_em_with_entities_skipped() {
2596 let rule = MD033NoInlineHtml::with_fix(true);
2598 let content = "<em> text</em>";
2599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600 let fixed = rule.fix(&ctx).unwrap();
2601 assert_eq!(fixed, content);
2603 }
2604
2605 #[test]
2606 fn test_md033_fix_skips_nested_em_in_code() {
2607 let rule = MD033NoInlineHtml::with_fix(true);
2610 let content = "<code><em>n</em></code>";
2611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2612 let fixed = rule.fix(&ctx).unwrap();
2613 assert!(
2616 !fixed.contains("*n*"),
2617 "Nested <em> should not be converted to markdown. Got: {fixed}"
2618 );
2619 }
2620
2621 #[test]
2622 fn test_md033_fix_skips_nested_in_table() {
2623 let rule = MD033NoInlineHtml::with_fix(true);
2625 let content = "| <code>><em>n</em></code> | description |";
2626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2627 let fixed = rule.fix(&ctx).unwrap();
2628 assert!(
2630 !fixed.contains("*n*"),
2631 "Nested tags in table should not be converted. Got: {fixed}"
2632 );
2633 }
2634
2635 #[test]
2636 fn test_md033_fix_standalone_em_still_converted() {
2637 let rule = MD033NoInlineHtml::with_fix(true);
2639 let content = "This is <em>emphasized</em> text.";
2640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641 let fixed = rule.fix(&ctx).unwrap();
2642 assert_eq!(fixed, "This is *emphasized* text.");
2643 }
2644
2645 #[test]
2657 fn test_md033_templater_basic_interpolation_not_flagged() {
2658 let rule = MD033NoInlineHtml::default();
2661 let content = "Today is <% tp.date.now() %> which is nice.";
2662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2663 let result = rule.check(&ctx).unwrap();
2664 assert!(
2665 result.is_empty(),
2666 "Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
2667 );
2668 }
2669
2670 #[test]
2671 fn test_md033_templater_file_functions_not_flagged() {
2672 let rule = MD033NoInlineHtml::default();
2674 let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
2675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2676 let result = rule.check(&ctx).unwrap();
2677 assert!(
2678 result.is_empty(),
2679 "Templater file functions should not be flagged. Got: {result:?}"
2680 );
2681 }
2682
2683 #[test]
2684 fn test_md033_templater_with_arguments_not_flagged() {
2685 let rule = MD033NoInlineHtml::default();
2687 let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
2688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2689 let result = rule.check(&ctx).unwrap();
2690 assert!(
2691 result.is_empty(),
2692 "Templater with arguments should not be flagged. Got: {result:?}"
2693 );
2694 }
2695
2696 #[test]
2697 fn test_md033_templater_javascript_execution_not_flagged() {
2698 let rule = MD033NoInlineHtml::default();
2700 let content = "<%* const today = tp.date.now(); tR += today; %>";
2701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2702 let result = rule.check(&ctx).unwrap();
2703 assert!(
2704 result.is_empty(),
2705 "Templater JS execution block should not be flagged. Got: {result:?}"
2706 );
2707 }
2708
2709 #[test]
2710 fn test_md033_templater_dynamic_execution_not_flagged() {
2711 let rule = MD033NoInlineHtml::default();
2713 let content = "Dynamic: <%+ tp.date.now() %>";
2714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2715 let result = rule.check(&ctx).unwrap();
2716 assert!(
2717 result.is_empty(),
2718 "Templater dynamic execution should not be flagged. Got: {result:?}"
2719 );
2720 }
2721
2722 #[test]
2723 fn test_md033_templater_whitespace_trim_all_not_flagged() {
2724 let rule = MD033NoInlineHtml::default();
2726 let content = "<%_ tp.date.now() _%>";
2727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2728 let result = rule.check(&ctx).unwrap();
2729 assert!(
2730 result.is_empty(),
2731 "Templater trim-all whitespace should not be flagged. Got: {result:?}"
2732 );
2733 }
2734
2735 #[test]
2736 fn test_md033_templater_whitespace_trim_newline_not_flagged() {
2737 let rule = MD033NoInlineHtml::default();
2739 let content = "<%- tp.date.now() -%>";
2740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2741 let result = rule.check(&ctx).unwrap();
2742 assert!(
2743 result.is_empty(),
2744 "Templater trim-newline should not be flagged. Got: {result:?}"
2745 );
2746 }
2747
2748 #[test]
2749 fn test_md033_templater_combined_modifiers_not_flagged() {
2750 let rule = MD033NoInlineHtml::default();
2752 let contents = [
2753 "<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
2758 for content in contents {
2759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2760 let result = rule.check(&ctx).unwrap();
2761 assert!(
2762 result.is_empty(),
2763 "Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
2764 );
2765 }
2766 }
2767
2768 #[test]
2769 fn test_md033_templater_multiline_block_not_flagged() {
2770 let rule = MD033NoInlineHtml::default();
2772 let content = r#"<%*
2773const x = 1;
2774const y = 2;
2775tR += x + y;
2776%>"#;
2777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2778 let result = rule.check(&ctx).unwrap();
2779 assert!(
2780 result.is_empty(),
2781 "Templater multi-line block should not be flagged. Got: {result:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
2787 let rule = MD033NoInlineHtml::default();
2790 let content = "<%* if (x < 5) { tR += 'small'; } %>";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert!(
2794 result.is_empty(),
2795 "Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
2796 );
2797 }
2798
2799 #[test]
2800 fn test_md033_templater_mixed_with_html_only_html_flagged() {
2801 let rule = MD033NoInlineHtml::default();
2803 let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
2804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2805 let result = rule.check(&ctx).unwrap();
2806 assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
2807 assert!(
2808 result[0].message.contains("<div>"),
2809 "Should flag <div>, got: {}",
2810 result[0].message
2811 );
2812 }
2813
2814 #[test]
2815 fn test_md033_templater_in_heading_not_flagged() {
2816 let rule = MD033NoInlineHtml::default();
2818 let content = "# <% tp.file.title %>";
2819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2820 let result = rule.check(&ctx).unwrap();
2821 assert!(
2822 result.is_empty(),
2823 "Templater in heading should not be flagged. Got: {result:?}"
2824 );
2825 }
2826
2827 #[test]
2828 fn test_md033_templater_multiple_on_same_line_not_flagged() {
2829 let rule = MD033NoInlineHtml::default();
2831 let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
2832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2833 let result = rule.check(&ctx).unwrap();
2834 assert!(
2835 result.is_empty(),
2836 "Multiple Templater blocks should not be flagged. Got: {result:?}"
2837 );
2838 }
2839
2840 #[test]
2841 fn test_md033_templater_in_code_block_not_flagged() {
2842 let rule = MD033NoInlineHtml::default();
2844 let content = "```\n<% tp.date.now() %>\n```";
2845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2846 let result = rule.check(&ctx).unwrap();
2847 assert!(
2848 result.is_empty(),
2849 "Templater in code block should not be flagged. Got: {result:?}"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_md033_templater_in_inline_code_not_flagged() {
2855 let rule = MD033NoInlineHtml::default();
2857 let content = "Use `<% tp.date.now() %>` for current date.";
2858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2859 let result = rule.check(&ctx).unwrap();
2860 assert!(
2861 result.is_empty(),
2862 "Templater in inline code should not be flagged. Got: {result:?}"
2863 );
2864 }
2865
2866 #[test]
2867 fn test_md033_templater_also_works_in_standard_flavor() {
2868 let rule = MD033NoInlineHtml::default();
2871 let content = "<% tp.date.now() %> works everywhere.";
2872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873 let result = rule.check(&ctx).unwrap();
2874 assert!(
2875 result.is_empty(),
2876 "Templater should not be flagged even in Standard flavor. Got: {result:?}"
2877 );
2878 }
2879
2880 #[test]
2881 fn test_md033_templater_empty_tag_not_flagged() {
2882 let rule = MD033NoInlineHtml::default();
2884 let content = "<%>";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2886 let result = rule.check(&ctx).unwrap();
2887 assert!(
2888 result.is_empty(),
2889 "Empty Templater-like tag should not be flagged. Got: {result:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_md033_templater_unclosed_not_flagged() {
2895 let rule = MD033NoInlineHtml::default();
2897 let content = "<% tp.date.now() without closing tag";
2898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2899 let result = rule.check(&ctx).unwrap();
2900 assert!(
2901 result.is_empty(),
2902 "Unclosed Templater should not be flagged as HTML. Got: {result:?}"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_md033_templater_with_newlines_inside_not_flagged() {
2908 let rule = MD033NoInlineHtml::default();
2910 let content = r#"<% tp.date.now("YYYY") +
2911"-" +
2912tp.date.now("MM") %>"#;
2913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2914 let result = rule.check(&ctx).unwrap();
2915 assert!(
2916 result.is_empty(),
2917 "Templater with internal newlines should not be flagged. Got: {result:?}"
2918 );
2919 }
2920
2921 #[test]
2922 fn test_md033_erb_style_tags_not_flagged() {
2923 let rule = MD033NoInlineHtml::default();
2926 let content = "<%= variable %> and <% code %> and <%# comment %>";
2927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2928 let result = rule.check(&ctx).unwrap();
2929 assert!(
2930 result.is_empty(),
2931 "ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
2932 );
2933 }
2934
2935 #[test]
2936 fn test_md033_templater_complex_expression_not_flagged() {
2937 let rule = MD033NoInlineHtml::default();
2939 let content = r#"<%*
2940const file = tp.file.title;
2941const date = tp.date.now("YYYY-MM-DD");
2942const folder = tp.file.folder();
2943tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
2944%>"#;
2945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2946 let result = rule.check(&ctx).unwrap();
2947 assert!(
2948 result.is_empty(),
2949 "Complex Templater expression should not be flagged. Got: {result:?}"
2950 );
2951 }
2952
2953 #[test]
2954 fn test_md033_percent_sign_variations_not_flagged() {
2955 let rule = MD033NoInlineHtml::default();
2957 let patterns = [
2958 "<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
2965 for pattern in patterns {
2966 let content = format!("{pattern} content %>");
2967 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2968 let result = rule.check(&ctx).unwrap();
2969 assert!(
2970 result.is_empty(),
2971 "Pattern {pattern} should not be flagged. Got: {result:?}"
2972 );
2973 }
2974 }
2975
2976 #[test]
2982 fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
2983 let rule = MD033NoInlineHtml::with_fix(true);
2986 let content = r#"<a href="https://example.com"></a>"#;
2987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988 let fixed = rule.fix(&ctx).unwrap();
2989
2990 assert_eq!(fixed, "[](https://example.com)",);
2991 assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
2992 assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
2993 }
2994
2995 #[test]
2996 fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
2997 let rule = MD033NoInlineHtml::with_fix(true);
2999 let content =
3000 r#"<a href="https://github.com/repo"></a>"#;
3001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3002 let fixed = rule.fix(&ctx).unwrap();
3003
3004 assert_eq!(
3005 fixed,
3006 "[](https://github.com/repo)"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_md033_fix_img_without_alt_produces_empty_alt() {
3012 let rule = MD033NoInlineHtml::with_fix(true);
3013 let content = r#"<img src="photo.jpg" />"#;
3014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3015 let fixed = rule.fix(&ctx).unwrap();
3016
3017 assert_eq!(fixed, "");
3018 }
3019
3020 #[test]
3021 fn test_md033_fix_leaves_a_tag_inside_an_unnamed_html_block() {
3022 let rule = MD033NoInlineHtml::with_fix(true);
3027 let content = "<span>\n<img src=\"a.png\" />\n\n<img src=\"b.png\" />\n";
3028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029 assert_eq!(rule.fix(&ctx).unwrap(), "<span>\n<img src=\"a.png\" />\n\n\n");
3030 }
3031
3032 #[test]
3033 fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
3034 let rule = MD033NoInlineHtml::with_fix(true);
3036 let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
3037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let fixed = rule.fix(&ctx).unwrap();
3039
3040 assert!(
3041 fixed.contains(r"\[brackets\]"),
3042 "Plain text brackets should be escaped: {fixed}"
3043 );
3044 }
3045
3046 #[test]
3047 fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
3048 let rule = MD033NoInlineHtml::with_fix(true);
3051 let content = r#"<a href="/link"> see [docs]</a>"#;
3052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3053 let fixed = rule.fix(&ctx).unwrap();
3054
3055 assert!(
3057 fixed.contains(r"\[docs\]"),
3058 "Brackets in mixed image+text content should be escaped: {fixed}"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_md033_fix_img_in_a_end_to_end() {
3064 use crate::config::Config;
3067 use crate::fix_coordinator::FixCoordinator;
3068
3069 let rule = MD033NoInlineHtml::with_fix(true);
3070 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3071
3072 let mut content =
3073 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
3074 .to_string();
3075 let config = Config::default();
3076 let coordinator = FixCoordinator::new();
3077
3078 let result = coordinator
3079 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3080 .unwrap();
3081
3082 assert_eq!(
3083 content, "[](https://github.com/org/repo)",
3084 "End-to-end: <a><img></a> should become valid linked image"
3085 );
3086 assert!(result.converged);
3087 assert!(!content.contains(r"\["), "No escaped brackets: {content}");
3088 }
3089
3090 #[test]
3091 fn test_md033_fix_img_in_a_with_alt_end_to_end() {
3092 use crate::config::Config;
3093 use crate::fix_coordinator::FixCoordinator;
3094
3095 let rule = MD033NoInlineHtml::with_fix(true);
3096 let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
3097
3098 let mut content =
3099 r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
3100 .to_string();
3101 let config = Config::default();
3102 let coordinator = FixCoordinator::new();
3103
3104 let result = coordinator
3105 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
3106 .unwrap();
3107
3108 assert_eq!(
3109 content,
3110 "[](https://github.com/org/repo)",
3111 );
3112 assert!(result.converged);
3113 }
3114
3115 #[test]
3125 fn test_md033_table_allowed_unset_falls_back_to_allowed() {
3126 let config = MD033Config {
3127 allowed: vec!["br".to_string()],
3128 table_allowed_elements: None,
3129 ..MD033Config::default()
3130 };
3131 let rule = MD033NoInlineHtml::from_config_struct(config);
3132 let content = "| col |\n|-----|\n| a<br>b |\n";
3133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3134 let result = rule.check(&ctx).unwrap();
3135 assert!(
3136 result.is_empty(),
3137 "<br> in table cell should be allowed via fallback to `allowed`, got {result:?}"
3138 );
3139 }
3140
3141 #[test]
3142 fn test_md033_table_allowed_explicit_empty_rejects_in_tables() {
3143 let config = MD033Config {
3144 allowed: vec!["br".to_string()],
3145 table_allowed_elements: Some(Vec::new()),
3146 ..MD033Config::default()
3147 };
3148 let rule = MD033NoInlineHtml::from_config_struct(config);
3149 let content = "| col |\n|-----|\n| a<br>b |\n";
3150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3151 let result = rule.check(&ctx).unwrap();
3152 assert_eq!(
3153 result.len(),
3154 1,
3155 "Explicit empty table_allowed should reject <br> in tables even if it's in `allowed`, got {result:?}"
3156 );
3157 assert_eq!(result[0].line, 3);
3158 }
3159
3160 #[test]
3161 fn test_md033_table_allowed_explicit_list_overrides_in_tables() {
3162 let config = MD033Config {
3163 allowed: vec!["br".to_string()],
3164 table_allowed_elements: Some(vec!["img".to_string()]),
3165 ..MD033Config::default()
3166 };
3167 let rule = MD033NoInlineHtml::from_config_struct(config);
3168 let content = "| col |\n|-----|\n| <br><img src=\"x\"/> |\n";
3171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3172 let result = rule.check(&ctx).unwrap();
3173 assert_eq!(
3174 result.len(),
3175 1,
3176 "table_allowed should override `allowed` inside tables, got {result:?}"
3177 );
3178 assert!(
3179 result[0].message.contains("br"),
3180 "expected the flagged tag to be <br>, got {:?}",
3181 result[0].message
3182 );
3183 }
3184
3185 #[test]
3186 fn test_md033_table_allowed_does_not_affect_out_of_table_tags() {
3187 let config = MD033Config {
3188 allowed: vec!["br".to_string()],
3189 table_allowed_elements: Some(Vec::new()),
3190 ..MD033Config::default()
3191 };
3192 let rule = MD033NoInlineHtml::from_config_struct(config);
3193 let content = "Paragraph with <br> tag.\n";
3195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3196 let result = rule.check(&ctx).unwrap();
3197 assert!(
3198 result.is_empty(),
3199 "<br> outside tables must still be allowed by `allowed`, got {result:?}"
3200 );
3201 }
3202
3203 #[test]
3204 fn test_md033_table_allowed_kebab_case_parses() {
3205 let toml_str = r#"
3206 allowed-elements = ["br"]
3207 table-allowed-elements = ["img"]
3208 "#;
3209 let config: MD033Config = toml::from_str(toml_str).unwrap();
3210 assert_eq!(config.allowed, vec!["br"]);
3211 assert_eq!(
3212 config.table_allowed_elements.as_deref(),
3213 Some(["img".to_string()].as_slice())
3214 );
3215 }
3216
3217 #[test]
3218 fn test_md033_table_allowed_snake_case_alias_parses() {
3219 let toml_str = r#"
3220 allowed_elements = ["br"]
3221 table_allowed_elements = ["img"]
3222 "#;
3223 let config: MD033Config = toml::from_str(toml_str).unwrap();
3224 assert_eq!(config.allowed, vec!["br"]);
3225 assert_eq!(
3226 config.table_allowed_elements.as_deref(),
3227 Some(["img".to_string()].as_slice())
3228 );
3229 }
3230
3231 #[test]
3232 fn test_md033_table_allowed_default_is_none() {
3233 let cfg = MD033Config::default();
3234 assert!(
3235 cfg.table_allowed_elements.is_none(),
3236 "Default for table_allowed_elements should be None (so it falls back to `allowed`)"
3237 );
3238 }
3239
3240 #[test]
3241 fn test_md033_table_allowed_case_insensitive() {
3242 let config = MD033Config {
3243 allowed: Vec::new(),
3244 table_allowed_elements: Some(vec!["BR".to_string()]),
3245 ..MD033Config::default()
3246 };
3247 let rule = MD033NoInlineHtml::from_config_struct(config);
3248 let content = "| col |\n|-----|\n| a<br>b |\n";
3249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3250 let result = rule.check(&ctx).unwrap();
3251 assert!(
3252 result.is_empty(),
3253 "table_allowed should be case-insensitive, got {result:?}"
3254 );
3255 }
3256
3257 fn reported_tags_in(rule: &MD033NoInlineHtml, content: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
3266 let ctx = LintContext::new(content, flavor, None);
3267 rule.check(&ctx)
3268 .unwrap()
3269 .into_iter()
3270 .map(|warning| {
3271 warning
3272 .message
3273 .strip_prefix("Inline HTML found: ")
3274 .expect("MD033 reports the tag it found")
3275 .to_string()
3276 })
3277 .collect()
3278 }
3279
3280 fn reported_tags(rule: &MD033NoInlineHtml, content: &str) -> Vec<String> {
3281 reported_tags_in(rule, content, crate::config::MarkdownFlavor::Standard)
3282 }
3283
3284 fn rule_allowing_inside(elements: &[&str]) -> MD033NoInlineHtml {
3285 MD033NoInlineHtml::from_config_struct(MD033Config {
3286 allowed_inside: elements.iter().map(ToString::to_string).collect(),
3287 ..MD033Config::default()
3288 })
3289 }
3290
3291 #[test]
3292 fn test_md033_allowed_inside_permits_the_element_and_its_contents() {
3293 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";
3294
3295 let without = reported_tags(&MD033NoInlineHtml::default(), content);
3296 assert_eq!(
3297 without,
3298 vec!["<details>", "<summary>", "<a href=\"./other.md\">", "<b>", "<b>"],
3299 "control: every tag is reported without the option"
3300 );
3301
3302 let with = reported_tags(&rule_allowing_inside(&["details"]), content);
3303 assert_eq!(
3304 with,
3305 vec!["<b>"],
3306 "only the tag outside the details element is left, got {with:?}"
3307 );
3308 }
3309
3310 #[test]
3311 fn test_md033_allowed_inside_ends_at_the_matching_closing_tag() {
3312 let content = "<details>\n<details>\n<b>x</b>\n</details>\n<i>y</i>\n</details>\n<b>z</b>\n";
3314 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3315 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3316
3317 let unclosed = "<details>\n\n<b>x</b>\n\n<i>y</i>\n";
3319 assert!(
3320 reported_tags(&rule_allowing_inside(&["details"]), unclosed).is_empty(),
3321 "an unclosed element reaches the end of the document"
3322 );
3323 }
3324
3325 #[test]
3326 fn test_md033_allowed_inside_ignores_an_element_quoted_in_a_code_block() {
3327 let content = "```html\n<details>\n```\n\n<b>x</b>\n";
3328 let reported = reported_tags(&rule_allowing_inside(&["details"]), content);
3329 assert_eq!(
3330 reported,
3331 vec!["<b>"],
3332 "a code block quotes the element, it does not open one: {reported:?}"
3333 );
3334 }
3335
3336 #[test]
3337 fn test_md033_allowed_inside_a_void_element_holds_nothing() {
3338 let content = "a<br>b <b>x</b>\n";
3340 let reported = reported_tags(&rule_allowing_inside(&["br"]), content);
3341 assert_eq!(
3342 reported,
3343 vec!["<br>", "<b>"],
3344 "a void element must not swallow the rest of the document: {reported:?}"
3345 );
3346 }
3347
3348 #[test]
3349 fn test_md033_allowed_inside_is_case_insensitive() {
3350 let content = "<DETAILS>\n<b>x</b>\n</DETAILS>\n<i>y</i>\n";
3351 let reported = reported_tags(&rule_allowing_inside(&["Details"]), content);
3352 assert_eq!(reported, vec!["<i>"], "got {reported:?}");
3353 }
3354
3355 #[test]
3356 fn test_md033_allowed_inside_takes_no_part_in_disallowed_mode() {
3357 let config = MD033Config {
3358 allowed_inside: vec!["details".to_string()],
3359 disallowed: vec!["b".to_string()],
3360 ..MD033Config::default()
3361 };
3362 let rule = MD033NoInlineHtml::from_config_struct(config);
3363 let content = "<details>\n<b>x</b>\n<kbd>k</kbd>\n</details>\n";
3364 let reported = reported_tags(&rule, content);
3365 assert_eq!(
3366 reported,
3367 vec!["<b>"],
3368 "a denylist names what is wrong wherever it appears: {reported:?}"
3369 );
3370 }
3371
3372 #[test]
3373 fn test_md033_allowed_inside_yields_to_an_explicit_table_allowlist() {
3374 let content = "| col |\n|-----|\n| <details><b>x</b></details> |\n";
3375
3376 let unset = reported_tags(&rule_allowing_inside(&["details"]), content);
3377 assert!(
3378 unset.is_empty(),
3379 "without a table allowlist the element applies inside a cell too: {unset:?}"
3380 );
3381
3382 let config = MD033Config {
3383 allowed_inside: vec!["details".to_string()],
3384 table_allowed_elements: Some(Vec::new()),
3385 ..MD033Config::default()
3386 };
3387 let rule = MD033NoInlineHtml::from_config_struct(config);
3388 let reported = reported_tags(&rule, content);
3389 assert_eq!(
3390 reported,
3391 vec!["<details>", "<b>"],
3392 "an explicit table allowlist decides inside a cell: {reported:?}"
3393 );
3394 }
3395
3396 fn rule_allowing(elements: &[&str]) -> MD033NoInlineHtml {
3404 MD033NoInlineHtml::with_allowed(elements.iter().map(ToString::to_string).collect())
3405 }
3406
3407 #[test]
3408 fn test_md033_no_markdown_equivalent_reports_only_what_markdown_can_write() {
3409 let content = "Press <kbd>Ctrl</kbd>, <b>bold</b>, <mark>hi</mark>, <abbr title=\"x\">A</abbr>, <em>i</em>, <details>d</details>\n";
3410
3411 let control = reported_tags(&MD033NoInlineHtml::default(), content);
3412 assert_eq!(control.len(), 6, "control: every tag is reported: {control:?}");
3413
3414 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3415 assert_eq!(
3416 reported,
3417 vec!["<b>", "<em>"],
3418 "only the elements with Markdown syntax are left: {reported:?}"
3419 );
3420 }
3421
3422 #[test]
3423 fn test_md033_no_markdown_equivalent_keeps_reporting_gfm_filtered_tags() {
3424 let content = "<kbd>k</kbd>\n\n<script>alert(1)</script>\n\n<iframe src=\"x\"></iframe>\n";
3426 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3427 assert_eq!(reported, vec!["<script>", "<iframe src=\"x\">"], "got {reported:?}");
3428 }
3429
3430 #[test]
3431 fn test_md033_no_markdown_equivalent_follows_the_flavor() {
3432 let content = "H<sub>2</sub>O, x<sup>2</sup>, <mark>hi</mark>, <kbd>k</kbd>\n";
3433 let rule = rule_allowing(&["no-markdown-equivalent"]);
3434
3435 assert!(
3436 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Standard).is_empty(),
3437 "standard Markdown writes none of these"
3438 );
3439 assert_eq!(
3440 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Pandoc),
3441 vec!["<sub>", "<sup>"],
3442 "Pandoc writes ~sub~ and ^sup^"
3443 );
3444 assert_eq!(
3445 reported_tags_in(&rule, content, crate::config::MarkdownFlavor::Obsidian),
3446 vec!["<mark>"],
3447 "Obsidian writes ==highlight=="
3448 );
3449 }
3450
3451 #[test]
3452 fn test_md033_no_markdown_equivalent_permits_a_line_break_inside_a_table_cell() {
3453 let content = "| col |\n|-----|\n| a<br>b |\n\nOutside a<br>b.\n";
3456 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent"]), content);
3457 assert_eq!(reported, vec!["<br>"], "got {reported:?}");
3458 }
3459
3460 #[test]
3461 fn test_md033_no_markdown_equivalent_composes_with_named_elements() {
3462 let content = "<kbd>k</kbd> <b>bold</b> <em>italic</em>\n";
3463 let reported = reported_tags(&rule_allowing(&["no-markdown-equivalent", "b"]), content);
3464 assert_eq!(
3465 reported,
3466 vec!["<em>"],
3467 "a named element joins the ones the sentinel permits: {reported:?}"
3468 );
3469 }
3470
3471 #[test]
3472 fn test_md033_no_markdown_equivalent_reads_the_snake_case_spelling() {
3473 let content = "<kbd>k</kbd> <b>bold</b>\n";
3475 let reported = reported_tags(&rule_allowing(&["no_markdown_equivalent"]), content);
3476 assert_eq!(reported, vec!["<b>"], "got {reported:?}");
3477 }
3478
3479 #[test]
3480 fn test_md033_no_markdown_equivalent_takes_no_part_in_disallowed_mode() {
3481 let config = MD033Config {
3482 allowed: vec!["no-markdown-equivalent".to_string()],
3483 disallowed: vec!["kbd".to_string()],
3484 ..MD033Config::default()
3485 };
3486 let rule = MD033NoInlineHtml::from_config_struct(config);
3487 let reported = reported_tags(&rule, "<kbd>k</kbd> <b>bold</b>\n");
3488 assert_eq!(
3489 reported,
3490 vec!["<kbd>"],
3491 "a denylist names what is wrong wherever it appears: {reported:?}"
3492 );
3493 }
3494}