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 let marker_byte = line_info.byte_offset + line_info.indent;
96 if line_info.in_esm_block
97 || ctx.overlaps_template_code(marker_byte, marker_byte + 1)
98 || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
99 {
100 continue;
101 }
102
103 if line_info.in_code_block
105 || line_info.in_html_block
106 || line_info.in_html_comment
107 || line_info.in_mdx_comment
108 || line_info.in_front_matter
109 || line_info.in_mkdocstrings
110 || line_info.in_jsx_block
111 || line_info.in_kramdown_extension_block
112 {
113 continue;
114 }
115
116 if let Some(blockquote) = &line_info.blockquote {
118 let skip_list_lines = !self.config.list_items;
125 let is_likely_list_continuation = skip_list_lines
126 && (ctx.is_in_list_block(line_num)
127 || line_info.list_item.is_some()
128 || self.previous_blockquote_line_had_list(ctx, line_idx));
129 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
130 let mut byte_pos = 0;
133 let mut found_markers = 0;
134 let mut found_first_space = false;
135
136 for (i, ch) in line_info.content(ctx.content).char_indices() {
137 if found_markers < blockquote.nesting_level {
138 if ch == '>' {
139 found_markers += 1;
140 }
141 } else if !found_first_space && (ch == ' ' || ch == '\t') {
142 found_first_space = true;
144 } else if found_first_space && (ch == ' ' || ch == '\t') {
145 byte_pos = i;
147 break;
148 }
149 }
150
151 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
153 .chars()
154 .take_while(|&c| c == ' ' || c == '\t')
155 .fold(0, |acc, ch| acc + ch.len_utf8());
156
157 if extra_spaces_bytes > 0 {
158 let (fix_byte_pos, fix_bytes) = if blockquote.content.is_empty() {
161 let first_space_pos = byte_pos - 1;
164 let all_spaces_bytes = line_info.content(ctx.content)[first_space_pos..]
165 .chars()
166 .take_while(|&c| c == ' ' || c == '\t')
167 .fold(0, |acc, ch| acc + ch.len_utf8());
168 (first_space_pos, all_spaces_bytes)
169 } else {
170 (byte_pos, extra_spaces_bytes)
171 };
172
173 let (start_line, start_col, end_line, end_col) =
174 calculate_match_range(line_num, line_info.content(ctx.content), fix_byte_pos, fix_bytes);
175
176 warnings.push(LintWarning {
177 rule_name: Some(self.name().to_string()),
178 line: start_line,
179 column: start_col,
180 end_line,
181 end_column: end_col,
182 message: "Multiple spaces after quote marker (>)".to_string(),
183 severity: Severity::Warning,
184 fix: Some(Fix::new(
185 {
186 let start_byte = ctx.line_column_byte_range(line_num, start_col).start;
187 let end_byte = ctx.line_column_byte_range(line_num, end_col).start;
188 start_byte..end_byte
189 },
190 String::new(),
191 )),
192 });
193 }
194 }
195 } else {
196 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
198 for (start, len, fixed_line, description) in malformed_attempts {
199 let (start_line, start_col, end_line, end_col) =
200 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
201
202 warnings.push(LintWarning {
203 rule_name: Some(self.name().to_string()),
204 line: start_line,
205 column: start_col,
206 end_line,
207 end_column: end_col,
208 message: format!("Malformed quote: {description}"),
209 severity: Severity::Warning,
210 fix: Some(Fix::new(
211 ctx.line_column_byte_range_with_length(
212 line_num,
213 1,
214 line_info.content(ctx.content).chars().count(),
215 ),
216 fixed_line,
217 )),
218 });
219 }
220 }
221 }
222
223 Ok(warnings)
224 }
225
226 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
227 if self.should_skip(ctx) {
228 return Ok(ctx.content.to_string());
229 }
230 let warnings = self.check(ctx)?;
231 if warnings.is_empty() {
232 return Ok(ctx.content.to_string());
233 }
234 let warnings =
235 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
236 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
237 .map_err(crate::rule::LintError::InvalidInput)
238 }
239
240 fn as_any(&self) -> &dyn std::any::Any {
241 self
242 }
243
244 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
245 where
246 Self: Sized,
247 {
248 let rule_config: MD027Config = load_rule_config(config);
249 Box::new(MD027MultipleSpacesBlockquote::with_config(rule_config))
250 }
251
252 crate::impl_rule_config_sections!(MD027Config);
253
254 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
256 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
257 }
258}
259
260impl MD027MultipleSpacesBlockquote {
261 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
265 for prev_idx in (0..line_idx).rev() {
268 let prev_line = &ctx.lines[prev_idx];
269
270 if prev_line.blockquote.is_none() {
272 return false;
273 }
274
275 if prev_line.list_item.is_some() {
277 return true;
278 }
279
280 if ctx.is_in_list_block(prev_idx + 1) {
282 return true;
283 }
284 }
285 false
286 }
287
288 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
290 let mut results = Vec::new();
291
292 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
293 if let Some(cap) = pattern.captures(line) {
294 let match_obj = cap.get(0).unwrap();
295 let start = match_obj.start();
296 let len = match_obj.len();
297
298 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
300 {
301 if self.looks_like_blockquote_attempt(line, &fixed_line) {
303 results.push((start, len, fixed_line, description));
304 }
305 }
306 }
307 }
308
309 results
310 }
311
312 fn extract_blockquote_fix_from_match(
314 &self,
315 cap: ®ex::Captures,
316 issue_type: &str,
317 _original_line: &str,
318 ) -> Option<(String, String)> {
319 match issue_type {
320 "missing spaces in nested blockquote" => {
321 let indent = cap.get(1).map_or("", |m| m.as_str());
323 let content = cap.get(2).map_or("", |m| m.as_str());
324 Some((
325 format!("{}> > {}", indent, content.trim()),
326 "Missing spaces in nested blockquote".to_string(),
327 ))
328 }
329 "missing spaces in deeply nested blockquote" => {
330 let indent = cap.get(1).map_or("", |m| m.as_str());
332 let content = cap.get(2).map_or("", |m| m.as_str());
333 Some((
334 format!("{}> > > {}", indent, content.trim()),
335 "Missing spaces in deeply nested blockquote".to_string(),
336 ))
337 }
338 "extra blockquote marker" => {
339 let indent = cap.get(1).map_or("", |m| m.as_str());
341 let content = cap.get(2).map_or("", |m| m.as_str());
342 Some((
343 format!("{}> {}", indent, content.trim()),
344 "Extra blockquote marker".to_string(),
345 ))
346 }
347 "indented blockquote missing space" => {
348 let indent = cap.get(1).map_or("", |m| m.as_str());
350 let content = cap.get(2).map_or("", |m| m.as_str());
351 Some((
352 format!("{}> {}", indent, content.trim()),
353 "Indented blockquote missing space".to_string(),
354 ))
355 }
356 _ => None,
357 }
358 }
359
360 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
362 let trimmed_original = original.trim();
366 if trimmed_original.len() < 5 {
367 return false;
369 }
370
371 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
373 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
374 return false;
376 }
377
378 if !content_after_markers.chars().any(char::is_alphabetic) {
380 return false;
381 }
382
383 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
386 return false;
387 }
388
389 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")
396 {
398 return false;
399 }
400
401 let word_count = content_after_markers.split_whitespace().count();
403 if word_count < 3 {
404 return false;
406 }
407
408 true
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::lint_context::LintContext;
416
417 #[test]
418 fn test_valid_blockquote() {
419 let rule = MD027MultipleSpacesBlockquote::default();
420 let content = "> This is a blockquote\n> > Nested quote";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
424 }
425
426 #[test]
427 fn test_multiple_spaces_after_marker() {
428 let rule = MD027MultipleSpacesBlockquote::default();
429 let content = "> This has two spaces\n> This has three spaces";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432 assert_eq!(result.len(), 2);
433 assert_eq!(result[0].line, 1);
434 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
436 assert_eq!(result[1].line, 2);
437 assert_eq!(result[1].column, 3);
438 }
439
440 #[test]
441 fn test_nested_multiple_spaces() {
442 let rule = MD027MultipleSpacesBlockquote::default();
443 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 2);
448 assert!(result[0].message.contains("Multiple spaces"));
449 assert!(result[1].message.contains("Multiple spaces"));
450 }
451
452 #[test]
453 fn test_malformed_nested_quote() {
454 let rule = MD027MultipleSpacesBlockquote::default();
455 let content = ">>This is a nested blockquote without space after markers";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460 assert_eq!(result.len(), 0);
462 }
463
464 #[test]
465 fn test_malformed_deeply_nested() {
466 let rule = MD027MultipleSpacesBlockquote::default();
467 let content = ">>>This is deeply nested without spaces after markers";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(result.len(), 0);
473 }
474
475 #[test]
476 fn test_extra_quote_marker() {
477 let rule = MD027MultipleSpacesBlockquote::default();
478 let content = "> >This looks like nested but is actually single level with >This as content";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert_eq!(result.len(), 0);
484 }
485
486 #[test]
487 fn test_indented_missing_space() {
488 let rule = MD027MultipleSpacesBlockquote::default();
489 let content = " >This has 3 spaces indent and no space after marker";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert_eq!(result.len(), 0);
496 }
497
498 #[test]
499 fn test_fix_multiple_spaces() {
500 let rule = MD027MultipleSpacesBlockquote::default();
501 let content = "> Two spaces\n> Three spaces";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let fixed = rule.fix(&ctx).unwrap();
504 assert_eq!(fixed, "> Two spaces\n> Three spaces");
505 }
506
507 #[test]
508 fn test_fix_malformed_quotes() {
509 let rule = MD027MultipleSpacesBlockquote::default();
510 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let fixed = rule.fix(&ctx).unwrap();
514 assert_eq!(fixed, content);
516 }
517
518 #[test]
519 fn test_fix_extra_marker() {
520 let rule = MD027MultipleSpacesBlockquote::default();
521 let content = "> >Extra marker here";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let fixed = rule.fix(&ctx).unwrap();
525 assert_eq!(fixed, content);
527 }
528
529 #[test]
530 fn test_code_block_ignored() {
531 let rule = MD027MultipleSpacesBlockquote::default();
532 let content = "```\n> This is in a code block\n```";
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(result.is_empty(), "Code blocks should be ignored");
536 }
537
538 #[test]
539 fn test_short_content_not_flagged() {
540 let rule = MD027MultipleSpacesBlockquote::default();
541 let content = ">>>\n>>";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert!(result.is_empty(), "Very short content should not be flagged");
545 }
546
547 #[test]
548 fn test_non_prose_not_flagged() {
549 let rule = MD027MultipleSpacesBlockquote::default();
550 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert!(result.is_empty(), "Non-prose content should not be flagged");
554 }
555
556 #[test]
557 fn test_preserve_trailing_newline() {
558 let rule = MD027MultipleSpacesBlockquote::default();
559 let content = "> Two spaces\n";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let fixed = rule.fix(&ctx).unwrap();
562 assert_eq!(fixed, "> Two spaces\n");
563
564 let content_no_newline = "> Two spaces";
565 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
566 let fixed2 = rule.fix(&ctx2).unwrap();
567 assert_eq!(fixed2, "> Two spaces");
568 }
569
570 #[test]
571 fn test_mixed_issues() {
572 let rule = MD027MultipleSpacesBlockquote::default();
573 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
577 assert_eq!(result[0].line, 1);
578 }
579
580 #[test]
581 fn test_looks_like_blockquote_attempt() {
582 let rule = MD027MultipleSpacesBlockquote::default();
583
584 assert!(rule.looks_like_blockquote_attempt(
586 ">>This is a real blockquote attempt with text",
587 "> > This is a real blockquote attempt with text"
588 ));
589
590 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
592
593 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
595
596 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
598 }
599
600 #[test]
601 fn test_extract_blockquote_fix() {
602 let rule = MD027MultipleSpacesBlockquote::default();
603 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
604 let cap = regex.captures(">>content").unwrap();
605
606 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
607 assert!(result.is_some());
608 let (fixed, desc) = result.unwrap();
609 assert_eq!(fixed, "> > content");
610 assert!(desc.contains("Missing spaces"));
611 }
612
613 #[test]
614 fn test_empty_blockquote() {
615 let rule = MD027MultipleSpacesBlockquote::default();
616 let content = ">\n> \n> content";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(result.len(), 1);
621 assert_eq!(result[0].line, 2);
622 }
623
624 #[test]
625 fn test_fix_preserves_indentation() {
626 let rule = MD027MultipleSpacesBlockquote::default();
627 let content = " > Indented with multiple spaces";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let fixed = rule.fix(&ctx).unwrap();
630 assert_eq!(fixed, " > Indented with multiple spaces");
631 }
632
633 #[test]
634 fn test_tabs_after_marker_not_flagged() {
635 let rule = MD027MultipleSpacesBlockquote::default();
639
640 let content = ">\tTab after marker";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
645
646 let content2 = ">\t\tTwo tabs";
648 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
649 let result2 = rule.check(&ctx2).unwrap();
650 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
651 }
652
653 #[test]
654 fn test_mixed_spaces_and_tabs() {
655 let rule = MD027MultipleSpacesBlockquote::default();
656 let content = "> Space Space";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert_eq!(result.len(), 1);
662 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
666 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
667 let result2 = rule.check(&ctx2).unwrap();
668 assert_eq!(result2.len(), 1);
669 }
670
671 #[test]
672 fn test_fix_multiple_spaces_various() {
673 let rule = MD027MultipleSpacesBlockquote::default();
674 let content = "> Three spaces";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let fixed = rule.fix(&ctx).unwrap();
678 assert_eq!(fixed, "> Three spaces");
679
680 let content2 = "> Four spaces";
682 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
683 let fixed2 = rule.fix(&ctx2).unwrap();
684 assert_eq!(fixed2, "> Four spaces");
685 }
686
687 #[test]
688 fn test_list_continuation_inside_blockquote_not_flagged() {
689 let rule = MD027MultipleSpacesBlockquote::default();
692
693 let content = "> - Item starts here\n> This continues the item\n> - Another item";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert!(
698 result.is_empty(),
699 "List continuation inside blockquote should not be flagged, got: {result:?}"
700 );
701
702 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
704 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
705 let result2 = rule.check(&ctx2).unwrap();
706 assert!(
707 result2.is_empty(),
708 "List continuations should not be flagged, got: {result2:?}"
709 );
710 }
711
712 #[test]
713 fn test_list_continuation_fix_preserves_indentation() {
714 let rule = MD027MultipleSpacesBlockquote::default();
716
717 let content = "> - Item\n> continuation";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let fixed = rule.fix(&ctx).unwrap();
720 assert_eq!(fixed, "> - Item\n> continuation");
722 }
723
724 #[test]
725 fn test_non_list_multiple_spaces_still_flagged() {
726 let rule = MD027MultipleSpacesBlockquote::default();
728
729 let content = "> This has extra spaces";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert_eq!(result.len(), 1, "Non-list line should be flagged");
734 }
735
736 #[test]
741 fn test_list_items_default_false_skips_list_lines() {
742 let rule = MD027MultipleSpacesBlockquote::default();
744 let content = "# Test\n\n> - item one\n> - item two\n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert!(
748 result.is_empty(),
749 "Default (list_items=false) should skip list-item lines, got {result:?}"
750 );
751 }
752
753 #[test]
754 fn test_list_items_true_flags_unordered_list_lines() {
755 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
757 let content = "# Test\n\n> - item one\n> - item two\n";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert_eq!(
761 result.len(),
762 2,
763 "list_items=true should flag both list-item lines, got {result:?}"
764 );
765 assert_eq!(result[0].line, 3);
766 assert_eq!(result[1].line, 4);
767 }
768
769 #[test]
770 fn test_list_items_true_flags_ordered_list_lines() {
771 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
772 let content = "# Test\n\n> 1. first\n> 2. second\n";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert_eq!(
776 result.len(),
777 2,
778 "list_items=true should flag ordered list-item lines, got {result:?}"
779 );
780 }
781
782 #[test]
783 fn test_list_items_true_flags_list_continuation() {
784 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
786 let content = "# Test\n\n> - first item\n> more list-y text\n";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert_eq!(
790 result.len(),
791 2,
792 "list_items=true should flag both list-item and continuation, got {result:?}"
793 );
794 }
795
796 #[test]
797 fn test_list_items_default_skips_continuation() {
798 let rule = MD027MultipleSpacesBlockquote::default();
800 let content = "# Test\n\n> - first item\n> more list-y text\n";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert!(
804 result.is_empty(),
805 "Default should skip both list-item and continuation, got {result:?}"
806 );
807 }
808
809 #[test]
810 fn test_plain_blockquote_text_flagged_in_both_modes() {
811 let content = "# Test\n\n> Plain blockquote text with extra space.\n";
812 for cfg in [MD027Config { list_items: false }, MD027Config { list_items: true }] {
813 let rule = MD027MultipleSpacesBlockquote::with_config(cfg.clone());
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(
817 result.len(),
818 1,
819 "Plain blockquote text with extra spaces should always be flagged (cfg={cfg:?}), got {result:?}"
820 );
821 }
822 }
823
824 #[test]
825 fn test_md027_config_kebab_case_parses() {
826 let toml_str = r#"
827 list-items = true
828 "#;
829 let config: MD027Config = toml::from_str(toml_str).unwrap();
830 assert!(config.list_items);
831 }
832
833 #[test]
834 fn test_md027_config_snake_case_alias_parses() {
835 let toml_str = r#"
836 list_items = true
837 "#;
838 let config: MD027Config = toml::from_str(toml_str).unwrap();
839 assert!(config.list_items);
840 }
841
842 #[test]
843 fn test_md027_config_default_is_false() {
844 let cfg = MD027Config::default();
845 assert!(!cfg.list_items, "rumdl default for list_items should be false");
846 }
847
848 #[test]
849 fn test_md027_html_comment() {
850 let rule = MD027MultipleSpacesBlockquote::default();
851 let content = "<!--\n> comment\n-->";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854 assert!(
855 result.is_empty(),
856 "MD027 should not flag blockquotes inside HTML comments: {result:?}"
857 );
858 }
859}