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_index.line_col_to_byte_range(line_num, start_col).start;
180 let end_byte = ctx.line_index.line_col_to_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_index.line_col_to_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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
246 let default_config = MD027Config::default();
247 let json_value = serde_json::to_value(&default_config).ok()?;
248 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
249 if let toml::Value::Table(table) = toml_value
250 && !table.is_empty()
251 {
252 return Some((MD027Config::RULE_NAME.to_string(), toml::Value::Table(table)));
253 }
254 None
255 }
256
257 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
259 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
260 }
261}
262
263impl MD027MultipleSpacesBlockquote {
264 fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
268 for prev_idx in (0..line_idx).rev() {
271 let prev_line = &ctx.lines[prev_idx];
272
273 if prev_line.blockquote.is_none() {
275 return false;
276 }
277
278 if prev_line.list_item.is_some() {
280 return true;
281 }
282
283 if ctx.is_in_list_block(prev_idx + 1) {
285 return true;
286 }
287 }
288 false
289 }
290
291 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
293 let mut results = Vec::new();
294
295 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
296 if let Some(cap) = pattern.captures(line) {
297 let match_obj = cap.get(0).unwrap();
298 let start = match_obj.start();
299 let len = match_obj.len();
300
301 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
303 {
304 if self.looks_like_blockquote_attempt(line, &fixed_line) {
306 results.push((start, len, fixed_line, description));
307 }
308 }
309 }
310 }
311
312 results
313 }
314
315 fn extract_blockquote_fix_from_match(
317 &self,
318 cap: ®ex::Captures,
319 issue_type: &str,
320 _original_line: &str,
321 ) -> Option<(String, String)> {
322 match issue_type {
323 "missing spaces in nested blockquote" => {
324 let indent = cap.get(1).map_or("", |m| m.as_str());
326 let content = cap.get(2).map_or("", |m| m.as_str());
327 Some((
328 format!("{}> > {}", indent, content.trim()),
329 "Missing spaces in nested blockquote".to_string(),
330 ))
331 }
332 "missing spaces in deeply nested blockquote" => {
333 let indent = cap.get(1).map_or("", |m| m.as_str());
335 let content = cap.get(2).map_or("", |m| m.as_str());
336 Some((
337 format!("{}> > > {}", indent, content.trim()),
338 "Missing spaces in deeply nested blockquote".to_string(),
339 ))
340 }
341 "extra blockquote marker" => {
342 let indent = cap.get(1).map_or("", |m| m.as_str());
344 let content = cap.get(2).map_or("", |m| m.as_str());
345 Some((
346 format!("{}> {}", indent, content.trim()),
347 "Extra blockquote marker".to_string(),
348 ))
349 }
350 "indented blockquote missing space" => {
351 let indent = cap.get(1).map_or("", |m| m.as_str());
353 let content = cap.get(2).map_or("", |m| m.as_str());
354 Some((
355 format!("{}> {}", indent, content.trim()),
356 "Indented blockquote missing space".to_string(),
357 ))
358 }
359 _ => None,
360 }
361 }
362
363 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
365 let trimmed_original = original.trim();
369 if trimmed_original.len() < 5 {
370 return false;
372 }
373
374 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
376 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
377 return false;
379 }
380
381 if !content_after_markers.chars().any(char::is_alphabetic) {
383 return false;
384 }
385
386 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
389 return false;
390 }
391
392 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")
399 {
401 return false;
402 }
403
404 let word_count = content_after_markers.split_whitespace().count();
406 if word_count < 3 {
407 return false;
409 }
410
411 true
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::lint_context::LintContext;
419
420 #[test]
421 fn test_valid_blockquote() {
422 let rule = MD027MultipleSpacesBlockquote::default();
423 let content = "> This is a blockquote\n> > Nested quote";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
427 }
428
429 #[test]
430 fn test_multiple_spaces_after_marker() {
431 let rule = MD027MultipleSpacesBlockquote::default();
432 let content = "> This has two spaces\n> This has three spaces";
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434 let result = rule.check(&ctx).unwrap();
435 assert_eq!(result.len(), 2);
436 assert_eq!(result[0].line, 1);
437 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
439 assert_eq!(result[1].line, 2);
440 assert_eq!(result[1].column, 3);
441 }
442
443 #[test]
444 fn test_nested_multiple_spaces() {
445 let rule = MD027MultipleSpacesBlockquote::default();
446 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert_eq!(result.len(), 2);
451 assert!(result[0].message.contains("Multiple spaces"));
452 assert!(result[1].message.contains("Multiple spaces"));
453 }
454
455 #[test]
456 fn test_malformed_nested_quote() {
457 let rule = MD027MultipleSpacesBlockquote::default();
458 let content = ">>This is a nested blockquote without space after markers";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463 assert_eq!(result.len(), 0);
465 }
466
467 #[test]
468 fn test_malformed_deeply_nested() {
469 let rule = MD027MultipleSpacesBlockquote::default();
470 let content = ">>>This is deeply nested without spaces after markers";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert_eq!(result.len(), 0);
476 }
477
478 #[test]
479 fn test_extra_quote_marker() {
480 let rule = MD027MultipleSpacesBlockquote::default();
481 let content = "> >This looks like nested but is actually single level with >This as content";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert_eq!(result.len(), 0);
487 }
488
489 #[test]
490 fn test_indented_missing_space() {
491 let rule = MD027MultipleSpacesBlockquote::default();
492 let content = " >This has 3 spaces indent and no space after marker";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496 assert_eq!(result.len(), 0);
499 }
500
501 #[test]
502 fn test_fix_multiple_spaces() {
503 let rule = MD027MultipleSpacesBlockquote::default();
504 let content = "> Two spaces\n> Three spaces";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let fixed = rule.fix(&ctx).unwrap();
507 assert_eq!(fixed, "> Two spaces\n> Three spaces");
508 }
509
510 #[test]
511 fn test_fix_malformed_quotes() {
512 let rule = MD027MultipleSpacesBlockquote::default();
513 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let fixed = rule.fix(&ctx).unwrap();
517 assert_eq!(fixed, content);
519 }
520
521 #[test]
522 fn test_fix_extra_marker() {
523 let rule = MD027MultipleSpacesBlockquote::default();
524 let content = "> >Extra marker here";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let fixed = rule.fix(&ctx).unwrap();
528 assert_eq!(fixed, content);
530 }
531
532 #[test]
533 fn test_code_block_ignored() {
534 let rule = MD027MultipleSpacesBlockquote::default();
535 let content = "```\n> This is in a code block\n```";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert!(result.is_empty(), "Code blocks should be ignored");
539 }
540
541 #[test]
542 fn test_short_content_not_flagged() {
543 let rule = MD027MultipleSpacesBlockquote::default();
544 let content = ">>>\n>>";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert!(result.is_empty(), "Very short content should not be flagged");
548 }
549
550 #[test]
551 fn test_non_prose_not_flagged() {
552 let rule = MD027MultipleSpacesBlockquote::default();
553 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert!(result.is_empty(), "Non-prose content should not be flagged");
557 }
558
559 #[test]
560 fn test_preserve_trailing_newline() {
561 let rule = MD027MultipleSpacesBlockquote::default();
562 let content = "> Two spaces\n";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let fixed = rule.fix(&ctx).unwrap();
565 assert_eq!(fixed, "> Two spaces\n");
566
567 let content_no_newline = "> Two spaces";
568 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
569 let fixed2 = rule.fix(&ctx2).unwrap();
570 assert_eq!(fixed2, "> Two spaces");
571 }
572
573 #[test]
574 fn test_mixed_issues() {
575 let rule = MD027MultipleSpacesBlockquote::default();
576 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
580 assert_eq!(result[0].line, 1);
581 }
582
583 #[test]
584 fn test_looks_like_blockquote_attempt() {
585 let rule = MD027MultipleSpacesBlockquote::default();
586
587 assert!(rule.looks_like_blockquote_attempt(
589 ">>This is a real blockquote attempt with text",
590 "> > This is a real blockquote attempt with text"
591 ));
592
593 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
595
596 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
598
599 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
601 }
602
603 #[test]
604 fn test_extract_blockquote_fix() {
605 let rule = MD027MultipleSpacesBlockquote::default();
606 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
607 let cap = regex.captures(">>content").unwrap();
608
609 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
610 assert!(result.is_some());
611 let (fixed, desc) = result.unwrap();
612 assert_eq!(fixed, "> > content");
613 assert!(desc.contains("Missing spaces"));
614 }
615
616 #[test]
617 fn test_empty_blockquote() {
618 let rule = MD027MultipleSpacesBlockquote::default();
619 let content = ">\n> \n> content";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert_eq!(result.len(), 1);
624 assert_eq!(result[0].line, 2);
625 }
626
627 #[test]
628 fn test_fix_preserves_indentation() {
629 let rule = MD027MultipleSpacesBlockquote::default();
630 let content = " > Indented with multiple spaces";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let fixed = rule.fix(&ctx).unwrap();
633 assert_eq!(fixed, " > Indented with multiple spaces");
634 }
635
636 #[test]
637 fn test_tabs_after_marker_not_flagged() {
638 let rule = MD027MultipleSpacesBlockquote::default();
642
643 let content = ">\tTab after marker";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
648
649 let content2 = ">\t\tTwo tabs";
651 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
652 let result2 = rule.check(&ctx2).unwrap();
653 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
654 }
655
656 #[test]
657 fn test_mixed_spaces_and_tabs() {
658 let rule = MD027MultipleSpacesBlockquote::default();
659 let content = "> Space Space";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert_eq!(result.len(), 1);
665 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
669 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
670 let result2 = rule.check(&ctx2).unwrap();
671 assert_eq!(result2.len(), 1);
672 }
673
674 #[test]
675 fn test_fix_multiple_spaces_various() {
676 let rule = MD027MultipleSpacesBlockquote::default();
677 let content = "> Three spaces";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681 assert_eq!(fixed, "> Three spaces");
682
683 let content2 = "> Four spaces";
685 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
686 let fixed2 = rule.fix(&ctx2).unwrap();
687 assert_eq!(fixed2, "> Four spaces");
688 }
689
690 #[test]
691 fn test_list_continuation_inside_blockquote_not_flagged() {
692 let rule = MD027MultipleSpacesBlockquote::default();
695
696 let content = "> - Item starts here\n> This continues the item\n> - Another item";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert!(
701 result.is_empty(),
702 "List continuation inside blockquote should not be flagged, got: {result:?}"
703 );
704
705 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
707 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
708 let result2 = rule.check(&ctx2).unwrap();
709 assert!(
710 result2.is_empty(),
711 "List continuations should not be flagged, got: {result2:?}"
712 );
713 }
714
715 #[test]
716 fn test_list_continuation_fix_preserves_indentation() {
717 let rule = MD027MultipleSpacesBlockquote::default();
719
720 let content = "> - Item\n> continuation";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let fixed = rule.fix(&ctx).unwrap();
723 assert_eq!(fixed, "> - Item\n> continuation");
725 }
726
727 #[test]
728 fn test_non_list_multiple_spaces_still_flagged() {
729 let rule = MD027MultipleSpacesBlockquote::default();
731
732 let content = "> This has extra spaces";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 1, "Non-list line should be flagged");
737 }
738
739 #[test]
744 fn test_list_items_default_false_skips_list_lines() {
745 let rule = MD027MultipleSpacesBlockquote::default();
747 let content = "# Test\n\n> - item one\n> - item two\n";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(
751 result.is_empty(),
752 "Default (list_items=false) should skip list-item lines, got {result:?}"
753 );
754 }
755
756 #[test]
757 fn test_list_items_true_flags_unordered_list_lines() {
758 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
760 let content = "# Test\n\n> - item one\n> - item two\n";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert_eq!(
764 result.len(),
765 2,
766 "list_items=true should flag both list-item lines, got {result:?}"
767 );
768 assert_eq!(result[0].line, 3);
769 assert_eq!(result[1].line, 4);
770 }
771
772 #[test]
773 fn test_list_items_true_flags_ordered_list_lines() {
774 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
775 let content = "# Test\n\n> 1. first\n> 2. second\n";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert_eq!(
779 result.len(),
780 2,
781 "list_items=true should flag ordered list-item lines, got {result:?}"
782 );
783 }
784
785 #[test]
786 fn test_list_items_true_flags_list_continuation() {
787 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
789 let content = "# Test\n\n> - first item\n> more list-y text\n";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let result = rule.check(&ctx).unwrap();
792 assert_eq!(
793 result.len(),
794 2,
795 "list_items=true should flag both list-item and continuation, got {result:?}"
796 );
797 }
798
799 #[test]
800 fn test_list_items_default_skips_continuation() {
801 let rule = MD027MultipleSpacesBlockquote::default();
803 let content = "# Test\n\n> - first item\n> more list-y text\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(
807 result.is_empty(),
808 "Default should skip both list-item and continuation, got {result:?}"
809 );
810 }
811
812 #[test]
813 fn test_plain_blockquote_text_flagged_in_both_modes() {
814 let content = "# Test\n\n> Plain blockquote text with extra space.\n";
815 for cfg in [MD027Config { list_items: false }, MD027Config { list_items: true }] {
816 let rule = MD027MultipleSpacesBlockquote::with_config(cfg.clone());
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert_eq!(
820 result.len(),
821 1,
822 "Plain blockquote text with extra spaces should always be flagged (cfg={cfg:?}), got {result:?}"
823 );
824 }
825 }
826
827 #[test]
828 fn test_md027_config_kebab_case_parses() {
829 let toml_str = r#"
830 list-items = true
831 "#;
832 let config: MD027Config = toml::from_str(toml_str).unwrap();
833 assert!(config.list_items);
834 }
835
836 #[test]
837 fn test_md027_config_snake_case_alias_parses() {
838 let toml_str = r#"
839 list_items = true
840 "#;
841 let config: MD027Config = toml::from_str(toml_str).unwrap();
842 assert!(config.list_items);
843 }
844
845 #[test]
846 fn test_md027_config_default_is_false() {
847 let cfg = MD027Config::default();
848 assert!(!cfg.list_items, "rumdl default for list_items should be false");
849 }
850
851 #[test]
852 fn test_md027_html_comment() {
853 let rule = MD027MultipleSpacesBlockquote::default();
854 let content = "<!--\n> comment\n-->";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert!(
858 result.is_empty(),
859 "MD027 should not flag blockquotes inside HTML comments: {result:?}"
860 );
861 }
862}