1use crate::lint_context::LintContext;
2use crate::rule::{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 BlockAnalysis {
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_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
146 let Some(info) = ctx.line_info(line_num) else {
147 return false;
148 };
149 if info.list_item.is_some() {
151 return false;
152 }
153 if info.in_code_block
155 || info.in_html_block
156 || info.in_html_comment
157 || info.in_mdx_comment
158 || info.in_front_matter
159 || info.in_math_block
160 || info.blockquote.is_some()
161 {
162 return false;
163 }
164 let content = info.content(ctx.content);
165 if content.trim().is_empty() {
166 return false;
167 }
168 let indent = content.len() - content.trim_start().len();
170 indent >= parent_content_col
171 }
172
173 fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
181 if next <= first + 1 {
182 return GapKind::Tight;
183 }
184 if !Self::is_effectively_blank(ctx, next - 1) {
186 return GapKind::Tight;
187 }
188 let mut scan = next - 1;
191 while scan > first && Self::is_effectively_blank(ctx, scan) {
192 scan -= 1;
193 }
194 if scan > first && Self::is_structural_content(ctx, scan) {
196 return GapKind::Structural;
197 }
198 let parent_content_col = ctx
201 .line_info(first)
202 .and_then(|li| li.list_item.as_ref())
203 .map_or(2, |item| item.content_column);
204 if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
205 return GapKind::ContinuationLoose;
206 }
207 GapKind::Loose
208 }
209
210 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
217 let mut blanks = Vec::new();
218 let mut line_num = next - 1;
219 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
220 blanks.push(line_num);
221 line_num -= 1;
222 }
223 if line_num > first && Self::is_structural_content(ctx, line_num) {
225 return Vec::new();
226 }
227 blanks.reverse();
228 blanks
229 }
230
231 fn analyze_block(
236 ctx: &LintContext,
237 block: &crate::lint_context::types::ListBlock,
238 style: &ListItemSpacingStyle,
239 allow_loose_continuation: bool,
240 ) -> Option<BlockAnalysis> {
241 let items: Vec<usize> = block
245 .item_lines
246 .iter()
247 .copied()
248 .filter(|&line_num| {
249 ctx.line_info(line_num)
250 .and_then(|li| li.list_item.as_ref())
251 .is_some_and(|item| item.marker_column / 2 == block.nesting_level)
252 })
253 .collect();
254
255 if items.len() < 2 {
256 return None;
257 }
258
259 let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
261
262 let loose_count = gaps
266 .iter()
267 .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
268 .count();
269 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
270
271 let (warn_loose_gaps, warn_tight_gaps) = match style {
272 ListItemSpacingStyle::Loose => (false, true),
273 ListItemSpacingStyle::Tight => (true, false),
274 ListItemSpacingStyle::Consistent => {
275 if loose_count == 0 || tight_count == 0 {
276 return None; }
278 if tight_count >= loose_count {
286 (true, false)
287 } else {
288 (false, true)
289 }
290 }
291 };
292
293 Some(BlockAnalysis {
294 items,
295 gaps,
296 warn_loose_gaps,
297 warn_tight_gaps,
298 })
299 }
300}
301
302impl Rule for MD076ListItemSpacing {
303 fn name(&self) -> &'static str {
304 "MD076"
305 }
306
307 fn description(&self) -> &'static str {
308 "List item spacing should be consistent"
309 }
310
311 fn category(&self) -> RuleCategory {
312 RuleCategory::List
313 }
314
315 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
316 ctx.content.is_empty() || ctx.list_blocks.is_empty()
317 }
318
319 fn check(&self, ctx: &LintContext) -> LintResult {
320 if ctx.content.is_empty() {
321 return Ok(Vec::new());
322 }
323
324 let mut warnings = Vec::new();
325
326 let allow_cont = self.config.allow_loose_continuation;
327
328 for block in &ctx.list_blocks {
329 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
330 continue;
331 };
332
333 for (i, &gap) in analysis.gaps.iter().enumerate() {
334 let is_loose_violation = match gap {
335 GapKind::Loose => analysis.warn_loose_gaps,
336 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
337 _ => false,
338 };
339
340 if is_loose_violation {
341 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
342 if let Some(&blank_line) = blanks.first() {
343 let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
344 warnings.push(LintWarning {
345 rule_name: Some(self.name().to_string()),
346 line: blank_line,
347 column: 1,
348 end_line: blank_line,
349 end_column: line_content.len() + 1,
350 message: "Unexpected blank line between list items".to_string(),
351 severity: Severity::Warning,
352 fix: None,
353 });
354 }
355 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
356 let next_item = analysis.items[i + 1];
357 let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
358 warnings.push(LintWarning {
359 rule_name: Some(self.name().to_string()),
360 line: next_item,
361 column: 1,
362 end_line: next_item,
363 end_column: line_content.len() + 1,
364 message: "Missing blank line between list items".to_string(),
365 severity: Severity::Warning,
366 fix: None,
367 });
368 }
369 }
370 }
371
372 Ok(warnings)
373 }
374
375 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
376 if ctx.content.is_empty() {
377 return Ok(ctx.content.to_string());
378 }
379
380 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
382 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
383
384 let allow_cont = self.config.allow_loose_continuation;
385
386 for block in &ctx.list_blocks {
387 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
388 continue;
389 };
390
391 for (i, &gap) in analysis.gaps.iter().enumerate() {
392 let is_loose_violation = match gap {
393 GapKind::Loose => analysis.warn_loose_gaps,
394 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
395 _ => false,
396 };
397
398 if is_loose_violation {
399 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
400 remove_lines.insert(blank_line);
401 }
402 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
403 insert_before.insert(analysis.items[i + 1]);
404 }
405 }
406 }
407
408 if insert_before.is_empty() && remove_lines.is_empty() {
409 return Ok(ctx.content.to_string());
410 }
411
412 let lines = ctx.raw_lines();
413 let mut result: Vec<String> = Vec::with_capacity(lines.len());
414
415 for (i, line) in lines.iter().enumerate() {
416 let line_num = i + 1;
417
418 if ctx.is_rule_disabled(self.name(), line_num) {
420 result.push((*line).to_string());
421 continue;
422 }
423
424 if remove_lines.contains(&line_num) {
425 continue;
426 }
427
428 if insert_before.contains(&line_num) {
429 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
430 result.push(bq_prefix);
431 }
432
433 result.push((*line).to_string());
434 }
435
436 let mut output = result.join("\n");
437 if ctx.content.ends_with('\n') {
438 output.push('\n');
439 }
440 Ok(output)
441 }
442
443 fn as_any(&self) -> &dyn std::any::Any {
444 self
445 }
446
447 fn default_config_section(&self) -> Option<(String, toml::Value)> {
448 let mut map = toml::map::Map::new();
449 let style_str = match self.config.style {
450 ListItemSpacingStyle::Consistent => "consistent",
451 ListItemSpacingStyle::Loose => "loose",
452 ListItemSpacingStyle::Tight => "tight",
453 };
454 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
455 map.insert(
456 "allow-loose-continuation".to_string(),
457 toml::Value::Boolean(self.config.allow_loose_continuation),
458 );
459 Some((self.name().to_string(), toml::Value::Table(map)))
460 }
461
462 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
463 where
464 Self: Sized,
465 {
466 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
467 .unwrap_or_else(|| "consistent".to_string());
468 let style = match style.as_str() {
469 "loose" => ListItemSpacingStyle::Loose,
470 "tight" => ListItemSpacingStyle::Tight,
471 _ => ListItemSpacingStyle::Consistent,
472 };
473 let allow_loose_continuation =
474 crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
475 .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
476 .unwrap_or(false);
477 Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let rule = MD076ListItemSpacing::new(style);
488 rule.check(&ctx).unwrap()
489 }
490
491 fn check_with_continuation(
492 content: &str,
493 style: ListItemSpacingStyle,
494 allow_loose_continuation: bool,
495 ) -> Vec<LintWarning> {
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
498 rule.check(&ctx).unwrap()
499 }
500
501 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let rule = MD076ListItemSpacing::new(style);
504 rule.fix(&ctx).unwrap()
505 }
506
507 fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
510 rule.fix(&ctx).unwrap()
511 }
512
513 #[test]
516 fn tight_list_tight_style_no_warnings() {
517 let content = "- Item 1\n- Item 2\n- Item 3\n";
518 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
519 }
520
521 #[test]
522 fn loose_list_loose_style_no_warnings() {
523 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
524 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
525 }
526
527 #[test]
528 fn tight_list_loose_style_warns() {
529 let content = "- Item 1\n- Item 2\n- Item 3\n";
530 let warnings = check(content, ListItemSpacingStyle::Loose);
531 assert_eq!(warnings.len(), 2);
532 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
533 }
534
535 #[test]
536 fn loose_list_tight_style_warns() {
537 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
538 let warnings = check(content, ListItemSpacingStyle::Tight);
539 assert_eq!(warnings.len(), 2);
540 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
541 }
542
543 #[test]
546 fn consistent_all_tight_no_warnings() {
547 let content = "- Item 1\n- Item 2\n- Item 3\n";
548 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
549 }
550
551 #[test]
552 fn consistent_all_loose_no_warnings() {
553 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
554 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
555 }
556
557 #[test]
558 fn consistent_mixed_majority_loose_warns_tight() {
559 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
561 let warnings = check(content, ListItemSpacingStyle::Consistent);
562 assert_eq!(warnings.len(), 1);
563 assert!(warnings[0].message.contains("Missing"));
564 }
565
566 #[test]
567 fn consistent_mixed_majority_tight_warns_loose() {
568 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
570 let warnings = check(content, ListItemSpacingStyle::Consistent);
571 assert_eq!(warnings.len(), 1);
572 assert!(warnings[0].message.contains("Unexpected"));
573 }
574
575 #[test]
576 fn consistent_tie_prefers_tight() {
577 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
581 let warnings = check(content, ListItemSpacingStyle::Consistent);
582 assert_eq!(warnings.len(), 1);
583 assert!(warnings[0].message.contains("Unexpected"));
584 }
585
586 #[test]
589 fn single_item_list_no_warnings() {
590 let content = "- Only item\n";
591 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
592 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
593 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
594 }
595
596 #[test]
597 fn empty_content_no_warnings() {
598 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
599 }
600
601 #[test]
602 fn ordered_list_tight_gaps_loose_style_warns() {
603 let content = "1. First\n2. Second\n3. Third\n";
604 let warnings = check(content, ListItemSpacingStyle::Loose);
605 assert_eq!(warnings.len(), 2);
606 }
607
608 #[test]
609 fn task_list_works() {
610 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
611 let warnings = check(content, ListItemSpacingStyle::Loose);
612 assert_eq!(warnings.len(), 2);
613 let fixed = fix(content, ListItemSpacingStyle::Loose);
614 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
615 }
616
617 #[test]
618 fn no_trailing_newline() {
619 let content = "- Item 1\n- Item 2";
620 let warnings = check(content, ListItemSpacingStyle::Loose);
621 assert_eq!(warnings.len(), 1);
622 let fixed = fix(content, ListItemSpacingStyle::Loose);
623 assert_eq!(fixed, "- Item 1\n\n- Item 2");
624 }
625
626 #[test]
627 fn two_separate_lists() {
628 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
629 let warnings = check(content, ListItemSpacingStyle::Loose);
630 assert_eq!(warnings.len(), 2);
631 let fixed = fix(content, ListItemSpacingStyle::Loose);
632 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
633 }
634
635 #[test]
636 fn no_list_content() {
637 let content = "Just a paragraph.\n\nAnother paragraph.\n";
638 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
639 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
640 }
641
642 #[test]
645 fn continuation_lines_tight_detected() {
646 let content = "- Item 1\n continuation\n- Item 2\n";
647 let warnings = check(content, ListItemSpacingStyle::Loose);
648 assert_eq!(warnings.len(), 1);
649 assert!(warnings[0].message.contains("Missing"));
650 }
651
652 #[test]
653 fn continuation_lines_loose_detected() {
654 let content = "- Item 1\n continuation\n\n- Item 2\n";
655 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
656 let warnings = check(content, ListItemSpacingStyle::Tight);
657 assert_eq!(warnings.len(), 1);
658 assert!(warnings[0].message.contains("Unexpected"));
659 }
660
661 #[test]
662 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
663 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
666 let warnings = check(content, ListItemSpacingStyle::Tight);
668 assert_eq!(
669 warnings.len(),
670 1,
671 "Should warn only on the inter-item blank, not the intra-item blank"
672 );
673 let fixed = fix(content, ListItemSpacingStyle::Tight);
676 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
677 }
678
679 #[test]
680 fn multi_paragraph_item_loose_style_no_warnings() {
681 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
683 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
684 }
685
686 #[test]
689 fn blockquote_tight_list_loose_style_warns() {
690 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
691 let warnings = check(content, ListItemSpacingStyle::Loose);
692 assert_eq!(warnings.len(), 2);
693 }
694
695 #[test]
696 fn blockquote_loose_list_detected() {
697 let content = "> - Item 1\n>\n> - Item 2\n";
699 let warnings = check(content, ListItemSpacingStyle::Tight);
700 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
701 assert!(warnings[0].message.contains("Unexpected"));
702 }
703
704 #[test]
705 fn blockquote_loose_list_no_warnings_when_loose() {
706 let content = "> - Item 1\n>\n> - Item 2\n";
707 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
708 }
709
710 #[test]
713 fn multiple_blanks_all_removed() {
714 let content = "- Item 1\n\n\n- Item 2\n";
715 let fixed = fix(content, ListItemSpacingStyle::Tight);
716 assert_eq!(fixed, "- Item 1\n- Item 2\n");
717 }
718
719 #[test]
720 fn multiple_blanks_fix_is_idempotent() {
721 let content = "- Item 1\n\n\n\n- Item 2\n";
722 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
723 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
724 assert_eq!(fixed_once, fixed_twice);
725 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
726 }
727
728 #[test]
731 fn fix_adds_blank_lines() {
732 let content = "- Item 1\n- Item 2\n- Item 3\n";
733 let fixed = fix(content, ListItemSpacingStyle::Loose);
734 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
735 }
736
737 #[test]
738 fn fix_removes_blank_lines() {
739 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
740 let fixed = fix(content, ListItemSpacingStyle::Tight);
741 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
742 }
743
744 #[test]
745 fn fix_consistent_adds_blank() {
746 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
748 let fixed = fix(content, ListItemSpacingStyle::Consistent);
749 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
750 }
751
752 #[test]
753 fn fix_idempotent_loose() {
754 let content = "- Item 1\n- Item 2\n";
755 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
756 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
757 assert_eq!(fixed_once, fixed_twice);
758 }
759
760 #[test]
761 fn fix_idempotent_tight() {
762 let content = "- Item 1\n\n- Item 2\n";
763 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
764 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
765 assert_eq!(fixed_once, fixed_twice);
766 }
767
768 #[test]
771 fn nested_list_does_not_affect_parent() {
772 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
774 let warnings = check(content, ListItemSpacingStyle::Tight);
775 assert!(
776 warnings.is_empty(),
777 "Nested items should not cause parent-level warnings"
778 );
779 }
780
781 #[test]
784 fn code_block_in_tight_list_no_false_positive() {
785 let content = "\
787- Item 1 with code:
788
789 ```python
790 print('hello')
791 ```
792
793- Item 2 simple.
794- Item 3 simple.
795";
796 assert!(
797 check(content, ListItemSpacingStyle::Consistent).is_empty(),
798 "Structural blank after code block should not make item 1 appear loose"
799 );
800 }
801
802 #[test]
803 fn table_in_tight_list_no_false_positive() {
804 let content = "\
806- Item 1 with table:
807
808 | Col 1 | Col 2 |
809 |-------|-------|
810 | A | B |
811
812- Item 2 simple.
813- Item 3 simple.
814";
815 assert!(
816 check(content, ListItemSpacingStyle::Consistent).is_empty(),
817 "Structural blank after table should not make item 1 appear loose"
818 );
819 }
820
821 #[test]
822 fn html_block_in_tight_list_no_false_positive() {
823 let content = "\
824- Item 1 with HTML:
825
826 <details>
827 <summary>Click</summary>
828 Content
829 </details>
830
831- Item 2 simple.
832- Item 3 simple.
833";
834 assert!(
835 check(content, ListItemSpacingStyle::Consistent).is_empty(),
836 "Structural blank after HTML block should not make item 1 appear loose"
837 );
838 }
839
840 #[test]
841 fn blockquote_in_tight_list_no_false_positive() {
842 let content = "\
844- Item 1 with quote:
845
846 > This is a blockquote
847 > with multiple lines.
848
849- Item 2 simple.
850- Item 3 simple.
851";
852 assert!(
853 check(content, ListItemSpacingStyle::Consistent).is_empty(),
854 "Structural blank around blockquote should not make item 1 appear loose"
855 );
856 assert!(
857 check(content, ListItemSpacingStyle::Tight).is_empty(),
858 "Blockquote in tight list should not trigger a violation"
859 );
860 }
861
862 #[test]
863 fn blockquote_multiple_items_with_quotes_tight() {
864 let content = "\
866- Item 1:
867
868 > Quote A
869
870- Item 2:
871
872 > Quote B
873
874- Item 3 plain.
875";
876 assert!(
877 check(content, ListItemSpacingStyle::Tight).is_empty(),
878 "Multiple items with blockquotes should remain tight"
879 );
880 }
881
882 #[test]
883 fn blockquote_mixed_with_genuine_loose_gap() {
884 let content = "\
886- Item 1:
887
888 > Quote
889
890- Item 2 plain.
891
892- Item 3 plain.
893";
894 let warnings = check(content, ListItemSpacingStyle::Tight);
895 assert!(
896 !warnings.is_empty(),
897 "Genuine loose gap between Item 2 and Item 3 should be flagged"
898 );
899 }
900
901 #[test]
902 fn blockquote_single_line_in_tight_list() {
903 let content = "\
904- Item 1:
905
906 > Single line quote.
907
908- Item 2.
909- Item 3.
910";
911 assert!(
912 check(content, ListItemSpacingStyle::Tight).is_empty(),
913 "Single-line blockquote should be structural"
914 );
915 }
916
917 #[test]
918 fn blockquote_in_ordered_list_tight() {
919 let content = "\
9201. Item 1:
921
922 > Quoted text in ordered list.
923
9241. Item 2.
9251. Item 3.
926";
927 assert!(
928 check(content, ListItemSpacingStyle::Tight).is_empty(),
929 "Blockquote in ordered list should be structural"
930 );
931 }
932
933 #[test]
934 fn nested_blockquote_in_tight_list() {
935 let content = "\
936- Item 1:
937
938 > Outer quote
939 > > Nested quote
940
941- Item 2.
942- Item 3.
943";
944 assert!(
945 check(content, ListItemSpacingStyle::Tight).is_empty(),
946 "Nested blockquote in tight list should be structural"
947 );
948 }
949
950 #[test]
951 fn blockquote_as_entire_item_is_loose() {
952 let content = "\
955- > Quote is the entire item content.
956
957- Item 2.
958- Item 3.
959";
960 let warnings = check(content, ListItemSpacingStyle::Tight);
961 assert!(
962 !warnings.is_empty(),
963 "Blank after blockquote-only item is a genuine loose gap"
964 );
965 }
966
967 #[test]
968 fn mixed_code_and_table_in_tight_list() {
969 let content = "\
9701. Item with code:
971
972 ```markdown
973 This is some Markdown
974 ```
975
9761. Simple item.
9771. Item with table:
978
979 | Col 1 | Col 2 |
980 |:------|:------|
981 | Row 1 | Row 1 |
982 | Row 2 | Row 2 |
983";
984 assert!(
985 check(content, ListItemSpacingStyle::Consistent).is_empty(),
986 "Mix of code blocks and tables should not cause false positives"
987 );
988 }
989
990 #[test]
991 fn code_block_with_genuinely_loose_gaps_still_warns() {
992 let content = "\
995- Item 1:
996
997 ```bash
998 echo hi
999 ```
1000
1001- Item 2
1002
1003- Item 3
1004- Item 4
1005";
1006 let warnings = check(content, ListItemSpacingStyle::Consistent);
1007 assert!(
1008 !warnings.is_empty(),
1009 "Genuine inconsistency with code blocks should still be flagged"
1010 );
1011 }
1012
1013 #[test]
1014 fn all_items_have_code_blocks_no_warnings() {
1015 let content = "\
1016- Item 1:
1017
1018 ```python
1019 print(1)
1020 ```
1021
1022- Item 2:
1023
1024 ```python
1025 print(2)
1026 ```
1027
1028- Item 3:
1029
1030 ```python
1031 print(3)
1032 ```
1033";
1034 assert!(
1035 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1036 "All items with code blocks should be consistently tight"
1037 );
1038 }
1039
1040 #[test]
1041 fn tilde_fence_code_block_in_list() {
1042 let content = "\
1043- Item 1:
1044
1045 ~~~
1046 code here
1047 ~~~
1048
1049- Item 2 simple.
1050- Item 3 simple.
1051";
1052 assert!(
1053 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1054 "Tilde fences should be recognized as structural content"
1055 );
1056 }
1057
1058 #[test]
1059 fn nested_list_with_code_block() {
1060 let content = "\
1061- Item 1
1062 - Nested with code:
1063
1064 ```
1065 nested code
1066 ```
1067
1068 - Nested simple.
1069- Item 2
1070";
1071 assert!(
1072 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1073 "Nested list with code block should not cause false positives"
1074 );
1075 }
1076
1077 #[test]
1078 fn tight_style_with_code_block_no_warnings() {
1079 let content = "\
1080- Item 1:
1081
1082 ```
1083 code
1084 ```
1085
1086- Item 2.
1087- Item 3.
1088";
1089 assert!(
1090 check(content, ListItemSpacingStyle::Tight).is_empty(),
1091 "Tight style should not warn about structural blanks around code blocks"
1092 );
1093 }
1094
1095 #[test]
1096 fn loose_style_with_code_block_missing_separator() {
1097 let content = "\
1100- Item 1:
1101
1102 ```
1103 code
1104 ```
1105
1106- Item 2.
1107- Item 3.
1108";
1109 let warnings = check(content, ListItemSpacingStyle::Loose);
1110 assert_eq!(
1111 warnings.len(),
1112 1,
1113 "Loose style should still require blank between simple items"
1114 );
1115 assert!(warnings[0].message.contains("Missing"));
1116 }
1117
1118 #[test]
1119 fn blockquote_list_with_code_block() {
1120 let content = "\
1121> - Item 1:
1122>
1123> ```
1124> code
1125> ```
1126>
1127> - Item 2.
1128> - Item 3.
1129";
1130 assert!(
1131 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1132 "Blockquote-prefixed list with code block should not cause false positives"
1133 );
1134 }
1135
1136 #[test]
1139 fn indented_code_block_in_list_no_false_positive() {
1140 let content = "\
11431. Item with indented code:
1144
1145 some code here
1146 more code
1147
11481. Simple item
11491. Another item
1150";
1151 assert!(
1152 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1153 "Structural blank after indented code block should not make item 1 appear loose"
1154 );
1155 }
1156
1157 #[test]
1160 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1161 let content = "\
11661. Item with code in middle:
1167
1168 ```
1169 code
1170 ```
1171
1172 Some text after the code block.
1173
11741. Simple item
11751. Another item
1176";
1177 let warnings = check(content, ListItemSpacingStyle::Consistent);
1178 assert!(
1179 !warnings.is_empty(),
1180 "Blank line after regular text (not structural content) is a genuine loose gap"
1181 );
1182 }
1183
1184 #[test]
1187 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1188 let content = "\
1191- Item 1:
1192
1193 ```
1194 code
1195 ```
1196
1197- Item 2.
1198- Item 3.
1199";
1200 let fixed = fix(content, ListItemSpacingStyle::Tight);
1201 assert_eq!(
1202 fixed, content,
1203 "Tight fix should not remove structural blanks around code blocks"
1204 );
1205 }
1206
1207 #[test]
1210 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1211 let content = "\
12161. First item
1217
12181. Second item with code block:
1219
1220 ```json
1221 {\"key\": \"value\"}
1222 ```
1223
12241. Third item
1225";
1226 assert!(
1227 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1228 "Structural blank after 4-space indented code block should not cause false positive"
1229 );
1230 }
1231
1232 #[test]
1233 fn four_space_indented_fence_tight_style_no_warnings() {
1234 let content = "\
12351. First item
12361. Second item with code block:
1237
1238 ```json
1239 {\"key\": \"value\"}
1240 ```
1241
12421. Third item
1243";
1244 assert!(
1245 check(content, ListItemSpacingStyle::Tight).is_empty(),
1246 "Tight style should not warn about structural blanks with 4-space fences"
1247 );
1248 }
1249
1250 #[test]
1251 fn four_space_indented_fence_loose_style_no_warnings() {
1252 let content = "\
12541. First item
1255
12561. Second item with code block:
1257
1258 ```json
1259 {\"key\": \"value\"}
1260 ```
1261
12621. Third item
1263";
1264 assert!(
1265 check(content, ListItemSpacingStyle::Loose).is_empty(),
1266 "Loose style should not warn when structural gaps are the only non-loose gaps"
1267 );
1268 }
1269
1270 #[test]
1271 fn structural_gap_with_genuine_inconsistency_still_warns() {
1272 let content = "\
12751. First item with code:
1276
1277 ```json
1278 {\"key\": \"value\"}
1279 ```
1280
12811. Second item
1282
12831. Third item
12841. Fourth item
1285";
1286 let warnings = check(content, ListItemSpacingStyle::Consistent);
1287 assert!(
1288 !warnings.is_empty(),
1289 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1290 );
1291 }
1292
1293 #[test]
1294 fn four_space_fence_fix_is_idempotent() {
1295 let content = "\
12981. First item
1299
13001. Second item with code block:
1301
1302 ```json
1303 {\"key\": \"value\"}
1304 ```
1305
13061. Third item
1307";
1308 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1309 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1310 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1311 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1312 }
1313
1314 #[test]
1315 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1316 let content = "\
13191. First item
13201. Second item with code block:
1321
1322 ```json
1323 {\"key\": \"value\"}
1324 ```
1325
13261. Third item
1327";
1328 let fixed = fix(content, ListItemSpacingStyle::Tight);
1329 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1330 }
1331
1332 #[test]
1333 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1334 let content = "\
13371. First item
1338
13391. Second item with code block:
1340
1341 ```json
1342 {\"key\": \"value\"}
1343 ```
1344
13451. Third item
1346";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1348 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1349 let warnings = rule.check(&ctx).unwrap();
1350 assert!(
1351 warnings.is_empty(),
1352 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1353 );
1354 }
1355
1356 #[test]
1359 fn code_block_in_second_item_detects_inconsistency() {
1360 let content = "\
1363# Test
1364
1365- Lorem ipsum dolor sit amet.
1366- Lorem ipsum dolor sit amet.
1367
1368 ```yaml
1369 hello: world
1370 ```
1371
1372- Lorem ipsum dolor sit amet.
1373
1374- Lorem ipsum dolor sit amet.
1375";
1376 let warnings = check(content, ListItemSpacingStyle::Consistent);
1377 assert!(
1378 !warnings.is_empty(),
1379 "Should detect inconsistent spacing when code block is inside a list item"
1380 );
1381 }
1382
1383 #[test]
1384 fn code_block_in_item_all_tight_no_warnings() {
1385 let content = "\
1387- Item 1
1388- Item 2
1389
1390 ```yaml
1391 hello: world
1392 ```
1393
1394- Item 3
1395- Item 4
1396";
1397 assert!(
1398 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1399 "All tight gaps with structural code block should not warn"
1400 );
1401 }
1402
1403 #[test]
1404 fn code_block_in_item_all_loose_no_warnings() {
1405 let content = "\
1407- Item 1
1408
1409- Item 2
1410
1411 ```yaml
1412 hello: world
1413 ```
1414
1415- Item 3
1416
1417- Item 4
1418";
1419 assert!(
1420 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1421 "All loose gaps with structural code block should not warn"
1422 );
1423 }
1424
1425 #[test]
1426 fn code_block_in_ordered_list_detects_inconsistency() {
1427 let content = "\
14281. First item
14291. Second item
1430
1431 ```json
1432 {\"key\": \"value\"}
1433 ```
1434
14351. Third item
1436
14371. Fourth item
1438";
1439 let warnings = check(content, ListItemSpacingStyle::Consistent);
1440 assert!(
1441 !warnings.is_empty(),
1442 "Ordered list with code block should still detect inconsistency"
1443 );
1444 }
1445
1446 #[test]
1447 fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1448 let content = "\
1454- Item 1
1455- Item 2
1456
1457 ```yaml
1458 code: here
1459 ```
1460
1461- Item 3
1462
1463- Item 4
1464";
1465 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1466 assert!(
1467 fixed.contains("- Item 3\n- Item 4"),
1468 "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1469 );
1470 assert!(
1471 !fixed.contains("- Item 1\n\n- Item 2"),
1472 "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1473 );
1474 }
1475
1476 #[test]
1477 fn tilde_code_block_in_item_detects_inconsistency() {
1478 let content = "\
1479- Item 1
1480- Item 2
1481
1482 ~~~
1483 code
1484 ~~~
1485
1486- Item 3
1487
1488- Item 4
1489";
1490 let warnings = check(content, ListItemSpacingStyle::Consistent);
1491 assert!(
1492 !warnings.is_empty(),
1493 "Tilde code block inside item should not prevent inconsistency detection"
1494 );
1495 }
1496
1497 #[test]
1498 fn multiple_code_blocks_all_tight_no_warnings() {
1499 let content = "\
1501- Item 1
1502
1503 ```
1504 code1
1505 ```
1506
1507- Item 2
1508
1509 ```
1510 code2
1511 ```
1512
1513- Item 3
1514- Item 4
1515";
1516 assert!(
1517 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1518 "All non-structural gaps are tight, so list is consistent"
1519 );
1520 }
1521
1522 #[test]
1523 fn code_block_with_mixed_genuine_gaps_warns() {
1524 let content = "\
1526- Item 1
1527
1528 ```
1529 code1
1530 ```
1531
1532- Item 2
1533
1534- Item 3
1535- Item 4
1536";
1537 let warnings = check(content, ListItemSpacingStyle::Consistent);
1538 assert!(
1539 !warnings.is_empty(),
1540 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1541 );
1542 }
1543
1544 #[test]
1547 fn continuation_loose_tight_style_default_warns() {
1548 let content = "\
1551- Item 1.
1552
1553 Continuation paragraph.
1554
1555- Item 2.
1556
1557 Continuation paragraph.
1558
1559- Item 3.
1560";
1561 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
1562 assert!(
1563 !warnings.is_empty(),
1564 "Should warn about loose gaps when allow_loose_continuation is false"
1565 );
1566 }
1567
1568 #[test]
1569 fn continuation_loose_tight_style_allowed_no_warnings() {
1570 let content = "\
1573- Item 1.
1574
1575 Continuation paragraph.
1576
1577- Item 2.
1578
1579 Continuation paragraph.
1580
1581- Item 3.
1582";
1583 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1584 assert!(
1585 warnings.is_empty(),
1586 "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
1587 );
1588 }
1589
1590 #[test]
1591 fn continuation_loose_mixed_items_warns() {
1592 let content = "\
1595- Item 1.
1596
1597- Item 2.
1598- Item 3.
1599";
1600 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1601 assert!(
1602 !warnings.is_empty(),
1603 "Genuine loose gaps should still warn even with allow_loose_continuation"
1604 );
1605 }
1606
1607 #[test]
1608 fn continuation_loose_consistent_mode() {
1609 let content = "\
1612- Item 1.
1613
1614 Continuation paragraph.
1615
1616- Item 2.
1617- Item 3.
1618";
1619 let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
1620 assert!(
1621 warnings.is_empty(),
1622 "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
1623 );
1624 }
1625
1626 #[test]
1627 fn continuation_loose_fix_preserves_continuation_blanks() {
1628 let content = "\
1629- Item 1.
1630
1631 Continuation paragraph.
1632
1633- Item 2.
1634
1635 Continuation paragraph.
1636
1637- Item 3.
1638";
1639 let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
1640 assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
1641 }
1642
1643 #[test]
1644 fn continuation_loose_fix_removes_genuine_loose_gaps() {
1645 let input = "\
1646- Item 1.
1647
1648- Item 2.
1649
1650- Item 3.
1651";
1652 let expected = "\
1653- Item 1.
1654- Item 2.
1655- Item 3.
1656";
1657 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1658 assert_eq!(fixed, expected);
1659 }
1660
1661 #[test]
1662 fn continuation_loose_ordered_list() {
1663 let content = "\
16641. Item 1.
1665
1666 Continuation paragraph.
1667
16682. Item 2.
1669
1670 Continuation paragraph.
1671
16723. Item 3.
1673";
1674 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1675 assert!(
1676 warnings.is_empty(),
1677 "Ordered list continuation should work too, got: {warnings:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn continuation_loose_disabled_by_default() {
1683 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
1685 assert!(!rule.config.allow_loose_continuation);
1686 }
1687
1688 #[test]
1689 fn continuation_loose_ordered_under_indented_warns() {
1690 let content = "\
16931. Item 1.
1694
1695 Under-indented text.
1696
16971. Item 2.
16981. Item 3.
1699";
1700 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1701 assert!(
1702 !warnings.is_empty(),
1703 "Under-indented text should not be treated as continuation, got: {warnings:?}"
1704 );
1705 }
1706
1707 #[test]
1708 fn continuation_loose_mix_continuation_and_genuine_gaps() {
1709 let content = "\
1711- Item 1.
1712
1713 Continuation paragraph.
1714
1715- Item 2.
1716
1717- Item 3.
1718";
1719 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1720 assert!(
1721 !warnings.is_empty(),
1722 "Genuine loose gap between items 2-3 should warn even with continuation allowed"
1723 );
1724 assert_eq!(
1726 warnings.len(),
1727 1,
1728 "Expected exactly one warning for the genuine loose gap"
1729 );
1730 }
1731
1732 #[test]
1733 fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
1734 let input = "\
1736- Item 1.
1737
1738 Continuation paragraph.
1739
1740- Item 2.
1741
1742- Item 3.
1743";
1744 let expected = "\
1745- Item 1.
1746
1747 Continuation paragraph.
1748
1749- Item 2.
1750- Item 3.
1751";
1752 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1753 assert_eq!(fixed, expected);
1754 }
1755
1756 #[test]
1757 fn continuation_loose_after_code_block() {
1758 let content = "\
1760- Item 1.
1761
1762 ```python
1763 code
1764 ```
1765
1766 Continuation after code.
1767
1768- Item 2.
1769- Item 3.
1770";
1771 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1772 assert!(
1773 warnings.is_empty(),
1774 "Code block + continuation should both be exempt, got: {warnings:?}"
1775 );
1776 }
1777
1778 #[test]
1779 fn continuation_loose_style_does_not_interfere() {
1780 let content = "\
1783- Item 1.
1784
1785 Continuation paragraph.
1786
1787- Item 2.
1788
1789 Continuation paragraph.
1790
1791- Item 3.
1792";
1793 let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
1794 assert!(
1795 warnings.is_empty(),
1796 "Loose style with continuation should not warn, got: {warnings:?}"
1797 );
1798 }
1799
1800 #[test]
1801 fn continuation_loose_tight_no_continuation_content() {
1802 let content = "\
1804- Item 1.
1805- Item 2.
1806- Item 3.
1807";
1808 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1809 assert!(
1810 warnings.is_empty(),
1811 "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
1812 );
1813 }
1814
1815 #[test]
1818 fn default_config_section_provides_style_key() {
1819 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1820 let section = rule.default_config_section();
1821 assert!(section.is_some());
1822 let (name, value) = section.unwrap();
1823 assert_eq!(name, "MD076");
1824 if let toml::Value::Table(map) = value {
1825 assert!(map.contains_key("style"));
1826 assert!(map.contains_key("allow-loose-continuation"));
1827 } else {
1828 panic!("Expected Table value from default_config_section");
1829 }
1830 }
1831}