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)]
24pub enum ListItemSpacingStyle {
25 #[default]
26 Consistent,
27 Loose,
28 Tight,
29}
30
31#[derive(Debug, Clone, Default)]
32pub(super) struct MD076Config {
33 pub style: ListItemSpacingStyle,
34 pub allow_loose_continuation: bool,
38}
39
40#[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 {
93 if let Some(info) = ctx.line_info(line_num) {
94 let content = info.content(ctx.content);
95 if content.trim().is_empty() {
96 return true;
97 }
98 if let Some(ref bq) = info.blockquote {
100 return bq.content.trim().is_empty();
101 }
102 false
103 } else {
104 false
105 }
106 }
107
108 fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
111 if let Some(info) = ctx.line_info(line_num) {
112 if info.in_code_block {
114 return true;
115 }
116 if info.in_html_block {
118 return true;
119 }
120 if info.blockquote.is_some() {
122 return true;
123 }
124 let content = info.content(ctx.content);
126 let effective = if let Some(ref bq) = info.blockquote {
128 bq.content.as_str()
129 } else {
130 content
131 };
132 if is_table_line(effective.trim_start()) {
133 return true;
134 }
135 }
136 false
137 }
138
139 fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize) -> bool {
150 let Some(info) = ctx.line_info(line_num) else {
151 return false;
152 };
153 if info.list_item.is_none() {
154 return false;
155 }
156
157 let line_range = info.byte_offset..info.byte_offset + info.byte_len;
158 ctx.code_block_details
159 .iter()
160 .any(|detail| detail.is_fenced && line_range.contains(&detail.start))
161 }
162
163 fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
170 let Some(info) = ctx.line_info(line_num) else {
171 return false;
172 };
173 if info.list_item.is_some() {
175 return false;
176 }
177 if info.in_code_block
179 || info.in_html_block
180 || info.in_html_comment
181 || info.in_mdx_comment
182 || info.in_front_matter
183 || info.in_math_block
184 || info.blockquote.is_some()
185 {
186 return false;
187 }
188 let content = info.content(ctx.content);
189 if content.trim().is_empty() {
190 return false;
191 }
192 let indent = content.len() - content.trim_start().len();
194 indent >= parent_content_col
195 }
196
197 fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
205 if next <= first + 1 {
206 return GapKind::Tight;
207 }
208 if !Self::is_effectively_blank(ctx, next - 1) {
210 return GapKind::Tight;
211 }
212 if Self::is_fenced_code_block_list_item(ctx, next) {
216 return GapKind::Structural;
217 }
218 let mut scan = next - 1;
221 while scan > first && Self::is_effectively_blank(ctx, scan) {
222 scan -= 1;
223 }
224 if scan > first && Self::is_structural_content(ctx, scan) {
226 return GapKind::Structural;
227 }
228 let parent_content_col = ctx
231 .line_info(first)
232 .and_then(|li| li.list_item.as_ref())
233 .map_or(2, |item| item.content_column);
234 if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
235 return GapKind::ContinuationLoose;
236 }
237 GapKind::Loose
238 }
239
240 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
247 let mut blanks = Vec::new();
248 let mut line_num = next - 1;
249 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
250 blanks.push(line_num);
251 line_num -= 1;
252 }
253 if line_num > first && Self::is_structural_content(ctx, line_num) {
255 return Vec::new();
256 }
257 blanks.reverse();
258 blanks
259 }
260
261 fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
265 ctx.list_blocks
266 .iter()
267 .flat_map(|block| ctx.list_block_item_groups(block))
268 .filter_map(|items| {
269 Self::analyze_list(ctx, items, &self.config.style, self.config.allow_loose_continuation)
270 })
271 .collect()
272 }
273
274 fn analyze_list(
280 ctx: &LintContext,
281 items: Vec<usize>,
282 style: &ListItemSpacingStyle,
283 allow_loose_continuation: bool,
284 ) -> Option<ListAnalysis> {
285 if items.len() < 2 {
286 return None;
287 }
288
289 let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
291
292 let loose_count = gaps
296 .iter()
297 .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
298 .count();
299 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
300
301 let (warn_loose_gaps, warn_tight_gaps) = match style {
302 ListItemSpacingStyle::Loose => (false, true),
303 ListItemSpacingStyle::Tight => (true, false),
304 ListItemSpacingStyle::Consistent => {
305 if loose_count == 0 || tight_count == 0 {
306 return None; }
308 if tight_count >= loose_count {
316 (true, false)
317 } else {
318 (false, true)
319 }
320 }
321 };
322
323 Some(ListAnalysis {
324 items,
325 gaps,
326 warn_loose_gaps,
327 warn_tight_gaps,
328 })
329 }
330}
331
332impl Rule for MD076ListItemSpacing {
333 fn name(&self) -> &'static str {
334 "MD076"
335 }
336
337 fn description(&self) -> &'static str {
338 "List item spacing should be consistent"
339 }
340
341 fn category(&self) -> RuleCategory {
342 RuleCategory::List
343 }
344
345 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
346 ctx.content.is_empty() || ctx.list_blocks.is_empty()
347 }
348
349 fn check(&self, ctx: &LintContext) -> LintResult {
350 if ctx.content.is_empty() {
351 return Ok(Vec::new());
352 }
353
354 let mut warnings = Vec::new();
355
356 let allow_cont = self.config.allow_loose_continuation;
357 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
361
362 for analysis in self.analyze(ctx) {
363 for (i, &gap) in analysis.gaps.iter().enumerate() {
364 let is_loose_violation = match gap {
365 GapKind::Loose => analysis.warn_loose_gaps,
366 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
367 _ => false,
368 };
369
370 if is_loose_violation {
371 let next_item = analysis.items[i + 1];
372 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
373 if let Some(&blank_line) = blanks.first() {
374 let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
375 let fix = ctx
378 .line_start_byte(blank_line)
379 .zip(ctx.line_start_byte(next_item))
380 .map(|(start, end)| Fix::new(start..end, String::new()));
381 warnings.push(LintWarning {
382 rule_name: Some(self.name().to_string()),
383 line: blank_line,
384 column: 1,
385 end_line: blank_line,
386 end_column: line_content.chars().count() + 1,
387 message: "Unexpected blank line between list items".to_string(),
388 severity: Severity::Warning,
389 fix,
390 });
391 }
392 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
393 let next_item = analysis.items[i + 1];
394 let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
395 let fix = ctx.line_start_byte(next_item).map(|start| {
398 let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
399 Fix::new(start..start, format!("{prefix}{line_ending}"))
400 });
401 warnings.push(LintWarning {
402 rule_name: Some(self.name().to_string()),
403 line: next_item,
404 column: 1,
405 end_line: next_item,
406 end_column: line_content.chars().count() + 1,
407 message: "Missing blank line between list items".to_string(),
408 severity: Severity::Warning,
409 fix,
410 });
411 }
412 }
413 }
414
415 Ok(warnings)
416 }
417
418 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
419 if ctx.content.is_empty() {
420 return Ok(ctx.content.to_string());
421 }
422
423 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
425 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
426
427 let allow_cont = self.config.allow_loose_continuation;
428
429 for analysis in self.analyze(ctx) {
430 for (i, &gap) in analysis.gaps.iter().enumerate() {
431 let is_loose_violation = match gap {
432 GapKind::Loose => analysis.warn_loose_gaps,
433 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
434 _ => false,
435 };
436
437 if is_loose_violation {
438 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
439 remove_lines.insert(blank_line);
440 }
441 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
442 insert_before.insert(analysis.items[i + 1]);
443 }
444 }
445 }
446
447 if insert_before.is_empty() && remove_lines.is_empty() {
448 return Ok(ctx.content.to_string());
449 }
450
451 let lines = ctx.raw_lines();
452 let mut result: Vec<String> = Vec::with_capacity(lines.len());
453
454 for (i, line) in lines.iter().enumerate() {
455 let line_num = i + 1;
456
457 if ctx.is_rule_disabled(self.name(), line_num) {
459 result.push((*line).to_string());
460 continue;
461 }
462
463 if remove_lines.contains(&line_num) {
464 continue;
465 }
466
467 if insert_before.contains(&line_num) {
468 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
469 result.push(bq_prefix);
470 }
471
472 result.push((*line).to_string());
473 }
474
475 let mut output = result.join("\n");
476 if ctx.content.ends_with('\n') {
477 output.push('\n');
478 }
479 Ok(output)
480 }
481
482 fn as_any(&self) -> &dyn std::any::Any {
483 self
484 }
485
486 fn default_config_section(&self) -> Option<(String, toml::Value)> {
487 let mut map = toml::map::Map::new();
488 let style_str = match self.config.style {
489 ListItemSpacingStyle::Consistent => "consistent",
490 ListItemSpacingStyle::Loose => "loose",
491 ListItemSpacingStyle::Tight => "tight",
492 };
493 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
494 map.insert(
495 "allow-loose-continuation".to_string(),
496 toml::Value::Boolean(self.config.allow_loose_continuation),
497 );
498 Some((self.name().to_string(), toml::Value::Table(map)))
499 }
500
501 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
502 where
503 Self: Sized,
504 {
505 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
506 .unwrap_or_else(|| "consistent".to_string());
507 let style = match style.as_str() {
508 "loose" => ListItemSpacingStyle::Loose,
509 "tight" => ListItemSpacingStyle::Tight,
510 _ => ListItemSpacingStyle::Consistent,
511 };
512 let allow_loose_continuation =
513 crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
514 .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
515 .unwrap_or(false);
516 Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let rule = MD076ListItemSpacing::new(style);
527 rule.check(&ctx).unwrap()
528 }
529
530 fn check_with_continuation(
531 content: &str,
532 style: ListItemSpacingStyle,
533 allow_loose_continuation: bool,
534 ) -> Vec<LintWarning> {
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
537 rule.check(&ctx).unwrap()
538 }
539
540 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let rule = MD076ListItemSpacing::new(style);
543 rule.fix(&ctx).unwrap()
544 }
545
546 fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
549 rule.fix(&ctx).unwrap()
550 }
551
552 #[test]
555 fn tight_list_tight_style_no_warnings() {
556 let content = "- Item 1\n- Item 2\n- Item 3\n";
557 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
558 }
559
560 #[test]
561 fn loose_list_loose_style_no_warnings() {
562 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
563 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
564 }
565
566 #[test]
567 fn tight_list_loose_style_warns() {
568 let content = "- Item 1\n- Item 2\n- Item 3\n";
569 let warnings = check(content, ListItemSpacingStyle::Loose);
570 assert_eq!(warnings.len(), 2);
571 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
572 }
573
574 #[test]
575 fn loose_list_tight_style_warns() {
576 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
577 let warnings = check(content, ListItemSpacingStyle::Tight);
578 assert_eq!(warnings.len(), 2);
579 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
580 }
581
582 #[test]
585 fn consistent_all_tight_no_warnings() {
586 let content = "- Item 1\n- Item 2\n- Item 3\n";
587 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
588 }
589
590 #[test]
591 fn consistent_all_loose_no_warnings() {
592 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
593 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
594 }
595
596 #[test]
597 fn consistent_mixed_majority_loose_warns_tight() {
598 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
600 let warnings = check(content, ListItemSpacingStyle::Consistent);
601 assert_eq!(warnings.len(), 1);
602 assert!(warnings[0].message.contains("Missing"));
603 }
604
605 #[test]
606 fn consistent_mixed_majority_tight_warns_loose() {
607 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
609 let warnings = check(content, ListItemSpacingStyle::Consistent);
610 assert_eq!(warnings.len(), 1);
611 assert!(warnings[0].message.contains("Unexpected"));
612 }
613
614 #[test]
615 fn consistent_tie_prefers_tight() {
616 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
620 let warnings = check(content, ListItemSpacingStyle::Consistent);
621 assert_eq!(warnings.len(), 1);
622 assert!(warnings[0].message.contains("Unexpected"));
623 }
624
625 #[test]
628 fn single_item_list_no_warnings() {
629 let content = "- Only item\n";
630 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
631 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
632 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
633 }
634
635 #[test]
636 fn empty_content_no_warnings() {
637 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
638 }
639
640 #[test]
641 fn ordered_list_tight_gaps_loose_style_warns() {
642 let content = "1. First\n2. Second\n3. Third\n";
643 let warnings = check(content, ListItemSpacingStyle::Loose);
644 assert_eq!(warnings.len(), 2);
645 }
646
647 #[test]
648 fn task_list_works() {
649 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
650 let warnings = check(content, ListItemSpacingStyle::Loose);
651 assert_eq!(warnings.len(), 2);
652 let fixed = fix(content, ListItemSpacingStyle::Loose);
653 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
654 }
655
656 #[test]
657 fn no_trailing_newline() {
658 let content = "- Item 1\n- Item 2";
659 let warnings = check(content, ListItemSpacingStyle::Loose);
660 assert_eq!(warnings.len(), 1);
661 let fixed = fix(content, ListItemSpacingStyle::Loose);
662 assert_eq!(fixed, "- Item 1\n\n- Item 2");
663 }
664
665 #[test]
666 fn two_separate_lists() {
667 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
668 let warnings = check(content, ListItemSpacingStyle::Loose);
669 assert_eq!(warnings.len(), 2);
670 let fixed = fix(content, ListItemSpacingStyle::Loose);
671 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
672 }
673
674 #[test]
675 fn no_list_content() {
676 let content = "Just a paragraph.\n\nAnother paragraph.\n";
677 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
678 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
679 }
680
681 #[test]
684 fn continuation_lines_tight_detected() {
685 let content = "- Item 1\n continuation\n- Item 2\n";
686 let warnings = check(content, ListItemSpacingStyle::Loose);
687 assert_eq!(warnings.len(), 1);
688 assert!(warnings[0].message.contains("Missing"));
689 }
690
691 #[test]
692 fn continuation_lines_loose_detected() {
693 let content = "- Item 1\n continuation\n\n- Item 2\n";
694 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
695 let warnings = check(content, ListItemSpacingStyle::Tight);
696 assert_eq!(warnings.len(), 1);
697 assert!(warnings[0].message.contains("Unexpected"));
698 }
699
700 #[test]
701 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
702 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
705 let warnings = check(content, ListItemSpacingStyle::Tight);
707 assert_eq!(
708 warnings.len(),
709 1,
710 "Should warn only on the inter-item blank, not the intra-item blank"
711 );
712 let fixed = fix(content, ListItemSpacingStyle::Tight);
715 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
716 }
717
718 #[test]
719 fn multi_paragraph_item_loose_style_no_warnings() {
720 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
722 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
723 }
724
725 #[test]
728 fn blockquote_tight_list_loose_style_warns() {
729 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
730 let warnings = check(content, ListItemSpacingStyle::Loose);
731 assert_eq!(warnings.len(), 2);
732 }
733
734 #[test]
735 fn blockquote_loose_list_detected() {
736 let content = "> - Item 1\n>\n> - Item 2\n";
738 let warnings = check(content, ListItemSpacingStyle::Tight);
739 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
740 assert!(warnings[0].message.contains("Unexpected"));
741 }
742
743 #[test]
744 fn blockquote_loose_list_no_warnings_when_loose() {
745 let content = "> - Item 1\n>\n> - Item 2\n";
746 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
747 }
748
749 #[test]
752 fn multiple_blanks_all_removed() {
753 let content = "- Item 1\n\n\n- Item 2\n";
754 let fixed = fix(content, ListItemSpacingStyle::Tight);
755 assert_eq!(fixed, "- Item 1\n- Item 2\n");
756 }
757
758 #[test]
759 fn multiple_blanks_fix_is_idempotent() {
760 let content = "- Item 1\n\n\n\n- Item 2\n";
761 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
762 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
763 assert_eq!(fixed_once, fixed_twice);
764 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
765 }
766
767 #[test]
770 fn fix_adds_blank_lines() {
771 let content = "- Item 1\n- Item 2\n- Item 3\n";
772 let fixed = fix(content, ListItemSpacingStyle::Loose);
773 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
774 }
775
776 #[test]
777 fn fix_removes_blank_lines() {
778 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
779 let fixed = fix(content, ListItemSpacingStyle::Tight);
780 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
781 }
782
783 #[test]
784 fn fix_consistent_adds_blank() {
785 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
787 let fixed = fix(content, ListItemSpacingStyle::Consistent);
788 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
789 }
790
791 #[test]
792 fn fix_idempotent_loose() {
793 let content = "- Item 1\n- Item 2\n";
794 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
795 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
796 assert_eq!(fixed_once, fixed_twice);
797 }
798
799 #[test]
800 fn fix_idempotent_tight() {
801 let content = "- Item 1\n\n- Item 2\n";
802 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
803 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
804 assert_eq!(fixed_once, fixed_twice);
805 }
806
807 #[test]
810 fn nested_list_does_not_affect_parent() {
811 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
813 let warnings = check(content, ListItemSpacingStyle::Tight);
814 assert!(
815 warnings.is_empty(),
816 "Nested items should not cause parent-level warnings"
817 );
818 }
819
820 #[test]
821 fn tab_nested_child_is_not_a_sibling() {
822 let content = "* parent\n\n\t1. child\n* next\n";
828 let warnings = check(content, ListItemSpacingStyle::Consistent);
829 assert!(
830 warnings.is_empty(),
831 "a tab-nested child is not a sibling of the parent items: {warnings:?}"
832 );
833 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
834
835 let sibling = "* parent\n\n* child\n* next\n";
838 let warnings = check(sibling, ListItemSpacingStyle::Consistent);
839 assert_eq!(warnings.len(), 1, "{warnings:?}");
840 assert_eq!(warnings[0].line, 2);
841 }
842
843 #[test]
844 fn nested_list_is_analysed_at_its_own_level() {
845 for (label, content, fixed) in [
851 (
852 "spaces",
853 "- parent\n - a\n\n - b\n - c\n- next\n",
854 "- parent\n - a\n - b\n - c\n- next\n",
855 ),
856 (
857 "tab",
858 "* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
859 "* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
860 ),
861 ] {
862 let warnings = check(content, ListItemSpacingStyle::Consistent);
863 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
864 assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
865 assert_eq!(
866 warnings[0].message, "Unexpected blank line between list items",
867 "{label}"
868 );
869 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
870 }
871
872 let content = "- parent\n - a\n\n - b\n- next\n";
875 let warnings = check(content, ListItemSpacingStyle::Consistent);
876 assert!(warnings.is_empty(), "{warnings:?}");
877 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
878 }
879
880 #[test]
881 fn nested_lists_under_different_parents_are_separate_lists() {
882 let content = "- a\n - a1\n - a2\n- b\n - b1\n\n - b2\n";
888 let warnings = check(content, ListItemSpacingStyle::Consistent);
889 assert!(warnings.is_empty(), "{warnings:?}");
890 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
891
892 let content = "- a\n - a1\n - a2\n - b1\n\n - b2\n";
895 let warnings = check(content, ListItemSpacingStyle::Consistent);
896 assert_eq!(warnings.len(), 1, "{warnings:?}");
897 assert_eq!(warnings[0].line, 5);
898 }
899
900 #[test]
901 fn every_warning_carries_the_edit_the_fix_applies() {
902 let cases = [
909 ("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
910 ("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
911 ("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
912 ("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
913 ("- p\n - a\n\n - b\n - c\n", ListItemSpacingStyle::Consistent),
914 ];
915 for (content, style) in cases {
916 let warnings = check(content, style.clone());
917 assert!(!warnings.is_empty(), "{content:?}");
918 assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
919 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
920 let fixed = fix(content, style);
921 assert_eq!(applied, fixed, "{content:?}");
922 assert_ne!(applied, content, "{content:?}");
923 }
924
925 let content = "- a\r\n\r\n- b\r\n- c\r\n";
931 let warnings = check(content, ListItemSpacingStyle::Consistent);
932 assert_eq!(
933 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
934 "- a\r\n- b\r\n- c\r\n"
935 );
936 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")] {
937 let warnings = check(content, ListItemSpacingStyle::Loose);
938 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
939 let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
940 assert_eq!(fix.replacement, replacement, "{content:?}");
941 assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
942 }
943 let content = "- a\r\n- b\r\n\r\n- c\r\n";
944 let warnings = check(content, ListItemSpacingStyle::Loose);
945 assert_eq!(
946 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
947 "- a\r\n\r\n- b\r\n\r\n- c\r\n"
948 );
949 }
950
951 #[test]
952 fn nested_lists_separated_by_parent_content_are_separate_lists() {
953 for content in [
962 "- p\n - a\n - b\n\n With:\n\n - c\n\n - d\n",
963 "- p\n - a\n - b\n <!-- parent comment -->\n - c\n\n - d\n",
964 "- p\n - a\n - b\n >\n - c\n\n - d\n",
965 "> - p\n> - a\n> - b\n> >\n> - c\n>\n> - d\n",
966 "- p\n - a\n >\n parent\n - c\n\n - d\n",
967 "- p\n - a\n - ```\n more\n - c\n\n - d\n",
968 "- p\n - a\n - | h |\n | --- |\n more\n - c\n\n - d\n",
969 ] {
970 let warnings = check(content, ListItemSpacingStyle::Consistent);
971 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
972 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
973 }
974
975 for (content, line, message) in [
980 (
981 "- p\n - a\n - b\n\n - c\n\n - d\n",
982 3,
983 "Missing blank line between list items",
984 ),
985 (
986 "- p\n - a\n - ```lang`bad\n more\n - c\n\n - d\n",
987 6,
988 "Unexpected blank line between list items",
989 ),
990 ] {
991 let warnings = check(content, ListItemSpacingStyle::Consistent);
992 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
993 assert_eq!(warnings[0].line, line, "{content:?}");
994 assert_eq!(warnings[0].message, message, "{content:?}");
995 }
996 }
997
998 #[test]
999 fn lists_of_different_marker_types_are_separate_lists() {
1000 for content in [
1005 "- parent\n - bullet a\n - bullet b\n 1. ordered a\n\n 2. ordered b\n- next\n",
1006 "- parent\n - dash a\n - dash b\n * star a\n\n * star b\n- next\n",
1007 "- parent\n 1. dot a\n 2. dot b\n 1) paren a\n\n 2) paren b\n- next\n",
1008 "- dash a\n- dash b\n* star a\n\n* star b\n",
1009 ] {
1010 let warnings = check(content, ListItemSpacingStyle::Consistent);
1011 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1012 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1013 }
1014
1015 for (content, line) in [
1018 ("- parent\n - a\n - b\n - c\n\n - d\n- next\n", 5),
1019 ("- parent\n 1. a\n 2. b\n 3. c\n\n 4. d\n- next\n", 5),
1020 ("- a\n- b\n- c\n\n- d\n", 4),
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!(
1026 warnings[0].message, "Unexpected blank line between list items",
1027 "{content:?}"
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn siblings_at_a_different_indent_are_not_the_nested_list() {
1034 let content = " - parent\n - child a\n - child b\n\n - sibling a\n\n - sibling b\n";
1039 let warnings = check(content, ListItemSpacingStyle::Consistent);
1040 assert!(warnings.is_empty(), "{warnings:?}");
1041 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1042
1043 let content = " > - parent\n> - child a\n>\n> - child b\n";
1047 let warnings = check(content, ListItemSpacingStyle::Consistent);
1048 assert!(warnings.is_empty(), "{warnings:?}");
1049 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1050
1051 for (content, line, message) in [
1054 (
1055 " - parent\n - child a\n - child b\n\n - sibling a\n - sibling b\n",
1056 4,
1057 "Unexpected blank line between list items",
1058 ),
1059 (
1060 " - parent\n - child a\n\n - child b\n - child c\n - sibling\n",
1061 3,
1062 "Unexpected blank line between list items",
1063 ),
1064 (
1065 " > - parent\n> - child a\n>\n> - child b\n> - child c\n",
1066 3,
1067 "Unexpected blank line between list items",
1068 ),
1069 ] {
1070 let warnings = check(content, ListItemSpacingStyle::Consistent);
1071 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1072 assert_eq!(warnings[0].line, line, "{content:?}");
1073 assert_eq!(warnings[0].message, message, "{content:?}");
1074 }
1075 }
1076
1077 #[test]
1078 fn lists_in_different_blockquotes_are_different_lists() {
1079 for content in [
1085 "- p\n >- b1\n >\n >- b2\n>- c\n>- d\n",
1086 "- p\n > - b1\n > - b2\n\n > - b3\n",
1087 "> - a\n> - b\n> - c\n\n> - d\n",
1088 ] {
1089 let warnings = check(content, ListItemSpacingStyle::Consistent);
1090 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1091 assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1092 }
1093
1094 for (content, line, message) in [
1097 (
1098 "- p\n > - b1\n > - b2\n >\n > - b3\n",
1099 4,
1100 "Unexpected blank line between list items",
1101 ),
1102 (
1103 "- p\n >- b1\n >- b2\n >\n >- b3\n>- c\n>- d\n",
1104 4,
1105 "Unexpected blank line between list items",
1106 ),
1107 ] {
1108 let warnings = check(content, ListItemSpacingStyle::Consistent);
1109 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1110 assert_eq!(warnings[0].line, line, "{content:?}");
1111 assert_eq!(warnings[0].message, message, "{content:?}");
1112 }
1113 }
1114
1115 #[test]
1116 fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
1117 for (content, style) in [
1123 ("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
1124 ("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
1125 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
1126 ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
1127 ] {
1128 let warnings = check(content, style.clone());
1129 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1130 assert_eq!(fix(content, style), content);
1131 }
1132
1133 let content = "- p\n```\n```\n > - b\n lazy\n> - c\n";
1136 let warnings = check(content, ListItemSpacingStyle::Loose);
1137 assert_eq!(warnings.len(), 1, "{warnings:?}");
1138 assert_eq!(warnings[0].line, 6);
1139 assert_eq!(warnings[0].message, "Missing blank line between list items");
1140 assert_eq!(
1141 fix(content, ListItemSpacingStyle::Loose),
1142 "- p\n```\n```\n > - b\n lazy\n>\n> - c\n"
1143 );
1144
1145 let content = "> - a\n>\n> - b\n> - c\n";
1147 let warnings = check(content, ListItemSpacingStyle::Consistent);
1148 assert_eq!(warnings.len(), 1, "{warnings:?}");
1149 assert_eq!(warnings[0].line, 2);
1150 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
1151 }
1152
1153 #[test]
1154 fn explicit_style_applies_to_nested_lists() {
1155 let content = "- a\n\n- b\n - b1\n - b2\n";
1158 let warnings = check(content, ListItemSpacingStyle::Loose);
1159 assert_eq!(warnings.len(), 1, "{warnings:?}");
1160 assert_eq!(warnings[0].line, 5);
1161 assert_eq!(warnings[0].message, "Missing blank line between list items");
1162 assert_eq!(
1163 fix(content, ListItemSpacingStyle::Loose),
1164 "- a\n\n- b\n - b1\n\n - b2\n"
1165 );
1166
1167 let content = "- a\n- b\n - b1\n\n - b2\n";
1170 let warnings = check(content, ListItemSpacingStyle::Tight);
1171 assert_eq!(warnings.len(), 1, "{warnings:?}");
1172 assert_eq!(warnings[0].line, 4);
1173 assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n - b1\n - b2\n");
1174 }
1175
1176 #[test]
1179 fn code_block_in_tight_list_no_false_positive() {
1180 let content = "\
1182- Item 1 with code:
1183
1184 ```python
1185 print('hello')
1186 ```
1187
1188- Item 2 simple.
1189- Item 3 simple.
1190";
1191 assert!(
1192 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1193 "Structural blank after code block should not make item 1 appear loose"
1194 );
1195 }
1196
1197 #[test]
1198 fn table_in_tight_list_no_false_positive() {
1199 let content = "\
1201- Item 1 with table:
1202
1203 | Col 1 | Col 2 |
1204 |-------|-------|
1205 | A | B |
1206
1207- Item 2 simple.
1208- Item 3 simple.
1209";
1210 assert!(
1211 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1212 "Structural blank after table should not make item 1 appear loose"
1213 );
1214 }
1215
1216 #[test]
1217 fn html_block_in_tight_list_no_false_positive() {
1218 let content = "\
1219- Item 1 with HTML:
1220
1221 <details>
1222 <summary>Click</summary>
1223 Content
1224 </details>
1225
1226- Item 2 simple.
1227- Item 3 simple.
1228";
1229 assert!(
1230 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1231 "Structural blank after HTML block should not make item 1 appear loose"
1232 );
1233 }
1234
1235 #[test]
1236 fn blockquote_in_tight_list_no_false_positive() {
1237 let content = "\
1239- Item 1 with quote:
1240
1241 > This is a blockquote
1242 > with multiple lines.
1243
1244- Item 2 simple.
1245- Item 3 simple.
1246";
1247 assert!(
1248 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1249 "Structural blank around blockquote should not make item 1 appear loose"
1250 );
1251 assert!(
1252 check(content, ListItemSpacingStyle::Tight).is_empty(),
1253 "Blockquote in tight list should not trigger a violation"
1254 );
1255 }
1256
1257 #[test]
1258 fn blockquote_multiple_items_with_quotes_tight() {
1259 let content = "\
1261- Item 1:
1262
1263 > Quote A
1264
1265- Item 2:
1266
1267 > Quote B
1268
1269- Item 3 plain.
1270";
1271 assert!(
1272 check(content, ListItemSpacingStyle::Tight).is_empty(),
1273 "Multiple items with blockquotes should remain tight"
1274 );
1275 }
1276
1277 #[test]
1278 fn blockquote_mixed_with_genuine_loose_gap() {
1279 let content = "\
1281- Item 1:
1282
1283 > Quote
1284
1285- Item 2 plain.
1286
1287- Item 3 plain.
1288";
1289 let warnings = check(content, ListItemSpacingStyle::Tight);
1290 assert!(
1291 !warnings.is_empty(),
1292 "Genuine loose gap between Item 2 and Item 3 should be flagged"
1293 );
1294 }
1295
1296 #[test]
1297 fn blockquote_single_line_in_tight_list() {
1298 let content = "\
1299- Item 1:
1300
1301 > Single line quote.
1302
1303- Item 2.
1304- Item 3.
1305";
1306 assert!(
1307 check(content, ListItemSpacingStyle::Tight).is_empty(),
1308 "Single-line blockquote should be structural"
1309 );
1310 }
1311
1312 #[test]
1313 fn blockquote_in_ordered_list_tight() {
1314 let content = "\
13151. Item 1:
1316
1317 > Quoted text in ordered list.
1318
13191. Item 2.
13201. Item 3.
1321";
1322 assert!(
1323 check(content, ListItemSpacingStyle::Tight).is_empty(),
1324 "Blockquote in ordered list should be structural"
1325 );
1326 }
1327
1328 #[test]
1329 fn nested_blockquote_in_tight_list() {
1330 let content = "\
1331- Item 1:
1332
1333 > Outer quote
1334 > > Nested quote
1335
1336- Item 2.
1337- Item 3.
1338";
1339 assert!(
1340 check(content, ListItemSpacingStyle::Tight).is_empty(),
1341 "Nested blockquote in tight list should be structural"
1342 );
1343 }
1344
1345 #[test]
1346 fn blockquote_as_entire_item_is_loose() {
1347 let content = "\
1350- > Quote is the entire item content.
1351
1352- Item 2.
1353- Item 3.
1354";
1355 let warnings = check(content, ListItemSpacingStyle::Tight);
1356 assert!(
1357 !warnings.is_empty(),
1358 "Blank after blockquote-only item is a genuine loose gap"
1359 );
1360 }
1361
1362 #[test]
1363 fn mixed_code_and_table_in_tight_list() {
1364 let content = "\
13651. Item with code:
1366
1367 ```markdown
1368 This is some Markdown
1369 ```
1370
13711. Simple item.
13721. Item with table:
1373
1374 | Col 1 | Col 2 |
1375 |:------|:------|
1376 | Row 1 | Row 1 |
1377 | Row 2 | Row 2 |
1378";
1379 assert!(
1380 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1381 "Mix of code blocks and tables should not cause false positives"
1382 );
1383 }
1384
1385 #[test]
1386 fn code_block_with_genuinely_loose_gaps_still_warns() {
1387 let content = "\
1390- Item 1:
1391
1392 ```bash
1393 echo hi
1394 ```
1395
1396- Item 2
1397
1398- Item 3
1399- Item 4
1400";
1401 let warnings = check(content, ListItemSpacingStyle::Consistent);
1402 assert!(
1403 !warnings.is_empty(),
1404 "Genuine inconsistency with code blocks should still be flagged"
1405 );
1406 }
1407
1408 #[test]
1409 fn all_items_have_code_blocks_no_warnings() {
1410 let content = "\
1411- Item 1:
1412
1413 ```python
1414 print(1)
1415 ```
1416
1417- Item 2:
1418
1419 ```python
1420 print(2)
1421 ```
1422
1423- Item 3:
1424
1425 ```python
1426 print(3)
1427 ```
1428";
1429 assert!(
1430 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1431 "All items with code blocks should be consistently tight"
1432 );
1433 }
1434
1435 #[test]
1436 fn tilde_fence_code_block_in_list() {
1437 let content = "\
1438- Item 1:
1439
1440 ~~~
1441 code here
1442 ~~~
1443
1444- Item 2 simple.
1445- Item 3 simple.
1446";
1447 assert!(
1448 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1449 "Tilde fences should be recognized as structural content"
1450 );
1451 }
1452
1453 #[test]
1454 fn nested_list_with_code_block() {
1455 let content = "\
1456- Item 1
1457 - Nested with code:
1458
1459 ```
1460 nested code
1461 ```
1462
1463 - Nested simple.
1464- Item 2
1465";
1466 assert!(
1467 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1468 "Nested list with code block should not cause false positives"
1469 );
1470 }
1471
1472 #[test]
1473 fn tight_style_with_code_block_no_warnings() {
1474 let content = "\
1475- Item 1:
1476
1477 ```
1478 code
1479 ```
1480
1481- Item 2.
1482- Item 3.
1483";
1484 assert!(
1485 check(content, ListItemSpacingStyle::Tight).is_empty(),
1486 "Tight style should not warn about structural blanks around code blocks"
1487 );
1488 }
1489
1490 #[test]
1491 fn loose_style_with_code_block_missing_separator() {
1492 let content = "\
1495- Item 1:
1496
1497 ```
1498 code
1499 ```
1500
1501- Item 2.
1502- Item 3.
1503";
1504 let warnings = check(content, ListItemSpacingStyle::Loose);
1505 assert_eq!(
1506 warnings.len(),
1507 1,
1508 "Loose style should still require blank between simple items"
1509 );
1510 assert!(warnings[0].message.contains("Missing"));
1511 }
1512
1513 #[test]
1514 fn blockquote_list_with_code_block() {
1515 let content = "\
1516> - Item 1:
1517>
1518> ```
1519> code
1520> ```
1521>
1522> - Item 2.
1523> - Item 3.
1524";
1525 assert!(
1526 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1527 "Blockquote-prefixed list with code block should not cause false positives"
1528 );
1529 }
1530
1531 #[test]
1534 fn indented_code_block_in_list_no_false_positive() {
1535 let content = "\
15381. Item with indented code:
1539
1540 some code here
1541 more code
1542
15431. Simple item
15441. Another item
1545";
1546 assert!(
1547 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1548 "Structural blank after indented code block should not make item 1 appear loose"
1549 );
1550 }
1551
1552 #[test]
1555 fn fence_on_marker_line_keeps_its_structural_blank() {
1556 for spaces in 1..=4 {
1561 let pad = " ".repeat(spaces);
1562 let indent = " ".repeat(spaces + 1);
1563 let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
1564 assert!(
1565 check(&content, ListItemSpacingStyle::Tight).is_empty(),
1566 "a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
1567 );
1568 assert_eq!(
1569 fix(&content, ListItemSpacingStyle::Tight),
1570 content,
1571 "tight fix must keep the blank MD031 requires ({spaces} space(s))"
1572 );
1573 }
1574 }
1575
1576 #[test]
1577 fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
1578 for fence in ["```", "~~~"] {
1583 let content = format!("- a\n\n- {fence}\n code\n {fence}\n- c\n");
1584 let warnings = check(&content, ListItemSpacingStyle::Tight);
1585 assert_eq!(
1586 warnings.len(),
1587 1,
1588 "no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
1589 );
1590 assert_eq!(
1591 fix(&content, ListItemSpacingStyle::Tight),
1592 format!("- a\n- {fence}\n code\n {fence}\n- c\n"),
1593 "tight fix must remove a blank that MD031 does not require ({fence})"
1594 );
1595 }
1596 }
1597
1598 #[test]
1601 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1602 let content = "\
16071. Item with code in middle:
1608
1609 ```
1610 code
1611 ```
1612
1613 Some text after the code block.
1614
16151. Simple item
16161. Another item
1617";
1618 let warnings = check(content, ListItemSpacingStyle::Consistent);
1619 assert!(
1620 !warnings.is_empty(),
1621 "Blank line after regular text (not structural content) is a genuine loose gap"
1622 );
1623 }
1624
1625 #[test]
1628 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1629 let content = "\
1632- Item 1:
1633
1634 ```
1635 code
1636 ```
1637
1638- Item 2.
1639- Item 3.
1640";
1641 let fixed = fix(content, ListItemSpacingStyle::Tight);
1642 assert_eq!(
1643 fixed, content,
1644 "Tight fix should not remove structural blanks around code blocks"
1645 );
1646 }
1647
1648 #[test]
1651 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1652 let content = "\
16571. First item
1658
16591. Second item with code block:
1660
1661 ```json
1662 {\"key\": \"value\"}
1663 ```
1664
16651. Third item
1666";
1667 assert!(
1668 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1669 "Structural blank after 4-space indented code block should not cause false positive"
1670 );
1671 }
1672
1673 #[test]
1674 fn four_space_indented_fence_tight_style_no_warnings() {
1675 let content = "\
16761. First item
16771. Second item with code block:
1678
1679 ```json
1680 {\"key\": \"value\"}
1681 ```
1682
16831. Third item
1684";
1685 assert!(
1686 check(content, ListItemSpacingStyle::Tight).is_empty(),
1687 "Tight style should not warn about structural blanks with 4-space fences"
1688 );
1689 }
1690
1691 #[test]
1692 fn four_space_indented_fence_loose_style_no_warnings() {
1693 let content = "\
16951. First item
1696
16971. Second item with code block:
1698
1699 ```json
1700 {\"key\": \"value\"}
1701 ```
1702
17031. Third item
1704";
1705 assert!(
1706 check(content, ListItemSpacingStyle::Loose).is_empty(),
1707 "Loose style should not warn when structural gaps are the only non-loose gaps"
1708 );
1709 }
1710
1711 #[test]
1712 fn structural_gap_with_genuine_inconsistency_still_warns() {
1713 let content = "\
17161. First item with code:
1717
1718 ```json
1719 {\"key\": \"value\"}
1720 ```
1721
17221. Second item
1723
17241. Third item
17251. Fourth item
1726";
1727 let warnings = check(content, ListItemSpacingStyle::Consistent);
1728 assert!(
1729 !warnings.is_empty(),
1730 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1731 );
1732 }
1733
1734 #[test]
1735 fn four_space_fence_fix_is_idempotent() {
1736 let content = "\
17391. First item
1740
17411. Second item with code block:
1742
1743 ```json
1744 {\"key\": \"value\"}
1745 ```
1746
17471. Third item
1748";
1749 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1750 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1751 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1752 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1753 }
1754
1755 #[test]
1756 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1757 let content = "\
17601. First item
17611. Second item with code block:
1762
1763 ```json
1764 {\"key\": \"value\"}
1765 ```
1766
17671. Third item
1768";
1769 let fixed = fix(content, ListItemSpacingStyle::Tight);
1770 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1771 }
1772
1773 #[test]
1774 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1775 let content = "\
17781. First item
1779
17801. Second item with code block:
1781
1782 ```json
1783 {\"key\": \"value\"}
1784 ```
1785
17861. Third item
1787";
1788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1789 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1790 let warnings = rule.check(&ctx).unwrap();
1791 assert!(
1792 warnings.is_empty(),
1793 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1794 );
1795 }
1796
1797 #[test]
1800 fn code_block_in_second_item_detects_inconsistency() {
1801 let content = "\
1804# Test
1805
1806- Lorem ipsum dolor sit amet.
1807- Lorem ipsum dolor sit amet.
1808
1809 ```yaml
1810 hello: world
1811 ```
1812
1813- Lorem ipsum dolor sit amet.
1814
1815- Lorem ipsum dolor sit amet.
1816";
1817 let warnings = check(content, ListItemSpacingStyle::Consistent);
1818 assert!(
1819 !warnings.is_empty(),
1820 "Should detect inconsistent spacing when code block is inside a list item"
1821 );
1822 }
1823
1824 #[test]
1825 fn code_block_in_item_all_tight_no_warnings() {
1826 let content = "\
1828- Item 1
1829- Item 2
1830
1831 ```yaml
1832 hello: world
1833 ```
1834
1835- Item 3
1836- Item 4
1837";
1838 assert!(
1839 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1840 "All tight gaps with structural code block should not warn"
1841 );
1842 }
1843
1844 #[test]
1845 fn code_block_in_item_all_loose_no_warnings() {
1846 let content = "\
1848- Item 1
1849
1850- Item 2
1851
1852 ```yaml
1853 hello: world
1854 ```
1855
1856- Item 3
1857
1858- Item 4
1859";
1860 assert!(
1861 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1862 "All loose gaps with structural code block should not warn"
1863 );
1864 }
1865
1866 #[test]
1867 fn code_block_in_ordered_list_detects_inconsistency() {
1868 let content = "\
18691. First item
18701. Second item
1871
1872 ```json
1873 {\"key\": \"value\"}
1874 ```
1875
18761. Third item
1877
18781. Fourth item
1879";
1880 let warnings = check(content, ListItemSpacingStyle::Consistent);
1881 assert!(
1882 !warnings.is_empty(),
1883 "Ordered list with code block should still detect inconsistency"
1884 );
1885 }
1886
1887 #[test]
1888 fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1889 let content = "\
1895- Item 1
1896- Item 2
1897
1898 ```yaml
1899 code: here
1900 ```
1901
1902- Item 3
1903
1904- Item 4
1905";
1906 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1907 assert!(
1908 fixed.contains("- Item 3\n- Item 4"),
1909 "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1910 );
1911 assert!(
1912 !fixed.contains("- Item 1\n\n- Item 2"),
1913 "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1914 );
1915 }
1916
1917 #[test]
1918 fn tilde_code_block_in_item_detects_inconsistency() {
1919 let content = "\
1920- Item 1
1921- Item 2
1922
1923 ~~~
1924 code
1925 ~~~
1926
1927- Item 3
1928
1929- Item 4
1930";
1931 let warnings = check(content, ListItemSpacingStyle::Consistent);
1932 assert!(
1933 !warnings.is_empty(),
1934 "Tilde code block inside item should not prevent inconsistency detection"
1935 );
1936 }
1937
1938 #[test]
1939 fn multiple_code_blocks_all_tight_no_warnings() {
1940 let content = "\
1942- Item 1
1943
1944 ```
1945 code1
1946 ```
1947
1948- Item 2
1949
1950 ```
1951 code2
1952 ```
1953
1954- Item 3
1955- Item 4
1956";
1957 assert!(
1958 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1959 "All non-structural gaps are tight, so list is consistent"
1960 );
1961 }
1962
1963 #[test]
1964 fn code_block_with_mixed_genuine_gaps_warns() {
1965 let content = "\
1967- Item 1
1968
1969 ```
1970 code1
1971 ```
1972
1973- Item 2
1974
1975- Item 3
1976- Item 4
1977";
1978 let warnings = check(content, ListItemSpacingStyle::Consistent);
1979 assert!(
1980 !warnings.is_empty(),
1981 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1982 );
1983 }
1984
1985 #[test]
1988 fn continuation_loose_tight_style_default_warns() {
1989 let content = "\
1992- Item 1.
1993
1994 Continuation paragraph.
1995
1996- Item 2.
1997
1998 Continuation paragraph.
1999
2000- Item 3.
2001";
2002 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2003 assert!(
2004 !warnings.is_empty(),
2005 "Should warn about loose gaps when allow_loose_continuation is false"
2006 );
2007 }
2008
2009 #[test]
2010 fn continuation_loose_tight_style_allowed_no_warnings() {
2011 let content = "\
2014- Item 1.
2015
2016 Continuation paragraph.
2017
2018- Item 2.
2019
2020 Continuation paragraph.
2021
2022- Item 3.
2023";
2024 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2025 assert!(
2026 warnings.is_empty(),
2027 "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
2028 );
2029 }
2030
2031 #[test]
2032 fn continuation_loose_mixed_items_warns() {
2033 let content = "\
2036- Item 1.
2037
2038- Item 2.
2039- Item 3.
2040";
2041 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2042 assert!(
2043 !warnings.is_empty(),
2044 "Genuine loose gaps should still warn even with allow_loose_continuation"
2045 );
2046 }
2047
2048 #[test]
2049 fn continuation_loose_consistent_mode() {
2050 let content = "\
2053- Item 1.
2054
2055 Continuation paragraph.
2056
2057- Item 2.
2058- Item 3.
2059";
2060 let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
2061 assert!(
2062 warnings.is_empty(),
2063 "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
2064 );
2065 }
2066
2067 #[test]
2068 fn continuation_loose_fix_preserves_continuation_blanks() {
2069 let content = "\
2070- Item 1.
2071
2072 Continuation paragraph.
2073
2074- Item 2.
2075
2076 Continuation paragraph.
2077
2078- Item 3.
2079";
2080 let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
2081 assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
2082 }
2083
2084 #[test]
2085 fn continuation_loose_fix_removes_genuine_loose_gaps() {
2086 let input = "\
2087- Item 1.
2088
2089- Item 2.
2090
2091- Item 3.
2092";
2093 let expected = "\
2094- Item 1.
2095- Item 2.
2096- Item 3.
2097";
2098 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2099 assert_eq!(fixed, expected);
2100 }
2101
2102 #[test]
2103 fn continuation_loose_ordered_list() {
2104 let content = "\
21051. Item 1.
2106
2107 Continuation paragraph.
2108
21092. Item 2.
2110
2111 Continuation paragraph.
2112
21133. Item 3.
2114";
2115 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2116 assert!(
2117 warnings.is_empty(),
2118 "Ordered list continuation should work too, got: {warnings:?}"
2119 );
2120 }
2121
2122 #[test]
2123 fn continuation_loose_disabled_by_default() {
2124 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
2126 assert!(!rule.config.allow_loose_continuation);
2127 }
2128
2129 #[test]
2130 fn continuation_loose_ordered_under_indented_ends_the_list() {
2131 let content = "\
21391. Item 1.
2140
2141 Under-indented text.
2142
21431. Item 2.
21441. Item 3.
2145";
2146 for (style, allow) in [
2147 (ListItemSpacingStyle::Tight, true),
2148 (ListItemSpacingStyle::Tight, false),
2149 (ListItemSpacingStyle::Consistent, false),
2150 ] {
2151 let warnings = check_with_continuation(content, style, allow);
2152 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2153 }
2154 let content = "\
21551. Item 1.
2156
2157 Continuation text.
2158
21591. Item 2.
21601. Item 3.
2161";
2162 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2163 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2164 assert_eq!(warnings[0].line, 4);
2165 assert_eq!(warnings[0].message, "Unexpected blank line between list items");
2166 }
2167
2168 #[test]
2169 fn continuation_loose_mix_continuation_and_genuine_gaps() {
2170 let content = "\
2172- Item 1.
2173
2174 Continuation paragraph.
2175
2176- Item 2.
2177
2178- Item 3.
2179";
2180 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2181 assert!(
2182 !warnings.is_empty(),
2183 "Genuine loose gap between items 2-3 should warn even with continuation allowed"
2184 );
2185 assert_eq!(
2187 warnings.len(),
2188 1,
2189 "Expected exactly one warning for the genuine loose gap"
2190 );
2191 }
2192
2193 #[test]
2194 fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
2195 let input = "\
2197- Item 1.
2198
2199 Continuation paragraph.
2200
2201- Item 2.
2202
2203- Item 3.
2204";
2205 let expected = "\
2206- Item 1.
2207
2208 Continuation paragraph.
2209
2210- Item 2.
2211- Item 3.
2212";
2213 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2214 assert_eq!(fixed, expected);
2215 }
2216
2217 #[test]
2218 fn continuation_loose_after_code_block() {
2219 let content = "\
2221- Item 1.
2222
2223 ```python
2224 code
2225 ```
2226
2227 Continuation after code.
2228
2229- Item 2.
2230- Item 3.
2231";
2232 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2233 assert!(
2234 warnings.is_empty(),
2235 "Code block + continuation should both be exempt, got: {warnings:?}"
2236 );
2237 }
2238
2239 #[test]
2240 fn continuation_loose_style_does_not_interfere() {
2241 let content = "\
2244- Item 1.
2245
2246 Continuation paragraph.
2247
2248- Item 2.
2249
2250 Continuation paragraph.
2251
2252- Item 3.
2253";
2254 let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
2255 assert!(
2256 warnings.is_empty(),
2257 "Loose style with continuation should not warn, got: {warnings:?}"
2258 );
2259 }
2260
2261 #[test]
2262 fn continuation_loose_tight_no_continuation_content() {
2263 let content = "\
2265- Item 1.
2266- Item 2.
2267- Item 3.
2268";
2269 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2270 assert!(
2271 warnings.is_empty(),
2272 "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
2273 );
2274 }
2275
2276 #[test]
2279 fn default_config_section_provides_style_key() {
2280 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2281 let section = rule.default_config_section();
2282 assert!(section.is_some());
2283 let (name, value) = section.unwrap();
2284 assert_eq!(name, "MD076");
2285 if let toml::Value::Table(map) = value {
2286 assert!(map.contains_key("style"));
2287 assert!(map.contains_key("allow-loose-continuation"));
2288 } else {
2289 panic!("Expected Table value from default_config_section");
2290 }
2291 }
2292}