use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::regex_cache::*;
use std::collections::HashSet;
mod md033_config;
use md033_config::{MD033Config, MD033FixMode};
#[derive(Clone)]
pub struct MD033NoInlineHtml {
config: MD033Config,
allowed: HashSet<String>,
disallowed: HashSet<String>,
drop_attributes: HashSet<String>,
strip_wrapper_elements: HashSet<String>,
}
impl Default for MD033NoInlineHtml {
fn default() -> Self {
let config = MD033Config::default();
let allowed = config.allowed_set();
let disallowed = config.disallowed_set();
let drop_attributes = config.drop_attributes_set();
let strip_wrapper_elements = config.strip_wrapper_elements_set();
Self {
config,
allowed,
disallowed,
drop_attributes,
strip_wrapper_elements,
}
}
}
impl MD033NoInlineHtml {
pub fn new() -> Self {
Self::default()
}
pub fn with_allowed(allowed_vec: Vec<String>) -> Self {
let config = MD033Config {
allowed: allowed_vec.clone(),
disallowed: Vec::new(),
fix: false,
..MD033Config::default()
};
let allowed = config.allowed_set();
let disallowed = config.disallowed_set();
let drop_attributes = config.drop_attributes_set();
let strip_wrapper_elements = config.strip_wrapper_elements_set();
Self {
config,
allowed,
disallowed,
drop_attributes,
strip_wrapper_elements,
}
}
pub fn with_disallowed(disallowed_vec: Vec<String>) -> Self {
let config = MD033Config {
allowed: Vec::new(),
disallowed: disallowed_vec.clone(),
fix: false,
..MD033Config::default()
};
let allowed = config.allowed_set();
let disallowed = config.disallowed_set();
let drop_attributes = config.drop_attributes_set();
let strip_wrapper_elements = config.strip_wrapper_elements_set();
Self {
config,
allowed,
disallowed,
drop_attributes,
strip_wrapper_elements,
}
}
pub fn with_fix(fix: bool) -> Self {
let config = MD033Config {
allowed: Vec::new(),
disallowed: Vec::new(),
fix,
..MD033Config::default()
};
let allowed = config.allowed_set();
let disallowed = config.disallowed_set();
let drop_attributes = config.drop_attributes_set();
let strip_wrapper_elements = config.strip_wrapper_elements_set();
Self {
config,
allowed,
disallowed,
drop_attributes,
strip_wrapper_elements,
}
}
pub fn from_config_struct(config: MD033Config) -> Self {
let allowed = config.allowed_set();
let disallowed = config.disallowed_set();
let drop_attributes = config.drop_attributes_set();
let strip_wrapper_elements = config.strip_wrapper_elements_set();
Self {
config,
allowed,
disallowed,
drop_attributes,
strip_wrapper_elements,
}
}
#[inline]
fn is_tag_allowed(&self, tag: &str) -> bool {
if self.allowed.is_empty() {
return false;
}
let tag = tag.trim_start_matches('<').trim_start_matches('/');
let tag_name = tag
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()
.unwrap_or("");
self.allowed.contains(&tag_name.to_lowercase())
}
#[inline]
fn is_tag_disallowed(&self, tag: &str) -> bool {
if self.disallowed.is_empty() {
return false;
}
let tag = tag.trim_start_matches('<').trim_start_matches('/');
let tag_name = tag
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()
.unwrap_or("");
self.disallowed.contains(&tag_name.to_lowercase())
}
#[inline]
fn is_disallowed_mode(&self) -> bool {
self.config.is_disallowed_mode()
}
#[inline]
fn is_html_comment(&self, tag: &str) -> bool {
tag.starts_with("<!--") && tag.ends_with("-->")
}
#[inline]
fn is_html_element_or_custom(tag_name: &str) -> bool {
const HTML_ELEMENTS: &[&str] = &[
"a",
"abbr",
"acronym",
"address",
"applet",
"area",
"article",
"aside",
"audio",
"b",
"base",
"basefont",
"bdi",
"bdo",
"big",
"blockquote",
"body",
"br",
"button",
"canvas",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"data",
"datalist",
"dd",
"del",
"details",
"dfn",
"dialog",
"dir",
"div",
"dl",
"dt",
"em",
"embed",
"fieldset",
"figcaption",
"figure",
"font",
"footer",
"form",
"frame",
"frameset",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hgroup",
"hr",
"html",
"i",
"iframe",
"img",
"input",
"ins",
"isindex",
"kbd",
"label",
"legend",
"li",
"link",
"main",
"map",
"mark",
"marquee",
"math",
"menu",
"meta",
"meter",
"nav",
"noembed",
"noframes",
"noscript",
"object",
"ol",
"optgroup",
"option",
"output",
"p",
"param",
"picture",
"plaintext",
"pre",
"progress",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"script",
"search",
"section",
"select",
"slot",
"small",
"source",
"span",
"strike",
"strong",
"style",
"sub",
"summary",
"sup",
"svg",
"table",
"tbody",
"td",
"template",
"textarea",
"tfoot",
"th",
"thead",
"time",
"title",
"tr",
"track",
"tt",
"u",
"ul",
"var",
"video",
"wbr",
"xmp",
];
let lower = tag_name.to_ascii_lowercase();
if HTML_ELEMENTS.binary_search(&lower.as_str()).is_ok() {
return true;
}
tag_name.contains('-')
}
#[inline]
fn is_likely_type_annotation(&self, tag: &str) -> bool {
const COMMON_TYPES: &[&str] = &[
"any",
"apiresponse",
"array",
"bigint",
"config",
"data",
"date",
"e",
"element",
"error",
"function",
"generator",
"item",
"iterator",
"k",
"map",
"node",
"null",
"number",
"options",
"params",
"promise",
"regexp",
"request",
"response",
"result",
"set",
"string",
"symbol",
"t",
"u",
"undefined",
"userdata",
"v",
"void",
"weakmap",
"weakset",
];
let tag_content = tag
.trim_start_matches('<')
.trim_end_matches('>')
.trim_start_matches('/');
let tag_name = tag_content
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()
.unwrap_or("");
if !tag_content.contains(' ') && !tag_content.contains('=') {
let lower = tag_name.to_ascii_lowercase();
COMMON_TYPES.binary_search(&lower.as_str()).is_ok()
} else {
false
}
}
#[inline]
fn is_email_address(&self, tag: &str) -> bool {
let content = tag.trim_start_matches('<').trim_end_matches('>');
content.contains('@')
&& content.chars().all(|c| c.is_alphanumeric() || "@.-_+".contains(c))
&& content.split('@').count() == 2
&& content.split('@').all(|part| !part.is_empty())
}
#[inline]
fn has_markdown_attribute(&self, tag: &str) -> bool {
tag.contains(" markdown>") || tag.contains(" markdown=") || tag.contains(" markdown ")
}
#[inline]
fn has_jsx_attributes(tag: &str) -> bool {
tag.contains("className")
|| tag.contains("htmlFor")
|| tag.contains("dangerouslySetInnerHTML")
|| tag.contains("onClick")
|| tag.contains("onChange")
|| tag.contains("onSubmit")
|| tag.contains("onFocus")
|| tag.contains("onBlur")
|| tag.contains("onKeyDown")
|| tag.contains("onKeyUp")
|| tag.contains("onKeyPress")
|| tag.contains("onMouseDown")
|| tag.contains("onMouseUp")
|| tag.contains("onMouseEnter")
|| tag.contains("onMouseLeave")
|| tag.contains("={")
}
#[inline]
fn is_url_in_angle_brackets(&self, tag: &str) -> bool {
let content = tag.trim_start_matches('<').trim_end_matches('>');
content.starts_with("http://")
|| content.starts_with("https://")
|| content.starts_with("ftp://")
|| content.starts_with("ftps://")
|| content.starts_with("mailto:")
}
#[inline]
fn is_relaxed_fix_mode(&self) -> bool {
self.config.fix_mode == MD033FixMode::Relaxed
}
#[inline]
fn is_droppable_attribute(&self, attr_name: &str) -> bool {
if attr_name.starts_with("on") && attr_name.len() > 2 {
return false;
}
self.drop_attributes.contains(attr_name)
|| (attr_name.starts_with("data-")
&& (self.drop_attributes.contains("data-*") || self.drop_attributes.contains("data-")))
}
#[inline]
fn is_strippable_wrapper(&self, tag_name: &str) -> bool {
self.is_relaxed_fix_mode() && self.strip_wrapper_elements.contains(tag_name)
}
fn is_inside_strippable_wrapper(&self, content: &str, byte_offset: usize) -> bool {
if byte_offset == 0 {
return false;
}
let before = content[..byte_offset].trim_end();
if !before.ends_with('>') || before.ends_with("->") {
return false;
}
if let Some(last_lt) = before.rfind('<') {
let potential_tag = &before[last_lt..];
if potential_tag.starts_with("</") || potential_tag.starts_with("<!--") {
return false;
}
let parent_name = potential_tag
.trim_start_matches('<')
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()
.unwrap_or("")
.to_lowercase();
if !self.strip_wrapper_elements.contains(&parent_name) {
return false;
}
let wrapper_before = before[..last_lt].trim_end();
if wrapper_before.ends_with('>')
&& !wrapper_before.ends_with("->")
&& let Some(outer_lt) = wrapper_before.rfind('<')
&& let outer_tag = &wrapper_before[outer_lt..]
&& !outer_tag.starts_with("</")
&& !outer_tag.starts_with("<!--")
{
return false;
}
return true;
}
false
}
fn convert_to_markdown(tag_name: &str, inner_content: &str) -> Option<String> {
if inner_content.contains('<') {
return None;
}
if inner_content.contains('&') && inner_content.contains(';') {
let has_entity = inner_content
.split('&')
.skip(1)
.any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
if has_entity {
return None;
}
}
match tag_name {
"em" | "i" => Some(format!("*{inner_content}*")),
"strong" | "b" => Some(format!("**{inner_content}**")),
"code" => {
if inner_content.contains('`') {
Some(format!("`` {inner_content} ``"))
} else {
Some(format!("`{inner_content}`"))
}
}
_ => None,
}
}
fn convert_self_closing_to_markdown(&self, tag_name: &str, opening_tag: &str) -> Option<String> {
match tag_name {
"br" => match self.config.br_style {
md033_config::BrStyle::TrailingSpaces => Some(" \n".to_string()),
md033_config::BrStyle::Backslash => Some("\\\n".to_string()),
},
"hr" => Some("\n---\n".to_string()),
"img" => self.convert_img_to_markdown(opening_tag),
_ => None,
}
}
fn parse_attributes(tag: &str) -> Vec<(String, Option<String>)> {
let mut attrs = Vec::new();
let tag_content = tag.trim_start_matches('<').trim_end_matches('>').trim_end_matches('/');
let attr_start = tag_content
.find(|c: char| c.is_whitespace())
.map(|i| i + 1)
.unwrap_or(tag_content.len());
if attr_start >= tag_content.len() {
return attrs;
}
let attr_str = &tag_content[attr_start..];
let mut chars = attr_str.chars().peekable();
while chars.peek().is_some() {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek().is_none() {
break;
}
let mut attr_name = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
break;
}
attr_name.push(c);
chars.next();
}
if attr_name.is_empty() {
break;
}
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek() == Some(&'=') {
chars.next();
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let mut value = String::new();
if let Some("e) = chars.peek() {
if quote == '"' || quote == '\'' {
chars.next(); for c in chars.by_ref() {
if c == quote {
break;
}
value.push(c);
}
} else {
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '>' || c == '/' {
break;
}
value.push(c);
chars.next();
}
}
}
attrs.push((attr_name.to_ascii_lowercase(), Some(value)));
} else {
attrs.push((attr_name.to_ascii_lowercase(), None));
}
}
attrs
}
fn extract_attribute(tag: &str, attr_name: &str) -> Option<String> {
let attrs = Self::parse_attributes(tag);
let attr_lower = attr_name.to_ascii_lowercase();
attrs
.into_iter()
.find(|(name, _)| name == &attr_lower)
.and_then(|(_, value)| value)
}
fn has_extra_attributes(&self, tag: &str, allowed_attrs: &[&str]) -> bool {
let attrs = Self::parse_attributes(tag);
const DANGEROUS_ATTR_PREFIXES: &[&str] = &["on"]; const DANGEROUS_ATTRS: &[&str] = &[
"class",
"id",
"style",
"target",
"rel",
"download",
"referrerpolicy",
"crossorigin",
"loading",
"decoding",
"fetchpriority",
"sizes",
"srcset",
"usemap",
"ismap",
"width",
"height",
"name", "data-*", ];
for (attr_name, _) in attrs {
if allowed_attrs.iter().any(|a| a.to_ascii_lowercase() == attr_name) {
continue;
}
if self.is_relaxed_fix_mode() {
if self.is_droppable_attribute(&attr_name) {
continue;
}
return true;
}
for prefix in DANGEROUS_ATTR_PREFIXES {
if attr_name.starts_with(prefix) && attr_name.len() > prefix.len() {
return true;
}
}
if attr_name.starts_with("data-") {
return true;
}
if DANGEROUS_ATTRS.contains(&attr_name.as_str()) {
return true;
}
}
false
}
fn convert_a_to_markdown(&self, opening_tag: &str, inner_content: &str) -> Option<String> {
let href = Self::extract_attribute(opening_tag, "href")?;
if !MD033Config::is_safe_url(&href) {
return None;
}
if inner_content.contains('<') {
return None;
}
if inner_content.contains('&') && inner_content.contains(';') {
let has_entity = inner_content
.split('&')
.skip(1)
.any(|part| part.split(';').next().is_some_and(|e| !e.is_empty() && e.len() < 10));
if has_entity {
return None;
}
}
let title = Self::extract_attribute(opening_tag, "title");
if self.has_extra_attributes(opening_tag, &["href", "title"]) {
return None;
}
let trimmed_inner = inner_content.trim();
let is_markdown_image =
trimmed_inner.starts_with(" && trimmed_inner.ends_with(')') && {
if let Some(bracket_close) = trimmed_inner.rfind("](") {
let after_paren = &trimmed_inner[bracket_close + 2..];
after_paren.ends_with(')')
&& after_paren.chars().filter(|&c| c == ')').count()
>= after_paren.chars().filter(|&c| c == '(').count()
} else {
false
}
};
let escaped_text = if is_markdown_image {
trimmed_inner.to_string()
} else {
inner_content.replace('[', r"\[").replace(']', r"\]")
};
let escaped_url = href.replace('(', "%28").replace(')', "%29");
if let Some(title_text) = title {
let escaped_title = title_text.replace('"', r#"\""#);
Some(format!("[{escaped_text}]({escaped_url} \"{escaped_title}\")"))
} else {
Some(format!("[{escaped_text}]({escaped_url})"))
}
}
fn convert_img_to_markdown(&self, tag: &str) -> Option<String> {
let src = Self::extract_attribute(tag, "src")?;
if !MD033Config::is_safe_url(&src) {
return None;
}
let alt = Self::extract_attribute(tag, "alt").unwrap_or_default();
let title = Self::extract_attribute(tag, "title");
if self.has_extra_attributes(tag, &["src", "alt", "title"]) {
return None;
}
let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
let escaped_url = src.replace('(', "%28").replace(')', "%29");
if let Some(title_text) = title {
let escaped_title = title_text.replace('"', r#"\""#);
Some(format!(""))
} else {
Some(format!(""))
}
}
fn has_significant_attributes(opening_tag: &str) -> bool {
let tag_content = opening_tag
.trim_start_matches('<')
.trim_end_matches('>')
.trim_end_matches('/');
let parts: Vec<&str> = tag_content.split_whitespace().collect();
parts.len() > 1
}
fn is_nested_in_html(content: &str, tag_byte_start: usize, tag_byte_end: usize) -> bool {
if tag_byte_start > 0 {
let before = &content[..tag_byte_start];
let before_trimmed = before.trim_end();
if before_trimmed.ends_with('>') && !before_trimmed.ends_with("->") {
if let Some(last_lt) = before_trimmed.rfind('<') {
let potential_tag = &before_trimmed[last_lt..];
if !potential_tag.starts_with("</") && !potential_tag.starts_with("<!--") {
return true;
}
}
}
}
if tag_byte_end < content.len() {
let after = &content[tag_byte_end..];
let after_trimmed = after.trim_start();
if after_trimmed.starts_with("</") {
return true;
}
}
false
}
fn calculate_fix(
&self,
content: &str,
opening_tag: &str,
tag_byte_start: usize,
in_html_block: bool,
) -> Option<(std::ops::Range<usize>, String)> {
let tag_name = opening_tag
.trim_start_matches('<')
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()?
.to_lowercase();
let is_self_closing =
opening_tag.ends_with("/>") || matches!(tag_name.as_str(), "br" | "hr" | "img" | "input" | "meta" | "link");
if is_self_closing {
let block_ok = !in_html_block
|| (self.is_relaxed_fix_mode() && self.is_inside_strippable_wrapper(content, tag_byte_start));
if self.config.fix
&& MD033Config::is_safe_fixable_tag(&tag_name)
&& block_ok
&& let Some(markdown) = self.convert_self_closing_to_markdown(&tag_name, opening_tag)
{
return Some((tag_byte_start..tag_byte_start + opening_tag.len(), markdown));
}
return None;
}
let search_start = tag_byte_start + opening_tag.len();
let search_slice = &content[search_start..];
let closing_tag_lower = format!("</{tag_name}>");
let closing_pos = search_slice.to_ascii_lowercase().find(&closing_tag_lower);
if let Some(closing_pos) = closing_pos {
let closing_tag_len = closing_tag_lower.len();
let closing_byte_start = search_start + closing_pos;
let closing_byte_end = closing_byte_start + closing_tag_len;
let inner_content = &content[search_start..closing_byte_start];
if self.config.fix && self.is_strippable_wrapper(&tag_name) {
if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
return None;
}
if inner_content.contains('<') {
return None;
}
return Some((tag_byte_start..closing_byte_end, inner_content.trim().to_string()));
}
if in_html_block {
return None;
}
if Self::is_nested_in_html(content, tag_byte_start, closing_byte_end) {
return None;
}
if self.config.fix && MD033Config::is_safe_fixable_tag(&tag_name) {
if tag_name == "a" {
if let Some(markdown) = self.convert_a_to_markdown(opening_tag, inner_content) {
return Some((tag_byte_start..closing_byte_end, markdown));
}
return None;
}
if Self::has_significant_attributes(opening_tag) {
return None;
}
if let Some(markdown) = Self::convert_to_markdown(&tag_name, inner_content) {
return Some((tag_byte_start..closing_byte_end, markdown));
}
return None;
}
return None;
}
None
}
}
impl Rule for MD033NoInlineHtml {
fn name(&self) -> &'static str {
"MD033"
}
fn description(&self) -> &'static str {
"Inline HTML is not allowed"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
if content.is_empty() || !ctx.likely_has_html() {
return Ok(Vec::new());
}
if !HTML_TAG_QUICK_CHECK.is_match(content) {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
let html_tags = ctx.html_tags();
for html_tag in html_tags.iter() {
if html_tag.is_closing {
continue;
}
let line_num = html_tag.line;
let tag_byte_start = html_tag.byte_offset;
let tag = &content[html_tag.byte_offset..html_tag.byte_end];
if ctx
.line_info(line_num)
.is_some_and(|info| info.in_code_block || info.in_pymdown_block || info.is_kramdown_block_ial)
{
continue;
}
if ctx.is_in_html_comment(tag_byte_start) {
continue;
}
if self.is_html_comment(tag) {
continue;
}
if ctx.is_in_link_title(tag_byte_start) {
continue;
}
if ctx.flavor.supports_jsx() && html_tag.tag_name.chars().next().is_some_and(|c| c.is_uppercase()) {
continue;
}
if ctx.flavor.supports_jsx() && (html_tag.tag_name.is_empty() || tag == "<>" || tag == "</>") {
continue;
}
if ctx.flavor.supports_jsx() && Self::has_jsx_attributes(tag) {
continue;
}
if !Self::is_html_element_or_custom(&html_tag.tag_name) {
continue;
}
if self.is_likely_type_annotation(tag) {
continue;
}
if self.is_email_address(tag) {
continue;
}
if self.is_url_in_angle_brackets(tag) {
continue;
}
if ctx.is_byte_offset_in_code_span(tag_byte_start) {
continue;
}
if self.is_disallowed_mode() {
if !self.is_tag_disallowed(tag) {
continue;
}
} else {
if self.is_tag_allowed(tag) {
continue;
}
}
if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && self.has_markdown_attribute(tag) {
continue;
}
let in_html_block = ctx.is_in_html_block(line_num);
let fix = self
.calculate_fix(content, tag, tag_byte_start, in_html_block)
.map(|(range, replacement)| Fix { range, replacement });
let (end_line, end_col) = if html_tag.byte_end > 0 {
ctx.offset_to_line_col(html_tag.byte_end - 1)
} else {
(line_num, html_tag.end_col + 1)
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: line_num,
column: html_tag.start_col + 1, end_line, end_column: end_col + 1, message: format!("Inline HTML found: {tag}"),
severity: Severity::Warning,
fix,
});
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if !self.config.fix {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
return Ok(ctx.content.to_string());
}
let mut fixes: Vec<_> = warnings
.iter()
.filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
.collect();
fixes.sort_by(|a, b| b.0.cmp(&a.0));
let mut result = ctx.content.to_string();
for (start, end, replacement) in fixes {
if start < result.len() && end <= result.len() && start <= end {
result.replace_range(start..end, replacement);
}
}
Ok(result)
}
fn fix_capability(&self) -> crate::rule::FixCapability {
if self.config.fix {
crate::rule::FixCapability::FullyFixable
} else {
crate::rule::FixCapability::Unfixable
}
}
fn category(&self) -> RuleCategory {
RuleCategory::Html
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_html()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let json_value = serde_json::to_value(&self.config).ok()?;
Some((
self.name().to_string(),
crate::rule_config_serde::json_to_toml_value(&json_value)?,
))
}
fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
let mut aliases = std::collections::HashMap::new();
aliases.insert("allowed".to_string(), "allowed-elements".to_string());
aliases.insert("disallowed".to_string(), "disallowed-elements".to_string());
Some(aliases)
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD033Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::rule::Rule;
fn relaxed_fix_rule() -> MD033NoInlineHtml {
let config = MD033Config {
fix: true,
fix_mode: MD033FixMode::Relaxed,
..MD033Config::default()
};
MD033NoInlineHtml::from_config_struct(config)
}
#[test]
fn test_md033_basic_html() {
let rule = MD033NoInlineHtml::default();
let content = "<div>Some content</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); assert!(result[0].message.starts_with("Inline HTML found: <div>"));
}
#[test]
fn test_md033_case_insensitive() {
let rule = MD033NoInlineHtml::default();
let content = "<DiV>Some <B>content</B></dIv>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2); assert_eq!(result[0].message, "Inline HTML found: <DiV>");
assert_eq!(result[1].message, "Inline HTML found: <B>");
}
#[test]
fn test_md033_allowed_tags() {
let rule = MD033NoInlineHtml::with_allowed(vec!["div".to_string(), "br".to_string()]);
let content = "<div>Allowed</div><p>Not allowed</p><br/>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].message, "Inline HTML found: <p>");
let content2 = "<DIV>Allowed</DIV><P>Not allowed</P><BR/>";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <P>");
}
#[test]
fn test_md033_html_comments() {
let rule = MD033NoInlineHtml::default();
let content = "<!-- This is a comment --> <p>Not a comment</p>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <p>");
}
#[test]
fn test_md033_tags_in_links() {
let rule = MD033NoInlineHtml::default();
let content = "[Link](http://example.com/<div>)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].message, "Inline HTML found: <div>");
let content2 = "[Link <a>text</a>](url)";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 1); assert_eq!(result2[0].message, "Inline HTML found: <a>");
}
#[test]
fn test_md033_fix_escaping() {
let rule = MD033NoInlineHtml::default();
let content = "Text with <div> and <br/> tags.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_content = rule.fix(&ctx).unwrap();
assert_eq!(fixed_content, content);
}
#[test]
fn test_md033_in_code_blocks() {
let rule = MD033NoInlineHtml::default();
let content = "```html\n<div>Code</div>\n```\n<div>Not code</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); assert_eq!(result[0].message, "Inline HTML found: <div>");
}
#[test]
fn test_md033_in_code_spans() {
let rule = MD033NoInlineHtml::default();
let content = "Text with `<p>in code</p>` span. <br/> Not in span.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].message, "Inline HTML found: <br/>");
}
#[test]
fn test_md033_issue_90_code_span_with_diff_block() {
let rule = MD033NoInlineHtml::default();
let content = r#"# Heading
`<env>`
```diff
- this
+ that
```"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Should not report HTML tags inside code spans");
}
#[test]
fn test_md033_multiple_code_spans_with_angle_brackets() {
let rule = MD033NoInlineHtml::default();
let content = "`<one>` and `<two>` and `<three>` are all code spans";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Should not report HTML tags inside any code spans");
}
#[test]
fn test_md033_nested_angle_brackets_in_code_span() {
let rule = MD033NoInlineHtml::default();
let content = "Text with `<<nested>>` brackets";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Should handle nested angle brackets in code spans");
}
#[test]
fn test_md033_code_span_at_end_before_code_block() {
let rule = MD033NoInlineHtml::default();
let content = "Testing `<test>`\n```\ncode here\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Should handle code span before code block");
}
#[test]
fn test_md033_quick_fix_inline_tag() {
let rule = MD033NoInlineHtml::default();
let content = "This has <span>inline text</span> that should keep content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert!(
result[0].fix.is_none(),
"Non-fixable tags like <span> should not have a fix"
);
}
#[test]
fn test_md033_quick_fix_multiline_tag() {
let rule = MD033NoInlineHtml::default();
let content = "<div>\nBlock content\n</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert!(result[0].fix.is_none(), "HTML block elements should NOT have auto-fix");
}
#[test]
fn test_md033_quick_fix_self_closing_tag() {
let rule = MD033NoInlineHtml::default();
let content = "Self-closing: <br/>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert!(
result[0].fix.is_none(),
"Self-closing tags should not have a fix when fix config is false"
);
}
#[test]
fn test_md033_quick_fix_multiple_tags() {
let rule = MD033NoInlineHtml::default();
let content = "<span>first</span> and <strong>second</strong>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should find two HTML tags");
assert!(result[0].fix.is_none(), "Non-fixable <span> should not have a fix");
assert!(
result[1].fix.is_none(),
"<strong> should not have a fix when fix config is false"
);
}
#[test]
fn test_md033_skip_angle_brackets_in_link_titles() {
let rule = MD033NoInlineHtml::default();
let content = r#"# Test
[example]: <https://example.com> "Title with <Angle Brackets> inside"
Regular text with <div>content</div> HTML tag.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find opening div tag");
assert!(
result[0].message.contains("<div>"),
"Should flag <div>, got: {}",
result[0].message
);
}
#[test]
fn test_md033_skip_angle_brackets_in_link_title_single_quotes() {
let rule = MD033NoInlineHtml::default();
let content = r#"[ref]: url 'Title <Help Wanted> here'
<span>text</span> here
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find opening span tag");
assert!(
result[0].message.contains("<span>"),
"Should flag <span>, got: {}",
result[0].message
);
}
#[test]
fn test_md033_multiline_tag_end_line_calculation() {
let rule = MD033NoInlineHtml::default();
let content = "<div\n class=\"test\"\n id=\"example\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert_eq!(result[0].line, 1, "Start line should be 1");
assert_eq!(result[0].end_line, 3, "End line should be 3");
}
#[test]
fn test_md033_single_line_tag_same_start_end_line() {
let rule = MD033NoInlineHtml::default();
let content = "Some text <div class=\"test\"> more text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert_eq!(result[0].line, 1, "Start line should be 1");
assert_eq!(result[0].end_line, 1, "End line should be 1 for single-line tag");
}
#[test]
fn test_md033_multiline_tag_with_many_attributes() {
let rule = MD033NoInlineHtml::default();
let content =
"Text\n<div\n data-attr1=\"value1\"\n data-attr2=\"value2\"\n data-attr3=\"value3\">\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert_eq!(result[0].line, 2, "Start line should be 2");
assert_eq!(result[0].end_line, 5, "End line should be 5");
}
#[test]
fn test_md033_disallowed_mode_basic() {
let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string(), "iframe".to_string()]);
let content = "<div>Safe content</div><script>alert('xss')</script>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag disallowed tags");
assert!(result[0].message.contains("<script>"), "Should flag script tag");
}
#[test]
fn test_md033_disallowed_gfm_security_tags() {
let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
let content = r#"
<div>Safe</div>
<title>Bad title</title>
<textarea>Bad textarea</textarea>
<style>.bad{}</style>
<iframe src="evil"></iframe>
<script>evil()</script>
<plaintext>old tag</plaintext>
<span>Safe span</span>
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 6, "Should flag 6 GFM security tags");
let flagged_tags: Vec<&str> = result
.iter()
.filter_map(|w| w.message.split("<").nth(1))
.filter_map(|s| s.split(">").next())
.filter_map(|s| s.split_whitespace().next())
.collect();
assert!(flagged_tags.contains(&"title"), "Should flag title");
assert!(flagged_tags.contains(&"textarea"), "Should flag textarea");
assert!(flagged_tags.contains(&"style"), "Should flag style");
assert!(flagged_tags.contains(&"iframe"), "Should flag iframe");
assert!(flagged_tags.contains(&"script"), "Should flag script");
assert!(flagged_tags.contains(&"plaintext"), "Should flag plaintext");
assert!(!flagged_tags.contains(&"div"), "Should NOT flag div");
assert!(!flagged_tags.contains(&"span"), "Should NOT flag span");
}
#[test]
fn test_md033_disallowed_case_insensitive() {
let rule = MD033NoInlineHtml::with_disallowed(vec!["script".to_string()]);
let content = "<SCRIPT>alert('xss')</SCRIPT><Script>alert('xss')</Script>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag both case variants");
}
#[test]
fn test_md033_disallowed_with_attributes() {
let rule = MD033NoInlineHtml::with_disallowed(vec!["iframe".to_string()]);
let content = r#"<iframe src="https://evil.com" width="100" height="100"></iframe>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag iframe with attributes");
assert!(result[0].message.contains("iframe"), "Should flag iframe");
}
#[test]
fn test_md033_disallowed_all_gfm_tags() {
use md033_config::GFM_DISALLOWED_TAGS;
let rule = MD033NoInlineHtml::with_disallowed(vec!["gfm".to_string()]);
for tag in GFM_DISALLOWED_TAGS {
let content = format!("<{tag}>content</{tag}>");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "GFM tag <{tag}> should be flagged");
}
}
#[test]
fn test_md033_disallowed_mixed_with_custom() {
let rule = MD033NoInlineHtml::with_disallowed(vec![
"gfm".to_string(),
"marquee".to_string(), ]);
let content = r#"<script>bad</script><marquee>annoying</marquee><div>ok</div>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag both gfm and custom tags");
}
#[test]
fn test_md033_disallowed_empty_means_default_mode() {
let rule = MD033NoInlineHtml::with_disallowed(vec![]);
let content = "<div>content</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Empty disallowed = default mode");
}
#[test]
fn test_md033_jsx_fragments_in_mdx() {
let rule = MD033NoInlineHtml::default();
let content = r#"# MDX Document
<>
<Heading />
<Content />
</>
<div>Regular HTML should still be flagged</div>
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only find one HTML tag (the div)");
assert!(
result[0].message.contains("<div>"),
"Should flag <div>, not JSX fragments"
);
}
#[test]
fn test_md033_jsx_components_in_mdx() {
let rule = MD033NoInlineHtml::default();
let content = r#"<CustomComponent prop="value">
Content
</CustomComponent>
<MyButton onClick={handler}>Click</MyButton>
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Should not flag JSX components in MDX");
}
#[test]
fn test_md033_jsx_not_skipped_in_standard_markdown() {
let rule = MD033NoInlineHtml::default();
let content = "<Script>alert(1)</Script>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag <Script> in standard markdown");
}
#[test]
fn test_md033_jsx_attributes_in_mdx() {
let rule = MD033NoInlineHtml::default();
let content = r#"# MDX with JSX Attributes
<div className="card big">Content</div>
<button onClick={handleClick}>Click me</button>
<label htmlFor="input-id">Label</label>
<input onChange={handleChange} />
<div class="html-class">Regular HTML should be flagged</div>
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only flag HTML element without JSX attributes, got: {result:?}"
);
assert!(
result[0].message.contains("<div class="),
"Should flag the div with HTML class attribute"
);
}
#[test]
fn test_md033_jsx_attributes_not_skipped_in_standard() {
let rule = MD033NoInlineHtml::default();
let content = r#"<div className="card">Content</div>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag JSX-style elements in standard markdown");
}
#[test]
fn test_md033_fix_disabled_by_default() {
let rule = MD033NoInlineHtml::default();
assert!(!rule.config.fix, "Fix should be disabled by default");
assert_eq!(rule.fix_capability(), crate::rule::FixCapability::Unfixable);
}
#[test]
fn test_md033_fix_enabled_em_to_italic() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <em>emphasized text</em> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has *emphasized text* here.");
}
#[test]
fn test_md033_fix_enabled_i_to_italic() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <i>italic text</i> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has *italic text* here.");
}
#[test]
fn test_md033_fix_enabled_strong_to_bold() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <strong>bold text</strong> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has **bold text** here.");
}
#[test]
fn test_md033_fix_enabled_b_to_bold() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <b>bold text</b> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has **bold text** here.");
}
#[test]
fn test_md033_fix_enabled_code_to_backticks() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <code>inline code</code> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has `inline code` here.");
}
#[test]
fn test_md033_fix_enabled_code_with_backticks() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <code>text with `backticks`</code> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has `` text with `backticks` `` here.");
}
#[test]
fn test_md033_fix_enabled_br_trailing_spaces() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "First line<br>Second line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "First line \nSecond line");
}
#[test]
fn test_md033_fix_enabled_br_self_closing() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "First<br/>second<br />third";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "First \nsecond \nthird");
}
#[test]
fn test_md033_fix_enabled_br_backslash_style() {
let config = MD033Config {
allowed: Vec::new(),
disallowed: Vec::new(),
fix: true,
br_style: md033_config::BrStyle::Backslash,
..MD033Config::default()
};
let rule = MD033NoInlineHtml::from_config_struct(config);
let content = "First line<br>Second line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "First line\\\nSecond line");
}
#[test]
fn test_md033_fix_enabled_hr() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Above<hr>Below";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Above\n---\nBelow");
}
#[test]
fn test_md033_fix_enabled_hr_self_closing() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Above<hr/>Below";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Above\n---\nBelow");
}
#[test]
fn test_md033_fix_skips_nested_tags() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <em>text with <strong>nested</strong> tags</em> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has <em>text with **nested** tags</em> here.");
}
#[test]
fn test_md033_fix_skips_tags_with_attributes() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <em class=\"highlight\">emphasized</em> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_disabled_no_changes() {
let rule = MD033NoInlineHtml::default(); let content = "This has <em>emphasized text</em> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Should return original content when fix is disabled");
}
#[test]
fn test_md033_fix_capability_enabled() {
let rule = MD033NoInlineHtml::with_fix(true);
assert_eq!(rule.fix_capability(), crate::rule::FixCapability::FullyFixable);
}
#[test]
fn test_md033_fix_multiple_tags() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Here is <em>italic</em> and <strong>bold</strong> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Here is *italic* and **bold** text.");
}
#[test]
fn test_md033_fix_uppercase_tags() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <EM>emphasized</EM> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has *emphasized* text.");
}
#[test]
fn test_md033_fix_unsafe_tags_not_modified() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This has <div>a div</div> content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This has <div>a div</div> content.");
}
#[test]
fn test_md033_fix_img_tag_converted() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Image: ");
}
#[test]
fn test_md033_fix_img_tag_with_extra_attrs_not_converted() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">");
}
#[test]
fn test_md033_fix_relaxed_a_with_target_is_converted() {
let rule = relaxed_fix_rule();
let content = "Link: <a href=\"https://example.com\" target=\"_blank\">Example</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Link: [Example](https://example.com)");
}
#[test]
fn test_md033_fix_relaxed_img_with_width_is_converted() {
let rule = relaxed_fix_rule();
let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" width=\"100\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Image: ");
}
#[test]
fn test_md033_fix_relaxed_rejects_unknown_extra_attributes() {
let rule = relaxed_fix_rule();
let content = "Image: <img src=\"photo.jpg\" alt=\"My Photo\" aria-label=\"hero\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Unknown attributes should not be dropped by default");
}
#[test]
fn test_md033_fix_relaxed_still_blocks_unsafe_schemes() {
let rule = relaxed_fix_rule();
let content = "Link: <a href=\"javascript:alert(1)\" target=\"_blank\">Example</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Unsafe URL schemes must never be converted");
}
#[test]
fn test_md033_fix_relaxed_wrapper_strip_requires_second_pass_for_nested_html() {
let rule = relaxed_fix_rule();
let content = "<p align=\"center\">\n <img src=\"logo.svg\" alt=\"Logo\" width=\"120\" />\n</p>";
let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_once = rule.fix(&ctx1).unwrap();
assert!(
fixed_once.contains("<p"),
"First pass should keep wrapper when inner HTML is still present: {fixed_once}"
);
assert!(
fixed_once.contains(""),
"Inner image should be converted on first pass: {fixed_once}"
);
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert!(
!fixed_twice.contains("<p"),
"Second pass should strip configured wrapper: {fixed_twice}"
);
assert!(fixed_twice.contains(""));
}
#[test]
fn test_md033_fix_relaxed_multiple_droppable_attrs() {
let rule = relaxed_fix_rule();
let content = "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener\" class=\"btn\">Click</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "[Click](https://example.com)");
}
#[test]
fn test_md033_fix_relaxed_img_multiple_droppable_attrs() {
let rule = relaxed_fix_rule();
let content = "<img src=\"logo.png\" alt=\"Logo\" width=\"120\" height=\"40\" style=\"border:none\" />";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "");
}
#[test]
fn test_md033_fix_relaxed_event_handler_never_dropped() {
let rule = relaxed_fix_rule();
let content = "<a href=\"https://example.com\" onclick=\"track()\">Link</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Event handler attributes must block conversion");
}
#[test]
fn test_md033_fix_relaxed_event_handler_even_with_custom_config() {
let config = MD033Config {
fix: true,
fix_mode: MD033FixMode::Relaxed,
drop_attributes: vec!["on*".to_string(), "target".to_string()],
..MD033Config::default()
};
let rule = MD033NoInlineHtml::from_config_struct(config);
let content = "<a href=\"https://example.com\" onclick=\"alert(1)\">Link</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "on* event handlers must never be dropped");
}
#[test]
fn test_md033_fix_relaxed_custom_drop_attributes() {
let config = MD033Config {
fix: true,
fix_mode: MD033FixMode::Relaxed,
drop_attributes: vec!["loading".to_string()],
..MD033Config::default()
};
let rule = MD033NoInlineHtml::from_config_struct(config);
let content = "<img src=\"x.jpg\" alt=\"\" loading=\"lazy\">";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "", "Custom drop-attributes should be respected");
let content2 = "<img src=\"x.jpg\" alt=\"\" width=\"100\">";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(
fixed2, content2,
"Attributes not in custom list should block conversion"
);
}
#[test]
fn test_md033_fix_relaxed_custom_strip_wrapper() {
let config = MD033Config {
fix: true,
fix_mode: MD033FixMode::Relaxed,
strip_wrapper_elements: vec!["div".to_string()],
..MD033Config::default()
};
let rule = MD033NoInlineHtml::from_config_struct(config);
let content = "<div>Some text content</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Some text content");
}
#[test]
fn test_md033_fix_relaxed_wrapper_with_plain_text() {
let rule = relaxed_fix_rule();
let content = "<p align=\"center\">Just some text</p>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Just some text");
}
#[test]
fn test_md033_fix_relaxed_data_attr_with_wildcard() {
let config = MD033Config {
fix: true,
fix_mode: MD033FixMode::Relaxed,
drop_attributes: vec!["data-*".to_string(), "target".to_string()],
..MD033Config::default()
};
let rule = MD033NoInlineHtml::from_config_struct(config);
let content = "<a href=\"https://example.com\" data-tracking=\"abc\" target=\"_blank\">Link</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "[Link](https://example.com)");
}
#[test]
fn test_md033_fix_relaxed_mixed_droppable_and_blocking_attrs() {
let rule = relaxed_fix_rule();
let content = "<a href=\"https://example.com\" target=\"_blank\" aria-label=\"nav\">Link</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Non-droppable attribute should block conversion");
}
#[test]
fn test_md033_fix_relaxed_badge_pattern() {
let rule = relaxed_fix_rule();
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>";
let ctx1 = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_once = rule.fix(&ctx1).unwrap();
assert!(
fixed_once.contains(""),
"Inner img should be converted: {fixed_once}"
);
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert!(
fixed_twice
.contains("[](https://crates.io/crates/rumdl)"),
"Badge should produce nested markdown image link: {fixed_twice}"
);
}
#[test]
fn test_md033_fix_relaxed_conservative_mode_unchanged() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<a href=\"https://example.com\" target=\"_blank\">Link</a>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Conservative mode should not drop target attribute");
}
#[test]
fn test_md033_fix_relaxed_img_inside_pre_not_converted() {
let rule = relaxed_fix_rule();
let content = "<pre>\n <img src=\"diagram.png\" alt=\"d\" width=\"100\" />\n</pre>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("<img"), "img inside pre must not be converted: {fixed}");
}
#[test]
fn test_md033_fix_relaxed_wrapper_nested_inside_div_not_stripped() {
let rule = relaxed_fix_rule();
let content = "<div><p>text</p></div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("<p>text</p>") || fixed.contains("<p>"),
"Nested <p> inside <div> should not be stripped: {fixed}"
);
}
#[test]
fn test_md033_fix_relaxed_img_inside_nested_wrapper_not_converted() {
let rule = relaxed_fix_rule();
let content = "<div><p><img src=\"x.jpg\" alt=\"pic\" width=\"100\" /></p></div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("<img"),
"img inside nested wrapper must not be converted: {fixed}"
);
}
#[test]
fn test_md033_fix_mixed_safe_tags() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<em>italic</em> and <img src=\"x.jpg\"> and <strong>bold</strong>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "*italic* and  and **bold**");
}
#[test]
fn test_md033_fix_multiple_tags_same_line() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "Regular text <i>italic</i> and <b>bold</b> here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Regular text *italic* and **bold** here.");
}
#[test]
fn test_md033_fix_multiple_em_tags_same_line() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<em>first</em> and <strong>second</strong> and <code>third</code>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "*first* and **second** and `third`");
}
#[test]
fn test_md033_fix_skips_tags_inside_pre() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<pre><code><em>VALUE</em></code></pre>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("*VALUE*"),
"Tags inside <pre> should not be converted to markdown. Got: {fixed}"
);
}
#[test]
fn test_md033_fix_skips_tags_inside_div() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<div>\n<em>emphasized</em>\n</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("*emphasized*"),
"Tags inside HTML blocks should not be converted. Got: {fixed}"
);
}
#[test]
fn test_md033_fix_outside_html_block() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<div>\ncontent\n</div>\n\nOutside <em>emphasized</em> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("*emphasized*"),
"Tags outside HTML blocks should be converted. Got: {fixed}"
);
}
#[test]
fn test_md033_fix_with_id_attribute() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "See <em id=\"important\">this note</em> for details.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_with_style_attribute() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This is <strong style=\"color: red\">important</strong> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_mixed_with_and_without_attributes() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<em>normal</em> and <em class=\"special\">styled</em> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "*normal* and <em class=\"special\">styled</em> text.");
}
#[test]
fn test_md033_quick_fix_tag_with_attributes_no_fix() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<em class=\"test\">emphasized</em>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should find one HTML tag");
assert!(
result[0].fix.is_none(),
"Should NOT have a fix for tags with attributes"
);
}
#[test]
fn test_md033_fix_skips_html_entities() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<code>|</code>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_skips_multiple_html_entities() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<code><T></code>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_allows_ampersand_without_entity() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<code>a & b</code>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "`a & b`");
}
#[test]
fn test_md033_fix_em_with_entities_skipped() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<em> text</em>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_md033_fix_skips_nested_em_in_code() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "<code><em>n</em></code>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("*n*"),
"Nested <em> should not be converted to markdown. Got: {fixed}"
);
}
#[test]
fn test_md033_fix_skips_nested_in_table() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "| <code>><em>n</em></code> | description |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("*n*"),
"Nested tags in table should not be converted. Got: {fixed}"
);
}
#[test]
fn test_md033_fix_standalone_em_still_converted() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = "This is <em>emphasized</em> text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This is *emphasized* text.");
}
#[test]
fn test_md033_templater_basic_interpolation_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "Today is <% tp.date.now() %> which is nice.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater basic interpolation should not be flagged as HTML. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_file_functions_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "File: <% tp.file.title %>\nCreated: <% tp.file.creation_date() %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater file functions should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_with_arguments_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = r#"Date: <% tp.date.now("YYYY-MM-DD") %>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater with arguments should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_javascript_execution_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%* const today = tp.date.now(); tR += today; %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater JS execution block should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_dynamic_execution_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "Dynamic: <%+ tp.date.now() %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater dynamic execution should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_whitespace_trim_all_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%_ tp.date.now() _%>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater trim-all whitespace should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_whitespace_trim_newline_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%- tp.date.now() -%>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater trim-newline should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_combined_modifiers_not_flagged() {
let rule = MD033NoInlineHtml::default();
let contents = [
"<%-* const x = 1; -%>", "<%_+ tp.date.now() _%>", "<%- tp.file.title -%>", "<%_ tp.file.title _%>", ];
for content in contents {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater combined modifiers should not be flagged: {content}. Got: {result:?}"
);
}
}
#[test]
fn test_md033_templater_multiline_block_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = r#"<%*
const x = 1;
const y = 2;
tR += x + y;
%>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater multi-line block should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_with_angle_brackets_in_condition_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%* if (x < 5) { tR += 'small'; } %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater with angle brackets in conditions should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_mixed_with_html_only_html_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<% tp.date.now() %> is today's date. <div>This is HTML</div>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag the HTML div tag");
assert!(
result[0].message.contains("<div>"),
"Should flag <div>, got: {}",
result[0].message
);
}
#[test]
fn test_md033_templater_in_heading_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "# <% tp.file.title %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater in heading should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_multiple_on_same_line_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "From <% tp.date.now() %> to <% tp.date.tomorrow() %> we have meetings.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multiple Templater blocks should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_in_code_block_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "```\n<% tp.date.now() %>\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater in code block should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_in_inline_code_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "Use `<% tp.date.now() %>` for current date.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater in inline code should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_also_works_in_standard_flavor() {
let rule = MD033NoInlineHtml::default();
let content = "<% tp.date.now() %> works everywhere.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater should not be flagged even in Standard flavor. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_empty_tag_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Empty Templater-like tag should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_unclosed_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<% tp.date.now() without closing tag";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Unclosed Templater should not be flagged as HTML. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_with_newlines_inside_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = r#"<% tp.date.now("YYYY") +
"-" +
tp.date.now("MM") %>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Templater with internal newlines should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_erb_style_tags_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = "<%= variable %> and <% code %> and <%# comment %>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"ERB/EJS style tags should not be flagged as HTML. Got: {result:?}"
);
}
#[test]
fn test_md033_templater_complex_expression_not_flagged() {
let rule = MD033NoInlineHtml::default();
let content = r#"<%*
const file = tp.file.title;
const date = tp.date.now("YYYY-MM-DD");
const folder = tp.file.folder();
tR += `# ${file}\n\nCreated: ${date}\nIn: ${folder}`;
%>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Complex Templater expression should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_md033_percent_sign_variations_not_flagged() {
let rule = MD033NoInlineHtml::default();
let patterns = [
"<%=", "<%#", "<%%", "<%!", "<%@", "<%--", ];
for pattern in patterns {
let content = format!("{pattern} content %>");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Pattern {pattern} should not be flagged. Got: {result:?}"
);
}
}
#[test]
fn test_md033_fix_a_wrapping_markdown_image_no_escaped_brackets() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = r#"<a href="https://example.com"></a>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "[](https://example.com)",);
assert!(!fixed.contains(r"\["), "Must not escape brackets: {fixed}");
assert!(!fixed.contains(r"\]"), "Must not escape brackets: {fixed}");
}
#[test]
fn test_md033_fix_a_wrapping_markdown_image_with_alt() {
let rule = MD033NoInlineHtml::with_fix(true);
let content =
r#"<a href="https://github.com/repo"></a>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"[](https://github.com/repo)"
);
}
#[test]
fn test_md033_fix_img_without_alt_produces_empty_alt() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = r#"<img src="photo.jpg" />"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "");
}
#[test]
fn test_md033_fix_a_with_plain_text_still_escapes_brackets() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = r#"<a href="https://example.com">text with [brackets]</a>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains(r"\[brackets\]"),
"Plain text brackets should be escaped: {fixed}"
);
}
#[test]
fn test_md033_fix_a_with_image_plus_extra_text_escapes_brackets() {
let rule = MD033NoInlineHtml::with_fix(true);
let content = r#"<a href="/link"> see [docs]</a>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains(r"\[docs\]"),
"Brackets in mixed image+text content should be escaped: {fixed}"
);
}
#[test]
fn test_md033_fix_img_in_a_end_to_end() {
use crate::config::Config;
use crate::fix_coordinator::FixCoordinator;
let rule = MD033NoInlineHtml::with_fix(true);
let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
let mut content =
r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image?repo=org/repo" /></a>"#
.to_string();
let config = Config::default();
let coordinator = FixCoordinator::new();
let result = coordinator
.apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
.unwrap();
assert_eq!(
content, "[](https://github.com/org/repo)",
"End-to-end: <a><img></a> should become valid linked image"
);
assert!(result.converged);
assert!(!content.contains(r"\["), "No escaped brackets: {content}");
}
#[test]
fn test_md033_fix_img_in_a_with_alt_end_to_end() {
use crate::config::Config;
use crate::fix_coordinator::FixCoordinator;
let rule = MD033NoInlineHtml::with_fix(true);
let rules: Vec<Box<dyn crate::rule::Rule>> = vec![Box::new(rule)];
let mut content =
r#"<a href="https://github.com/org/repo"><img src="https://contrib.rocks/image" alt="Contributors" /></a>"#
.to_string();
let config = Config::default();
let coordinator = FixCoordinator::new();
let result = coordinator
.apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
.unwrap();
assert_eq!(
content,
"[](https://github.com/org/repo)",
);
assert!(result.converged);
}
}