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 {
60 continue;
61 }
62
63 if let Some(blockquote) = &line_info.blockquote {
65 if blockquote.has_multiple_spaces_after_marker {
67 let mut byte_pos = 0;
70 let mut found_markers = 0;
71 let mut found_first_space = false;
72
73 for (i, ch) in line_info.content(ctx.content).char_indices() {
74 if found_markers < blockquote.nesting_level {
75 if ch == '>' {
76 found_markers += 1;
77 }
78 } else if !found_first_space && (ch == ' ' || ch == '\t') {
79 found_first_space = true;
81 } else if found_first_space && (ch == ' ' || ch == '\t') {
82 byte_pos = i;
84 break;
85 }
86 }
87
88 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
90 .chars()
91 .take_while(|&c| c == ' ' || c == '\t')
92 .fold(0, |acc, ch| acc + ch.len_utf8());
93
94 if extra_spaces_bytes > 0 {
95 let (start_line, start_col, end_line, end_col) = calculate_match_range(
96 line_num,
97 line_info.content(ctx.content),
98 byte_pos,
99 extra_spaces_bytes,
100 );
101
102 warnings.push(LintWarning {
103 rule_name: Some(self.name().to_string()),
104 line: start_line,
105 column: start_col,
106 end_line,
107 end_column: end_col,
108 message: "Multiple spaces after quote marker (>)".to_string(),
109 severity: Severity::Warning,
110 fix: Some(Fix {
111 range: {
112 let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
113 let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
114 start_byte..end_byte
115 },
116 replacement: "".to_string(), }),
118 });
119 }
120 }
121 } else {
122 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
124 for (start, len, fixed_line, description) in malformed_attempts {
125 let (start_line, start_col, end_line, end_col) =
126 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
127
128 warnings.push(LintWarning {
129 rule_name: Some(self.name().to_string()),
130 line: start_line,
131 column: start_col,
132 end_line,
133 end_column: end_col,
134 message: format!("Malformed quote: {description}"),
135 severity: Severity::Warning,
136 fix: Some(Fix {
137 range: ctx.line_index.line_col_to_byte_range(line_num, 1),
138 replacement: fixed_line,
139 }),
140 });
141 }
142 }
143 }
144
145 Ok(warnings)
146 }
147
148 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
149 let mut result = Vec::with_capacity(ctx.lines.len());
150
151 for line_info in &ctx.lines {
152 if let Some(blockquote) = &line_info.blockquote {
153 if blockquote.has_multiple_spaces_after_marker {
155 let fixed_line = if blockquote.content.is_empty() {
158 format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
159 } else {
160 format!(
161 "{}{} {}",
162 blockquote.indent,
163 ">".repeat(blockquote.nesting_level),
164 blockquote.content
165 )
166 };
167 result.push(fixed_line);
168 } else {
169 result.push(line_info.content(ctx.content).to_string());
170 }
171 } else {
172 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
174 if !malformed_attempts.is_empty() {
175 let (_, _, fixed_line, _) = &malformed_attempts[0];
177 result.push(fixed_line.clone());
178 } else {
179 result.push(line_info.content(ctx.content).to_string());
180 }
181 }
182 }
183
184 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
186 }
187
188 fn as_any(&self) -> &dyn std::any::Any {
189 self
190 }
191
192 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
193 where
194 Self: Sized,
195 {
196 Box::new(MD027MultipleSpacesBlockquote)
197 }
198
199 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
201 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
202 }
203}
204
205impl MD027MultipleSpacesBlockquote {
206 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
208 let mut results = Vec::new();
209
210 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
211 if let Some(cap) = pattern.captures(line) {
212 let match_obj = cap.get(0).unwrap();
213 let start = match_obj.start();
214 let len = match_obj.len();
215
216 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
218 {
219 if self.looks_like_blockquote_attempt(line, &fixed_line) {
221 results.push((start, len, fixed_line, description));
222 }
223 }
224 }
225 }
226
227 results
228 }
229
230 fn extract_blockquote_fix_from_match(
232 &self,
233 cap: ®ex::Captures,
234 issue_type: &str,
235 _original_line: &str,
236 ) -> Option<(String, String)> {
237 match issue_type {
238 "missing spaces in nested blockquote" => {
239 let indent = cap.get(1).map_or("", |m| m.as_str());
241 let content = cap.get(2).map_or("", |m| m.as_str());
242 Some((
243 format!("{}> > {}", indent, content.trim()),
244 "Missing spaces in nested blockquote".to_string(),
245 ))
246 }
247 "missing spaces in deeply nested blockquote" => {
248 let indent = cap.get(1).map_or("", |m| m.as_str());
250 let content = cap.get(2).map_or("", |m| m.as_str());
251 Some((
252 format!("{}> > > {}", indent, content.trim()),
253 "Missing spaces in deeply nested blockquote".to_string(),
254 ))
255 }
256 "extra blockquote marker" => {
257 let indent = cap.get(1).map_or("", |m| m.as_str());
259 let content = cap.get(2).map_or("", |m| m.as_str());
260 Some((
261 format!("{}> {}", indent, content.trim()),
262 "Extra blockquote marker".to_string(),
263 ))
264 }
265 "indented blockquote missing space" => {
266 let indent = cap.get(1).map_or("", |m| m.as_str());
268 let content = cap.get(2).map_or("", |m| m.as_str());
269 Some((
270 format!("{}> {}", indent, content.trim()),
271 "Indented blockquote missing space".to_string(),
272 ))
273 }
274 _ => None,
275 }
276 }
277
278 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
280 let trimmed_original = original.trim();
284 if trimmed_original.len() < 5 {
285 return false;
287 }
288
289 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
291 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
292 return false;
294 }
295
296 if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
298 return false;
299 }
300
301 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
304 return false;
305 }
306
307 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")
314 {
316 return false;
317 }
318
319 let word_count = content_after_markers.split_whitespace().count();
321 if word_count < 3 {
322 return false;
324 }
325
326 true
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::lint_context::LintContext;
334
335 #[test]
336 fn test_valid_blockquote() {
337 let rule = MD027MultipleSpacesBlockquote;
338 let content = "> This is a blockquote\n> > Nested quote";
339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
340 let result = rule.check(&ctx).unwrap();
341 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
342 }
343
344 #[test]
345 fn test_multiple_spaces_after_marker() {
346 let rule = MD027MultipleSpacesBlockquote;
347 let content = "> This has two spaces\n> This has three spaces";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349 let result = rule.check(&ctx).unwrap();
350 assert_eq!(result.len(), 2);
351 assert_eq!(result[0].line, 1);
352 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
354 assert_eq!(result[1].line, 2);
355 assert_eq!(result[1].column, 3);
356 }
357
358 #[test]
359 fn test_nested_multiple_spaces() {
360 let rule = MD027MultipleSpacesBlockquote;
361 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364 let result = rule.check(&ctx).unwrap();
365 assert_eq!(result.len(), 2);
366 assert!(result[0].message.contains("Multiple spaces"));
367 assert!(result[1].message.contains("Multiple spaces"));
368 }
369
370 #[test]
371 fn test_malformed_nested_quote() {
372 let rule = MD027MultipleSpacesBlockquote;
373 let content = ">>This is a nested blockquote without space after markers";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
377 let result = rule.check(&ctx).unwrap();
378 assert_eq!(result.len(), 0);
380 }
381
382 #[test]
383 fn test_malformed_deeply_nested() {
384 let rule = MD027MultipleSpacesBlockquote;
385 let content = ">>>This is deeply nested without spaces after markers";
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388 let result = rule.check(&ctx).unwrap();
389 assert_eq!(result.len(), 0);
391 }
392
393 #[test]
394 fn test_extra_quote_marker() {
395 let rule = MD027MultipleSpacesBlockquote;
396 let content = "> >This looks like nested but is actually single level with >This as content";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 0);
402 }
403
404 #[test]
405 fn test_indented_missing_space() {
406 let rule = MD027MultipleSpacesBlockquote;
407 let content = " >This has 3 spaces indent and no space after marker";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
410 let result = rule.check(&ctx).unwrap();
411 assert_eq!(result.len(), 0);
414 }
415
416 #[test]
417 fn test_fix_multiple_spaces() {
418 let rule = MD027MultipleSpacesBlockquote;
419 let content = "> Two spaces\n> Three spaces";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
421 let fixed = rule.fix(&ctx).unwrap();
422 assert_eq!(fixed, "> Two spaces\n> Three spaces");
423 }
424
425 #[test]
426 fn test_fix_malformed_quotes() {
427 let rule = MD027MultipleSpacesBlockquote;
428 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
431 let fixed = rule.fix(&ctx).unwrap();
432 assert_eq!(fixed, content);
434 }
435
436 #[test]
437 fn test_fix_extra_marker() {
438 let rule = MD027MultipleSpacesBlockquote;
439 let content = "> >Extra marker here";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442 let fixed = rule.fix(&ctx).unwrap();
443 assert_eq!(fixed, content);
445 }
446
447 #[test]
448 fn test_code_block_ignored() {
449 let rule = MD027MultipleSpacesBlockquote;
450 let content = "```\n> This is in a code block\n```";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
452 let result = rule.check(&ctx).unwrap();
453 assert!(result.is_empty(), "Code blocks should be ignored");
454 }
455
456 #[test]
457 fn test_short_content_not_flagged() {
458 let rule = MD027MultipleSpacesBlockquote;
459 let content = ">>>\n>>";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
461 let result = rule.check(&ctx).unwrap();
462 assert!(result.is_empty(), "Very short content should not be flagged");
463 }
464
465 #[test]
466 fn test_non_prose_not_flagged() {
467 let rule = MD027MultipleSpacesBlockquote;
468 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470 let result = rule.check(&ctx).unwrap();
471 assert!(result.is_empty(), "Non-prose content should not be flagged");
472 }
473
474 #[test]
475 fn test_preserve_trailing_newline() {
476 let rule = MD027MultipleSpacesBlockquote;
477 let content = "> Two spaces\n";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
479 let fixed = rule.fix(&ctx).unwrap();
480 assert_eq!(fixed, "> Two spaces\n");
481
482 let content_no_newline = "> Two spaces";
483 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
484 let fixed2 = rule.fix(&ctx2).unwrap();
485 assert_eq!(fixed2, "> Two spaces");
486 }
487
488 #[test]
489 fn test_mixed_issues() {
490 let rule = MD027MultipleSpacesBlockquote;
491 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let result = rule.check(&ctx).unwrap();
494 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
495 assert_eq!(result[0].line, 1);
496 }
497
498 #[test]
499 fn test_looks_like_blockquote_attempt() {
500 let rule = MD027MultipleSpacesBlockquote;
501
502 assert!(rule.looks_like_blockquote_attempt(
504 ">>This is a real blockquote attempt with text",
505 "> > This is a real blockquote attempt with text"
506 ));
507
508 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
510
511 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
513
514 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
516 }
517
518 #[test]
519 fn test_extract_blockquote_fix() {
520 let rule = MD027MultipleSpacesBlockquote;
521 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
522 let cap = regex.captures(">>content").unwrap();
523
524 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
525 assert!(result.is_some());
526 let (fixed, desc) = result.unwrap();
527 assert_eq!(fixed, "> > content");
528 assert!(desc.contains("Missing spaces"));
529 }
530
531 #[test]
532 fn test_empty_blockquote() {
533 let rule = MD027MultipleSpacesBlockquote;
534 let content = ">\n> \n> content";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 1);
539 assert_eq!(result[0].line, 2);
540 }
541
542 #[test]
543 fn test_fix_preserves_indentation() {
544 let rule = MD027MultipleSpacesBlockquote;
545 let content = " > Indented with multiple spaces";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let fixed = rule.fix(&ctx).unwrap();
548 assert_eq!(fixed, " > Indented with multiple spaces");
549 }
550
551 #[test]
552 fn test_tabs_after_marker_not_flagged() {
553 let rule = MD027MultipleSpacesBlockquote;
557
558 let content = ">\tTab after marker";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
561 let result = rule.check(&ctx).unwrap();
562 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
563
564 let content2 = ">\t\tTwo tabs";
566 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
567 let result2 = rule.check(&ctx2).unwrap();
568 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
569 }
570
571 #[test]
572 fn test_mixed_spaces_and_tabs() {
573 let rule = MD027MultipleSpacesBlockquote;
574 let content = "> Space Space";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
578 let result = rule.check(&ctx).unwrap();
579 assert_eq!(result.len(), 1);
580 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
584 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
585 let result2 = rule.check(&ctx2).unwrap();
586 assert_eq!(result2.len(), 1);
587 }
588
589 #[test]
590 fn test_fix_multiple_spaces_various() {
591 let rule = MD027MultipleSpacesBlockquote;
592 let content = "> Three spaces";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
595 let fixed = rule.fix(&ctx).unwrap();
596 assert_eq!(fixed, "> Three spaces");
597
598 let content2 = "> Four spaces";
600 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
601 let fixed2 = rule.fix(&ctx2).unwrap();
602 assert_eq!(fixed2, "> Four spaces");
603 }
604}