1use crate::utils::range_utils::calculate_match_range;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
9 vec![
10 (
12 Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
13 "missing spaces in nested blockquote",
14 ),
15 (
17 Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
18 "missing spaces in deeply nested blockquote",
19 ),
20 (
22 Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
23 "extra blockquote marker",
24 ),
25 (
27 Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
28 "indented blockquote missing space",
29 ),
30 ]
31});
32
33static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
35
36#[derive(Debug, Default, Clone)]
41pub struct MD027MultipleSpacesBlockquote;
42
43impl Rule for MD027MultipleSpacesBlockquote {
44 fn name(&self) -> &'static str {
45 "MD027"
46 }
47
48 fn description(&self) -> &'static str {
49 "Multiple spaces after quote marker (>)"
50 }
51
52 fn category(&self) -> RuleCategory {
53 RuleCategory::Blockquote
54 }
55
56 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
57 let mut warnings = Vec::new();
58
59 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
60 let line_num = line_idx + 1;
61
62 if line_info.in_code_block || line_info.in_html_block {
64 continue;
65 }
66
67 if let Some(blockquote) = &line_info.blockquote {
69 let is_likely_list_continuation =
74 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
75 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
76 let mut byte_pos = 0;
79 let mut found_markers = 0;
80 let mut found_first_space = false;
81
82 for (i, ch) in line_info.content(ctx.content).char_indices() {
83 if found_markers < blockquote.nesting_level {
84 if ch == '>' {
85 found_markers += 1;
86 }
87 } else if !found_first_space && (ch == ' ' || ch == '\t') {
88 found_first_space = true;
90 } else if found_first_space && (ch == ' ' || ch == '\t') {
91 byte_pos = i;
93 break;
94 }
95 }
96
97 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
99 .chars()
100 .take_while(|&c| c == ' ' || c == '\t')
101 .fold(0, |acc, ch| acc + ch.len_utf8());
102
103 if extra_spaces_bytes > 0 {
104 let (start_line, start_col, end_line, end_col) = calculate_match_range(
105 line_num,
106 line_info.content(ctx.content),
107 byte_pos,
108 extra_spaces_bytes,
109 );
110
111 warnings.push(LintWarning {
112 rule_name: Some(self.name().to_string()),
113 line: start_line,
114 column: start_col,
115 end_line,
116 end_column: end_col,
117 message: "Multiple spaces after quote marker (>)".to_string(),
118 severity: Severity::Warning,
119 fix: Some(Fix {
120 range: {
121 let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
122 let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
123 start_byte..end_byte
124 },
125 replacement: "".to_string(), }),
127 });
128 }
129 }
130 } else {
131 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
133 for (start, len, fixed_line, description) in malformed_attempts {
134 let (start_line, start_col, end_line, end_col) =
135 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
136
137 warnings.push(LintWarning {
138 rule_name: Some(self.name().to_string()),
139 line: start_line,
140 column: start_col,
141 end_line,
142 end_column: end_col,
143 message: format!("Malformed quote: {description}"),
144 severity: Severity::Warning,
145 fix: Some(Fix {
146 range: ctx.line_index.line_col_to_byte_range_with_length(
147 line_num,
148 1,
149 line_info.content(ctx.content).chars().count(),
150 ),
151 replacement: fixed_line,
152 }),
153 });
154 }
155 }
156 }
157
158 Ok(warnings)
159 }
160
161 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
162 let mut result = Vec::with_capacity(ctx.lines.len());
163
164 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
165 let line_num = line_idx + 1;
166
167 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
169 result.push(line_info.content(ctx.content).to_string());
170 continue;
171 }
172
173 if let Some(blockquote) = &line_info.blockquote {
174 let is_likely_list_continuation =
177 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
178 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
179 let fixed_line = if blockquote.content.is_empty() {
182 format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
183 } else {
184 format!(
185 "{}{} {}",
186 blockquote.indent,
187 ">".repeat(blockquote.nesting_level),
188 blockquote.content
189 )
190 };
191 result.push(fixed_line);
192 } else {
193 result.push(line_info.content(ctx.content).to_string());
194 }
195 } else {
196 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
198 if !malformed_attempts.is_empty() {
199 let (_, _, fixed_line, _) = &malformed_attempts[0];
201 result.push(fixed_line.clone());
202 } else {
203 result.push(line_info.content(ctx.content).to_string());
204 }
205 }
206 }
207
208 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
210 }
211
212 fn as_any(&self) -> &dyn std::any::Any {
213 self
214 }
215
216 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
217 where
218 Self: Sized,
219 {
220 Box::new(MD027MultipleSpacesBlockquote)
221 }
222
223 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
225 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
226 }
227}
228
229impl MD027MultipleSpacesBlockquote {
230 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
234 for prev_idx in (0..line_idx).rev() {
237 let prev_line = &ctx.lines[prev_idx];
238
239 if prev_line.blockquote.is_none() {
241 return false;
242 }
243
244 if prev_line.list_item.is_some() {
246 return true;
247 }
248
249 if ctx.is_in_list_block(prev_idx + 1) {
251 return true;
252 }
253 }
254 false
255 }
256
257 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
259 let mut results = Vec::new();
260
261 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
262 if let Some(cap) = pattern.captures(line) {
263 let match_obj = cap.get(0).unwrap();
264 let start = match_obj.start();
265 let len = match_obj.len();
266
267 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
269 {
270 if self.looks_like_blockquote_attempt(line, &fixed_line) {
272 results.push((start, len, fixed_line, description));
273 }
274 }
275 }
276 }
277
278 results
279 }
280
281 fn extract_blockquote_fix_from_match(
283 &self,
284 cap: ®ex::Captures,
285 issue_type: &str,
286 _original_line: &str,
287 ) -> Option<(String, String)> {
288 match issue_type {
289 "missing spaces in nested blockquote" => {
290 let indent = cap.get(1).map_or("", |m| m.as_str());
292 let content = cap.get(2).map_or("", |m| m.as_str());
293 Some((
294 format!("{}> > {}", indent, content.trim()),
295 "Missing spaces in nested blockquote".to_string(),
296 ))
297 }
298 "missing spaces in deeply nested blockquote" => {
299 let indent = cap.get(1).map_or("", |m| m.as_str());
301 let content = cap.get(2).map_or("", |m| m.as_str());
302 Some((
303 format!("{}> > > {}", indent, content.trim()),
304 "Missing spaces in deeply nested blockquote".to_string(),
305 ))
306 }
307 "extra blockquote marker" => {
308 let indent = cap.get(1).map_or("", |m| m.as_str());
310 let content = cap.get(2).map_or("", |m| m.as_str());
311 Some((
312 format!("{}> {}", indent, content.trim()),
313 "Extra blockquote marker".to_string(),
314 ))
315 }
316 "indented blockquote missing space" => {
317 let indent = cap.get(1).map_or("", |m| m.as_str());
319 let content = cap.get(2).map_or("", |m| m.as_str());
320 Some((
321 format!("{}> {}", indent, content.trim()),
322 "Indented blockquote missing space".to_string(),
323 ))
324 }
325 _ => None,
326 }
327 }
328
329 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
331 let trimmed_original = original.trim();
335 if trimmed_original.len() < 5 {
336 return false;
338 }
339
340 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
342 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
343 return false;
345 }
346
347 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
349 return false;
350 }
351
352 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
355 return false;
356 }
357
358 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")
365 {
367 return false;
368 }
369
370 let word_count = content_after_markers.split_whitespace().count();
372 if word_count < 3 {
373 return false;
375 }
376
377 true
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::lint_context::LintContext;
385
386 #[test]
387 fn test_valid_blockquote() {
388 let rule = MD027MultipleSpacesBlockquote;
389 let content = "> This is a blockquote\n> > Nested quote";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
393 }
394
395 #[test]
396 fn test_multiple_spaces_after_marker() {
397 let rule = MD027MultipleSpacesBlockquote;
398 let content = "> This has two spaces\n> This has three spaces";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 2);
402 assert_eq!(result[0].line, 1);
403 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
405 assert_eq!(result[1].line, 2);
406 assert_eq!(result[1].column, 3);
407 }
408
409 #[test]
410 fn test_nested_multiple_spaces() {
411 let rule = MD027MultipleSpacesBlockquote;
412 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415 let result = rule.check(&ctx).unwrap();
416 assert_eq!(result.len(), 2);
417 assert!(result[0].message.contains("Multiple spaces"));
418 assert!(result[1].message.contains("Multiple spaces"));
419 }
420
421 #[test]
422 fn test_malformed_nested_quote() {
423 let rule = MD027MultipleSpacesBlockquote;
424 let content = ">>This is a nested blockquote without space after markers";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429 assert_eq!(result.len(), 0);
431 }
432
433 #[test]
434 fn test_malformed_deeply_nested() {
435 let rule = MD027MultipleSpacesBlockquote;
436 let content = ">>>This is deeply nested without spaces after markers";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 0);
442 }
443
444 #[test]
445 fn test_extra_quote_marker() {
446 let rule = MD027MultipleSpacesBlockquote;
447 let content = "> >This looks like nested but is actually single level with >This as content";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452 assert_eq!(result.len(), 0);
453 }
454
455 #[test]
456 fn test_indented_missing_space() {
457 let rule = MD027MultipleSpacesBlockquote;
458 let content = " >This has 3 spaces indent and no space after marker";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461 let result = rule.check(&ctx).unwrap();
462 assert_eq!(result.len(), 0);
465 }
466
467 #[test]
468 fn test_fix_multiple_spaces() {
469 let rule = MD027MultipleSpacesBlockquote;
470 let content = "> Two spaces\n> Three spaces";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let fixed = rule.fix(&ctx).unwrap();
473 assert_eq!(fixed, "> Two spaces\n> Three spaces");
474 }
475
476 #[test]
477 fn test_fix_malformed_quotes() {
478 let rule = MD027MultipleSpacesBlockquote;
479 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let fixed = rule.fix(&ctx).unwrap();
483 assert_eq!(fixed, content);
485 }
486
487 #[test]
488 fn test_fix_extra_marker() {
489 let rule = MD027MultipleSpacesBlockquote;
490 let content = "> >Extra marker here";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let fixed = rule.fix(&ctx).unwrap();
494 assert_eq!(fixed, content);
496 }
497
498 #[test]
499 fn test_code_block_ignored() {
500 let rule = MD027MultipleSpacesBlockquote;
501 let content = "```\n> This is in a code block\n```";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert!(result.is_empty(), "Code blocks should be ignored");
505 }
506
507 #[test]
508 fn test_short_content_not_flagged() {
509 let rule = MD027MultipleSpacesBlockquote;
510 let content = ">>>\n>>";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty(), "Very short content should not be flagged");
514 }
515
516 #[test]
517 fn test_non_prose_not_flagged() {
518 let rule = MD027MultipleSpacesBlockquote;
519 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(result.is_empty(), "Non-prose content should not be flagged");
523 }
524
525 #[test]
526 fn test_preserve_trailing_newline() {
527 let rule = MD027MultipleSpacesBlockquote;
528 let content = "> Two spaces\n";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let fixed = rule.fix(&ctx).unwrap();
531 assert_eq!(fixed, "> Two spaces\n");
532
533 let content_no_newline = "> Two spaces";
534 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
535 let fixed2 = rule.fix(&ctx2).unwrap();
536 assert_eq!(fixed2, "> Two spaces");
537 }
538
539 #[test]
540 fn test_mixed_issues() {
541 let rule = MD027MultipleSpacesBlockquote;
542 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
546 assert_eq!(result[0].line, 1);
547 }
548
549 #[test]
550 fn test_looks_like_blockquote_attempt() {
551 let rule = MD027MultipleSpacesBlockquote;
552
553 assert!(rule.looks_like_blockquote_attempt(
555 ">>This is a real blockquote attempt with text",
556 "> > This is a real blockquote attempt with text"
557 ));
558
559 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
561
562 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
564
565 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
567 }
568
569 #[test]
570 fn test_extract_blockquote_fix() {
571 let rule = MD027MultipleSpacesBlockquote;
572 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
573 let cap = regex.captures(">>content").unwrap();
574
575 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
576 assert!(result.is_some());
577 let (fixed, desc) = result.unwrap();
578 assert_eq!(fixed, "> > content");
579 assert!(desc.contains("Missing spaces"));
580 }
581
582 #[test]
583 fn test_empty_blockquote() {
584 let rule = MD027MultipleSpacesBlockquote;
585 let content = ">\n> \n> content";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1);
590 assert_eq!(result[0].line, 2);
591 }
592
593 #[test]
594 fn test_fix_preserves_indentation() {
595 let rule = MD027MultipleSpacesBlockquote;
596 let content = " > Indented with multiple spaces";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599 assert_eq!(fixed, " > Indented with multiple spaces");
600 }
601
602 #[test]
603 fn test_tabs_after_marker_not_flagged() {
604 let rule = MD027MultipleSpacesBlockquote;
608
609 let content = ">\tTab after marker";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
614
615 let content2 = ">\t\tTwo tabs";
617 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
618 let result2 = rule.check(&ctx2).unwrap();
619 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
620 }
621
622 #[test]
623 fn test_mixed_spaces_and_tabs() {
624 let rule = MD027MultipleSpacesBlockquote;
625 let content = "> Space Space";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(result.len(), 1);
631 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
635 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
636 let result2 = rule.check(&ctx2).unwrap();
637 assert_eq!(result2.len(), 1);
638 }
639
640 #[test]
641 fn test_fix_multiple_spaces_various() {
642 let rule = MD027MultipleSpacesBlockquote;
643 let content = "> Three spaces";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let fixed = rule.fix(&ctx).unwrap();
647 assert_eq!(fixed, "> Three spaces");
648
649 let content2 = "> Four spaces";
651 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
652 let fixed2 = rule.fix(&ctx2).unwrap();
653 assert_eq!(fixed2, "> Four spaces");
654 }
655
656 #[test]
657 fn test_list_continuation_inside_blockquote_not_flagged() {
658 let rule = MD027MultipleSpacesBlockquote;
661
662 let content = "> - Item starts here\n> This continues the item\n> - Another item";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert!(
667 result.is_empty(),
668 "List continuation inside blockquote should not be flagged, got: {result:?}"
669 );
670
671 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
673 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
674 let result2 = rule.check(&ctx2).unwrap();
675 assert!(
676 result2.is_empty(),
677 "List continuations should not be flagged, got: {result2:?}"
678 );
679 }
680
681 #[test]
682 fn test_list_continuation_fix_preserves_indentation() {
683 let rule = MD027MultipleSpacesBlockquote;
685
686 let content = "> - Item\n> continuation";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let fixed = rule.fix(&ctx).unwrap();
689 assert_eq!(fixed, "> - Item\n> continuation");
691 }
692
693 #[test]
694 fn test_non_list_multiple_spaces_still_flagged() {
695 let rule = MD027MultipleSpacesBlockquote;
697
698 let content = "> This has extra spaces";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert_eq!(result.len(), 1, "Non-list line should be flagged");
703 }
704}