1use crate::lint_context::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::skip_context::is_table_line;
4
5#[derive(Debug, Clone, PartialEq, Eq, Default)]
7pub enum ListItemSpacingStyle {
8 #[default]
9 Consistent,
10 Loose,
11 Tight,
12}
13
14#[derive(Debug, Clone, Default, PartialEq)]
15pub(super) struct MD076Config {
16 pub style: ListItemSpacingStyle,
17 pub allow_loose_continuation: bool,
21}
22
23#[derive(Debug, Clone, Default)]
41pub struct MD076ListItemSpacing {
42 config: MD076Config,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum GapKind {
48 Tight,
50 Loose,
52 Structural,
55 ContinuationLoose,
59}
60
61struct ListAnalysis {
63 items: Vec<usize>,
65 gaps: Vec<GapKind>,
67 warn_loose_gaps: bool,
69 warn_tight_gaps: bool,
71}
72
73impl MD076ListItemSpacing {
74 pub fn new(style: ListItemSpacingStyle) -> Self {
75 Self {
76 config: MD076Config {
77 style,
78 allow_loose_continuation: false,
79 },
80 }
81 }
82
83 pub fn with_allow_loose_continuation(mut self, allow: bool) -> Self {
84 self.config.allow_loose_continuation = allow;
85 self
86 }
87
88 fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
96 if let Some(info) = ctx.line_info(line_num) {
97 let content = info.content(ctx.content);
98 if content.trim_matches([' ', '\t', '\r']).is_empty() {
99 return true;
100 }
101 if let Some(ref bq) = info.blockquote {
103 return bq.content.trim_matches([' ', '\t', '\r']).is_empty();
104 }
105 false
106 } else {
107 false
108 }
109 }
110
111 fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
114 if let Some(info) = ctx.line_info(line_num) {
115 if info.in_code_block {
117 return true;
118 }
119 if info.in_html_block {
121 return true;
122 }
123 if info.blockquote.is_some() {
125 return true;
126 }
127 let content = info.content(ctx.content);
129 let effective = if let Some(ref bq) = info.blockquote {
131 bq.content.as_str()
132 } else {
133 content
134 };
135 if is_table_line(effective.trim_start()) {
136 return true;
137 }
138 }
139 false
140 }
141
142 fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize) -> bool {
153 let Some(info) = ctx.line_info(line_num) else {
154 return false;
155 };
156 if info.list_item.is_none() {
157 return false;
158 }
159
160 let line_range = info.byte_offset..info.byte_offset + info.byte_len;
161 ctx.code_block_details
162 .iter()
163 .any(|detail| detail.is_fenced && line_range.contains(&detail.start))
164 }
165
166 fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
173 let Some(info) = ctx.line_info(line_num) else {
174 return false;
175 };
176 if info.list_item.is_some() {
178 return false;
179 }
180 if info.in_code_block
182 || info.in_html_block
183 || info.in_html_comment
184 || info.in_mdx_comment
185 || info.in_front_matter
186 || info.in_math_block
187 || info.blockquote.is_some()
188 {
189 return false;
190 }
191 let content = info.content(ctx.content);
192 if content.trim_matches([' ', '\t', '\r']).is_empty() {
193 return false;
194 }
195 let indent = content.len() - content.trim_start().len();
197 indent >= parent_content_col
198 }
199
200 fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
208 if next <= first + 1 {
209 return GapKind::Tight;
210 }
211 if !Self::is_effectively_blank(ctx, next - 1) {
213 return GapKind::Tight;
214 }
215 if Self::is_fenced_code_block_list_item(ctx, next) {
219 return GapKind::Structural;
220 }
221 let mut scan = next - 1;
224 while scan > first && Self::is_effectively_blank(ctx, scan) {
225 scan -= 1;
226 }
227 if scan > first && Self::is_structural_content(ctx, scan) {
229 return GapKind::Structural;
230 }
231 let parent_content_col = ctx
234 .line_info(first)
235 .and_then(|li| li.list_item.as_ref())
236 .map_or(2, |item| item.content_column);
237 if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
238 return GapKind::ContinuationLoose;
239 }
240 GapKind::Loose
241 }
242
243 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
250 let mut blanks = Vec::new();
251 let mut line_num = next - 1;
252 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
253 blanks.push(line_num);
254 line_num -= 1;
255 }
256 if line_num > first && Self::is_structural_content(ctx, line_num) {
258 return Vec::new();
259 }
260 blanks.reverse();
261 blanks
262 }
263
264 fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
268 ctx.list_blocks
269 .iter()
270 .flat_map(|block| ctx.list_block_item_groups(block))
271 .filter_map(|items| {
272 Self::analyze_list(ctx, items, &self.config.style, self.config.allow_loose_continuation)
273 })
274 .collect()
275 }
276
277 fn analyze_list(
283 ctx: &LintContext,
284 items: Vec<usize>,
285 style: &ListItemSpacingStyle,
286 allow_loose_continuation: bool,
287 ) -> Option<ListAnalysis> {
288 if items.len() < 2 {
289 return None;
290 }
291
292 let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
294
295 let loose_count = gaps
299 .iter()
300 .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
301 .count();
302 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
303
304 let (warn_loose_gaps, warn_tight_gaps) = match style {
305 ListItemSpacingStyle::Loose => (false, true),
306 ListItemSpacingStyle::Tight => (true, false),
307 ListItemSpacingStyle::Consistent => {
308 if loose_count == 0 || tight_count == 0 {
309 return None; }
311 if tight_count >= loose_count {
319 (true, false)
320 } else {
321 (false, true)
322 }
323 }
324 };
325
326 Some(ListAnalysis {
327 items,
328 gaps,
329 warn_loose_gaps,
330 warn_tight_gaps,
331 })
332 }
333}
334
335impl Rule for MD076ListItemSpacing {
336 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
337 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(76))
338 }
339
340 fn name(&self) -> &'static str {
341 "MD076"
342 }
343
344 fn description(&self) -> &'static str {
345 "List item spacing should be consistent"
346 }
347
348 fn category(&self) -> RuleCategory {
349 RuleCategory::List
350 }
351
352 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
353 ctx.content.is_empty() || ctx.list_blocks.is_empty()
354 }
355
356 fn check(&self, ctx: &LintContext) -> LintResult {
357 if ctx.content.is_empty() {
358 return Ok(Vec::new());
359 }
360
361 let mut warnings = Vec::new();
362
363 let allow_cont = self.config.allow_loose_continuation;
364 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
368
369 for analysis in self.analyze(ctx) {
370 for (i, &gap) in analysis.gaps.iter().enumerate() {
371 let is_loose_violation = match gap {
372 GapKind::Loose => analysis.warn_loose_gaps,
373 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
374 _ => false,
375 };
376
377 if is_loose_violation {
378 let next_item = analysis.items[i + 1];
379 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
380 if let Some(&blank_line) = blanks.first() {
381 let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
382 let fix = ctx
385 .line_start_byte(blank_line)
386 .zip(ctx.line_start_byte(next_item))
387 .map(|(start, end)| Fix::new(start..end, String::new()));
388 warnings.push(LintWarning {
389 rule_name: Some(self.name().to_string()),
390 line: blank_line,
391 column: 1,
392 end_line: blank_line,
393 end_column: line_content.chars().count() + 1,
394 message: "Unexpected blank line between list items".to_string(),
395 severity: Severity::Warning,
396 fix,
397 });
398 }
399 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
400 let next_item = analysis.items[i + 1];
401 let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
402 let fix = ctx.line_start_byte(next_item).map(|start| {
405 let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
406 Fix::new(start..start, format!("{prefix}{line_ending}"))
407 });
408 warnings.push(LintWarning {
409 rule_name: Some(self.name().to_string()),
410 line: next_item,
411 column: 1,
412 end_line: next_item,
413 end_column: line_content.chars().count() + 1,
414 message: "Missing blank line between list items".to_string(),
415 severity: Severity::Warning,
416 fix,
417 });
418 }
419 }
420 }
421
422 Ok(warnings)
423 }
424
425 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
426 if ctx.content.is_empty() {
427 return Ok(ctx.content.to_string());
428 }
429
430 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
432 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
433
434 let allow_cont = self.config.allow_loose_continuation;
435
436 for analysis in self.analyze(ctx) {
437 for (i, &gap) in analysis.gaps.iter().enumerate() {
438 let is_loose_violation = match gap {
439 GapKind::Loose => analysis.warn_loose_gaps,
440 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
441 _ => false,
442 };
443
444 if is_loose_violation {
445 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
446 remove_lines.insert(blank_line);
447 }
448 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
449 insert_before.insert(analysis.items[i + 1]);
450 }
451 }
452 }
453
454 if insert_before.is_empty() && remove_lines.is_empty() {
455 return Ok(ctx.content.to_string());
456 }
457
458 let lines = ctx.raw_lines();
459 let mut result: Vec<String> = Vec::with_capacity(lines.len());
460
461 for (i, line) in lines.iter().enumerate() {
462 let line_num = i + 1;
463
464 if ctx.is_rule_disabled(self.name(), line_num) {
466 result.push((*line).to_string());
467 continue;
468 }
469
470 if remove_lines.contains(&line_num) {
471 continue;
472 }
473
474 if insert_before.contains(&line_num) {
475 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
476 result.push(bq_prefix);
477 }
478
479 result.push((*line).to_string());
480 }
481
482 let mut output = result.join("\n");
483 if ctx.content.ends_with('\n') {
484 output.push('\n');
485 }
486 Ok(output)
487 }
488
489 fn as_any(&self) -> &dyn std::any::Any {
490 self
491 }
492
493 fn default_config_section(&self) -> Option<(String, toml::Value)> {
494 let mut map = toml::map::Map::new();
495 let style_str = match self.config.style {
496 ListItemSpacingStyle::Consistent => "consistent",
497 ListItemSpacingStyle::Loose => "loose",
498 ListItemSpacingStyle::Tight => "tight",
499 };
500 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
501 map.insert(
502 "allow-loose-continuation".to_string(),
503 toml::Value::Boolean(self.config.allow_loose_continuation),
504 );
505 Some((self.name().to_string(), toml::Value::Table(map)))
506 }
507
508 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
509 where
510 Self: Sized,
511 {
512 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
513 .unwrap_or_else(|| "consistent".to_string());
514 let style = match style.as_str() {
515 "loose" => ListItemSpacingStyle::Loose,
516 "tight" => ListItemSpacingStyle::Tight,
517 _ => ListItemSpacingStyle::Consistent,
518 };
519 let allow_loose_continuation =
520 crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
521 .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
522 .unwrap_or(false);
523 Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let rule = MD076ListItemSpacing::new(style);
534 rule.check(&ctx).unwrap()
535 }
536
537 fn check_with_continuation(
538 content: &str,
539 style: ListItemSpacingStyle,
540 allow_loose_continuation: bool,
541 ) -> Vec<LintWarning> {
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
544 rule.check(&ctx).unwrap()
545 }
546
547 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let rule = MD076ListItemSpacing::new(style);
550 rule.fix(&ctx).unwrap()
551 }
552
553 fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
556 rule.fix(&ctx).unwrap()
557 }
558
559 #[test]
562 fn tight_list_tight_style_no_warnings() {
563 let content = "- Item 1\n- Item 2\n- Item 3\n";
564 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
565 }
566
567 #[test]
568 fn loose_list_loose_style_no_warnings() {
569 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
570 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
571 }
572
573 #[test]
574 fn tight_list_loose_style_warns() {
575 let content = "- Item 1\n- Item 2\n- Item 3\n";
576 let warnings = check(content, ListItemSpacingStyle::Loose);
577 assert_eq!(warnings.len(), 2);
578 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
579 }
580
581 #[test]
582 fn loose_list_tight_style_warns() {
583 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
584 let warnings = check(content, ListItemSpacingStyle::Tight);
585 assert_eq!(warnings.len(), 2);
586 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
587 }
588
589 #[test]
592 fn consistent_all_tight_no_warnings() {
593 let content = "- Item 1\n- Item 2\n- Item 3\n";
594 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
595 }
596
597 #[test]
598 fn consistent_all_loose_no_warnings() {
599 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
600 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
601 }
602
603 #[test]
604 fn consistent_mixed_majority_loose_warns_tight() {
605 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
607 let warnings = check(content, ListItemSpacingStyle::Consistent);
608 assert_eq!(warnings.len(), 1);
609 assert!(warnings[0].message.contains("Missing"));
610 }
611
612 #[test]
613 fn consistent_mixed_majority_tight_warns_loose() {
614 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
616 let warnings = check(content, ListItemSpacingStyle::Consistent);
617 assert_eq!(warnings.len(), 1);
618 assert!(warnings[0].message.contains("Unexpected"));
619 }
620
621 #[test]
622 fn consistent_tie_prefers_tight() {
623 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
627 let warnings = check(content, ListItemSpacingStyle::Consistent);
628 assert_eq!(warnings.len(), 1);
629 assert!(warnings[0].message.contains("Unexpected"));
630 }
631
632 #[test]
635 fn single_item_list_no_warnings() {
636 let content = "- Only item\n";
637 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
638 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
639 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
640 }
641
642 #[test]
643 fn empty_content_no_warnings() {
644 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
645 }
646
647 #[test]
648 fn ordered_list_tight_gaps_loose_style_warns() {
649 let content = "1. First\n2. Second\n3. Third\n";
650 let warnings = check(content, ListItemSpacingStyle::Loose);
651 assert_eq!(warnings.len(), 2);
652 }
653
654 #[test]
655 fn task_list_works() {
656 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
657 let warnings = check(content, ListItemSpacingStyle::Loose);
658 assert_eq!(warnings.len(), 2);
659 let fixed = fix(content, ListItemSpacingStyle::Loose);
660 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
661 }
662
663 #[test]
664 fn no_trailing_newline() {
665 let content = "- Item 1\n- Item 2";
666 let warnings = check(content, ListItemSpacingStyle::Loose);
667 assert_eq!(warnings.len(), 1);
668 let fixed = fix(content, ListItemSpacingStyle::Loose);
669 assert_eq!(fixed, "- Item 1\n\n- Item 2");
670 }
671
672 #[test]
673 fn two_separate_lists() {
674 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
675 let warnings = check(content, ListItemSpacingStyle::Loose);
676 assert_eq!(warnings.len(), 2);
677 let fixed = fix(content, ListItemSpacingStyle::Loose);
678 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
679 }
680
681 #[test]
682 fn no_list_content() {
683 let content = "Just a paragraph.\n\nAnother paragraph.\n";
684 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
685 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
686 }
687
688 #[test]
691 fn continuation_lines_tight_detected() {
692 let content = "- Item 1\n continuation\n- Item 2\n";
693 let warnings = check(content, ListItemSpacingStyle::Loose);
694 assert_eq!(warnings.len(), 1);
695 assert!(warnings[0].message.contains("Missing"));
696 }
697
698 #[test]
699 fn continuation_lines_loose_detected() {
700 let content = "- Item 1\n continuation\n\n- Item 2\n";
701 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
702 let warnings = check(content, ListItemSpacingStyle::Tight);
703 assert_eq!(warnings.len(), 1);
704 assert!(warnings[0].message.contains("Unexpected"));
705 }
706
707 #[test]
708 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
709 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
712 let warnings = check(content, ListItemSpacingStyle::Tight);
714 assert_eq!(
715 warnings.len(),
716 1,
717 "Should warn only on the inter-item blank, not the intra-item blank"
718 );
719 let fixed = fix(content, ListItemSpacingStyle::Tight);
722 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
723 }
724
725 #[test]
726 fn multi_paragraph_item_loose_style_no_warnings() {
727 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
729 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
730 }
731
732 #[test]
735 fn blockquote_tight_list_loose_style_warns() {
736 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
737 let warnings = check(content, ListItemSpacingStyle::Loose);
738 assert_eq!(warnings.len(), 2);
739 }
740
741 #[test]
742 fn blockquote_loose_list_detected() {
743 let content = "> - Item 1\n>\n> - Item 2\n";
745 let warnings = check(content, ListItemSpacingStyle::Tight);
746 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
747 assert!(warnings[0].message.contains("Unexpected"));
748 }
749
750 #[test]
751 fn blockquote_loose_list_no_warnings_when_loose() {
752 let content = "> - Item 1\n>\n> - Item 2\n";
753 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
754 }
755
756 #[test]
759 fn multiple_blanks_all_removed() {
760 let content = "- Item 1\n\n\n- Item 2\n";
761 let fixed = fix(content, ListItemSpacingStyle::Tight);
762 assert_eq!(fixed, "- Item 1\n- Item 2\n");
763 }
764
765 #[test]
766 fn multiple_blanks_fix_is_idempotent() {
767 let content = "- Item 1\n\n\n\n- Item 2\n";
768 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
769 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
770 assert_eq!(fixed_once, fixed_twice);
771 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
772 }
773
774 #[test]
777 fn fix_adds_blank_lines() {
778 let content = "- Item 1\n- Item 2\n- Item 3\n";
779 let fixed = fix(content, ListItemSpacingStyle::Loose);
780 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
781 }
782
783 #[test]
784 fn fix_removes_blank_lines() {
785 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
786 let fixed = fix(content, ListItemSpacingStyle::Tight);
787 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
788 }
789
790 #[test]
791 fn fix_consistent_adds_blank() {
792 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
794 let fixed = fix(content, ListItemSpacingStyle::Consistent);
795 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
796 }
797
798 #[test]
799 fn fix_idempotent_loose() {
800 let content = "- Item 1\n- Item 2\n";
801 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
802 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
803 assert_eq!(fixed_once, fixed_twice);
804 }
805
806 #[test]
807 fn fix_idempotent_tight() {
808 let content = "- Item 1\n\n- Item 2\n";
809 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
810 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
811 assert_eq!(fixed_once, fixed_twice);
812 }
813
814 #[test]
817 fn nested_list_does_not_affect_parent() {
818 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
820 let warnings = check(content, ListItemSpacingStyle::Tight);
821 assert!(
822 warnings.is_empty(),
823 "Nested items should not cause parent-level warnings"
824 );
825 }
826
827 #[test]
828 fn tab_nested_child_is_not_a_sibling() {
829 let content = "* parent\n\n\t1. child\n* next\n";
835 let warnings = check(content, ListItemSpacingStyle::Consistent);
836 assert!(
837 warnings.is_empty(),
838 "a tab-nested child is not a sibling of the parent items: {warnings:?}"
839 );
840 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
841
842 let sibling = "* parent\n\n* child\n* next\n";
845 let warnings = check(sibling, ListItemSpacingStyle::Consistent);
846 assert_eq!(warnings.len(), 1, "{warnings:?}");
847 assert_eq!(warnings[0].line, 2);
848 }
849
850 #[test]
851 fn nested_list_is_analysed_at_its_own_level() {
852 for (label, content, fixed) in [
858 (
859 "spaces",
860 "- parent\n - a\n\n - b\n - c\n- next\n",
861 "- parent\n - a\n - b\n - c\n- next\n",
862 ),
863 (
864 "tab",
865 "* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
866 "* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
867 ),
868 ] {
869 let warnings = check(content, ListItemSpacingStyle::Consistent);
870 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
871 assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
872 assert_eq!(
873 warnings[0].message, "Unexpected blank line between list items",
874 "{label}"
875 );
876 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
877 }
878
879 let content = "- parent\n - a\n\n - b\n- next\n";
882 let warnings = check(content, ListItemSpacingStyle::Consistent);
883 assert!(warnings.is_empty(), "{warnings:?}");
884 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
885 }
886
887 #[test]
888 fn nested_lists_under_different_parents_are_separate_lists() {
889 let content = "- a\n - a1\n - a2\n- b\n - b1\n\n - b2\n";
895 let warnings = check(content, ListItemSpacingStyle::Consistent);
896 assert!(warnings.is_empty(), "{warnings:?}");
897 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
898
899 let content = "- a\n - a1\n - a2\n - b1\n\n - b2\n";
902 let warnings = check(content, ListItemSpacingStyle::Consistent);
903 assert_eq!(warnings.len(), 1, "{warnings:?}");
904 assert_eq!(warnings[0].line, 5);
905 }
906
907 #[test]
908 fn every_warning_carries_the_edit_the_fix_applies() {
909 let cases = [
916 ("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
917 ("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
918 ("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
919 ("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
920 ("- p\n - a\n\n - b\n - c\n", ListItemSpacingStyle::Consistent),
921 ];
922 for (content, style) in cases {
923 let warnings = check(content, style.clone());
924 assert!(!warnings.is_empty(), "{content:?}");
925 assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
926 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
927 let fixed = fix(content, style);
928 assert_eq!(applied, fixed, "{content:?}");
929 assert_ne!(applied, content, "{content:?}");
930 }
931
932 let content = "- a\r\n\r\n- b\r\n- c\r\n";
938 let warnings = check(content, ListItemSpacingStyle::Consistent);
939 assert_eq!(
940 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
941 "- a\r\n- b\r\n- c\r\n"
942 );
943 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")] {
944 let warnings = check(content, ListItemSpacingStyle::Loose);
945 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
946 let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
947 assert_eq!(fix.replacement, replacement, "{content:?}");
948 assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
949 }
950 let content = "- a\r\n- b\r\n\r\n- c\r\n";
951 let warnings = check(content, ListItemSpacingStyle::Loose);
952 assert_eq!(
953 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
954 "- a\r\n\r\n- b\r\n\r\n- c\r\n"
955 );
956 }
957
958 #[test]
959 fn nested_lists_separated_by_parent_content_are_separate_lists() {
960 for content in [
969 "- p\n - a\n - b\n\n With:\n\n - c\n\n - d\n",
970 "- p\n - a\n - b\n <!-- parent comment -->\n - c\n\n - d\n",
971 "- p\n - a\n - b\n >\n - c\n\n - d\n",
972 "> - p\n> - a\n> - b\n> >\n> - c\n>\n> - d\n",
973 "- p\n - a\n >\n parent\n - c\n\n - d\n",
974 "- p\n - a\n - ```\n more\n - c\n\n - d\n",
975 "- p\n - a\n - | h |\n | --- |\n more\n - c\n\n - d\n",
976 ] {
977 let warnings = check(content, ListItemSpacingStyle::Consistent);
978 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
979 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
980 }
981
982 for (content, line, message) in [
987 (
988 "- p\n - a\n - b\n\n - c\n\n - d\n",
989 3,
990 "Missing blank line between list items",
991 ),
992 (
993 "- p\n - a\n - ```lang`bad\n more\n - c\n\n - d\n",
994 6,
995 "Unexpected blank line between list items",
996 ),
997 ] {
998 let warnings = check(content, ListItemSpacingStyle::Consistent);
999 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1000 assert_eq!(warnings[0].line, line, "{content:?}");
1001 assert_eq!(warnings[0].message, message, "{content:?}");
1002 }
1003 }
1004
1005 #[test]
1006 fn lists_of_different_marker_types_are_separate_lists() {
1007 for content in [
1012 "- parent\n - bullet a\n - bullet b\n 1. ordered a\n\n 2. ordered b\n- next\n",
1013 "- parent\n - dash a\n - dash b\n * star a\n\n * star b\n- next\n",
1014 "- parent\n 1. dot a\n 2. dot b\n 1) paren a\n\n 2) paren b\n- next\n",
1015 "- dash a\n- dash b\n* star a\n\n* star b\n",
1016 ] {
1017 let warnings = check(content, ListItemSpacingStyle::Consistent);
1018 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1019 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1020 }
1021
1022 for (content, line) in [
1025 ("- parent\n - a\n - b\n - c\n\n - d\n- next\n", 5),
1026 ("- parent\n 1. a\n 2. b\n 3. c\n\n 4. d\n- next\n", 5),
1027 ("- a\n- b\n- c\n\n- d\n", 4),
1028 ] {
1029 let warnings = check(content, ListItemSpacingStyle::Consistent);
1030 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1031 assert_eq!(warnings[0].line, line, "{content:?}");
1032 assert_eq!(
1033 warnings[0].message, "Unexpected blank line between list items",
1034 "{content:?}"
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn siblings_at_a_different_indent_are_not_the_nested_list() {
1041 let content = " - parent\n - child a\n - child b\n\n - sibling a\n\n - sibling b\n";
1046 let warnings = check(content, ListItemSpacingStyle::Consistent);
1047 assert!(warnings.is_empty(), "{warnings:?}");
1048 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1049
1050 let content = " > - parent\n> - child a\n>\n> - child b\n";
1054 let warnings = check(content, ListItemSpacingStyle::Consistent);
1055 assert!(warnings.is_empty(), "{warnings:?}");
1056 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1057
1058 for (content, line, message) in [
1061 (
1062 " - parent\n - child a\n - child b\n\n - sibling a\n - sibling b\n",
1063 4,
1064 "Unexpected blank line between list items",
1065 ),
1066 (
1067 " - parent\n - child a\n\n - child b\n - child c\n - sibling\n",
1068 3,
1069 "Unexpected blank line between list items",
1070 ),
1071 (
1072 " > - parent\n> - child a\n>\n> - child b\n> - child c\n",
1073 3,
1074 "Unexpected blank line between list items",
1075 ),
1076 ] {
1077 let warnings = check(content, ListItemSpacingStyle::Consistent);
1078 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1079 assert_eq!(warnings[0].line, line, "{content:?}");
1080 assert_eq!(warnings[0].message, message, "{content:?}");
1081 }
1082 }
1083
1084 #[test]
1085 fn lists_in_different_blockquotes_are_different_lists() {
1086 for content in [
1092 "- p\n >- b1\n >\n >- b2\n>- c\n>- d\n",
1093 "- p\n > - b1\n > - b2\n\n > - b3\n",
1094 "> - a\n> - b\n> - c\n\n> - d\n",
1095 ] {
1096 let warnings = check(content, ListItemSpacingStyle::Consistent);
1097 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1098 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1099 }
1100
1101 for (content, line, message) in [
1104 (
1105 "- p\n > - b1\n > - b2\n >\n > - b3\n",
1106 4,
1107 "Unexpected blank line between list items",
1108 ),
1109 (
1110 "- p\n >- b1\n >- b2\n >\n >- b3\n>- c\n>- d\n",
1111 4,
1112 "Unexpected blank line between list items",
1113 ),
1114 ] {
1115 let warnings = check(content, ListItemSpacingStyle::Consistent);
1116 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1117 assert_eq!(warnings[0].line, line, "{content:?}");
1118 assert_eq!(warnings[0].message, message, "{content:?}");
1119 }
1120 }
1121
1122 #[test]
1123 fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
1124 for (content, style) in [
1130 ("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
1131 ("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
1132 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
1133 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
1134 ] {
1135 let warnings = check(content, style.clone());
1136 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1137 assert_eq!(fix(content, style), content);
1138 }
1139
1140 let content = "- p\n```\n```\n > - b\n lazy\n> - c\n";
1143 let warnings = check(content, ListItemSpacingStyle::Loose);
1144 assert_eq!(warnings.len(), 1, "{warnings:?}");
1145 assert_eq!(warnings[0].line, 6);
1146 assert_eq!(warnings[0].message, "Missing blank line between list items");
1147 assert_eq!(
1148 fix(content, ListItemSpacingStyle::Loose),
1149 "- p\n```\n```\n > - b\n lazy\n>\n> - c\n"
1150 );
1151
1152 let content = "> - a\n>\n> - b\n> - c\n";
1154 let warnings = check(content, ListItemSpacingStyle::Consistent);
1155 assert_eq!(warnings.len(), 1, "{warnings:?}");
1156 assert_eq!(warnings[0].line, 2);
1157 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
1158 }
1159
1160 #[test]
1161 fn explicit_style_applies_to_nested_lists() {
1162 let content = "- a\n\n- b\n - b1\n - b2\n";
1165 let warnings = check(content, ListItemSpacingStyle::Loose);
1166 assert_eq!(warnings.len(), 1, "{warnings:?}");
1167 assert_eq!(warnings[0].line, 5);
1168 assert_eq!(warnings[0].message, "Missing blank line between list items");
1169 assert_eq!(
1170 fix(content, ListItemSpacingStyle::Loose),
1171 "- a\n\n- b\n - b1\n\n - b2\n"
1172 );
1173
1174 let content = "- a\n- b\n - b1\n\n - b2\n";
1177 let warnings = check(content, ListItemSpacingStyle::Tight);
1178 assert_eq!(warnings.len(), 1, "{warnings:?}");
1179 assert_eq!(warnings[0].line, 4);
1180 assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n - b1\n - b2\n");
1181 }
1182
1183 #[test]
1186 fn code_block_in_tight_list_no_false_positive() {
1187 let content = "\
1189- Item 1 with code:
1190
1191 ```python
1192 print('hello')
1193 ```
1194
1195- Item 2 simple.
1196- Item 3 simple.
1197";
1198 assert!(
1199 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1200 "Structural blank after code block should not make item 1 appear loose"
1201 );
1202 }
1203
1204 #[test]
1205 fn table_in_tight_list_no_false_positive() {
1206 let content = "\
1208- Item 1 with table:
1209
1210 | Col 1 | Col 2 |
1211 |-------|-------|
1212 | A | B |
1213
1214- Item 2 simple.
1215- Item 3 simple.
1216";
1217 assert!(
1218 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1219 "Structural blank after table should not make item 1 appear loose"
1220 );
1221 }
1222
1223 #[test]
1224 fn html_block_in_tight_list_no_false_positive() {
1225 let content = "\
1226- Item 1 with HTML:
1227
1228 <details>
1229 <summary>Click</summary>
1230 Content
1231 </details>
1232
1233- Item 2 simple.
1234- Item 3 simple.
1235";
1236 assert!(
1237 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1238 "Structural blank after HTML block should not make item 1 appear loose"
1239 );
1240 }
1241
1242 #[test]
1243 fn blockquote_in_tight_list_no_false_positive() {
1244 let content = "\
1246- Item 1 with quote:
1247
1248 > This is a blockquote
1249 > with multiple lines.
1250
1251- Item 2 simple.
1252- Item 3 simple.
1253";
1254 assert!(
1255 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1256 "Structural blank around blockquote should not make item 1 appear loose"
1257 );
1258 assert!(
1259 check(content, ListItemSpacingStyle::Tight).is_empty(),
1260 "Blockquote in tight list should not trigger a violation"
1261 );
1262 }
1263
1264 #[test]
1265 fn blockquote_multiple_items_with_quotes_tight() {
1266 let content = "\
1268- Item 1:
1269
1270 > Quote A
1271
1272- Item 2:
1273
1274 > Quote B
1275
1276- Item 3 plain.
1277";
1278 assert!(
1279 check(content, ListItemSpacingStyle::Tight).is_empty(),
1280 "Multiple items with blockquotes should remain tight"
1281 );
1282 }
1283
1284 #[test]
1285 fn blockquote_mixed_with_genuine_loose_gap() {
1286 let content = "\
1288- Item 1:
1289
1290 > Quote
1291
1292- Item 2 plain.
1293
1294- Item 3 plain.
1295";
1296 let warnings = check(content, ListItemSpacingStyle::Tight);
1297 assert!(
1298 !warnings.is_empty(),
1299 "Genuine loose gap between Item 2 and Item 3 should be flagged"
1300 );
1301 }
1302
1303 #[test]
1304 fn blockquote_single_line_in_tight_list() {
1305 let content = "\
1306- Item 1:
1307
1308 > Single line quote.
1309
1310- Item 2.
1311- Item 3.
1312";
1313 assert!(
1314 check(content, ListItemSpacingStyle::Tight).is_empty(),
1315 "Single-line blockquote should be structural"
1316 );
1317 }
1318
1319 #[test]
1320 fn blockquote_in_ordered_list_tight() {
1321 let content = "\
13221. Item 1:
1323
1324 > Quoted text in ordered list.
1325
13261. Item 2.
13271. Item 3.
1328";
1329 assert!(
1330 check(content, ListItemSpacingStyle::Tight).is_empty(),
1331 "Blockquote in ordered list should be structural"
1332 );
1333 }
1334
1335 #[test]
1336 fn nested_blockquote_in_tight_list() {
1337 let content = "\
1338- Item 1:
1339
1340 > Outer quote
1341 > > Nested quote
1342
1343- Item 2.
1344- Item 3.
1345";
1346 assert!(
1347 check(content, ListItemSpacingStyle::Tight).is_empty(),
1348 "Nested blockquote in tight list should be structural"
1349 );
1350 }
1351
1352 #[test]
1353 fn blockquote_as_entire_item_is_loose() {
1354 let content = "\
1357- > Quote is the entire item content.
1358
1359- Item 2.
1360- Item 3.
1361";
1362 let warnings = check(content, ListItemSpacingStyle::Tight);
1363 assert!(
1364 !warnings.is_empty(),
1365 "Blank after blockquote-only item is a genuine loose gap"
1366 );
1367 }
1368
1369 #[test]
1370 fn mixed_code_and_table_in_tight_list() {
1371 let content = "\
13721. Item with code:
1373
1374 ```markdown
1375 This is some Markdown
1376 ```
1377
13781. Simple item.
13791. Item with table:
1380
1381 | Col 1 | Col 2 |
1382 |:------|:------|
1383 | Row 1 | Row 1 |
1384 | Row 2 | Row 2 |
1385";
1386 assert!(
1387 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1388 "Mix of code blocks and tables should not cause false positives"
1389 );
1390 }
1391
1392 #[test]
1393 fn code_block_with_genuinely_loose_gaps_still_warns() {
1394 let content = "\
1397- Item 1:
1398
1399 ```bash
1400 echo hi
1401 ```
1402
1403- Item 2
1404
1405- Item 3
1406- Item 4
1407";
1408 let warnings = check(content, ListItemSpacingStyle::Consistent);
1409 assert!(
1410 !warnings.is_empty(),
1411 "Genuine inconsistency with code blocks should still be flagged"
1412 );
1413 }
1414
1415 #[test]
1416 fn all_items_have_code_blocks_no_warnings() {
1417 let content = "\
1418- Item 1:
1419
1420 ```python
1421 print(1)
1422 ```
1423
1424- Item 2:
1425
1426 ```python
1427 print(2)
1428 ```
1429
1430- Item 3:
1431
1432 ```python
1433 print(3)
1434 ```
1435";
1436 assert!(
1437 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1438 "All items with code blocks should be consistently tight"
1439 );
1440 }
1441
1442 #[test]
1443 fn tilde_fence_code_block_in_list() {
1444 let content = "\
1445- Item 1:
1446
1447 ~~~
1448 code here
1449 ~~~
1450
1451- Item 2 simple.
1452- Item 3 simple.
1453";
1454 assert!(
1455 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1456 "Tilde fences should be recognized as structural content"
1457 );
1458 }
1459
1460 #[test]
1461 fn nested_list_with_code_block() {
1462 let content = "\
1463- Item 1
1464 - Nested with code:
1465
1466 ```
1467 nested code
1468 ```
1469
1470 - Nested simple.
1471- Item 2
1472";
1473 assert!(
1474 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1475 "Nested list with code block should not cause false positives"
1476 );
1477 }
1478
1479 #[test]
1480 fn tight_style_with_code_block_no_warnings() {
1481 let content = "\
1482- Item 1:
1483
1484 ```
1485 code
1486 ```
1487
1488- Item 2.
1489- Item 3.
1490";
1491 assert!(
1492 check(content, ListItemSpacingStyle::Tight).is_empty(),
1493 "Tight style should not warn about structural blanks around code blocks"
1494 );
1495 }
1496
1497 #[test]
1498 fn loose_style_with_code_block_missing_separator() {
1499 let content = "\
1502- Item 1:
1503
1504 ```
1505 code
1506 ```
1507
1508- Item 2.
1509- Item 3.
1510";
1511 let warnings = check(content, ListItemSpacingStyle::Loose);
1512 assert_eq!(
1513 warnings.len(),
1514 1,
1515 "Loose style should still require blank between simple items"
1516 );
1517 assert!(warnings[0].message.contains("Missing"));
1518 }
1519
1520 #[test]
1521 fn blockquote_list_with_code_block() {
1522 let content = "\
1523> - Item 1:
1524>
1525> ```
1526> code
1527> ```
1528>
1529> - Item 2.
1530> - Item 3.
1531";
1532 assert!(
1533 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1534 "Blockquote-prefixed list with code block should not cause false positives"
1535 );
1536 }
1537
1538 #[test]
1541 fn indented_code_block_in_list_no_false_positive() {
1542 let content = "\
15451. Item with indented code:
1546
1547 some code here
1548 more code
1549
15501. Simple item
15511. Another item
1552";
1553 assert!(
1554 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1555 "Structural blank after indented code block should not make item 1 appear loose"
1556 );
1557 }
1558
1559 #[test]
1562 fn fence_on_marker_line_keeps_its_structural_blank() {
1563 for spaces in 1..=4 {
1568 let pad = " ".repeat(spaces);
1569 let indent = " ".repeat(spaces + 1);
1570 let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
1571 assert!(
1572 check(&content, ListItemSpacingStyle::Tight).is_empty(),
1573 "a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
1574 );
1575 assert_eq!(
1576 fix(&content, ListItemSpacingStyle::Tight),
1577 content,
1578 "tight fix must keep the blank MD031 requires ({spaces} space(s))"
1579 );
1580 }
1581 }
1582
1583 #[test]
1584 fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
1585 for fence in ["```", "~~~"] {
1590 let content = format!("- a\n\n- {fence}\n code\n {fence}\n- c\n");
1591 let warnings = check(&content, ListItemSpacingStyle::Tight);
1592 assert_eq!(
1593 warnings.len(),
1594 1,
1595 "no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
1596 );
1597 assert_eq!(
1598 fix(&content, ListItemSpacingStyle::Tight),
1599 format!("- a\n- {fence}\n code\n {fence}\n- c\n"),
1600 "tight fix must remove a blank that MD031 does not require ({fence})"
1601 );
1602 }
1603 }
1604
1605 #[test]
1608 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1609 let content = "\
16141. Item with code in middle:
1615
1616 ```
1617 code
1618 ```
1619
1620 Some text after the code block.
1621
16221. Simple item
16231. Another item
1624";
1625 let warnings = check(content, ListItemSpacingStyle::Consistent);
1626 assert!(
1627 !warnings.is_empty(),
1628 "Blank line after regular text (not structural content) is a genuine loose gap"
1629 );
1630 }
1631
1632 #[test]
1635 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1636 let content = "\
1639- Item 1:
1640
1641 ```
1642 code
1643 ```
1644
1645- Item 2.
1646- Item 3.
1647";
1648 let fixed = fix(content, ListItemSpacingStyle::Tight);
1649 assert_eq!(
1650 fixed, content,
1651 "Tight fix should not remove structural blanks around code blocks"
1652 );
1653 }
1654
1655 #[test]
1658 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1659 let content = "\
16641. First item
1665
16661. Second item with code block:
1667
1668 ```json
1669 {\"key\": \"value\"}
1670 ```
1671
16721. Third item
1673";
1674 assert!(
1675 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1676 "Structural blank after 4-space indented code block should not cause false positive"
1677 );
1678 }
1679
1680 #[test]
1681 fn four_space_indented_fence_tight_style_no_warnings() {
1682 let content = "\
16831. First item
16841. Second item with code block:
1685
1686 ```json
1687 {\"key\": \"value\"}
1688 ```
1689
16901. Third item
1691";
1692 assert!(
1693 check(content, ListItemSpacingStyle::Tight).is_empty(),
1694 "Tight style should not warn about structural blanks with 4-space fences"
1695 );
1696 }
1697
1698 #[test]
1699 fn four_space_indented_fence_loose_style_no_warnings() {
1700 let content = "\
17021. First item
1703
17041. Second item with code block:
1705
1706 ```json
1707 {\"key\": \"value\"}
1708 ```
1709
17101. Third item
1711";
1712 assert!(
1713 check(content, ListItemSpacingStyle::Loose).is_empty(),
1714 "Loose style should not warn when structural gaps are the only non-loose gaps"
1715 );
1716 }
1717
1718 #[test]
1719 fn structural_gap_with_genuine_inconsistency_still_warns() {
1720 let content = "\
17231. First item with code:
1724
1725 ```json
1726 {\"key\": \"value\"}
1727 ```
1728
17291. Second item
1730
17311. Third item
17321. Fourth item
1733";
1734 let warnings = check(content, ListItemSpacingStyle::Consistent);
1735 assert!(
1736 !warnings.is_empty(),
1737 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1738 );
1739 }
1740
1741 #[test]
1742 fn four_space_fence_fix_is_idempotent() {
1743 let content = "\
17461. First item
1747
17481. Second item with code block:
1749
1750 ```json
1751 {\"key\": \"value\"}
1752 ```
1753
17541. Third item
1755";
1756 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1757 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1758 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1759 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1760 }
1761
1762 #[test]
1763 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1764 let content = "\
17671. First item
17681. Second item with code block:
1769
1770 ```json
1771 {\"key\": \"value\"}
1772 ```
1773
17741. Third item
1775";
1776 let fixed = fix(content, ListItemSpacingStyle::Tight);
1777 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1778 }
1779
1780 #[test]
1781 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1782 let content = "\
17851. First item
1786
17871. Second item with code block:
1788
1789 ```json
1790 {\"key\": \"value\"}
1791 ```
1792
17931. Third item
1794";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1796 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1797 let warnings = rule.check(&ctx).unwrap();
1798 assert!(
1799 warnings.is_empty(),
1800 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1801 );
1802 }
1803
1804 #[test]
1807 fn code_block_in_second_item_detects_inconsistency() {
1808 let content = "\
1811# Test
1812
1813- Lorem ipsum dolor sit amet.
1814- Lorem ipsum dolor sit amet.
1815
1816 ```yaml
1817 hello: world
1818 ```
1819
1820- Lorem ipsum dolor sit amet.
1821
1822- Lorem ipsum dolor sit amet.
1823";
1824 let warnings = check(content, ListItemSpacingStyle::Consistent);
1825 assert!(
1826 !warnings.is_empty(),
1827 "Should detect inconsistent spacing when code block is inside a list item"
1828 );
1829 }
1830
1831 #[test]
1832 fn code_block_in_item_all_tight_no_warnings() {
1833 let content = "\
1835- Item 1
1836- Item 2
1837
1838 ```yaml
1839 hello: world
1840 ```
1841
1842- Item 3
1843- Item 4
1844";
1845 assert!(
1846 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1847 "All tight gaps with structural code block should not warn"
1848 );
1849 }
1850
1851 #[test]
1852 fn code_block_in_item_all_loose_no_warnings() {
1853 let content = "\
1855- Item 1
1856
1857- Item 2
1858
1859 ```yaml
1860 hello: world
1861 ```
1862
1863- Item 3
1864
1865- Item 4
1866";
1867 assert!(
1868 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1869 "All loose gaps with structural code block should not warn"
1870 );
1871 }
1872
1873 #[test]
1874 fn code_block_in_ordered_list_detects_inconsistency() {
1875 let content = "\
18761. First item
18771. Second item
1878
1879 ```json
1880 {\"key\": \"value\"}
1881 ```
1882
18831. Third item
1884
18851. Fourth item
1886";
1887 let warnings = check(content, ListItemSpacingStyle::Consistent);
1888 assert!(
1889 !warnings.is_empty(),
1890 "Ordered list with code block should still detect inconsistency"
1891 );
1892 }
1893
1894 #[test]
1895 fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1896 let content = "\
1902- Item 1
1903- Item 2
1904
1905 ```yaml
1906 code: here
1907 ```
1908
1909- Item 3
1910
1911- Item 4
1912";
1913 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1914 assert!(
1915 fixed.contains("- Item 3\n- Item 4"),
1916 "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1917 );
1918 assert!(
1919 !fixed.contains("- Item 1\n\n- Item 2"),
1920 "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1921 );
1922 }
1923
1924 #[test]
1925 fn tilde_code_block_in_item_detects_inconsistency() {
1926 let content = "\
1927- Item 1
1928- Item 2
1929
1930 ~~~
1931 code
1932 ~~~
1933
1934- Item 3
1935
1936- Item 4
1937";
1938 let warnings = check(content, ListItemSpacingStyle::Consistent);
1939 assert!(
1940 !warnings.is_empty(),
1941 "Tilde code block inside item should not prevent inconsistency detection"
1942 );
1943 }
1944
1945 #[test]
1946 fn multiple_code_blocks_all_tight_no_warnings() {
1947 let content = "\
1949- Item 1
1950
1951 ```
1952 code1
1953 ```
1954
1955- Item 2
1956
1957 ```
1958 code2
1959 ```
1960
1961- Item 3
1962- Item 4
1963";
1964 assert!(
1965 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1966 "All non-structural gaps are tight, so list is consistent"
1967 );
1968 }
1969
1970 #[test]
1971 fn code_block_with_mixed_genuine_gaps_warns() {
1972 let content = "\
1974- Item 1
1975
1976 ```
1977 code1
1978 ```
1979
1980- Item 2
1981
1982- Item 3
1983- Item 4
1984";
1985 let warnings = check(content, ListItemSpacingStyle::Consistent);
1986 assert!(
1987 !warnings.is_empty(),
1988 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1989 );
1990 }
1991
1992 #[test]
1995 fn continuation_loose_tight_style_default_warns() {
1996 let content = "\
1999- Item 1.
2000
2001 Continuation paragraph.
2002
2003- Item 2.
2004
2005 Continuation paragraph.
2006
2007- Item 3.
2008";
2009 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2010 assert!(
2011 !warnings.is_empty(),
2012 "Should warn about loose gaps when allow_loose_continuation is false"
2013 );
2014 }
2015
2016 #[test]
2017 fn continuation_loose_tight_style_allowed_no_warnings() {
2018 let content = "\
2021- Item 1.
2022
2023 Continuation paragraph.
2024
2025- Item 2.
2026
2027 Continuation paragraph.
2028
2029- Item 3.
2030";
2031 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2032 assert!(
2033 warnings.is_empty(),
2034 "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
2035 );
2036 }
2037
2038 #[test]
2039 fn continuation_loose_mixed_items_warns() {
2040 let content = "\
2043- Item 1.
2044
2045- Item 2.
2046- Item 3.
2047";
2048 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2049 assert!(
2050 !warnings.is_empty(),
2051 "Genuine loose gaps should still warn even with allow_loose_continuation"
2052 );
2053 }
2054
2055 #[test]
2056 fn continuation_loose_consistent_mode() {
2057 let content = "\
2060- Item 1.
2061
2062 Continuation paragraph.
2063
2064- Item 2.
2065- Item 3.
2066";
2067 let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
2068 assert!(
2069 warnings.is_empty(),
2070 "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
2071 );
2072 }
2073
2074 #[test]
2075 fn continuation_loose_fix_preserves_continuation_blanks() {
2076 let content = "\
2077- Item 1.
2078
2079 Continuation paragraph.
2080
2081- Item 2.
2082
2083 Continuation paragraph.
2084
2085- Item 3.
2086";
2087 let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
2088 assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
2089 }
2090
2091 #[test]
2092 fn continuation_loose_fix_removes_genuine_loose_gaps() {
2093 let input = "\
2094- Item 1.
2095
2096- Item 2.
2097
2098- Item 3.
2099";
2100 let expected = "\
2101- Item 1.
2102- Item 2.
2103- Item 3.
2104";
2105 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2106 assert_eq!(fixed, expected);
2107 }
2108
2109 #[test]
2110 fn continuation_loose_ordered_list() {
2111 let content = "\
21121. Item 1.
2113
2114 Continuation paragraph.
2115
21162. Item 2.
2117
2118 Continuation paragraph.
2119
21203. Item 3.
2121";
2122 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2123 assert!(
2124 warnings.is_empty(),
2125 "Ordered list continuation should work too, got: {warnings:?}"
2126 );
2127 }
2128
2129 #[test]
2130 fn continuation_loose_disabled_by_default() {
2131 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
2133 assert!(!rule.config.allow_loose_continuation);
2134 }
2135
2136 #[test]
2137 fn continuation_loose_ordered_under_indented_ends_the_list() {
2138 let content = "\
21461. Item 1.
2147
2148 Under-indented text.
2149
21501. Item 2.
21511. Item 3.
2152";
2153 for (style, allow) in [
2154 (ListItemSpacingStyle::Tight, true),
2155 (ListItemSpacingStyle::Tight, false),
2156 (ListItemSpacingStyle::Consistent, false),
2157 ] {
2158 let warnings = check_with_continuation(content, style, allow);
2159 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2160 }
2161 let content = "\
21621. Item 1.
2163
2164 Continuation text.
2165
21661. Item 2.
21671. Item 3.
2168";
2169 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2170 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2171 assert_eq!(warnings[0].line, 4);
2172 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
2173 }
2174
2175 #[test]
2176 fn continuation_loose_mix_continuation_and_genuine_gaps() {
2177 let content = "\
2179- Item 1.
2180
2181 Continuation paragraph.
2182
2183- Item 2.
2184
2185- Item 3.
2186";
2187 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2188 assert!(
2189 !warnings.is_empty(),
2190 "Genuine loose gap between items 2-3 should warn even with continuation allowed"
2191 );
2192 assert_eq!(
2194 warnings.len(),
2195 1,
2196 "Expected exactly one warning for the genuine loose gap"
2197 );
2198 }
2199
2200 #[test]
2201 fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
2202 let input = "\
2204- Item 1.
2205
2206 Continuation paragraph.
2207
2208- Item 2.
2209
2210- Item 3.
2211";
2212 let expected = "\
2213- Item 1.
2214
2215 Continuation paragraph.
2216
2217- Item 2.
2218- Item 3.
2219";
2220 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2221 assert_eq!(fixed, expected);
2222 }
2223
2224 #[test]
2225 fn continuation_loose_after_code_block() {
2226 let content = "\
2228- Item 1.
2229
2230 ```python
2231 code
2232 ```
2233
2234 Continuation after code.
2235
2236- Item 2.
2237- Item 3.
2238";
2239 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2240 assert!(
2241 warnings.is_empty(),
2242 "Code block + continuation should both be exempt, got: {warnings:?}"
2243 );
2244 }
2245
2246 #[test]
2247 fn continuation_loose_style_does_not_interfere() {
2248 let content = "\
2251- Item 1.
2252
2253 Continuation paragraph.
2254
2255- Item 2.
2256
2257 Continuation paragraph.
2258
2259- Item 3.
2260";
2261 let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
2262 assert!(
2263 warnings.is_empty(),
2264 "Loose style with continuation should not warn, got: {warnings:?}"
2265 );
2266 }
2267
2268 #[test]
2269 fn continuation_loose_tight_no_continuation_content() {
2270 let content = "\
2272- Item 1.
2273- Item 2.
2274- Item 3.
2275";
2276 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2277 assert!(
2278 warnings.is_empty(),
2279 "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
2280 );
2281 }
2282
2283 #[test]
2286 fn default_config_section_provides_style_key() {
2287 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2288 let section = rule.default_config_section();
2289 assert!(section.is_some());
2290 let (name, value) = section.unwrap();
2291 assert_eq!(name, "MD076");
2292 if let toml::Value::Table(map) = value {
2293 assert!(map.contains_key("style"));
2294 assert!(map.contains_key("allow-loose-continuation"));
2295 } else {
2296 panic!("Expected Table value from default_config_section");
2297 }
2298 }
2299
2300 #[test]
2301 fn unicode_space_line_is_text_not_a_blank_line() {
2302 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2305 for source in [
2306 "- a\n\u{a0}\n- b\n- c\n",
2307 "- a\n\u{a0}\ntext\n\u{a0}\n- b\n c\n- d\n\n- e\n",
2308 "- a\n\n- b\n\u{a0}\n- c\n- d\n",
2309 ] {
2310 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2311 let fixed = rule.fix(&ctx).unwrap();
2312 assert_eq!(
2313 fixed.matches('\u{a0}').count(),
2314 source.matches('\u{a0}').count(),
2315 "{source:?} -> {fixed:?}"
2316 );
2317 }
2318 }
2319}