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 if let Some(blockquote) = &line_info.blockquote {
163 let is_likely_list_continuation =
166 ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
167 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
168 let fixed_line = if blockquote.content.is_empty() {
171 format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
172 } else {
173 format!(
174 "{}{} {}",
175 blockquote.indent,
176 ">".repeat(blockquote.nesting_level),
177 blockquote.content
178 )
179 };
180 result.push(fixed_line);
181 } else {
182 result.push(line_info.content(ctx.content).to_string());
183 }
184 } else {
185 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
187 if !malformed_attempts.is_empty() {
188 let (_, _, fixed_line, _) = &malformed_attempts[0];
190 result.push(fixed_line.clone());
191 } else {
192 result.push(line_info.content(ctx.content).to_string());
193 }
194 }
195 }
196
197 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
199 }
200
201 fn as_any(&self) -> &dyn std::any::Any {
202 self
203 }
204
205 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
206 where
207 Self: Sized,
208 {
209 Box::new(MD027MultipleSpacesBlockquote)
210 }
211
212 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
214 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
215 }
216}
217
218impl MD027MultipleSpacesBlockquote {
219 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
223 for prev_idx in (0..line_idx).rev() {
226 let prev_line = &ctx.lines[prev_idx];
227
228 if prev_line.blockquote.is_none() {
230 return false;
231 }
232
233 if prev_line.list_item.is_some() {
235 return true;
236 }
237
238 if ctx.is_in_list_block(prev_idx + 1) {
240 return true;
241 }
242 }
243 false
244 }
245
246 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
248 let mut results = Vec::new();
249
250 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
251 if let Some(cap) = pattern.captures(line) {
252 let match_obj = cap.get(0).unwrap();
253 let start = match_obj.start();
254 let len = match_obj.len();
255
256 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
258 {
259 if self.looks_like_blockquote_attempt(line, &fixed_line) {
261 results.push((start, len, fixed_line, description));
262 }
263 }
264 }
265 }
266
267 results
268 }
269
270 fn extract_blockquote_fix_from_match(
272 &self,
273 cap: ®ex::Captures,
274 issue_type: &str,
275 _original_line: &str,
276 ) -> Option<(String, String)> {
277 match issue_type {
278 "missing spaces in nested blockquote" => {
279 let indent = cap.get(1).map_or("", |m| m.as_str());
281 let content = cap.get(2).map_or("", |m| m.as_str());
282 Some((
283 format!("{}> > {}", indent, content.trim()),
284 "Missing spaces in nested blockquote".to_string(),
285 ))
286 }
287 "missing spaces in deeply nested blockquote" => {
288 let indent = cap.get(1).map_or("", |m| m.as_str());
290 let content = cap.get(2).map_or("", |m| m.as_str());
291 Some((
292 format!("{}> > > {}", indent, content.trim()),
293 "Missing spaces in deeply nested blockquote".to_string(),
294 ))
295 }
296 "extra blockquote marker" => {
297 let indent = cap.get(1).map_or("", |m| m.as_str());
299 let content = cap.get(2).map_or("", |m| m.as_str());
300 Some((
301 format!("{}> {}", indent, content.trim()),
302 "Extra blockquote marker".to_string(),
303 ))
304 }
305 "indented blockquote missing space" => {
306 let indent = cap.get(1).map_or("", |m| m.as_str());
308 let content = cap.get(2).map_or("", |m| m.as_str());
309 Some((
310 format!("{}> {}", indent, content.trim()),
311 "Indented blockquote missing space".to_string(),
312 ))
313 }
314 _ => None,
315 }
316 }
317
318 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
320 let trimmed_original = original.trim();
324 if trimmed_original.len() < 5 {
325 return false;
327 }
328
329 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
331 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
332 return false;
334 }
335
336 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
338 return false;
339 }
340
341 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
344 return false;
345 }
346
347 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")
354 {
356 return false;
357 }
358
359 let word_count = content_after_markers.split_whitespace().count();
361 if word_count < 3 {
362 return false;
364 }
365
366 true
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::lint_context::LintContext;
374
375 #[test]
376 fn test_valid_blockquote() {
377 let rule = MD027MultipleSpacesBlockquote;
378 let content = "> This is a blockquote\n> > Nested quote";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
382 }
383
384 #[test]
385 fn test_multiple_spaces_after_marker() {
386 let rule = MD027MultipleSpacesBlockquote;
387 let content = "> This has two spaces\n> This has three spaces";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390 assert_eq!(result.len(), 2);
391 assert_eq!(result[0].line, 1);
392 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
394 assert_eq!(result[1].line, 2);
395 assert_eq!(result[1].column, 3);
396 }
397
398 #[test]
399 fn test_nested_multiple_spaces() {
400 let rule = MD027MultipleSpacesBlockquote;
401 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404 let result = rule.check(&ctx).unwrap();
405 assert_eq!(result.len(), 2);
406 assert!(result[0].message.contains("Multiple spaces"));
407 assert!(result[1].message.contains("Multiple spaces"));
408 }
409
410 #[test]
411 fn test_malformed_nested_quote() {
412 let rule = MD027MultipleSpacesBlockquote;
413 let content = ">>This is a nested blockquote without space after markers";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert_eq!(result.len(), 0);
420 }
421
422 #[test]
423 fn test_malformed_deeply_nested() {
424 let rule = MD027MultipleSpacesBlockquote;
425 let content = ">>>This is deeply nested without spaces 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_extra_quote_marker() {
435 let rule = MD027MultipleSpacesBlockquote;
436 let content = "> >This looks like nested but is actually single level with >This as content";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440 let result = rule.check(&ctx).unwrap();
441 assert_eq!(result.len(), 0);
442 }
443
444 #[test]
445 fn test_indented_missing_space() {
446 let rule = MD027MultipleSpacesBlockquote;
447 let content = " >This has 3 spaces indent and no space after marker";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert_eq!(result.len(), 0);
454 }
455
456 #[test]
457 fn test_fix_multiple_spaces() {
458 let rule = MD027MultipleSpacesBlockquote;
459 let content = "> Two spaces\n> Three spaces";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461 let fixed = rule.fix(&ctx).unwrap();
462 assert_eq!(fixed, "> Two spaces\n> Three spaces");
463 }
464
465 #[test]
466 fn test_fix_malformed_quotes() {
467 let rule = MD027MultipleSpacesBlockquote;
468 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471 let fixed = rule.fix(&ctx).unwrap();
472 assert_eq!(fixed, content);
474 }
475
476 #[test]
477 fn test_fix_extra_marker() {
478 let rule = MD027MultipleSpacesBlockquote;
479 let content = "> >Extra marker here";
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_code_block_ignored() {
489 let rule = MD027MultipleSpacesBlockquote;
490 let content = "```\n> This is in a code block\n```";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert!(result.is_empty(), "Code blocks should be ignored");
494 }
495
496 #[test]
497 fn test_short_content_not_flagged() {
498 let rule = MD027MultipleSpacesBlockquote;
499 let content = ">>>\n>>";
500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501 let result = rule.check(&ctx).unwrap();
502 assert!(result.is_empty(), "Very short content should not be flagged");
503 }
504
505 #[test]
506 fn test_non_prose_not_flagged() {
507 let rule = MD027MultipleSpacesBlockquote;
508 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511 assert!(result.is_empty(), "Non-prose content should not be flagged");
512 }
513
514 #[test]
515 fn test_preserve_trailing_newline() {
516 let rule = MD027MultipleSpacesBlockquote;
517 let content = "> Two spaces\n";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let fixed = rule.fix(&ctx).unwrap();
520 assert_eq!(fixed, "> Two spaces\n");
521
522 let content_no_newline = "> Two spaces";
523 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
524 let fixed2 = rule.fix(&ctx2).unwrap();
525 assert_eq!(fixed2, "> Two spaces");
526 }
527
528 #[test]
529 fn test_mixed_issues() {
530 let rule = MD027MultipleSpacesBlockquote;
531 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
535 assert_eq!(result[0].line, 1);
536 }
537
538 #[test]
539 fn test_looks_like_blockquote_attempt() {
540 let rule = MD027MultipleSpacesBlockquote;
541
542 assert!(rule.looks_like_blockquote_attempt(
544 ">>This is a real blockquote attempt with text",
545 "> > This is a real blockquote attempt with text"
546 ));
547
548 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
550
551 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
553
554 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
556 }
557
558 #[test]
559 fn test_extract_blockquote_fix() {
560 let rule = MD027MultipleSpacesBlockquote;
561 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
562 let cap = regex.captures(">>content").unwrap();
563
564 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
565 assert!(result.is_some());
566 let (fixed, desc) = result.unwrap();
567 assert_eq!(fixed, "> > content");
568 assert!(desc.contains("Missing spaces"));
569 }
570
571 #[test]
572 fn test_empty_blockquote() {
573 let rule = MD027MultipleSpacesBlockquote;
574 let content = ">\n> \n> content";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let result = rule.check(&ctx).unwrap();
577 assert_eq!(result.len(), 1);
579 assert_eq!(result[0].line, 2);
580 }
581
582 #[test]
583 fn test_fix_preserves_indentation() {
584 let rule = MD027MultipleSpacesBlockquote;
585 let content = " > Indented with multiple spaces";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let fixed = rule.fix(&ctx).unwrap();
588 assert_eq!(fixed, " > Indented with multiple spaces");
589 }
590
591 #[test]
592 fn test_tabs_after_marker_not_flagged() {
593 let rule = MD027MultipleSpacesBlockquote;
597
598 let content = ">\tTab after marker";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
603
604 let content2 = ">\t\tTwo tabs";
606 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
607 let result2 = rule.check(&ctx2).unwrap();
608 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
609 }
610
611 #[test]
612 fn test_mixed_spaces_and_tabs() {
613 let rule = MD027MultipleSpacesBlockquote;
614 let content = "> Space Space";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(result.len(), 1);
620 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
624 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
625 let result2 = rule.check(&ctx2).unwrap();
626 assert_eq!(result2.len(), 1);
627 }
628
629 #[test]
630 fn test_fix_multiple_spaces_various() {
631 let rule = MD027MultipleSpacesBlockquote;
632 let content = "> Three spaces";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let fixed = rule.fix(&ctx).unwrap();
636 assert_eq!(fixed, "> Three spaces");
637
638 let content2 = "> Four spaces";
640 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
641 let fixed2 = rule.fix(&ctx2).unwrap();
642 assert_eq!(fixed2, "> Four spaces");
643 }
644
645 #[test]
646 fn test_list_continuation_inside_blockquote_not_flagged() {
647 let rule = MD027MultipleSpacesBlockquote;
650
651 let content = "> - Item starts here\n> This continues the item\n> - Another item";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert!(
656 result.is_empty(),
657 "List continuation inside blockquote should not be flagged, got: {result:?}"
658 );
659
660 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
662 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
663 let result2 = rule.check(&ctx2).unwrap();
664 assert!(
665 result2.is_empty(),
666 "List continuations should not be flagged, got: {result2:?}"
667 );
668 }
669
670 #[test]
671 fn test_list_continuation_fix_preserves_indentation() {
672 let rule = MD027MultipleSpacesBlockquote;
674
675 let content = "> - Item\n> continuation";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let fixed = rule.fix(&ctx).unwrap();
678 assert_eq!(fixed, "> - Item\n> continuation");
680 }
681
682 #[test]
683 fn test_non_list_multiple_spaces_still_flagged() {
684 let rule = MD027MultipleSpacesBlockquote;
686
687 let content = "> This has extra spaces";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert_eq!(result.len(), 1, "Non-list line should be flagged");
692 }
693}