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 (fix_byte_pos, fix_bytes) = if blockquote.content.is_empty() {
107 let first_space_pos = byte_pos - 1;
110 let all_spaces_bytes = line_info.content(ctx.content)[first_space_pos..]
111 .chars()
112 .take_while(|&c| c == ' ' || c == '\t')
113 .fold(0, |acc, ch| acc + ch.len_utf8());
114 (first_space_pos, all_spaces_bytes)
115 } else {
116 (byte_pos, extra_spaces_bytes)
117 };
118
119 let (start_line, start_col, end_line, end_col) =
120 calculate_match_range(line_num, line_info.content(ctx.content), fix_byte_pos, fix_bytes);
121
122 warnings.push(LintWarning {
123 rule_name: Some(self.name().to_string()),
124 line: start_line,
125 column: start_col,
126 end_line,
127 end_column: end_col,
128 message: "Multiple spaces after quote marker (>)".to_string(),
129 severity: Severity::Warning,
130 fix: Some(Fix {
131 range: {
132 let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
133 let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
134 start_byte..end_byte
135 },
136 replacement: "".to_string(),
137 }),
138 });
139 }
140 }
141 } else {
142 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
144 for (start, len, fixed_line, description) in malformed_attempts {
145 let (start_line, start_col, end_line, end_col) =
146 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
147
148 warnings.push(LintWarning {
149 rule_name: Some(self.name().to_string()),
150 line: start_line,
151 column: start_col,
152 end_line,
153 end_column: end_col,
154 message: format!("Malformed quote: {description}"),
155 severity: Severity::Warning,
156 fix: Some(Fix {
157 range: ctx.line_index.line_col_to_byte_range_with_length(
158 line_num,
159 1,
160 line_info.content(ctx.content).chars().count(),
161 ),
162 replacement: fixed_line,
163 }),
164 });
165 }
166 }
167 }
168
169 Ok(warnings)
170 }
171
172 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
173 if self.should_skip(ctx) {
174 return Ok(ctx.content.to_string());
175 }
176 let warnings = self.check(ctx)?;
177 if warnings.is_empty() {
178 return Ok(ctx.content.to_string());
179 }
180 let warnings =
181 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
182 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
183 .map_err(crate::rule::LintError::InvalidInput)
184 }
185
186 fn as_any(&self) -> &dyn std::any::Any {
187 self
188 }
189
190 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
191 where
192 Self: Sized,
193 {
194 Box::new(MD027MultipleSpacesBlockquote)
195 }
196
197 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
199 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
200 }
201}
202
203impl MD027MultipleSpacesBlockquote {
204 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
208 for prev_idx in (0..line_idx).rev() {
211 let prev_line = &ctx.lines[prev_idx];
212
213 if prev_line.blockquote.is_none() {
215 return false;
216 }
217
218 if prev_line.list_item.is_some() {
220 return true;
221 }
222
223 if ctx.is_in_list_block(prev_idx + 1) {
225 return true;
226 }
227 }
228 false
229 }
230
231 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
233 let mut results = Vec::new();
234
235 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
236 if let Some(cap) = pattern.captures(line) {
237 let match_obj = cap.get(0).unwrap();
238 let start = match_obj.start();
239 let len = match_obj.len();
240
241 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
243 {
244 if self.looks_like_blockquote_attempt(line, &fixed_line) {
246 results.push((start, len, fixed_line, description));
247 }
248 }
249 }
250 }
251
252 results
253 }
254
255 fn extract_blockquote_fix_from_match(
257 &self,
258 cap: ®ex::Captures,
259 issue_type: &str,
260 _original_line: &str,
261 ) -> Option<(String, String)> {
262 match issue_type {
263 "missing spaces in nested blockquote" => {
264 let indent = cap.get(1).map_or("", |m| m.as_str());
266 let content = cap.get(2).map_or("", |m| m.as_str());
267 Some((
268 format!("{}> > {}", indent, content.trim()),
269 "Missing spaces in nested blockquote".to_string(),
270 ))
271 }
272 "missing spaces in deeply nested blockquote" => {
273 let indent = cap.get(1).map_or("", |m| m.as_str());
275 let content = cap.get(2).map_or("", |m| m.as_str());
276 Some((
277 format!("{}> > > {}", indent, content.trim()),
278 "Missing spaces in deeply nested blockquote".to_string(),
279 ))
280 }
281 "extra blockquote marker" => {
282 let indent = cap.get(1).map_or("", |m| m.as_str());
284 let content = cap.get(2).map_or("", |m| m.as_str());
285 Some((
286 format!("{}> {}", indent, content.trim()),
287 "Extra blockquote marker".to_string(),
288 ))
289 }
290 "indented blockquote missing space" => {
291 let indent = cap.get(1).map_or("", |m| m.as_str());
293 let content = cap.get(2).map_or("", |m| m.as_str());
294 Some((
295 format!("{}> {}", indent, content.trim()),
296 "Indented blockquote missing space".to_string(),
297 ))
298 }
299 _ => None,
300 }
301 }
302
303 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
305 let trimmed_original = original.trim();
309 if trimmed_original.len() < 5 {
310 return false;
312 }
313
314 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
316 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
317 return false;
319 }
320
321 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
323 return false;
324 }
325
326 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
329 return false;
330 }
331
332 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")
339 {
341 return false;
342 }
343
344 let word_count = content_after_markers.split_whitespace().count();
346 if word_count < 3 {
347 return false;
349 }
350
351 true
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::lint_context::LintContext;
359
360 #[test]
361 fn test_valid_blockquote() {
362 let rule = MD027MultipleSpacesBlockquote;
363 let content = "> This is a blockquote\n> > Nested quote";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let result = rule.check(&ctx).unwrap();
366 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
367 }
368
369 #[test]
370 fn test_multiple_spaces_after_marker() {
371 let rule = MD027MultipleSpacesBlockquote;
372 let content = "> This has two spaces\n> This has three spaces";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374 let result = rule.check(&ctx).unwrap();
375 assert_eq!(result.len(), 2);
376 assert_eq!(result[0].line, 1);
377 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
379 assert_eq!(result[1].line, 2);
380 assert_eq!(result[1].column, 3);
381 }
382
383 #[test]
384 fn test_nested_multiple_spaces() {
385 let rule = MD027MultipleSpacesBlockquote;
386 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
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!(result[0].message.contains("Multiple spaces"));
392 assert!(result[1].message.contains("Multiple spaces"));
393 }
394
395 #[test]
396 fn test_malformed_nested_quote() {
397 let rule = MD027MultipleSpacesBlockquote;
398 let content = ">>This is a nested blockquote without space after markers";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403 assert_eq!(result.len(), 0);
405 }
406
407 #[test]
408 fn test_malformed_deeply_nested() {
409 let rule = MD027MultipleSpacesBlockquote;
410 let content = ">>>This is deeply nested without spaces 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_extra_quote_marker() {
420 let rule = MD027MultipleSpacesBlockquote;
421 let content = "> >This looks like nested but is actually single level with >This as content";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert_eq!(result.len(), 0);
427 }
428
429 #[test]
430 fn test_indented_missing_space() {
431 let rule = MD027MultipleSpacesBlockquote;
432 let content = " >This has 3 spaces indent and no space after marker";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert_eq!(result.len(), 0);
439 }
440
441 #[test]
442 fn test_fix_multiple_spaces() {
443 let rule = MD027MultipleSpacesBlockquote;
444 let content = "> Two spaces\n> Three spaces";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let fixed = rule.fix(&ctx).unwrap();
447 assert_eq!(fixed, "> Two spaces\n> Three spaces");
448 }
449
450 #[test]
451 fn test_fix_malformed_quotes() {
452 let rule = MD027MultipleSpacesBlockquote;
453 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let fixed = rule.fix(&ctx).unwrap();
457 assert_eq!(fixed, content);
459 }
460
461 #[test]
462 fn test_fix_extra_marker() {
463 let rule = MD027MultipleSpacesBlockquote;
464 let content = "> >Extra marker here";
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_code_block_ignored() {
474 let rule = MD027MultipleSpacesBlockquote;
475 let content = "```\n> This is in a code block\n```";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert!(result.is_empty(), "Code blocks should be ignored");
479 }
480
481 #[test]
482 fn test_short_content_not_flagged() {
483 let rule = MD027MultipleSpacesBlockquote;
484 let content = ">>>\n>>";
485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.check(&ctx).unwrap();
487 assert!(result.is_empty(), "Very short content should not be flagged");
488 }
489
490 #[test]
491 fn test_non_prose_not_flagged() {
492 let rule = MD027MultipleSpacesBlockquote;
493 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496 assert!(result.is_empty(), "Non-prose content should not be flagged");
497 }
498
499 #[test]
500 fn test_preserve_trailing_newline() {
501 let rule = MD027MultipleSpacesBlockquote;
502 let content = "> Two spaces\n";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504 let fixed = rule.fix(&ctx).unwrap();
505 assert_eq!(fixed, "> Two spaces\n");
506
507 let content_no_newline = "> Two spaces";
508 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
509 let fixed2 = rule.fix(&ctx2).unwrap();
510 assert_eq!(fixed2, "> Two spaces");
511 }
512
513 #[test]
514 fn test_mixed_issues() {
515 let rule = MD027MultipleSpacesBlockquote;
516 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
520 assert_eq!(result[0].line, 1);
521 }
522
523 #[test]
524 fn test_looks_like_blockquote_attempt() {
525 let rule = MD027MultipleSpacesBlockquote;
526
527 assert!(rule.looks_like_blockquote_attempt(
529 ">>This is a real blockquote attempt with text",
530 "> > This is a real blockquote attempt with text"
531 ));
532
533 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
535
536 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
538
539 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
541 }
542
543 #[test]
544 fn test_extract_blockquote_fix() {
545 let rule = MD027MultipleSpacesBlockquote;
546 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
547 let cap = regex.captures(">>content").unwrap();
548
549 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
550 assert!(result.is_some());
551 let (fixed, desc) = result.unwrap();
552 assert_eq!(fixed, "> > content");
553 assert!(desc.contains("Missing spaces"));
554 }
555
556 #[test]
557 fn test_empty_blockquote() {
558 let rule = MD027MultipleSpacesBlockquote;
559 let content = ">\n> \n> content";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562 assert_eq!(result.len(), 1);
564 assert_eq!(result[0].line, 2);
565 }
566
567 #[test]
568 fn test_fix_preserves_indentation() {
569 let rule = MD027MultipleSpacesBlockquote;
570 let content = " > Indented with multiple spaces";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let fixed = rule.fix(&ctx).unwrap();
573 assert_eq!(fixed, " > Indented with multiple spaces");
574 }
575
576 #[test]
577 fn test_tabs_after_marker_not_flagged() {
578 let rule = MD027MultipleSpacesBlockquote;
582
583 let content = ">\tTab after marker";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
588
589 let content2 = ">\t\tTwo tabs";
591 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
592 let result2 = rule.check(&ctx2).unwrap();
593 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
594 }
595
596 #[test]
597 fn test_mixed_spaces_and_tabs() {
598 let rule = MD027MultipleSpacesBlockquote;
599 let content = "> Space Space";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert_eq!(result.len(), 1);
605 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
609 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
610 let result2 = rule.check(&ctx2).unwrap();
611 assert_eq!(result2.len(), 1);
612 }
613
614 #[test]
615 fn test_fix_multiple_spaces_various() {
616 let rule = MD027MultipleSpacesBlockquote;
617 let content = "> Three spaces";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let fixed = rule.fix(&ctx).unwrap();
621 assert_eq!(fixed, "> Three spaces");
622
623 let content2 = "> Four spaces";
625 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
626 let fixed2 = rule.fix(&ctx2).unwrap();
627 assert_eq!(fixed2, "> Four spaces");
628 }
629
630 #[test]
631 fn test_list_continuation_inside_blockquote_not_flagged() {
632 let rule = MD027MultipleSpacesBlockquote;
635
636 let content = "> - Item starts here\n> This continues the item\n> - Another item";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(
641 result.is_empty(),
642 "List continuation inside blockquote should not be flagged, got: {result:?}"
643 );
644
645 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
647 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
648 let result2 = rule.check(&ctx2).unwrap();
649 assert!(
650 result2.is_empty(),
651 "List continuations should not be flagged, got: {result2:?}"
652 );
653 }
654
655 #[test]
656 fn test_list_continuation_fix_preserves_indentation() {
657 let rule = MD027MultipleSpacesBlockquote;
659
660 let content = "> - Item\n> continuation";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, "> - Item\n> continuation");
665 }
666
667 #[test]
668 fn test_non_list_multiple_spaces_still_flagged() {
669 let rule = MD027MultipleSpacesBlockquote;
671
672 let content = "> This has extra spaces";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert_eq!(result.len(), 1, "Non-list line should be flagged");
677 }
678}