1use std::cell::OnceCell;
2
3use crate::lint_context::LintContext;
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::skip_context::is_table_line;
6
7#[derive(Debug, Clone, PartialEq, Eq, Default)]
9pub enum ListItemSpacingStyle {
10 #[default]
11 Consistent,
12 Loose,
13 Tight,
14}
15
16#[derive(Debug, Clone, Default, PartialEq)]
17pub(super) struct MD076Config {
18 pub style: ListItemSpacingStyle,
19 pub allow_loose_continuation: bool,
23}
24
25#[derive(Debug, Clone, Default)]
43pub struct MD076ListItemSpacing {
44 config: MD076Config,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum GapKind {
50 Tight,
52 Loose,
54 Structural,
57 ContinuationLoose,
61}
62
63struct ListAnalysis {
65 items: Vec<usize>,
67 gaps: Vec<GapKind>,
69 warn_loose_gaps: bool,
71 warn_tight_gaps: bool,
73}
74
75impl MD076ListItemSpacing {
76 pub fn new(style: ListItemSpacingStyle) -> Self {
77 Self {
78 config: MD076Config {
79 style,
80 allow_loose_continuation: false,
81 },
82 }
83 }
84
85 pub fn with_allow_loose_continuation(mut self, allow: bool) -> Self {
86 self.config.allow_loose_continuation = allow;
87 self
88 }
89
90 fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
98 if let Some(info) = ctx.line_info(line_num) {
99 let content = info.content(ctx.content);
100 if content.trim_matches([' ', '\t', '\r']).is_empty() {
101 return true;
102 }
103 if let Some(ref bq) = info.blockquote {
105 return bq.content.trim_matches([' ', '\t', '\r']).is_empty();
106 }
107 false
108 } else {
109 false
110 }
111 }
112
113 fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
116 if let Some(info) = ctx.line_info(line_num) {
117 if info.in_code_block {
119 return true;
120 }
121 if info.in_html_block {
123 return true;
124 }
125 if info.blockquote.is_some() {
127 return true;
128 }
129 let content = info.content(ctx.content);
131 let effective = if let Some(ref bq) = info.blockquote {
133 bq.content.as_str()
134 } else {
135 content
136 };
137 if is_table_line(effective.trim_start()) {
138 return true;
139 }
140 }
141 false
142 }
143
144 fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize, fence_starts: &OnceCell<Vec<usize>>) -> bool {
155 let Some(info) = ctx.line_info(line_num) else {
156 return false;
157 };
158 if info.list_item.is_none() {
159 return false;
160 }
161
162 let line_range = info.byte_offset..info.byte_offset + info.byte_len;
163 let starts = fence_starts.get_or_init(|| {
166 let mut starts: Vec<_> = ctx
167 .code_block_details
168 .iter()
169 .filter(|detail| detail.is_fenced)
170 .map(|detail| detail.start)
171 .collect();
172 starts.sort_unstable();
173 starts
174 });
175 let next = starts.partition_point(|&start| start < line_range.start);
176 starts.get(next).is_some_and(|start| line_range.contains(start))
177 }
178
179 fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
186 let Some(info) = ctx.line_info(line_num) else {
187 return false;
188 };
189 if info.list_item.is_some() {
191 return false;
192 }
193 if info.in_code_block
195 || info.in_html_block
196 || info.in_html_comment
197 || info.in_mdx_comment
198 || info.in_front_matter
199 || info.in_math_block
200 || info.blockquote.is_some()
201 {
202 return false;
203 }
204 let content = info.content(ctx.content);
205 if content.trim_matches([' ', '\t', '\r']).is_empty() {
206 return false;
207 }
208 let indent = content.len() - content.trim_start().len();
210 indent >= parent_content_col
211 }
212
213 fn classify_gap(ctx: &LintContext, first: usize, next: usize, fence_starts: &OnceCell<Vec<usize>>) -> GapKind {
221 if next <= first + 1 {
222 return GapKind::Tight;
223 }
224 if !Self::is_effectively_blank(ctx, next - 1) {
226 return GapKind::Tight;
227 }
228 if Self::is_fenced_code_block_list_item(ctx, next, fence_starts) {
232 return GapKind::Structural;
233 }
234 let mut scan = next - 1;
237 while scan > first && Self::is_effectively_blank(ctx, scan) {
238 scan -= 1;
239 }
240 if scan > first && Self::is_structural_content(ctx, scan) {
242 return GapKind::Structural;
243 }
244 let parent_content_col = ctx
247 .line_info(first)
248 .and_then(|li| li.list_item.as_ref())
249 .map_or(2, |item| item.content_column);
250 if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
251 return GapKind::ContinuationLoose;
252 }
253 GapKind::Loose
254 }
255
256 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
263 let mut blanks = Vec::new();
264 let mut line_num = next - 1;
265 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
266 blanks.push(line_num);
267 line_num -= 1;
268 }
269 if line_num > first && Self::is_structural_content(ctx, line_num) {
271 return Vec::new();
272 }
273 blanks.reverse();
274 blanks
275 }
276
277 fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
281 let fence_starts = OnceCell::new();
282 ctx.list_blocks
283 .iter()
284 .flat_map(|block| ctx.list_block_item_groups(block))
285 .filter_map(|items| {
286 Self::analyze_list(
287 ctx,
288 items,
289 &self.config.style,
290 self.config.allow_loose_continuation,
291 &fence_starts,
292 )
293 })
294 .collect()
295 }
296
297 fn analyze_list(
303 ctx: &LintContext,
304 items: Vec<usize>,
305 style: &ListItemSpacingStyle,
306 allow_loose_continuation: bool,
307 fence_starts: &OnceCell<Vec<usize>>,
308 ) -> Option<ListAnalysis> {
309 if items.len() < 2 {
310 return None;
311 }
312
313 let gaps: Vec<GapKind> = items
315 .windows(2)
316 .map(|w| Self::classify_gap(ctx, w[0], w[1], fence_starts))
317 .collect();
318
319 let loose_count = gaps
323 .iter()
324 .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
325 .count();
326 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
327
328 let (warn_loose_gaps, warn_tight_gaps) = match style {
329 ListItemSpacingStyle::Loose => (false, true),
330 ListItemSpacingStyle::Tight => (true, false),
331 ListItemSpacingStyle::Consistent => {
332 if loose_count == 0 || tight_count == 0 {
333 return None; }
335 if tight_count >= loose_count {
343 (true, false)
344 } else {
345 (false, true)
346 }
347 }
348 };
349
350 Some(ListAnalysis {
351 items,
352 gaps,
353 warn_loose_gaps,
354 warn_tight_gaps,
355 })
356 }
357}
358
359impl Rule for MD076ListItemSpacing {
360 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
361 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(76))
362 }
363
364 fn name(&self) -> &'static str {
365 "MD076"
366 }
367
368 fn description(&self) -> &'static str {
369 "List item spacing should be consistent"
370 }
371
372 fn category(&self) -> RuleCategory {
373 RuleCategory::List
374 }
375
376 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
377 ctx.content.is_empty() || ctx.list_blocks.is_empty()
378 }
379
380 fn check(&self, ctx: &LintContext) -> LintResult {
381 if ctx.content.is_empty() {
382 return Ok(Vec::new());
383 }
384
385 let mut warnings = Vec::new();
386
387 let allow_cont = self.config.allow_loose_continuation;
388 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
392
393 for analysis in self.analyze(ctx) {
394 for (i, &gap) in analysis.gaps.iter().enumerate() {
395 let is_loose_violation = match gap {
396 GapKind::Loose => analysis.warn_loose_gaps,
397 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
398 _ => false,
399 };
400
401 if is_loose_violation {
402 let next_item = analysis.items[i + 1];
403 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
404 if let Some(&blank_line) = blanks.first() {
405 let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
406 let fix = ctx
409 .line_start_byte(blank_line)
410 .zip(ctx.line_start_byte(next_item))
411 .map(|(start, end)| Fix::new(start..end, String::new()));
412 warnings.push(LintWarning {
413 rule_name: Some(self.name().to_string()),
414 line: blank_line,
415 column: 1,
416 end_line: blank_line,
417 end_column: line_content.chars().count() + 1,
418 message: "Unexpected blank line between list items".to_string(),
419 severity: Severity::Warning,
420 fix,
421 });
422 }
423 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
424 let next_item = analysis.items[i + 1];
425 let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
426 let fix = ctx.line_start_byte(next_item).map(|start| {
429 let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
430 Fix::new(start..start, format!("{prefix}{line_ending}"))
431 });
432 warnings.push(LintWarning {
433 rule_name: Some(self.name().to_string()),
434 line: next_item,
435 column: 1,
436 end_line: next_item,
437 end_column: line_content.chars().count() + 1,
438 message: "Missing blank line between list items".to_string(),
439 severity: Severity::Warning,
440 fix,
441 });
442 }
443 }
444 }
445
446 Ok(warnings)
447 }
448
449 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
450 if ctx.content.is_empty() {
451 return Ok(ctx.content.to_string());
452 }
453
454 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
456 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
457
458 let allow_cont = self.config.allow_loose_continuation;
459
460 for analysis in self.analyze(ctx) {
461 for (i, &gap) in analysis.gaps.iter().enumerate() {
462 let is_loose_violation = match gap {
463 GapKind::Loose => analysis.warn_loose_gaps,
464 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
465 _ => false,
466 };
467
468 if is_loose_violation {
469 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
470 remove_lines.insert(blank_line);
471 }
472 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
473 insert_before.insert(analysis.items[i + 1]);
474 }
475 }
476 }
477
478 if insert_before.is_empty() && remove_lines.is_empty() {
479 return Ok(ctx.content.to_string());
480 }
481
482 let lines = ctx.raw_lines();
483 let mut result: Vec<String> = Vec::with_capacity(lines.len());
484
485 for (i, line) in lines.iter().enumerate() {
486 let line_num = i + 1;
487
488 if ctx.is_rule_disabled(self.name(), line_num) {
490 result.push((*line).to_string());
491 continue;
492 }
493
494 if remove_lines.contains(&line_num) {
495 continue;
496 }
497
498 if insert_before.contains(&line_num) {
499 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
500 result.push(bq_prefix);
501 }
502
503 result.push((*line).to_string());
504 }
505
506 let mut output = result.join("\n");
507 if ctx.content.ends_with('\n') {
508 output.push('\n');
509 }
510 Ok(output)
511 }
512
513 fn as_any(&self) -> &dyn std::any::Any {
514 self
515 }
516
517 fn default_config_section(&self) -> Option<(String, toml::Value)> {
518 let mut map = toml::map::Map::new();
519 let style_str = match self.config.style {
520 ListItemSpacingStyle::Consistent => "consistent",
521 ListItemSpacingStyle::Loose => "loose",
522 ListItemSpacingStyle::Tight => "tight",
523 };
524 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
525 map.insert(
526 "allow-loose-continuation".to_string(),
527 toml::Value::Boolean(self.config.allow_loose_continuation),
528 );
529 Some((self.name().to_string(), toml::Value::Table(map)))
530 }
531
532 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
533 where
534 Self: Sized,
535 {
536 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
537 .unwrap_or_else(|| "consistent".to_string());
538 let style = match style.as_str() {
539 "loose" => ListItemSpacingStyle::Loose,
540 "tight" => ListItemSpacingStyle::Tight,
541 _ => ListItemSpacingStyle::Consistent,
542 };
543 let allow_loose_continuation =
544 crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
545 .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
546 .unwrap_or(false);
547 Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557 let rule = MD076ListItemSpacing::new(style);
558 rule.check(&ctx).unwrap()
559 }
560
561 fn check_with_continuation(
562 content: &str,
563 style: ListItemSpacingStyle,
564 allow_loose_continuation: bool,
565 ) -> Vec<LintWarning> {
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
568 rule.check(&ctx).unwrap()
569 }
570
571 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let rule = MD076ListItemSpacing::new(style);
574 rule.fix(&ctx).unwrap()
575 }
576
577 fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
580 rule.fix(&ctx).unwrap()
581 }
582
583 #[test]
586 fn tight_list_tight_style_no_warnings() {
587 let content = "- Item 1\n- Item 2\n- Item 3\n";
588 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
589 }
590
591 #[test]
592 fn loose_list_loose_style_no_warnings() {
593 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
594 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
595 }
596
597 #[test]
598 fn tight_list_loose_style_warns() {
599 let content = "- Item 1\n- Item 2\n- Item 3\n";
600 let warnings = check(content, ListItemSpacingStyle::Loose);
601 assert_eq!(warnings.len(), 2);
602 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
603 }
604
605 #[test]
606 fn loose_list_tight_style_warns() {
607 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
608 let warnings = check(content, ListItemSpacingStyle::Tight);
609 assert_eq!(warnings.len(), 2);
610 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
611 }
612
613 #[test]
616 fn consistent_all_tight_no_warnings() {
617 let content = "- Item 1\n- Item 2\n- Item 3\n";
618 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
619 }
620
621 #[test]
622 fn consistent_all_loose_no_warnings() {
623 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
624 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
625 }
626
627 #[test]
628 fn consistent_mixed_majority_loose_warns_tight() {
629 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
631 let warnings = check(content, ListItemSpacingStyle::Consistent);
632 assert_eq!(warnings.len(), 1);
633 assert!(warnings[0].message.contains("Missing"));
634 }
635
636 #[test]
637 fn consistent_mixed_majority_tight_warns_loose() {
638 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
640 let warnings = check(content, ListItemSpacingStyle::Consistent);
641 assert_eq!(warnings.len(), 1);
642 assert!(warnings[0].message.contains("Unexpected"));
643 }
644
645 #[test]
646 fn consistent_tie_prefers_tight() {
647 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
651 let warnings = check(content, ListItemSpacingStyle::Consistent);
652 assert_eq!(warnings.len(), 1);
653 assert!(warnings[0].message.contains("Unexpected"));
654 }
655
656 #[test]
659 fn single_item_list_no_warnings() {
660 let content = "- Only item\n";
661 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
662 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
663 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
664 }
665
666 #[test]
667 fn empty_content_no_warnings() {
668 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
669 }
670
671 #[test]
672 fn ordered_list_tight_gaps_loose_style_warns() {
673 let content = "1. First\n2. Second\n3. Third\n";
674 let warnings = check(content, ListItemSpacingStyle::Loose);
675 assert_eq!(warnings.len(), 2);
676 }
677
678 #[test]
679 fn task_list_works() {
680 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
681 let warnings = check(content, ListItemSpacingStyle::Loose);
682 assert_eq!(warnings.len(), 2);
683 let fixed = fix(content, ListItemSpacingStyle::Loose);
684 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
685 }
686
687 #[test]
688 fn no_trailing_newline() {
689 let content = "- Item 1\n- Item 2";
690 let warnings = check(content, ListItemSpacingStyle::Loose);
691 assert_eq!(warnings.len(), 1);
692 let fixed = fix(content, ListItemSpacingStyle::Loose);
693 assert_eq!(fixed, "- Item 1\n\n- Item 2");
694 }
695
696 #[test]
697 fn two_separate_lists() {
698 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
699 let warnings = check(content, ListItemSpacingStyle::Loose);
700 assert_eq!(warnings.len(), 2);
701 let fixed = fix(content, ListItemSpacingStyle::Loose);
702 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
703 }
704
705 #[test]
706 fn no_list_content() {
707 let content = "Just a paragraph.\n\nAnother paragraph.\n";
708 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
709 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
710 }
711
712 #[test]
715 fn continuation_lines_tight_detected() {
716 let content = "- Item 1\n continuation\n- Item 2\n";
717 let warnings = check(content, ListItemSpacingStyle::Loose);
718 assert_eq!(warnings.len(), 1);
719 assert!(warnings[0].message.contains("Missing"));
720 }
721
722 #[test]
723 fn continuation_lines_loose_detected() {
724 let content = "- Item 1\n continuation\n\n- Item 2\n";
725 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
726 let warnings = check(content, ListItemSpacingStyle::Tight);
727 assert_eq!(warnings.len(), 1);
728 assert!(warnings[0].message.contains("Unexpected"));
729 }
730
731 #[test]
732 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
733 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
736 let warnings = check(content, ListItemSpacingStyle::Tight);
738 assert_eq!(
739 warnings.len(),
740 1,
741 "Should warn only on the inter-item blank, not the intra-item blank"
742 );
743 let fixed = fix(content, ListItemSpacingStyle::Tight);
746 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
747 }
748
749 #[test]
750 fn multi_paragraph_item_loose_style_no_warnings() {
751 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
753 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
754 }
755
756 #[test]
759 fn blockquote_tight_list_loose_style_warns() {
760 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
761 let warnings = check(content, ListItemSpacingStyle::Loose);
762 assert_eq!(warnings.len(), 2);
763 }
764
765 #[test]
766 fn blockquote_loose_list_detected() {
767 let content = "> - Item 1\n>\n> - Item 2\n";
769 let warnings = check(content, ListItemSpacingStyle::Tight);
770 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
771 assert!(warnings[0].message.contains("Unexpected"));
772 }
773
774 #[test]
775 fn blockquote_loose_list_no_warnings_when_loose() {
776 let content = "> - Item 1\n>\n> - Item 2\n";
777 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
778 }
779
780 #[test]
783 fn multiple_blanks_all_removed() {
784 let content = "- Item 1\n\n\n- Item 2\n";
785 let fixed = fix(content, ListItemSpacingStyle::Tight);
786 assert_eq!(fixed, "- Item 1\n- Item 2\n");
787 }
788
789 #[test]
790 fn multiple_blanks_fix_is_idempotent() {
791 let content = "- Item 1\n\n\n\n- Item 2\n";
792 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
793 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
794 assert_eq!(fixed_once, fixed_twice);
795 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
796 }
797
798 #[test]
801 fn fix_adds_blank_lines() {
802 let content = "- Item 1\n- Item 2\n- Item 3\n";
803 let fixed = fix(content, ListItemSpacingStyle::Loose);
804 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
805 }
806
807 #[test]
808 fn fix_removes_blank_lines() {
809 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
810 let fixed = fix(content, ListItemSpacingStyle::Tight);
811 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
812 }
813
814 #[test]
815 fn fix_consistent_adds_blank() {
816 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
818 let fixed = fix(content, ListItemSpacingStyle::Consistent);
819 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
820 }
821
822 #[test]
823 fn fix_idempotent_loose() {
824 let content = "- Item 1\n- Item 2\n";
825 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
826 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
827 assert_eq!(fixed_once, fixed_twice);
828 }
829
830 #[test]
831 fn fix_idempotent_tight() {
832 let content = "- Item 1\n\n- Item 2\n";
833 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
834 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
835 assert_eq!(fixed_once, fixed_twice);
836 }
837
838 #[test]
841 fn nested_list_does_not_affect_parent() {
842 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
844 let warnings = check(content, ListItemSpacingStyle::Tight);
845 assert!(
846 warnings.is_empty(),
847 "Nested items should not cause parent-level warnings"
848 );
849 }
850
851 #[test]
852 fn tab_nested_child_is_not_a_sibling() {
853 let content = "* parent\n\n\t1. child\n* next\n";
859 let warnings = check(content, ListItemSpacingStyle::Consistent);
860 assert!(
861 warnings.is_empty(),
862 "a tab-nested child is not a sibling of the parent items: {warnings:?}"
863 );
864 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
865
866 let sibling = "* parent\n\n* child\n* next\n";
869 let warnings = check(sibling, ListItemSpacingStyle::Consistent);
870 assert_eq!(warnings.len(), 1, "{warnings:?}");
871 assert_eq!(warnings[0].line, 2);
872 }
873
874 #[test]
875 fn nested_list_is_analysed_at_its_own_level() {
876 for (label, content, fixed) in [
882 (
883 "spaces",
884 "- parent\n - a\n\n - b\n - c\n- next\n",
885 "- parent\n - a\n - b\n - c\n- next\n",
886 ),
887 (
888 "tab",
889 "* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
890 "* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
891 ),
892 ] {
893 let warnings = check(content, ListItemSpacingStyle::Consistent);
894 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
895 assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
896 assert_eq!(
897 warnings[0].message, "Unexpected blank line between list items",
898 "{label}"
899 );
900 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
901 }
902
903 let content = "- parent\n - a\n\n - b\n- next\n";
906 let warnings = check(content, ListItemSpacingStyle::Consistent);
907 assert!(warnings.is_empty(), "{warnings:?}");
908 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
909 }
910
911 #[test]
912 fn nested_lists_under_different_parents_are_separate_lists() {
913 let content = "- a\n - a1\n - a2\n- b\n - b1\n\n - b2\n";
919 let warnings = check(content, ListItemSpacingStyle::Consistent);
920 assert!(warnings.is_empty(), "{warnings:?}");
921 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
922
923 let content = "- a\n - a1\n - a2\n - b1\n\n - b2\n";
926 let warnings = check(content, ListItemSpacingStyle::Consistent);
927 assert_eq!(warnings.len(), 1, "{warnings:?}");
928 assert_eq!(warnings[0].line, 5);
929 }
930
931 #[test]
932 fn every_warning_carries_the_edit_the_fix_applies() {
933 let cases = [
940 ("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
941 ("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
942 ("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
943 ("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
944 ("- p\n - a\n\n - b\n - c\n", ListItemSpacingStyle::Consistent),
945 ];
946 for (content, style) in cases {
947 let warnings = check(content, style.clone());
948 assert!(!warnings.is_empty(), "{content:?}");
949 assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
950 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
951 let fixed = fix(content, style);
952 assert_eq!(applied, fixed, "{content:?}");
953 assert_ne!(applied, content, "{content:?}");
954 }
955
956 let content = "- a\r\n\r\n- b\r\n- c\r\n";
962 let warnings = check(content, ListItemSpacingStyle::Consistent);
963 assert_eq!(
964 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
965 "- a\r\n- b\r\n- c\r\n"
966 );
967 for (content, replacement) in [("- a\r\n- b\r\n\r\n- c\r\n", "\r\n"), ("> - a\r\n> - b\r\n", ">\r\n")] {
968 let warnings = check(content, ListItemSpacingStyle::Loose);
969 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
970 let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
971 assert_eq!(fix.replacement, replacement, "{content:?}");
972 assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
973 }
974 let content = "- a\r\n- b\r\n\r\n- c\r\n";
975 let warnings = check(content, ListItemSpacingStyle::Loose);
976 assert_eq!(
977 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
978 "- a\r\n\r\n- b\r\n\r\n- c\r\n"
979 );
980 }
981
982 #[test]
983 fn nested_lists_separated_by_parent_content_are_separate_lists() {
984 for content in [
993 "- p\n - a\n - b\n\n With:\n\n - c\n\n - d\n",
994 "- p\n - a\n - b\n <!-- parent comment -->\n - c\n\n - d\n",
995 "- p\n - a\n - b\n >\n - c\n\n - d\n",
996 "> - p\n> - a\n> - b\n> >\n> - c\n>\n> - d\n",
997 "- p\n - a\n >\n parent\n - c\n\n - d\n",
998 "- p\n - a\n - ```\n more\n - c\n\n - d\n",
999 "- p\n - a\n - | h |\n | --- |\n more\n - c\n\n - d\n",
1000 ] {
1001 let warnings = check(content, ListItemSpacingStyle::Consistent);
1002 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1003 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1004 }
1005
1006 for (content, line, message) in [
1011 (
1012 "- p\n - a\n - b\n\n - c\n\n - d\n",
1013 3,
1014 "Missing blank line between list items",
1015 ),
1016 (
1017 "- p\n - a\n - ```lang`bad\n more\n - c\n\n - d\n",
1018 6,
1019 "Unexpected blank line between list items",
1020 ),
1021 ] {
1022 let warnings = check(content, ListItemSpacingStyle::Consistent);
1023 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1024 assert_eq!(warnings[0].line, line, "{content:?}");
1025 assert_eq!(warnings[0].message, message, "{content:?}");
1026 }
1027 }
1028
1029 #[test]
1030 fn lists_of_different_marker_types_are_separate_lists() {
1031 for content in [
1036 "- parent\n - bullet a\n - bullet b\n 1. ordered a\n\n 2. ordered b\n- next\n",
1037 "- parent\n - dash a\n - dash b\n * star a\n\n * star b\n- next\n",
1038 "- parent\n 1. dot a\n 2. dot b\n 1) paren a\n\n 2) paren b\n- next\n",
1039 "- dash a\n- dash b\n* star a\n\n* star b\n",
1040 ] {
1041 let warnings = check(content, ListItemSpacingStyle::Consistent);
1042 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1043 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1044 }
1045
1046 for (content, line) in [
1049 ("- parent\n - a\n - b\n - c\n\n - d\n- next\n", 5),
1050 ("- parent\n 1. a\n 2. b\n 3. c\n\n 4. d\n- next\n", 5),
1051 ("- a\n- b\n- c\n\n- d\n", 4),
1052 ] {
1053 let warnings = check(content, ListItemSpacingStyle::Consistent);
1054 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1055 assert_eq!(warnings[0].line, line, "{content:?}");
1056 assert_eq!(
1057 warnings[0].message, "Unexpected blank line between list items",
1058 "{content:?}"
1059 );
1060 }
1061 }
1062
1063 #[test]
1064 fn siblings_at_a_different_indent_are_not_the_nested_list() {
1065 let content = " - parent\n - child a\n - child b\n\n - sibling a\n\n - sibling b\n";
1070 let warnings = check(content, ListItemSpacingStyle::Consistent);
1071 assert!(warnings.is_empty(), "{warnings:?}");
1072 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1073
1074 let content = " > - parent\n> - child a\n>\n> - child b\n";
1078 let warnings = check(content, ListItemSpacingStyle::Consistent);
1079 assert!(warnings.is_empty(), "{warnings:?}");
1080 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1081
1082 for (content, line, message) in [
1085 (
1086 " - parent\n - child a\n - child b\n\n - sibling a\n - sibling b\n",
1087 4,
1088 "Unexpected blank line between list items",
1089 ),
1090 (
1091 " - parent\n - child a\n\n - child b\n - child c\n - sibling\n",
1092 3,
1093 "Unexpected blank line between list items",
1094 ),
1095 (
1096 " > - parent\n> - child a\n>\n> - child b\n> - child c\n",
1097 3,
1098 "Unexpected blank line between list items",
1099 ),
1100 ] {
1101 let warnings = check(content, ListItemSpacingStyle::Consistent);
1102 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1103 assert_eq!(warnings[0].line, line, "{content:?}");
1104 assert_eq!(warnings[0].message, message, "{content:?}");
1105 }
1106 }
1107
1108 #[test]
1109 fn lists_in_different_blockquotes_are_different_lists() {
1110 for content in [
1116 "- p\n >- b1\n >\n >- b2\n>- c\n>- d\n",
1117 "- p\n > - b1\n > - b2\n\n > - b3\n",
1118 "> - a\n> - b\n> - c\n\n> - d\n",
1119 ] {
1120 let warnings = check(content, ListItemSpacingStyle::Consistent);
1121 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1122 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1123 }
1124
1125 for (content, line, message) in [
1128 (
1129 "- p\n > - b1\n > - b2\n >\n > - b3\n",
1130 4,
1131 "Unexpected blank line between list items",
1132 ),
1133 (
1134 "- p\n >- b1\n >- b2\n >\n >- b3\n>- c\n>- d\n",
1135 4,
1136 "Unexpected blank line between list items",
1137 ),
1138 ] {
1139 let warnings = check(content, ListItemSpacingStyle::Consistent);
1140 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1141 assert_eq!(warnings[0].line, line, "{content:?}");
1142 assert_eq!(warnings[0].message, message, "{content:?}");
1143 }
1144 }
1145
1146 #[test]
1147 fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
1148 for (content, style) in [
1154 ("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
1155 ("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
1156 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
1157 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
1158 ] {
1159 let warnings = check(content, style.clone());
1160 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1161 assert_eq!(fix(content, style), content);
1162 }
1163
1164 let content = "- p\n```\n```\n > - b\n lazy\n> - c\n";
1167 let warnings = check(content, ListItemSpacingStyle::Loose);
1168 assert_eq!(warnings.len(), 1, "{warnings:?}");
1169 assert_eq!(warnings[0].line, 6);
1170 assert_eq!(warnings[0].message, "Missing blank line between list items");
1171 assert_eq!(
1172 fix(content, ListItemSpacingStyle::Loose),
1173 "- p\n```\n```\n > - b\n lazy\n>\n> - c\n"
1174 );
1175
1176 let content = "> - a\n>\n> - b\n> - c\n";
1178 let warnings = check(content, ListItemSpacingStyle::Consistent);
1179 assert_eq!(warnings.len(), 1, "{warnings:?}");
1180 assert_eq!(warnings[0].line, 2);
1181 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
1182 }
1183
1184 #[test]
1185 fn explicit_style_applies_to_nested_lists() {
1186 let content = "- a\n\n- b\n - b1\n - b2\n";
1189 let warnings = check(content, ListItemSpacingStyle::Loose);
1190 assert_eq!(warnings.len(), 1, "{warnings:?}");
1191 assert_eq!(warnings[0].line, 5);
1192 assert_eq!(warnings[0].message, "Missing blank line between list items");
1193 assert_eq!(
1194 fix(content, ListItemSpacingStyle::Loose),
1195 "- a\n\n- b\n - b1\n\n - b2\n"
1196 );
1197
1198 let content = "- a\n- b\n - b1\n\n - b2\n";
1201 let warnings = check(content, ListItemSpacingStyle::Tight);
1202 assert_eq!(warnings.len(), 1, "{warnings:?}");
1203 assert_eq!(warnings[0].line, 4);
1204 assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n - b1\n - b2\n");
1205 }
1206
1207 #[test]
1210 fn code_block_in_tight_list_no_false_positive() {
1211 let content = "\
1213- Item 1 with code:
1214
1215 ```python
1216 print('hello')
1217 ```
1218
1219- Item 2 simple.
1220- Item 3 simple.
1221";
1222 assert!(
1223 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1224 "Structural blank after code block should not make item 1 appear loose"
1225 );
1226 }
1227
1228 #[test]
1229 fn table_in_tight_list_no_false_positive() {
1230 let content = "\
1232- Item 1 with table:
1233
1234 | Col 1 | Col 2 |
1235 |-------|-------|
1236 | A | B |
1237
1238- Item 2 simple.
1239- Item 3 simple.
1240";
1241 assert!(
1242 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1243 "Structural blank after table should not make item 1 appear loose"
1244 );
1245 }
1246
1247 #[test]
1248 fn html_block_in_tight_list_no_false_positive() {
1249 let content = "\
1250- Item 1 with HTML:
1251
1252 <details>
1253 <summary>Click</summary>
1254 Content
1255 </details>
1256
1257- Item 2 simple.
1258- Item 3 simple.
1259";
1260 assert!(
1261 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1262 "Structural blank after HTML block should not make item 1 appear loose"
1263 );
1264 }
1265
1266 #[test]
1267 fn blockquote_in_tight_list_no_false_positive() {
1268 let content = "\
1270- Item 1 with quote:
1271
1272 > This is a blockquote
1273 > with multiple lines.
1274
1275- Item 2 simple.
1276- Item 3 simple.
1277";
1278 assert!(
1279 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1280 "Structural blank around blockquote should not make item 1 appear loose"
1281 );
1282 assert!(
1283 check(content, ListItemSpacingStyle::Tight).is_empty(),
1284 "Blockquote in tight list should not trigger a violation"
1285 );
1286 }
1287
1288 #[test]
1289 fn blockquote_multiple_items_with_quotes_tight() {
1290 let content = "\
1292- Item 1:
1293
1294 > Quote A
1295
1296- Item 2:
1297
1298 > Quote B
1299
1300- Item 3 plain.
1301";
1302 assert!(
1303 check(content, ListItemSpacingStyle::Tight).is_empty(),
1304 "Multiple items with blockquotes should remain tight"
1305 );
1306 }
1307
1308 #[test]
1309 fn blockquote_mixed_with_genuine_loose_gap() {
1310 let content = "\
1312- Item 1:
1313
1314 > Quote
1315
1316- Item 2 plain.
1317
1318- Item 3 plain.
1319";
1320 let warnings = check(content, ListItemSpacingStyle::Tight);
1321 assert!(
1322 !warnings.is_empty(),
1323 "Genuine loose gap between Item 2 and Item 3 should be flagged"
1324 );
1325 }
1326
1327 #[test]
1328 fn blockquote_single_line_in_tight_list() {
1329 let content = "\
1330- Item 1:
1331
1332 > Single line quote.
1333
1334- Item 2.
1335- Item 3.
1336";
1337 assert!(
1338 check(content, ListItemSpacingStyle::Tight).is_empty(),
1339 "Single-line blockquote should be structural"
1340 );
1341 }
1342
1343 #[test]
1344 fn blockquote_in_ordered_list_tight() {
1345 let content = "\
13461. Item 1:
1347
1348 > Quoted text in ordered list.
1349
13501. Item 2.
13511. Item 3.
1352";
1353 assert!(
1354 check(content, ListItemSpacingStyle::Tight).is_empty(),
1355 "Blockquote in ordered list should be structural"
1356 );
1357 }
1358
1359 #[test]
1360 fn nested_blockquote_in_tight_list() {
1361 let content = "\
1362- Item 1:
1363
1364 > Outer quote
1365 > > Nested quote
1366
1367- Item 2.
1368- Item 3.
1369";
1370 assert!(
1371 check(content, ListItemSpacingStyle::Tight).is_empty(),
1372 "Nested blockquote in tight list should be structural"
1373 );
1374 }
1375
1376 #[test]
1377 fn blockquote_as_entire_item_is_loose() {
1378 let content = "\
1381- > Quote is the entire item content.
1382
1383- Item 2.
1384- Item 3.
1385";
1386 let warnings = check(content, ListItemSpacingStyle::Tight);
1387 assert!(
1388 !warnings.is_empty(),
1389 "Blank after blockquote-only item is a genuine loose gap"
1390 );
1391 }
1392
1393 #[test]
1394 fn mixed_code_and_table_in_tight_list() {
1395 let content = "\
13961. Item with code:
1397
1398 ```markdown
1399 This is some Markdown
1400 ```
1401
14021. Simple item.
14031. Item with table:
1404
1405 | Col 1 | Col 2 |
1406 |:------|:------|
1407 | Row 1 | Row 1 |
1408 | Row 2 | Row 2 |
1409";
1410 assert!(
1411 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1412 "Mix of code blocks and tables should not cause false positives"
1413 );
1414 }
1415
1416 #[test]
1417 fn code_block_with_genuinely_loose_gaps_still_warns() {
1418 let content = "\
1421- Item 1:
1422
1423 ```bash
1424 echo hi
1425 ```
1426
1427- Item 2
1428
1429- Item 3
1430- Item 4
1431";
1432 let warnings = check(content, ListItemSpacingStyle::Consistent);
1433 assert!(
1434 !warnings.is_empty(),
1435 "Genuine inconsistency with code blocks should still be flagged"
1436 );
1437 }
1438
1439 #[test]
1440 fn all_items_have_code_blocks_no_warnings() {
1441 let content = "\
1442- Item 1:
1443
1444 ```python
1445 print(1)
1446 ```
1447
1448- Item 2:
1449
1450 ```python
1451 print(2)
1452 ```
1453
1454- Item 3:
1455
1456 ```python
1457 print(3)
1458 ```
1459";
1460 assert!(
1461 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1462 "All items with code blocks should be consistently tight"
1463 );
1464 }
1465
1466 #[test]
1467 fn tilde_fence_code_block_in_list() {
1468 let content = "\
1469- Item 1:
1470
1471 ~~~
1472 code here
1473 ~~~
1474
1475- Item 2 simple.
1476- Item 3 simple.
1477";
1478 assert!(
1479 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1480 "Tilde fences should be recognized as structural content"
1481 );
1482 }
1483
1484 #[test]
1485 fn nested_list_with_code_block() {
1486 let content = "\
1487- Item 1
1488 - Nested with code:
1489
1490 ```
1491 nested code
1492 ```
1493
1494 - Nested simple.
1495- Item 2
1496";
1497 assert!(
1498 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1499 "Nested list with code block should not cause false positives"
1500 );
1501 }
1502
1503 #[test]
1504 fn tight_style_with_code_block_no_warnings() {
1505 let content = "\
1506- Item 1:
1507
1508 ```
1509 code
1510 ```
1511
1512- Item 2.
1513- Item 3.
1514";
1515 assert!(
1516 check(content, ListItemSpacingStyle::Tight).is_empty(),
1517 "Tight style should not warn about structural blanks around code blocks"
1518 );
1519 }
1520
1521 #[test]
1522 fn loose_style_with_code_block_missing_separator() {
1523 let content = "\
1526- Item 1:
1527
1528 ```
1529 code
1530 ```
1531
1532- Item 2.
1533- Item 3.
1534";
1535 let warnings = check(content, ListItemSpacingStyle::Loose);
1536 assert_eq!(
1537 warnings.len(),
1538 1,
1539 "Loose style should still require blank between simple items"
1540 );
1541 assert!(warnings[0].message.contains("Missing"));
1542 }
1543
1544 #[test]
1545 fn blockquote_list_with_code_block() {
1546 let content = "\
1547> - Item 1:
1548>
1549> ```
1550> code
1551> ```
1552>
1553> - Item 2.
1554> - Item 3.
1555";
1556 assert!(
1557 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1558 "Blockquote-prefixed list with code block should not cause false positives"
1559 );
1560 }
1561
1562 #[test]
1565 fn indented_code_block_in_list_no_false_positive() {
1566 let content = "\
15691. Item with indented code:
1570
1571 some code here
1572 more code
1573
15741. Simple item
15751. Another item
1576";
1577 assert!(
1578 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1579 "Structural blank after indented code block should not make item 1 appear loose"
1580 );
1581 }
1582
1583 #[test]
1586 fn fence_on_marker_line_keeps_its_structural_blank() {
1587 for spaces in 1..=4 {
1592 let pad = " ".repeat(spaces);
1593 let indent = " ".repeat(spaces + 1);
1594 let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
1595 assert!(
1596 check(&content, ListItemSpacingStyle::Tight).is_empty(),
1597 "a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
1598 );
1599 assert_eq!(
1600 fix(&content, ListItemSpacingStyle::Tight),
1601 content,
1602 "tight fix must keep the blank MD031 requires ({spaces} space(s))"
1603 );
1604 }
1605 }
1606
1607 #[test]
1608 fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
1609 for fence in ["```", "~~~"] {
1614 let content = format!("- a\n\n- {fence}\n code\n {fence}\n- c\n");
1615 let warnings = check(&content, ListItemSpacingStyle::Tight);
1616 assert_eq!(
1617 warnings.len(),
1618 1,
1619 "no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
1620 );
1621 assert_eq!(
1622 fix(&content, ListItemSpacingStyle::Tight),
1623 format!("- a\n- {fence}\n code\n {fence}\n- c\n"),
1624 "tight fix must remove a blank that MD031 does not require ({fence})"
1625 );
1626 }
1627 }
1628
1629 #[test]
1632 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1633 let content = "\
16381. Item with code in middle:
1639
1640 ```
1641 code
1642 ```
1643
1644 Some text after the code block.
1645
16461. Simple item
16471. Another item
1648";
1649 let warnings = check(content, ListItemSpacingStyle::Consistent);
1650 assert!(
1651 !warnings.is_empty(),
1652 "Blank line after regular text (not structural content) is a genuine loose gap"
1653 );
1654 }
1655
1656 #[test]
1659 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1660 let content = "\
1663- Item 1:
1664
1665 ```
1666 code
1667 ```
1668
1669- Item 2.
1670- Item 3.
1671";
1672 let fixed = fix(content, ListItemSpacingStyle::Tight);
1673 assert_eq!(
1674 fixed, content,
1675 "Tight fix should not remove structural blanks around code blocks"
1676 );
1677 }
1678
1679 #[test]
1682 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1683 let content = "\
16881. First item
1689
16901. Second item with code block:
1691
1692 ```json
1693 {\"key\": \"value\"}
1694 ```
1695
16961. Third item
1697";
1698 assert!(
1699 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1700 "Structural blank after 4-space indented code block should not cause false positive"
1701 );
1702 }
1703
1704 #[test]
1705 fn four_space_indented_fence_tight_style_no_warnings() {
1706 let content = "\
17071. First item
17081. Second item with code block:
1709
1710 ```json
1711 {\"key\": \"value\"}
1712 ```
1713
17141. Third item
1715";
1716 assert!(
1717 check(content, ListItemSpacingStyle::Tight).is_empty(),
1718 "Tight style should not warn about structural blanks with 4-space fences"
1719 );
1720 }
1721
1722 #[test]
1723 fn four_space_indented_fence_loose_style_no_warnings() {
1724 let content = "\
17261. First item
1727
17281. Second item with code block:
1729
1730 ```json
1731 {\"key\": \"value\"}
1732 ```
1733
17341. Third item
1735";
1736 assert!(
1737 check(content, ListItemSpacingStyle::Loose).is_empty(),
1738 "Loose style should not warn when structural gaps are the only non-loose gaps"
1739 );
1740 }
1741
1742 #[test]
1743 fn structural_gap_with_genuine_inconsistency_still_warns() {
1744 let content = "\
17471. First item with code:
1748
1749 ```json
1750 {\"key\": \"value\"}
1751 ```
1752
17531. Second item
1754
17551. Third item
17561. Fourth item
1757";
1758 let warnings = check(content, ListItemSpacingStyle::Consistent);
1759 assert!(
1760 !warnings.is_empty(),
1761 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1762 );
1763 }
1764
1765 #[test]
1766 fn four_space_fence_fix_is_idempotent() {
1767 let content = "\
17701. First item
1771
17721. Second item with code block:
1773
1774 ```json
1775 {\"key\": \"value\"}
1776 ```
1777
17781. Third item
1779";
1780 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1781 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1782 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1783 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1784 }
1785
1786 #[test]
1787 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1788 let content = "\
17911. First item
17921. Second item with code block:
1793
1794 ```json
1795 {\"key\": \"value\"}
1796 ```
1797
17981. Third item
1799";
1800 let fixed = fix(content, ListItemSpacingStyle::Tight);
1801 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1802 }
1803
1804 #[test]
1805 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1806 let content = "\
18091. First item
1810
18111. Second item with code block:
1812
1813 ```json
1814 {\"key\": \"value\"}
1815 ```
1816
18171. Third item
1818";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1820 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1821 let warnings = rule.check(&ctx).unwrap();
1822 assert!(
1823 warnings.is_empty(),
1824 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1825 );
1826 }
1827
1828 #[test]
1831 fn code_block_in_second_item_detects_inconsistency() {
1832 let content = "\
1835# Test
1836
1837- Lorem ipsum dolor sit amet.
1838- Lorem ipsum dolor sit amet.
1839
1840 ```yaml
1841 hello: world
1842 ```
1843
1844- Lorem ipsum dolor sit amet.
1845
1846- Lorem ipsum dolor sit amet.
1847";
1848 let warnings = check(content, ListItemSpacingStyle::Consistent);
1849 assert!(
1850 !warnings.is_empty(),
1851 "Should detect inconsistent spacing when code block is inside a list item"
1852 );
1853 }
1854
1855 #[test]
1856 fn code_block_in_item_all_tight_no_warnings() {
1857 let content = "\
1859- Item 1
1860- Item 2
1861
1862 ```yaml
1863 hello: world
1864 ```
1865
1866- Item 3
1867- Item 4
1868";
1869 assert!(
1870 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1871 "All tight gaps with structural code block should not warn"
1872 );
1873 }
1874
1875 #[test]
1876 fn code_block_in_item_all_loose_no_warnings() {
1877 let content = "\
1879- Item 1
1880
1881- Item 2
1882
1883 ```yaml
1884 hello: world
1885 ```
1886
1887- Item 3
1888
1889- Item 4
1890";
1891 assert!(
1892 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1893 "All loose gaps with structural code block should not warn"
1894 );
1895 }
1896
1897 #[test]
1898 fn code_block_in_ordered_list_detects_inconsistency() {
1899 let content = "\
19001. First item
19011. Second item
1902
1903 ```json
1904 {\"key\": \"value\"}
1905 ```
1906
19071. Third item
1908
19091. Fourth item
1910";
1911 let warnings = check(content, ListItemSpacingStyle::Consistent);
1912 assert!(
1913 !warnings.is_empty(),
1914 "Ordered list with code block should still detect inconsistency"
1915 );
1916 }
1917
1918 #[test]
1919 fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1920 let content = "\
1926- Item 1
1927- Item 2
1928
1929 ```yaml
1930 code: here
1931 ```
1932
1933- Item 3
1934
1935- Item 4
1936";
1937 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1938 assert!(
1939 fixed.contains("- Item 3\n- Item 4"),
1940 "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1941 );
1942 assert!(
1943 !fixed.contains("- Item 1\n\n- Item 2"),
1944 "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1945 );
1946 }
1947
1948 #[test]
1949 fn tilde_code_block_in_item_detects_inconsistency() {
1950 let content = "\
1951- Item 1
1952- Item 2
1953
1954 ~~~
1955 code
1956 ~~~
1957
1958- Item 3
1959
1960- Item 4
1961";
1962 let warnings = check(content, ListItemSpacingStyle::Consistent);
1963 assert!(
1964 !warnings.is_empty(),
1965 "Tilde code block inside item should not prevent inconsistency detection"
1966 );
1967 }
1968
1969 #[test]
1970 fn multiple_code_blocks_all_tight_no_warnings() {
1971 let content = "\
1973- Item 1
1974
1975 ```
1976 code1
1977 ```
1978
1979- Item 2
1980
1981 ```
1982 code2
1983 ```
1984
1985- Item 3
1986- Item 4
1987";
1988 assert!(
1989 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1990 "All non-structural gaps are tight, so list is consistent"
1991 );
1992 }
1993
1994 #[test]
1995 fn code_block_with_mixed_genuine_gaps_warns() {
1996 let content = "\
1998- Item 1
1999
2000 ```
2001 code1
2002 ```
2003
2004- Item 2
2005
2006- Item 3
2007- Item 4
2008";
2009 let warnings = check(content, ListItemSpacingStyle::Consistent);
2010 assert!(
2011 !warnings.is_empty(),
2012 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
2013 );
2014 }
2015
2016 #[test]
2019 fn continuation_loose_tight_style_default_warns() {
2020 let content = "\
2023- Item 1.
2024
2025 Continuation paragraph.
2026
2027- Item 2.
2028
2029 Continuation paragraph.
2030
2031- Item 3.
2032";
2033 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2034 assert!(
2035 !warnings.is_empty(),
2036 "Should warn about loose gaps when allow_loose_continuation is false"
2037 );
2038 }
2039
2040 #[test]
2041 fn continuation_loose_tight_style_allowed_no_warnings() {
2042 let content = "\
2045- Item 1.
2046
2047 Continuation paragraph.
2048
2049- Item 2.
2050
2051 Continuation paragraph.
2052
2053- Item 3.
2054";
2055 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2056 assert!(
2057 warnings.is_empty(),
2058 "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
2059 );
2060 }
2061
2062 #[test]
2063 fn continuation_loose_mixed_items_warns() {
2064 let content = "\
2067- Item 1.
2068
2069- Item 2.
2070- Item 3.
2071";
2072 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2073 assert!(
2074 !warnings.is_empty(),
2075 "Genuine loose gaps should still warn even with allow_loose_continuation"
2076 );
2077 }
2078
2079 #[test]
2080 fn continuation_loose_consistent_mode() {
2081 let content = "\
2084- Item 1.
2085
2086 Continuation paragraph.
2087
2088- Item 2.
2089- Item 3.
2090";
2091 let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
2092 assert!(
2093 warnings.is_empty(),
2094 "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
2095 );
2096 }
2097
2098 #[test]
2099 fn continuation_loose_fix_preserves_continuation_blanks() {
2100 let content = "\
2101- Item 1.
2102
2103 Continuation paragraph.
2104
2105- Item 2.
2106
2107 Continuation paragraph.
2108
2109- Item 3.
2110";
2111 let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
2112 assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
2113 }
2114
2115 #[test]
2116 fn continuation_loose_fix_removes_genuine_loose_gaps() {
2117 let input = "\
2118- Item 1.
2119
2120- Item 2.
2121
2122- Item 3.
2123";
2124 let expected = "\
2125- Item 1.
2126- Item 2.
2127- Item 3.
2128";
2129 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2130 assert_eq!(fixed, expected);
2131 }
2132
2133 #[test]
2134 fn continuation_loose_ordered_list() {
2135 let content = "\
21361. Item 1.
2137
2138 Continuation paragraph.
2139
21402. Item 2.
2141
2142 Continuation paragraph.
2143
21443. Item 3.
2145";
2146 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2147 assert!(
2148 warnings.is_empty(),
2149 "Ordered list continuation should work too, got: {warnings:?}"
2150 );
2151 }
2152
2153 #[test]
2154 fn continuation_loose_disabled_by_default() {
2155 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
2157 assert!(!rule.config.allow_loose_continuation);
2158 }
2159
2160 #[test]
2161 fn continuation_loose_ordered_under_indented_ends_the_list() {
2162 let content = "\
21701. Item 1.
2171
2172 Under-indented text.
2173
21741. Item 2.
21751. Item 3.
2176";
2177 for (style, allow) in [
2178 (ListItemSpacingStyle::Tight, true),
2179 (ListItemSpacingStyle::Tight, false),
2180 (ListItemSpacingStyle::Consistent, false),
2181 ] {
2182 let warnings = check_with_continuation(content, style, allow);
2183 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2184 }
2185 let content = "\
21861. Item 1.
2187
2188 Continuation text.
2189
21901. Item 2.
21911. Item 3.
2192";
2193 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2194 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2195 assert_eq!(warnings[0].line, 4);
2196 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
2197 }
2198
2199 #[test]
2200 fn continuation_loose_mix_continuation_and_genuine_gaps() {
2201 let content = "\
2203- Item 1.
2204
2205 Continuation paragraph.
2206
2207- Item 2.
2208
2209- Item 3.
2210";
2211 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2212 assert!(
2213 !warnings.is_empty(),
2214 "Genuine loose gap between items 2-3 should warn even with continuation allowed"
2215 );
2216 assert_eq!(
2218 warnings.len(),
2219 1,
2220 "Expected exactly one warning for the genuine loose gap"
2221 );
2222 }
2223
2224 #[test]
2225 fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
2226 let input = "\
2228- Item 1.
2229
2230 Continuation paragraph.
2231
2232- Item 2.
2233
2234- Item 3.
2235";
2236 let expected = "\
2237- Item 1.
2238
2239 Continuation paragraph.
2240
2241- Item 2.
2242- Item 3.
2243";
2244 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2245 assert_eq!(fixed, expected);
2246 }
2247
2248 #[test]
2249 fn continuation_loose_after_code_block() {
2250 let content = "\
2252- Item 1.
2253
2254 ```python
2255 code
2256 ```
2257
2258 Continuation after code.
2259
2260- Item 2.
2261- Item 3.
2262";
2263 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2264 assert!(
2265 warnings.is_empty(),
2266 "Code block + continuation should both be exempt, got: {warnings:?}"
2267 );
2268 }
2269
2270 #[test]
2271 fn continuation_loose_style_does_not_interfere() {
2272 let content = "\
2275- Item 1.
2276
2277 Continuation paragraph.
2278
2279- Item 2.
2280
2281 Continuation paragraph.
2282
2283- Item 3.
2284";
2285 let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
2286 assert!(
2287 warnings.is_empty(),
2288 "Loose style with continuation should not warn, got: {warnings:?}"
2289 );
2290 }
2291
2292 #[test]
2293 fn continuation_loose_tight_no_continuation_content() {
2294 let content = "\
2296- Item 1.
2297- Item 2.
2298- Item 3.
2299";
2300 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2301 assert!(
2302 warnings.is_empty(),
2303 "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
2304 );
2305 }
2306
2307 #[test]
2310 fn default_config_section_provides_style_key() {
2311 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2312 let section = rule.default_config_section();
2313 assert!(section.is_some());
2314 let (name, value) = section.unwrap();
2315 assert_eq!(name, "MD076");
2316 if let toml::Value::Table(map) = value {
2317 assert!(map.contains_key("style"));
2318 assert!(map.contains_key("allow-loose-continuation"));
2319 } else {
2320 panic!("Expected Table value from default_config_section");
2321 }
2322 }
2323
2324 #[test]
2325 fn unicode_space_line_is_text_not_a_blank_line() {
2326 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2329 for source in [
2330 "- a\n\u{a0}\n- b\n- c\n",
2331 "- a\n\u{a0}\ntext\n\u{a0}\n- b\n c\n- d\n\n- e\n",
2332 "- a\n\n- b\n\u{a0}\n- c\n- d\n",
2333 ] {
2334 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2335 let fixed = rule.fix(&ctx).unwrap();
2336 assert_eq!(
2337 fixed.matches('\u{a0}').count(),
2338 source.matches('\u{a0}').count(),
2339 "{source:?} -> {fixed:?}"
2340 );
2341 }
2342 }
2343}