use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
use crate::utils::calculate_indentation_width_default;
use crate::utils::mdg;
use crate::utils::mkdocs_admonitions;
use crate::utils::mkdocs_tabs;
use crate::utils::range_utils::calculate_line_range;
use toml;
mod md046_config;
pub use md046_config::CodeBlockStyle;
use md046_config::MD046Config;
static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
struct IndentContext<'a> {
in_list_context: &'a [bool],
in_tab_context: &'a [bool],
in_admonition_context: &'a [bool],
in_comment_or_html: &'a [bool],
list_item_baseline: &'a [Option<usize>],
}
struct OwnedIndentContext {
in_list_context: Vec<bool>,
in_tab_context: Vec<bool>,
in_admonition_context: Vec<bool>,
in_comment_or_html: Vec<bool>,
list_item_baseline: Vec<Option<usize>>,
}
impl OwnedIndentContext {
fn borrow(&self) -> IndentContext<'_> {
IndentContext {
in_list_context: &self.in_list_context,
in_tab_context: &self.in_tab_context,
in_admonition_context: &self.in_admonition_context,
in_comment_or_html: &self.in_comment_or_html,
list_item_baseline: &self.list_item_baseline,
}
}
}
#[derive(Clone)]
pub struct MD046CodeBlockStyle {
config: MD046Config,
style_explicit: bool,
}
impl MD046CodeBlockStyle {
const FENCE: &'static str = "```";
pub fn new(style: CodeBlockStyle) -> Self {
Self {
config: MD046Config { style },
style_explicit: true,
}
}
pub fn from_config_struct(config: MD046Config) -> Self {
Self {
config,
style_explicit: false,
}
}
fn has_valid_fence_indent(line: &str) -> bool {
calculate_indentation_width_default(line) < 4
}
fn has_valid_fence_indent_at(line: &str, baseline: usize) -> bool {
let indent = calculate_indentation_width_default(line);
indent >= baseline && indent - baseline < 4
}
fn is_fenced_code_block_start(&self, line: &str) -> bool {
if !Self::has_valid_fence_indent(line) {
return false;
}
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
fn is_fenced_code_block_start_at(&self, line: &str, baseline: usize) -> bool {
if baseline == 0 {
return self.is_fenced_code_block_start(line);
}
Self::has_valid_fence_indent_at(line, baseline)
&& (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
}
fn is_closing_fence(line: &str, fence_char: char, opener_len: usize, baseline: usize) -> bool {
if !Self::has_valid_fence_indent_at(line, baseline) {
return false;
}
let trimmed = line.trim_start();
let closer_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
closer_len >= opener_len && closer_len > 0 && trimmed[closer_len..].trim().is_empty()
}
fn strip_indentation_columns(line: &str, columns: usize) -> String {
if columns == 0 {
return line.to_string();
}
let mut width = 0usize;
let mut consumed = 0usize;
for (byte_index, ch) in line.char_indices() {
let next_width = match ch {
' ' => width + 1,
'\t' => ((width / 4) + 1) * 4,
_ => break,
};
consumed = byte_index + ch.len_utf8();
if next_width >= columns {
let remainder = next_width - columns;
let mut stripped = String::with_capacity(remainder + line.len() - consumed);
stripped.extend(std::iter::repeat_n(' ', remainder));
stripped.push_str(&line[consumed..]);
return stripped;
}
width = next_width;
}
line[consumed..].to_string()
}
fn is_list_item(&self, line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
return true;
}
let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
after_digits.len() < trimmed.len() && (after_digits.starts_with(". ") || after_digits.starts_with(") "))
}
fn is_footnote_definition(&self, line: &str) -> bool {
let trimmed = line.trim_start();
if !trimmed.starts_with("[^") || trimmed.len() < 5 {
return false;
}
if let Some(close_bracket_pos) = trimmed.find("]:")
&& close_bracket_pos > 2
{
let label = &trimmed[2..close_bracket_pos];
if label.trim().is_empty() {
return false;
}
if label.contains('\r') {
return false;
}
if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return true;
}
}
false
}
fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
let mut in_continuation_context = vec![false; lines.len()];
let mut last_list_item_line: Option<usize> = None;
let mut last_footnote_line: Option<usize> = None;
let mut blank_line_count = 0;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
let indent_len = line.len() - trimmed.len();
if self.is_list_item(line) {
last_list_item_line = Some(i);
last_footnote_line = None; blank_line_count = 0;
in_continuation_context[i] = true;
continue;
}
if self.is_footnote_definition(line) {
last_footnote_line = Some(i);
last_list_item_line = None; blank_line_count = 0;
in_continuation_context[i] = true;
continue;
}
if line.trim().is_empty() {
if last_list_item_line.is_some() || last_footnote_line.is_some() {
blank_line_count += 1;
in_continuation_context[i] = true;
}
continue;
}
if indent_len == 0 && !trimmed.is_empty() {
if trimmed.starts_with('#') {
last_list_item_line = None;
last_footnote_line = None;
blank_line_count = 0;
continue;
}
if trimmed.starts_with("---") || trimmed.starts_with("***") {
last_list_item_line = None;
last_footnote_line = None;
blank_line_count = 0;
continue;
}
if let Some(list_line) = last_list_item_line
&& (i - list_line > 5 || blank_line_count > 1)
{
last_list_item_line = None;
}
if last_footnote_line.is_some() {
last_footnote_line = None;
}
blank_line_count = 0;
if last_list_item_line.is_none() && last_footnote_line.is_some() {
last_footnote_line = None;
}
continue;
}
if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
in_continuation_context[i] = true;
blank_line_count = 0;
}
}
in_continuation_context
}
fn precompute_list_item_baseline(
&self,
ctx: &crate::lint_context::LintContext,
lines: &[&str],
) -> Vec<Option<usize>> {
let mut baselines = vec![None; lines.len()];
let mut last_baseline: Option<usize> = None;
let mut last_list_item_line: Option<usize> = None;
let mut blank_line_count = 0usize;
let mut last_item_quoted = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
let indent_len = line.len() - trimmed.len();
if let Some(info) = ctx.line_info(i + 1)
&& let Some(item) = info.list_item.as_ref()
{
last_baseline = Some(item.content_column);
last_list_item_line = Some(i);
last_item_quoted = info.blockquote.is_some();
blank_line_count = 0;
baselines[i] = last_baseline;
continue;
}
if last_item_quoted && line.trim().is_empty() {
last_baseline = None;
last_list_item_line = None;
last_item_quoted = false;
continue;
}
if line.trim().is_empty() {
if last_baseline.is_some() {
blank_line_count += 1;
baselines[i] = last_baseline;
}
continue;
}
if indent_len == 0 {
if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
last_baseline = None;
last_list_item_line = None;
} else if let Some(list_line) = last_list_item_line
&& (i - list_line > 5 || blank_line_count > 1)
{
last_baseline = None;
last_list_item_line = None;
}
blank_line_count = 0;
continue;
}
if last_baseline.is_some() {
baselines[i] = last_baseline;
blank_line_count = 0;
}
}
baselines
}
fn is_indented_code_block_with_context(
&self,
lines: &[&str],
i: usize,
is_mkdocs: bool,
ctx: &IndentContext,
prev_is_code: bool,
) -> bool {
if i >= lines.len() {
return false;
}
let line = lines[i];
if line.trim().is_empty() {
return false;
}
let indent = calculate_indentation_width_default(line);
if indent < 4 {
return false;
}
if ctx.in_list_context[i] {
let crosses_baseline = ctx
.list_item_baseline
.get(i)
.copied()
.flatten()
.is_some_and(|base| indent >= base + 4);
if !crosses_baseline {
return false;
}
}
if is_mkdocs && ctx.in_tab_context[i] {
return false;
}
if is_mkdocs && ctx.in_admonition_context[i] {
return false;
}
if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
return false;
}
let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
has_blank_line_before || prev_is_code
}
fn first_code_block_line(
ctx: &crate::lint_context::LintContext,
block_lines: &[bool],
start: usize,
block_end: usize,
) -> Option<usize> {
(start..block_lines.len())
.take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
.find(|&idx| block_lines[idx])
}
fn indented_block_lines(
&self,
lines: &[&str],
is_mkdocs: bool,
ictx: &IndentContext<'_>,
ctx: &crate::lint_context::LintContext,
) -> Vec<bool> {
let mut member = vec![false; lines.len()];
for i in 0..lines.len() {
let prev_is_code = i > 0 && member[i - 1];
member[i] = self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx, prev_is_code);
}
if ctx.flavor == crate::config::MarkdownFlavor::MDG {
let mut i = 0;
while i < member.len() {
if !member[i] {
i += 1;
continue;
}
let start = i;
while i < member.len() && member[i] {
i += 1;
}
if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
member[start..i].fill(false);
}
}
}
let mut i = 0;
while i < lines.len() {
if !member[i] {
i += 1;
continue;
}
let mut next = i + 1;
while next < lines.len() && lines[next].trim().is_empty() {
next += 1;
}
if next < lines.len() && member[next] {
member[i + 1..next].fill(true);
}
i = next;
}
member
}
fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
let mut ignored: Vec<bool> = (0..line_count)
.map(|i| {
ctx.line_info(i + 1).is_some_and(|info| {
info.in_html_comment
|| info.in_mdx_comment
|| info.in_html_block
|| info.in_jsx_block
|| info.in_mkdocstrings
|| info.in_footnote_definition
|| info.blockquote.is_some()
|| info.in_front_matter
})
})
.collect();
let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
for detail in &ctx.code_block_details {
if Self::block_is_literal(ctx, detail, ambiguity_limit)
&& let Some(first) = Self::code_block_start_line(ctx, detail)
{
let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
ignored[first..last.min(line_count)].fill(true);
}
}
if let Some(limit) = ambiguity_limit {
let first = ctx
.line_offsets
.partition_point(|&offset| offset <= limit)
.saturating_sub(1);
ignored[first..].fill(true);
}
ignored
}
fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
let mut in_tab_context = vec![false; lines.len()];
let mut current_tab_indent: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
if mkdocs_tabs::is_tab_marker(line) {
let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
current_tab_indent = Some(tab_indent);
in_tab_context[i] = true;
continue;
}
if let Some(tab_indent) = current_tab_indent {
if mkdocs_tabs::is_tab_content(line, tab_indent) {
in_tab_context[i] = true;
} else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
current_tab_indent = None;
} else {
in_tab_context[i] = true;
}
}
}
in_tab_context
}
fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
let mut in_admonition_context = vec![false; lines.len()];
let mut admonition_stack: Vec<usize> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let line_indent = calculate_indentation_width_default(line);
if mkdocs_admonitions::is_admonition_start(line) {
let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
while let Some(&top_indent) = admonition_stack.last() {
if adm_indent <= top_indent {
admonition_stack.pop();
} else {
break;
}
}
admonition_stack.push(adm_indent);
in_admonition_context[i] = true;
continue;
}
if line.trim().is_empty() {
if !admonition_stack.is_empty() {
in_admonition_context[i] = true;
}
continue;
}
while let Some(&top_indent) = admonition_stack.last() {
if line_indent >= top_indent + 4 {
break;
} else {
admonition_stack.pop();
}
}
if !admonition_stack.is_empty() {
in_admonition_context[i] = true;
}
}
in_admonition_context
}
fn build_indent_context(
&self,
ctx: &crate::lint_context::LintContext,
lines: &[&str],
is_mkdocs: bool,
) -> OwnedIndentContext {
OwnedIndentContext {
in_list_context: self.precompute_block_continuation_context(lines),
in_tab_context: if is_mkdocs {
self.precompute_mkdocs_tab_context(lines)
} else {
vec![false; lines.len()]
},
in_admonition_context: if is_mkdocs {
self.precompute_mkdocs_admonition_context(lines)
} else {
vec![false; lines.len()]
},
in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
}
}
fn categorize_indented_blocks(&self, lines: &[&str], block_lines: &[bool]) -> (Vec<bool>, Vec<bool>) {
let mut is_misplaced = vec![false; lines.len()];
let mut contains_fences = vec![false; lines.len()];
let mut i = 0;
while i < lines.len() {
if !block_lines[i] {
i += 1;
continue;
}
let block_start = i;
let mut block_end = i;
while block_end < lines.len() && block_lines[block_end] {
block_end += 1;
}
if block_end > block_start {
let first_line = lines[block_start].trim_start();
let last_line = lines[block_end - 1].trim_start();
let is_backtick_fence = first_line.starts_with("```");
let is_tilde_fence = first_line.starts_with("~~~");
if is_backtick_fence || is_tilde_fence {
let fence_char = if is_backtick_fence { '`' } else { '~' };
let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
let after_closer = &last_line[closer_fence_len..];
if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
is_misplaced[block_start..block_end].fill(true);
} else {
contains_fences[block_start..block_end].fill(true);
}
} else {
let has_fence_markers = (block_start..block_end).any(|j| {
let trimmed = lines[j].trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
});
if has_fence_markers {
contains_fences[block_start..block_end].fill(true);
}
}
}
i = block_end;
}
(is_misplaced, contains_fences)
}
fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
if !d.is_fenced || Self::block_is_literal(ctx, d, ambiguity_limit) {
return false;
}
let lang = d.info_string.to_lowercase();
lang.starts_with("markdown") || lang.starts_with("md")
});
if has_markdown_doc_block {
return warnings;
}
for detail in &ctx.code_block_details {
if !detail.is_fenced || Self::block_is_literal(ctx, detail, ambiguity_limit) {
continue;
}
if detail.end != ctx.content.len() {
continue;
}
let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
};
let line = lines.get(opening_line_idx).unwrap_or(&"");
let fence_pos = line.find("```").into_iter().chain(line.find("~~~")).min().unwrap_or(0);
let fence_char = line[fence_pos..].chars().next().unwrap_or('`');
let fence_marker: String = line[fence_pos..].chars().take_while(|&ch| ch == fence_char).collect();
let opening_quote = crate::utils::blockquote::parse_blockquote_prefix(line);
let quote_level = opening_quote.map_or(0, |quote| quote.nesting_level);
let owned = self.build_indent_context(ctx, lines, ctx.flavor == crate::config::MarkdownFlavor::MkDocs);
let baseline = owned
.list_item_baseline
.get(opening_line_idx)
.copied()
.flatten()
.unwrap_or(0);
let has_closing_fence = lines
.iter()
.enumerate()
.rev()
.find_map(|(idx, candidate)| {
let quote = crate::utils::blockquote::parse_blockquote_prefix(candidate);
let body = quote.map_or(*candidate, |quote| quote.content);
if body.trim().is_empty() {
return None;
}
Some(
idx > opening_line_idx
&& quote.map_or(0, |quote| quote.nesting_level) == quote_level
&& Self::is_closing_fence(body, fence_char, fence_marker.len(), baseline),
)
})
.unwrap_or(false);
if !has_closing_fence {
if ctx
.lines
.get(opening_line_idx)
.is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
{
continue;
}
let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!("Code block opened with '{fence_marker}' but never closed"),
severity: Severity::Warning,
fix: Some(Fix::new(ctx.content.len()..ctx.content.len(), {
let prefix: String = line[..fence_pos]
.chars()
.map(|ch| if ch == '>' || ch.is_whitespace() { ch } else { ' ' })
.collect();
let newline = crate::utils::detect_line_ending(ctx.content);
if ctx.content.ends_with('\n') {
format!("{prefix}{fence_marker}{newline}")
} else {
format!("{newline}{prefix}{fence_marker}")
}
})),
});
}
}
warnings
}
fn effective_target_style(
&self,
ctx: &crate::lint_context::LintContext,
detect: impl FnOnce() -> CodeBlockStyle,
) -> CodeBlockStyle {
if ctx.flavor == crate::config::MarkdownFlavor::MDG {
self.warn_once_about_overridden_style();
return CodeBlockStyle::Fenced;
}
let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
match self.config.style {
CodeBlockStyle::Consistent => {
let detected = detect();
if detected == CodeBlockStyle::Indented
&& ctx.code_block_details.iter().any(|detail| {
detail.is_fenced
&& !detail.info_string.trim().is_empty()
&& Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit)
})
{
CodeBlockStyle::Fenced
} else {
detected
}
}
style => style,
}
}
fn block_is_literal(
ctx: &crate::lint_context::LintContext,
detail: &crate::utils::code_block_utils::CodeBlockDetail,
ambiguity_limit: Option<usize>,
) -> bool {
ambiguity_limit.is_some_and(|limit| detail.start >= limit)
|| Self::code_block_start_line(ctx, detail).is_some_and(|line| {
ctx.lines.get(line).is_some_and(|info| {
let marker = info.byte_offset + info.indent;
ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker)
})
})
}
fn code_block_is_style_eligible(
ctx: &crate::lint_context::LintContext,
detail: &crate::utils::code_block_utils::CodeBlockDetail,
ambiguity_limit: Option<usize>,
) -> bool {
if Self::block_is_literal(ctx, detail, ambiguity_limit) {
return false;
}
let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
return false;
};
!ctx.lines.get(line_idx).is_some_and(|info| {
info.in_html_comment
|| info.in_mdx_comment
|| info.in_html_block
|| info.in_jsx_block
|| info.in_mkdocstrings
|| info.in_footnote_definition
|| info.blockquote.is_some()
|| info.in_front_matter
})
}
fn code_block_start_line(
ctx: &crate::lint_context::LintContext,
detail: &crate::utils::code_block_utils::CodeBlockDetail,
) -> Option<usize> {
if detail.start >= ctx.content.len() {
return None;
}
Some(match ctx.line_offsets.binary_search(&detail.start) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
})
}
fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
let mut lines = std::collections::HashSet::new();
for pair in ctx.code_block_details.windows(2) {
let [previous, next] = pair else {
continue;
};
if previous.end > next.start || next.start > ctx.content.len() {
continue;
}
if !ctx.content[previous.end..next.start].trim().is_empty() {
continue;
}
for detail in [previous, next] {
if detail.is_fenced
&& let Some(line) = Self::code_block_start_line(ctx, detail)
{
lines.insert(line);
}
}
}
lines
}
fn fenced_boundary_blank_lines(
ctx: &crate::lint_context::LintContext,
lines: &[&str],
ictx: &IndentContext,
) -> std::collections::HashSet<usize> {
let mut boundary_blank_lines = std::collections::HashSet::new();
for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
let Some(start) = Self::code_block_start_line(ctx, detail) else {
continue;
};
let Some(opener) = lines.get(start) else {
continue;
};
let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
let trimmed = opener.trim_start();
if !Self::has_valid_fence_indent_at(opener, baseline) {
continue;
}
let fence_char = if trimmed.starts_with("```") {
'`'
} else if trimmed.starts_with("~~~") {
'~'
} else {
continue;
};
let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
let mut block_end = start + 1;
let mut closer = None;
while block_end < lines.len()
&& ctx
.line_offsets
.get(block_end)
.is_some_and(|&offset| offset < detail.end)
{
if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
closer = Some(block_end);
break;
}
block_end += 1;
}
let payload_end = closer.unwrap_or(block_end);
if start + 1 == payload_end
|| (start + 1 < payload_end
&& (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
{
boundary_blank_lines.insert(start);
}
}
boundary_blank_lines
}
fn warn_once_about_overridden_style(&self) {
if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
return;
}
MDG_STYLE_OVERRIDE.report(
"MD046",
"style",
"indented",
"fenced",
"a Gherkin Doc String is only ever a backtick fence",
);
}
fn detect_style(
&self,
ctx: &crate::lint_context::LintContext,
lines: &[&str],
is_mkdocs: bool,
ictx: &IndentContext,
) -> Option<CodeBlockStyle> {
if lines.is_empty() {
return None;
}
let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
let mut fenced_count = 0;
let mut indented_count = 0;
let mut in_fenced = false;
let mut prev_was_indented = false;
for (i, line) in lines.iter().enumerate() {
let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
prev_was_indented = false;
continue;
}
if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
prev_was_indented = false;
continue;
}
let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
if self.is_fenced_code_block_start_at(line, baseline) {
if in_container {
prev_was_indented = false;
continue;
}
if !in_fenced {
fenced_count += 1;
in_fenced = true;
} else {
in_fenced = false;
}
prev_was_indented = false;
} else if !in_fenced && block_lines[i] {
if !prev_was_indented {
indented_count += 1;
}
prev_was_indented = true;
} else {
prev_was_indented = false;
}
}
if fenced_count == 0 && indented_count == 0 {
None
} else if fenced_count > 0 && indented_count == 0 {
Some(CodeBlockStyle::Fenced)
} else if fenced_count == 0 && indented_count > 0 {
Some(CodeBlockStyle::Indented)
} else if fenced_count >= indented_count {
Some(CodeBlockStyle::Fenced)
} else {
Some(CodeBlockStyle::Indented)
}
}
}
impl Rule for MD046CodeBlockStyle {
fn name(&self) -> &'static str {
"MD046"
}
fn description(&self) -> &'static str {
"Code blocks should use a consistent style"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if ctx.content.is_empty() {
return Ok(Vec::new());
}
if !ctx.content.contains("```")
&& !ctx.content.contains("~~~")
&& !ctx.content.contains(" ")
&& !ctx.content.contains('\t')
{
return Ok(Vec::new());
}
let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
if !unclosed_warnings.is_empty() {
let fixed = self.fix(ctx)?;
for warning in &mut unclosed_warnings {
warning.fix = if fixed == ctx.content {
None
} else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
} else {
Some(Fix::new(0..ctx.content.len(), fixed.clone()))
};
}
return Ok(unclosed_warnings);
}
let lines = ctx.raw_lines();
let mut warnings = Vec::new();
let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
let target_style = self.effective_target_style(ctx, || {
let owned = self.build_indent_context(ctx, lines, is_mkdocs);
let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
detected.unwrap_or(CodeBlockStyle::Fenced)
});
let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
&& ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
.then(|| {
let owned = self.build_indent_context(ctx, lines, is_mkdocs);
self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
});
let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
for detail in &ctx.code_block_details {
if detail.start >= ctx.content.len()
|| detail.end > ctx.content.len()
|| Self::block_is_literal(ctx, detail, ambiguity_limit)
{
continue;
}
let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
};
if detail.is_fenced {
if target_style == CodeBlockStyle::Indented {
let line = lines.get(start_line_idx).unwrap_or(&"");
if ctx
.lines
.get(start_line_idx)
.is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
{
continue;
}
let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: "Use indented code blocks".to_string(),
severity: Severity::Warning,
fix: None,
});
}
} else {
if target_style == CodeBlockStyle::Fenced {
let start_line_idx = match &mdg_block_lines {
Some(block_lines) => {
match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
Some(idx) => idx,
None => continue,
}
}
None => start_line_idx,
};
if reported_indented_lines.contains(&start_line_idx) {
continue;
}
let line = lines.get(start_line_idx).unwrap_or(&"");
if ctx.lines.get(start_line_idx).is_some_and(|info| {
info.in_html_comment
|| info.in_mdx_comment
|| info.in_html_block
|| info.in_jsx_block
|| info.in_mkdocstrings
|| info.in_footnote_definition
|| info.blockquote.is_some()
|| info.in_front_matter
}) {
continue;
}
if is_mkdocs
&& ctx
.lines
.get(start_line_idx)
.is_some_and(|info| info.in_admonition || info.in_content_tab)
{
continue;
}
reported_indented_lines.insert(start_line_idx);
let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: "Use fenced code blocks".to_string(),
severity: Severity::Warning,
fix: None,
});
}
}
}
if !warnings.is_empty() {
let conversion = self.convert_closed_blocks(ctx)?;
for warning in &mut warnings {
let line_idx = warning.line - 1;
warning.fix = conversion
.edits
.iter()
.find(|edit| edit.lines.contains(&line_idx))
.map(|edit| edit.fix.clone());
}
}
warnings.sort_by_key(|w| (w.line, w.column));
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
if content.is_empty() {
return Ok(String::new());
}
let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
self.check_unclosed_code_blocks(ctx),
ctx.inline_config(),
self.name(),
);
if !unclosed.is_empty() {
let repaired =
crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
let repaired_ctx = crate::lint_context::LintContext::new(
&repaired,
ctx.flavor,
ctx.source_file().map(std::path::Path::to_path_buf),
);
return Ok(self.convert_closed_blocks(&repaired_ctx)?.content);
}
Ok(self.convert_closed_blocks(ctx)?.content)
}
fn category(&self) -> RuleCategory {
RuleCategory::CodeBlock
}
fn fix_capability(&self) -> FixCapability {
FixCapability::ConditionallyFixable
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD046Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
let style_explicit = option_is_explicit(config, "MD046", "style");
Box::new(Self {
config: rule_config,
style_explicit,
})
}
}
impl MD046CodeBlockStyle {
fn convert_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<Conversion, LintError> {
let content = ctx.content;
let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
let lines = ctx.raw_lines();
let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
let owned = self.build_indent_context(ctx, lines, is_mkdocs);
let ictx = owned.borrow();
let target_style = self.effective_target_style(ctx, || {
self.detect_style(ctx, lines, is_mkdocs, &ictx)
.unwrap_or(CodeBlockStyle::Fenced)
});
let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
Self::fenced_separator_lines(ctx)
} else {
std::collections::HashSet::new()
};
let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
} else {
std::collections::HashSet::new()
};
let fenced_start_lines: std::collections::HashSet<usize> = ctx
.code_block_details
.iter()
.filter(|detail| detail.is_fenced && !Self::block_is_literal(ctx, detail, ambiguity_limit))
.filter_map(|detail| Self::code_block_start_line(ctx, detail))
.collect();
let has_unsupported_fence_opener = ctx
.code_block_details
.iter()
.filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit))
.filter_map(|detail| Self::code_block_start_line(ctx, detail))
.any(|line_index| {
let Some(line) = lines.get(line_index) else {
return true;
};
let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
!self.is_fenced_code_block_start_at(line, baseline)
});
let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
let mut result = String::with_capacity(content.len());
let mut in_fenced_block = false;
let mut fenced_fence_opener: Option<(char, usize)> = None;
let mut in_indented_block = false;
let mut current_block_fence_indent = String::new();
let mut current_block_must_stay_fenced = false;
let mut current_fence_indent = 0usize;
let mut current_fence_baseline = 0usize;
let mut current_block_indented_prefix = String::from(" ");
let mut converted_fenced_to_indented = false;
let mut retained_structurally_unsafe_fence =
target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
let mut output_starts = Vec::with_capacity(lines.len() + 1);
let mut line_blocks: Vec<Option<usize>> = vec![None; lines.len()];
let mut current_block: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
output_starts.push(result.len());
let line_num = i + 1;
let trimmed = line.trim_start();
let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
let fence_baseline = list_baseline.unwrap_or(0);
if !in_fenced_block
&& fenced_start_lines.contains(&i)
&& Self::has_valid_fence_indent_at(line, fence_baseline)
&& (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
{
let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
in_fenced_block = true;
current_block = Some(i);
line_blocks[i] = current_block;
let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
fenced_fence_opener = Some((fence_char, opener_len));
current_fence_indent = calculate_indentation_width_default(line);
current_fence_baseline = fence_baseline;
current_block_indented_prefix = " ".repeat(fence_baseline + 4);
let follows_list_item = i
.checked_sub(1)
.and_then(|previous| ictx.list_item_baseline.get(previous))
.copied()
.flatten()
.is_some();
let would_become_list_prose = target_style == CodeBlockStyle::Indented
&& list_baseline.is_none()
&& (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
&& i > 0
&& !lines[i - 1].trim().is_empty()
&& ctx
.lines
.get(i - 1)
.is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
&& crate::lint_context::is_paragraph_text_line(lines[i - 1]);
let would_merge_code_blocks = fenced_separator_lines.contains(&i);
let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
current_block_must_stay_fenced = block_disabled
|| !trimmed[opener_len..].trim().is_empty()
|| would_become_list_prose
|| would_interrupt_paragraph
|| would_merge_code_blocks
|| would_lose_boundary_blanks;
retained_structurally_unsafe_fence |= would_become_list_prose
|| would_interrupt_paragraph
|| would_merge_code_blocks
|| would_lose_boundary_blanks;
if current_block_must_stay_fenced {
result.push_str(line);
result.push('\n');
} else if target_style == CodeBlockStyle::Indented {
in_indented_block = true;
converted_fenced_to_indented = true;
} else {
result.push_str(line);
result.push('\n');
}
} else if in_fenced_block && fenced_fence_opener.is_some() {
line_blocks[i] = current_block;
let (fence_char, opener_len) = fenced_fence_opener.unwrap();
let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
if is_closer {
in_fenced_block = false;
fenced_fence_opener = None;
in_indented_block = false;
if current_block_must_stay_fenced {
result.push_str(line);
result.push('\n');
} else if target_style == CodeBlockStyle::Indented {
} else {
result.push_str(line);
result.push('\n');
}
current_block_must_stay_fenced = false;
current_fence_indent = 0;
current_fence_baseline = 0;
current_block_indented_prefix.clear();
} else if current_block_must_stay_fenced {
result.push_str(line);
result.push('\n');
} else if target_style == CodeBlockStyle::Indented {
if !line.is_empty() {
let body = Self::strip_indentation_columns(line, current_fence_indent);
result.push_str(¤t_block_indented_prefix);
result.push_str(&body);
}
result.push('\n');
} else {
result.push_str(line);
result.push('\n');
}
} else if block_lines[i] {
if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
result.push_str(line);
result.push('\n');
continue;
}
let prev_line_is_indented = i > 0 && block_lines[i - 1];
if !prev_line_is_indented {
current_block = Some(i);
}
line_blocks[i] = current_block;
if target_style == CodeBlockStyle::Fenced {
let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
let body = if line.trim().is_empty() {
String::new()
} else {
Self::strip_indentation_columns(line, 4)
};
if misplaced_fence_lines[i] {
result.push_str(line.trim_start());
result.push('\n');
} else if unsafe_fence_lines[i] {
result.push_str(line);
result.push('\n');
} else if !prev_line_is_indented && !in_indented_block {
current_block_fence_indent = " ".repeat(baseline);
result.push_str(¤t_block_fence_indent);
result.push_str(Self::FENCE);
result.push('\n');
result.push_str(&body);
result.push('\n');
in_indented_block = true;
} else {
result.push_str(&body);
result.push('\n');
}
let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
if !next_line_is_indented
&& in_indented_block
&& !misplaced_fence_lines[i]
&& !unsafe_fence_lines[i]
{
result.push_str(¤t_block_fence_indent);
result.push_str(Self::FENCE);
result.push('\n');
in_indented_block = false;
current_block_fence_indent.clear();
}
} else {
result.push_str(line);
result.push('\n');
}
} else {
if in_indented_block && target_style == CodeBlockStyle::Fenced {
result.push_str(¤t_block_fence_indent);
result.push_str(Self::FENCE);
result.push('\n');
in_indented_block = false;
current_block_fence_indent.clear();
output_starts[i] = result.len();
}
current_block = None;
result.push_str(line);
result.push('\n');
}
}
if in_indented_block && target_style == CodeBlockStyle::Fenced {
result.push_str(¤t_block_fence_indent);
result.push_str(Self::FENCE);
result.push('\n');
}
output_starts.push(result.len());
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
}
if converted_fenced_to_indented {
let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
if reparsed_block_count != ctx.code_block_details.len() {
if self.config.style == CodeBlockStyle::Consistent {
return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
}
return Ok(Conversion::unchanged(content));
}
}
if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
return Ok(Conversion::unchanged(content));
}
let mut edits = Self::block_edits(ctx, &result, &output_starts, &line_blocks);
let unsafe_blocks: Vec<_> = ctx
.code_block_details
.iter()
.filter(|detail| {
!Self::block_is_literal(ctx, detail, ambiguity_limit)
&& Self::code_block_start_line(ctx, detail).is_some_and(|first| {
let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
(first..last).any(|line| {
!lines[line].is_empty()
&& ctx.lines.get(line).is_some_and(|info| {
ctx.is_inside_template_code(info.byte_offset)
|| ctx.is_inside_mdx_code(info.byte_offset)
})
})
})
})
.map(|detail| detail.start..detail.end)
.collect();
let original_edit_count = edits.len();
edits.retain(|edit| {
!unsafe_blocks
.iter()
.any(|block| edit.fix.range.start < block.end && block.start < edit.fix.range.end)
});
if edits.len() != original_edit_count {
let mut preserved = content.to_string();
for edit in edits.iter().rev() {
preserved.replace_range(edit.fix.range.clone(), &edit.fix.replacement);
}
return Ok(Conversion {
content: preserved,
edits,
});
}
Ok(Conversion {
content: crate::utils::ensure_consistent_line_endings(content, &result),
edits,
})
}
fn block_edits(
ctx: &crate::lint_context::LintContext,
result: &str,
output_starts: &[usize],
line_blocks: &[Option<usize>],
) -> Vec<BlockEdit> {
let content = ctx.content;
let line_count = line_blocks.len();
let line_start = |i: usize| {
if i < line_count {
ctx.line_offsets[i]
} else {
content.len()
}
};
let mut edits = Vec::new();
let mut i = 0;
while i < line_count {
let Some(block) = line_blocks[i] else {
i += 1;
continue;
};
let first = i;
while i < line_count && line_blocks[i] == Some(block) {
i += 1;
}
let last = i - 1;
let original = &content[line_start(first)..line_start(i)];
let replacement = &result[output_starts[first]..output_starts[i].min(result.len())];
if original.replace("\r\n", "\n") == replacement {
continue;
}
edits.push(BlockEdit {
lines: first..=last,
fix: Fix::new(
line_start(first)..line_start(i),
crate::utils::ensure_consistent_line_endings(content, replacement),
),
});
}
edits
}
}
struct Conversion {
content: String,
edits: Vec<BlockEdit>,
}
impl Conversion {
fn unchanged(content: &str) -> Self {
Self {
content: content.to_string(),
edits: Vec::new(),
}
}
}
struct BlockEdit {
lines: std::ops::RangeInclusive<usize>,
fix: Fix,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
let flavor = if is_mkdocs {
crate::config::MarkdownFlavor::MkDocs
} else {
crate::config::MarkdownFlavor::Standard
};
let ctx = LintContext::new(content, flavor, None);
let lines: Vec<&str> = content.lines().collect();
let in_list_context = rule.precompute_block_continuation_context(&lines);
let in_tab_context = if is_mkdocs {
rule.precompute_mkdocs_tab_context(&lines)
} else {
vec![false; lines.len()]
};
let in_admonition_context = if is_mkdocs {
rule.precompute_mkdocs_admonition_context(&lines)
} else {
vec![false; lines.len()]
};
let in_comment_or_html = vec![false; lines.len()];
let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
let ictx = IndentContext {
in_list_context: &in_list_context,
in_tab_context: &in_tab_context,
in_admonition_context: &in_admonition_context,
in_comment_or_html: &in_comment_or_html,
list_item_baseline: &list_item_baseline,
};
rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
}
#[test]
fn test_unclosed_fence_diagnostic_matches_document_fix() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
for (content, expected) in [
("```", "```\n```"),
("```\ncode\n", "```\ncode\n```\n"),
("````\ncode\n```\n", "````\ncode\n```\n````\n"),
("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
("- ```\n code\n", "- ```\n code\n ```\n"),
(" ```\n code\n", " ```\n code\n ```\n"),
] {
for newline in ["\n", "\r\n"] {
let content = content.replace('\n', newline);
let expected = if content.contains('\n') {
expected.replace('\n', newline)
} else {
expected.to_string()
};
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
assert_eq!(edited, expected, "{content:?}");
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
}
}
#[test]
fn test_closed_quote_fence_at_eof_is_unchanged() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
for content in [
"> - item\n> ```\n> code\n> ```",
"> > ~~~~\n> > code\n> > ~~~~",
"```\r\ncode\r\n```\r\n",
"Text\r\n\n- item\r\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
}
#[test]
fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "> ```\n> code\n";
let expected = "> ```\n> code\n> ```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
let remaining = rule.check(&fixed_ctx).unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].message, "Use indented code blocks");
assert!(
remaining[0].fix.is_none(),
"Container conversion is intentionally unsupported"
);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
#[test]
fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "```\ncode\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(edited, " code\n");
assert_eq!(rule.fix(&ctx).unwrap(), edited);
let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
}
#[test]
fn test_fenced_code_block_detection() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
assert!(rule.is_fenced_code_block_start("```"));
assert!(rule.is_fenced_code_block_start("```rust"));
assert!(rule.is_fenced_code_block_start("~~~"));
assert!(rule.is_fenced_code_block_start("~~~python"));
assert!(rule.is_fenced_code_block_start(" ```"));
assert!(!rule.is_fenced_code_block_start("``"));
assert!(!rule.is_fenced_code_block_start("~~"));
assert!(!rule.is_fenced_code_block_start("Regular text"));
}
#[test]
fn test_fix_capability_is_conditional() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
}
#[test]
fn test_consistent_style_with_fenced_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_consistent_style_with_indented_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_consistent_style_mixed() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_fenced_style_with_indented_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "Text\n\n indented code\n more code\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_fenced_style_with_tab_indented_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Mixed whitespace (2 spaces + tab) should be detected as indented code"
);
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_fenced_style_with_one_space_tab_indent() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_indented_style_with_fenced_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "Text\n\n```\nfenced code\n```\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
assert!(result[0].message.contains("Use indented code blocks"));
}
#[test]
fn test_unclosed_code_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```\ncode without closing fence";
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.contains("never closed"));
}
#[test]
fn test_nested_code_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fix_indented_to_fenced() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "Text\n\n code line 1\n code line 2\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
}
#[test]
fn test_fix_fenced_to_indented() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(" code line 1\n code line 2"));
assert!(!fixed.contains("```"));
}
#[test]
fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
for line in fixed.lines() {
assert!(
line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
"no line may have trailing whitespace, got {line:?}"
);
assert_ne!(line, " ", "blank line was indented to trailing spaces");
}
assert!(fixed.contains(" code line 1\n\n code line 2"));
}
#[test]
fn test_is_list_item_requires_delimiter_after_digits() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
assert!(rule.is_list_item("1. First"));
assert!(rule.is_list_item("42) Item"));
assert!(rule.is_list_item(" 3. Indented item"));
assert!(rule.is_list_item("- bullet"));
assert!(rule.is_list_item("* bullet"));
assert!(!rule.is_list_item("2 results. More info."));
assert!(!rule.is_list_item("3 options (a, b) here"));
assert!(!rule.is_list_item("100 items in stock. Buy now"));
}
#[test]
fn test_fix_fenced_to_indented_preserves_internal_indentation() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = r#"# Test
```
<!doctype html>
<html>
<head>
<title>Test</title>
</head>
</html>
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains(" <head>"),
"Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
);
assert!(
fixed.contains(" <title>"),
"Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
);
assert!(!fixed.contains("```"), "Fenced markers should be removed");
}
#[test]
fn test_fix_fenced_to_indented_preserves_python_indentation() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = r#"# Python Example
```
def greet(name):
if name:
print(f"Hello, {name}!")
else:
print("Hello, World!")
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains(" def greet(name):"),
"Function def should have 4 spaces (code block indent)"
);
assert!(
fixed.contains(" if name:"),
"if statement should have 8 spaces (4 code + 4 Python)"
);
assert!(
fixed.contains(" print"),
"print should have 12 spaces (4 code + 8 Python)"
);
}
#[test]
fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = r#"# Config
```
server:
host: localhost
port: 8080
ssl:
enabled: true
cert: /path/to/cert
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
assert!(fixed.contains(" host:"), "First level should have 6 spaces");
assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
}
#[test]
fn test_fix_fenced_to_indented_preserves_empty_lines() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "```\nline1\n\nline2\n```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(" line1"), "line1 should be indented");
assert!(fixed.contains(" line2"), "line2 should be indented");
assert!(
fixed.contains(" line1\n\n line2"),
"blank line must stay empty, got {fixed:?}"
);
}
#[test]
fn test_fix_fenced_to_indented_multiple_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = r#"# Doc
```
def foo():
pass
```
Text between.
```
key:
value: 1
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(" def foo():"), "Python def should be indented");
assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
assert!(!fixed.contains("```"), "No fence markers should remain");
}
#[test]
fn test_fix_unclosed_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```\ncode without closing";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.ends_with("```"));
}
#[test]
fn test_code_block_in_list() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "- List item\n code in list\n more code\n- Next item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_detect_style_fenced() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "```\ncode\n```";
let style = detect_style_from_content(&rule, content, false);
assert_eq!(style, Some(CodeBlockStyle::Fenced));
}
#[test]
fn test_detect_style_indented() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "Text\n\n code\n\nMore";
let style = detect_style_from_content(&rule, content, false);
assert_eq!(style, Some(CodeBlockStyle::Indented));
}
#[test]
fn test_detect_style_none() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "No code blocks here";
let style = detect_style_from_content(&rule, content, false);
assert_eq!(style, None);
}
#[test]
fn test_tilde_fence() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "~~~\ncode\n~~~";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_language_specification() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```rust\nfn main() {}\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_empty_content() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_default_config() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let (name, _config) = rule.default_config_section().unwrap();
assert_eq!(name, "MD046");
}
#[test]
fn test_markdown_documentation_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_preserve_trailing_newline() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "```\ncode\n```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_mkdocs_tabs_not_flagged_as_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
=== "Python"
This is tab content
Not an indented code block
```python
def hello():
print("Hello")
```
=== "JavaScript"
More tab content here
Also not an indented code block"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_mkdocs_tabs_with_actual_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
=== "Tab 1"
This is tab content
Regular text
This is an actual indented code block
Should be flagged"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_mkdocs_tabs_detect_style() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = r#"=== "Tab 1"
Content in tab
More content
=== "Tab 2"
Content in second tab"#;
let style = detect_style_from_content(&rule, content, true);
assert_eq!(style, None);
let style = detect_style_from_content(&rule, content, false);
assert_eq!(style, Some(CodeBlockStyle::Indented));
}
#[test]
fn test_mkdocs_nested_tabs() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
=== "Outer Tab"
Some content
=== "Nested Tab"
Nested tab content
Should not be flagged"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
!!! note
This is normal admonition content, not a code block.
It spans multiple lines.
??? warning "Collapsible Warning"
This is also admonition content.
???+ tip "Expanded Tip"
And this one too.
Regular text outside admonitions."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Admonition content in MkDocs mode should not trigger MD046"
);
}
#[test]
fn test_mkdocs_admonition_with_actual_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
!!! note
This is admonition content.
Regular text ends the admonition.
This is actual indented code (should be flagged)"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_admonition_in_standard_mode_flagged() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
!!! note
This looks like code in standard mode.
Regular text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Admonition content in Standard mode should be flagged as indented code"
);
}
#[test]
fn test_mkdocs_admonition_with_fenced_code_inside() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
!!! note "Code Example"
Here's some code:
```python
def hello():
print("world")
```
More text after code.
Regular text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
}
#[test]
fn test_mkdocs_nested_admonitions() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
!!! note "Outer"
Outer content.
!!! warning "Inner"
Inner content.
More inner content.
Back to outer.
Regular text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
}
#[test]
fn test_mkdocs_admonition_fix_does_not_wrap() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"!!! note
Content that should stay as admonition content.
Not be wrapped in code fences.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("```\n Content"),
"Admonition content should not be wrapped in fences"
);
assert_eq!(fixed, content, "Content should remain unchanged");
}
#[test]
fn test_mkdocs_empty_admonition() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"!!! note
Regular paragraph after empty admonition.
This IS an indented code block (after blank + non-indented line)."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
}
#[test]
fn test_mkdocs_indented_admonition() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"- List item
!!! note
Indented admonition content.
More content.
- Next item"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Indented admonitions (e.g., in lists) should not be flagged"
);
}
#[test]
fn test_footnote_indented_paragraphs_not_flagged() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Test Document with Footnotes
This is some text with a footnote[^1].
Here's some code:
```bash
echo "fenced code block"
```
More text with another footnote[^2].
[^1]: Really interesting footnote text.
Even more interesting second paragraph.
[^2]: Another footnote.
With a second paragraph too.
And even a third paragraph!"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_footnote_definition_detection() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
assert!(rule.is_footnote_definition("[^1]: Footnote text"));
assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
assert!(rule.is_footnote_definition("[^123]: Numeric label"));
assert!(rule.is_footnote_definition("[^_]: Single underscore"));
assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
assert!(!rule.is_footnote_definition("[^]: No label"));
assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
assert!(!rule.is_footnote_definition("Regular text [^1]:"));
assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
assert!(!rule.is_footnote_definition("[^test.name]: Period"));
assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
}
#[test]
fn test_footnote_with_blank_lines() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
Text with footnote[^1].
[^1]: First paragraph.
Second paragraph after blank line.
Third paragraph after another blank line.
Regular text at column 0 ends the footnote."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Indented content within footnotes should not trigger MD046"
);
}
#[test]
fn test_footnote_multiple_consecutive_blank_lines() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text[^1].
[^1]: First paragraph.
Content after three blank lines (still part of footnote).
Not indented, so footnote ends here."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Multiple blank lines shouldn't break footnote continuation"
);
}
#[test]
fn test_footnote_terminated_by_non_indented_content() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"[^1]: Footnote content.
More indented content in footnote.
This paragraph is not indented, so footnote ends.
This should be flagged as indented code block."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Indented code after footnote termination should be flagged"
);
assert!(
result[0].message.contains("Use fenced code blocks"),
"Expected MD046 warning for indented code block"
);
assert!(result[0].line >= 7, "Warning should be on the indented code block line");
}
#[test]
fn test_footnote_terminated_by_structural_elements() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"[^1]: Footnote content.
More content.
## Heading terminates footnote
This indented content should be flagged.
---
This should also be flagged (after horizontal rule)."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Both indented blocks after termination should be flagged"
);
}
#[test]
fn test_footnote_with_code_block_inside() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text[^1].
[^1]: Footnote with code:
```python
def hello():
print("world")
```
More footnote text after code."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
}
#[test]
fn test_footnote_with_8_space_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text[^1].
[^1]: Footnote with nested code.
code block
more code"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"8-space indented code within footnotes represents nested code blocks"
);
}
#[test]
fn test_multiple_footnotes() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text[^1] and more[^2].
[^1]: First footnote.
Continuation of first.
[^2]: Second footnote starts here, ending the first.
Continuation of second."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Multiple footnotes should each maintain their continuation context"
);
}
#[test]
fn test_list_item_ends_footnote_context() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"[^1]: Footnote.
Content in footnote.
- List item starts here (ends footnote context).
This indented content is part of the list, not the footnote."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"List items should end footnote context and start their own"
);
}
#[test]
fn test_footnote_vs_actual_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Heading
Text with footnote[^1].
[^1]: Footnote content.
Part of footnote (should not be flagged).
Regular paragraph ends footnote context.
This is actual indented code (MUST be flagged)
Should be detected as code block"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Must still detect indented code blocks outside footnotes"
);
assert!(
result[0].message.contains("Use fenced code blocks"),
"Expected MD046 warning for indented code"
);
assert!(
result[0].line >= 11,
"Warning should be on the actual indented code line"
);
}
#[test]
fn test_spec_compliant_label_characters() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
assert!(rule.is_footnote_definition("[^test]: text"));
assert!(rule.is_footnote_definition("[^TEST]: text"));
assert!(rule.is_footnote_definition("[^test-name]: text"));
assert!(rule.is_footnote_definition("[^test_name]: text"));
assert!(rule.is_footnote_definition("[^test123]: text"));
assert!(rule.is_footnote_definition("[^123]: text"));
assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
assert!(!rule.is_footnote_definition("[^test.name]: text")); assert!(!rule.is_footnote_definition("[^test name]: text")); assert!(!rule.is_footnote_definition("[^test@name]: text")); assert!(!rule.is_footnote_definition("[^test#name]: text")); assert!(!rule.is_footnote_definition("[^test$name]: text")); assert!(!rule.is_footnote_definition("[^test%name]: text")); }
#[test]
fn test_code_block_inside_html_comment() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
Some text.
<!--
Example code block in comment:
```typescript
console.log("Hello");
```
More comment text.
-->
More content."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Code blocks inside HTML comments should not be flagged as unclosed"
);
}
#[test]
fn test_unclosed_fence_inside_html_comment() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
<!--
Example with intentionally unclosed fence:
```
code without closing
-->
More content."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Unclosed fences inside HTML comments should be ignored"
);
}
#[test]
fn test_multiline_html_comment_with_indented_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
<!--
Example:
indented code
more code
End of comment.
-->
Regular text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Indented code inside HTML comments should not be flagged"
);
}
#[test]
fn test_code_block_after_html_comment() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"# Document
<!-- comment -->
Text before.
indented code should be flagged
More text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Code blocks after HTML comments should still be detected"
);
assert!(result[0].message.contains("Use fenced code blocks"));
}
#[test]
fn test_consistent_style_indented_html_comment() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# MD046 false-positive reproduction\n\
\n\
<!--\n \
This is just an indented comment, not a code block.\n\
\n \
A second line is required to trigger the false-positive.\n\
\n \
Actually, three lines are required.\n\
-->\n\
\n\
```md\n\
This should be fine, since it's the only code block and therefore consistent.\n\
```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"A single fenced block and an indented HTML comment must produce no MD046 warnings",
);
}
#[test]
fn test_consistent_style_indented_html_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
<div class=\"note\">\n \
line one of indented html content\n \
line two of indented html content\n \
line three of indented html content\n\
</div>\n\
\n\
```md\n\
real fenced block\n\
```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Indented content inside a raw HTML block must not influence MD046 style detection",
);
}
#[test]
fn test_consistent_style_fake_fence_inside_html_comment() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Title\n\
\n\
<!--\n\
```\n\
fake fence inside comment\n\
```\n\
-->\n\
\n \
real indented code block line 1\n \
real indented code block line 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Fence markers inside an HTML comment must not influence MD046 style detection",
);
}
#[test]
fn test_consistent_style_indented_footnote_definition() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
Reference to a footnote[^note].\n\
\n\
[^note]: First line of the footnote.\n \
Second indented continuation line.\n \
Third indented continuation line.\n \
Fourth indented continuation line.\n\
\n\
```md\n\
real fenced block\n\
```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Footnote-definition continuation content must not influence MD046 style detection",
);
}
#[test]
fn test_consistent_style_indented_blockquote() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
> line one of quoted indented content\n\
>\n\
> line two of quoted indented content\n\
>\n\
> line three of quoted indented content\n\
\n\
```md\n\
real fenced block\n\
```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Indented content inside a blockquote must not influence MD046 style detection",
);
}
#[test]
fn test_consistent_style_genuine_indented_block_detected_as_indented() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
Some prose.\n\
\n \
real indented code line 1\n \
real indented code line 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"A genuine top-level indented block must be detected as Indented style under Consistent",
);
}
#[test]
fn test_consistent_style_skipped_lines_dont_override_real_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
<!--\n \
skipped indented comment line 1\n \
skipped indented comment line 2\n\
-->\n\
\n\
<!--\n \
second skipped region\n \
also skipped\n\
-->\n\
\n \
real indented code line\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Skipped container lines must not outweigh the single real indented block",
);
}
#[test]
fn test_consistent_style_fenced_wins_over_skipped_indented() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# Heading\n\
\n\
<!--\n \
skipped indented region one\n \
more of region one\n\
-->\n\
\n\
<!--\n \
skipped indented region two\n \
more of region two\n\
-->\n\
\n\
```md\n\
real fenced block\n\
```\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result,
vec![],
"Fenced block must win when all indented lines are inside skipped containers",
);
}
#[test]
fn test_four_space_indented_fence_is_not_valid_fence() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
assert!(rule.is_fenced_code_block_start("```"));
assert!(rule.is_fenced_code_block_start(" ```"));
assert!(rule.is_fenced_code_block_start(" ```"));
assert!(rule.is_fenced_code_block_start(" ```"));
assert!(!rule.is_fenced_code_block_start(" ```"));
assert!(!rule.is_fenced_code_block_start(" ```"));
assert!(!rule.is_fenced_code_block_start(" ```"));
assert!(!rule.is_fenced_code_block_start("\t```"));
}
#[test]
fn test_issue_237_indented_fenced_block_detected_as_indented() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"## Test
```js
var foo = "hello";
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"4-space indented fence should be detected as indented code block"
);
assert!(
result[0].message.contains("Use fenced code blocks"),
"Expected 'Use fenced code blocks' message"
);
}
#[test]
fn test_issue_276_indented_code_in_list() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"1. First item
2. Second item with code:
# This is a code block in a list
print("Hello, world!")
4. Third item"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Indented code block inside list should be flagged when style=fenced"
);
assert!(
result[0].message.contains("Use fenced code blocks"),
"Expected 'Use fenced code blocks' message"
);
}
#[test]
fn test_three_space_indented_fence_is_valid() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"## Test
```js
var foo = "hello";
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"3-space indented fence should be recognized as valid fenced code block"
);
}
#[test]
fn test_indented_style_with_deeply_indented_fenced() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = r#"Text
```js
var foo = "hello";
```
More text
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"4-space indented content should be valid when style=indented"
);
}
#[test]
fn test_fix_misplaced_fenced_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"## Test
```js
var foo = "hello";
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = r#"## Test
```js
var foo = "hello";
```
"#;
assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
}
#[test]
fn test_fix_regular_indented_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text
var foo = "hello";
console.log(foo);
More text
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
}
#[test]
fn test_fix_indented_block_with_fence_like_content() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text
some code
```not a fence opener
more code
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
}
#[test]
fn test_fix_mixed_indented_and_misplaced_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = r#"Text
regular indented code
More text
```python
print("hello")
```
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("```\nregular indented code\n```"),
"First block should be wrapped in fences"
);
assert!(
fixed.contains("\n```python\nprint(\"hello\")\n```"),
"Second block should be dedented, not double-wrapped"
);
assert!(
!fixed.contains("```\n```python"),
"Should not have nested fence openers"
);
}
#[test]
fn test_md046_front_matter() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "---\nmetadata:\n\n description: Indented\n---\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_md046_fix_front_matter() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "---\nmetadata:\n\n description: Indented\n---\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_whitespace_only_line_is_not_an_indented_code_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
}
#[test]
fn test_interior_blank_line_keeps_indented_block_together() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
}
#[test]
fn test_consistent_style_counts_a_block_with_interior_blank_once() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
}
#[test]
fn test_indented_lazy_continuation_lines_are_not_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
);
}
#[test]
fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
}
#[test]
fn test_mdg_overrides_indented_style_to_fenced() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&mdg_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let standard_warnings = rule.check(&standard_ctx).unwrap();
assert_eq!(standard_warnings.len(), 1);
assert_eq!(standard_warnings[0].message, "Use indented code blocks");
assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
}
#[test]
fn test_mdg_indented_style_still_fences_indented_blocks() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content =
"# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let warnings = rule.check(&mdg_ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].message, "Use fenced code blocks");
let fixed = rule.fix(&mdg_ctx).unwrap();
assert_eq!(
fixed,
"# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
);
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&standard_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
}
#[test]
fn test_mdg_steers_indented_code_to_fenced() {
let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
for rule in [
MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].message, "Use fenced code blocks");
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
}
}
#[test]
fn test_mdg_consistent_style_ignores_indented_prevalence() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
let indented_majority = "# Feature: Payloads\n\n## Scenario: Mixed payloads\n\n* Given this payload\n\n```\n{\"ok\": true}\n```\n\nFirst ordinary example:\n\n one\n\nSecond ordinary example:\n\n two\n";
let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
let standard_warnings = rule.check(&standard_ctx).unwrap();
assert_eq!(standard_warnings.len(), 1);
assert_eq!(standard_warnings[0].message, "Use indented code blocks");
let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
let mdg_warnings = rule.check(&mdg_ctx).unwrap();
assert_eq!(mdg_warnings.len(), 2);
assert!(
mdg_warnings
.iter()
.all(|warning| warning.message == "Use fenced code blocks")
);
}
#[test]
fn test_mdg_repairs_unclosed_fence_like_standard() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let warnings = rule.check(&mdg_ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("never closed"));
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&mdg_ctx).unwrap(),
rule.fix(&standard_ctx).unwrap(),
"MDG must not differ from Standard"
);
}
#[test]
fn test_mdg_table_above_prose_is_never_fenced() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n\n a note about the data\n\n## Scenario: Other\n\n unrelated indented code\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(reported, vec![8, 12]);
let fixed = rule.fix(&mdg_ctx).unwrap();
assert_eq!(
fixed,
"# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n\n```\na note about the data\n```\n\n## Scenario: Other\n\n```\n unrelated indented code\n```\n"
);
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(
rule.check(&fixed_ctx).unwrap().is_empty(),
"MDG check must have nothing left to report after its own fix"
);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(standard_reported, vec![5, 12]);
assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
}
#[test]
fn test_mdg_repairs_unclosed_fence_under_indented_style() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let warnings = rule.check(&mdg_ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("never closed"));
let fixed = rule.fix(&mdg_ctx).unwrap();
assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
}
#[test]
fn test_mdg_tab_indented_table_is_not_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
for indent in ["\t\t", " \t"] {
let content = format!(
"# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
);
let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
let fixed = rule.fix(&mdg_ctx).unwrap();
assert!(
fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
"MDG must leave the tab-indented table alone: {fixed:?}"
);
let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(standard_reported, vec![5, 10]);
}
}
#[test]
fn test_from_config_records_whether_style_was_configured() {
use crate::config::Config;
use std::collections::BTreeMap;
let mut values = BTreeMap::new();
values.insert("style".to_string(), toml::Value::String("indented".to_string()));
let mut config = Config::default();
config.rules.insert(
"MD046".to_string(),
crate::config::RuleConfig { severity: None, values },
);
let configured = MD046CodeBlockStyle::from_config(&config);
let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
assert_eq!(configured.config.style, CodeBlockStyle::Indented);
assert!(configured.style_explicit);
let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
assert!(!defaulted.style_explicit);
let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
style: CodeBlockStyle::Indented,
});
let content = "# Feature: F\n\nText.\n\n code here\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
}
#[test]
fn test_mdg_indented_style_keeps_tables_out_of_code() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&mdg_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&standard_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
}
#[test]
fn test_style_warnings_carry_their_block_fix() {
let cases = [
(
CodeBlockStyle::Fenced,
"# T\n\n one\n\n still one\n\nText\n\n two\n",
"# T\n\n```\none\n\nstill one\n```\n\nText\n\n```\ntwo\n```\n",
),
(
CodeBlockStyle::Indented,
"# T\n\n```\none\n```\n\nText\n\n~~~\ntwo\n~~~\n",
"# T\n\n one\n\nText\n\n two\n",
),
(
CodeBlockStyle::Fenced,
"# T\n\n ```bash\n ls\n ```\n",
"# T\n\n```bash\nls\n```\n",
),
(CodeBlockStyle::Fenced, "# T\n\n code", "# T\n\n```\ncode\n```"),
];
for (style, content, expected) in cases {
let rule = MD046CodeBlockStyle::new(style);
for newline in ["\n", "\r\n"] {
let content = content.replace('\n', newline);
let expected = expected.replace('\n', newline);
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "{content:?}");
for warning in &warnings {
let fix = warning
.fix
.as_ref()
.unwrap_or_else(|| panic!("{content:?}: {warning:?}"));
let fix_line = content[..fix.range.start].matches('\n').count() + 1;
assert_eq!(
fix_line, warning.line,
"a warning's fix starts at its block: {content:?}"
);
}
let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
assert_eq!(edited, expected, "{content:?}");
let first = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings[..1]).unwrap();
let first_ctx = LintContext::new(&first, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.check(&first_ctx).unwrap().len(),
warnings.len() - 1,
"{content:?} -> {first:?}"
);
}
}
}
#[test]
fn test_style_warning_without_a_fix_where_fix_keeps_the_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = "# T\n\n ```\n code\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.fix(&ctx).unwrap(), content);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].fix.is_none(), "{warnings:?}");
}
#[test]
fn literal_code_blocks_are_preserved_beside_real_style_fixes() {
for (style, value, adjacent, target) in [
(
CodeBlockStyle::Fenced,
"first\n\n inner\n\nlast",
" Adjacent",
"```\nAdjacent\n```",
),
(
CodeBlockStyle::Indented,
"first\n\n~~~\ninner\n~~~\n\nlast",
"~~~\nAdjacent\n~~~",
" Adjacent",
),
] {
let rule = MD046CodeBlockStyle::new(style);
for (flavor, code) in [
(
crate::config::MarkdownFlavor::Standard,
format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}"),
),
(
crate::config::MarkdownFlavor::Hugo,
format!("{{{{< note title=`{value}` >}}}}"),
),
] {
for ending in ["\n", "\r\n"] {
let source = format!("{code}\n\n{adjacent}\n").replace('\n', ending);
let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
let ctx = LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert!(warnings[0].fix.is_some());
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
}
#[test]
fn literal_block_styles_do_not_choose_visible_code_style() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
for (value, adjacent) in [
("first\n\n~~~\nFake\n~~~\n\n~~~\nOther\n~~~\n\nlast", " Adjacent"),
("first\n\n Fake\n\nText\n\n Other\n\nlast", "~~~\nAdjacent\n~~~"),
] {
let source = format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}\n\n{adjacent}\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn literal_unclosed_fences_do_not_trigger_document_repair() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
for (flavor, code) in [
(
crate::config::MarkdownFlavor::Standard,
"{% set unused=\"first\n\n~~~\ninner\nlast\" %}{{ unused|length }}",
),
(
crate::config::MarkdownFlavor::Hugo,
"{{< note title=`first\n\n~~~\ninner\nlast` >}}",
),
] {
for ending in ["\n", "\r\n"] {
let source = format!("{code}\n\nVisible.\n").replace('\n', ending);
let ctx = LintContext::new(&source, flavor, None);
assert!(rule.check(&ctx).unwrap().is_empty());
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
}
#[test]
fn real_indented_template_blocks_are_not_partially_converted() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let title = " {% set unused=\"first\n last\" %}{{ unused|length }}";
let source = format!("{title}\n\nParagraph.\n\n Adjacent\n");
let expected = format!("{title}\n\nParagraph.\n\n```\nAdjacent\n```\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn real_fenced_template_blocks_are_not_partially_converted() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let title = "~~~\n{% set unused=\"first\nlast\" %}{{ unused|length }}\n~~~";
let source = format!("{title}\n\nParagraph.\n\n~~~\nAdjacent\n~~~\n");
let expected = format!("{title}\n\nParagraph.\n\n Adjacent\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn real_single_line_template_code_blocks_remain_convertible() {
for (style, source, expected) in [
(CodeBlockStyle::Fenced, " {{ title }}\n", "```\n{{ title }}\n```\n"),
(CodeBlockStyle::Indented, "~~~\n{{ title }}\n~~~\n", " {{ title }}\n"),
] {
let rule = MD046CodeBlockStyle::new(style);
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn mdx_literal_indentation_does_not_become_a_code_block() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
for code in [
"export const text = `first\n\n inner\n\nlast`\n\n{text}",
"{`first\n\n inner\n\nlast`}",
] {
let source = format!("{code}\n\n```\nAdjacent\n```\n");
let expected = format!("{code}\n\n```\nAdjacent\n```\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn literal_closers_keep_ambiguous_fence_suffixes_unchanged() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
let suffix = "~~~\n{% set unused=\"first\n~~~\nlast\" %}{{ unused|length }}\n~~~\n";
let source = format!("~~~\nEarlier\n~~~\n\nParagraph.\n\n{suffix}");
let expected = format!(" Earlier\n\nParagraph.\n\n{suffix}");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}