use crate::lint_context::LintContext;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::skip_context::is_table_line;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ListItemSpacingStyle {
#[default]
Consistent,
Loose,
Tight,
}
#[derive(Debug, Clone, Default)]
pub(super) struct MD076Config {
pub style: ListItemSpacingStyle,
pub allow_loose_continuation: bool,
}
#[derive(Debug, Clone, Default)]
pub struct MD076ListItemSpacing {
config: MD076Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GapKind {
Tight,
Loose,
Structural,
ContinuationLoose,
}
struct ListAnalysis {
items: Vec<usize>,
gaps: Vec<GapKind>,
warn_loose_gaps: bool,
warn_tight_gaps: bool,
}
impl MD076ListItemSpacing {
pub fn new(style: ListItemSpacingStyle) -> Self {
Self {
config: MD076Config {
style,
allow_loose_continuation: false,
},
}
}
pub fn with_allow_loose_continuation(mut self, allow: bool) -> Self {
self.config.allow_loose_continuation = allow;
self
}
fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
if let Some(info) = ctx.line_info(line_num) {
let content = info.content(ctx.content);
if content.trim().is_empty() {
return true;
}
if let Some(ref bq) = info.blockquote {
return bq.content.trim().is_empty();
}
false
} else {
false
}
}
fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
if let Some(info) = ctx.line_info(line_num) {
if info.in_code_block {
return true;
}
if info.in_html_block {
return true;
}
if info.blockquote.is_some() {
return true;
}
let content = info.content(ctx.content);
let effective = if let Some(ref bq) = info.blockquote {
bq.content.as_str()
} else {
content
};
if is_table_line(effective.trim_start()) {
return true;
}
}
false
}
fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize) -> bool {
let Some(info) = ctx.line_info(line_num) else {
return false;
};
if info.list_item.is_none() {
return false;
}
let line_range = info.byte_offset..info.byte_offset + info.byte_len;
ctx.code_block_details
.iter()
.any(|detail| detail.is_fenced && line_range.contains(&detail.start))
}
fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
let Some(info) = ctx.line_info(line_num) else {
return false;
};
if info.list_item.is_some() {
return false;
}
if info.in_code_block
|| info.in_html_block
|| info.in_html_comment
|| info.in_mdx_comment
|| info.in_front_matter
|| info.in_math_block
|| info.blockquote.is_some()
{
return false;
}
let content = info.content(ctx.content);
if content.trim().is_empty() {
return false;
}
let indent = content.len() - content.trim_start().len();
indent >= parent_content_col
}
fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
if next <= first + 1 {
return GapKind::Tight;
}
if !Self::is_effectively_blank(ctx, next - 1) {
return GapKind::Tight;
}
if Self::is_fenced_code_block_list_item(ctx, next) {
return GapKind::Structural;
}
let mut scan = next - 1;
while scan > first && Self::is_effectively_blank(ctx, scan) {
scan -= 1;
}
if scan > first && Self::is_structural_content(ctx, scan) {
return GapKind::Structural;
}
let parent_content_col = ctx
.line_info(first)
.and_then(|li| li.list_item.as_ref())
.map_or(2, |item| item.content_column);
if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
return GapKind::ContinuationLoose;
}
GapKind::Loose
}
fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
let mut blanks = Vec::new();
let mut line_num = next - 1;
while line_num > first && Self::is_effectively_blank(ctx, line_num) {
blanks.push(line_num);
line_num -= 1;
}
if line_num > first && Self::is_structural_content(ctx, line_num) {
return Vec::new();
}
blanks.reverse();
blanks
}
fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.filter_map(|items| {
Self::analyze_list(ctx, items, &self.config.style, self.config.allow_loose_continuation)
})
.collect()
}
fn analyze_list(
ctx: &LintContext,
items: Vec<usize>,
style: &ListItemSpacingStyle,
allow_loose_continuation: bool,
) -> Option<ListAnalysis> {
if items.len() < 2 {
return None;
}
let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
let loose_count = gaps
.iter()
.filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
.count();
let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
let (warn_loose_gaps, warn_tight_gaps) = match style {
ListItemSpacingStyle::Loose => (false, true),
ListItemSpacingStyle::Tight => (true, false),
ListItemSpacingStyle::Consistent => {
if loose_count == 0 || tight_count == 0 {
return None; }
if tight_count >= loose_count {
(true, false)
} else {
(false, true)
}
}
};
Some(ListAnalysis {
items,
gaps,
warn_loose_gaps,
warn_tight_gaps,
})
}
}
impl Rule for MD076ListItemSpacing {
fn name(&self) -> &'static str {
"MD076"
}
fn description(&self) -> &'static str {
"List item spacing should be consistent"
}
fn category(&self) -> RuleCategory {
RuleCategory::List
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || ctx.list_blocks.is_empty()
}
fn check(&self, ctx: &LintContext) -> LintResult {
if ctx.content.is_empty() {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
let allow_cont = self.config.allow_loose_continuation;
let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
for analysis in self.analyze(ctx) {
for (i, &gap) in analysis.gaps.iter().enumerate() {
let is_loose_violation = match gap {
GapKind::Loose => analysis.warn_loose_gaps,
GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
_ => false,
};
if is_loose_violation {
let next_item = analysis.items[i + 1];
let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
if let Some(&blank_line) = blanks.first() {
let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
let fix = ctx
.line_start_byte(blank_line)
.zip(ctx.line_start_byte(next_item))
.map(|(start, end)| Fix::new(start..end, String::new()));
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: blank_line,
column: 1,
end_line: blank_line,
end_column: line_content.chars().count() + 1,
message: "Unexpected blank line between list items".to_string(),
severity: Severity::Warning,
fix,
});
}
} else if gap == GapKind::Tight && analysis.warn_tight_gaps {
let next_item = analysis.items[i + 1];
let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
let fix = ctx.line_start_byte(next_item).map(|start| {
let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
Fix::new(start..start, format!("{prefix}{line_ending}"))
});
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: next_item,
column: 1,
end_line: next_item,
end_column: line_content.chars().count() + 1,
message: "Missing blank line between list items".to_string(),
severity: Severity::Warning,
fix,
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
if ctx.content.is_empty() {
return Ok(ctx.content.to_string());
}
let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
let allow_cont = self.config.allow_loose_continuation;
for analysis in self.analyze(ctx) {
for (i, &gap) in analysis.gaps.iter().enumerate() {
let is_loose_violation = match gap {
GapKind::Loose => analysis.warn_loose_gaps,
GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
_ => false,
};
if is_loose_violation {
for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
remove_lines.insert(blank_line);
}
} else if gap == GapKind::Tight && analysis.warn_tight_gaps {
insert_before.insert(analysis.items[i + 1]);
}
}
}
if insert_before.is_empty() && remove_lines.is_empty() {
return Ok(ctx.content.to_string());
}
let lines = ctx.raw_lines();
let mut result: Vec<String> = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1;
if ctx.is_rule_disabled(self.name(), line_num) {
result.push((*line).to_string());
continue;
}
if remove_lines.contains(&line_num) {
continue;
}
if insert_before.contains(&line_num) {
let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
result.push(bq_prefix);
}
result.push((*line).to_string());
}
let mut output = result.join("\n");
if ctx.content.ends_with('\n') {
output.push('\n');
}
Ok(output)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let mut map = toml::map::Map::new();
let style_str = match self.config.style {
ListItemSpacingStyle::Consistent => "consistent",
ListItemSpacingStyle::Loose => "loose",
ListItemSpacingStyle::Tight => "tight",
};
map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
map.insert(
"allow-loose-continuation".to_string(),
toml::Value::Boolean(self.config.allow_loose_continuation),
);
Some((self.name().to_string(), toml::Value::Table(map)))
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
.unwrap_or_else(|| "consistent".to_string());
let style = match style.as_str() {
"loose" => ListItemSpacingStyle::Loose,
"tight" => ListItemSpacingStyle::Tight,
_ => ListItemSpacingStyle::Consistent,
};
let allow_loose_continuation =
crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
.or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
.unwrap_or(false);
Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD076ListItemSpacing::new(style);
rule.check(&ctx).unwrap()
}
fn check_with_continuation(
content: &str,
style: ListItemSpacingStyle,
allow_loose_continuation: bool,
) -> Vec<LintWarning> {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
rule.check(&ctx).unwrap()
}
fn fix(content: &str, style: ListItemSpacingStyle) -> String {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD076ListItemSpacing::new(style);
rule.fix(&ctx).unwrap()
}
fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
rule.fix(&ctx).unwrap()
}
#[test]
fn tight_list_tight_style_no_warnings() {
let content = "- Item 1\n- Item 2\n- Item 3\n";
assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
}
#[test]
fn loose_list_loose_style_no_warnings() {
let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
}
#[test]
fn tight_list_loose_style_warns() {
let content = "- Item 1\n- Item 2\n- Item 3\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 2);
assert!(warnings.iter().all(|w| w.message.contains("Missing")));
}
#[test]
fn loose_list_tight_style_warns() {
let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert_eq!(warnings.len(), 2);
assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
}
#[test]
fn consistent_all_tight_no_warnings() {
let content = "- Item 1\n- Item 2\n- Item 3\n";
assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
}
#[test]
fn consistent_all_loose_no_warnings() {
let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
}
#[test]
fn consistent_mixed_majority_loose_warns_tight() {
let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Missing"));
}
#[test]
fn consistent_mixed_majority_tight_warns_loose() {
let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unexpected"));
}
#[test]
fn consistent_tie_prefers_tight() {
let content = "- Item 1\n\n- Item 2\n- Item 3\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unexpected"));
}
#[test]
fn single_item_list_no_warnings() {
let content = "- Only item\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
}
#[test]
fn empty_content_no_warnings() {
assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
}
#[test]
fn ordered_list_tight_gaps_loose_style_warns() {
let content = "1. First\n2. Second\n3. Third\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 2);
}
#[test]
fn task_list_works() {
let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 2);
let fixed = fix(content, ListItemSpacingStyle::Loose);
assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
}
#[test]
fn no_trailing_newline() {
let content = "- Item 1\n- Item 2";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 1);
let fixed = fix(content, ListItemSpacingStyle::Loose);
assert_eq!(fixed, "- Item 1\n\n- Item 2");
}
#[test]
fn two_separate_lists() {
let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 2);
let fixed = fix(content, ListItemSpacingStyle::Loose);
assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
}
#[test]
fn no_list_content() {
let content = "Just a paragraph.\n\nAnother paragraph.\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
}
#[test]
fn continuation_lines_tight_detected() {
let content = "- Item 1\n continuation\n- Item 2\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Missing"));
}
#[test]
fn continuation_lines_loose_detected() {
let content = "- Item 1\n continuation\n\n- Item 2\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
let warnings = check(content, ListItemSpacingStyle::Tight);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unexpected"));
}
#[test]
fn multi_paragraph_item_not_treated_as_inter_item_gap() {
let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert_eq!(
warnings.len(),
1,
"Should warn only on the inter-item blank, not the intra-item blank"
);
let fixed = fix(content, ListItemSpacingStyle::Tight);
assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
}
#[test]
fn multi_paragraph_item_loose_style_no_warnings() {
let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
}
#[test]
fn blockquote_tight_list_loose_style_warns() {
let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 2);
}
#[test]
fn blockquote_loose_list_detected() {
let content = "> - Item 1\n>\n> - Item 2\n";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
assert!(warnings[0].message.contains("Unexpected"));
}
#[test]
fn blockquote_loose_list_no_warnings_when_loose() {
let content = "> - Item 1\n>\n> - Item 2\n";
assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
}
#[test]
fn multiple_blanks_all_removed() {
let content = "- Item 1\n\n\n- Item 2\n";
let fixed = fix(content, ListItemSpacingStyle::Tight);
assert_eq!(fixed, "- Item 1\n- Item 2\n");
}
#[test]
fn multiple_blanks_fix_is_idempotent() {
let content = "- Item 1\n\n\n\n- Item 2\n";
let fixed_once = fix(content, ListItemSpacingStyle::Tight);
let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
assert_eq!(fixed_once, fixed_twice);
assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
}
#[test]
fn fix_adds_blank_lines() {
let content = "- Item 1\n- Item 2\n- Item 3\n";
let fixed = fix(content, ListItemSpacingStyle::Loose);
assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
}
#[test]
fn fix_removes_blank_lines() {
let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
let fixed = fix(content, ListItemSpacingStyle::Tight);
assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
}
#[test]
fn fix_consistent_adds_blank() {
let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
let fixed = fix(content, ListItemSpacingStyle::Consistent);
assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
}
#[test]
fn fix_idempotent_loose() {
let content = "- Item 1\n- Item 2\n";
let fixed_once = fix(content, ListItemSpacingStyle::Loose);
let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
assert_eq!(fixed_once, fixed_twice);
}
#[test]
fn fix_idempotent_tight() {
let content = "- Item 1\n\n- Item 2\n";
let fixed_once = fix(content, ListItemSpacingStyle::Tight);
let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
assert_eq!(fixed_once, fixed_twice);
}
#[test]
fn nested_list_does_not_affect_parent() {
let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert!(
warnings.is_empty(),
"Nested items should not cause parent-level warnings"
);
}
#[test]
fn tab_nested_child_is_not_a_sibling() {
let content = "* parent\n\n\t1. child\n* next\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
warnings.is_empty(),
"a tab-nested child is not a sibling of the parent items: {warnings:?}"
);
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
let sibling = "* parent\n\n* child\n* next\n";
let warnings = check(sibling, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 2);
}
#[test]
fn nested_list_is_analysed_at_its_own_level() {
for (label, content, fixed) in [
(
"spaces",
"- parent\n - a\n\n - b\n - c\n- next\n",
"- parent\n - a\n - b\n - c\n- next\n",
),
(
"tab",
"* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
"* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
),
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
assert_eq!(
warnings[0].message, "Unexpected blank line between list items",
"{label}"
);
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
}
let content = "- parent\n - a\n\n - b\n- next\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
}
#[test]
fn nested_lists_under_different_parents_are_separate_lists() {
let content = "- a\n - a1\n - a2\n- b\n - b1\n\n - b2\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
let content = "- a\n - a1\n - a2\n - b1\n\n - b2\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 5);
}
#[test]
fn every_warning_carries_the_edit_the_fix_applies() {
let cases = [
("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
("- p\n - a\n\n - b\n - c\n", ListItemSpacingStyle::Consistent),
];
for (content, style) in cases {
let warnings = check(content, style.clone());
assert!(!warnings.is_empty(), "{content:?}");
assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
let fixed = fix(content, style);
assert_eq!(applied, fixed, "{content:?}");
assert_ne!(applied, content, "{content:?}");
}
let content = "- a\r\n\r\n- b\r\n- c\r\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
"- a\r\n- b\r\n- c\r\n"
);
for (content, replacement) in [("- a\r\n- b\r\n\r\n- c\r\n", "\r\n"), ("> - a\r\n> - b\r\n", ">\r\n")] {
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
assert_eq!(fix.replacement, replacement, "{content:?}");
assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
}
let content = "- a\r\n- b\r\n\r\n- c\r\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
"- a\r\n\r\n- b\r\n\r\n- c\r\n"
);
}
#[test]
fn nested_lists_separated_by_parent_content_are_separate_lists() {
for content in [
"- p\n - a\n - b\n\n With:\n\n - c\n\n - d\n",
"- p\n - a\n - b\n <!-- parent comment -->\n - c\n\n - d\n",
"- p\n - a\n - b\n >\n - c\n\n - d\n",
"> - p\n> - a\n> - b\n> >\n> - c\n>\n> - d\n",
"- p\n - a\n >\n parent\n - c\n\n - d\n",
"- p\n - a\n - ```\n more\n - c\n\n - d\n",
"- p\n - a\n - | h |\n | --- |\n more\n - c\n\n - d\n",
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
}
for (content, line, message) in [
(
"- p\n - a\n - b\n\n - c\n\n - d\n",
3,
"Missing blank line between list items",
),
(
"- p\n - a\n - ```lang`bad\n more\n - c\n\n - d\n",
6,
"Unexpected blank line between list items",
),
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].line, line, "{content:?}");
assert_eq!(warnings[0].message, message, "{content:?}");
}
}
#[test]
fn lists_of_different_marker_types_are_separate_lists() {
for content in [
"- parent\n - bullet a\n - bullet b\n 1. ordered a\n\n 2. ordered b\n- next\n",
"- parent\n - dash a\n - dash b\n * star a\n\n * star b\n- next\n",
"- parent\n 1. dot a\n 2. dot b\n 1) paren a\n\n 2) paren b\n- next\n",
"- dash a\n- dash b\n* star a\n\n* star b\n",
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
}
for (content, line) in [
("- parent\n - a\n - b\n - c\n\n - d\n- next\n", 5),
("- parent\n 1. a\n 2. b\n 3. c\n\n 4. d\n- next\n", 5),
("- a\n- b\n- c\n\n- d\n", 4),
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].line, line, "{content:?}");
assert_eq!(
warnings[0].message, "Unexpected blank line between list items",
"{content:?}"
);
}
}
#[test]
fn siblings_at_a_different_indent_are_not_the_nested_list() {
let content = " - parent\n - child a\n - child b\n\n - sibling a\n\n - sibling b\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
let content = " > - parent\n> - child a\n>\n> - child b\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
for (content, line, message) in [
(
" - parent\n - child a\n - child b\n\n - sibling a\n - sibling b\n",
4,
"Unexpected blank line between list items",
),
(
" - parent\n - child a\n\n - child b\n - child c\n - sibling\n",
3,
"Unexpected blank line between list items",
),
(
" > - parent\n> - child a\n>\n> - child b\n> - child c\n",
3,
"Unexpected blank line between list items",
),
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].line, line, "{content:?}");
assert_eq!(warnings[0].message, message, "{content:?}");
}
}
#[test]
fn lists_in_different_blockquotes_are_different_lists() {
for content in [
"- p\n >- b1\n >\n >- b2\n>- c\n>- d\n",
"- p\n > - b1\n > - b2\n\n > - b3\n",
"> - a\n> - b\n> - c\n\n> - d\n",
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
}
for (content, line, message) in [
(
"- p\n > - b1\n > - b2\n >\n > - b3\n",
4,
"Unexpected blank line between list items",
),
(
"- p\n >- b1\n >- b2\n >\n >- b3\n>- c\n>- d\n",
4,
"Unexpected blank line between list items",
),
] {
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].line, line, "{content:?}");
assert_eq!(warnings[0].message, message, "{content:?}");
}
}
#[test]
fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
for (content, style) in [
("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
] {
let warnings = check(content, style.clone());
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
assert_eq!(fix(content, style), content);
}
let content = "- p\n```\n```\n > - b\n lazy\n> - c\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 6);
assert_eq!(warnings[0].message, "Missing blank line between list items");
assert_eq!(
fix(content, ListItemSpacingStyle::Loose),
"- p\n```\n```\n > - b\n lazy\n>\n> - c\n"
);
let content = "> - a\n>\n> - b\n> - c\n";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 2);
assert_eq!(warnings[0].message, "Unexpected blank line between list items");
}
#[test]
fn explicit_style_applies_to_nested_lists() {
let content = "- a\n\n- b\n - b1\n - b2\n";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 5);
assert_eq!(warnings[0].message, "Missing blank line between list items");
assert_eq!(
fix(content, ListItemSpacingStyle::Loose),
"- a\n\n- b\n - b1\n\n - b2\n"
);
let content = "- a\n- b\n - b1\n\n - b2\n";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 4);
assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n - b1\n - b2\n");
}
#[test]
fn code_block_in_tight_list_no_false_positive() {
let content = "\
- Item 1 with code:
```python
print('hello')
```
- Item 2 simple.
- Item 3 simple.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank after code block should not make item 1 appear loose"
);
}
#[test]
fn table_in_tight_list_no_false_positive() {
let content = "\
- Item 1 with table:
| Col 1 | Col 2 |
|-------|-------|
| A | B |
- Item 2 simple.
- Item 3 simple.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank after table should not make item 1 appear loose"
);
}
#[test]
fn html_block_in_tight_list_no_false_positive() {
let content = "\
- Item 1 with HTML:
<details>
<summary>Click</summary>
Content
</details>
- Item 2 simple.
- Item 3 simple.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank after HTML block should not make item 1 appear loose"
);
}
#[test]
fn blockquote_in_tight_list_no_false_positive() {
let content = "\
- Item 1 with quote:
> This is a blockquote
> with multiple lines.
- Item 2 simple.
- Item 3 simple.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank around blockquote should not make item 1 appear loose"
);
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Blockquote in tight list should not trigger a violation"
);
}
#[test]
fn blockquote_multiple_items_with_quotes_tight() {
let content = "\
- Item 1:
> Quote A
- Item 2:
> Quote B
- Item 3 plain.
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Multiple items with blockquotes should remain tight"
);
}
#[test]
fn blockquote_mixed_with_genuine_loose_gap() {
let content = "\
- Item 1:
> Quote
- Item 2 plain.
- Item 3 plain.
";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert!(
!warnings.is_empty(),
"Genuine loose gap between Item 2 and Item 3 should be flagged"
);
}
#[test]
fn blockquote_single_line_in_tight_list() {
let content = "\
- Item 1:
> Single line quote.
- Item 2.
- Item 3.
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Single-line blockquote should be structural"
);
}
#[test]
fn blockquote_in_ordered_list_tight() {
let content = "\
1. Item 1:
> Quoted text in ordered list.
1. Item 2.
1. Item 3.
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Blockquote in ordered list should be structural"
);
}
#[test]
fn nested_blockquote_in_tight_list() {
let content = "\
- Item 1:
> Outer quote
> > Nested quote
- Item 2.
- Item 3.
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Nested blockquote in tight list should be structural"
);
}
#[test]
fn blockquote_as_entire_item_is_loose() {
let content = "\
- > Quote is the entire item content.
- Item 2.
- Item 3.
";
let warnings = check(content, ListItemSpacingStyle::Tight);
assert!(
!warnings.is_empty(),
"Blank after blockquote-only item is a genuine loose gap"
);
}
#[test]
fn mixed_code_and_table_in_tight_list() {
let content = "\
1. Item with code:
```markdown
This is some Markdown
```
1. Simple item.
1. Item with table:
| Col 1 | Col 2 |
|:------|:------|
| Row 1 | Row 1 |
| Row 2 | Row 2 |
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Mix of code blocks and tables should not cause false positives"
);
}
#[test]
fn code_block_with_genuinely_loose_gaps_still_warns() {
let content = "\
- Item 1:
```bash
echo hi
```
- Item 2
- Item 3
- Item 4
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Genuine inconsistency with code blocks should still be flagged"
);
}
#[test]
fn all_items_have_code_blocks_no_warnings() {
let content = "\
- Item 1:
```python
print(1)
```
- Item 2:
```python
print(2)
```
- Item 3:
```python
print(3)
```
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"All items with code blocks should be consistently tight"
);
}
#[test]
fn tilde_fence_code_block_in_list() {
let content = "\
- Item 1:
~~~
code here
~~~
- Item 2 simple.
- Item 3 simple.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Tilde fences should be recognized as structural content"
);
}
#[test]
fn nested_list_with_code_block() {
let content = "\
- Item 1
- Nested with code:
```
nested code
```
- Nested simple.
- Item 2
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Nested list with code block should not cause false positives"
);
}
#[test]
fn tight_style_with_code_block_no_warnings() {
let content = "\
- Item 1:
```
code
```
- Item 2.
- Item 3.
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Tight style should not warn about structural blanks around code blocks"
);
}
#[test]
fn loose_style_with_code_block_missing_separator() {
let content = "\
- Item 1:
```
code
```
- Item 2.
- Item 3.
";
let warnings = check(content, ListItemSpacingStyle::Loose);
assert_eq!(
warnings.len(),
1,
"Loose style should still require blank between simple items"
);
assert!(warnings[0].message.contains("Missing"));
}
#[test]
fn blockquote_list_with_code_block() {
let content = "\
> - Item 1:
>
> ```
> code
> ```
>
> - Item 2.
> - Item 3.
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Blockquote-prefixed list with code block should not cause false positives"
);
}
#[test]
fn indented_code_block_in_list_no_false_positive() {
let content = "\
1. Item with indented code:
some code here
more code
1. Simple item
1. Another item
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank after indented code block should not make item 1 appear loose"
);
}
#[test]
fn fence_on_marker_line_keeps_its_structural_blank() {
for spaces in 1..=4 {
let pad = " ".repeat(spaces);
let indent = " ".repeat(spaces + 1);
let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
assert!(
check(&content, ListItemSpacingStyle::Tight).is_empty(),
"a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
);
assert_eq!(
fix(&content, ListItemSpacingStyle::Tight),
content,
"tight fix must keep the blank MD031 requires ({spaces} space(s))"
);
}
}
#[test]
fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
for fence in ["```", "~~~"] {
let content = format!("- a\n\n- {fence}\n code\n {fence}\n- c\n");
let warnings = check(&content, ListItemSpacingStyle::Tight);
assert_eq!(
warnings.len(),
1,
"no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
);
assert_eq!(
fix(&content, ListItemSpacingStyle::Tight),
format!("- a\n- {fence}\n code\n {fence}\n- c\n"),
"tight fix must remove a blank that MD031 does not require ({fence})"
);
}
}
#[test]
fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
let content = "\
1. Item with code in middle:
```
code
```
Some text after the code block.
1. Simple item
1. Another item
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Blank line after regular text (not structural content) is a genuine loose gap"
);
}
#[test]
fn tight_fix_preserves_structural_blanks_around_code_blocks() {
let content = "\
- Item 1:
```
code
```
- Item 2.
- Item 3.
";
let fixed = fix(content, ListItemSpacingStyle::Tight);
assert_eq!(
fixed, content,
"Tight fix should not remove structural blanks around code blocks"
);
}
#[test]
fn four_space_indented_fence_in_loose_list_no_false_positive() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"Structural blank after 4-space indented code block should not cause false positive"
);
}
#[test]
fn four_space_indented_fence_tight_style_no_warnings() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
assert!(
check(content, ListItemSpacingStyle::Tight).is_empty(),
"Tight style should not warn about structural blanks with 4-space fences"
);
}
#[test]
fn four_space_indented_fence_loose_style_no_warnings() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
assert!(
check(content, ListItemSpacingStyle::Loose).is_empty(),
"Loose style should not warn when structural gaps are the only non-loose gaps"
);
}
#[test]
fn structural_gap_with_genuine_inconsistency_still_warns() {
let content = "\
1. First item with code:
```json
{\"key\": \"value\"}
```
1. Second item
1. Third item
1. Fourth item
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Genuine loose/tight inconsistency should still warn even with structural gaps"
);
}
#[test]
fn four_space_fence_fix_is_idempotent() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
let fixed = fix(content, ListItemSpacingStyle::Consistent);
assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
}
#[test]
fn four_space_fence_fix_does_not_insert_duplicate_blank() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
let fixed = fix(content, ListItemSpacingStyle::Tight);
assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
}
#[test]
fn mkdocs_flavor_code_block_in_list_no_false_positive() {
let content = "\
1. First item
1. Second item with code block:
```json
{\"key\": \"value\"}
```
1. Third item
";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
);
}
#[test]
fn code_block_in_second_item_detects_inconsistency() {
let content = "\
# Test
- Lorem ipsum dolor sit amet.
- Lorem ipsum dolor sit amet.
```yaml
hello: world
```
- Lorem ipsum dolor sit amet.
- Lorem ipsum dolor sit amet.
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Should detect inconsistent spacing when code block is inside a list item"
);
}
#[test]
fn code_block_in_item_all_tight_no_warnings() {
let content = "\
- Item 1
- Item 2
```yaml
hello: world
```
- Item 3
- Item 4
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"All tight gaps with structural code block should not warn"
);
}
#[test]
fn code_block_in_item_all_loose_no_warnings() {
let content = "\
- Item 1
- Item 2
```yaml
hello: world
```
- Item 3
- Item 4
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"All loose gaps with structural code block should not warn"
);
}
#[test]
fn code_block_in_ordered_list_detects_inconsistency() {
let content = "\
1. First item
1. Second item
```json
{\"key\": \"value\"}
```
1. Third item
1. Fourth item
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Ordered list with code block should still detect inconsistency"
);
}
#[test]
fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
let content = "\
- Item 1
- Item 2
```yaml
code: here
```
- Item 3
- Item 4
";
let fixed = fix(content, ListItemSpacingStyle::Consistent);
assert!(
fixed.contains("- Item 3\n- Item 4"),
"Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
);
assert!(
!fixed.contains("- Item 1\n\n- Item 2"),
"Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
);
}
#[test]
fn tilde_code_block_in_item_detects_inconsistency() {
let content = "\
- Item 1
- Item 2
~~~
code
~~~
- Item 3
- Item 4
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Tilde code block inside item should not prevent inconsistency detection"
);
}
#[test]
fn multiple_code_blocks_all_tight_no_warnings() {
let content = "\
- Item 1
```
code1
```
- Item 2
```
code2
```
- Item 3
- Item 4
";
assert!(
check(content, ListItemSpacingStyle::Consistent).is_empty(),
"All non-structural gaps are tight, so list is consistent"
);
}
#[test]
fn code_block_with_mixed_genuine_gaps_warns() {
let content = "\
- Item 1
```
code1
```
- Item 2
- Item 3
- Item 4
";
let warnings = check(content, ListItemSpacingStyle::Consistent);
assert!(
!warnings.is_empty(),
"Mixed genuine gaps (loose + tight) with structural code block should still warn"
);
}
#[test]
fn continuation_loose_tight_style_default_warns() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
Continuation paragraph.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
assert!(
!warnings.is_empty(),
"Should warn about loose gaps when allow_loose_continuation is false"
);
}
#[test]
fn continuation_loose_tight_style_allowed_no_warnings() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
Continuation paragraph.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
warnings.is_empty(),
"Should not warn when allow_loose_continuation is true, got: {warnings:?}"
);
}
#[test]
fn continuation_loose_mixed_items_warns() {
let content = "\
- Item 1.
- Item 2.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
!warnings.is_empty(),
"Genuine loose gaps should still warn even with allow_loose_continuation"
);
}
#[test]
fn continuation_loose_consistent_mode() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
assert!(
warnings.is_empty(),
"Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
);
}
#[test]
fn continuation_loose_fix_preserves_continuation_blanks() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
Continuation paragraph.
- Item 3.
";
let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
}
#[test]
fn continuation_loose_fix_removes_genuine_loose_gaps() {
let input = "\
- Item 1.
- Item 2.
- Item 3.
";
let expected = "\
- Item 1.
- Item 2.
- Item 3.
";
let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
assert_eq!(fixed, expected);
}
#[test]
fn continuation_loose_ordered_list() {
let content = "\
1. Item 1.
Continuation paragraph.
2. Item 2.
Continuation paragraph.
3. Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
warnings.is_empty(),
"Ordered list continuation should work too, got: {warnings:?}"
);
}
#[test]
fn continuation_loose_disabled_by_default() {
let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
assert!(!rule.config.allow_loose_continuation);
}
#[test]
fn continuation_loose_ordered_under_indented_ends_the_list() {
let content = "\
1. Item 1.
Under-indented text.
1. Item 2.
1. Item 3.
";
for (style, allow) in [
(ListItemSpacingStyle::Tight, true),
(ListItemSpacingStyle::Tight, false),
(ListItemSpacingStyle::Consistent, false),
] {
let warnings = check_with_continuation(content, style, allow);
assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
}
let content = "\
1. Item 1.
Continuation text.
1. Item 2.
1. Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].line, 4);
assert_eq!(warnings[0].message, "Unexpected blank line between list items");
}
#[test]
fn continuation_loose_mix_continuation_and_genuine_gaps() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
!warnings.is_empty(),
"Genuine loose gap between items 2-3 should warn even with continuation allowed"
);
assert_eq!(
warnings.len(),
1,
"Expected exactly one warning for the genuine loose gap"
);
}
#[test]
fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
let input = "\
- Item 1.
Continuation paragraph.
- Item 2.
- Item 3.
";
let expected = "\
- Item 1.
Continuation paragraph.
- Item 2.
- Item 3.
";
let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
assert_eq!(fixed, expected);
}
#[test]
fn continuation_loose_after_code_block() {
let content = "\
- Item 1.
```python
code
```
Continuation after code.
- Item 2.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
warnings.is_empty(),
"Code block + continuation should both be exempt, got: {warnings:?}"
);
}
#[test]
fn continuation_loose_style_does_not_interfere() {
let content = "\
- Item 1.
Continuation paragraph.
- Item 2.
Continuation paragraph.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
assert!(
warnings.is_empty(),
"Loose style with continuation should not warn, got: {warnings:?}"
);
}
#[test]
fn continuation_loose_tight_no_continuation_content() {
let content = "\
- Item 1.
- Item 2.
- Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
warnings.is_empty(),
"Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
);
}
#[test]
fn default_config_section_provides_style_key() {
let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
let section = rule.default_config_section();
assert!(section.is_some());
let (name, value) = section.unwrap();
assert_eq!(name, "MD076");
if let toml::Value::Table(map) = value {
assert!(map.contains_key("style"));
assert!(map.contains_key("allow-loose-continuation"));
} else {
panic!("Expected Table value from default_config_section");
}
}
}