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 loose_count >= tight_count {
280 (false, true)
281 } else {
282 (true, false)
283 }
284 }
285 };
286
287 Some(BlockAnalysis {
288 items,
289 gaps,
290 warn_loose_gaps,
291 warn_tight_gaps,
292 })
293 }
294}
295
296impl Rule for MD076ListItemSpacing {
297 fn name(&self) -> &'static str {
298 "MD076"
299 }
300
301 fn description(&self) -> &'static str {
302 "List item spacing should be consistent"
303 }
304
305 fn category(&self) -> RuleCategory {
306 RuleCategory::List
307 }
308
309 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
310 ctx.content.is_empty() || ctx.list_blocks.is_empty()
311 }
312
313 fn check(&self, ctx: &LintContext) -> LintResult {
314 if ctx.content.is_empty() {
315 return Ok(Vec::new());
316 }
317
318 let mut warnings = Vec::new();
319
320 let allow_cont = self.config.allow_loose_continuation;
321
322 for block in &ctx.list_blocks {
323 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
324 continue;
325 };
326
327 for (i, &gap) in analysis.gaps.iter().enumerate() {
328 let is_loose_violation = match gap {
329 GapKind::Loose => analysis.warn_loose_gaps,
330 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
331 _ => false,
332 };
333
334 if is_loose_violation {
335 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
336 if let Some(&blank_line) = blanks.first() {
337 let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
338 warnings.push(LintWarning {
339 rule_name: Some(self.name().to_string()),
340 line: blank_line,
341 column: 1,
342 end_line: blank_line,
343 end_column: line_content.len() + 1,
344 message: "Unexpected blank line between list items".to_string(),
345 severity: Severity::Warning,
346 fix: None,
347 });
348 }
349 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
350 let next_item = analysis.items[i + 1];
351 let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
352 warnings.push(LintWarning {
353 rule_name: Some(self.name().to_string()),
354 line: next_item,
355 column: 1,
356 end_line: next_item,
357 end_column: line_content.len() + 1,
358 message: "Missing blank line between list items".to_string(),
359 severity: Severity::Warning,
360 fix: None,
361 });
362 }
363 }
364 }
365
366 Ok(warnings)
367 }
368
369 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
370 if ctx.content.is_empty() {
371 return Ok(ctx.content.to_string());
372 }
373
374 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
376 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
377
378 let allow_cont = self.config.allow_loose_continuation;
379
380 for block in &ctx.list_blocks {
381 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
382 continue;
383 };
384
385 for (i, &gap) in analysis.gaps.iter().enumerate() {
386 let is_loose_violation = match gap {
387 GapKind::Loose => analysis.warn_loose_gaps,
388 GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
389 _ => false,
390 };
391
392 if is_loose_violation {
393 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
394 remove_lines.insert(blank_line);
395 }
396 } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
397 insert_before.insert(analysis.items[i + 1]);
398 }
399 }
400 }
401
402 if insert_before.is_empty() && remove_lines.is_empty() {
403 return Ok(ctx.content.to_string());
404 }
405
406 let lines = ctx.raw_lines();
407 let mut result: Vec<String> = Vec::with_capacity(lines.len());
408
409 for (i, line) in lines.iter().enumerate() {
410 let line_num = i + 1;
411
412 if ctx.is_rule_disabled(self.name(), line_num) {
414 result.push((*line).to_string());
415 continue;
416 }
417
418 if remove_lines.contains(&line_num) {
419 continue;
420 }
421
422 if insert_before.contains(&line_num) {
423 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
424 result.push(bq_prefix);
425 }
426
427 result.push((*line).to_string());
428 }
429
430 let mut output = result.join("\n");
431 if ctx.content.ends_with('\n') {
432 output.push('\n');
433 }
434 Ok(output)
435 }
436
437 fn as_any(&self) -> &dyn std::any::Any {
438 self
439 }
440
441 fn default_config_section(&self) -> Option<(String, toml::Value)> {
442 let mut map = toml::map::Map::new();
443 let style_str = match self.config.style {
444 ListItemSpacingStyle::Consistent => "consistent",
445 ListItemSpacingStyle::Loose => "loose",
446 ListItemSpacingStyle::Tight => "tight",
447 };
448 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
449 map.insert(
450 "allow-loose-continuation".to_string(),
451 toml::Value::Boolean(self.config.allow_loose_continuation),
452 );
453 Some((self.name().to_string(), toml::Value::Table(map)))
454 }
455
456 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
457 where
458 Self: Sized,
459 {
460 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
461 .unwrap_or_else(|| "consistent".to_string());
462 let style = match style.as_str() {
463 "loose" => ListItemSpacingStyle::Loose,
464 "tight" => ListItemSpacingStyle::Tight,
465 _ => ListItemSpacingStyle::Consistent,
466 };
467 let allow_loose_continuation =
468 crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
469 .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
470 .unwrap_or(false);
471 Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let rule = MD076ListItemSpacing::new(style);
482 rule.check(&ctx).unwrap()
483 }
484
485 fn check_with_continuation(
486 content: &str,
487 style: ListItemSpacingStyle,
488 allow_loose_continuation: bool,
489 ) -> Vec<LintWarning> {
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
492 rule.check(&ctx).unwrap()
493 }
494
495 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let rule = MD076ListItemSpacing::new(style);
498 rule.fix(&ctx).unwrap()
499 }
500
501 fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
504 rule.fix(&ctx).unwrap()
505 }
506
507 #[test]
510 fn tight_list_tight_style_no_warnings() {
511 let content = "- Item 1\n- Item 2\n- Item 3\n";
512 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
513 }
514
515 #[test]
516 fn loose_list_loose_style_no_warnings() {
517 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
518 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
519 }
520
521 #[test]
522 fn tight_list_loose_style_warns() {
523 let content = "- Item 1\n- Item 2\n- Item 3\n";
524 let warnings = check(content, ListItemSpacingStyle::Loose);
525 assert_eq!(warnings.len(), 2);
526 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
527 }
528
529 #[test]
530 fn loose_list_tight_style_warns() {
531 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
532 let warnings = check(content, ListItemSpacingStyle::Tight);
533 assert_eq!(warnings.len(), 2);
534 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
535 }
536
537 #[test]
540 fn consistent_all_tight_no_warnings() {
541 let content = "- Item 1\n- Item 2\n- Item 3\n";
542 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
543 }
544
545 #[test]
546 fn consistent_all_loose_no_warnings() {
547 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
548 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
549 }
550
551 #[test]
552 fn consistent_mixed_majority_loose_warns_tight() {
553 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
555 let warnings = check(content, ListItemSpacingStyle::Consistent);
556 assert_eq!(warnings.len(), 1);
557 assert!(warnings[0].message.contains("Missing"));
558 }
559
560 #[test]
561 fn consistent_mixed_majority_tight_warns_loose() {
562 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
564 let warnings = check(content, ListItemSpacingStyle::Consistent);
565 assert_eq!(warnings.len(), 1);
566 assert!(warnings[0].message.contains("Unexpected"));
567 }
568
569 #[test]
570 fn consistent_tie_prefers_loose() {
571 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
572 let warnings = check(content, ListItemSpacingStyle::Consistent);
573 assert_eq!(warnings.len(), 1);
574 assert!(warnings[0].message.contains("Missing"));
575 }
576
577 #[test]
580 fn single_item_list_no_warnings() {
581 let content = "- Only item\n";
582 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
583 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
584 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
585 }
586
587 #[test]
588 fn empty_content_no_warnings() {
589 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
590 }
591
592 #[test]
593 fn ordered_list_tight_gaps_loose_style_warns() {
594 let content = "1. First\n2. Second\n3. Third\n";
595 let warnings = check(content, ListItemSpacingStyle::Loose);
596 assert_eq!(warnings.len(), 2);
597 }
598
599 #[test]
600 fn task_list_works() {
601 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
602 let warnings = check(content, ListItemSpacingStyle::Loose);
603 assert_eq!(warnings.len(), 2);
604 let fixed = fix(content, ListItemSpacingStyle::Loose);
605 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
606 }
607
608 #[test]
609 fn no_trailing_newline() {
610 let content = "- Item 1\n- Item 2";
611 let warnings = check(content, ListItemSpacingStyle::Loose);
612 assert_eq!(warnings.len(), 1);
613 let fixed = fix(content, ListItemSpacingStyle::Loose);
614 assert_eq!(fixed, "- Item 1\n\n- Item 2");
615 }
616
617 #[test]
618 fn two_separate_lists() {
619 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
620 let warnings = check(content, ListItemSpacingStyle::Loose);
621 assert_eq!(warnings.len(), 2);
622 let fixed = fix(content, ListItemSpacingStyle::Loose);
623 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
624 }
625
626 #[test]
627 fn no_list_content() {
628 let content = "Just a paragraph.\n\nAnother paragraph.\n";
629 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
630 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
631 }
632
633 #[test]
636 fn continuation_lines_tight_detected() {
637 let content = "- Item 1\n continuation\n- Item 2\n";
638 let warnings = check(content, ListItemSpacingStyle::Loose);
639 assert_eq!(warnings.len(), 1);
640 assert!(warnings[0].message.contains("Missing"));
641 }
642
643 #[test]
644 fn continuation_lines_loose_detected() {
645 let content = "- Item 1\n continuation\n\n- Item 2\n";
646 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
647 let warnings = check(content, ListItemSpacingStyle::Tight);
648 assert_eq!(warnings.len(), 1);
649 assert!(warnings[0].message.contains("Unexpected"));
650 }
651
652 #[test]
653 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
654 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
657 let warnings = check(content, ListItemSpacingStyle::Tight);
659 assert_eq!(
660 warnings.len(),
661 1,
662 "Should warn only on the inter-item blank, not the intra-item blank"
663 );
664 let fixed = fix(content, ListItemSpacingStyle::Tight);
667 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
668 }
669
670 #[test]
671 fn multi_paragraph_item_loose_style_no_warnings() {
672 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
674 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
675 }
676
677 #[test]
680 fn blockquote_tight_list_loose_style_warns() {
681 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
682 let warnings = check(content, ListItemSpacingStyle::Loose);
683 assert_eq!(warnings.len(), 2);
684 }
685
686 #[test]
687 fn blockquote_loose_list_detected() {
688 let content = "> - Item 1\n>\n> - Item 2\n";
690 let warnings = check(content, ListItemSpacingStyle::Tight);
691 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
692 assert!(warnings[0].message.contains("Unexpected"));
693 }
694
695 #[test]
696 fn blockquote_loose_list_no_warnings_when_loose() {
697 let content = "> - Item 1\n>\n> - Item 2\n";
698 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
699 }
700
701 #[test]
704 fn multiple_blanks_all_removed() {
705 let content = "- Item 1\n\n\n- Item 2\n";
706 let fixed = fix(content, ListItemSpacingStyle::Tight);
707 assert_eq!(fixed, "- Item 1\n- Item 2\n");
708 }
709
710 #[test]
711 fn multiple_blanks_fix_is_idempotent() {
712 let content = "- Item 1\n\n\n\n- Item 2\n";
713 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
714 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
715 assert_eq!(fixed_once, fixed_twice);
716 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
717 }
718
719 #[test]
722 fn fix_adds_blank_lines() {
723 let content = "- Item 1\n- Item 2\n- Item 3\n";
724 let fixed = fix(content, ListItemSpacingStyle::Loose);
725 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
726 }
727
728 #[test]
729 fn fix_removes_blank_lines() {
730 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
731 let fixed = fix(content, ListItemSpacingStyle::Tight);
732 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
733 }
734
735 #[test]
736 fn fix_consistent_adds_blank() {
737 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
739 let fixed = fix(content, ListItemSpacingStyle::Consistent);
740 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
741 }
742
743 #[test]
744 fn fix_idempotent_loose() {
745 let content = "- Item 1\n- Item 2\n";
746 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
747 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
748 assert_eq!(fixed_once, fixed_twice);
749 }
750
751 #[test]
752 fn fix_idempotent_tight() {
753 let content = "- Item 1\n\n- Item 2\n";
754 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
755 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
756 assert_eq!(fixed_once, fixed_twice);
757 }
758
759 #[test]
762 fn nested_list_does_not_affect_parent() {
763 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
765 let warnings = check(content, ListItemSpacingStyle::Tight);
766 assert!(
767 warnings.is_empty(),
768 "Nested items should not cause parent-level warnings"
769 );
770 }
771
772 #[test]
775 fn code_block_in_tight_list_no_false_positive() {
776 let content = "\
778- Item 1 with code:
779
780 ```python
781 print('hello')
782 ```
783
784- Item 2 simple.
785- Item 3 simple.
786";
787 assert!(
788 check(content, ListItemSpacingStyle::Consistent).is_empty(),
789 "Structural blank after code block should not make item 1 appear loose"
790 );
791 }
792
793 #[test]
794 fn table_in_tight_list_no_false_positive() {
795 let content = "\
797- Item 1 with table:
798
799 | Col 1 | Col 2 |
800 |-------|-------|
801 | A | B |
802
803- Item 2 simple.
804- Item 3 simple.
805";
806 assert!(
807 check(content, ListItemSpacingStyle::Consistent).is_empty(),
808 "Structural blank after table should not make item 1 appear loose"
809 );
810 }
811
812 #[test]
813 fn html_block_in_tight_list_no_false_positive() {
814 let content = "\
815- Item 1 with HTML:
816
817 <details>
818 <summary>Click</summary>
819 Content
820 </details>
821
822- Item 2 simple.
823- Item 3 simple.
824";
825 assert!(
826 check(content, ListItemSpacingStyle::Consistent).is_empty(),
827 "Structural blank after HTML block should not make item 1 appear loose"
828 );
829 }
830
831 #[test]
832 fn blockquote_in_tight_list_no_false_positive() {
833 let content = "\
835- Item 1 with quote:
836
837 > This is a blockquote
838 > with multiple lines.
839
840- Item 2 simple.
841- Item 3 simple.
842";
843 assert!(
844 check(content, ListItemSpacingStyle::Consistent).is_empty(),
845 "Structural blank around blockquote should not make item 1 appear loose"
846 );
847 assert!(
848 check(content, ListItemSpacingStyle::Tight).is_empty(),
849 "Blockquote in tight list should not trigger a violation"
850 );
851 }
852
853 #[test]
854 fn blockquote_multiple_items_with_quotes_tight() {
855 let content = "\
857- Item 1:
858
859 > Quote A
860
861- Item 2:
862
863 > Quote B
864
865- Item 3 plain.
866";
867 assert!(
868 check(content, ListItemSpacingStyle::Tight).is_empty(),
869 "Multiple items with blockquotes should remain tight"
870 );
871 }
872
873 #[test]
874 fn blockquote_mixed_with_genuine_loose_gap() {
875 let content = "\
877- Item 1:
878
879 > Quote
880
881- Item 2 plain.
882
883- Item 3 plain.
884";
885 let warnings = check(content, ListItemSpacingStyle::Tight);
886 assert!(
887 !warnings.is_empty(),
888 "Genuine loose gap between Item 2 and Item 3 should be flagged"
889 );
890 }
891
892 #[test]
893 fn blockquote_single_line_in_tight_list() {
894 let content = "\
895- Item 1:
896
897 > Single line quote.
898
899- Item 2.
900- Item 3.
901";
902 assert!(
903 check(content, ListItemSpacingStyle::Tight).is_empty(),
904 "Single-line blockquote should be structural"
905 );
906 }
907
908 #[test]
909 fn blockquote_in_ordered_list_tight() {
910 let content = "\
9111. Item 1:
912
913 > Quoted text in ordered list.
914
9151. Item 2.
9161. Item 3.
917";
918 assert!(
919 check(content, ListItemSpacingStyle::Tight).is_empty(),
920 "Blockquote in ordered list should be structural"
921 );
922 }
923
924 #[test]
925 fn nested_blockquote_in_tight_list() {
926 let content = "\
927- Item 1:
928
929 > Outer quote
930 > > Nested quote
931
932- Item 2.
933- Item 3.
934";
935 assert!(
936 check(content, ListItemSpacingStyle::Tight).is_empty(),
937 "Nested blockquote in tight list should be structural"
938 );
939 }
940
941 #[test]
942 fn blockquote_as_entire_item_is_loose() {
943 let content = "\
946- > Quote is the entire item content.
947
948- Item 2.
949- Item 3.
950";
951 let warnings = check(content, ListItemSpacingStyle::Tight);
952 assert!(
953 !warnings.is_empty(),
954 "Blank after blockquote-only item is a genuine loose gap"
955 );
956 }
957
958 #[test]
959 fn mixed_code_and_table_in_tight_list() {
960 let content = "\
9611. Item with code:
962
963 ```markdown
964 This is some Markdown
965 ```
966
9671. Simple item.
9681. Item with table:
969
970 | Col 1 | Col 2 |
971 |:------|:------|
972 | Row 1 | Row 1 |
973 | Row 2 | Row 2 |
974";
975 assert!(
976 check(content, ListItemSpacingStyle::Consistent).is_empty(),
977 "Mix of code blocks and tables should not cause false positives"
978 );
979 }
980
981 #[test]
982 fn code_block_with_genuinely_loose_gaps_still_warns() {
983 let content = "\
986- Item 1:
987
988 ```bash
989 echo hi
990 ```
991
992- Item 2
993
994- Item 3
995- Item 4
996";
997 let warnings = check(content, ListItemSpacingStyle::Consistent);
998 assert!(
999 !warnings.is_empty(),
1000 "Genuine inconsistency with code blocks should still be flagged"
1001 );
1002 }
1003
1004 #[test]
1005 fn all_items_have_code_blocks_no_warnings() {
1006 let content = "\
1007- Item 1:
1008
1009 ```python
1010 print(1)
1011 ```
1012
1013- Item 2:
1014
1015 ```python
1016 print(2)
1017 ```
1018
1019- Item 3:
1020
1021 ```python
1022 print(3)
1023 ```
1024";
1025 assert!(
1026 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1027 "All items with code blocks should be consistently tight"
1028 );
1029 }
1030
1031 #[test]
1032 fn tilde_fence_code_block_in_list() {
1033 let content = "\
1034- Item 1:
1035
1036 ~~~
1037 code here
1038 ~~~
1039
1040- Item 2 simple.
1041- Item 3 simple.
1042";
1043 assert!(
1044 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1045 "Tilde fences should be recognized as structural content"
1046 );
1047 }
1048
1049 #[test]
1050 fn nested_list_with_code_block() {
1051 let content = "\
1052- Item 1
1053 - Nested with code:
1054
1055 ```
1056 nested code
1057 ```
1058
1059 - Nested simple.
1060- Item 2
1061";
1062 assert!(
1063 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1064 "Nested list with code block should not cause false positives"
1065 );
1066 }
1067
1068 #[test]
1069 fn tight_style_with_code_block_no_warnings() {
1070 let content = "\
1071- Item 1:
1072
1073 ```
1074 code
1075 ```
1076
1077- Item 2.
1078- Item 3.
1079";
1080 assert!(
1081 check(content, ListItemSpacingStyle::Tight).is_empty(),
1082 "Tight style should not warn about structural blanks around code blocks"
1083 );
1084 }
1085
1086 #[test]
1087 fn loose_style_with_code_block_missing_separator() {
1088 let content = "\
1091- Item 1:
1092
1093 ```
1094 code
1095 ```
1096
1097- Item 2.
1098- Item 3.
1099";
1100 let warnings = check(content, ListItemSpacingStyle::Loose);
1101 assert_eq!(
1102 warnings.len(),
1103 1,
1104 "Loose style should still require blank between simple items"
1105 );
1106 assert!(warnings[0].message.contains("Missing"));
1107 }
1108
1109 #[test]
1110 fn blockquote_list_with_code_block() {
1111 let content = "\
1112> - Item 1:
1113>
1114> ```
1115> code
1116> ```
1117>
1118> - Item 2.
1119> - Item 3.
1120";
1121 assert!(
1122 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1123 "Blockquote-prefixed list with code block should not cause false positives"
1124 );
1125 }
1126
1127 #[test]
1130 fn indented_code_block_in_list_no_false_positive() {
1131 let content = "\
11341. Item with indented code:
1135
1136 some code here
1137 more code
1138
11391. Simple item
11401. Another item
1141";
1142 assert!(
1143 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1144 "Structural blank after indented code block should not make item 1 appear loose"
1145 );
1146 }
1147
1148 #[test]
1151 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1152 let content = "\
11571. Item with code in middle:
1158
1159 ```
1160 code
1161 ```
1162
1163 Some text after the code block.
1164
11651. Simple item
11661. Another item
1167";
1168 let warnings = check(content, ListItemSpacingStyle::Consistent);
1169 assert!(
1170 !warnings.is_empty(),
1171 "Blank line after regular text (not structural content) is a genuine loose gap"
1172 );
1173 }
1174
1175 #[test]
1178 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1179 let content = "\
1182- Item 1:
1183
1184 ```
1185 code
1186 ```
1187
1188- Item 2.
1189- Item 3.
1190";
1191 let fixed = fix(content, ListItemSpacingStyle::Tight);
1192 assert_eq!(
1193 fixed, content,
1194 "Tight fix should not remove structural blanks around code blocks"
1195 );
1196 }
1197
1198 #[test]
1201 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1202 let content = "\
12071. First item
1208
12091. Second item with code block:
1210
1211 ```json
1212 {\"key\": \"value\"}
1213 ```
1214
12151. Third item
1216";
1217 assert!(
1218 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1219 "Structural blank after 4-space indented code block should not cause false positive"
1220 );
1221 }
1222
1223 #[test]
1224 fn four_space_indented_fence_tight_style_no_warnings() {
1225 let content = "\
12261. First item
12271. Second item with code block:
1228
1229 ```json
1230 {\"key\": \"value\"}
1231 ```
1232
12331. Third item
1234";
1235 assert!(
1236 check(content, ListItemSpacingStyle::Tight).is_empty(),
1237 "Tight style should not warn about structural blanks with 4-space fences"
1238 );
1239 }
1240
1241 #[test]
1242 fn four_space_indented_fence_loose_style_no_warnings() {
1243 let content = "\
12451. First item
1246
12471. Second item with code block:
1248
1249 ```json
1250 {\"key\": \"value\"}
1251 ```
1252
12531. Third item
1254";
1255 assert!(
1256 check(content, ListItemSpacingStyle::Loose).is_empty(),
1257 "Loose style should not warn when structural gaps are the only non-loose gaps"
1258 );
1259 }
1260
1261 #[test]
1262 fn structural_gap_with_genuine_inconsistency_still_warns() {
1263 let content = "\
12661. First item with code:
1267
1268 ```json
1269 {\"key\": \"value\"}
1270 ```
1271
12721. Second item
1273
12741. Third item
12751. Fourth item
1276";
1277 let warnings = check(content, ListItemSpacingStyle::Consistent);
1278 assert!(
1279 !warnings.is_empty(),
1280 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1281 );
1282 }
1283
1284 #[test]
1285 fn four_space_fence_fix_is_idempotent() {
1286 let content = "\
12891. First item
1290
12911. Second item with code block:
1292
1293 ```json
1294 {\"key\": \"value\"}
1295 ```
1296
12971. Third item
1298";
1299 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1300 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1301 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1302 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1303 }
1304
1305 #[test]
1306 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1307 let content = "\
13101. First item
13111. Second item with code block:
1312
1313 ```json
1314 {\"key\": \"value\"}
1315 ```
1316
13171. Third item
1318";
1319 let fixed = fix(content, ListItemSpacingStyle::Tight);
1320 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1321 }
1322
1323 #[test]
1324 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1325 let content = "\
13281. First item
1329
13301. Second item with code block:
1331
1332 ```json
1333 {\"key\": \"value\"}
1334 ```
1335
13361. Third item
1337";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1339 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1340 let warnings = rule.check(&ctx).unwrap();
1341 assert!(
1342 warnings.is_empty(),
1343 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1344 );
1345 }
1346
1347 #[test]
1350 fn code_block_in_second_item_detects_inconsistency() {
1351 let content = "\
1354# Test
1355
1356- Lorem ipsum dolor sit amet.
1357- Lorem ipsum dolor sit amet.
1358
1359 ```yaml
1360 hello: world
1361 ```
1362
1363- Lorem ipsum dolor sit amet.
1364
1365- Lorem ipsum dolor sit amet.
1366";
1367 let warnings = check(content, ListItemSpacingStyle::Consistent);
1368 assert!(
1369 !warnings.is_empty(),
1370 "Should detect inconsistent spacing when code block is inside a list item"
1371 );
1372 }
1373
1374 #[test]
1375 fn code_block_in_item_all_tight_no_warnings() {
1376 let content = "\
1378- Item 1
1379- Item 2
1380
1381 ```yaml
1382 hello: world
1383 ```
1384
1385- Item 3
1386- Item 4
1387";
1388 assert!(
1389 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1390 "All tight gaps with structural code block should not warn"
1391 );
1392 }
1393
1394 #[test]
1395 fn code_block_in_item_all_loose_no_warnings() {
1396 let content = "\
1398- Item 1
1399
1400- Item 2
1401
1402 ```yaml
1403 hello: world
1404 ```
1405
1406- Item 3
1407
1408- Item 4
1409";
1410 assert!(
1411 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1412 "All loose gaps with structural code block should not warn"
1413 );
1414 }
1415
1416 #[test]
1417 fn code_block_in_ordered_list_detects_inconsistency() {
1418 let content = "\
14191. First item
14201. Second item
1421
1422 ```json
1423 {\"key\": \"value\"}
1424 ```
1425
14261. Third item
1427
14281. Fourth item
1429";
1430 let warnings = check(content, ListItemSpacingStyle::Consistent);
1431 assert!(
1432 !warnings.is_empty(),
1433 "Ordered list with code block should still detect inconsistency"
1434 );
1435 }
1436
1437 #[test]
1438 fn code_block_in_item_fix_adds_missing_blanks() {
1439 let content = "\
1441- Item 1
1442- Item 2
1443
1444 ```yaml
1445 code: here
1446 ```
1447
1448- Item 3
1449
1450- Item 4
1451";
1452 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1453 assert!(
1454 fixed.contains("- Item 1\n\n- Item 2"),
1455 "Fix should add blank line between items 1 and 2"
1456 );
1457 }
1458
1459 #[test]
1460 fn tilde_code_block_in_item_detects_inconsistency() {
1461 let content = "\
1462- Item 1
1463- Item 2
1464
1465 ~~~
1466 code
1467 ~~~
1468
1469- Item 3
1470
1471- Item 4
1472";
1473 let warnings = check(content, ListItemSpacingStyle::Consistent);
1474 assert!(
1475 !warnings.is_empty(),
1476 "Tilde code block inside item should not prevent inconsistency detection"
1477 );
1478 }
1479
1480 #[test]
1481 fn multiple_code_blocks_all_tight_no_warnings() {
1482 let content = "\
1484- Item 1
1485
1486 ```
1487 code1
1488 ```
1489
1490- Item 2
1491
1492 ```
1493 code2
1494 ```
1495
1496- Item 3
1497- Item 4
1498";
1499 assert!(
1500 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1501 "All non-structural gaps are tight, so list is consistent"
1502 );
1503 }
1504
1505 #[test]
1506 fn code_block_with_mixed_genuine_gaps_warns() {
1507 let content = "\
1509- Item 1
1510
1511 ```
1512 code1
1513 ```
1514
1515- Item 2
1516
1517- Item 3
1518- Item 4
1519";
1520 let warnings = check(content, ListItemSpacingStyle::Consistent);
1521 assert!(
1522 !warnings.is_empty(),
1523 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1524 );
1525 }
1526
1527 #[test]
1530 fn continuation_loose_tight_style_default_warns() {
1531 let content = "\
1534- Item 1.
1535
1536 Continuation paragraph.
1537
1538- Item 2.
1539
1540 Continuation paragraph.
1541
1542- Item 3.
1543";
1544 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
1545 assert!(
1546 !warnings.is_empty(),
1547 "Should warn about loose gaps when allow_loose_continuation is false"
1548 );
1549 }
1550
1551 #[test]
1552 fn continuation_loose_tight_style_allowed_no_warnings() {
1553 let content = "\
1556- Item 1.
1557
1558 Continuation paragraph.
1559
1560- Item 2.
1561
1562 Continuation paragraph.
1563
1564- Item 3.
1565";
1566 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1567 assert!(
1568 warnings.is_empty(),
1569 "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
1570 );
1571 }
1572
1573 #[test]
1574 fn continuation_loose_mixed_items_warns() {
1575 let content = "\
1578- Item 1.
1579
1580- Item 2.
1581- Item 3.
1582";
1583 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1584 assert!(
1585 !warnings.is_empty(),
1586 "Genuine loose gaps should still warn even with allow_loose_continuation"
1587 );
1588 }
1589
1590 #[test]
1591 fn continuation_loose_consistent_mode() {
1592 let content = "\
1595- Item 1.
1596
1597 Continuation paragraph.
1598
1599- Item 2.
1600- Item 3.
1601";
1602 let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
1603 assert!(
1604 warnings.is_empty(),
1605 "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn continuation_loose_fix_preserves_continuation_blanks() {
1611 let content = "\
1612- Item 1.
1613
1614 Continuation paragraph.
1615
1616- Item 2.
1617
1618 Continuation paragraph.
1619
1620- Item 3.
1621";
1622 let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
1623 assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
1624 }
1625
1626 #[test]
1627 fn continuation_loose_fix_removes_genuine_loose_gaps() {
1628 let input = "\
1629- Item 1.
1630
1631- Item 2.
1632
1633- Item 3.
1634";
1635 let expected = "\
1636- Item 1.
1637- Item 2.
1638- Item 3.
1639";
1640 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1641 assert_eq!(fixed, expected);
1642 }
1643
1644 #[test]
1645 fn continuation_loose_ordered_list() {
1646 let content = "\
16471. Item 1.
1648
1649 Continuation paragraph.
1650
16512. Item 2.
1652
1653 Continuation paragraph.
1654
16553. Item 3.
1656";
1657 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1658 assert!(
1659 warnings.is_empty(),
1660 "Ordered list continuation should work too, got: {warnings:?}"
1661 );
1662 }
1663
1664 #[test]
1665 fn continuation_loose_disabled_by_default() {
1666 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
1668 assert!(!rule.config.allow_loose_continuation);
1669 }
1670
1671 #[test]
1672 fn continuation_loose_ordered_under_indented_warns() {
1673 let content = "\
16761. Item 1.
1677
1678 Under-indented text.
1679
16801. Item 2.
16811. Item 3.
1682";
1683 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1684 assert!(
1685 !warnings.is_empty(),
1686 "Under-indented text should not be treated as continuation, got: {warnings:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn continuation_loose_mix_continuation_and_genuine_gaps() {
1692 let content = "\
1694- Item 1.
1695
1696 Continuation paragraph.
1697
1698- Item 2.
1699
1700- Item 3.
1701";
1702 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1703 assert!(
1704 !warnings.is_empty(),
1705 "Genuine loose gap between items 2-3 should warn even with continuation allowed"
1706 );
1707 assert_eq!(
1709 warnings.len(),
1710 1,
1711 "Expected exactly one warning for the genuine loose gap"
1712 );
1713 }
1714
1715 #[test]
1716 fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
1717 let input = "\
1719- Item 1.
1720
1721 Continuation paragraph.
1722
1723- Item 2.
1724
1725- Item 3.
1726";
1727 let expected = "\
1728- Item 1.
1729
1730 Continuation paragraph.
1731
1732- Item 2.
1733- Item 3.
1734";
1735 let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1736 assert_eq!(fixed, expected);
1737 }
1738
1739 #[test]
1740 fn continuation_loose_after_code_block() {
1741 let content = "\
1743- Item 1.
1744
1745 ```python
1746 code
1747 ```
1748
1749 Continuation after code.
1750
1751- Item 2.
1752- Item 3.
1753";
1754 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1755 assert!(
1756 warnings.is_empty(),
1757 "Code block + continuation should both be exempt, got: {warnings:?}"
1758 );
1759 }
1760
1761 #[test]
1762 fn continuation_loose_style_does_not_interfere() {
1763 let content = "\
1766- Item 1.
1767
1768 Continuation paragraph.
1769
1770- Item 2.
1771
1772 Continuation paragraph.
1773
1774- Item 3.
1775";
1776 let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
1777 assert!(
1778 warnings.is_empty(),
1779 "Loose style with continuation should not warn, got: {warnings:?}"
1780 );
1781 }
1782
1783 #[test]
1784 fn continuation_loose_tight_no_continuation_content() {
1785 let content = "\
1787- Item 1.
1788- Item 2.
1789- Item 3.
1790";
1791 let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1792 assert!(
1793 warnings.is_empty(),
1794 "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
1795 );
1796 }
1797
1798 #[test]
1801 fn default_config_section_provides_style_key() {
1802 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1803 let section = rule.default_config_section();
1804 assert!(section.is_some());
1805 let (name, value) = section.unwrap();
1806 assert_eq!(name, "MD076");
1807 if let toml::Value::Table(map) = value {
1808 assert!(map.contains_key("style"));
1809 assert!(map.contains_key("allow-loose-continuation"));
1810 } else {
1811 panic!("Expected Table value from default_config_section");
1812 }
1813 }
1814}