1use crate::utils::html_block::{opens_tag_line_html_block, opens_untagged_html_block, parse_html_block_start};
2use crate::utils::list_fix_guard::{Allowed, drop_mdx_structure_changing_fixes, drop_structure_changing_fixes};
3use crate::utils::range_utils::calculate_match_range;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::rule_config_serde::{RuleConfig, load_rule_config};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "kebab-case")]
20pub struct MD027Config {
21 #[serde(default, alias = "list_items")]
24 pub list_items: bool,
25}
26
27impl RuleConfig for MD027Config {
28 const RULE_NAME: &'static str = "MD027";
29}
30
31static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
33 vec![
34 (
36 Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
37 "missing spaces in nested blockquote",
38 ),
39 (
41 Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
42 "missing spaces in deeply nested blockquote",
43 ),
44 (
46 Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
47 "extra blockquote marker",
48 ),
49 (
51 Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
52 "indented blockquote missing space",
53 ),
54 ]
55});
56
57static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
59
60#[derive(Clone, Copy)]
63struct QuotedItem {
64 kind: u8,
67 content_column: usize,
69 empty: bool,
71}
72
73#[derive(Default)]
80struct QuotedItems {
81 quote_level: usize,
82 items: Vec<QuotedItem>,
83 paragraph: bool,
85 table: bool,
87 html: bool,
89 header_cells: Option<usize>,
92 parents: Vec<QuotedItems>,
94}
95
96impl QuotedItems {
97 fn enters(&mut self, line: &str, level: usize) -> bool {
100 if level > self.quote_level && self.quote_level > 0 {
101 self.close_before_nested_quote(line);
102 }
103 self.enter_quote(level);
104 let mut seen = 0;
105 let mut rest = line;
106 while seen < level {
107 let Some(at) = rest.find('>') else { return false };
108 rest = &rest[at + 1..];
109 seen += 1;
110 }
111 let (edge, rest) = Self::quote_content(line, rest);
112 let text = rest.trim_start_matches([' ', '\t']);
113 let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
114 if text.is_empty() {
115 if self.items.last().is_some_and(|item| item.empty) {
116 self.items.pop();
117 }
118 self.paragraph = false;
119 self.table = false;
120 self.html = false;
121 self.header_cells = None;
122 return false;
123 }
124 let breaks = Self::is_thematic_break(text);
125 let mut marker = Self::marker_len(text).filter(|_| !breaks);
126 let lazy = self.paragraph && marker.is_none() && !breaks && !self.items.is_empty() && !Self::starts_block(text);
129 let mut shallowest_closed = None;
130 if !lazy {
131 while let Some(item) = self.items.last().filter(|item| item.content_column > indent) {
132 shallowest_closed = Some(*item);
133 self.items.pop();
134 }
135 }
136 if shallowest_closed.is_some() {
137 self.table = false;
138 self.html = false;
139 }
140 if self.paragraph && shallowest_closed.is_none() && marker.is_some_and(|(.., interrupts)| !interrupts) {
144 marker = None;
145 }
146 if let Some(item) = self.items.last_mut() {
147 item.empty = false;
148 }
149 let mut belongs = self.items.iter().any(|item| item.content_column <= indent);
150 let base = self.items.last().map_or(0, |item| item.content_column);
153 let shallow = indent - base.min(indent) < 4;
155 let starts_block = |text: &str| shallow && Self::starts_block(text);
156 let breaks = shallow && breaks;
157 let marker = marker.filter(|_| shallow);
158 let mut first_text = marker.map_or(text, |(width, ..)| text[width..].trim_start_matches([' ', '\t']));
159 if let Some(first) = marker {
160 belongs |= shallowest_closed.is_some_and(|item| item.kind != first.2);
161 let mut current = Some(first);
164 let mut at = text;
165 let mut column = indent;
166 while let Some((width, padding, kind, _)) = current {
167 let after = &at[width..];
168 let marker_end = Self::columns(&line[..line.len() - after.len()]);
169 let rest = after.trim_start_matches([' ', '\t']);
170 let gap = Self::columns(&line[..line.len() - rest.len()]) - marker_end;
171 let spaced = (1..=4).contains(&gap) && padding < after.len();
172 let content_column = column + width + if spaced { gap } else { 1 };
173 self.items.push(QuotedItem {
174 kind,
175 content_column,
176 empty: rest.is_empty(),
177 });
178 at = rest;
179 column = content_column;
180 current = Some(at)
181 .filter(|at| spaced && !at.is_empty() && !Self::is_thematic_break(at))
182 .and_then(Self::marker_len);
183 }
184 first_text = at;
185 }
186 let run = text.trim_end_matches([' ', '\t']);
189 let reentered = self.items.iter().all(|item| item.content_column <= indent);
190 let underlines = self.paragraph
191 && shallow
192 && marker.is_none()
193 && (run.bytes().all(|byte| byte == b'=') || run.bytes().all(|byte| byte == b'-'))
194 && reentered;
195 self.table = marker.is_none()
198 && !breaks
199 && !starts_block(text)
200 && (self.table
201 || (self.paragraph
202 && shallow
203 && !underlines
204 && reentered
205 && Self::is_table_delimiter(text)
206 && self.header_cells == Some(Self::cells(text))));
207 let continues = self.paragraph && marker.is_none();
208 self.html |= first_text.starts_with('<')
209 && (starts_block(first_text) || (!continues && opens_tag_line_html_block(first_text)));
210 self.paragraph =
211 !breaks && !underlines && !self.table && !self.html && !first_text.is_empty() && !starts_block(first_text);
212 self.header_cells = self.paragraph.then(|| Self::cells(first_text));
213 belongs
214 }
215
216 fn unquoted_line(&mut self, line: &str) {
219 let text = line.trim_start_matches([' ', '\t']);
220 let deep = Self::columns(&line[..line.len() - text.len()]) >= 4;
221 let lazy = self.paragraph
222 && !text.is_empty()
223 && (deep
224 || (!Self::starts_block(text) && !Self::is_thematic_break(text) && Self::marker_len(text).is_none()));
225 if !lazy {
226 *self = Self::default();
227 }
228 }
229
230 fn close_before_nested_quote(&mut self, line: &str) {
233 let mut rest = line;
234 for _ in 0..self.quote_level {
235 let Some(at) = rest.find('>') else { return };
236 rest = &rest[at + 1..];
237 }
238 let (edge, rest) = Self::quote_content(line, rest);
239 let text = rest.trim_start_matches([' ', '\t']);
240 let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
241 while self.items.last().is_some_and(|item| item.content_column > indent) {
242 self.items.pop();
243 }
244 }
245
246 fn enter_quote(&mut self, level: usize) {
250 if level == self.quote_level {
251 return;
252 }
253 let nested_paragraph = self.paragraph;
254 let mut outer = std::mem::take(&mut self.parents);
255 if level > self.quote_level {
256 if self.quote_level > 0 {
257 outer.push(std::mem::take(self));
258 }
259 } else {
260 while outer.last().is_some_and(|parent| parent.quote_level > level) {
261 outer.pop();
262 }
263 }
264 *self = match outer.pop_if(|parent| parent.quote_level == level) {
265 Some(parent) => Self {
268 paragraph: level < self.quote_level && nested_paragraph,
269 table: false,
270 html: false,
271 header_cells: None,
272 ..parent
273 },
274 None => Self {
275 quote_level: level,
276 ..Self::default()
277 },
278 };
279 self.parents = outer;
280 }
281
282 fn code_line(&mut self, line: &str, level: usize) {
285 if level > self.quote_level && self.quote_level > 0 {
286 self.close_before_nested_quote(line);
287 }
288 self.enter_quote(level);
289 let mut rest = line;
290 for _ in 0..level {
291 let Some(at) = rest.find('>') else { return };
292 rest = &rest[at + 1..];
293 }
294 let (edge, rest) = Self::quote_content(line, rest);
295 let text = rest.trim_start_matches([' ', '\t']);
296 let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
297 if !text.is_empty() {
298 while self.items.last().is_some_and(|item| item.content_column > indent) {
299 self.items.pop();
300 }
301 }
302 self.paragraph = false;
303 }
304
305 fn columns(prefix: &str) -> usize {
308 prefix
309 .chars()
310 .fold(0, |column, c| if c == '\t' { (column / 4 + 1) * 4 } else { column + 1 })
311 }
312
313 fn quote_content<'a>(line: &str, after_marker: &'a str) -> (usize, &'a str) {
317 let marker_end = Self::columns(&line[..line.len() - after_marker.len()]);
318 match after_marker.strip_prefix([' ', '\t']) {
319 Some(rest) => (marker_end + 1, rest),
320 None => (marker_end, after_marker),
321 }
322 }
323
324 fn starts_block(text: &str) -> bool {
327 let hashes = text.bytes().take_while(|&byte| byte == b'#').count();
328 match text.as_bytes()[0] {
329 b'#' => (1..=6).contains(&hashes) && (text.len() == hashes || text[hashes..].starts_with([' ', '\t'])),
330 b'>' => true,
331 b'<' => parse_html_block_start(text).is_some() || opens_untagged_html_block(text),
332 b'`' => {
333 let ticks = text.bytes().take_while(|&byte| byte == b'`').count();
334 ticks >= 3 && !text[ticks..].contains('`')
335 }
336 b'~' => text.starts_with("~~~"),
337 _ => false,
338 }
339 }
340
341 fn cells(text: &str) -> usize {
344 let row = text.trim_matches([' ', '\t']);
345 let row = row.strip_prefix('|').unwrap_or(row);
346 let row = row.strip_suffix('|').filter(|row| !row.ends_with('\\')).unwrap_or(row);
347 let mut escaped = false;
348 1 + row
349 .chars()
350 .filter(|&c| {
351 let separator = c == '|' && !escaped;
352 escaped = c == '\\' && !escaped;
353 separator
354 })
355 .count()
356 }
357
358 fn is_table_delimiter(text: &str) -> bool {
361 let row = text.trim_matches([' ', '\t']);
362 text.contains('|')
363 && row
364 .strip_prefix('|')
365 .unwrap_or(row)
366 .strip_suffix('|')
367 .unwrap_or_else(|| row.strip_prefix('|').unwrap_or(row))
368 .split('|')
369 .all(|cell| {
370 let cell = cell.trim_matches([' ', '\t']);
371 let dashes = cell.strip_prefix(':').unwrap_or(cell);
372 let dashes = dashes.strip_suffix(':').unwrap_or(dashes);
373 !dashes.is_empty() && dashes.bytes().all(|byte| byte == b'-')
374 })
375 }
376
377 fn is_thematic_break(text: &str) -> bool {
379 let Some(first) = text.chars().next().filter(|c| matches!(c, '-' | '_' | '*')) else {
380 return false;
381 };
382 let mut count = 0;
383 for c in text.chars() {
384 if c == first {
385 count += 1;
386 } else if !matches!(c, ' ' | '\t') {
387 return false;
388 }
389 }
390 count >= 3
391 }
392
393 fn marker_len(text: &str) -> Option<(usize, usize, u8, bool)> {
396 let bytes = text.as_bytes();
397 let (width, kind, starts_list) = match bytes[0] {
398 b @ (b'-' | b'+' | b'*') => (1, b, true),
399 b'0'..=b'9' => {
400 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
401 match bytes.get(digits) {
402 Some(&d @ (b'.' | b')')) if digits <= 9 => (digits + 1, d | 0x80, text[..digits].parse() == Ok(1)),
403 _ => return None,
404 }
405 }
406 _ => return None,
407 };
408 let padding = match bytes.get(width) {
409 None => 0,
410 Some(b' ' | b'\t') => text[width..].len() - text[width..].trim_start_matches([' ', '\t']).len(),
411 _ => return None,
412 };
413 let interrupts = starts_list && padding < text.len() - width;
414 Some((width, padding, kind, interrupts))
415 }
416}
417
418#[derive(Debug, Default, Clone)]
422pub struct MD027MultipleSpacesBlockquote {
423 config: MD027Config,
424}
425
426impl MD027MultipleSpacesBlockquote {
427 pub fn new() -> Self {
428 Self::default()
429 }
430
431 pub fn with_config(config: MD027Config) -> Self {
432 Self { config }
433 }
434}
435
436impl Rule for MD027MultipleSpacesBlockquote {
437 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
438 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(27))
439 }
440
441 fn name(&self) -> &'static str {
442 "MD027"
443 }
444
445 fn description(&self) -> &'static str {
446 "Multiple spaces after quote marker (>)"
447 }
448
449 fn category(&self) -> RuleCategory {
450 RuleCategory::Blockquote
451 }
452
453 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
454 let mut spacing = Vec::new();
458 let mut malformed = Vec::new();
459 let mut quoted_items = QuotedItems::default();
460
461 let previous_blockquote_line_had_list = if self.config.list_items {
462 Vec::new()
463 } else {
464 Self::previous_blockquote_lines_had_list(ctx)
465 };
466 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
467 let line_num = line_idx + 1;
468 let marker_byte = line_info.byte_offset + line_info.indent;
469 if line_info.in_esm_block
470 || ctx.overlaps_template_code(marker_byte, marker_byte + 1)
471 || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
472 {
473 continue;
474 }
475
476 if let Some(blockquote) = &line_info.blockquote {
477 if line_info.in_code_block {
478 let line_end = line_info.byte_offset + line_info.content(ctx.content).len();
481 let opens_on_line = ctx
482 .code_block_details
483 .iter()
484 .any(|block| !block.is_fenced && (line_info.byte_offset..line_end).contains(&block.start));
485 if opens_on_line {
486 quoted_items.enters(line_info.content(ctx.content), blockquote.nesting_level);
487 quoted_items.paragraph = false;
488 } else {
489 quoted_items.code_line(line_info.content(ctx.content), blockquote.nesting_level);
490 }
491 }
492 } else {
493 quoted_items.unquoted_line(line_info.content(ctx.content));
494 }
495
496 if line_info.in_code_block
498 || line_info.in_html_block
499 || line_info.in_html_comment
500 || line_info.in_mdx_comment
501 || line_info.in_front_matter
502 || line_info.in_mkdocstrings
503 || line_info.in_jsx_block
504 || line_info.in_kramdown_extension_block
505 {
506 continue;
507 }
508
509 if let Some(blockquote) = &line_info.blockquote {
511 let mut inside_item = quoted_items.enters(line_info.content(ctx.content), blockquote.nesting_level);
512 inside_item |= ctx
515 .definition_text_at(line_num)
516 .is_some_and(|text| text.marker_prefix_len.is_none());
517 let skip_list_lines = !self.config.list_items;
524 let is_likely_list_continuation = skip_list_lines
525 && (ctx.is_in_list_block(line_num)
526 || line_info.list_item.is_some()
527 || previous_blockquote_line_had_list[line_idx]);
528 if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation && !inside_item {
529 let mut byte_pos = 0;
532 let mut found_markers = 0;
533 let mut found_first_space = false;
534
535 for (i, ch) in line_info.content(ctx.content).char_indices() {
536 if found_markers < blockquote.nesting_level {
537 if ch == '>' {
538 found_markers += 1;
539 }
540 } else if !found_first_space && (ch == ' ' || ch == '\t') {
541 found_first_space = true;
543 } else if found_first_space && (ch == ' ' || ch == '\t') {
544 byte_pos = i;
546 break;
547 }
548 }
549
550 let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
552 .chars()
553 .take_while(|&c| c == ' ' || c == '\t')
554 .fold(0, |acc, ch| acc + ch.len_utf8());
555
556 if extra_spaces_bytes > 0 {
557 let (fix_byte_pos, fix_bytes) = if blockquote.content.is_empty() {
560 let first_space_pos = byte_pos - 1;
563 let all_spaces_bytes = line_info.content(ctx.content)[first_space_pos..]
564 .chars()
565 .take_while(|&c| c == ' ' || c == '\t')
566 .fold(0, |acc, ch| acc + ch.len_utf8());
567 (first_space_pos, all_spaces_bytes)
568 } else {
569 (byte_pos, extra_spaces_bytes)
570 };
571
572 let (start_line, start_col, end_line, end_col) =
573 calculate_match_range(line_num, line_info.content(ctx.content), fix_byte_pos, fix_bytes);
574
575 spacing.push(LintWarning {
576 rule_name: Some(self.name().to_string()),
577 line: start_line,
578 column: start_col,
579 end_line,
580 end_column: end_col,
581 message: "Multiple spaces after quote marker (>)".to_string(),
582 severity: Severity::Warning,
583 fix: Some(Fix::new(
584 {
585 let start_byte = ctx.line_column_byte_range(line_num, start_col).start;
586 let end_byte = ctx.line_column_byte_range(line_num, end_col).start;
587 start_byte..end_byte
588 },
589 String::new(),
590 )),
591 });
592 }
593 }
594 } else {
595 quoted_items.unquoted_line(line_info.content(ctx.content));
596 let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
598 for (start, len, fixed_line, description) in malformed_attempts {
599 let (start_line, start_col, end_line, end_col) =
600 calculate_match_range(line_num, line_info.content(ctx.content), start, len);
601
602 malformed.push(LintWarning {
603 rule_name: Some(self.name().to_string()),
604 line: start_line,
605 column: start_col,
606 end_line,
607 end_column: end_col,
608 message: format!("Malformed quote: {description}"),
609 severity: Severity::Warning,
610 fix: Some(Fix::new(
611 ctx.line_column_byte_range_with_length(
612 line_num,
613 1,
614 line_info.content(ctx.content).chars().count(),
615 ),
616 fixed_line,
617 )),
618 });
619 }
620 }
621 }
622
623 if ctx.flavor == crate::config::MarkdownFlavor::MDX {
628 drop_mdx_structure_changing_fixes(ctx, &mut spacing);
629 } else {
630 drop_structure_changing_fixes(ctx, &mut spacing, Allowed::Nothing);
631 }
632
633 let mut warnings = spacing;
634 warnings.extend(malformed);
635 warnings.sort_by_key(|warning| warning.line);
636 Ok(warnings)
637 }
638
639 fn fixes_from_diagnostics(&self) -> bool {
640 true
641 }
642
643 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
644 if self.should_skip(ctx) {
645 return Ok(ctx.content.to_string());
646 }
647 let warnings = self.check(ctx)?;
648 if warnings.is_empty() {
649 return Ok(ctx.content.to_string());
650 }
651 let warnings =
652 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
653 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
654 .map_err(crate::rule::LintError::InvalidInput)
655 }
656
657 fn as_any(&self) -> &dyn std::any::Any {
658 self
659 }
660
661 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
662 where
663 Self: Sized,
664 {
665 let rule_config: MD027Config = load_rule_config(config);
666 Box::new(MD027MultipleSpacesBlockquote::with_config(rule_config))
667 }
668
669 crate::impl_rule_config_sections!(MD027Config);
670
671 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
673 ctx.content.is_empty() || !ctx.likely_has_blockquotes()
674 }
675}
676
677impl MD027MultipleSpacesBlockquote {
678 fn previous_blockquote_lines_had_list(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
683 let mut had_list = Vec::with_capacity(ctx.lines.len());
684 let mut run_has_list = false;
685 for (idx, line) in ctx.lines.iter().enumerate() {
686 had_list.push(run_has_list);
687 if line.blockquote.is_none() {
688 run_has_list = false;
689 } else if line.list_item.is_some() || ctx.is_in_list_block(idx + 1) {
690 run_has_list = true;
691 }
692 }
693 had_list
694 }
695
696 fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
698 let mut results = Vec::new();
699 if !line.trim_start().starts_with('>') {
702 return results;
703 }
704
705 for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
706 if let Some(cap) = pattern.captures(line) {
707 let match_obj = cap.get(0).unwrap();
708 let start = match_obj.start();
709 let len = match_obj.len();
710
711 if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
713 {
714 if self.looks_like_blockquote_attempt(line, &fixed_line) {
716 results.push((start, len, fixed_line, description));
717 }
718 }
719 }
720 }
721
722 results
723 }
724
725 fn extract_blockquote_fix_from_match(
727 &self,
728 cap: ®ex::Captures,
729 issue_type: &str,
730 _original_line: &str,
731 ) -> Option<(String, String)> {
732 match issue_type {
733 "missing spaces in nested blockquote" => {
734 let indent = cap.get(1).map_or("", |m| m.as_str());
736 let content = cap.get(2).map_or("", |m| m.as_str());
737 Some((
738 format!("{}> > {}", indent, content.trim()),
739 "Missing spaces in nested blockquote".to_string(),
740 ))
741 }
742 "missing spaces in deeply nested blockquote" => {
743 let indent = cap.get(1).map_or("", |m| m.as_str());
745 let content = cap.get(2).map_or("", |m| m.as_str());
746 Some((
747 format!("{}> > > {}", indent, content.trim()),
748 "Missing spaces in deeply nested blockquote".to_string(),
749 ))
750 }
751 "extra blockquote marker" => {
752 let indent = cap.get(1).map_or("", |m| m.as_str());
754 let content = cap.get(2).map_or("", |m| m.as_str());
755 Some((
756 format!("{}> {}", indent, content.trim()),
757 "Extra blockquote marker".to_string(),
758 ))
759 }
760 "indented blockquote missing space" => {
761 let indent = cap.get(1).map_or("", |m| m.as_str());
763 let content = cap.get(2).map_or("", |m| m.as_str());
764 Some((
765 format!("{}> {}", indent, content.trim()),
766 "Indented blockquote missing space".to_string(),
767 ))
768 }
769 _ => None,
770 }
771 }
772
773 fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
775 let trimmed_original = original.trim();
779 if trimmed_original.len() < 5 {
780 return false;
782 }
783
784 let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
786 if content_after_markers.is_empty() || content_after_markers.len() < 3 {
787 return false;
789 }
790
791 if !content_after_markers.chars().any(char::is_alphabetic) {
793 return false;
794 }
795
796 if !BLOCKQUOTE_PATTERN.is_match(fixed) {
799 return false;
800 }
801
802 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")
809 {
811 return false;
812 }
813
814 let word_count = content_after_markers.split_whitespace().count();
816 if word_count < 3 {
817 return false;
819 }
820
821 true
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use crate::lint_context::LintContext;
829
830 #[test]
831 fn test_valid_blockquote() {
832 let rule = MD027MultipleSpacesBlockquote::default();
833 let content = "> This is a blockquote\n> > Nested quote";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(result.is_empty(), "Valid blockquotes should not be flagged");
837 }
838
839 #[test]
840 fn test_multiple_spaces_after_marker() {
841 let rule = MD027MultipleSpacesBlockquote::default();
842 let content = "> This has two spaces\n> This has three spaces";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert_eq!(result.len(), 2);
846 assert_eq!(result[0].line, 1);
847 assert_eq!(result[0].column, 3); assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
849 assert_eq!(result[1].line, 2);
850 assert_eq!(result[1].column, 3);
851 }
852
853 #[test]
854 fn test_nested_multiple_spaces() {
855 let rule = MD027MultipleSpacesBlockquote::default();
856 let content = "> Two spaces after marker\n>> Two spaces in nested blockquote";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860 assert_eq!(result.len(), 2);
861 assert!(result[0].message.contains("Multiple spaces"));
862 assert!(result[1].message.contains("Multiple spaces"));
863 }
864
865 #[test]
866 fn test_malformed_nested_quote() {
867 let rule = MD027MultipleSpacesBlockquote::default();
868 let content = ">>This is a nested blockquote without space after markers";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert_eq!(result.len(), 0);
875 }
876
877 #[test]
878 fn test_malformed_deeply_nested() {
879 let rule = MD027MultipleSpacesBlockquote::default();
880 let content = ">>>This is deeply nested without spaces after markers";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884 assert_eq!(result.len(), 0);
886 }
887
888 #[test]
889 fn test_extra_quote_marker() {
890 let rule = MD027MultipleSpacesBlockquote::default();
891 let content = "> >This looks like nested but is actually single level with >This as content";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896 assert_eq!(result.len(), 0);
897 }
898
899 #[test]
900 fn test_indented_missing_space() {
901 let rule = MD027MultipleSpacesBlockquote::default();
902 let content = " >This has 3 spaces indent and no space after marker";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert_eq!(result.len(), 0);
909 }
910
911 #[test]
912 fn test_fix_multiple_spaces() {
913 let rule = MD027MultipleSpacesBlockquote::default();
914 let content = "> Two spaces\n> Three spaces";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let fixed = rule.fix(&ctx).unwrap();
917 assert_eq!(fixed, "> Two spaces\n> Three spaces");
918 }
919
920 #[test]
921 fn test_fix_malformed_quotes() {
922 let rule = MD027MultipleSpacesBlockquote::default();
923 let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let fixed = rule.fix(&ctx).unwrap();
927 assert_eq!(fixed, content);
929 }
930
931 #[test]
932 fn test_fix_extra_marker() {
933 let rule = MD027MultipleSpacesBlockquote::default();
934 let content = "> >Extra marker here";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let fixed = rule.fix(&ctx).unwrap();
938 assert_eq!(fixed, content);
940 }
941
942 #[test]
943 fn test_code_block_ignored() {
944 let rule = MD027MultipleSpacesBlockquote::default();
945 let content = "```\n> This is in a code block\n```";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let result = rule.check(&ctx).unwrap();
948 assert!(result.is_empty(), "Code blocks should be ignored");
949 }
950
951 #[test]
952 fn test_short_content_not_flagged() {
953 let rule = MD027MultipleSpacesBlockquote::default();
954 let content = ">>>\n>>";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(result.is_empty(), "Very short content should not be flagged");
958 }
959
960 #[test]
961 fn test_non_prose_not_flagged() {
962 let rule = MD027MultipleSpacesBlockquote::default();
963 let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(result.is_empty(), "Non-prose content should not be flagged");
967 }
968
969 #[test]
970 fn test_preserve_trailing_newline() {
971 let rule = MD027MultipleSpacesBlockquote::default();
972 let content = "> Two spaces\n";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975 assert_eq!(fixed, "> Two spaces\n");
976
977 let content_no_newline = "> Two spaces";
978 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
979 let fixed2 = rule.fix(&ctx2).unwrap();
980 assert_eq!(fixed2, "> Two spaces");
981 }
982
983 #[test]
984 fn test_mixed_issues() {
985 let rule = MD027MultipleSpacesBlockquote::default();
986 let content = "> Multiple spaces here\n>>Normal nested quote\n> Normal quote";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
990 assert_eq!(result[0].line, 1);
991 }
992
993 #[test]
994 fn test_looks_like_blockquote_attempt() {
995 let rule = MD027MultipleSpacesBlockquote::default();
996
997 assert!(rule.looks_like_blockquote_attempt(
999 ">>This is a real blockquote attempt with text",
1000 "> > This is a real blockquote attempt with text"
1001 ));
1002
1003 assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
1005
1006 assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
1008
1009 assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
1011 }
1012
1013 #[test]
1014 fn test_extract_blockquote_fix() {
1015 let rule = MD027MultipleSpacesBlockquote::default();
1016 let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
1017 let cap = regex.captures(">>content").unwrap();
1018
1019 let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
1020 assert!(result.is_some());
1021 let (fixed, desc) = result.unwrap();
1022 assert_eq!(fixed, "> > content");
1023 assert!(desc.contains("Missing spaces"));
1024 }
1025
1026 #[test]
1027 fn test_empty_blockquote() {
1028 let rule = MD027MultipleSpacesBlockquote::default();
1029 let content = ">\n> \n> content";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let result = rule.check(&ctx).unwrap();
1032 assert_eq!(result.len(), 1);
1034 assert_eq!(result[0].line, 2);
1035 }
1036
1037 #[test]
1038 fn test_fix_preserves_indentation() {
1039 let rule = MD027MultipleSpacesBlockquote::default();
1040 let content = " > Indented with multiple spaces";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let fixed = rule.fix(&ctx).unwrap();
1043 assert_eq!(fixed, " > Indented with multiple spaces");
1044 }
1045
1046 #[test]
1047 fn test_tabs_after_marker_not_flagged() {
1048 let rule = MD027MultipleSpacesBlockquote::default();
1052
1053 let content = ">\tTab after marker";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
1058
1059 let content2 = ">\t\tTwo tabs";
1061 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1062 let result2 = rule.check(&ctx2).unwrap();
1063 assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
1064 }
1065
1066 #[test]
1067 fn test_mixed_spaces_and_tabs() {
1068 let rule = MD027MultipleSpacesBlockquote::default();
1069 let content = "> Space Space";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert_eq!(result.len(), 1);
1075 assert_eq!(result[0].column, 3); let content2 = "> Three spaces";
1079 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1080 let result2 = rule.check(&ctx2).unwrap();
1081 assert_eq!(result2.len(), 1);
1082 }
1083
1084 #[test]
1085 fn test_fix_multiple_spaces_various() {
1086 let rule = MD027MultipleSpacesBlockquote::default();
1087 let content = "> Three spaces";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let fixed = rule.fix(&ctx).unwrap();
1091 assert_eq!(fixed, "> Three spaces");
1092
1093 let content2 = "> Four spaces";
1095 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1096 let fixed2 = rule.fix(&ctx2).unwrap();
1097 assert_eq!(fixed2, "> Four spaces");
1098 }
1099
1100 #[test]
1101 fn test_list_continuation_inside_blockquote_not_flagged() {
1102 let rule = MD027MultipleSpacesBlockquote::default();
1105
1106 let content = "> - Item starts here\n> This continues the item\n> - Another item";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(
1111 result.is_empty(),
1112 "List continuation inside blockquote should not be flagged, got: {result:?}"
1113 );
1114
1115 let content2 = "> * First item\n> First item continuation\n> Still continuing\n> * Second item";
1117 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1118 let result2 = rule.check(&ctx2).unwrap();
1119 assert!(
1120 result2.is_empty(),
1121 "List continuations should not be flagged, got: {result2:?}"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_list_continuation_fix_preserves_indentation() {
1127 let rule = MD027MultipleSpacesBlockquote::default();
1129
1130 let content = "> - Item\n> continuation";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let fixed = rule.fix(&ctx).unwrap();
1133 assert_eq!(fixed, "> - Item\n> continuation");
1135 }
1136
1137 #[test]
1138 fn test_non_list_multiple_spaces_still_flagged() {
1139 let rule = MD027MultipleSpacesBlockquote::default();
1141
1142 let content = "> This has extra spaces";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert_eq!(result.len(), 1, "Non-list line should be flagged");
1147 }
1148
1149 #[test]
1154 fn test_list_items_default_false_skips_list_lines() {
1155 let rule = MD027MultipleSpacesBlockquote::default();
1157 let content = "# Test\n\n> - item one\n> - item two\n";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160 assert!(
1161 result.is_empty(),
1162 "Default (list_items=false) should skip list-item lines, got {result:?}"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_list_items_true_flags_unordered_list_lines() {
1168 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1170 let content = "# Test\n\n> - item one\n> - item two\n";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert_eq!(
1174 result.len(),
1175 2,
1176 "list_items=true should flag both list-item lines, got {result:?}"
1177 );
1178 assert_eq!(result[0].line, 3);
1179 assert_eq!(result[1].line, 4);
1180 }
1181
1182 #[test]
1183 fn test_list_items_true_flags_ordered_list_lines() {
1184 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1185 let content = "# Test\n\n> 1. first\n> 2. second\n";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188 assert_eq!(
1189 result.len(),
1190 2,
1191 "list_items=true should flag ordered list-item lines, got {result:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_list_items_true_flags_list_continuation() {
1197 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1199 let content = "# Test\n\n> - first item\n> more list-y text\n";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 let result = rule.check(&ctx).unwrap();
1202 assert_eq!(
1203 result.len(),
1204 2,
1205 "list_items=true should flag both list-item and continuation, got {result:?}"
1206 );
1207 }
1208
1209 #[test]
1212 fn test_list_items_true_measures_indent_from_the_enclosing_item() {
1213 let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1214 for (content, expected) in [
1215 ("> - a\n> - b\n", vec![]),
1217 ("> - a\n>\n> para in a\n", vec![]),
1218 ("> 1. a\n> - b\n> - c\n", vec![]),
1219 ("> - a\n> - b\n", vec![]),
1220 ("> - a\n> continued\n", vec![]),
1221 ("> - a\n>\n> over-indented para\n", vec![]),
1222 (">> - a\n>> - b\n", vec![]),
1223 ("> - a\n> - b\n> - c\n> - d\n", vec![]),
1224 ("> - a\n> - b\n", vec![2]),
1226 ("> 1. a\n> 2. b\n", vec![2]),
1227 ("> - a\n> * b\n", vec![]),
1228 ("> - a\n> 1. b\n", vec![]),
1229 ("> 1. a\n> 1) b\n", vec![]),
1230 ("> text\n> - b\n", vec![2]),
1231 ("> 1. x\n> * x\n", vec![1, 2]),
1232 ("> - a\n>> x\n", vec![2]),
1233 ("> - a\n> > x\n", vec![2]),
1234 ("> - a\n> - b\n", vec![1, 2]),
1235 ("> a\n> b\n", vec![2]),
1236 ("> - a\n> text\n> x\n", vec![]),
1237 ("> - a\n>\n> text\n> x\n", vec![4]),
1238 ("> - a\n\n> x\n", vec![3]),
1239 (">> - a\n>> - b\n", vec![1, 2]),
1240 ("> - a\n> ---\n> text\n", vec![3]),
1243 ("> - a\n> * * *\n> text\n", vec![3]),
1244 ("> - a\n> ```\n> code\n> ```\n> text\n", vec![5]),
1245 ("> - a\n> ~~~\n> code\n> ~~~\n> text\n", vec![5]),
1246 ("> -\n> text\n> text\n", vec![3]),
1247 ("> - # heading\n> text\n> text\n", vec![3]),
1248 ("> - a\n> ```\n> code\n> ```\n> text\n", vec![]),
1250 ("> - a\n> ```\n> code\n> ```\n> text\n> x\n", vec![6]),
1251 ("> - a\n> text\n> more\n", vec![]),
1252 ("> text\n> 2. not a list\n> bad spacing\n", vec![3]),
1255 ("> text\n> -\n> bad spacing\n", vec![3]),
1256 ("> -\n>\n> text\n", vec![3]),
1257 ("> - a\n>> ```\n>> code\n>> ```\n> bad\n", vec![5]),
1258 ("> - a\n> ===\n> text\n> x\n", vec![4]),
1259 ("> - a\n> ===\n> x\n", vec![]),
1260 ("> - a\n> --\n> text\n> bad\n", vec![4]),
1261 ("> - a | b\n> --- | ---\n> outside\n> bad\n", vec![4]),
1262 ("> `a`\n> -\n> bad spacing\n", vec![3]),
1263 ("> a | b\n> --- | --- | ---\n> -\n> bad spacing\n", vec![4]),
1264 ("> #hashtag\n> -\n> bad spacing\n", vec![3]),
1265 ("> <3 love\n> -\n> bad spacing\n", vec![3]),
1266 ("> - <em>a</em>\n> ===\n> text\n> bad\n", vec![4]),
1267 ("> - -\n> outside\n> bad\n", vec![3]),
1268 ("> - - # heading\n> outside\n> bad\n", vec![3]),
1269 ("> - - a\n> b\n> c\n", vec![]),
1270 ("> - a\n> b | c\n> --- | ---\n> outside\n> bad\n", vec![5]),
1271 ("> - <span>\n> outside\n> bad\n", vec![3]),
1272 ("> p\n> # fake\n> 2. text\n> c\n", vec![2, 4]),
1273 ("> p\nlazy\n # fake\n> 2. text\n> c\n", vec![5]),
1274 ("> p\n> ---\n> 2. text\n> c\n", vec![2, 4]),
1275 ("> a | b\n> --- | ---\n> 2. x\n> c\n", vec![2, 4]),
1276 ("> p\n> <div>\n> 2. x\n> c\n", vec![2, 4]),
1277 ("> p\n> ```\n> 2. x\n> c\n", vec![2, 4]),
1278 ("> - a\n>> q\n>\n> - b\n> - c\n", vec![4, 5]),
1279 ("> - a\n> > # q\n> text\n> bad\n", vec![4]),
1280 ("> - a\n> > q\n> text\n> bad\n", vec![]),
1281 ("# T\n\n> - a\n> > q\n>\n> - b\n", vec![]),
1282 ("# T\n\n> - code\n> - b\n", vec![]),
1283 ("# T\n\n> 1. code\n> 1. b\n> - x\n", vec![]),
1284 ("> 1. code\n> 2. x\n", vec![2]),
1285 ("> - code\n> - x\n", vec![2]),
1286 ("> - code\n> - b\n> - x\n", vec![3]),
1287 ("> - a\n> > q\n>\n> - b\n> - c\n", vec![5]),
1288 ("> - a\n> > q\n> > - n\n>\n> - b\n", vec![]),
1289 ("> text\nlazy\n> 2. not a list\n> bad\n", vec![4]),
1290 ("> text\n\n> 2. a\n> ok\n", vec![]),
1291 ("> a | b\n> --- | :\n> -\n> bad\n", vec![4]),
1292 ("> a | b\n> --- | ---\n> -\n> bad\n", vec![]),
1293 ("> text\n> ``` foo`bar\n> -\n> bad\n", vec![4]),
1294 ("> text\n> ~~~ foo`bar\n> -\n> bad\n", vec![]),
1295 ("> text\n> - <span>\n> inside\n> bad\n", vec![]),
1296 ("> text\n> - <span>\n> outside\n> bad\n", vec![4]),
1297 ("> text\n> <span>\n> -\n> bad\n", vec![4]),
1298 ("> - <div>\n> ===\n> text\n> bad\n", vec![4]),
1299 ("> - a \\| b | c\n> --- | ---\n> outside\n> bad\n", vec![4]),
1300 ("> - a | b\n> --- | ---\n> c | d\n> outside\n> bad\n", vec![5]),
1301 ("> - a | b\n> --- | ---\n> text\n> x\n", vec![]),
1302 ("> - a\n> -\n> text\n> bad\n", vec![4]),
1303 ("> - a\n> 2. b\n> c\n", vec![]),
1304 ("> text\n> 1. is a list\n> c\n", vec![]),
1305 ("> -\n> text\n", vec![]),
1306 ("> - a\n```\ncode\n```\n> text\n", vec![5]),
1309 ("> 1.\ta\n>\n> b\n", vec![3]),
1310 ("> 1.\ta\n>\n> b\n", vec![]),
1311 ("> - a\n>\t- b\n", vec![]),
1312 ] {
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1315 assert_eq!(lines, expected, "{content:?}");
1316 }
1317 }
1318
1319 #[test]
1320 fn test_list_items_default_skips_continuation() {
1321 let rule = MD027MultipleSpacesBlockquote::default();
1323 let content = "# Test\n\n> - first item\n> more list-y text\n";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326 assert!(
1327 result.is_empty(),
1328 "Default should skip both list-item and continuation, got {result:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_plain_blockquote_text_flagged_in_both_modes() {
1334 let content = "# Test\n\n> Plain blockquote text with extra space.\n";
1335 for cfg in [MD027Config { list_items: false }, MD027Config { list_items: true }] {
1336 let rule = MD027MultipleSpacesBlockquote::with_config(cfg.clone());
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 let result = rule.check(&ctx).unwrap();
1339 assert_eq!(
1340 result.len(),
1341 1,
1342 "Plain blockquote text with extra spaces should always be flagged (cfg={cfg:?}), got {result:?}"
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn test_md027_config_kebab_case_parses() {
1349 let toml_str = r#"
1350 list-items = true
1351 "#;
1352 let config: MD027Config = toml::from_str(toml_str).unwrap();
1353 assert!(config.list_items);
1354 }
1355
1356 #[test]
1357 fn test_md027_config_snake_case_alias_parses() {
1358 let toml_str = r#"
1359 list_items = true
1360 "#;
1361 let config: MD027Config = toml::from_str(toml_str).unwrap();
1362 assert!(config.list_items);
1363 }
1364
1365 #[test]
1366 fn test_md027_config_default_is_false() {
1367 let cfg = MD027Config::default();
1368 assert!(!cfg.list_items, "rumdl default for list_items should be false");
1369 }
1370
1371 #[test]
1372 fn test_md027_html_comment() {
1373 let rule = MD027MultipleSpacesBlockquote::default();
1374 let content = "<!--\n> comment\n-->";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let result = rule.check(&ctx).unwrap();
1377 assert!(
1378 result.is_empty(),
1379 "MD027 should not flag blockquotes inside HTML comments: {result:?}"
1380 );
1381 }
1382
1383 #[test]
1384 fn test_lazy_line_indent_that_changes_the_parse_is_reported_but_not_fixed() {
1385 let rule = MD027MultipleSpacesBlockquote::default();
1386 for content in [
1389 "> o\n> ---\n",
1390 "> o\n> ===\n",
1391 "> o\n> # x\n",
1392 "> o\n> - x\n",
1393 "> o\n> ```\n",
1394 "> > o\n> > ---\n",
1395 ] {
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 let warnings = rule.check(&ctx).unwrap();
1398 assert!(!warnings.is_empty(), "{content:?} must still be reported");
1399 assert!(
1400 warnings.iter().all(|w| w.fix.is_none()),
1401 "{content:?} must carry no fix: {warnings:?}"
1402 );
1403 assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1404 }
1405 }
1406
1407 #[test]
1408 fn test_lazy_line_indent_on_plain_prose_is_still_fixed() {
1409 let rule = MD027MultipleSpacesBlockquote::default();
1410 let content = "> o\n> more text\n> and more\n";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 assert_eq!(rule.fix(&ctx).unwrap(), "> o\n> more text\n> and more\n");
1413 }
1414}