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(line_num, 1),
143 replacement: fixed_line,
144 }),
145 });
146 }
147 }
148 }
149
150 Ok(warnings)
151 }
152
153 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
154 let mut result = Vec::with_capacity(ctx.lines.len());
155
156 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
157 let line_num = line_idx + 1;
158 if let Some(blockquote) = &line_info.blockquote {
159 let is_likely_list_continuation =
162 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
163 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
164 let fixed_line = if blockquote.content.is_empty() {
167 format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
168 } else {
169 format!(
170 "{}{} {}",
171 blockquote.indent,
172 ">".repeat(blockquote.nesting_level),
173 blockquote.content
174 )
175 };
176 result.push(fixed_line);
177 } else {
178 result.push(line_info.content(ctx.content).to_string());
179 }
180 } else {
181 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
183 if !malformed_attempts.is_empty() {
184 let (_, _, fixed_line, _) = &malformed_attempts[0];
186 result.push(fixed_line.clone());
187 } else {
188 result.push(line_info.content(ctx.content).to_string());
189 }
190 }
191 }
192
193 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
195 }
196
197 fn as_any(&self) -> &dyn std::any::Any {
198 self
199 }
200
201 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
202 where
203 Self: Sized,
204 {
205 Box::new(MD027MultipleSpacesBlockquote)
206 }
207
208 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
210 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
211 }
212}
213
214impl MD027MultipleSpacesBlockquote {
215 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
219 for prev_idx in (0..line_idx).rev() {
222 let prev_line = &ctx.lines[prev_idx];
223
224 if prev_line.blockquote.is_none() {
226 return false;
227 }
228
229 if prev_line.list_item.is_some() {
231 return true;
232 }
233
234 if ctx.is_in_list_block(prev_idx + 1) {
236 return true;
237 }
238 }
239 false
240 }
241
242 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
244 let mut results = Vec::new();
245
246 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
247 if let Some(cap) = pattern.captures(line) {
248 let match_obj = cap.get(0).unwrap();
249 let start = match_obj.start();
250 let len = match_obj.len();
251
252 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
254 {
255 if self.looks_like_blockquote_attempt(line, &fixed_line) {
257 results.push((start, len, fixed_line, description));
258 }
259 }
260 }
261 }
262
263 results
264 }
265
266 fn extract_blockquote_fix_from_match(
268 &self,
269 cap: ®ex::Captures,
270 issue_type: &str,
271 _original_line: &str,
272 ) -> Option<(String, String)> {
273 match issue_type {
274 "missing spaces in nested blockquote" => {
275 let indent = cap.get(1).map_or("", |m| m.as_str());
277 let content = cap.get(2).map_or("", |m| m.as_str());
278 Some((
279 format!("{}> > {}", indent, content.trim()),
280 "Missing spaces in nested blockquote".to_string(),
281 ))
282 }
283 "missing spaces in deeply nested blockquote" => {
284 let indent = cap.get(1).map_or("", |m| m.as_str());
286 let content = cap.get(2).map_or("", |m| m.as_str());
287 Some((
288 format!("{}> > > {}", indent, content.trim()),
289 "Missing spaces in deeply nested blockquote".to_string(),
290 ))
291 }
292 "extra blockquote marker" => {
293 let indent = cap.get(1).map_or("", |m| m.as_str());
295 let content = cap.get(2).map_or("", |m| m.as_str());
296 Some((
297 format!("{}> {}", indent, content.trim()),
298 "Extra blockquote marker".to_string(),
299 ))
300 }
301 "indented blockquote missing space" => {
302 let indent = cap.get(1).map_or("", |m| m.as_str());
304 let content = cap.get(2).map_or("", |m| m.as_str());
305 Some((
306 format!("{}> {}", indent, content.trim()),
307 "Indented blockquote missing space".to_string(),
308 ))
309 }
310 _ => None,
311 }
312 }
313
314 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
316 let trimmed_original = original.trim();
320 if trimmed_original.len() < 5 {
321 return false;
323 }
324
325 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
327 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
328 return false;
330 }
331
332 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
334 return false;
335 }
336
337 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
340 return false;
341 }
342
343 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")
350 {
352 return false;
353 }
354
355 let word_count = content_after_markers.split_whitespace().count();
357 if word_count < 3 {
358 return false;
360 }
361
362 true
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::lint_context::LintContext;
370
371 #[test]
372 fn test_valid_blockquote() {
373 let rule = MD027MultipleSpacesBlockquote;
374 let content = "> This is a blockquote\n> > Nested quote";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
378 }
379
380 #[test]
381 fn test_multiple_spaces_after_marker() {
382 let rule = MD027MultipleSpacesBlockquote;
383 let content = "> This has two spaces\n> This has three spaces";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert_eq!(result.len(), 2);
387 assert_eq!(result[0].line, 1);
388 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
390 assert_eq!(result[1].line, 2);
391 assert_eq!(result[1].column, 3);
392 }
393
394 #[test]
395 fn test_nested_multiple_spaces() {
396 let rule = MD027MultipleSpacesBlockquote;
397 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
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!(result[0].message.contains("Multiple spaces"));
403 assert!(result[1].message.contains("Multiple spaces"));
404 }
405
406 #[test]
407 fn test_malformed_nested_quote() {
408 let rule = MD027MultipleSpacesBlockquote;
409 let content = ">>This is a nested blockquote without space after markers";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert_eq!(result.len(), 0);
416 }
417
418 #[test]
419 fn test_malformed_deeply_nested() {
420 let rule = MD027MultipleSpacesBlockquote;
421 let content = ">>>This is deeply nested without spaces 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_extra_quote_marker() {
431 let rule = MD027MultipleSpacesBlockquote;
432 let content = "> >This looks like nested but is actually single level with >This as content";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert_eq!(result.len(), 0);
438 }
439
440 #[test]
441 fn test_indented_missing_space() {
442 let rule = MD027MultipleSpacesBlockquote;
443 let content = " >This has 3 spaces indent and no space after marker";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 0);
450 }
451
452 #[test]
453 fn test_fix_multiple_spaces() {
454 let rule = MD027MultipleSpacesBlockquote;
455 let content = "> Two spaces\n> Three spaces";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let fixed = rule.fix(&ctx).unwrap();
458 assert_eq!(fixed, "> Two spaces\n> Three spaces");
459 }
460
461 #[test]
462 fn test_fix_malformed_quotes() {
463 let rule = MD027MultipleSpacesBlockquote;
464 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let fixed = rule.fix(&ctx).unwrap();
468 assert_eq!(fixed, content);
470 }
471
472 #[test]
473 fn test_fix_extra_marker() {
474 let rule = MD027MultipleSpacesBlockquote;
475 let content = "> >Extra marker here";
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_code_block_ignored() {
485 let rule = MD027MultipleSpacesBlockquote;
486 let content = "```\n> This is in a code block\n```";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489 assert!(result.is_empty(), "Code blocks should be ignored");
490 }
491
492 #[test]
493 fn test_short_content_not_flagged() {
494 let rule = MD027MultipleSpacesBlockquote;
495 let content = ">>>\n>>";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert!(result.is_empty(), "Very short content should not be flagged");
499 }
500
501 #[test]
502 fn test_non_prose_not_flagged() {
503 let rule = MD027MultipleSpacesBlockquote;
504 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507 assert!(result.is_empty(), "Non-prose content should not be flagged");
508 }
509
510 #[test]
511 fn test_preserve_trailing_newline() {
512 let rule = MD027MultipleSpacesBlockquote;
513 let content = "> Two spaces\n";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let fixed = rule.fix(&ctx).unwrap();
516 assert_eq!(fixed, "> Two spaces\n");
517
518 let content_no_newline = "> Two spaces";
519 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
520 let fixed2 = rule.fix(&ctx2).unwrap();
521 assert_eq!(fixed2, "> Two spaces");
522 }
523
524 #[test]
525 fn test_mixed_issues() {
526 let rule = MD027MultipleSpacesBlockquote;
527 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
531 assert_eq!(result[0].line, 1);
532 }
533
534 #[test]
535 fn test_looks_like_blockquote_attempt() {
536 let rule = MD027MultipleSpacesBlockquote;
537
538 assert!(rule.looks_like_blockquote_attempt(
540 ">>This is a real blockquote attempt with text",
541 "> > This is a real blockquote attempt with text"
542 ));
543
544 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
546
547 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
549
550 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
552 }
553
554 #[test]
555 fn test_extract_blockquote_fix() {
556 let rule = MD027MultipleSpacesBlockquote;
557 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
558 let cap = regex.captures(">>content").unwrap();
559
560 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
561 assert!(result.is_some());
562 let (fixed, desc) = result.unwrap();
563 assert_eq!(fixed, "> > content");
564 assert!(desc.contains("Missing spaces"));
565 }
566
567 #[test]
568 fn test_empty_blockquote() {
569 let rule = MD027MultipleSpacesBlockquote;
570 let content = ">\n> \n> content";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert_eq!(result.len(), 1);
575 assert_eq!(result[0].line, 2);
576 }
577
578 #[test]
579 fn test_fix_preserves_indentation() {
580 let rule = MD027MultipleSpacesBlockquote;
581 let content = " > Indented with multiple spaces";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let fixed = rule.fix(&ctx).unwrap();
584 assert_eq!(fixed, " > Indented with multiple spaces");
585 }
586
587 #[test]
588 fn test_tabs_after_marker_not_flagged() {
589 let rule = MD027MultipleSpacesBlockquote;
593
594 let content = ">\tTab after marker";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
599
600 let content2 = ">\t\tTwo tabs";
602 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
603 let result2 = rule.check(&ctx2).unwrap();
604 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
605 }
606
607 #[test]
608 fn test_mixed_spaces_and_tabs() {
609 let rule = MD027MultipleSpacesBlockquote;
610 let content = "> Space Space";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert_eq!(result.len(), 1);
616 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
620 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
621 let result2 = rule.check(&ctx2).unwrap();
622 assert_eq!(result2.len(), 1);
623 }
624
625 #[test]
626 fn test_fix_multiple_spaces_various() {
627 let rule = MD027MultipleSpacesBlockquote;
628 let content = "> Three spaces";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let fixed = rule.fix(&ctx).unwrap();
632 assert_eq!(fixed, "> Three spaces");
633
634 let content2 = "> Four spaces";
636 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
637 let fixed2 = rule.fix(&ctx2).unwrap();
638 assert_eq!(fixed2, "> Four spaces");
639 }
640
641 #[test]
642 fn test_list_continuation_inside_blockquote_not_flagged() {
643 let rule = MD027MultipleSpacesBlockquote;
646
647 let content = "> - Item starts here\n> This continues the item\n> - Another item";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert!(
652 result.is_empty(),
653 "List continuation inside blockquote should not be flagged, got: {result:?}"
654 );
655
656 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
658 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
659 let result2 = rule.check(&ctx2).unwrap();
660 assert!(
661 result2.is_empty(),
662 "List continuations should not be flagged, got: {result2:?}"
663 );
664 }
665
666 #[test]
667 fn test_list_continuation_fix_preserves_indentation() {
668 let rule = MD027MultipleSpacesBlockquote;
670
671 let content = "> - Item\n> continuation";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let fixed = rule.fix(&ctx).unwrap();
674 assert_eq!(fixed, "> - Item\n> continuation");
676 }
677
678 #[test]
679 fn test_non_list_multiple_spaces_still_flagged() {
680 let rule = MD027MultipleSpacesBlockquote;
682
683 let content = "> This has extra spaces";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert_eq!(result.len(), 1, "Non-list line should be flagged");
688 }
689}