use crate::lint_context::LintContext;
use crate::rule::{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 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 BlockAnalysis {
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_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_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;
}
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(|item| item.content_column)
.unwrap_or(2);
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_block(
ctx: &LintContext,
block: &crate::lint_context::types::ListBlock,
style: &ListItemSpacingStyle,
allow_loose_continuation: bool,
) -> Option<BlockAnalysis> {
let items: Vec<usize> = block
.item_lines
.iter()
.copied()
.filter(|&line_num| {
ctx.line_info(line_num)
.and_then(|li| li.list_item.as_ref())
.map(|item| item.marker_column / 2 == block.nesting_level)
.unwrap_or(false)
})
.collect();
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 loose_count >= tight_count {
(false, true)
} else {
(true, false)
}
}
};
Some(BlockAnalysis {
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;
for block in &ctx.list_blocks {
let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
continue;
};
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 blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
if let Some(&blank_line) = blanks.first() {
let line_content = ctx
.line_info(blank_line)
.map(|li| li.content(ctx.content))
.unwrap_or("");
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: blank_line,
column: 1,
end_line: blank_line,
end_column: line_content.len() + 1,
message: "Unexpected blank line between list items".to_string(),
severity: Severity::Warning,
fix: None,
});
}
} 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(|li| li.content(ctx.content)).unwrap_or("");
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: next_item,
column: 1,
end_line: next_item,
end_column: line_content.len() + 1,
message: "Missing blank line between list items".to_string(),
severity: Severity::Warning,
fix: None,
});
}
}
}
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 block in &ctx.list_blocks {
let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
continue;
};
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_loose() {
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("Missing"));
}
#[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 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 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_adds_missing_blanks() {
let content = "\
- Item 1
- Item 2
```yaml
code: here
```
- Item 3
- Item 4
";
let fixed = fix(content, ListItemSpacingStyle::Consistent);
assert!(
fixed.contains("- Item 1\n\n- Item 2"),
"Fix should add blank line between items 1 and 2"
);
}
#[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_warns() {
let content = "\
1. Item 1.
Under-indented text.
1. Item 2.
1. Item 3.
";
let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
assert!(
!warnings.is_empty(),
"Under-indented text should not be treated as continuation, got: {warnings:?}"
);
}
#[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");
}
}
}