use crate::utils::blockquote::effective_indent_in_blockquote;
use crate::utils::list_fix_guard::{
Allowed, drop_structure_changing_fixes, is_inside_literal_code, protect_literal_code,
};
use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
use crate::utils::range_utils::calculate_match_range;
use crate::lint_context::{ParsedListBlock, ParsedListBlocks, ParsedListItem};
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use std::cell::RefCell;
use std::collections::HashMap;
use toml;
type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
#[derive(Clone, Default)]
pub struct MD005ListIndent {
top_level_indent: usize,
}
struct LineCacheInfo<'a> {
indentation: Vec<usize>,
blockquote_levels: Vec<usize>,
line_contents: &'a [&'a str],
flags: Vec<u8>,
parent_map: HashMap<usize, usize>,
item_end: HashMap<usize, usize>,
decided: RefCell<HashMap<ScanKey, Option<Decision>>>,
first_item_at: RefCell<HashMap<(usize, usize, usize), Option<usize>>>,
}
type ScanKey = (usize, usize, usize, usize, usize);
type Decision = (usize, Option<usize>);
const FLAG_HAS_CONTENT: u8 = 1;
const FLAG_IS_LIST_ITEM: u8 = 2;
impl<'a> LineCacheInfo<'a> {
fn new(ctx: &'a crate::lint_context::LintContext<'_>) -> Self {
let total_lines = ctx.lines.len();
let mut indentation = Vec::with_capacity(total_lines);
let mut blockquote_levels = Vec::with_capacity(total_lines);
let line_contents = ctx.raw_lines();
let mut flags = Vec::with_capacity(total_lines);
let mut parent_map = HashMap::new();
let mut item_end = HashMap::new();
let mut indent_stack: Vec<(usize, usize)> = Vec::new();
for (idx, line_info) in ctx.lines.iter().enumerate() {
let line_content = line_info.content(ctx.content);
let content = line_content.trim_start();
let line_indent = line_info.byte_len - content.len();
indentation.push(line_indent);
let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
blockquote_levels.push(bq_level);
let mut flag = 0u8;
if !content.is_empty() {
flag |= FLAG_HAS_CONTENT;
}
if let Some(list_item) = ctx
.list_item_on_line(idx + 1)
.filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()))
{
flag |= FLAG_IS_LIST_ITEM;
let line_num = idx + 1; let marker_column = list_item.marker_column();
while let Some(&(indent, popped_line)) = indent_stack.last() {
if indent < marker_column {
break;
}
item_end.insert(popped_line, line_num);
indent_stack.pop();
}
if let Some((_, parent_line)) = indent_stack.last() {
parent_map.insert(line_num, *parent_line);
}
indent_stack.push((marker_column, line_num));
}
flags.push(flag);
}
Self {
indentation,
blockquote_levels,
line_contents,
flags,
parent_map,
item_end,
decided: RefCell::default(),
first_item_at: RefCell::default(),
}
}
fn has_content(&self, idx: usize) -> bool {
self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
}
fn is_list_item(&self, idx: usize) -> bool {
self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
}
fn blockquote_info(&self, line: usize) -> (usize, usize) {
if line == 0 || line > self.line_contents.len() {
return (0, 0);
}
let idx = line - 1;
let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
if bq_level == 0 {
return (0, 0);
}
let content = &self.line_contents[idx];
let mut prefix_len = 0;
let mut found = 0;
for c in content.chars() {
prefix_len += c.len_utf8();
if c == '>' {
found += 1;
if found == bq_level {
if content.get(prefix_len..prefix_len + 1) == Some(" ") {
prefix_len += 1;
}
break;
}
}
}
(bq_level, prefix_len)
}
fn scan_continuation(
&self,
start_line: usize,
end_line: usize,
tight_threshold: usize,
loose_threshold: usize,
parent_bq_level: usize,
parent_bq_prefix_len: usize,
) -> Option<Decision> {
if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
return None;
}
let adjust = |t: usize| {
if parent_bq_level > 0 {
t.saturating_sub(parent_bq_prefix_len)
} else {
t
}
};
let tight = adjust(tight_threshold);
let loose = adjust(loose_threshold);
let mut seen_blank = false;
for idx in start_line - 1..end_line {
if !self.has_content(idx) {
seen_blank = true;
continue;
}
if self.is_list_item(idx) {
continue;
}
let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
let raw_indent = self.indentation[idx];
let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
effective_indent_in_blockquote(self.line_contents[idx], parent_bq_level, raw_indent)
} else {
raw_indent
};
let threshold = if seen_blank { loose } else { tight };
if effective_indent >= threshold {
return Some((idx + 1, Some(effective_indent)));
}
if seen_blank {
return Some((idx + 1, None));
}
}
None
}
fn continuation_decided(
&self,
parent_line: usize,
(tight_threshold, loose_threshold): (usize, usize),
(parent_bq_level, parent_bq_prefix_len): (usize, usize),
) -> Option<Decision> {
let key = (
parent_line,
tight_threshold,
loose_threshold,
parent_bq_level,
parent_bq_prefix_len,
);
if let Some(&decision) = self.decided.borrow().get(&key) {
return decision;
}
let last_owned = self
.item_end
.get(&parent_line)
.map_or(self.indentation.len(), |&end| end - 1);
let decision = self.scan_continuation(
parent_line + 1,
last_owned,
tight_threshold,
loose_threshold,
parent_bq_level,
parent_bq_prefix_len,
);
self.decided.borrow_mut().insert(key, decision);
decision
}
fn continuation_before(
&self,
parent_line: usize,
before_line: usize,
thresholds: (usize, usize),
parent_bq: (usize, usize),
) -> Option<usize> {
match self.continuation_decided(parent_line, thresholds, parent_bq) {
Some((decided_at, indent)) if decided_at < before_line => indent,
_ => None,
}
}
fn first_item_at_column(
&self,
ctx: &crate::lint_context::LintContext,
parent_line: usize,
from_line: usize,
marker_column: usize,
) -> Option<usize> {
let key = (parent_line, from_line, marker_column);
if let Some(&line) = self.first_item_at.borrow().get(&key) {
return line;
}
let end = self
.item_end
.get(&parent_line)
.copied()
.unwrap_or(self.indentation.len() + 1);
let line = (from_line..end).find(|&line_num| {
self.is_list_item(line_num - 1)
&& ctx
.list_item_on_line(line_num)
.is_some_and(|item| item.marker_column() == marker_column)
});
self.first_item_at.borrow_mut().insert(key, line);
line
}
}
impl MD005ListIndent {
const LIST_GROUP_GAP_TOLERANCE: usize = 2;
const MIN_CHILD_INDENT_INCREASE: usize = 2;
const SAME_LEVEL_TOLERANCE: i32 = 1;
const STANDARD_CONTINUATION_OFFSET: usize = 2;
fn create_indent_warning(
&self,
ctx: &crate::lint_context::LintContext,
line_num: usize,
line_info: &crate::lint_context::LineInfo,
actual_indent: usize,
expected_indent: usize,
) -> LintWarning {
let message = format!(
"Expected indentation of {} {}, found {}",
expected_indent,
if expected_indent == 1 { "space" } else { "spaces" },
actual_indent
);
let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
} else {
calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
};
let (fix_range, replacement) = if line_info.blockquote.is_some() {
let start_byte = line_info.byte_offset;
let mut end_byte = line_info.byte_offset;
let marker_column = ctx
.list_item_on_line(line_num)
.map_or(actual_indent, ParsedListItem::marker_column);
for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
if i >= marker_column {
break;
}
end_byte += ch.len_utf8();
}
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()
};
let correct_indent = " ".repeat(expected_indent);
let replacement = format!("{blockquote_prefix} {correct_indent}");
(start_byte..end_byte, replacement)
} else {
let fix_range = if actual_indent > 0 {
let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
let end_byte = start_byte + actual_indent;
start_byte..end_byte
} else {
let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
byte_pos..byte_pos
};
let replacement = if expected_indent > 0 {
" ".repeat(expected_indent)
} else {
String::new()
};
(fix_range, replacement)
};
LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message,
severity: Severity::Warning,
fix: Some(Fix::new(fix_range, replacement)),
}
}
fn check_indent_consistency(
&self,
ctx: &crate::lint_context::LintContext,
items: &[(usize, usize, &crate::lint_context::LineInfo)],
warnings: &mut Vec<(LintWarning, isize)>,
) {
if items.len() < 2 {
return;
}
let mut sorted_items: Vec<_> = items.iter().collect();
sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
if indents.len() > 1 {
let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
for (line_num, indent, line_info) in items {
if *indent != expected_indent {
warnings.push((
self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
expected_indent as isize - *indent as isize,
));
}
}
}
}
fn group_by_parent_content_column<'a>(
&self,
level: usize,
group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
level_map: &HashMap<usize, usize>,
) -> ParentContentGroups<'a> {
let parent_level = level - 1;
let is_ordered_map: HashMap<usize, bool> = all_list_items
.iter()
.map(|(ln, _, _, item)| (*ln, item.is_ordered()))
.collect();
let parent_items: Vec<(usize, usize)> = all_list_items
.iter()
.filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
.map(|(ln, _, _, item)| (*ln, item.content_column()))
.collect();
let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
for (line_num, indent, line_info) in group {
let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
if let Some(parent_col) = parent_content_col {
parent_content_groups
.entry((parent_col, item_is_ordered))
.or_default()
.push((*line_num, *indent, *line_info));
}
}
parent_content_groups
}
fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
let mut blocks = list_blocks.into_iter();
let Some(first_block) = blocks.next() else {
return Vec::new();
};
let mut groups = Vec::new();
let mut current_group = vec![first_block];
let mut prev_block = first_block;
for current_block in blocks {
let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
let same_quote = current_block.blockquote_prefix().matches('>').count()
== prev_block.blockquote_prefix().matches('>').count();
if same_quote && line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
current_group.push(current_block);
} else {
groups.push(current_group);
current_group = vec![current_block];
}
prev_block = current_block;
}
groups.push(current_group);
groups
}
fn is_continuation_content(
&self,
ctx: &crate::lint_context::LintContext,
cache: &LineCacheInfo,
list_line: usize,
list_indent: usize,
) -> bool {
let parent_line = cache.parent_map.get(&list_line).copied();
if let Some(parent_line) = parent_line
&& let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
{
let line_info = parent_list_item.line_info();
let parent_marker_column = parent_list_item.marker_column();
let parent_content_column = parent_list_item.content_column();
let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
let thresholds = (parent_marker_column + 1, parent_content_column);
let continuation_indent = cache.continuation_before(
parent_line,
list_line,
thresholds,
(parent_bq_level, parent_bq_prefix_len),
);
if let Some(continuation_indent) = continuation_indent {
let is_standard_continuation =
list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
let matches_content_indent = list_indent == continuation_indent;
if matches_content_indent || is_standard_continuation {
return true;
}
}
if list_indent > parent_marker_column {
if self.has_continuation_list_at_indent(ctx, cache, parent_line, list_line, list_indent, thresholds) {
return true;
}
let parent_bq = cache.blockquote_info(parent_line);
if cache
.continuation_before(parent_line, list_line, thresholds, parent_bq)
.is_some()
{
return true;
}
}
}
false
}
fn has_continuation_list_at_indent(
&self,
ctx: &crate::lint_context::LintContext,
cache: &LineCacheInfo,
parent_line: usize,
current_line: usize,
list_indent: usize,
thresholds: (usize, usize),
) -> bool {
let parent_bq = cache.blockquote_info(parent_line);
match cache.continuation_decided(parent_line, thresholds, parent_bq) {
Some((decided_at, Some(_))) => cache
.first_item_at_column(ctx, parent_line, decided_at + 1, list_indent)
.is_some_and(|line_num| line_num < current_line),
_ => false,
}
}
fn check_list_block_group(
&self,
ctx: &crate::lint_context::LintContext,
cache: &LineCacheInfo,
group: &[ParsedListBlock<'_>],
warnings: &mut Vec<(LintWarning, isize)>,
) {
let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
for list_block in group {
for list_item in list_block.items() {
if is_inside_literal_code(ctx, list_item.marker_byte_offset()) {
continue;
}
let item_line = list_item.line_num();
let line_info = list_item.line_info();
let effective_indent = if let Some(blockquote) = &line_info.blockquote {
list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
} else {
list_item.marker_column()
};
candidate_items.push((item_line, effective_indent, line_info, list_item));
}
}
candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
for (item_line, effective_indent, line_info, list_item) in candidate_items {
if line_info.in_footnote_definition {
skipped_lines.insert(item_line);
continue;
}
if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
skipped_lines.insert(item_line);
continue;
}
if let Some(&parent_line) = cache.parent_map.get(&item_line)
&& skipped_lines.contains(&parent_line)
{
skipped_lines.insert(item_line);
continue;
}
all_list_items.push((item_line, effective_indent, line_info, list_item));
}
if all_list_items.is_empty() {
return;
}
all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
let mut level_map: HashMap<usize, usize> = HashMap::new();
let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new();
let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
for (line_num, indent, _, _) in &all_list_items {
let level = if indent_to_level.is_empty() {
level_indents.entry(1).or_default().push(*indent);
1
} else {
let mut determined_level = 0;
if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
determined_level = existing_level;
} else {
let mut best_parent: Option<(usize, usize, usize)> = None;
for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
if tracked_indent < *indent {
if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
best_parent = Some((tracked_indent, tracked_level, tracked_line));
}
}
}
if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
determined_level = parent_level + 1;
} else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
determined_level = parent_level;
} else {
let mut found_similar = false;
if let Some(indents_at_level) = level_indents.get(&parent_level) {
for &level_indent in indents_at_level {
if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
determined_level = parent_level;
found_similar = true;
break;
}
}
}
if !found_similar {
determined_level = parent_level + 1;
}
}
}
if determined_level == 0 {
determined_level = 1;
}
level_indents.entry(determined_level).or_default().push(*indent);
}
determined_level
};
level_map.insert(*line_num, level);
indent_to_level.insert(*indent, (level, *line_num));
}
let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
for (line_num, indent, line_info, _) in &all_list_items {
let level = level_map[line_num];
level_groups
.entry(level)
.or_default()
.push((*line_num, *indent, *line_info));
}
for (level, mut group) in level_groups {
group.sort_by_key(|(line_num, _, _)| *line_num);
if level == 1 {
for (line_num, indent, line_info) in &group {
if *indent != self.top_level_indent {
warnings.push((
self.create_indent_warning(ctx, *line_num, line_info, *indent, self.top_level_indent),
self.top_level_indent as isize - *indent as isize,
));
}
}
} else {
let parent_content_groups =
self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
for items in parent_content_groups.values() {
self.check_indent_consistency(ctx, items, warnings);
}
}
}
}
fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
let content = ctx.content;
if content.is_empty() {
return Vec::new();
}
let list_blocks = ctx.parsed_list_blocks();
if list_blocks.is_empty() {
return Vec::new();
}
let mut warnings = Vec::new();
let cache = LineCacheInfo::new(ctx);
let block_groups = self.group_related_list_blocks(list_blocks);
for group in block_groups {
self.check_list_block_group(ctx, &cache, &group, &mut warnings);
}
let moves = warnings
.iter()
.enumerate()
.filter(|(_, (_, delta))| *delta != 0)
.map(|(index, (warning, delta))| (warning.line - 1, (index, *delta)))
.collect();
let mut warnings: Vec<LintWarning> = warnings.into_iter().map(|(warning, _)| warning).collect();
move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
warnings
}
}
impl Rule for MD005ListIndent {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
(self.top_level_indent == 0).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(5))
}
fn name(&self) -> &'static str {
"MD005"
}
fn description(&self) -> &'static str {
"List indentation should be consistent"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = self.check_optimized(ctx);
protect_literal_code(ctx, &mut warnings);
drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
Ok(warnings)
}
fn fixes_from_diagnostics(&self) -> bool {
true
}
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());
}
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
}
fn category(&self) -> RuleCategory {
RuleCategory::List
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.has_list_items()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
None
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let mut top_level_indent = 0;
if let Some(md007_config) = config.rules.get("MD007") {
if let Some(start_indented) = md007_config.values.get("start-indented")
&& let Some(start_indented_bool) = start_indented.as_bool()
&& start_indented_bool
{
if let Some(start_indent) = md007_config.values.get("start-indent") {
if let Some(indent_value) = start_indent.as_integer() {
top_level_indent = indent_value as usize;
}
} else {
top_level_indent = 2;
}
}
}
Box::new(MD005ListIndent { top_level_indent })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_valid_unordered_list() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Item 2
* Nested 1
* Nested 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_valid_ordered_list() {
let rule = MD005ListIndent::default();
let content = "\
1. Item 1
2. Item 2
1. Nested 1
2. Nested 2
3. 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_invalid_unordered_indent() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Item 2
* Nested 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
}
#[test]
fn test_invalid_ordered_indent() {
let rule = MD005ListIndent::default();
let content = "\
1. Item 1
2. Item 2
1. Nested 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
}
#[test]
fn test_mixed_list_types() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
1. Nested ordered
* Nested unordered
* Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_multiple_levels() {
let rule = MD005ListIndent::default();
let content = "\
* Level 1
* Level 2
* Level 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
}
#[test]
fn test_empty_lines() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Nested 1
* Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_no_lists() {
let rule = MD005ListIndent::default();
let content = "\
Just some text
More text
Even more text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_complex_nesting() {
let rule = MD005ListIndent::default();
let content = "\
* Level 1
* Level 2
* Level 3
* Back to 2
1. Ordered 3
2. Still 3
* Back to 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_invalid_complex_nesting() {
let rule = MD005ListIndent::default();
let content = "\
* Level 1
* Level 2
* Level 3
* Back to 2
1. Ordered 3
2. Still 3
* Back to 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("Expected indentation of 5 spaces, found 6")
|| result[0].message.contains("Expected indentation of 6 spaces, found 5")
);
}
#[test]
fn test_with_lint_context() {
let rule = MD005ListIndent::default();
let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty()); }
#[test]
fn test_list_with_continuations() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
This is a continuation
of the first item
* Nested item
with its own continuation
* Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_list_after_a_quoted_list_is_judged_on_its_own() {
let rule = MD005ListIndent::default();
let content = "> * item 49\n + item 50\n + item 52\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(lines, vec![1, 2]);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> * item 49\n+ item 50\n + item 52\n");
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
}
#[test]
fn test_list_in_blockquote() {
let rule = MD005ListIndent::default();
let content = "\
> * Item 1
> * Nested 1
> * Nested 2
> * Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for correctly indented blockquote list, got: {result:?}"
);
}
#[test]
fn test_list_with_code_blocks() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
```
code block
```
* Nested item
* Item 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_list_with_tabs() {
let rule = MD005ListIndent::default();
let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_inconsistent_at_same_level() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Nested 1
* Nested 2
* Wrong indent for same level
* Nested 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
assert!(result.iter().any(|w| w.line == 4));
}
#[test]
fn test_zero_indent_top_level() {
let rule = MD005ListIndent::default();
let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty());
assert!(result.iter().any(|w| w.line == 1));
}
#[test]
fn test_fix_preserves_content() {
let rule = MD005ListIndent::default();
let content = "\
* Item with **bold** and *italic*
* Wrong indent with `code`
* Also wrong with [link](url)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("**bold**"));
assert!(fixed.contains("*italic*"));
assert!(fixed.contains("`code`"));
assert!(fixed.contains("[link](url)"));
}
#[test]
fn test_deeply_nested_lists() {
let rule = MD005ListIndent::default();
let content = "\
* 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());
}
#[test]
fn test_fix_multiple_issues() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Wrong 1
* Wrong 2
* Wrong 3
* Correct";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "* Item 1\n* Wrong 1\n * Wrong 2\n * Wrong 3\n* Correct");
let content = format!("{content}\n * Wrong 4");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.check(&ctx).unwrap().is_empty());
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
#[test]
fn test_performance_large_document() {
let rule = MD005ListIndent::default();
let mut content = String::new();
for i in 0..100 {
content.push_str(&format!("* Item {i}\n"));
content.push_str(&format!(" * Nested {i}\n"));
}
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_column_positions() {
let rule = MD005ListIndent::default();
let content = " * Wrong indent";
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].column, 1, "Expected column 1, got {}", result[0].column);
assert_eq!(
result[0].end_column, 2,
"Expected end_column 2, got {}",
result[0].end_column
);
}
#[test]
fn test_should_skip() {
let rule = MD005ListIndent::default();
let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx));
let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx));
let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx));
let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx));
}
#[test]
fn test_should_skip_validation() {
let rule = MD005ListIndent::default();
let content = "* List item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx));
let content = "No lists here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx));
}
#[test]
fn test_edge_case_single_space_indent() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Single space - wrong
* Two spaces - correct";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
}
#[test]
fn test_edge_case_three_space_indent() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Three spaces - first establishes pattern
* Two spaces - inconsistent with established pattern";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
}
#[test]
fn test_nested_bullets_under_numbered_items() {
let rule = MD005ListIndent::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
- Verification of project account presence and changes";
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_nested_bullets_under_numbered_items_wrong_indent() {
let rule = MD005ListIndent::default();
let content = "\
1. **Active Directory/LDAP**
- Wrong: only 2 spaces
- Correct: 3 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning, got {}. Warnings: {:?}",
result.len(),
result
);
assert!(
result
.iter()
.any(|w| (w.line == 2 && w.message.contains("found 2"))
|| (w.line == 3 && w.message.contains("found 3")))
);
}
#[test]
fn test_regular_nested_bullets_still_work() {
let rule = MD005ListIndent::default();
let content = "\
* Top level
* Second level (2 spaces is correct for bullets under bullets)
* Third level (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 regular bullet nesting, got: {result:?}"
);
}
#[test]
fn test_fix_range_accuracy() {
let rule = MD005ListIndent::default();
let content = " * Wrong indent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fix = result[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, "");
}
#[test]
fn test_four_space_indent_pattern() {
let rule = MD005ListIndent::default();
let content = "\
* Item 1
* Item 2 with 4 spaces
* Item 3 with 8 spaces
* Item 4 with 4 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD005 should accept consistent 4-space indentation pattern, got {} warnings",
result.len()
);
}
#[test]
fn test_issue_64_scenario() {
let rule = MD005ListIndent::default();
let content = "\
* Top level item
* Sub item with 4 spaces (as configured in MD007)
* Nested sub item with 8 spaces
* Another sub item with 4 spaces
* Another top level";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
result.len()
);
}
#[test]
fn test_continuation_content_scenario() {
let rule = MD005ListIndent::default();
let content = "\
- **Changes to how the Python version is inferred** ([#16319](example))
In previous versions of Ruff, you could specify your Python version with:
- The `target-version` option in a `ruff.toml` file
- The `project.requires-python` field in a `pyproject.toml` file";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD005 should not flag continuation content lists, got {} warnings: {:?}",
result.len(),
result
);
}
#[test]
fn test_multiple_continuation_lists_scenario() {
let rule = MD005ListIndent::default();
let content = "\
- **Changes to how the Python version is inferred** ([#16319](example))
In previous versions of Ruff, you could specify your Python version with:
- The `target-version` option in a `ruff.toml` file
- The `project.requires-python` field in a `pyproject.toml` file
In v0.10, config discovery has been updated to address this issue:
- If Ruff finds a `ruff.toml` file without a `target-version`, it will check
- If Ruff finds a user-level configuration, the `requires-python` field will take precedence
- If there is no config file, Ruff will search for the closest `pyproject.toml`";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD005 should not flag continuation content lists, got {} warnings: {:?}",
result.len(),
result
);
}
#[test]
fn test_issue_115_sublist_after_code_block() {
let rule = MD005ListIndent::default();
let content = "\
1. List item 1
```rust
fn foo() {}
```
Sublist:
- A
- B
";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
result.len(),
result
);
}
#[test]
fn test_edge_case_continuation_at_exact_boundary() {
let rule = MD005ListIndent::default();
let content = "\
* Item (content at column 2)
Text at column 2 (exact boundary - continuation)
* Sub at column 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
);
}
#[test]
fn test_edge_case_unicode_in_continuation() {
let rule = MD005ListIndent::default();
let content = "\
* Parent
Text with emoji 😀 and Unicode ñ characters
* Sub-list should still work";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings with Unicode in continuation content, got: {result:?}"
);
}
#[test]
fn test_edge_case_large_empty_line_gap() {
let rule = MD005ListIndent::default();
let content = "\
* Parent at line 1
Continuation text
More continuation after many empty lines
* Child after gap
* Another child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings with large gaps in continuation content, got: {result:?}"
);
}
#[test]
fn consecutive_blank_continuations_preserve_line_endings_and_quotes() {
let rule = MD005ListIndent::default();
for content in ["* a\n b\n\n\n\n c\n", "> * a\n> b\n>\n>\n>\n> c\n"] {
for ending in ["\n", "\r\n"] {
let source = content.replace('\n', ending);
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
}
}
}
#[test]
fn test_edge_case_multiple_continuation_blocks_varying_indent() {
let rule = MD005ListIndent::default();
let content = "\
* Parent (content at column 2)
First paragraph at column 2
Indented quote at column 4
Back to column 2
* Sub-list at column 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings with varying continuation indent, got: {result:?}"
);
}
#[test]
fn test_edge_case_deep_nesting_no_continuation() {
let rule = MD005ListIndent::default();
let content = "\
* Parent
* Immediate child (no continuation text before)
* Grandchild
* Great-grandchild
* Great-great-grandchild
* Another child at level 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for deep nesting without continuation, got: {result:?}"
);
}
#[test]
fn test_edge_case_blockquote_continuation_content() {
let rule = MD005ListIndent::default();
let content = "\
> * Parent in blockquote
> Continuation in blockquote
> * Sub-list in blockquote
> * Another sub-list";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for blockquote continuation, got: {result:?}"
);
}
#[test]
fn test_edge_case_one_space_less_than_content_column() {
let rule = MD005ListIndent::default();
let content = "\
* Parent (content at column 2)
Text at column 1 (one less than content_column - NOT continuation)
* Child";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty() || !result.is_empty(),
"Test should complete without panic"
);
}
#[test]
fn test_edge_case_multiple_code_blocks_different_indentation() {
let rule = MD005ListIndent::default();
let content = "\
* Parent
```
code at 2 spaces
```
```
code at 4 spaces
```
* Sub-list should not be confused";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings with multiple code blocks, got: {result:?}"
);
}
#[test]
fn test_performance_very_large_document() {
let rule = MD005ListIndent::default();
let mut content = String::new();
for i in 0..1000 {
content.push_str(&format!("* Item {i}\n"));
content.push_str(&format!(" * Nested {i}\n"));
if i % 10 == 0 {
content.push_str(" Some continuation text\n");
}
}
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let start = std::time::Instant::now();
let result = rule.check(&ctx).unwrap();
let elapsed = start.elapsed();
assert!(result.is_empty());
println!("Processed 1000 list items in {elapsed:?}");
assert!(
elapsed.as_secs() < 1,
"Should complete in under 1 second, took {elapsed:?}"
);
}
#[test]
fn test_ordered_list_variable_marker_width() {
let rule = MD005ListIndent::default();
let content = "\
1. One
- One
- Two
2. Two
- One
3. Three
- One
4. Four
- One
5. Five
- One
6. Six
- One
7. Seven
- One
8. Eight
- One
9. Nine
- One
10. Ten
- One";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for ordered list with variable marker widths, got: {result:?}"
);
}
#[test]
fn test_ordered_list_inconsistent_siblings() {
let rule = MD005ListIndent::default();
let content = "\
1. Item one
- First sublist at 3 spaces
- Second sublist at 2 spaces (inconsistent)
- Third sublist at 3 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning for inconsistent sibling indent, got: {result:?}"
);
assert!(result[0].message.contains("Expected indentation of 3"));
}
#[test]
fn test_ordered_list_single_sublist_no_warning() {
let rule = MD005ListIndent::default();
let content = "\
10. Item ten
- Only sublist at 3 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 single sublist item, got: {result:?}"
);
}
#[test]
fn test_sublists_grouped_by_parent_content_column() {
let rule = MD005ListIndent::default();
let content = "\
9. Item nine
- First sublist at 3 spaces
- Second sublist at 3 spaces
- Third sublist at 3 spaces
10. Item ten
- First sublist at 4 spaces
- Second sublist at 4 spaces
- Third sublist at 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 sublists grouped by parent, got: {result:?}"
);
}
#[test]
fn test_inconsistent_indent_within_parent_group() {
let rule = MD005ListIndent::default();
let content = "\
10. Item ten
- First sublist at 4 spaces
- Second sublist at 3 spaces (inconsistent!)
- Third sublist at 4 spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
);
assert!(result[0].line == 3);
assert!(result[0].message.contains("Expected indentation of 4"));
}
#[test]
fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
use crate::rule::Rule;
let rule = MD005ListIndent::default();
let content = "> * Federation sender blacklists are now persisted.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
assert!(result[0].fix.is_some(), "Should have a fix");
let fixed = rule.fix(&ctx).expect("Fix should succeed");
assert!(
fixed.starts_with("> "),
"Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
);
assert!(
!fixed.starts_with("* "),
"Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
);
assert_eq!(
fixed.trim(),
"> * Federation sender blacklists are now persisted.",
"Fixed content should be '> * Federation sender...' with single space after >"
);
}
#[test]
fn test_nested_blockquote_list_fix_preserves_prefix() {
use crate::rule::Rule;
let rule = MD005ListIndent::default();
let content = ">> * Nested blockquote list item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
let fixed = rule.fix(&ctx).expect("Fix should succeed");
assert_eq!(fixed, "> > * Nested blockquote list item");
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
}
}