1use crate::utils::range_utils::calculate_match_range;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::{RuleConfig, load_rule_config};
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::sync::LazyLock;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "kebab-case")]
18pub struct MD027Config {
19 #[serde(default, alias = "list_items")]
22 pub list_items: bool,
23}
24
25impl RuleConfig for MD027Config {
26 const RULE_NAME: &'static str = "MD027";
27}
28
29static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
31 vec![
32 (
34 Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
35 "missing spaces in nested blockquote",
36 ),
37 (
39 Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
40 "missing spaces in deeply nested blockquote",
41 ),
42 (
44 Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
45 "extra blockquote marker",
46 ),
47 (
49 Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
50 "indented blockquote missing space",
51 ),
52 ]
53});
54
55static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
57
58#[derive(Debug, Default, Clone)]
63pub struct MD027MultipleSpacesBlockquote {
64 config: MD027Config,
65}
66
67impl MD027MultipleSpacesBlockquote {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn with_config(config: MD027Config) -> Self {
73 Self { config }
74 }
75}
76
77impl Rule for MD027MultipleSpacesBlockquote {
78 fn name(&self) -> &'static str {
79 "MD027"
80 }
81
82 fn description(&self) -> &'static str {
83 "Multiple spaces after quote marker (>)"
84 }
85
86 fn category(&self) -> RuleCategory {
87 RuleCategory::Blockquote
88 }
89
90 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
91 let mut warnings = Vec::new();
92
93 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
94 let line_num = line_idx + 1;
95
96 if line_info.in_code_block
98 || line_info.in_html_block
99 || line_info.in_html_comment
100 || line_info.in_mdx_comment
101 || line_info.in_front_matter
102 || line_info.in_mkdocstrings
103 || line_info.in_jsx_block
104 || line_info.in_kramdown_extension_block
105 {
106 continue;
107 }
108
109 if let Some(blockquote) = &line_info.blockquote {
111 let skip_list_lines = !self.config.list_items;
118 let is_likely_list_continuation = skip_list_lines
119 && (ctx.is_in_list_block(line_num)
120 || line_info.list_item.is_some()
121 || self.previous_blockquote_line_had_list(ctx, line_idx));
122 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
123 let mut byte_pos = 0;
126 let mut found_markers = 0;
127 let mut found_first_space = false;
128
129 for (i, ch) in line_info.content(ctx.content).char_indices() {
130 if found_markers < blockquote.nesting_level {
131 if ch == '>' {
132 found_markers += 1;
133 }
134 } else if !found_first_space && (ch == ' ' || ch == '\t') {
135 found_first_space = true;
137 } else if found_first_space && (ch == ' ' || ch == '\t') {
138 byte_pos = i;
140 break;
141 }
142 }
143
144 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
146 .chars()
147 .take_while(|&c| c == ' ' || c == '\t')
148 .fold(0, |acc, ch| acc + ch.len_utf8());
149
150 if extra_spaces_bytes > 0 {
151 let (fix_byte_pos, fix_bytes) = if blockquote.content.is_empty() {
154 let first_space_pos = byte_pos - 1;
157 let all_spaces_bytes = line_info.content(ctx.content)[first_space_pos..]
158 .chars()
159 .take_while(|&c| c == ' ' || c == '\t')
160 .fold(0, |acc, ch| acc + ch.len_utf8());
161 (first_space_pos, all_spaces_bytes)
162 } else {
163 (byte_pos, extra_spaces_bytes)
164 };
165
166 let (start_line, start_col, end_line, end_col) =
167 calculate_match_range(line_num, line_info.content(ctx.content), fix_byte_pos, fix_bytes);
168
169 warnings.push(LintWarning {
170 rule_name: Some(self.name().to_string()),
171 line: start_line,
172 column: start_col,
173 end_line,
174 end_column: end_col,
175 message: "Multiple spaces after quote marker (>)".to_string(),
176 severity: Severity::Warning,
177 fix: Some(Fix::new(
178 {
179 let start_byte = ctx.line_column_byte_range(line_num, start_col).start;
180 let end_byte = ctx.line_column_byte_range(line_num, end_col).start;
181 start_byte..end_byte
182 },
183 String::new(),
184 )),
185 });
186 }
187 }
188 } else {
189 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
191 for (start, len, fixed_line, description) in malformed_attempts {
192 let (start_line, start_col, end_line, end_col) =
193 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
194
195 warnings.push(LintWarning {
196 rule_name: Some(self.name().to_string()),
197 line: start_line,
198 column: start_col,
199 end_line,
200 end_column: end_col,
201 message: format!("Malformed quote: {description}"),
202 severity: Severity::Warning,
203 fix: Some(Fix::new(
204 ctx.line_column_byte_range_with_length(
205 line_num,
206 1,
207 line_info.content(ctx.content).chars().count(),
208 ),
209 fixed_line,
210 )),
211 });
212 }
213 }
214 }
215
216 Ok(warnings)
217 }
218
219 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
220 if self.should_skip(ctx) {
221 return Ok(ctx.content.to_string());
222 }
223 let warnings = self.check(ctx)?;
224 if warnings.is_empty() {
225 return Ok(ctx.content.to_string());
226 }
227 let warnings =
228 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
229 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
230 .map_err(crate::rule::LintError::InvalidInput)
231 }
232
233 fn as_any(&self) -> &dyn std::any::Any {
234 self
235 }
236
237 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
238 where
239 Self: Sized,
240 {
241 let rule_config: MD027Config = load_rule_config(config);
242 Box::new(MD027MultipleSpacesBlockquote::with_config(rule_config))
243 }
244
245 crate::impl_rule_config_sections!(MD027Config);
246
247 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
249 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
250 }
251}
252
253impl MD027MultipleSpacesBlockquote {
254 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
258 for prev_idx in (0..line_idx).rev() {
261 let prev_line = &ctx.lines[prev_idx];
262
263 if prev_line.blockquote.is_none() {
265 return false;
266 }
267
268 if prev_line.list_item.is_some() {
270 return true;
271 }
272
273 if ctx.is_in_list_block(prev_idx + 1) {
275 return true;
276 }
277 }
278 false
279 }
280
281 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
283 let mut results = Vec::new();
284
285 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
286 if let Some(cap) = pattern.captures(line) {
287 let match_obj = cap.get(0).unwrap();
288 let start = match_obj.start();
289 let len = match_obj.len();
290
291 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
293 {
294 if self.looks_like_blockquote_attempt(line, &fixed_line) {
296 results.push((start, len, fixed_line, description));
297 }
298 }
299 }
300 }
301
302 results
303 }
304
305 fn extract_blockquote_fix_from_match(
307 &self,
308 cap: ®ex::Captures,
309 issue_type: &str,
310 _original_line: &str,
311 ) -> Option<(String, String)> {
312 match issue_type {
313 "missing spaces in nested blockquote" => {
314 let indent = cap.get(1).map_or("", |m| m.as_str());
316 let content = cap.get(2).map_or("", |m| m.as_str());
317 Some((
318 format!("{}> > {}", indent, content.trim()),
319 "Missing spaces in nested blockquote".to_string(),
320 ))
321 }
322 "missing spaces in deeply nested blockquote" => {
323 let indent = cap.get(1).map_or("", |m| m.as_str());
325 let content = cap.get(2).map_or("", |m| m.as_str());
326 Some((
327 format!("{}> > > {}", indent, content.trim()),
328 "Missing spaces in deeply nested blockquote".to_string(),
329 ))
330 }
331 "extra blockquote marker" => {
332 let indent = cap.get(1).map_or("", |m| m.as_str());
334 let content = cap.get(2).map_or("", |m| m.as_str());
335 Some((
336 format!("{}> {}", indent, content.trim()),
337 "Extra blockquote marker".to_string(),
338 ))
339 }
340 "indented blockquote missing space" => {
341 let indent = cap.get(1).map_or("", |m| m.as_str());
343 let content = cap.get(2).map_or("", |m| m.as_str());
344 Some((
345 format!("{}> {}", indent, content.trim()),
346 "Indented blockquote missing space".to_string(),
347 ))
348 }
349 _ => None,
350 }
351 }
352
353 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
355 let trimmed_original = original.trim();
359 if trimmed_original.len() < 5 {
360 return false;
362 }
363
364 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
366 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
367 return false;
369 }
370
371 if !content_after_markers.chars().any(char::is_alphabetic) {
373 return false;
374 }
375
376 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
379 return false;
380 }
381
382 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")
389 {
391 return false;
392 }
393
394 let word_count = content_after_markers.split_whitespace().count();
396 if word_count < 3 {
397 return false;
399 }
400
401 true
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::lint_context::LintContext;
409
410 #[test]
411 fn test_valid_blockquote() {
412 let rule = MD027MultipleSpacesBlockquote::default();
413 let content = "> This is a blockquote\n> > Nested quote";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415 let result = rule.check(&ctx).unwrap();
416 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
417 }
418
419 #[test]
420 fn test_multiple_spaces_after_marker() {
421 let rule = MD027MultipleSpacesBlockquote::default();
422 let content = "> This has two spaces\n> This has three spaces";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let result = rule.check(&ctx).unwrap();
425 assert_eq!(result.len(), 2);
426 assert_eq!(result[0].line, 1);
427 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
429 assert_eq!(result[1].line, 2);
430 assert_eq!(result[1].column, 3);
431 }
432
433 #[test]
434 fn test_nested_multiple_spaces() {
435 let rule = MD027MultipleSpacesBlockquote::default();
436 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 2);
441 assert!(result[0].message.contains("Multiple spaces"));
442 assert!(result[1].message.contains("Multiple spaces"));
443 }
444
445 #[test]
446 fn test_malformed_nested_quote() {
447 let rule = MD027MultipleSpacesBlockquote::default();
448 let content = ">>This is a nested blockquote without space after markers";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert_eq!(result.len(), 0);
455 }
456
457 #[test]
458 fn test_malformed_deeply_nested() {
459 let rule = MD027MultipleSpacesBlockquote::default();
460 let content = ">>>This is deeply nested without spaces after markers";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert_eq!(result.len(), 0);
466 }
467
468 #[test]
469 fn test_extra_quote_marker() {
470 let rule = MD027MultipleSpacesBlockquote::default();
471 let content = "> >This looks like nested but is actually single level with >This as content";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476 assert_eq!(result.len(), 0);
477 }
478
479 #[test]
480 fn test_indented_missing_space() {
481 let rule = MD027MultipleSpacesBlockquote::default();
482 let content = " >This has 3 spaces indent and no space after marker";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert_eq!(result.len(), 0);
489 }
490
491 #[test]
492 fn test_fix_multiple_spaces() {
493 let rule = MD027MultipleSpacesBlockquote::default();
494 let content = "> Two spaces\n> Three spaces";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let fixed = rule.fix(&ctx).unwrap();
497 assert_eq!(fixed, "> Two spaces\n> Three spaces");
498 }
499
500 #[test]
501 fn test_fix_malformed_quotes() {
502 let rule = MD027MultipleSpacesBlockquote::default();
503 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let fixed = rule.fix(&ctx).unwrap();
507 assert_eq!(fixed, content);
509 }
510
511 #[test]
512 fn test_fix_extra_marker() {
513 let rule = MD027MultipleSpacesBlockquote::default();
514 let content = "> >Extra marker here";
516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517 let fixed = rule.fix(&ctx).unwrap();
518 assert_eq!(fixed, content);
520 }
521
522 #[test]
523 fn test_code_block_ignored() {
524 let rule = MD027MultipleSpacesBlockquote::default();
525 let content = "```\n> This is in a code block\n```";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.check(&ctx).unwrap();
528 assert!(result.is_empty(), "Code blocks should be ignored");
529 }
530
531 #[test]
532 fn test_short_content_not_flagged() {
533 let rule = MD027MultipleSpacesBlockquote::default();
534 let content = ">>>\n>>";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert!(result.is_empty(), "Very short content should not be flagged");
538 }
539
540 #[test]
541 fn test_non_prose_not_flagged() {
542 let rule = MD027MultipleSpacesBlockquote::default();
543 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert!(result.is_empty(), "Non-prose content should not be flagged");
547 }
548
549 #[test]
550 fn test_preserve_trailing_newline() {
551 let rule = MD027MultipleSpacesBlockquote::default();
552 let content = "> Two spaces\n";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let fixed = rule.fix(&ctx).unwrap();
555 assert_eq!(fixed, "> Two spaces\n");
556
557 let content_no_newline = "> Two spaces";
558 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
559 let fixed2 = rule.fix(&ctx2).unwrap();
560 assert_eq!(fixed2, "> Two spaces");
561 }
562
563 #[test]
564 fn test_mixed_issues() {
565 let rule = MD027MultipleSpacesBlockquote::default();
566 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let result = rule.check(&ctx).unwrap();
569 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
570 assert_eq!(result[0].line, 1);
571 }
572
573 #[test]
574 fn test_looks_like_blockquote_attempt() {
575 let rule = MD027MultipleSpacesBlockquote::default();
576
577 assert!(rule.looks_like_blockquote_attempt(
579 ">>This is a real blockquote attempt with text",
580 "> > This is a real blockquote attempt with text"
581 ));
582
583 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
585
586 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
588
589 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
591 }
592
593 #[test]
594 fn test_extract_blockquote_fix() {
595 let rule = MD027MultipleSpacesBlockquote::default();
596 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
597 let cap = regex.captures(">>content").unwrap();
598
599 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
600 assert!(result.is_some());
601 let (fixed, desc) = result.unwrap();
602 assert_eq!(fixed, "> > content");
603 assert!(desc.contains("Missing spaces"));
604 }
605
606 #[test]
607 fn test_empty_blockquote() {
608 let rule = MD027MultipleSpacesBlockquote::default();
609 let content = ">\n> \n> content";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612 assert_eq!(result.len(), 1);
614 assert_eq!(result[0].line, 2);
615 }
616
617 #[test]
618 fn test_fix_preserves_indentation() {
619 let rule = MD027MultipleSpacesBlockquote::default();
620 let content = " > Indented with multiple spaces";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let fixed = rule.fix(&ctx).unwrap();
623 assert_eq!(fixed, " > Indented with multiple spaces");
624 }
625
626 #[test]
627 fn test_tabs_after_marker_not_flagged() {
628 let rule = MD027MultipleSpacesBlockquote::default();
632
633 let content = ">\tTab after marker";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
638
639 let content2 = ">\t\tTwo tabs";
641 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
642 let result2 = rule.check(&ctx2).unwrap();
643 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
644 }
645
646 #[test]
647 fn test_mixed_spaces_and_tabs() {
648 let rule = MD027MultipleSpacesBlockquote::default();
649 let content = "> Space Space";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 1);
655 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
659 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
660 let result2 = rule.check(&ctx2).unwrap();
661 assert_eq!(result2.len(), 1);
662 }
663
664 #[test]
665 fn test_fix_multiple_spaces_various() {
666 let rule = MD027MultipleSpacesBlockquote::default();
667 let content = "> Three spaces";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let fixed = rule.fix(&ctx).unwrap();
671 assert_eq!(fixed, "> Three spaces");
672
673 let content2 = "> Four spaces";
675 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
676 let fixed2 = rule.fix(&ctx2).unwrap();
677 assert_eq!(fixed2, "> Four spaces");
678 }
679
680 #[test]
681 fn test_list_continuation_inside_blockquote_not_flagged() {
682 let rule = MD027MultipleSpacesBlockquote::default();
685
686 let content = "> - Item starts here\n> This continues the item\n> - Another item";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690 assert!(
691 result.is_empty(),
692 "List continuation inside blockquote should not be flagged, got: {result:?}"
693 );
694
695 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
697 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
698 let result2 = rule.check(&ctx2).unwrap();
699 assert!(
700 result2.is_empty(),
701 "List continuations should not be flagged, got: {result2:?}"
702 );
703 }
704
705 #[test]
706 fn test_list_continuation_fix_preserves_indentation() {
707 let rule = MD027MultipleSpacesBlockquote::default();
709
710 let content = "> - Item\n> continuation";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let fixed = rule.fix(&ctx).unwrap();
713 assert_eq!(fixed, "> - Item\n> continuation");
715 }
716
717 #[test]
718 fn test_non_list_multiple_spaces_still_flagged() {
719 let rule = MD027MultipleSpacesBlockquote::default();
721
722 let content = "> This has extra spaces";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert_eq!(result.len(), 1, "Non-list line should be flagged");
727 }
728
729 #[test]
734 fn test_list_items_default_false_skips_list_lines() {
735 let rule = MD027MultipleSpacesBlockquote::default();
737 let content = "# Test\n\n> - item one\n> - item two\n";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(
741 result.is_empty(),
742 "Default (list_items=false) should skip list-item lines, got {result:?}"
743 );
744 }
745
746 #[test]
747 fn test_list_items_true_flags_unordered_list_lines() {
748 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
750 let content = "# Test\n\n> - item one\n> - item two\n";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753 assert_eq!(
754 result.len(),
755 2,
756 "list_items=true should flag both list-item lines, got {result:?}"
757 );
758 assert_eq!(result[0].line, 3);
759 assert_eq!(result[1].line, 4);
760 }
761
762 #[test]
763 fn test_list_items_true_flags_ordered_list_lines() {
764 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
765 let content = "# Test\n\n> 1. first\n> 2. second\n";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let result = rule.check(&ctx).unwrap();
768 assert_eq!(
769 result.len(),
770 2,
771 "list_items=true should flag ordered list-item lines, got {result:?}"
772 );
773 }
774
775 #[test]
776 fn test_list_items_true_flags_list_continuation() {
777 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
779 let content = "# Test\n\n> - first item\n> more list-y text\n";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert_eq!(
783 result.len(),
784 2,
785 "list_items=true should flag both list-item and continuation, got {result:?}"
786 );
787 }
788
789 #[test]
790 fn test_list_items_default_skips_continuation() {
791 let rule = MD027MultipleSpacesBlockquote::default();
793 let content = "# Test\n\n> - first item\n> more list-y text\n";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 assert!(
797 result.is_empty(),
798 "Default should skip both list-item and continuation, got {result:?}"
799 );
800 }
801
802 #[test]
803 fn test_plain_blockquote_text_flagged_in_both_modes() {
804 let content = "# Test\n\n> Plain blockquote text with extra space.\n";
805 for cfg in [MD027Config { list_items: false }, MD027Config { list_items: true }] {
806 let rule = MD027MultipleSpacesBlockquote::with_config(cfg.clone());
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert_eq!(
810 result.len(),
811 1,
812 "Plain blockquote text with extra spaces should always be flagged (cfg={cfg:?}), got {result:?}"
813 );
814 }
815 }
816
817 #[test]
818 fn test_md027_config_kebab_case_parses() {
819 let toml_str = r#"
820 list-items = true
821 "#;
822 let config: MD027Config = toml::from_str(toml_str).unwrap();
823 assert!(config.list_items);
824 }
825
826 #[test]
827 fn test_md027_config_snake_case_alias_parses() {
828 let toml_str = r#"
829 list_items = true
830 "#;
831 let config: MD027Config = toml::from_str(toml_str).unwrap();
832 assert!(config.list_items);
833 }
834
835 #[test]
836 fn test_md027_config_default_is_false() {
837 let cfg = MD027Config::default();
838 assert!(!cfg.list_items, "rumdl default for list_items should be false");
839 }
840
841 #[test]
842 fn test_md027_html_comment() {
843 let rule = MD027MultipleSpacesBlockquote::default();
844 let content = "<!--\n> comment\n-->";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(
848 result.is_empty(),
849 "MD027 should not flag blockquotes inside HTML comments: {result:?}"
850 );
851 }
852}