1use crate::utils::range_utils::calculate_match_range;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, 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 check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
53 let mut warnings = Vec::new();
54
55 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
56 let line_num = line_idx + 1;
57
58 if line_info.in_code_block || line_info.in_html_block {
60 continue;
61 }
62
63 if let Some(blockquote) = &line_info.blockquote {
65 let is_likely_list_continuation =
70 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
71 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
72 let mut byte_pos = 0;
75 let mut found_markers = 0;
76 let mut found_first_space = false;
77
78 for (i, ch) in line_info.content(ctx.content).char_indices() {
79 if found_markers < blockquote.nesting_level {
80 if ch == '>' {
81 found_markers += 1;
82 }
83 } else if !found_first_space && (ch == ' ' || ch == '\t') {
84 found_first_space = true;
86 } else if found_first_space && (ch == ' ' || ch == '\t') {
87 byte_pos = i;
89 break;
90 }
91 }
92
93 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
95 .chars()
96 .take_while(|&c| c == ' ' || c == '\t')
97 .fold(0, |acc, ch| acc + ch.len_utf8());
98
99 if extra_spaces_bytes > 0 {
100 let (start_line, start_col, end_line, end_col) = calculate_match_range(
101 line_num,
102 line_info.content(ctx.content),
103 byte_pos,
104 extra_spaces_bytes,
105 );
106
107 warnings.push(LintWarning {
108 rule_name: Some(self.name().to_string()),
109 line: start_line,
110 column: start_col,
111 end_line,
112 end_column: end_col,
113 message: "Multiple spaces after quote marker (>)".to_string(),
114 severity: Severity::Warning,
115 fix: Some(Fix {
116 range: {
117 let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
118 let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
119 start_byte..end_byte
120 },
121 replacement: "".to_string(), }),
123 });
124 }
125 }
126 } else {
127 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
129 for (start, len, fixed_line, description) in malformed_attempts {
130 let (start_line, start_col, end_line, end_col) =
131 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
132
133 warnings.push(LintWarning {
134 rule_name: Some(self.name().to_string()),
135 line: start_line,
136 column: start_col,
137 end_line,
138 end_column: end_col,
139 message: format!("Malformed quote: {description}"),
140 severity: Severity::Warning,
141 fix: Some(Fix {
142 range: ctx.line_index.line_col_to_byte_range_with_length(
143 line_num,
144 1,
145 line_info.content(ctx.content).chars().count(),
146 ),
147 replacement: fixed_line,
148 }),
149 });
150 }
151 }
152 }
153
154 Ok(warnings)
155 }
156
157 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
158 let mut result = Vec::with_capacity(ctx.lines.len());
159
160 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
161 let line_num = line_idx + 1;
162
163 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
165 result.push(line_info.content(ctx.content).to_string());
166 continue;
167 }
168
169 if let Some(blockquote) = &line_info.blockquote {
170 let is_likely_list_continuation =
173 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
174 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
175 let fixed_line = if blockquote.content.is_empty() {
178 format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
179 } else {
180 format!(
181 "{}{} {}",
182 blockquote.indent,
183 ">".repeat(blockquote.nesting_level),
184 blockquote.content
185 )
186 };
187 result.push(fixed_line);
188 } else {
189 result.push(line_info.content(ctx.content).to_string());
190 }
191 } else {
192 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
194 if !malformed_attempts.is_empty() {
195 let (_, _, fixed_line, _) = &malformed_attempts[0];
197 result.push(fixed_line.clone());
198 } else {
199 result.push(line_info.content(ctx.content).to_string());
200 }
201 }
202 }
203
204 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
206 }
207
208 fn as_any(&self) -> &dyn std::any::Any {
209 self
210 }
211
212 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
213 where
214 Self: Sized,
215 {
216 Box::new(MD027MultipleSpacesBlockquote)
217 }
218
219 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
222 }
223}
224
225impl MD027MultipleSpacesBlockquote {
226 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
230 for prev_idx in (0..line_idx).rev() {
233 let prev_line = &ctx.lines[prev_idx];
234
235 if prev_line.blockquote.is_none() {
237 return false;
238 }
239
240 if prev_line.list_item.is_some() {
242 return true;
243 }
244
245 if ctx.is_in_list_block(prev_idx + 1) {
247 return true;
248 }
249 }
250 false
251 }
252
253 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
255 let mut results = Vec::new();
256
257 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
258 if let Some(cap) = pattern.captures(line) {
259 let match_obj = cap.get(0).unwrap();
260 let start = match_obj.start();
261 let len = match_obj.len();
262
263 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
265 {
266 if self.looks_like_blockquote_attempt(line, &fixed_line) {
268 results.push((start, len, fixed_line, description));
269 }
270 }
271 }
272 }
273
274 results
275 }
276
277 fn extract_blockquote_fix_from_match(
279 &self,
280 cap: ®ex::Captures,
281 issue_type: &str,
282 _original_line: &str,
283 ) -> Option<(String, String)> {
284 match issue_type {
285 "missing spaces in nested blockquote" => {
286 let indent = cap.get(1).map_or("", |m| m.as_str());
288 let content = cap.get(2).map_or("", |m| m.as_str());
289 Some((
290 format!("{}> > {}", indent, content.trim()),
291 "Missing spaces in nested blockquote".to_string(),
292 ))
293 }
294 "missing spaces in deeply nested blockquote" => {
295 let indent = cap.get(1).map_or("", |m| m.as_str());
297 let content = cap.get(2).map_or("", |m| m.as_str());
298 Some((
299 format!("{}> > > {}", indent, content.trim()),
300 "Missing spaces in deeply nested blockquote".to_string(),
301 ))
302 }
303 "extra blockquote marker" => {
304 let indent = cap.get(1).map_or("", |m| m.as_str());
306 let content = cap.get(2).map_or("", |m| m.as_str());
307 Some((
308 format!("{}> {}", indent, content.trim()),
309 "Extra blockquote marker".to_string(),
310 ))
311 }
312 "indented blockquote missing space" => {
313 let indent = cap.get(1).map_or("", |m| m.as_str());
315 let content = cap.get(2).map_or("", |m| m.as_str());
316 Some((
317 format!("{}> {}", indent, content.trim()),
318 "Indented blockquote missing space".to_string(),
319 ))
320 }
321 _ => None,
322 }
323 }
324
325 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
327 let trimmed_original = original.trim();
331 if trimmed_original.len() < 5 {
332 return false;
334 }
335
336 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
338 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
339 return false;
341 }
342
343 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
345 return false;
346 }
347
348 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
351 return false;
352 }
353
354 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")
361 {
363 return false;
364 }
365
366 let word_count = content_after_markers.split_whitespace().count();
368 if word_count < 3 {
369 return false;
371 }
372
373 true
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::lint_context::LintContext;
381
382 #[test]
383 fn test_valid_blockquote() {
384 let rule = MD027MultipleSpacesBlockquote;
385 let content = "> This is a blockquote\n> > Nested quote";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
389 }
390
391 #[test]
392 fn test_multiple_spaces_after_marker() {
393 let rule = MD027MultipleSpacesBlockquote;
394 let content = "> This has two spaces\n> This has three spaces";
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396 let result = rule.check(&ctx).unwrap();
397 assert_eq!(result.len(), 2);
398 assert_eq!(result[0].line, 1);
399 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
401 assert_eq!(result[1].line, 2);
402 assert_eq!(result[1].column, 3);
403 }
404
405 #[test]
406 fn test_nested_multiple_spaces() {
407 let rule = MD027MultipleSpacesBlockquote;
408 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411 let result = rule.check(&ctx).unwrap();
412 assert_eq!(result.len(), 2);
413 assert!(result[0].message.contains("Multiple spaces"));
414 assert!(result[1].message.contains("Multiple spaces"));
415 }
416
417 #[test]
418 fn test_malformed_nested_quote() {
419 let rule = MD027MultipleSpacesBlockquote;
420 let content = ">>This is a nested blockquote without space after markers";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let result = rule.check(&ctx).unwrap();
425 assert_eq!(result.len(), 0);
427 }
428
429 #[test]
430 fn test_malformed_deeply_nested() {
431 let rule = MD027MultipleSpacesBlockquote;
432 let content = ">>>This is deeply nested without spaces after markers";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert_eq!(result.len(), 0);
438 }
439
440 #[test]
441 fn test_extra_quote_marker() {
442 let rule = MD027MultipleSpacesBlockquote;
443 let content = "> >This looks like nested but is actually single level with >This as content";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(result.len(), 0);
449 }
450
451 #[test]
452 fn test_indented_missing_space() {
453 let rule = MD027MultipleSpacesBlockquote;
454 let content = " >This has 3 spaces indent and no space after marker";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 0);
461 }
462
463 #[test]
464 fn test_fix_multiple_spaces() {
465 let rule = MD027MultipleSpacesBlockquote;
466 let content = "> Two spaces\n> Three spaces";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468 let fixed = rule.fix(&ctx).unwrap();
469 assert_eq!(fixed, "> Two spaces\n> Three spaces");
470 }
471
472 #[test]
473 fn test_fix_malformed_quotes() {
474 let rule = MD027MultipleSpacesBlockquote;
475 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let fixed = rule.fix(&ctx).unwrap();
479 assert_eq!(fixed, content);
481 }
482
483 #[test]
484 fn test_fix_extra_marker() {
485 let rule = MD027MultipleSpacesBlockquote;
486 let content = "> >Extra marker here";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let fixed = rule.fix(&ctx).unwrap();
490 assert_eq!(fixed, content);
492 }
493
494 #[test]
495 fn test_code_block_ignored() {
496 let rule = MD027MultipleSpacesBlockquote;
497 let content = "```\n> This is in a code block\n```";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(result.is_empty(), "Code blocks should be ignored");
501 }
502
503 #[test]
504 fn test_short_content_not_flagged() {
505 let rule = MD027MultipleSpacesBlockquote;
506 let content = ">>>\n>>";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert!(result.is_empty(), "Very short content should not be flagged");
510 }
511
512 #[test]
513 fn test_non_prose_not_flagged() {
514 let rule = MD027MultipleSpacesBlockquote;
515 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517 let result = rule.check(&ctx).unwrap();
518 assert!(result.is_empty(), "Non-prose content should not be flagged");
519 }
520
521 #[test]
522 fn test_preserve_trailing_newline() {
523 let rule = MD027MultipleSpacesBlockquote;
524 let content = "> Two spaces\n";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let fixed = rule.fix(&ctx).unwrap();
527 assert_eq!(fixed, "> Two spaces\n");
528
529 let content_no_newline = "> Two spaces";
530 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
531 let fixed2 = rule.fix(&ctx2).unwrap();
532 assert_eq!(fixed2, "> Two spaces");
533 }
534
535 #[test]
536 fn test_mixed_issues() {
537 let rule = MD027MultipleSpacesBlockquote;
538 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx).unwrap();
541 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
542 assert_eq!(result[0].line, 1);
543 }
544
545 #[test]
546 fn test_looks_like_blockquote_attempt() {
547 let rule = MD027MultipleSpacesBlockquote;
548
549 assert!(rule.looks_like_blockquote_attempt(
551 ">>This is a real blockquote attempt with text",
552 "> > This is a real blockquote attempt with text"
553 ));
554
555 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
557
558 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
560
561 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
563 }
564
565 #[test]
566 fn test_extract_blockquote_fix() {
567 let rule = MD027MultipleSpacesBlockquote;
568 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
569 let cap = regex.captures(">>content").unwrap();
570
571 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
572 assert!(result.is_some());
573 let (fixed, desc) = result.unwrap();
574 assert_eq!(fixed, "> > content");
575 assert!(desc.contains("Missing spaces"));
576 }
577
578 #[test]
579 fn test_empty_blockquote() {
580 let rule = MD027MultipleSpacesBlockquote;
581 let content = ">\n> \n> content";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert_eq!(result.len(), 1);
586 assert_eq!(result[0].line, 2);
587 }
588
589 #[test]
590 fn test_fix_preserves_indentation() {
591 let rule = MD027MultipleSpacesBlockquote;
592 let content = " > Indented with multiple spaces";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let fixed = rule.fix(&ctx).unwrap();
595 assert_eq!(fixed, " > Indented with multiple spaces");
596 }
597
598 #[test]
599 fn test_tabs_after_marker_not_flagged() {
600 let rule = MD027MultipleSpacesBlockquote;
604
605 let content = ">\tTab after marker";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
610
611 let content2 = ">\t\tTwo tabs";
613 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
614 let result2 = rule.check(&ctx2).unwrap();
615 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
616 }
617
618 #[test]
619 fn test_mixed_spaces_and_tabs() {
620 let rule = MD027MultipleSpacesBlockquote;
621 let content = "> Space Space";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(result.len(), 1);
627 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
631 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
632 let result2 = rule.check(&ctx2).unwrap();
633 assert_eq!(result2.len(), 1);
634 }
635
636 #[test]
637 fn test_fix_multiple_spaces_various() {
638 let rule = MD027MultipleSpacesBlockquote;
639 let content = "> Three spaces";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let fixed = rule.fix(&ctx).unwrap();
643 assert_eq!(fixed, "> Three spaces");
644
645 let content2 = "> Four spaces";
647 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
648 let fixed2 = rule.fix(&ctx2).unwrap();
649 assert_eq!(fixed2, "> Four spaces");
650 }
651
652 #[test]
653 fn test_list_continuation_inside_blockquote_not_flagged() {
654 let rule = MD027MultipleSpacesBlockquote;
657
658 let content = "> - Item starts here\n> This continues the item\n> - Another item";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(
663 result.is_empty(),
664 "List continuation inside blockquote should not be flagged, got: {result:?}"
665 );
666
667 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
669 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
670 let result2 = rule.check(&ctx2).unwrap();
671 assert!(
672 result2.is_empty(),
673 "List continuations should not be flagged, got: {result2:?}"
674 );
675 }
676
677 #[test]
678 fn test_list_continuation_fix_preserves_indentation() {
679 let rule = MD027MultipleSpacesBlockquote;
681
682 let content = "> - Item\n> continuation";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let fixed = rule.fix(&ctx).unwrap();
685 assert_eq!(fixed, "> - Item\n> continuation");
687 }
688
689 #[test]
690 fn test_non_list_multiple_spaces_still_flagged() {
691 let rule = MD027MultipleSpacesBlockquote;
693
694 let content = "> This has extra spaces";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert_eq!(result.len(), 1, "Non-list line should be flagged");
699 }
700}