use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
mod md007_config;
use md007_config::MD007Config;
#[derive(Debug, Clone, Default)]
pub struct MD007ULIndent {
config: MD007Config,
}
impl MD007ULIndent {
pub fn new(indent: usize) -> Self {
Self {
config: MD007Config {
indent: crate::types::IndentSize::from_const(indent as u8),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false, indent_explicit: false, },
}
}
pub fn from_config_struct(config: MD007Config) -> Self {
Self { config }
}
fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
let mut visual_col = 0;
for (current_pos, ch) in content.chars().enumerate() {
if current_pos >= char_pos {
break;
}
if ch == '\t' {
visual_col = (visual_col / 4 + 1) * 4;
} else {
visual_col += 1;
}
}
visual_col
}
fn calculate_expected_indent(
&self,
nesting_level: usize,
parent_info: Option<(bool, usize)>, ) -> usize {
if nesting_level == 0 {
return 0;
}
if self.config.style_explicit {
return match self.config.style {
md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
md007_config::IndentStyle::TextAligned => {
parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
}
};
}
if self.config.indent_explicit {
match parent_info {
Some((true, parent_content_col)) => {
return parent_content_col;
}
_ => {
return nesting_level * self.config.indent.get() as usize;
}
}
}
match parent_info {
Some((true, parent_content_col)) => {
parent_content_col
}
Some((false, parent_content_col)) => {
let parent_level = nesting_level.saturating_sub(1);
let expected_parent_marker = parent_level * self.config.indent.get() as usize;
let parent_marker_col = parent_content_col.saturating_sub(2);
if parent_marker_col == expected_parent_marker {
nesting_level * self.config.indent.get() as usize
} else {
parent_content_col
}
}
None => {
nesting_level * self.config.indent.get() as usize
}
}
}
}
impl Rule for MD007ULIndent {
fn name(&self) -> &'static str {
"MD007"
}
fn description(&self) -> &'static str {
"Unordered list indentation"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
let mut list_stack: Vec<(usize, usize, bool, usize, usize)> = Vec::new();
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
continue;
}
if let Some(list_item) = &line_info.list_item {
let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
let line_content = line_info.content(ctx.content);
let mut remaining = line_content;
let mut content_start = 0;
loop {
let trimmed = remaining.trim_start();
if !trimmed.starts_with('>') {
break;
}
content_start += remaining.len() - trimmed.len();
content_start += 1;
let after_gt = &trimmed[1..];
if let Some(stripped) = after_gt.strip_prefix(' ') {
content_start += 1;
remaining = stripped;
} else if let Some(stripped) = after_gt.strip_prefix('\t') {
content_start += 1;
remaining = stripped;
} else {
remaining = after_gt;
}
}
let content_after_prefix = &line_content[content_start..];
let adjusted_col = if list_item.marker_column >= content_start {
list_item.marker_column - content_start
} else {
list_item.marker_column
};
(content_after_prefix.to_string(), adjusted_col)
} else {
(line_info.content(ctx.content).to_string(), list_item.marker_column)
};
let visual_marker_column =
Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
let visual_content_column = if line_info.blockquote.is_some() {
let adjusted_content_col =
if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
list_item.content_column - (line_info.byte_len - content_for_calculation.len())
} else {
list_item.content_column
};
Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
} else {
Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
};
let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
0
} else {
visual_marker_column
};
let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
while let Some(&(indent, _, _, _, item_bq_depth)) = list_stack.last() {
if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
list_stack.pop();
} else if item_bq_depth > bq_depth {
list_stack.pop();
} else {
break;
}
}
if list_item.is_ordered {
list_stack.push((visual_marker_column, line_idx, true, visual_content_column, bq_depth));
continue;
}
let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
let parent_info = list_stack
.iter()
.rev()
.find(|item| item.4 == bq_depth)
.map(|&(_, _, is_ordered, content_col, _)| (is_ordered, content_col));
let mut expected_indent = if self.config.start_indented {
self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
} else {
self.calculate_expected_indent(nesting_level, parent_info)
};
let also_acceptable =
if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
Some(nesting_level * self.config.indent.get() as usize)
} else {
None
};
if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
&& let Some(&(parent_marker_col, _, true, _, _)) =
list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
{
expected_indent = expected_indent.max(parent_marker_col + 4);
}
let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
visual_marker_column
} else {
expected_indent
};
let expected_content_visual_col = accepted_indent + 2;
list_stack.push((
visual_marker_column,
line_idx,
false,
expected_content_visual_col,
bq_depth,
));
if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
continue;
}
if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
if let Some(alt) = also_acceptable {
expected_indent = alt;
}
let fix = {
let correct_indent = " ".repeat(expected_indent);
let replacement = if line_info.blockquote.is_some() {
let mut blockquote_count = 0;
for ch in line_info.content(ctx.content).chars() {
if ch == '>' {
blockquote_count += 1;
} else if ch != ' ' && ch != '\t' {
break;
}
}
let blockquote_prefix = if blockquote_count > 1 {
(0..blockquote_count)
.map(|_| "> ")
.collect::<String>()
.trim_end()
.to_string()
} else {
">".to_string()
};
format!("{blockquote_prefix} {correct_indent}")
} else {
correct_indent
};
let start_byte = line_info.byte_offset;
let mut end_byte = line_info.byte_offset;
for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
if i >= list_item.marker_column {
break;
}
end_byte += ch.len_utf8();
}
Some(crate::rule::Fix {
range: start_byte..end_byte,
replacement,
})
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!(
"Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
),
line: line_idx + 1, column: 1, end_line: line_idx + 1,
end_column: visual_marker_column + 1, severity: Severity::Warning,
fix,
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let mut fixes: Vec<_> = warnings
.iter()
.filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
.collect();
fixes.sort_by(|a, b| b.0.cmp(&a.0));
let mut result = ctx.content.to_string();
for (start, end, replacement) in fixes {
if start < result.len() && end <= result.len() && start <= end {
result.replace_range(start..end, replacement);
}
}
Ok(result)
}
fn category(&self) -> RuleCategory {
RuleCategory::List
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if ctx.content.is_empty() || !ctx.likely_has_lists() {
return true;
}
!ctx.lines
.iter()
.any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD007Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
if let Some(rule_cfg) = config.rules.get("MD007") {
rule_config.style_explicit = rule_cfg.values.contains_key("style");
rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
if rule_config.indent_explicit
&& rule_config.style_explicit
&& rule_config.style == md007_config::IndentStyle::TextAligned
{
eprintln!(
"\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
Text-aligned style ignores indent and aligns nested items with parent text. \
To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
rule_config.indent.get()
);
}
}
if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
if rule_config.indent_explicit && rule_config.indent.get() < 4 {
eprintln!(
"\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
(Python-Markdown enforces 4-space indentation). \
Overriding indent={} to indent=4.",
rule_config.indent.get()
);
}
if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
eprintln!(
"\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
(Python-Markdown uses fixed 4-space indentation). \
Overriding style=\"text-aligned\" to style=\"fixed\"."
);
}
if rule_config.indent.get() < 4 {
rule_config.indent = crate::types::IndentSize::from_const(4);
}
rule_config.style = md007_config::IndentStyle::Fixed;
}
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::rule::Rule;
#[test]
fn test_valid_list_indent() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for valid indentation, but got {} warnings",
result.len()
);
}
#[test]
fn test_invalid_list_indent() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 2);
assert_eq!(result[0].column, 1);
assert_eq!(result[1].line, 3);
assert_eq!(result[1].column, 1);
}
#[test]
fn test_mixed_indentation() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
assert_eq!(result[0].column, 1);
}
#[test]
fn test_fix_indentation() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "* Item 1\n * Item 2\n * Item 3";
assert_eq!(result, expected);
}
#[test]
fn test_md007_in_yaml_code_block() {
let rule = MD007ULIndent::default();
let content = r#"```yaml
repos:
- repo: https://github.com/rvben/rumdl
rev: v0.5.0
hooks:
- id: rumdl-check
```"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD007 should not trigger inside a code block, but got warnings: {result:?}"
);
}
#[test]
fn test_blockquoted_list_indent() {
let rule = MD007ULIndent::default();
let content = "> * Item 1\n> * Item 2\n> * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for valid blockquoted list indentation, but got {result:?}"
);
}
#[test]
fn test_blockquoted_list_invalid_indent() {
let rule = MD007ULIndent::default();
let content = "> * Item 1\n> * Item 2\n> * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
);
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 3);
}
#[test]
fn test_nested_blockquote_list_indent() {
let rule = MD007ULIndent::default();
let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
);
}
#[test]
fn test_blockquote_list_with_code_block() {
let rule = MD007ULIndent::default();
let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
);
}
#[test]
fn test_properly_indented_lists() {
let rule = MD007ULIndent::default();
let test_cases = vec![
"* Item 1\n* Item 2",
"* Item 1\n * Item 1.1\n * Item 1.1.1",
"- Item 1\n - Item 1.1",
"+ Item 1\n + Item 1.1",
"* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
];
for content in test_cases {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for properly indented list:\n{}\nGot {} warnings",
content,
result.len()
);
}
}
#[test]
fn test_under_indented_lists() {
let rule = MD007ULIndent::default();
let test_cases = vec![
("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
for (content, expected_warnings, line) in test_cases {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
expected_warnings,
"Expected {expected_warnings} warnings for under-indented list:\n{content}"
);
if expected_warnings > 0 {
assert_eq!(result[0].line, line);
}
}
}
#[test]
fn test_over_indented_lists() {
let rule = MD007ULIndent::default();
let test_cases = vec![
("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
for (content, expected_warnings, line) in test_cases {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
expected_warnings,
"Expected {expected_warnings} warnings for over-indented list:\n{content}"
);
if expected_warnings > 0 {
assert_eq!(result[0].line, line);
}
}
}
#[test]
fn test_custom_indent_2_spaces() {
let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_custom_indent_3_spaces() {
let rule = MD007ULIndent::new(3);
let correct_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Fixed style expects 0, 3, 6 spaces but got: {result:?}"
);
let wrong_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
}
#[test]
fn test_custom_indent_4_spaces() {
let rule = MD007ULIndent::new(4);
let correct_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Fixed style expects 0, 4, 8 spaces but got: {result:?}"
);
let wrong_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
}
#[test]
fn test_tab_indentation() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n * Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "* Item 1\n * Item 2");
let content_multi = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
let content_mixed = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
}
#[test]
fn test_mixed_ordered_unordered_lists() {
let rule = MD007ULIndent::default();
let content = r#"1. Ordered item
* Unordered sub-item (correct - 3 spaces under ordered)
2. Ordered sub-item
* Unordered item
1. Ordered sub-item
* Unordered sub-item"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_list_markers_variety() {
let rule = MD007ULIndent::default();
let content = r#"* Asterisk
* Nested asterisk
- Hyphen
- Nested hyphen
+ Plus
+ Nested plus"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All unordered list markers should work with proper indentation"
);
let wrong_content = r#"* Asterisk
* Wrong asterisk
- Hyphen
- Wrong hyphen
+ Plus
+ Wrong plus"#;
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
}
#[test]
fn test_empty_list_items() {
let rule = MD007ULIndent::default();
let content = "* Item 1\n* \n * Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Empty list items should not affect indentation checks"
);
}
#[test]
fn test_list_with_code_blocks() {
let rule = MD007ULIndent::default();
let content = r#"* Item 1
```
code
```
* Item 2
* Item 3"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_list_in_front_matter() {
let rule = MD007ULIndent::default();
let content = r#"---
tags:
- tag1
- tag2
---
* Item 1
* Item 2"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
}
#[test]
fn test_fix_preserves_content() {
let rule = MD007ULIndent::default();
let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
assert_eq!(fixed, expected, "Fix should only change indentation, not content");
}
#[test]
fn test_start_indented_config() {
let config = MD007Config {
start_indented: true,
start_indent: crate::types::IndentSize::from_const(4),
indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: true, indent_explicit: false,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = " * Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Expected no warnings with start_indented config");
let wrong_content = " * Item 1\n * Item 2";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
assert_eq!(result[1].line, 2);
assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, " * Item 1\n * Item 2");
}
#[test]
fn test_start_indented_false_allows_any_first_level() {
let rule = MD007ULIndent::default();
let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"First level at any indentation should be allowed when start_indented is false"
);
let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All first-level items should be allowed at any indentation"
);
}
#[test]
fn test_deeply_nested_lists() {
let rule = MD007ULIndent::default();
let content = r#"* L1
* L2
* L3
* L4
* L5
* L6"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let wrong_content = r#"* L1
* L2
* L3
* L4
* L5
* L6"#;
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
}
#[test]
fn test_excessive_indentation_detected() {
let rule = MD007ULIndent::default();
let content = "- Item 1\n - Item 2 with 5 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
assert_eq!(result[0].line, 2);
assert!(result[0].message.contains("Expected 2 spaces"));
assert!(result[0].message.contains("found 5"));
let content = "- Item 1\n - Item 2 with 3 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should detect slightly excessive indentation (3 instead of 2)"
);
assert_eq!(result[0].line, 2);
assert!(result[0].message.contains("Expected 2 spaces"));
assert!(result[0].message.contains("found 3"));
let content = "- Item 1\n - Item 2 with 1 space";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should detect 1-space indent (insufficient for nesting, expected 0)"
);
assert_eq!(result[0].line, 2);
assert!(result[0].message.contains("Expected 0 spaces"));
assert!(result[0].message.contains("found 1"));
}
#[test]
fn test_excessive_indentation_with_4_space_config() {
let rule = MD007ULIndent::new(4);
let content = "- Formatter:\n - The stable style changed";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should detect 5 spaces when expecting 4 (fixed style)"
);
let correct_content = "- Formatter:\n - The stable style changed";
let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
}
#[test]
fn test_bullets_nested_under_numbered_items() {
let rule = MD007ULIndent::default();
let content = "\
1. **Active Directory/LDAP**
- User authentication and directory services
- LDAP for user information and validation
2. **Oracle Unified Directory (OUD)**
- Extended user directory services";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
);
}
#[test]
fn test_bullets_nested_under_numbered_items_wrong_indent() {
let rule = MD007ULIndent::default();
let content = "\
1. **Active Directory/LDAP**
- Wrong: only 2 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected warning for incorrect indentation under numbered items"
);
assert!(
result
.iter()
.any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
);
}
#[test]
fn test_regular_bullet_nesting_still_works() {
let rule = MD007ULIndent::default();
let content = "\
* Top level
* Nested bullet (2 spaces is correct)
* Deeply nested (4 spaces)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for standard bullet nesting, got: {result:?}"
);
}
#[test]
fn test_blockquote_with_tab_after_marker() {
let rule = MD007ULIndent::default();
let content = ">\t* List item\n>\t * Nested\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Tab after blockquote marker should be handled correctly, got: {result:?}"
);
}
#[test]
fn test_blockquote_with_space_then_tab_after_marker() {
let rule = MD007ULIndent::default();
let content = "> \t* List item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
);
}
#[test]
fn test_blockquote_with_multiple_tabs() {
let rule = MD007ULIndent::default();
let content = ">\t\t* List item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
);
}
#[test]
fn test_nested_blockquote_with_tab() {
let rule = MD007ULIndent::default();
let content = ">\t>\t* List item\n>\t>\t * Nested\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Nested blockquotes with tabs should work correctly, got: {result:?}"
);
}
#[test]
fn test_smart_style_pure_unordered_uses_fixed() {
let rule = MD007ULIndent::new(4);
let content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
);
}
#[test]
fn test_smart_style_mixed_lists_uses_text_aligned() {
let rule = MD007ULIndent::new(4);
let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Mixed lists should use text-aligned style, got: {result:?}"
);
}
#[test]
fn test_smart_style_explicit_fixed_overrides() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::Fixed,
style_explicit: true, indent_explicit: false,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Explicit fixed style should be respected, got: {result:?}"
);
}
#[test]
fn test_smart_style_explicit_text_aligned_overrides() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: true, indent_explicit: false,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Explicit text-aligned should be respected, got: {result:?}"
);
let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"With explicit text-aligned, 4-space indent should be wrong (expected 2)"
);
}
#[test]
fn test_smart_style_default_indent_no_autoswitch() {
let rule = MD007ULIndent::new(2);
let content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Default indent should work regardless of style, got: {result:?}"
);
}
#[test]
fn test_has_mixed_list_nesting_detection() {
let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Pure unordered should not be detected as mixed"
);
let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Pure ordered should not be detected as mixed"
);
let content = "1. Ordered\n * Unordered child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Unordered under ordered should be detected as mixed"
);
let content = "* Unordered\n 1. Ordered child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Ordered under unordered should be detected as mixed"
);
let content = "* Unordered\n\n1. Ordered (separate list)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Separate lists should not be detected as mixed"
);
let content = "> 1. Ordered in blockquote\n> * Unordered child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Mixed lists in blockquotes should be detected"
);
}
#[test]
fn test_issue_210_exact_reproduction() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
let rule = MD007ULIndent::from_config_struct(config);
let content = "# Title\n\n* some\n * list\n * items\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
);
}
#[test]
fn test_issue_209_still_fixed() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(3),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: true, indent_explicit: false,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = r#"# Header 1
- **Second item**:
- **This is a nested list**:
1. **First point**
- First subpoint
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
);
}
#[test]
fn test_multi_level_mixed_detection_grandparent() {
let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Should detect mixed nesting when grandparent differs in type"
);
let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Should detect mixed nesting for ordered descendants under unordered"
);
}
#[test]
fn test_html_comments_skipped_in_detection() {
let content = r#"* Unordered list
<!-- This is a comment
1. This ordered list is inside a comment
* This nested bullet is also inside
-->
* Another unordered item"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Lists in HTML comments should be ignored in mixed detection"
);
}
#[test]
fn test_blank_lines_separate_lists() {
let content = "* First unordered list\n\n1. Second list is ordered (separate)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Blank line at root should separate lists"
);
let content = "1. Ordered parent\n\n * Still a child due to indentation";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Indented list after blank is still nested"
);
}
#[test]
fn test_column_1_normalization() {
let content = "* First item\n * Second item with 1 space (sibling)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD007ULIndent::default();
let result = rule.check(&ctx).unwrap();
assert!(
result.iter().any(|w| w.line == 2),
"1-space indent should be flagged as incorrect"
);
}
#[test]
fn test_code_blocks_skipped_in_detection() {
let content = r#"* Unordered list
```
1. This ordered list is inside a code block
* This nested bullet is also inside
```
* Another unordered item"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Lists in code blocks should be ignored in mixed detection"
);
}
#[test]
fn test_front_matter_skipped_in_detection() {
let content = r#"---
items:
- yaml list item
- another item
---
* Unordered list after front matter"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Lists in front matter should be ignored in mixed detection"
);
}
#[test]
fn test_alternating_types_at_same_level() {
let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Alternating types at same level should not be detected as mixed"
);
}
#[test]
fn test_five_level_deep_mixed_nesting() {
let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
}
#[test]
fn test_very_deep_pure_unordered_nesting() {
let mut content = String::from("* L1");
for level in 2..=12 {
let indent = " ".repeat(level - 1);
content.push_str(&format!("\n{indent}* L{level}"));
}
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Pure unordered deep nesting should not be detected as mixed"
);
let rule = MD007ULIndent::new(4);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
}
#[test]
fn test_interleaved_content_between_list_items() {
let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Should detect mixed nesting even with interleaved paragraphs"
);
}
#[test]
fn test_esm_blocks_skipped_in_detection() {
let content = "* Unordered list\n * Nested unordered";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Pure unordered should not be detected as mixed"
);
}
#[test]
fn test_multiple_list_blocks_pure_then_mixed() {
let content = r#"* Pure unordered
* Nested unordered
1. Mixed section
* Bullet under ordered"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Should detect mixed nesting in any part of document"
);
}
#[test]
fn test_multiple_separate_pure_lists() {
let content = r#"* First list
* Nested
* Second list
* Also nested
* Third list
* Deeply
* Nested"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
!ctx.has_mixed_list_nesting(),
"Multiple separate pure unordered lists should not be mixed"
);
}
#[test]
fn test_code_block_between_list_items() {
let content = r#"1. Ordered
```
code
```
* Still a mixed child"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Code block between items should not prevent mixed detection"
);
}
#[test]
fn test_blockquoted_mixed_detection() {
let content = "> 1. Ordered in blockquote\n> * Mixed child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.has_mixed_list_nesting(),
"Should detect mixed nesting in blockquotes"
);
}
#[test]
fn test_indent_explicit_uses_fixed_style() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
let rule = MD007ULIndent::from_config_struct(config);
let content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
);
let wrong_content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should flag text-aligned spacing when indent_explicit=true"
);
}
#[test]
fn test_explicit_style_overrides_indent_explicit() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: true, indent_explicit: true, };
let rule = MD007ULIndent::from_config_struct(config);
let content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Explicit text-aligned style should be respected, got: {result:?}"
);
}
#[test]
fn test_no_indent_explicit_uses_smart_detection() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: false, };
let rule = MD007ULIndent::from_config_struct(config);
let content = "* Level 0\n * Level 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Smart detection should accept 4-space indent, got: {result:?}"
);
}
#[test]
fn test_issue_273_exact_reproduction() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned, style_explicit: false,
indent_explicit: true, };
let rule = MD007ULIndent::from_config_struct(config);
let content = r#"* Item 1
* Item 2
* Item 3"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #273: indent=4 should use 4-space increments, got: {result:?}"
);
}
#[test]
fn test_indent_explicit_with_ordered_parent() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true, };
let rule = MD007ULIndent::from_config_struct(config);
let content = "1. Ordered\n * Bullet with 4-space indent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"4-space indent under ordered should pass with indent=4: {result:?}"
);
let content_3 = "1. Ordered\n * Bullet with 3-space indent";
let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"3-space indent under ordered should pass (text-aligned): {result:?}"
);
let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"2-space indent under ordered list should be flagged when indent=4: {result:?}"
);
}
#[test]
fn test_indent_explicit_mixed_list_deep_nesting() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content_text_aligned = r#"* Level 0
* Level 1 (4-space indent from bullet parent)
1. Level 2 ordered
* Level 3 bullet (text-aligned under ordered)"#;
let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Text-aligned nesting under ordered should pass: {result:?}"
);
let content_fixed = r#"* Level 0
* Level 1 (4-space indent from bullet parent)
1. Level 2 ordered
* Level 3 bullet (fixed indent under ordered)"#;
let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Fixed indent nesting under ordered should also pass: {result:?}"
);
}
#[test]
fn test_ordered_list_double_digit_markers() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "10. Double digit\n * Bullet at col 4";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Bullet under '10.' should align at column 4: {result:?}"
);
let content_3 = "1. Single digit\n * Bullet at col 3";
let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
);
let content_4 = "1. Single digit\n * Bullet at col 4";
let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
);
}
#[test]
fn test_indent_explicit_pure_unordered_uses_fixed() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Pure unordered with indent=4 should use 4-space increments: {result:?}"
);
let wrong_content = "* Level 0\n * Level 1\n * Level 2";
let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"2-space indent should be flagged when indent=4 is configured"
);
}
#[test]
fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
);
}
#[test]
fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
);
}
#[test]
fn test_standard_flavor_ordered_list_with_4_space_warns() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"4-space indent under ordered list should warn in Standard flavor"
);
}
#[test]
fn test_mkdocs_multi_digit_ordered_list() {
let rule = MD007ULIndent::default();
let content = "10. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
);
}
#[test]
fn test_mkdocs_triple_digit_ordered_list() {
let rule = MD007ULIndent::default();
let content = "100. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
);
}
#[test]
fn test_mkdocs_insufficient_indent_under_ordered() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"2-space indent under ordered list should warn in MkDocs flavor"
);
assert!(
result[0].message.contains("Expected 4"),
"Warning should expect 4 spaces (MkDocs minimum), got: {}",
result[0].message
);
}
#[test]
fn test_mkdocs_deeper_nesting_under_ordered() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - sub\n - subsub";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
);
}
#[test]
fn test_mkdocs_fix_adjusts_to_4_spaces() {
let rule = MD007ULIndent::default();
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "1. text\n\n - nested item",
"Fix should adjust indent to 4 spaces in MkDocs"
);
}
#[test]
fn test_mkdocs_start_indented_with_ordered_parent() {
let config = MD007Config {
start_indented: true,
..Default::default()
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "1. text\n\n - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
);
}
#[test]
fn test_mkdocs_ordered_at_nonzero_indent() {
let rule = MD007ULIndent::default();
let content = "- outer\n 1. inner\n - deep";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
);
}
#[test]
fn test_mkdocs_blockquoted_ordered_list() {
let rule = MD007ULIndent::default();
let content = "> 1. text\n>\n> - nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
);
}
#[test]
fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
let rule = MD007ULIndent::default();
let content = "- outer\n 1. inner\n - deep";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
);
}
#[test]
fn test_issue_504_indent4_ordered_parent() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = r#"# Things
+ An unordered list
+ An item with 4 spaces, ok.
1. A numbered list
+ A sublist with 4 spaces, not ok
+ A sub item with 4 spaces, ok
+ Why is rumdl expecting 3 spaces for a 4 space indent?
2. Item 2
3. Item 3"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
);
}
#[test]
fn test_indent2_explicit_with_ordered_parent() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(2),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "1. Ordered\n * Bullet at 3 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
);
let content_2 = "1. Ordered\n * Bullet at 2 spaces";
let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
);
}
#[test]
fn test_indent4_explicit_with_wide_ordered_parent() {
let config = MD007Config {
indent: crate::types::IndentSize::from_const(4),
start_indented: false,
start_indent: crate::types::IndentSize::from_const(2),
style: md007_config::IndentStyle::TextAligned,
style_explicit: false,
indent_explicit: true,
};
let rule = MD007ULIndent::from_config_struct(config);
let content = "100. Wide ordered\n * Bullet at 5 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"indent=4 under '100.' should accept 5-space indent: {result:?}"
);
let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"indent=4 under '100.' should accept 4-space indent: {result:?}"
);
}
}