use crate::lint_context::LazyContLine;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote, parse_blockquote_prefix};
use crate::utils::calculate_indentation_width_default;
use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes, protect_literal_code};
use crate::utils::pandoc;
use crate::utils::range_utils::calculate_line_range;
use regex::Regex;
use std::sync::LazyLock;
mod md032_config;
pub(super) use md032_config::MD032Config;
static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
fn is_thematic_break(line: &str) -> bool {
if calculate_indentation_width_default(line) > 3 {
return false;
}
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
let chars: Vec<char> = trimmed.chars().collect();
let first_non_space = chars.iter().find(|&&c| c != ' ');
if let Some(&marker) = first_non_space {
if marker != '-' && marker != '*' && marker != '_' {
return false;
}
let marker_count = chars.iter().filter(|&&c| c == marker).count();
let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
marker_count >= 3 && other_count == 0
} else {
false
}
}
#[derive(Debug, Clone, Default)]
pub struct MD032BlanksAroundLists {
config: MD032Config,
}
struct ListCoverage {
starts: Vec<usize>,
reach: Vec<usize>,
}
impl ListCoverage {
fn new(list_blocks: &[(usize, usize, String)]) -> Self {
let mut spans: Vec<(usize, usize)> = list_blocks.iter().map(|&(start, end, _)| (start, end)).collect();
spans.sort_unstable();
let mut furthest = 0;
let (starts, reach) = spans
.into_iter()
.map(|(start, end)| {
furthest = furthest.max(end);
(start, furthest)
})
.unzip();
Self { starts, reach }
}
fn contains(&self, line_num: usize) -> bool {
let before = self.starts.partition_point(|&start| start <= line_num);
before > 0 && self.reach[before - 1] >= line_num
}
}
impl MD032BlanksAroundLists {
pub fn from_config_struct(config: MD032Config) -> Self {
Self { config }
}
}
impl MD032BlanksAroundLists {
fn should_require_blank_line_before(
ctx: &crate::lint_context::LintContext,
prev_line_num: usize,
current_line_num: usize,
) -> bool {
if ctx
.line_info(prev_line_num)
.is_some_and(|info| info.in_code_block || info.in_front_matter)
{
return true;
}
if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
return false;
}
true
}
fn is_nested_list(
ctx: &crate::lint_context::LintContext,
prev_line_num: usize, current_line_num: usize, ) -> bool {
if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
let current_line = &ctx.lines[current_line_num - 1];
if current_line.indent >= 2 {
if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
let prev_line = &ctx.lines[prev_line_num - 1];
if prev_line.list_item.is_some() || prev_line.indent >= 2 {
return true;
}
}
}
}
false
}
fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
ctx.lines
.get(line_num.saturating_sub(1))
.is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
}
fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
if !ctx.flavor.is_pandoc_compatible() {
return false;
}
let trimmed = info.content(ctx.content).trim();
pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
}
fn is_reportable_lazy_line(
ctx: &crate::lint_context::LintContext,
coverage: &ListCoverage,
line_num: usize,
) -> bool {
if !coverage.contains(line_num) {
return false;
}
ctx.lines
.get(line_num.saturating_sub(1))
.is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
}
fn calculate_lazy_continuation_fix(
ctx: &crate::lint_context::LintContext,
line_num: usize,
lazy_info: &LazyContLine,
) -> Option<Fix> {
let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
let line_content = line_info.content(ctx.content);
if lazy_info.blockquote_level == 0 {
let start_byte = line_info.byte_offset;
let end_byte = start_byte + lazy_info.current_indent;
let replacement = " ".repeat(lazy_info.expected_indent);
Some(Fix::new(start_byte..end_byte, replacement))
} else {
let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
if prefix_byte_len == 0 {
return None;
}
let current_indent = after_bq.len() - after_bq.trim_start().len();
let start_byte = line_info.byte_offset + prefix_byte_len;
let end_byte = start_byte + current_indent;
let replacement = " ".repeat(lazy_info.expected_indent);
Some(Fix::new(start_byte..end_byte, replacement))
}
}
fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
if lazy_info.blockquote_level == 0 {
let content = line.trim_start();
format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
} else {
let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
let prefix_len = line.len().saturating_sub(after_bq.len());
if prefix_len == 0 {
return line.to_string();
}
let prefix = &line[..prefix_len];
let rest = after_bq.trim_start();
format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
}
}
fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
for line_num in (1..before_line).rev() {
let idx = line_num - 1;
if let Some(info) = ctx.lines.get(idx) {
if is_blank_in_context(info.content(ctx.content)) {
return (line_num, true);
}
if info.in_html_comment || info.in_mdx_comment {
continue;
}
if Self::is_transparent_div_marker(ctx, info) {
continue;
}
return (line_num, is_blank_in_context(info.content(ctx.content)));
}
}
(0, true)
}
fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
let num_lines = ctx.lines.len();
for line_num in (after_line + 1)..=num_lines {
let idx = line_num - 1;
if let Some(info) = ctx.lines.get(idx) {
if is_blank_in_context(info.content(ctx.content)) {
return (line_num, true);
}
if info.in_html_comment || info.in_mdx_comment {
continue;
}
if Self::is_transparent_div_marker(ctx, info) {
continue;
}
return (line_num, is_blank_in_context(info.content(ctx.content)));
}
}
(0, true)
}
fn block_ends_in_comment_line(lines: &[&str], end_line: usize) -> bool {
lines.get(end_line - 1).is_some_and(|line| is_blank_in_context(line))
}
fn prefix_for_blank_after(
ctx: &crate::lint_context::LintContext,
end_line: usize,
following_line: usize,
list_bq_level: usize,
) -> String {
let end_bq_level = ctx
.line_info(end_line)
.and_then(|info| info.blockquote.as_ref())
.map_or(0, |bq| bq.nesting_level);
let source = if end_bq_level == list_bq_level {
end_line
} else {
following_line
};
ctx.blockquote_prefix_for_blank_line(source - 1)
}
fn is_following_content_excluded(ctx: &crate::lint_context::LintContext, line_num: usize, prefix: &str) -> bool {
ctx.line_info(line_num).is_some_and(|info| {
info.in_front_matter
|| (info.in_code_block
&& effective_indent_in_blockquote(
info.content(ctx.content),
prefix.chars().filter(|&c| c == '>').count(),
info.indent,
) >= 2)
})
}
fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
let mut blocks: Vec<(usize, usize, String)> = Vec::new();
for block in &ctx.list_blocks {
if ctx
.line_info(block.start_line)
.is_some_and(|info| info.in_footnote_definition)
{
continue;
}
let mut segments: Vec<(usize, usize)> = Vec::new();
let mut current_start = block.start_line;
let mut prev_item_line = 0;
let get_blockquote_level = |line_num: usize| -> usize {
if line_num == 0 || line_num > ctx.lines.len() {
return 0;
}
let line_content = ctx.lines[line_num - 1].content(ctx.content);
parse_blockquote_prefix(line_content).map_or(0, |bq| bq.nesting_level)
};
let mut prev_bq_level = 0;
for &item_line in &block.item_lines {
let current_bq_level = get_blockquote_level(item_line);
if prev_item_line > 0 {
let blockquote_level_changed = prev_bq_level != current_bq_level;
let mut has_standalone_code_fence = false;
let min_indent_for_content = if block.is_ordered {
3 } else {
2 };
for check_line in (prev_item_line + 1)..item_line {
if check_line - 1 < ctx.lines.len() {
let line = &ctx.lines[check_line - 1];
let line_content = line.content(ctx.content);
if line.in_code_block
&& (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
{
if line.indent < min_indent_for_content {
has_standalone_code_fence = true;
break;
}
}
}
}
if has_standalone_code_fence || blockquote_level_changed {
segments.push((current_start, prev_item_line));
current_start = item_line;
}
}
prev_item_line = item_line;
prev_bq_level = current_bq_level;
}
if prev_item_line > 0 {
segments.push((current_start, prev_item_line));
}
for (start, end) in &segments {
let mut actual_end = *end;
if *end < block.end_line {
let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
let min_continuation_indent = if block_bq_level > 0 {
if block.is_ordered {
block.max_marker_width
} else {
2 }
} else {
ctx.lines
.get(*end - 1)
.and_then(|line_info| line_info.list_item.as_ref())
.map_or(2, |item| item.content_column)
};
for check_line in (*end + 1)..=block.end_line {
if check_line - 1 < ctx.lines.len() {
let line = &ctx.lines[check_line - 1];
let line_content = line.content(ctx.content);
if block.item_lines.contains(&check_line) || line.heading.is_some() {
break;
}
if line.in_code_block {
break;
}
let effective_indent =
effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
if effective_indent >= min_continuation_indent {
actual_end = check_line;
}
else if !line.is_blank
&& line.heading.is_none()
&& !block.item_lines.contains(&check_line)
&& !is_thematic_break(line_content)
{
actual_end = check_line;
} else if !line.is_blank {
break;
}
}
}
}
blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
}
}
blocks.retain(|(start, end, _)| {
let all_in_comment = (*start..=*end).all(|line_num| {
ctx.lines
.get(line_num - 1)
.is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
});
!all_in_comment
});
blocks
}
fn perform_checks(
&self,
ctx: &crate::lint_context::LintContext,
lines: &[&str],
list_blocks: &[(usize, usize, String)],
) -> (Vec<LintWarning>, usize) {
let mut warnings = Vec::new();
let num_lines = lines.len();
let coverage = ListCoverage::new(list_blocks);
for (line_idx, line) in lines.iter().enumerate() {
let line_num = line_idx + 1;
if coverage.contains(line_num) {
continue;
}
if ctx.line_info(line_num).is_some_and(|info| {
info.in_code_block
|| info.in_front_matter
|| info.in_html_comment
|| info.in_mdx_comment
|| info.in_html_block
|| info.in_jsx_block
}) {
continue;
}
if ORDERED_LIST_NON_ONE_RE.is_match(line) {
if line_idx > 0 {
let prev_line = lines[line_idx - 1];
let prev_is_blank = is_blank_in_context(prev_line);
let prev_line_info = ctx.line_info(line_idx);
let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
let prev_in_mkdocs_container =
prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
let continues_stale_container_list = prev_in_mkdocs_container && {
let item_indent = calculate_indentation_width_default(line);
let mut found_marker = false;
for j in (0..line_idx).rev() {
let in_container = ctx
.line_info(j + 1)
.is_some_and(|info| info.in_admonition || info.in_content_tab);
if !in_container {
break;
}
let candidate = lines[j];
if is_blank_in_context(candidate) {
continue;
}
let candidate_indent = calculate_indentation_width_default(candidate);
if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
&& candidate_indent == item_indent
{
found_marker = true;
break;
}
if candidate_indent <= item_indent {
break;
}
}
found_marker
};
let prev_trimmed = prev_line.trim();
let is_sentence_continuation = continues_stale_container_list
|| (!prev_is_blank
&& !prev_trimmed.is_empty()
&& !prev_trimmed.ends_with('.')
&& !prev_trimmed.ends_with('!')
&& !prev_trimmed.ends_with('?')
&& !prev_trimmed.ends_with(':')
&& !prev_trimmed.ends_with(';')
&& !prev_trimmed.ends_with('>')
&& !prev_trimmed.ends_with('-')
&& !prev_trimmed.ends_with('*'));
if prev_is_blank || !is_sentence_continuation {
if !prev_is_blank && !prev_excluded {
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
warnings.push(LintWarning {
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
rule_name: Some(self.name().to_string()),
message: "Ordered list starting with non-1 should be preceded by blank line"
.to_string(),
fix: Some(Fix::new(
ctx.line_column_byte_range_with_length(line_num, 1, 0),
format!("{bq_prefix}\n"),
)),
});
}
if line_idx + 1 < num_lines {
let next_line = lines[line_idx + 1];
let next_is_blank = is_blank_in_context(next_line);
let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
let next_trimmed = next_line.trim_start();
let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
|| next_line.starts_with("1. ")
|| (next_line.len() > next_trimmed.len()
&& !next_trimmed.starts_with("- ")
&& !next_trimmed.starts_with("* ")
&& !next_trimmed.starts_with("+ "));
if !next_is_ordered_content {
let (start_line, start_col, end_line, end_col) =
calculate_line_range(line_num, line);
let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
warnings.push(LintWarning {
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
rule_name: Some(self.name().to_string()),
message: "List should be followed by blank line".to_string(),
fix: Some(Fix::new(
ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
format!("{bq_prefix}\n"),
)),
});
}
}
}
}
}
}
}
let separating_from = warnings.len();
for &(start_line, end_line, ref prefix) in list_blocks {
let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
if ctx
.line_info(start_line)
.is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
{
continue;
}
if start_line > 1 {
let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
if !has_blank_separation && content_line > 0 {
let prev_line_str = lines[content_line - 1];
let is_prev_excluded = ctx
.line_info(content_line)
.is_some_and(|info| info.in_code_block || info.in_front_matter);
let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
let prefixes_match = prev_bq_level == block_bq_level;
let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
if !is_prev_excluded && prefixes_match && should_require {
let (start_line, start_col, end_line, end_col) =
calculate_line_range(start_line, lines[start_line - 1]);
warnings.push(LintWarning {
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
rule_name: Some(self.name().to_string()),
message: "List should be preceded by blank line".to_string(),
fix: Some(Fix::new(
ctx.line_column_byte_range_with_length(start_line, 1, 0),
format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
)),
});
}
}
}
if end_line < num_lines && !Self::block_ends_in_comment_line(lines, end_line) {
let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
if !has_blank_separation && content_line > 0 {
let next_line_str = lines[content_line - 1];
let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
let end_line_str = lines[end_line - 1];
let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
let prefixes_match = next_line_bq_level == block_bq_level;
if !is_next_excluded && prefixes_match && !exits_blockquote {
let (start_line_last, start_col_last, end_line_last, end_col_last) =
calculate_line_range(end_line, lines[end_line - 1]);
warnings.push(LintWarning {
line: start_line_last,
column: start_col_last,
end_line: end_line_last,
end_column: end_col_last,
severity: Severity::Warning,
rule_name: Some(self.name().to_string()),
message: "List should be followed by blank line".to_string(),
fix: Some(Fix::new(
ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
format!(
"{}\n",
Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level)
),
)),
});
}
}
}
}
(warnings, separating_from)
}
}
impl Rule for MD032BlanksAroundLists {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
(self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(32))
}
fn name(&self) -> &'static str {
"MD032"
}
fn description(&self) -> &'static str {
"Lists should be surrounded by blank lines"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if self.config == Default::default() {
return Ok(self.prepared(ctx).warnings.clone());
}
Ok(self.check_native(ctx))
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.config == Default::default() {
let prepared = self.prepared(ctx);
return Ok(self.fix_with_inputs(ctx, &prepared.withheld, &prepared.blocks, &prepared.fallback));
}
Ok(self.fix_with_structure_impl(ctx, &self.withheld_fixes(ctx)))
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || ctx.list_blocks.is_empty()
}
fn category(&self) -> RuleCategory {
RuleCategory::List
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD032Config);
}
impl MD032BlanksAroundLists {
fn check_native(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
let (mut warnings, separating_from) = self.unguarded_warnings(ctx);
drop_structure_changing_fixes(ctx, &mut warnings[separating_from..], Allowed::Nothing);
protect_literal_code(ctx, &mut warnings);
warnings
}
fn prepared<'a>(
&self,
ctx: &'a crate::lint_context::LintContext,
) -> &'a crate::lint_context::NativeListSpacingPlan {
debug_assert!(self.config == Default::default());
ctx.native_list_spacing_plan(|| {
let blocks = self.convert_list_blocks(ctx);
let (mut warnings, separating_from) = self.unguarded_warnings_with_blocks(ctx, &blocks);
let fallback = warnings[..separating_from].to_vec();
let withheld = Self::guard_warnings(ctx, &mut warnings, separating_from);
crate::lint_context::NativeListSpacingPlan {
warnings,
withheld,
blocks,
fallback,
}
})
}
fn unguarded_warnings(&self, ctx: &crate::lint_context::LintContext) -> (Vec<LintWarning>, usize) {
let list_blocks = self.convert_list_blocks(ctx);
self.unguarded_warnings_with_blocks(ctx, &list_blocks)
}
fn unguarded_warnings_with_blocks(
&self,
ctx: &crate::lint_context::LintContext,
list_blocks: &[(usize, usize, String)],
) -> (Vec<LintWarning>, usize) {
let lines = ctx.raw_lines();
if lines.is_empty() {
return (Vec::new(), 0);
}
if list_blocks.is_empty() {
return (Vec::new(), 0);
}
let (mut warnings, separating_from) = self.perform_checks(ctx, lines, list_blocks);
if !self.config.allow_lazy_continuation {
let lazy_cont_lines = ctx.lazy_continuation_lines();
let coverage = ListCoverage::new(list_blocks);
for lazy_info in lazy_cont_lines.iter() {
let line_num = lazy_info.line_num;
if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
continue;
}
let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
} else {
None
};
warnings.push(LintWarning {
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
rule_name: Some(self.name().to_string()),
message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
fix,
});
}
}
(warnings, separating_from)
}
fn withheld_fixes(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<(usize, bool)> {
let (mut guarded, separating_from) = self.unguarded_warnings(ctx);
Self::guard_warnings(ctx, &mut guarded, separating_from)
}
fn guard_warnings(
ctx: &crate::lint_context::LintContext,
guarded: &mut Vec<LintWarning>,
separating_from: usize,
) -> std::collections::HashSet<(usize, bool)> {
let fix_key = |fix: &Fix| {
let line = ctx.offset_to_line_col(fix.range.start).0;
(line, fix.range.is_empty() && fix.replacement.ends_with('\n'))
};
let mut withheld: std::collections::HashSet<_> = guarded
.iter()
.filter_map(|warning| warning.fix.as_ref())
.map(fix_key)
.collect();
drop_structure_changing_fixes(ctx, &mut guarded[separating_from..], Allowed::Nothing);
protect_literal_code(ctx, guarded);
for fix in guarded.iter().filter_map(|warning| warning.fix.as_ref()) {
withheld.remove(&fix_key(fix));
}
withheld
}
fn fix_with_structure_impl(
&self,
ctx: &crate::lint_context::LintContext,
withheld: &std::collections::HashSet<(usize, bool)>,
) -> String {
let list_blocks = self.convert_list_blocks(ctx);
let (mut fallback, separating_from) = self.unguarded_warnings(ctx);
fallback.truncate(separating_from);
self.fix_with_inputs(ctx, withheld, &list_blocks, &fallback)
}
fn fix_with_inputs(
&self,
ctx: &crate::lint_context::LintContext,
withheld: &std::collections::HashSet<(usize, bool)>,
list_blocks: &[(usize, usize, String)],
fallback_warnings: &[LintWarning],
) -> String {
let lines = ctx.raw_lines();
let num_lines = lines.len();
if num_lines == 0 {
return String::new();
}
if list_blocks.is_empty() {
return ctx.content.to_string();
}
let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
if !self.config.allow_lazy_continuation {
let lazy_cont_lines = ctx.lazy_continuation_lines();
let coverage = ListCoverage::new(list_blocks);
for lazy_info in lazy_cont_lines.iter() {
let line_num = lazy_info.line_num;
if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
continue;
}
if !Self::should_apply_lazy_fix(ctx, line_num)
|| ctx.inline_config().is_rule_disabled(self.name(), line_num)
{
continue;
}
if !withheld.contains(&(line_num, false)) {
lazy_fixes.insert(line_num, lazy_info.clone());
}
}
}
let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
for warning in fallback_warnings {
if ctx.inline_config().is_rule_disabled(self.name(), warning.line) {
continue;
}
if let Some(fix) = &warning.fix
&& fix.range.is_empty()
&& let Some(prefix) = fix.replacement.strip_suffix('\n')
{
let line_num = ctx.offset_to_line_col(fix.range.start).0;
if !withheld.contains(&(line_num, true)) {
insertions.entry(line_num).or_insert_with(|| prefix.to_string());
}
}
}
for &(start_line, end_line, ref prefix) in list_blocks {
let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
if ctx
.line_info(start_line)
.is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
{
continue;
}
if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
if !has_blank_separation && content_line > 0 {
let prev_line_str = lines[content_line - 1];
let is_prev_excluded = ctx
.line_info(content_line)
.is_some_and(|info| info.in_code_block || info.in_front_matter);
let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
if !withheld.contains(&(start_line, true)) {
insertions.insert(start_line, bq_prefix);
}
}
}
}
if end_line < num_lines
&& !ctx.inline_config().is_rule_disabled(self.name(), end_line)
&& !Self::block_ends_in_comment_line(lines, end_line)
{
let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
if !has_blank_separation && content_line > 0 {
let next_line_str = lines[content_line - 1];
let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
let end_line_str = lines[end_line - 1];
let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
let bq_prefix = Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level);
if !withheld.contains(&(end_line + 1, true)) {
insertions.insert(end_line + 1, bq_prefix);
}
}
}
}
}
if insertions.is_empty() && lazy_fixes.is_empty() {
return ctx.content.to_string();
}
let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
for (i, line) in lines.iter().enumerate() {
let current_line_num = i + 1;
if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
&& (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
{
result_lines.push(prefix_to_insert.clone());
}
if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
result_lines.push(fixed_line);
} else {
result_lines.push(line.to_string());
}
}
let line_ending = crate::utils::detect_line_ending(ctx.content);
let mut result = result_lines.join(line_ending);
if ctx.content.ends_with('\n') {
result.push_str(line_ending);
}
result
}
}
fn is_blank_in_context(line: &str) -> bool {
parse_blockquote_prefix(line)
.map_or(line, |bq| bq.content)
.trim()
.is_empty()
|| crate::utils::blank_lines::is_blank_or_comment_only(line)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::rule::Rule;
#[test]
fn prepared_native_list_spacing_preserves_diagnostics_fixes_and_configuration() {
use crate::config::MarkdownFlavor::*;
let flavors = [
Standard,
MkDocs,
MDX,
Pandoc,
Quarto,
Obsidian,
Kramdown,
AzureDevOps,
MyST,
Hugo,
MDG,
GhAw,
];
let bodies = [
"text\n- first\n- second\nfollowing text",
"- first\nlazy paragraph\n- second\nafter",
"- first\n\n```rust\n- literal\n```\n- second",
"- first\n ```rust\n - literal\n ```\n- second",
"---\ntitle: List\n---\ntext\n- first\nnext",
"<!-- rumdl-disable MD032 -->\ntext\n- first\nnext",
"text\n- first\n<!-- rumdl-disable-next-line MD032 -->\n- second\nnext",
"<!--\n- literal\n-->\ntext\n- first\nnext",
"text\n2. fallback\n3. next",
"text\n- first | cell\n| --- | --- |\n- second",
"É 🦀 text\n- `literal`\nlazy text\n- next",
"",
];
for flavor in flavors {
for prefix in ["", "> ", "> > "] {
for body in bodies {
let source = body.lines().fold(String::new(), |mut source, line| {
source.push_str(prefix);
source.push_str(line);
source.push('\n');
source
});
for source in [source.clone(), source.replace('\n', "\r\n")] {
for fix_first in [false, true] {
let ctx = LintContext::new(&source, flavor, None);
for allow_lazy_continuation in [true, false, true] {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation,
});
let expected_warnings = rule.check_native(&ctx);
let expected_fix = rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx));
if fix_first {
assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
}
assert_eq!(rule.check(&ctx).unwrap(), expected_warnings, "{flavor:?} {source:?}");
assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
assert_eq!(rule.check(&ctx).unwrap(), expected_warnings);
}
let rule = MD032BlanksAroundLists::default();
let fixed = rule.fix(&ctx).unwrap();
let next = LintContext::new(&fixed, flavor, None);
assert_eq!(rule.check(&next).unwrap(), rule.check_native(&next));
assert_eq!(
rule.fix(&next).unwrap(),
rule.fix_with_structure_impl(&next, &rule.withheld_fixes(&next))
);
}
}
}
}
}
}
#[test]
fn test_list_coverage_matches_every_block_whatever_their_order() {
let blocks: Vec<(usize, usize, String)> = [(20, 22), (3, 10), (5, 6), (9, 14), (30, 30), (17, 17)]
.into_iter()
.map(|(start, end)| (start, end, String::new()))
.collect();
let coverage = ListCoverage::new(&blocks);
for line in 0..=35 {
let expected = blocks.iter().any(|&(start, end, _)| start <= line && line <= end);
assert_eq!(coverage.contains(line), expected, "line {line}");
}
assert!(!ListCoverage::new(&[]).contains(1));
}
fn lint(content: &str) -> Vec<LintWarning> {
let rule = MD032BlanksAroundLists::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.check(&ctx).expect("Lint check failed")
}
fn fix(content: &str) -> String {
let rule = MD032BlanksAroundLists::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.fix(&ctx).expect("Lint fix failed")
}
#[test]
fn test_spaced_nested_blockquotes_list_separation() {
for (list_prefix, surrounding_prefix) in [
("> >", "> >"),
("> >", "> >"),
("> > >", "> > >"),
("> >", ">>"),
(">>", "> >"),
] {
let content = format!(
"{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
);
let expected = format!(
"{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
);
let warnings = lint(&content);
assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
assert!(warnings.iter().all(|warning| warning.line == 2));
let mut edited = content.clone();
for warning in warnings.iter().rev() {
let edit = warning.fix.as_ref().expect("missing diagnostic fix");
edited.replace_range(edit.range.clone(), &edit.replacement);
}
assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
assert_eq!(fix(&content), expected);
assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
assert_eq!(fix(&expected), expected, "Fix must be idempotent");
}
}
#[test]
fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
for content in [
"> > - item\n> > ```\n> > code\n> > ```\n",
"> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
"> > - item\n> ~~~\n> code\n> ~~~\n",
"> > - item\n~~~\ncode\n~~~\n",
"> > - item\n>> - next item\n",
] {
assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
assert_eq!(fix(content), content);
}
}
#[test]
fn test_fix_after_a_list_split_by_a_fence_keeps_its_continuation_lines() {
for (content, expected) in [
(
"1. a\n2. b\n c\n```\nx\n```\n3. d\n",
"1. a\n2. b\n c\n\n```\nx\n```\n3. d\n",
),
(
"* a\n```\n```\n* b\n](x)\n\ntext\n",
"* a\n\n```\n```\n* b\n](x)\n\ntext\n",
),
] {
assert_eq!(fix(content), expected, "{content:?}");
}
let warnings = lint("1. a\n2. b\n c\n```\nx\n```\n3. d\n");
assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![3]);
}
#[test]
fn test_backtick_line_short_of_the_content_column_is_lazy_text() {
let content = " 1. a\n ```\n code\n";
assert!(lint(content).is_empty(), "{:?}", lint(content));
assert_eq!(fix(content), content);
}
#[test]
fn test_blank_after_a_quoted_list_ending_in_lazy_text_stays_in_the_quote() {
let content = "> 1. a\nlazy\n> - b\n";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(fix(content), "> 1. a\nlazy\n>\n> - b\n");
}
#[test]
fn test_fix_never_changes_how_the_lists_parse() {
for (content, expected) in [
("1. item 41\n + item 51\n+ item 52\n", None),
(" * item 17\n 10. item 21\n1. item 22\n", None),
("- item 6\n> ```\n 10. item 23\n 1. item 24\n", None),
(
" 10. item 43\n> + item 47\n> continuation text\n> 1. item 50\n",
Some(" 10. item 43\n>\n> + item 47\n> continuation text\n> 1. item 50\n"),
),
(
" 1. item 63\n> + item 65\n> + item 66\n",
Some(" 1. item 63\n>\n> + item 65\n> + item 66\n"),
),
] {
let expected = expected.unwrap_or(content);
assert_eq!(fix(content), expected, "{content:?}");
let fixable = lint(content).iter().filter(|w| w.fix.is_some()).count();
assert_eq!(fixable, usize::from(expected != content), "{content:?}");
}
}
#[test]
fn test_fix_separates_list_from_standalone_code_fence() {
for (content, expected) in [
(
"# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
"# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
),
("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
(
">> - item\n>> ~~~\n>> code\n>> ~~~\n",
">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
),
] {
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].message, "List should be followed by blank line");
let edit = warnings[0].fix.as_ref().expect("missing warning fix");
let mut edited = content.to_string();
edited.replace_range(edit.range.clone(), &edit.replacement);
assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
assert_eq!(fix(content), expected, "{content:?}");
assert!(lint(expected).is_empty(), "{expected:?}");
assert_eq!(fix(expected), expected, "Fix must be idempotent");
}
}
#[test]
fn test_fix_preserves_code_fence_inside_list_item() {
for content in [
"- item\n ```\n code\n ```\n",
"1. item\n ~~~\n code\n ~~~\n",
"> - item\n> ```\n> code\n> ```\n",
] {
assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
}
}
#[test]
fn test_fix_does_not_split_item_before_different_list_type() {
let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
let warnings = lint(content);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].line, 2);
assert_eq!(warnings[1].line, 3);
}
#[test]
fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
}
#[test]
fn test_fix_keeps_lazy_continuation_with_its_item() {
let content = "- alpha beta\nlazy\n1. ordered item\n";
assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
let warnings = lint(content);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].line, 2);
assert_eq!(warnings[1].line, 3);
}
#[test]
fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
}
#[test]
fn test_fix_indents_lazy_continuation_when_not_allowed() {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
let content = "- alpha beta\nlazy\n1. ordered item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).expect("Lint fix failed");
assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
}
#[test]
fn test_lazy_continuation_does_not_indent_template_values() {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
for (flavor, code) in [
(
crate::config::MarkdownFlavor::Standard,
"{% set unused=\"first\nLiteral\nlast\" %}{{ unused|length }}",
),
(
crate::config::MarkdownFlavor::Hugo,
"{{< note title=`first\nLiteral\nlast` >}}",
),
] {
let source = format!("- Visible {code}\n- Sibling\n\nOther:\n- Item\n");
let expected = format!("- Visible {code}\n- Sibling\n\nOther:\n\n- Item\n");
let ctx = LintContext::new(&source, flavor, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
}
}
#[test]
fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).expect("Lint check failed");
assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
let fixed = rule.fix(&ctx).expect("Lint fix failed");
assert_eq!(fixed, content);
}
#[test]
fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).expect("Lint check failed");
assert_eq!(
warnings.len(),
1,
"Expected one lazy-continuation warning, got: {warnings:?}"
);
assert_eq!(warnings[0].line, 3);
let fixed = rule.fix(&ctx).expect("Lint fix failed");
assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
}
#[test]
fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 5);
assert!(warnings[0].fix.is_none(), "{warnings:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content);
let content = "Intro\n\n- List item 1\n- List item 2\nlazy\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
"Intro\n\n- List item 1\n- List item 2\n lazy\n"
);
}
fn check_warnings_have_fixes(content: &str) {
let warnings = lint(content);
for warning in &warnings {
assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
}
}
#[test]
fn test_list_at_start() {
let content = "- Item 1\n- Item 2\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Trailing text is lazy continuation per CommonMark - no warning expected"
);
}
#[test]
fn test_list_at_end() {
let content = "Text\n- Item 1\n- Item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Expected 1 warning for list at end without preceding blank line"
);
assert_eq!(
warnings[0].line, 2,
"Warning should be on the first line of the list (line 2)"
);
assert!(warnings[0].message.contains("preceded by blank line"));
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_list_in_middle() {
let content = "Text 1\n- Item 1\n- Item 2\nText 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
);
assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
assert!(warnings[0].message.contains("preceded by blank line"));
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_correct_spacing() {
let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
let fixed_content = fix(content);
assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
}
#[test]
fn test_list_with_content() {
let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
);
assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
assert!(warnings[0].message.contains("preceded by blank line"));
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
assert_eq!(
fixed_content, expected_fixed,
"Fix did not produce the expected output. Got:\n{fixed_content}"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_nested_list() {
let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Nested list block needs preceding blank only. Got: {warnings:?}"
);
assert_eq!(warnings[0].line, 2);
assert!(warnings[0].message.contains("preceded by blank line"));
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_list_with_internal_blanks() {
let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"List with internal blanks needs preceding blank only. Got: {warnings:?}"
);
assert_eq!(warnings[0].line, 2);
assert!(warnings[0].message.contains("preceded by blank line"));
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(
fixed_content,
"Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_ignore_code_blocks() {
let content = "```\n- Not a list item\n```\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 0);
let fixed_content = fix(content);
assert_eq!(fixed_content, content);
}
#[test]
fn test_ignore_front_matter() {
let content = "---\ntitle: Test\n---\n- List Item\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Front matter test should have no MD032 warnings. Got: {warnings:?}"
);
let fixed_content = fix(content);
assert_eq!(fixed_content, content, "No changes when no warnings");
}
#[test]
fn test_multiple_lists() {
let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
let warnings = lint(content);
assert!(
!warnings.is_empty(),
"Should have at least one warning for missing blank line. Got: {warnings:?}"
);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_adjacent_lists() {
let content = "- List 1\n\n* List 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0);
let fixed_content = fix(content);
assert_eq!(fixed_content, content);
}
#[test]
fn test_list_in_blockquote() {
let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
);
assert_eq!(warnings[0].line, 2);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(
fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
"Fix for blockquoted list failed. Got:\n{fixed_content}"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_ordered_list() {
let content = "Text\n1. Item 1\n2. Item 2\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_no_double_blank_fix() {
let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should have no warnings - properly preceded, trailing is lazy"
);
let fixed_content = fix(content);
assert_eq!(
fixed_content, content,
"No fix needed when no warnings. Got:\n{fixed_content}"
);
let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
assert_eq!(warnings2.len(), 1);
if !warnings2.is_empty() {
assert_eq!(
warnings2[0].line, 2,
"Warning line for missing blank before should be the first line of the block"
);
}
check_warnings_have_fixes(content2);
let fixed_content2 = fix(content2);
assert_eq!(
fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
"Fix added extra blank before. Got:\n{fixed_content2}"
);
}
#[test]
fn test_empty_input() {
let content = "";
let warnings = lint(content);
assert_eq!(warnings.len(), 0);
let fixed_content = fix(content);
assert_eq!(fixed_content, "");
}
#[test]
fn test_only_list() {
let content = "- Item 1\n- Item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0);
let fixed_content = fix(content);
assert_eq!(fixed_content, content);
}
#[test]
fn test_fix_complex_nested_blockquote() {
let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Should warn for missing preceding blank only. Got: {warnings:?}"
);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
}
#[test]
fn test_fix_mixed_list_markers() {
let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
let warnings = lint(content);
assert!(
!warnings.is_empty(),
"Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
assert!(
fixed_content.contains("Text\n\n-"),
"Fix should add blank line before first list item"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_ordered_list_with_different_numbers() {
let content = "Text\n1. First\n3. Third\n2. Second\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
assert_eq!(
fixed_content, expected,
"Fix should handle ordered lists with non-sequential numbers"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_list_with_code_blocks_inside() {
let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
assert_eq!(
fixed_content, expected,
"Fix should handle lists with internal code blocks"
);
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_deeply_nested_lists() {
let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_list_with_multiline_items() {
let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Should only warn for missing blank before list (trailing text is lazy continuation)"
);
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
assert_eq!(fixed_content, expected, "Fix should add blank before list only");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_list_at_document_boundaries() {
let content1 = "- Item 1\n- Item 2";
let warnings1 = lint(content1);
assert_eq!(
warnings1.len(),
0,
"List at document start should not need blank before"
);
let fixed1 = fix(content1);
assert_eq!(fixed1, content1, "No fix needed for list at start");
let content2 = "Text\n- Item 1\n- Item 2";
let warnings2 = lint(content2);
assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
check_warnings_have_fixes(content2);
let fixed2 = fix(content2);
assert_eq!(
fixed2, "Text\n\n- Item 1\n- Item 2",
"Should add blank before list at end"
);
}
#[test]
fn test_fix_preserves_existing_blank_lines() {
let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
let fixed_content = fix(content);
assert_eq!(fixed_content, content, "Fix should not modify already correct content");
}
#[test]
fn test_fix_handles_tabs_and_spaces() {
let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
let warnings = lint(content);
assert!(!warnings.is_empty(), "Should warn for missing blank before list");
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
assert_eq!(fixed_content, expected, "Fix should add blank before list item");
let warnings_after_fix = lint(&fixed_content);
assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
}
#[test]
fn test_fix_warning_objects_have_correct_ranges() {
let content = "Text\n- Item 1\n- Item 2\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
for warning in &warnings {
assert!(warning.fix.is_some(), "Warning should have fix");
let fix = warning.fix.as_ref().unwrap();
assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
assert!(
!fix.replacement.is_empty() || fix.range.start == fix.range.end,
"Fix should have replacement or be insertion"
);
}
}
#[test]
fn test_fix_idempotent() {
let content = "Text\n- Item 1\n- Item 2\nText";
let fixed_once = fix(content);
assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
let fixed_twice = fix(&fixed_once);
assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
let warnings_after_fix = lint(&fixed_once);
assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
}
#[test]
fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
let rule = MD032BlanksAroundLists::default();
for (content, expected) in [
("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
(
"> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
"> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
),
("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
("Text\r\n\n- item\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();
let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(edited, expected);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
}
#[test]
fn test_fix_respects_inline_config_at_each_list_boundary() {
use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
let rule = MD032BlanksAroundLists::default();
for (content, expected, warning_lines) in [
(
"Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
"Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
vec![4],
),
(
"Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
"Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
vec![2],
),
(
"Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
"Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
vec![2, 8],
),
(
"Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
"Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
vec![2, 8],
),
(
"Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
"Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
vec![3],
),
] {
for ending in ["\n", "\r\n"] {
for final_newline in [true, false] {
let content = if final_newline {
content
} else {
content.trim_end_matches('\n')
};
let expected = if final_newline {
expected
} else {
expected.trim_end_matches('\n')
};
let content = content.replace('\n', ending);
let expected = expected.replace('\n', ending);
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let warnings =
filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
assert_eq!(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);
assert!(
filter_warnings_by_inline_config(
rule.check(&fixed_ctx).unwrap(),
fixed_ctx.inline_config(),
rule.name()
)
.is_empty()
);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
}
}
}
#[test]
fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
allow_lazy_continuation: false,
});
let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let raw = rule.check(&ctx).unwrap();
assert_eq!(raw.len(), 1);
assert!(raw[0].fix.is_some());
let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
assert!(warnings.is_empty());
assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
#[test]
fn test_fix_with_normalized_line_endings() {
let content = "Text\n- Item 1\n- Item 2\nText";
let warnings = lint(content);
assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
check_warnings_have_fixes(content);
let fixed_content = fix(content);
let expected = "Text\n\n- Item 1\n- Item 2\nText";
assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
}
#[test]
fn test_fix_preserves_final_newline() {
let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
let fixed_with_newline = fix(content_with_newline);
assert!(
fixed_with_newline.ends_with('\n'),
"Fix should preserve final newline when present"
);
assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
let fixed_without_newline = fix(content_without_newline);
assert!(
!fixed_without_newline.ends_with('\n'),
"Fix should not add final newline when not present"
);
assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
}
#[test]
fn test_fix_multiline_list_items_no_indent() {
let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description that continues\non the next line without indentation.\n- `option2`: Another description that also continues\non the next line.\n\n## Next Section";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
);
let fixed_content = fix(content);
assert_eq!(
fixed_content, content,
"Should not modify correctly formatted multi-line list items"
);
}
#[test]
fn test_nested_list_with_lazy_continuation() {
let content = r#"# Test
- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1. Switch/case dispatcher statements (original Phase 3.2)
2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
- 30 explicit tokens extracted, 23 dispatcher rules with embedded token
references"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
0,
"Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
);
}
#[test]
fn test_pipes_in_code_spans_not_detected_as_table() {
let content = r#"# Test
- Item with `a | b` inline code
- Nested item should work
"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
0,
"Pipes in code spans should not break lists. Got: {md032_warnings:?}"
);
}
#[test]
fn test_multiple_code_spans_with_pipes() {
let content = r#"# Test
- Item with `a | b` and `c || d` operators
- Nested item should work
"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
0,
"Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
);
}
#[test]
fn test_actual_table_breaks_list() {
let content = r#"# Test
- Item before table
| Col1 | Col2 |
|------|------|
| A | B |
- Item after table
"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
0,
"Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
);
}
#[test]
fn test_thematic_break_not_lazy_continuation() {
let content = r#"- Item 1
- Item 2
***
More text.
"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
1,
"Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
);
assert!(
md032_warnings[0].message.contains("followed by blank line"),
"Warning should be about missing blank after list"
);
}
#[test]
fn test_thematic_break_with_blank_line() {
let content = r#"- Item 1
- Item 2
***
More text.
"#;
let warnings = lint(content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
0,
"Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
);
}
#[test]
fn test_various_thematic_break_styles() {
for hr in ["---", "***", "___"] {
let content = format!(
r#"- Item 1
- Item 2
{hr}
More text.
"#
);
let warnings = lint(&content);
let md032_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.collect();
assert_eq!(
md032_warnings.len(),
1,
"Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
);
}
}
fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
let rule = MD032BlanksAroundLists::from_config_struct(config);
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.check(&ctx).expect("Lint check failed")
}
fn fix_with_config(content: &str, config: MD032Config) -> String {
let rule = MD032BlanksAroundLists::from_config_struct(config);
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.fix(&ctx).expect("Lint fix failed")
}
#[test]
fn test_lazy_continuation_allowed_by_default() {
let content = "# Heading\n\n1. List\nSome text.";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Default behavior should allow lazy continuation. Got: {warnings:?}"
);
}
#[test]
fn test_lazy_continuation_disallowed() {
let content = "# Heading\n\n1. List\nSome text.";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert_eq!(
warnings.len(),
1,
"Should warn when lazy continuation is disallowed. Got: {warnings:?}"
);
assert!(
warnings[0].message.contains("Lazy continuation"),
"Warning message should mention lazy continuation"
);
assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
}
#[test]
fn test_lazy_continuation_fix() {
let content = "# Heading\n\n1. List\nSome text.";
let config = MD032Config {
allow_lazy_continuation: false,
};
let fixed = fix_with_config(content, config.clone());
assert_eq!(
fixed, "# Heading\n\n1. List\n Some text.",
"Fix should add proper indentation to lazy continuation"
);
let warnings_after = lint_with_config(&fixed, config);
assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
}
#[test]
fn test_lazy_continuation_multiple_lines() {
let content = "- Item 1\nLine 2\nLine 3";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(
warnings.len(),
2,
"Should warn for each lazy continuation line. Got: {warnings:?}"
);
let fixed = fix_with_config(content, config.clone());
assert_eq!(
fixed, "- Item 1\n Line 2\n Line 3",
"Fix should add proper indentation to lazy continuation lines"
);
let warnings_after = lint_with_config(&fixed, config);
assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
}
#[test]
fn test_lazy_continuation_with_indented_content() {
let content = "- Item 1\n Indented content\nLazy text";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert_eq!(
warnings.len(),
1,
"Should warn for lazy text after indented content. Got: {warnings:?}"
);
}
#[test]
fn test_lazy_continuation_properly_separated() {
let content = "- Item 1\n\nSome text.";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert_eq!(
warnings.len(),
0,
"Should not warn when list is properly followed by blank line. Got: {warnings:?}"
);
}
#[test]
fn test_lazy_continuation_ordered_list_parenthesis_marker() {
let content = "1) First item\nLazy continuation";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(
warnings.len(),
1,
"Should warn for lazy continuation with parenthesis marker"
);
let fixed = fix_with_config(content, config);
assert_eq!(fixed, "1) First item\n Lazy continuation");
}
#[test]
fn test_lazy_continuation_followed_by_another_list() {
let content = "- Item 1\nSome text\n- Item 2";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert_eq!(
warnings.len(),
1,
"Should warn about lazy continuation within list. Got: {warnings:?}"
);
assert!(
warnings[0].message.contains("Lazy continuation"),
"Warning should be about lazy continuation"
);
assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
}
#[test]
fn test_lazy_continuation_multiple_in_document() {
let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(
warnings.len(),
2,
"Should warn for both lazy continuations. Got: {warnings:?}"
);
let fixed = fix_with_config(content, config.clone());
assert!(
fixed.contains(" Lazy 1"),
"Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
);
assert!(
fixed.contains(" Lazy 2"),
"Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
);
let warnings_after = lint_with_config(&fixed, config);
assert_eq!(
warnings_after.len(),
0,
"All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
);
}
#[test]
fn test_lazy_continuation_end_of_document_no_newline() {
let content = "- Item\nNo trailing newline";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(warnings.len(), 1, "Should warn even at end of document");
let fixed = fix_with_config(content, config);
assert_eq!(fixed, "- Item\n No trailing newline");
}
#[test]
fn test_lazy_continuation_thematic_break_still_needs_blank() {
let content = "- Item 1\n---";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(
warnings.len(),
1,
"List should need blank line before thematic break. Got: {warnings:?}"
);
let fixed = fix_with_config(content, config);
assert_eq!(fixed, "- Item 1\n\n---");
}
#[test]
fn test_lazy_continuation_heading_not_flagged() {
let content = "- Item 1\n# Heading";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert!(
warnings.iter().all(|w| !w.message.contains("lazy")),
"Heading should not trigger lazy continuation warning"
);
}
#[test]
fn test_lazy_continuation_mixed_list_types() {
let content = "- Unordered\n1. Ordered\nLazy text";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert!(!warnings.is_empty(), "Should warn about structure issues");
}
#[test]
fn test_lazy_continuation_deep_nesting() {
let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert!(
!warnings.is_empty(),
"Should warn about lazy continuation after nested list"
);
let fixed = fix_with_config(content, config.clone());
let warnings_after = lint_with_config(&fixed, config);
assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
}
#[test]
fn test_lazy_continuation_with_emphasis_in_text() {
let content = "- Item\n*emphasized* continuation";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
let fixed = fix_with_config(content, config);
assert_eq!(fixed, "- Item\n *emphasized* continuation");
}
#[test]
fn test_lazy_continuation_with_code_span() {
let content = "- Item\n`code` continuation";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
let fixed = fix_with_config(content, config);
assert_eq!(fixed, "- Item\n `code` continuation");
}
#[test]
fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
let content = r#"1. Create a new Chat conversation:
- On the sidebar, select **New Chat**.
- In the box, type `/new`.
A new Chat conversation replaces the previous one.
1. Under the Chat text box, turn off the toggle."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert!(
!lazy_warnings.is_empty(),
"Should detect lazy continuation after nested bullets. Got: {warnings:?}"
);
assert!(
lazy_warnings.iter().any(|w| w.line == 4),
"Should warn on line 4. Got: {lazy_warnings:?}"
);
}
#[test]
fn test_issue295_case3_code_span_starts_lazy_continuation() {
let content = r#"- `field`: Is the specific key:
- `password`: Accesses the password.
- `api_key`: Accesses the api_key.
`token`: Specifies which ID token to use.
- `version_id`: Is the unique identifier."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert!(
!lazy_warnings.is_empty(),
"Should detect lazy continuation starting with code span. Got: {warnings:?}"
);
assert!(
lazy_warnings.iter().any(|w| w.line == 4),
"Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
);
}
#[test]
fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
let content = r#"- Check out the branch, and test locally.
- If the MR requires significant modifications:
- **Skip local testing** and review instead.
- **Request verification** from the author.
- **Identify the minimal change** needed.
Your testing might result in opportunities.
- If you don't understand, _say so_."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert!(
!lazy_warnings.is_empty(),
"Should detect lazy continuation after deep nesting. Got: {warnings:?}"
);
assert!(
lazy_warnings.iter().any(|w| w.line == 6),
"Should warn on line 6. Got: {lazy_warnings:?}"
);
}
#[test]
fn test_issue295_ordered_list_nested_bullets_continuation() {
let content = r#"# Test
1. First item.
- Nested A.
- Nested B.
Continuation at outer level.
1. Second item."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert!(
!lazy_warnings.is_empty(),
"Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
);
assert!(
lazy_warnings.iter().any(|w| w.line == 6),
"Should warn on line 6. Got: {lazy_warnings:?}"
);
}
#[test]
fn test_issue295_multiple_lazy_lines_after_nested() {
let content = r#"1. The device client receives a response.
- Those defined by OAuth Framework.
- Those specific to device authorization.
Those error responses are described below.
For more information on each response,
see the documentation.
1. Next step in the process."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert!(
lazy_warnings.len() >= 3,
"Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
lazy_warnings.len()
);
}
#[test]
fn test_issue295_properly_indented_not_lazy() {
let content = r#"1. First item.
- Nested A.
- Nested B.
Properly indented continuation.
1. Second item."#;
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
let lazy_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("Lazy continuation"))
.collect();
assert_eq!(
lazy_warnings.len(),
0,
"Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
);
}
#[test]
fn test_html_comment_before_list_with_preceding_blank() {
let content = "Some text.\n\n<!-- comment -->\n- List item";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
);
}
#[test]
fn test_html_comment_after_list_with_following_blank() {
let content = "- List item\n<!-- comment -->\n\nSome text.";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
);
}
#[test]
fn test_list_inside_html_comment_ignored() {
let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not analyze lists inside HTML comments. Got: {warnings:?}"
);
}
#[test]
fn test_multiline_html_comment_before_list() {
let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Multi-line HTML comment should be transparent. Got: {warnings:?}"
);
}
#[test]
fn test_a_comment_line_separates_the_paragraph_from_the_list() {
let content = "Some text.\n<!-- comment -->\n- List item";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"A comment-only line separates the blocks around it. Got: {warnings:?}"
);
}
#[test]
fn test_a_line_carrying_text_beside_a_comment_still_warns() {
let content = "Some text. <!-- comment -->\n- List item";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
);
assert!(
warnings[0].message.contains("preceded by blank line"),
"Should be 'preceded by blank line' warning"
);
}
#[test]
fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
let content = "- List item\n<!-- comment -->\nSome text.";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
);
}
#[test]
fn test_list_followed_by_heading_through_comment_should_warn() {
let content = "- List item\n<!-- comment -->\n# Heading";
let warnings = lint(content);
assert!(
warnings.len() <= 1,
"Should handle heading after comment gracefully. Got: {warnings:?}"
);
}
#[test]
fn test_html_comment_between_list_and_text_both_directions() {
let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn with proper separation through comments. Got: {warnings:?}"
);
}
#[test]
fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
let content = "Text.\n\n<!-- comment -->\n- Item";
let fixed = fix(content);
assert_eq!(fixed, content, "Fix should not modify already-correct content");
}
#[test]
fn test_html_comment_fix_adds_blank_when_needed() {
let separated = "Text.\n<!-- comment -->\n- Item";
assert_eq!(
fix(separated),
separated,
"A comment-only line needs no blank line inserted around it"
);
let content = "Text. <!-- comment -->\n- Item";
let fixed = fix(content);
assert!(
fixed.contains("Text. <!-- comment -->\n\n- Item"),
"Fix should add blank line before list. Got: {fixed}"
);
}
#[test]
fn test_ordered_list_inside_html_comment() {
let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_exit_no_warning() {
let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn when exiting blockquote. Got: {warnings:?}"
);
}
#[test]
fn test_nested_blockquote_list_exit() {
let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn when exiting nested blockquote list. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_same_level_no_warning() {
let content = "> - item 1\n> - item 2\n> Text after";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_with_special_chars() {
let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Special chars in content should not affect blockquote detection. Got: {warnings:?}"
);
}
#[test]
fn test_lazy_continuation_whitespace_only_line() {
let content = "- Item\n \nText after whitespace-only line";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert_eq!(
warnings.len(),
0,
"Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
);
}
#[test]
fn test_lazy_continuation_blockquote_context() {
let content = "> - Item\n> Lazy in quote";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config);
assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
}
#[test]
fn test_lazy_continuation_fix_preserves_content() {
let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
let config = MD032Config {
allow_lazy_continuation: false,
};
let fixed = fix_with_config(content, config);
assert!(fixed.contains("<>&"), "Should preserve special chars");
assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
}
#[test]
fn test_lazy_continuation_fix_idempotent() {
let content = "- Item\nLazy";
let config = MD032Config {
allow_lazy_continuation: false,
};
let fixed_once = fix_with_config(content, config.clone());
let fixed_twice = fix_with_config(&fixed_once, config);
assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
}
#[test]
fn test_lazy_continuation_config_default_allows() {
let content = "- Item\nLazy text that continues";
let default_config = MD032Config::default();
assert!(
default_config.allow_lazy_continuation,
"Default should allow lazy continuation"
);
let warnings = lint_with_config(content, default_config);
assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
}
#[test]
fn test_lazy_continuation_after_multi_line_item() {
let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
let config = MD032Config {
allow_lazy_continuation: false,
};
let warnings = lint_with_config(content, config.clone());
assert_eq!(
warnings.len(),
1,
"Should warn only for the lazy line, not the indented line"
);
}
#[test]
fn test_blockquote_list_with_continuation_and_nested() {
let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_simple() {
let content = "> - item 1\n> - item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
}
#[test]
fn test_blockquote_list_with_continuation_only() {
let content = "> - item 1\n> continuation\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Blockquoted list with continuation should have no warnings"
);
}
#[test]
fn test_blockquote_list_with_lazy_continuation() {
let content = "> - item 1\n> lazy continuation\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Blockquoted list with lazy continuation should have no warnings"
);
}
#[test]
fn test_nested_blockquote_list() {
let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
}
#[test]
fn test_blockquote_list_needs_preceding_blank() {
let content = "> Text before\n> - item 1\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Should warn for missing blank before blockquoted list"
);
}
#[test]
fn test_blockquote_list_properly_separated() {
let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Properly separated blockquoted list should have no warnings"
);
}
#[test]
fn test_blockquote_ordered_list() {
let content = "> 1. item 1\n> continuation\n> 2. item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
}
#[test]
fn test_blockquote_list_with_empty_blockquote_line() {
let content = "> - item 1\n>\n> - item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
}
#[test]
fn test_blockquote_list_multi_paragraph_items() {
let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_ordered_list_multi_paragraph_items() {
let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_multiple_continuations() {
let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
);
}
#[test]
fn test_nested_blockquote_multi_paragraph_list() {
let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_triple_nested_blockquote_multi_paragraph_list() {
let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_last_item_continuation() {
let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Last item with continuation should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_list_first_item_only_continuation() {
let content = "> - Item 1\n> \n> Continuation of item 1\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Single item with continuation should have no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_level_change_breaks_list() {
let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
let warnings = lint(content);
assert!(
warnings.len() <= 2,
"Blockquote level change warnings should be reasonable. Got: {warnings:?}"
);
}
#[test]
fn test_exit_blockquote_needs_blank_before_list() {
let content = "> Blockquote text\n\n- List outside blockquote\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"List after blank line outside blockquote should be fine. Got: {warnings:?}"
);
let content2 = "> Blockquote text\n- List outside blockquote\n";
let warnings2 = lint(content2);
assert!(
warnings2.len() <= 1,
"List after blockquote warnings should be reasonable. Got: {warnings2:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_all_unordered_markers() {
let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
let warnings = lint(content_dash);
assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
let warnings = lint(content_asterisk);
assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
let warnings = lint(content_plus);
assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
}
#[test]
fn test_blockquote_multi_paragraph_parenthesis_marker() {
let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Parenthesis ordered markers should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_multi_digit_numbers() {
let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Multi-digit ordered list should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_with_formatting() {
let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Continuation with inline formatting should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_all_items_have_continuation() {
let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"All items with continuations should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_lowercase_continuation() {
let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Lowercase continuation should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_uppercase_continuation() {
let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Uppercase continuation with proper indent should work. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
let warnings = lint(content);
assert!(
warnings.len() <= 1,
"Separate lists with continuations should be reasonable. Got: {warnings:?}"
);
}
#[test]
fn test_blockquote_multi_paragraph_bare_marker_blank() {
let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
}
#[test]
fn test_blockquote_list_varying_spaces_after_marker() {
let content = "> - item 1\n> continuation with more indent\n> - item 2";
let warnings = lint(content);
assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
}
#[test]
fn test_deeply_nested_blockquote_list() {
let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Deeply nested blockquote list should have no warnings"
);
}
#[test]
fn test_blockquote_level_change_in_list() {
let content = "> - item 1\n>> - deeper item\n> - item 2";
let warnings = lint(content);
assert!(
!warnings.is_empty(),
"Blockquote level change should break list and trigger warnings"
);
}
#[test]
fn test_blockquote_list_with_code_span() {
let content = "> - item with `code`\n> continuation\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Blockquote list with code span should have no warnings"
);
}
#[test]
fn test_code_span_html_comment_delimiters_no_false_positive() {
let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
);
}
#[test]
fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
let fixed = fix(content);
assert_eq!(
fixed, content,
"MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
);
}
#[test]
fn test_blockquote_list_at_document_end() {
let content = "> Some text\n>\n> - item 1\n> - item 2";
let warnings = lint(content);
assert_eq!(
warnings.len(),
0,
"Blockquote list at document end should have no warnings"
);
}
#[test]
fn test_fix_preserves_blockquote_prefix_before_list() {
let content = "> Text before
> - Item 1
> - Item 2";
let fixed = fix(content);
let expected = "> Text before
>
> - Item 1
> - Item 2";
assert_eq!(
fixed, expected,
"Fix should insert '>' blank line, not plain blank line"
);
}
#[test]
fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
let content = ">>> Triple nested
>>> - Item 1
>>> - Item 2
>>> More text";
let fixed = fix(content);
let expected = ">>> Triple nested
>>>
>>> - Item 1
>>> - Item 2
>>> More text";
assert_eq!(
fixed, expected,
"Fix should preserve triple-nested blockquote prefix '>>>'"
);
}
fn lint_quarto(content: &str) -> Vec<LintWarning> {
let rule = MD032BlanksAroundLists::default();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
rule.check(&ctx).unwrap()
}
#[test]
fn test_quarto_list_after_div_open() {
let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
let warnings = lint_quarto(content);
assert!(
warnings.is_empty(),
"Quarto div marker should be transparent before list: {warnings:?}"
);
}
#[test]
fn test_quarto_list_before_div_close() {
let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
let warnings = lint_quarto(content);
assert!(
warnings.is_empty(),
"Quarto div marker should be transparent after list: {warnings:?}"
);
}
#[test]
fn test_quarto_list_needs_blank_without_div() {
let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
let warnings = lint_quarto(content);
assert!(
!warnings.is_empty(),
"Should still require blank when not present: {warnings:?}"
);
}
#[test]
fn test_quarto_list_in_callout_with_content() {
let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
let warnings = lint_quarto(content);
assert!(
warnings.is_empty(),
"List with proper blanks inside callout should pass: {warnings:?}"
);
}
#[test]
fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
let warnings = lint(content); assert!(
!warnings.is_empty(),
"Standard flavor should not treat ::: as transparent: {warnings:?}"
);
}
#[test]
fn test_quarto_nested_divs_with_list() {
let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
let warnings = lint_quarto(content);
assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
}
#[test]
fn test_issue512_complex_nested_list_with_continuation() {
let content = "\
- First level of indentation.
- Second level of indentation.
- Third level of indentation.
- Third level of indentation.
Second level list continuation.
First level list continuation.
- First level of indentation.
";
let warnings = lint(content);
assert!(
warnings.is_empty(),
"Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_issue512_continuation_at_root_level() {
let content = "\
- First level.
- Second level.
First level continuation.
Root level lazy continuation.
- Another first level item.
";
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"Should warn on line 7 (new list after break). Got: {warnings:?}"
);
assert_eq!(warnings[0].line, 7);
}
#[test]
fn test_issue512_three_level_nesting_continuation_at_each_level() {
let content = "\
- Level 1 item.
- Level 2 item.
- Level 3 item.
Level 3 continuation.
Level 2 continuation.
Level 1 continuation (indented under marker).
- Another level 1 item.
";
let warnings = lint(content);
assert!(
warnings.is_empty(),
"Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
);
}
#[test]
fn test_pandoc_list_after_div_open() {
let rule = MD032BlanksAroundLists::default();
let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
);
}
#[test]
fn test_md032_html_comment() {
let rule = MD032BlanksAroundLists::default();
let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
);
}
#[test]
fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"admonition-nested ordered list should not be flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a period.\n 3. no error here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"cascading admonition-nested ordered list should not be flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n=== \"Tab A\"\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"content-tab-nested ordered list should not be flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. item one that wraps\n onto a second line.\n 2. item two\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"wrapped continuation of a nested list item must not be flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
);
assert_eq!(warnings[0].line, 4);
assert!(warnings[0].message.contains("non-1"));
}
#[test]
fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. no error here\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
);
}
#[test]
fn test_standard_flavor_admonition_indented_list_unchanged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"indented code block under standard flavor should not be flagged: {warnings:?}"
);
}
#[test]
fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
let rule = MD032BlanksAroundLists::default();
let content = "1. no error here\n\n<div markdown=\"1\">\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n\n</div>\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
);
assert_eq!(warnings[0].line, 6);
}
#[test]
fn test_pseudo_list_marker_after_list() {
let content = indoc::indoc! {"
- Item 1
Item 1 content.
The unsigned-integer types may be written `uN`, with `N` a positive multiple of
8. Unsigned integer types wrap around on overflow; we strongly advise that they
are not used except when those semantics are desired.
"};
let warnings = lint(content);
assert!(
warnings.is_empty(),
"Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
);
}
#[test]
fn test_pseudo_list_marker_without_preceding_list() {
let content = indoc::indoc! {"
The unsigned-integer types may be written `uN`, with `N` a positive multiple of
8. Unsigned integer types wrap around on overflow; we strongly advise that they
are not used except when those semantics are desired.
"};
let warnings = lint(content);
assert!(
warnings.is_empty(),
"Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
);
}
#[test]
fn test_no_space_hash_continuation_line_stays_in_its_item() {
let content = indoc::indoc! {"
5. **`M.md`** - the deltas (esp. items #1,
#2, #3, #5, #8).
---
## Plan
### Phase 0
- [ ] task one
wrapped
"};
let warnings = lint(content);
assert_eq!(
warnings.len(),
1,
"only the task list is missing a blank line, got: {warnings:?}"
);
assert_eq!(warnings[0].line, 9);
assert_eq!(warnings[0].message, "List should be preceded by blank line");
let expected = indoc::indoc! {"
5. **`M.md`** - the deltas (esp. items #1,
#2, #3, #5, #8).
---
## Plan
### Phase 0
- [ ] task one
wrapped
"};
assert_eq!(fix(content), expected);
}
#[test]
fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
let content = indoc::indoc! {"
1. first
3. item
continuation
1. nested
2. nested
## Heading
- task
"};
let warnings = lint(content);
assert_eq!(
warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![10],
"only the list after the heading is missing a blank line, got: {warnings:?}"
);
let expected = indoc::indoc! {"
1. first
3. item
continuation
1. nested
2. nested
## Heading
- task
"};
assert_eq!(fix(content), expected);
}
#[test]
fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
let content = indoc::indoc! {"
- item (esp. #1,
#2, #3).
- next item
## Heading
- task
"};
let warnings = lint(content);
assert_eq!(
warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![6],
"only the list after the heading is missing a blank line, got: {warnings:?}"
);
let expected = indoc::indoc! {"
- item (esp. #1,
#2, #3).
- next item
## Heading
- task
"};
assert_eq!(fix(content), expected);
}
#[test]
fn test_under_indented_continuation_lines_stay_in_their_item() {
for content in [
"1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
"> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
"- Item\n lazy continuation\n- another item\n",
"> - Item\n> lazy continuation\n> - another item\n",
] {
let warnings = lint(content);
assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
assert_eq!(fix(content), content, "{content:?}");
}
}
#[test]
fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
let config = MD032Config {
allow_lazy_continuation: false,
};
for (content, lazy_lines) in [
("- Item\n lazy continuation\n- another item\n", vec![2]),
("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
] {
let warnings = lint_with_config(content, config.clone());
assert!(
warnings.iter().all(|w| w.message.contains("Lazy continuation")),
"{content:?}: got {warnings:?}"
);
assert_eq!(
warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
lazy_lines,
"{content:?}: got {warnings:?}"
);
}
}
#[test]
fn test_structural_line_at_short_indent_ends_the_list() {
for (content, expected) in [
("1. item\n ---\n", "1. item\n\n ---\n"),
("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
] {
let warnings = lint(content);
assert_eq!(
warnings
.iter()
.map(|w| (w.line, w.message.as_str()))
.collect::<Vec<_>>(),
vec![(1, "List should be followed by blank line")],
"{content:?}: got {warnings:?}"
);
assert_eq!(fix(content), expected, "{content:?}");
}
}
#[test]
fn test_html_block_at_short_indent_ends_the_list() {
for (content, expected) in [
(
"- item\n<script>\nx\n</script>\n- next\n",
"- item\n\n<script>\nx\n</script>\n\n- next\n",
),
(
"- item\n <script>\n x\n </script>\n- next\n",
"- item\n\n <script>\n x\n </script>\n\n- next\n",
),
(
"- item\n <pre>\n x\n </pre>\n- next\n",
"- item\n\n <pre>\n x\n </pre>\n\n- next\n",
),
(
"> - item\n> <script>\n> x\n> </script>\n> - next\n",
"> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
),
(
"> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
"> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
),
] {
let warnings = lint(content);
assert_eq!(
warnings
.iter()
.map(|w| (w.line, w.message.as_str()))
.collect::<Vec<_>>(),
vec![
(1, "List should be followed by blank line"),
(5, "List should be preceded by blank line"),
],
"{content:?}: got {warnings:?}"
);
assert_eq!(fix(content), expected, "{content:?}");
}
}
#[test]
fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
for content in [
"100. item\n <div>\n101. next\n",
"> 100. item\n> <div>\n> 101. next\n",
"100. item\n <div>\ntext\n101. next\n",
"- item\n<div.class>\n- next\n",
"> - item\n> <div.class>\n> - next\n",
"100. item\n\t<div>\n101. next\n",
"- item\n \t<div>\n- next\n",
"> - item\n> \t<div>\n> - next\n",
"> - item\n>\t<div>\n> - next\n",
"> 100. item\n> \t<div>\n> 101. next\n",
"- outer\n - inner\n <script>\n x\n </script>\n- next\n",
"- outer\n - inner\n <script>\n x\n </script>\n- next\n",
"> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
] {
let warnings = lint(content);
assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
assert_eq!(fix(content), content, "{content:?}");
}
for (content, last_item_line) in [
("100. item\n <div>\n101. next\n", 1),
("> 100. item\n> <div>\n> 101. next\n", 1),
("- item\n <div>\n- next\n", 1),
("> 1. item\n> \t<div>\n> 2. next\n", 1),
("> 1. item\n>\t<div>\n> 2. next\n", 1),
("1. outer\n 1. inner\n <div>\n2. next\n", 2),
] {
let warnings = lint(content);
assert_eq!(
warnings
.iter()
.map(|w| (w.line, w.message.as_str()))
.collect::<Vec<_>>(),
vec![(last_item_line, "List should be followed by blank line")],
"{content:?}: got {warnings:?}"
);
}
}
#[test]
fn test_tab_indented_nested_list_stays_inside_its_item() {
for content in [
"* item text\n\t1. nested\n\t more\n",
"* item text\n\tcontinuation\n\t1. nested\n",
"1. item text\n\t- nested\n",
"> * item text\n>\t1. nested\n",
"> * item text\n> \t1. nested\n",
"* item text\n\tcontinuation\n\t- nested\n",
] {
let warnings = lint(content);
assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
assert_eq!(fix(content), content, "{content:?}");
}
for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
let warnings = lint(content);
assert_eq!(
warnings
.iter()
.map(|w| (w.line, w.message.as_str()))
.collect::<Vec<_>>(),
vec![
(1, "List should be followed by blank line"),
(2, "List should be preceded by blank line"),
],
"{content:?}: got {warnings:?}"
);
}
}
#[test]
fn test_list_marker_inside_an_unclosed_html_block_is_html() {
for (content, expected) in [
(
"- item\n<div>\nx\n</div>\n- next\n",
"- item\n\n<div>\nx\n</div>\n- next\n",
),
(
"> - item\n> <div>\n> x\n> </div>\n> - next\n",
"> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
),
] {
let warnings = lint(content);
assert_eq!(
warnings
.iter()
.map(|w| (w.line, w.message.as_str()))
.collect::<Vec<_>>(),
vec![(1, "List should be followed by blank line")],
"{content:?}: got {warnings:?}"
);
assert_eq!(fix(content), expected, "{content:?}");
}
}
#[test]
fn test_html_block_at_content_column_is_item_content() {
for content in [
"- item\n <script>\n x\n </script>\n- next\n",
"- item\n <div>\n x\n </div>\n- next\n",
"1. item\n <pre>\n x\n </pre>\n2. next\n",
"> - item\n> <div>\n> x\n> </div>\n> - next\n",
] {
assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
assert_eq!(fix(content), content, "{content:?}");
}
}
#[test]
fn test_ordered_fallback_fix_applies_advertised_insertions() {
use crate::utils::fix_utils::apply_warning_fixes;
let rule = MD032BlanksAroundLists::default();
for body in [
"First.\n3. Item\nLast.\n",
"First.\n3. Item\n4. Second\nLast.\n",
"First.\n3. Item.\n4. Second.\nLast.\n",
"First.\n3. Item\n# Heading\n",
"First.\n3. Item\nLast.\n\nSecond.\n8. Another\nTail.\n",
] {
for ending in ["\n", "\r\n"] {
for trailing in [true, false] {
let source = format!("- Real\n\n{body}");
let source = if trailing {
source
} else {
source.trim_end_matches('\n').to_string()
};
let source = source.replace('\n', ending);
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty());
let expected = apply_warning_fixes(&source, &warnings).unwrap();
assert_ne!(expected, source);
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
}
}
}
#[test]
fn test_ordered_fallback_fix_keeps_literal_and_disabled_insertions_withheld() {
use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
let rule = MD032BlanksAroundLists::default();
for (flavor, source) in [
(
crate::config::MarkdownFlavor::Standard,
"{% set unused=\"First.\n3. Literal\nLast.\" %}{{ unused|length }}\n\n- Real\n",
),
(
crate::config::MarkdownFlavor::MDX,
"export const text = `First.\n3. Literal\nLast.`\n\n- Real\n",
),
(
crate::config::MarkdownFlavor::Standard,
"- Real\n\nFirst.\n<!-- rumdl-disable-next-line MD032 -->\n3. Item\nLast.\n",
),
(
crate::config::MarkdownFlavor::Standard,
"- Real\n\nFirst.\n3. Item <!-- rumdl-disable-line MD032 -->\nLast.\n",
),
(crate::config::MarkdownFlavor::Standard, "First.\n3. Item\nLast.\n"),
] {
for ending in ["\n", "\r\n"] {
let source = source.replace('\n', ending);
let ctx = LintContext::new(&source, flavor, None);
let warnings =
filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
assert!(warnings.is_empty(), "{source}: {warnings:?}");
assert_eq!(apply_warning_fixes(&source, &warnings).unwrap(), source);
assert_eq!(rule.fix(&ctx).unwrap(), source, "{source}");
}
}
}
}
#[cfg(test)]
mod configured_plan_regressions {
use super::*;
#[test]
fn cached_default_plan_never_crosses_rule_configuration() {
for source in [
"- item\nunindented continuation\n",
"before\n\n- item\n\n3. third\n",
"<!-- rumdl-configure-file {\"MD032\": {\"allow-lazy-continuation\": false}} -->\n- item\ntail\n",
] {
for custom_first in [false, true] {
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let default = MD032BlanksAroundLists::default();
let custom = MD032BlanksAroundLists {
config: MD032Config {
allow_lazy_continuation: false,
},
};
let rules = if custom_first {
[&custom, &default, &custom, &default]
} else {
[&default, &custom, &default, &custom]
};
for rule in rules {
assert_eq!(rule.check(&ctx).unwrap(), rule.check_native(&ctx));
assert_eq!(
rule.fix(&ctx).unwrap(),
rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx))
);
}
}
}
}
}