use crate::utils::range_utils::calculate_match_range;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use regex::Regex;
use std::sync::LazyLock;
static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(
Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
"missing spaces in nested blockquote",
),
(
Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
"missing spaces in deeply nested blockquote",
),
(
Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
"extra blockquote marker",
),
(
Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
"indented blockquote missing space",
),
]
});
static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
#[derive(Debug, Default, Clone)]
pub struct MD027MultipleSpacesBlockquote;
impl Rule for MD027MultipleSpacesBlockquote {
fn name(&self) -> &'static str {
"MD027"
}
fn description(&self) -> &'static str {
"Multiple spaces after quote marker (>)"
}
fn category(&self) -> RuleCategory {
RuleCategory::Blockquote
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
let line_num = line_idx + 1;
if line_info.in_code_block || line_info.in_html_block {
continue;
}
if let Some(blockquote) = &line_info.blockquote {
let is_likely_list_continuation =
ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
let mut byte_pos = 0;
let mut found_markers = 0;
let mut found_first_space = false;
for (i, ch) in line_info.content(ctx.content).char_indices() {
if found_markers < blockquote.nesting_level {
if ch == '>' {
found_markers += 1;
}
} else if !found_first_space && (ch == ' ' || ch == '\t') {
found_first_space = true;
} else if found_first_space && (ch == ' ' || ch == '\t') {
byte_pos = i;
break;
}
}
let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
.chars()
.take_while(|&c| c == ' ' || c == '\t')
.fold(0, |acc, ch| acc + ch.len_utf8());
if extra_spaces_bytes > 0 {
let (start_line, start_col, end_line, end_col) = calculate_match_range(
line_num,
line_info.content(ctx.content),
byte_pos,
extra_spaces_bytes,
);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: "Multiple spaces after quote marker (>)".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: {
let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
start_byte..end_byte
},
replacement: "".to_string(), }),
});
}
}
} else {
let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
for (start, len, fixed_line, description) in malformed_attempts {
let (start_line, start_col, end_line, end_col) =
calculate_match_range(line_num, line_info.content(ctx.content), start, len);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!("Malformed quote: {description}"),
severity: Severity::Warning,
fix: Some(Fix {
range: ctx.line_index.line_col_to_byte_range_with_length(
line_num,
1,
line_info.content(ctx.content).chars().count(),
),
replacement: fixed_line,
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let mut result = Vec::with_capacity(ctx.lines.len());
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
let line_num = line_idx + 1;
if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
result.push(line_info.content(ctx.content).to_string());
continue;
}
if let Some(blockquote) = &line_info.blockquote {
let is_likely_list_continuation =
ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
let fixed_line = if blockquote.content.is_empty() {
format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
} else {
format!(
"{}{} {}",
blockquote.indent,
">".repeat(blockquote.nesting_level),
blockquote.content
)
};
result.push(fixed_line);
} else {
result.push(line_info.content(ctx.content).to_string());
}
} else {
let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
if !malformed_attempts.is_empty() {
let (_, _, fixed_line, _) = &malformed_attempts[0];
result.push(fixed_line.clone());
} else {
result.push(line_info.content(ctx.content).to_string());
}
}
}
Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD027MultipleSpacesBlockquote)
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_blockquotes()
}
}
impl MD027MultipleSpacesBlockquote {
fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
for prev_idx in (0..line_idx).rev() {
let prev_line = &ctx.lines[prev_idx];
if prev_line.blockquote.is_none() {
return false;
}
if prev_line.list_item.is_some() {
return true;
}
if ctx.is_in_list_block(prev_idx + 1) {
return true;
}
}
false
}
fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
let mut results = Vec::new();
for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
if let Some(cap) = pattern.captures(line) {
let match_obj = cap.get(0).unwrap();
let start = match_obj.start();
let len = match_obj.len();
if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
{
if self.looks_like_blockquote_attempt(line, &fixed_line) {
results.push((start, len, fixed_line, description));
}
}
}
}
results
}
fn extract_blockquote_fix_from_match(
&self,
cap: ®ex::Captures,
issue_type: &str,
_original_line: &str,
) -> Option<(String, String)> {
match issue_type {
"missing spaces in nested blockquote" => {
let indent = cap.get(1).map_or("", |m| m.as_str());
let content = cap.get(2).map_or("", |m| m.as_str());
Some((
format!("{}> > {}", indent, content.trim()),
"Missing spaces in nested blockquote".to_string(),
))
}
"missing spaces in deeply nested blockquote" => {
let indent = cap.get(1).map_or("", |m| m.as_str());
let content = cap.get(2).map_or("", |m| m.as_str());
Some((
format!("{}> > > {}", indent, content.trim()),
"Missing spaces in deeply nested blockquote".to_string(),
))
}
"extra blockquote marker" => {
let indent = cap.get(1).map_or("", |m| m.as_str());
let content = cap.get(2).map_or("", |m| m.as_str());
Some((
format!("{}> {}", indent, content.trim()),
"Extra blockquote marker".to_string(),
))
}
"indented blockquote missing space" => {
let indent = cap.get(1).map_or("", |m| m.as_str());
let content = cap.get(2).map_or("", |m| m.as_str());
Some((
format!("{}> {}", indent, content.trim()),
"Indented blockquote missing space".to_string(),
))
}
_ => None,
}
}
fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
let trimmed_original = original.trim();
if trimmed_original.len() < 5 {
return false;
}
let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
if content_after_markers.is_empty() || content_after_markers.len() < 3 {
return false;
}
if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
return false;
}
if !BLOCKQUOTE_PATTERN.is_match(fixed) {
return false;
}
if content_after_markers.starts_with('#') || content_after_markers.starts_with('[') || content_after_markers.starts_with('`') || content_after_markers.starts_with("http") || content_after_markers.starts_with("www.") || content_after_markers.starts_with("ftp")
{
return false;
}
let word_count = content_after_markers.split_whitespace().count();
if word_count < 3 {
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_valid_blockquote() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> This is a blockquote\n> > Nested quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Valid blockquotes should not be flagged");
}
#[test]
fn test_multiple_spaces_after_marker() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> This has two spaces\n> This has three spaces";
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, 1);
assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
assert_eq!(result[1].line, 2);
assert_eq!(result[1].column, 3);
}
#[test]
fn test_nested_multiple_spaces() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].message.contains("Multiple spaces"));
assert!(result[1].message.contains("Multiple spaces"));
}
#[test]
fn test_malformed_nested_quote() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">>This is a nested blockquote without space after markers";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_malformed_deeply_nested() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">>>This is deeply nested without spaces after markers";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_extra_quote_marker() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> >This looks like nested but is actually single level with >This as content";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_indented_missing_space() {
let rule = MD027MultipleSpacesBlockquote;
let content = " >This has 3 spaces indent and no space after marker";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fix_multiple_spaces() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Two spaces\n> Three spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> Two spaces\n> Three spaces");
}
#[test]
fn test_fix_malformed_quotes() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_fix_extra_marker() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> >Extra marker here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_code_block_ignored() {
let rule = MD027MultipleSpacesBlockquote;
let content = "```\n> This is in a code block\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Code blocks should be ignored");
}
#[test]
fn test_short_content_not_flagged() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">>>\n>>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Very short content should not be flagged");
}
#[test]
fn test_non_prose_not_flagged() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Non-prose content should not be flagged");
}
#[test]
fn test_preserve_trailing_newline() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Two spaces\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> Two spaces\n");
let content_no_newline = "> Two spaces";
let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(fixed2, "> Two spaces");
}
#[test]
fn test_mixed_issues() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
assert_eq!(result[0].line, 1);
}
#[test]
fn test_looks_like_blockquote_attempt() {
let rule = MD027MultipleSpacesBlockquote;
assert!(rule.looks_like_blockquote_attempt(
">>This is a real blockquote attempt with text",
"> > This is a real blockquote attempt with text"
));
assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
}
#[test]
fn test_extract_blockquote_fix() {
let rule = MD027MultipleSpacesBlockquote;
let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
let cap = regex.captures(">>content").unwrap();
let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
assert!(result.is_some());
let (fixed, desc) = result.unwrap();
assert_eq!(fixed, "> > content");
assert!(desc.contains("Missing spaces"));
}
#[test]
fn test_empty_blockquote() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">\n> \n> content";
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, 2);
}
#[test]
fn test_fix_preserves_indentation() {
let rule = MD027MultipleSpacesBlockquote;
let content = " > Indented with multiple spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, " > Indented with multiple spaces");
}
#[test]
fn test_tabs_after_marker_not_flagged() {
let rule = MD027MultipleSpacesBlockquote;
let content = ">\tTab after marker";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
let content2 = ">\t\tTwo tabs";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
}
#[test]
fn test_mixed_spaces_and_tabs() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Space Space";
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, 3);
let content2 = "> Three spaces";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 1);
}
#[test]
fn test_fix_multiple_spaces_various() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> Three spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> Three spaces");
let content2 = "> Four spaces";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(fixed2, "> Four spaces");
}
#[test]
fn test_list_continuation_inside_blockquote_not_flagged() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> - Item starts here\n> This continues the item\n> - Another item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"List continuation inside blockquote should not be flagged, got: {result:?}"
);
let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"List continuations should not be flagged, got: {result2:?}"
);
}
#[test]
fn test_list_continuation_fix_preserves_indentation() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> - Item\n> continuation";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> - Item\n> continuation");
}
#[test]
fn test_non_list_multiple_spaces_still_flagged() {
let rule = MD027MultipleSpacesBlockquote;
let content = "> This has extra spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Non-list line should be flagged");
}
}