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