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