use crate::lint_context::LintContext;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
#[derive(Clone, Default)]
pub struct MD077ListContinuationIndent;
impl MD077ListContinuationIndent {
fn is_block_level_construct(trimmed: &str) -> bool {
if trimmed.starts_with("[^") && trimmed.contains("]:") {
return true;
}
if trimmed.starts_with("*[") && trimmed.contains("]:") {
return true;
}
if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
return true;
}
false
}
fn is_code_fence(trimmed: &str) -> bool {
let bytes = trimmed.as_bytes();
if bytes.len() < 3 {
return false;
}
let ch = bytes[0];
(ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
}
fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
if info.in_code_block && !Self::is_code_fence(trimmed) {
return true;
}
info.in_front_matter
|| info.in_html_block
|| info.in_html_comment
|| info.in_mkdocstrings
|| info.in_esm_block
|| info.in_math_block
|| info.in_admonition
|| info.in_content_tab
|| info.in_pymdown_block
|| info.in_definition_list
|| info.in_mkdocs_html_markdown
|| info.in_kramdown_extension_block
}
}
impl Rule for MD077ListContinuationIndent {
fn name(&self) -> &'static str {
"MD077"
}
fn description(&self) -> &'static str {
"List continuation content indentation"
}
fn check(&self, ctx: &LintContext) -> LintResult {
if ctx.content.is_empty() {
return Ok(Vec::new());
}
let strict_indent = ctx.flavor.requires_strict_list_indent();
let total_lines = ctx.lines.len();
let mut warnings = Vec::new();
let mut flagged_lines = std::collections::HashSet::new();
let mut items: Vec<(usize, usize, usize)> = Vec::new(); for block in &ctx.list_blocks {
for &item_line in &block.item_lines {
if let Some(info) = ctx.line_info(item_line)
&& let Some(ref li) = info.list_item
{
items.push((item_line, li.marker_column, li.content_column));
}
}
}
items.sort_unstable();
items.dedup_by_key(|&mut (ln, _, _)| ln);
for (item_idx, &(item_line, marker_col, content_col)) in items.iter().enumerate() {
let required = if strict_indent { content_col.max(4) } else { content_col };
let range_end = items
.iter()
.skip(item_idx + 1)
.find(|&&(_, mc, _)| mc <= marker_col)
.map(|&(ln, _, _)| ln - 1)
.unwrap_or(total_lines);
let mut saw_blank = false;
for line_num in (item_line + 1)..=range_end {
let Some(line_info) = ctx.line_info(line_num) else {
continue;
};
let trimmed = line_info.content(ctx.content).trim_start();
if Self::should_skip_line(line_info, trimmed) {
continue;
}
if line_info.is_blank {
saw_blank = true;
continue;
}
if line_info.list_item.is_some() {
saw_blank = false;
continue;
}
if line_info.heading.is_some() {
break;
}
if line_info.is_horizontal_rule {
break;
}
if Self::is_block_level_construct(trimmed) {
continue;
}
if !saw_blank {
continue;
}
let actual = line_info.visual_indent;
if actual <= marker_col {
break;
}
if actual < required && flagged_lines.insert(line_num) {
let line_content = line_info.content(ctx.content);
let message = if strict_indent {
format!(
"Content inside list item needs {required} spaces of indentation \
for MkDocs compatibility (found {actual})",
)
} else {
format!(
"Content after blank line in list item needs {required} spaces of \
indentation to remain part of the list (found {actual})",
)
};
let fix_start = line_info.byte_offset;
let fix_end = fix_start + line_info.indent;
warnings.push(LintWarning {
rule_name: Some("MD077".to_string()),
line: line_num,
column: 1,
end_line: line_num,
end_column: line_content.len() + 1,
message,
severity: Severity::Warning,
fix: Some(Fix {
range: fix_start..fix_end,
replacement: " ".repeat(required),
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &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<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
let mut content = ctx.content.to_string();
for fix in fixes {
if fix.range.start <= content.len() && fix.range.end <= content.len() {
content.replace_range(fix.range, &fix.replacement);
}
}
Ok(content)
}
fn category(&self) -> RuleCategory {
RuleCategory::List
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || ctx.list_blocks.is_empty()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
fn check(content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD077ListContinuationIndent;
rule.check(&ctx).unwrap()
}
fn check_mkdocs(content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = MD077ListContinuationIndent;
rule.check(&ctx).unwrap()
}
fn fix(content: &str) -> String {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD077ListContinuationIndent;
rule.fix(&ctx).unwrap()
}
fn fix_mkdocs(content: &str) -> String {
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = MD077ListContinuationIndent;
rule.fix(&ctx).unwrap()
}
#[test]
fn lazy_continuation_not_flagged() {
let content = "- Item\ncontinuation\n";
assert!(check(content).is_empty());
}
#[test]
fn unordered_correct_indent_no_warning() {
let content = "- Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn unordered_partial_indent_warns() {
let content = "- Item\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 3);
assert!(warnings[0].message.contains("2 spaces"));
assert!(warnings[0].message.contains("found 1"));
}
#[test]
fn unordered_zero_indent_is_new_paragraph() {
let content = "- Item\n\ncontinuation\n";
assert!(check(content).is_empty());
}
#[test]
fn ordered_3space_correct_commonmark() {
let content = "1. Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn ordered_2space_under_indent_commonmark() {
let content = "1. Item\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("3 spaces"));
assert!(warnings[0].message.contains("found 2"));
}
#[test]
fn multi_digit_marker_correct() {
let content = "10. Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn multi_digit_marker_under_indent() {
let content = "10. Item\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("4 spaces"));
}
#[test]
fn mkdocs_3space_ordered_warns() {
let content = "1. Item\n\n continuation\n";
let warnings = check_mkdocs(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("4 spaces"));
assert!(warnings[0].message.contains("MkDocs"));
}
#[test]
fn mkdocs_4space_ordered_no_warning() {
let content = "1. Item\n\n continuation\n";
assert!(check_mkdocs(content).is_empty());
}
#[test]
fn mkdocs_unordered_2space_ok() {
let content = "- Item\n\n continuation\n";
assert!(check_mkdocs(content).is_empty());
}
#[test]
fn mkdocs_unordered_2space_warns() {
let content = "- Item\n\n continuation\n";
let warnings = check_mkdocs(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("4 spaces"));
}
#[test]
fn fix_unordered_indent() {
let content = "- Item\n\n continuation\n";
let fixed = fix(content);
assert_eq!(fixed, "- Item\n\n continuation\n");
}
#[test]
fn fix_ordered_indent() {
let content = "1. Item\n\n continuation\n";
let fixed = fix(content);
assert_eq!(fixed, "1. Item\n\n continuation\n");
}
#[test]
fn fix_mkdocs_indent() {
let content = "1. Item\n\n continuation\n";
let fixed = fix_mkdocs(content);
assert_eq!(fixed, "1. Item\n\n continuation\n");
}
#[test]
fn nested_list_items_not_flagged() {
let content = "- Parent\n\n - Child\n";
assert!(check(content).is_empty());
}
#[test]
fn nested_list_zero_indent_is_new_paragraph() {
let content = "- Parent\n - Child\n\ncontinuation of parent\n";
assert!(check(content).is_empty());
}
#[test]
fn nested_list_partial_indent_flagged() {
let content = "- Parent\n - Child\n\n continuation of parent\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("2 spaces"));
}
#[test]
fn code_block_correctly_indented_no_warning() {
let content = "- Item\n\n ```\n code\n ```\n";
assert!(check(content).is_empty());
}
#[test]
fn code_fence_under_indented_warns() {
let content = "- Item\n\n ```\n code\n ```\n";
let warnings = check(content);
assert_eq!(warnings.len(), 2);
}
#[test]
fn code_fence_under_indented_ordered_mkdocs() {
let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
assert_eq!(warnings.len(), 2); assert!(warnings[0].message.contains("4 spaces"));
assert!(warnings[0].message.contains("MkDocs"));
}
#[test]
fn code_fence_tilde_under_indented() {
let content = "- Item\n\n ~~~\n code\n ~~~\n";
let warnings = check(content);
assert_eq!(warnings.len(), 2); }
#[test]
fn multiple_blank_lines_zero_indent_is_new_paragraph() {
let content = "- Item\n\n\ncontinuation\n";
assert!(check(content).is_empty());
}
#[test]
fn multiple_blank_lines_partial_indent_flags() {
let content = "- Item\n\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
}
#[test]
fn empty_item_no_warning() {
let content = "- \n- Second\n";
assert!(check(content).is_empty());
}
#[test]
fn multiple_items_mixed_indent() {
let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 7);
}
#[test]
fn task_list_correct_indent() {
let content = "- [ ] Task\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn frontmatter_not_flagged() {
let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn fix_multiple_items() {
let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
let fixed = fix(content);
assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
}
#[test]
fn fix_multiline_loose_continuation_all_lines() {
let content = "1. Item\n\n line one\n line two\n line three\n";
let fixed = fix(content);
assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
}
#[test]
fn sibling_item_boundary_respected() {
let content = "- First\n- Second\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn blockquote_list_correct_indent_no_warning() {
let content = "> - Item\n>\n> continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn blockquote_list_under_indent_no_false_positive() {
let content = "> - Item\n>\n> continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn deeply_nested_correct_indent() {
let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
assert!(check(content).is_empty());
}
#[test]
fn deeply_nested_under_indent() {
let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("6 spaces"));
assert!(warnings[0].message.contains("found 5"));
}
#[test]
fn tab_indent_correct() {
let content = "- Item\n\n\tcontinuation\n";
assert!(check(content).is_empty());
}
#[test]
fn multiple_continuations_correct() {
let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
assert!(check(content).is_empty());
}
#[test]
fn multiple_continuations_second_under_indent() {
let content = "- Item\n\n para 1\n\n continuation 2\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 5);
}
#[test]
fn ordered_paren_marker_correct() {
let content = "1) Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn ordered_paren_marker_under_indent() {
let content = "1) Item\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("3 spaces"));
}
#[test]
fn star_marker_correct() {
let content = "* Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn star_marker_under_indent() {
let content = "* Item\n\n continuation\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
}
#[test]
fn plus_marker_correct() {
let content = "+ Item\n\n continuation\n";
assert!(check(content).is_empty());
}
#[test]
fn heading_after_list_no_warning() {
let content = "- Item\n\n# Heading\n";
assert!(check(content).is_empty());
}
#[test]
fn hr_after_list_no_warning() {
let content = "- Item\n\n---\n";
assert!(check(content).is_empty());
}
#[test]
fn reference_link_def_not_flagged() {
let content = "- Item\n\n [link]: https://example.com\n";
assert!(check(content).is_empty());
}
#[test]
fn footnote_def_not_flagged() {
let content = "- Item\n\n [^1]: footnote text\n";
assert!(check(content).is_empty());
}
#[test]
fn fix_deeply_nested() {
let content = "- L1\n - L2\n - L3\n\n under-indented\n";
let fixed = fix(content);
assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
}
#[test]
fn fix_mkdocs_unordered() {
let content = "- Item\n\n continuation\n";
let fixed = fix_mkdocs(content);
assert_eq!(fixed, "- Item\n\n continuation\n");
}
#[test]
fn fix_code_fence_indent() {
let content = "- Item\n\n ```\n code\n ```\n";
let fixed = fix(content);
assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
}
#[test]
fn fix_mkdocs_code_fence_indent() {
let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
let fixed = fix_mkdocs(content);
assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
}
#[test]
fn empty_document_no_warning() {
assert!(check("").is_empty());
}
#[test]
fn whitespace_only_no_warning() {
assert!(check(" \n\n \n").is_empty());
}
#[test]
fn no_list_no_warning() {
let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
assert!(check(content).is_empty());
}
#[test]
fn multiline_continuation_all_lines_flagged() {
let content = "1. This is a list item.\n\n This is continuation text and\n it has multiple lines.\n This is yet another line.\n";
let warnings = check(content);
assert_eq!(warnings.len(), 3);
assert_eq!(warnings[0].line, 3);
assert_eq!(warnings[1].line, 4);
assert_eq!(warnings[2].line, 5);
}
#[test]
fn multiline_continuation_with_frontmatter_fix() {
let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n";
let fixed = fix(content);
assert_eq!(
fixed,
"---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n"
);
}
#[test]
fn multiline_continuation_correct_indent_no_warning() {
let content = "1. Item\n\n line one\n line two\n line three\n";
assert!(check(content).is_empty());
}
#[test]
fn multiline_continuation_mixed_indent() {
let content = "1. Item\n\n correct\n wrong\n correct\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 4);
}
#[test]
fn multiline_continuation_unordered() {
let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
let warnings = check(content);
assert_eq!(warnings.len(), 3);
let fixed = fix(content);
assert_eq!(
fixed,
"- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
);
}
#[test]
fn multiline_continuation_two_items_fix() {
let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
let fixed = fix(content);
assert_eq!(
fixed,
"1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
);
}
#[test]
fn multiline_continuation_separated_by_blank() {
let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
let warnings = check(content);
assert_eq!(warnings.len(), 4);
let fixed = fix(content);
assert_eq!(
fixed,
"1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
);
}
}