1use crate::lint_context::LintContext;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, 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}
35
36#[derive(Debug, Clone, Default)]
37pub struct MD076ListItemSpacing {
38 config: MD076Config,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum GapKind {
44 Tight,
46 Loose,
48 Structural,
51}
52
53struct BlockAnalysis {
55 items: Vec<usize>,
57 gaps: Vec<GapKind>,
59 warn_loose_gaps: bool,
61 warn_tight_gaps: bool,
63}
64
65impl MD076ListItemSpacing {
66 pub fn new(style: ListItemSpacingStyle) -> Self {
67 Self {
68 config: MD076Config { style },
69 }
70 }
71
72 fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
77 if let Some(info) = ctx.line_info(line_num) {
78 let content = info.content(ctx.content);
79 if content.trim().is_empty() {
80 return true;
81 }
82 if let Some(ref bq) = info.blockquote {
84 return bq.content.trim().is_empty();
85 }
86 false
87 } else {
88 false
89 }
90 }
91
92 fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
95 if let Some(info) = ctx.line_info(line_num) {
96 if info.in_code_block {
98 return true;
99 }
100 if info.in_html_block {
102 return true;
103 }
104 let content = info.content(ctx.content);
106 let effective = if let Some(ref bq) = info.blockquote {
108 bq.content.as_str()
109 } else {
110 content
111 };
112 if is_table_line(effective.trim_start()) {
113 return true;
114 }
115 }
116 false
117 }
118
119 fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
125 if next <= first + 1 {
126 return GapKind::Tight;
127 }
128 if !Self::is_effectively_blank(ctx, next - 1) {
130 return GapKind::Tight;
131 }
132 let mut scan = next - 1;
135 while scan > first && Self::is_effectively_blank(ctx, scan) {
136 scan -= 1;
137 }
138 if scan > first && Self::is_structural_content(ctx, scan) {
140 return GapKind::Structural;
141 }
142 GapKind::Loose
143 }
144
145 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
152 let mut blanks = Vec::new();
153 let mut line_num = next - 1;
154 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
155 blanks.push(line_num);
156 line_num -= 1;
157 }
158 if line_num > first && Self::is_structural_content(ctx, line_num) {
160 return Vec::new();
161 }
162 blanks.reverse();
163 blanks
164 }
165
166 fn analyze_block(
171 ctx: &LintContext,
172 block: &crate::lint_context::types::ListBlock,
173 style: &ListItemSpacingStyle,
174 ) -> Option<BlockAnalysis> {
175 let items: Vec<usize> = block
179 .item_lines
180 .iter()
181 .copied()
182 .filter(|&line_num| {
183 ctx.line_info(line_num)
184 .and_then(|li| li.list_item.as_ref())
185 .map(|item| item.marker_column / 2 == block.nesting_level)
186 .unwrap_or(false)
187 })
188 .collect();
189
190 if items.len() < 2 {
191 return None;
192 }
193
194 let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
196
197 let loose_count = gaps.iter().filter(|&&g| g == GapKind::Loose).count();
201 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
202
203 let (warn_loose_gaps, warn_tight_gaps) = match style {
204 ListItemSpacingStyle::Loose => (false, true),
205 ListItemSpacingStyle::Tight => (true, false),
206 ListItemSpacingStyle::Consistent => {
207 if loose_count == 0 || tight_count == 0 {
208 return None; }
210 if loose_count >= tight_count {
212 (false, true)
213 } else {
214 (true, false)
215 }
216 }
217 };
218
219 Some(BlockAnalysis {
220 items,
221 gaps,
222 warn_loose_gaps,
223 warn_tight_gaps,
224 })
225 }
226}
227
228impl Rule for MD076ListItemSpacing {
229 fn name(&self) -> &'static str {
230 "MD076"
231 }
232
233 fn description(&self) -> &'static str {
234 "List item spacing should be consistent"
235 }
236
237 fn check(&self, ctx: &LintContext) -> LintResult {
238 if ctx.content.is_empty() {
239 return Ok(Vec::new());
240 }
241
242 let mut warnings = Vec::new();
243
244 for block in &ctx.list_blocks {
245 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
246 continue;
247 };
248
249 for (i, &gap) in analysis.gaps.iter().enumerate() {
250 match gap {
251 GapKind::Structural => {
252 }
255 GapKind::Loose if analysis.warn_loose_gaps => {
256 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
258 if let Some(&blank_line) = blanks.first() {
259 let line_content = ctx
260 .line_info(blank_line)
261 .map(|li| li.content(ctx.content))
262 .unwrap_or("");
263 warnings.push(LintWarning {
264 rule_name: Some(self.name().to_string()),
265 line: blank_line,
266 column: 1,
267 end_line: blank_line,
268 end_column: line_content.len() + 1,
269 message: "Unexpected blank line between list items".to_string(),
270 severity: Severity::Warning,
271 fix: None,
272 });
273 }
274 }
275 GapKind::Tight if analysis.warn_tight_gaps => {
276 let next_item = analysis.items[i + 1];
278 let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
279 warnings.push(LintWarning {
280 rule_name: Some(self.name().to_string()),
281 line: next_item,
282 column: 1,
283 end_line: next_item,
284 end_column: line_content.len() + 1,
285 message: "Missing blank line between list items".to_string(),
286 severity: Severity::Warning,
287 fix: None,
288 });
289 }
290 _ => {}
291 }
292 }
293 }
294
295 Ok(warnings)
296 }
297
298 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
299 if ctx.content.is_empty() {
300 return Ok(ctx.content.to_string());
301 }
302
303 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
305 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
306
307 for block in &ctx.list_blocks {
308 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
309 continue;
310 };
311
312 for (i, &gap) in analysis.gaps.iter().enumerate() {
313 match gap {
314 GapKind::Structural => {
315 }
317 GapKind::Loose if analysis.warn_loose_gaps => {
318 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
320 remove_lines.insert(blank_line);
321 }
322 }
323 GapKind::Tight if analysis.warn_tight_gaps => {
324 insert_before.insert(analysis.items[i + 1]);
325 }
326 _ => {}
327 }
328 }
329 }
330
331 if insert_before.is_empty() && remove_lines.is_empty() {
332 return Ok(ctx.content.to_string());
333 }
334
335 let lines = ctx.raw_lines();
336 let mut result: Vec<String> = Vec::with_capacity(lines.len());
337
338 for (i, line) in lines.iter().enumerate() {
339 let line_num = i + 1;
340
341 if remove_lines.contains(&line_num) {
342 continue;
343 }
344
345 if insert_before.contains(&line_num) {
346 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
347 result.push(bq_prefix);
348 }
349
350 result.push((*line).to_string());
351 }
352
353 let mut output = result.join("\n");
354 if ctx.content.ends_with('\n') {
355 output.push('\n');
356 }
357 Ok(output)
358 }
359
360 fn as_any(&self) -> &dyn std::any::Any {
361 self
362 }
363
364 fn default_config_section(&self) -> Option<(String, toml::Value)> {
365 let mut map = toml::map::Map::new();
366 let style_str = match self.config.style {
367 ListItemSpacingStyle::Consistent => "consistent",
368 ListItemSpacingStyle::Loose => "loose",
369 ListItemSpacingStyle::Tight => "tight",
370 };
371 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
372 Some((self.name().to_string(), toml::Value::Table(map)))
373 }
374
375 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
376 where
377 Self: Sized,
378 {
379 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
380 .unwrap_or_else(|| "consistent".to_string());
381 let style = match style.as_str() {
382 "loose" => ListItemSpacingStyle::Loose,
383 "tight" => ListItemSpacingStyle::Tight,
384 _ => ListItemSpacingStyle::Consistent,
385 };
386 Box::new(Self::new(style))
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396 let rule = MD076ListItemSpacing::new(style);
397 rule.check(&ctx).unwrap()
398 }
399
400 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let rule = MD076ListItemSpacing::new(style);
403 rule.fix(&ctx).unwrap()
404 }
405
406 #[test]
409 fn tight_list_tight_style_no_warnings() {
410 let content = "- Item 1\n- Item 2\n- Item 3\n";
411 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
412 }
413
414 #[test]
415 fn loose_list_loose_style_no_warnings() {
416 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
417 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
418 }
419
420 #[test]
421 fn tight_list_loose_style_warns() {
422 let content = "- Item 1\n- Item 2\n- Item 3\n";
423 let warnings = check(content, ListItemSpacingStyle::Loose);
424 assert_eq!(warnings.len(), 2);
425 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
426 }
427
428 #[test]
429 fn loose_list_tight_style_warns() {
430 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
431 let warnings = check(content, ListItemSpacingStyle::Tight);
432 assert_eq!(warnings.len(), 2);
433 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
434 }
435
436 #[test]
439 fn consistent_all_tight_no_warnings() {
440 let content = "- Item 1\n- Item 2\n- Item 3\n";
441 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
442 }
443
444 #[test]
445 fn consistent_all_loose_no_warnings() {
446 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
447 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
448 }
449
450 #[test]
451 fn consistent_mixed_majority_loose_warns_tight() {
452 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
454 let warnings = check(content, ListItemSpacingStyle::Consistent);
455 assert_eq!(warnings.len(), 1);
456 assert!(warnings[0].message.contains("Missing"));
457 }
458
459 #[test]
460 fn consistent_mixed_majority_tight_warns_loose() {
461 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
463 let warnings = check(content, ListItemSpacingStyle::Consistent);
464 assert_eq!(warnings.len(), 1);
465 assert!(warnings[0].message.contains("Unexpected"));
466 }
467
468 #[test]
469 fn consistent_tie_prefers_loose() {
470 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
471 let warnings = check(content, ListItemSpacingStyle::Consistent);
472 assert_eq!(warnings.len(), 1);
473 assert!(warnings[0].message.contains("Missing"));
474 }
475
476 #[test]
479 fn single_item_list_no_warnings() {
480 let content = "- Only item\n";
481 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
482 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
483 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
484 }
485
486 #[test]
487 fn empty_content_no_warnings() {
488 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
489 }
490
491 #[test]
492 fn ordered_list_tight_gaps_loose_style_warns() {
493 let content = "1. First\n2. Second\n3. Third\n";
494 let warnings = check(content, ListItemSpacingStyle::Loose);
495 assert_eq!(warnings.len(), 2);
496 }
497
498 #[test]
499 fn task_list_works() {
500 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
501 let warnings = check(content, ListItemSpacingStyle::Loose);
502 assert_eq!(warnings.len(), 2);
503 let fixed = fix(content, ListItemSpacingStyle::Loose);
504 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
505 }
506
507 #[test]
508 fn no_trailing_newline() {
509 let content = "- Item 1\n- Item 2";
510 let warnings = check(content, ListItemSpacingStyle::Loose);
511 assert_eq!(warnings.len(), 1);
512 let fixed = fix(content, ListItemSpacingStyle::Loose);
513 assert_eq!(fixed, "- Item 1\n\n- Item 2");
514 }
515
516 #[test]
517 fn two_separate_lists() {
518 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
519 let warnings = check(content, ListItemSpacingStyle::Loose);
520 assert_eq!(warnings.len(), 2);
521 let fixed = fix(content, ListItemSpacingStyle::Loose);
522 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
523 }
524
525 #[test]
526 fn no_list_content() {
527 let content = "Just a paragraph.\n\nAnother paragraph.\n";
528 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
529 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
530 }
531
532 #[test]
535 fn continuation_lines_tight_detected() {
536 let content = "- Item 1\n continuation\n- Item 2\n";
537 let warnings = check(content, ListItemSpacingStyle::Loose);
538 assert_eq!(warnings.len(), 1);
539 assert!(warnings[0].message.contains("Missing"));
540 }
541
542 #[test]
543 fn continuation_lines_loose_detected() {
544 let content = "- Item 1\n continuation\n\n- Item 2\n";
545 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
546 let warnings = check(content, ListItemSpacingStyle::Tight);
547 assert_eq!(warnings.len(), 1);
548 assert!(warnings[0].message.contains("Unexpected"));
549 }
550
551 #[test]
552 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
553 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
556 let warnings = check(content, ListItemSpacingStyle::Tight);
558 assert_eq!(
559 warnings.len(),
560 1,
561 "Should warn only on the inter-item blank, not the intra-item blank"
562 );
563 let fixed = fix(content, ListItemSpacingStyle::Tight);
566 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
567 }
568
569 #[test]
570 fn multi_paragraph_item_loose_style_no_warnings() {
571 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
573 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
574 }
575
576 #[test]
579 fn blockquote_tight_list_loose_style_warns() {
580 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
581 let warnings = check(content, ListItemSpacingStyle::Loose);
582 assert_eq!(warnings.len(), 2);
583 }
584
585 #[test]
586 fn blockquote_loose_list_detected() {
587 let content = "> - Item 1\n>\n> - Item 2\n";
589 let warnings = check(content, ListItemSpacingStyle::Tight);
590 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
591 assert!(warnings[0].message.contains("Unexpected"));
592 }
593
594 #[test]
595 fn blockquote_loose_list_no_warnings_when_loose() {
596 let content = "> - Item 1\n>\n> - Item 2\n";
597 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
598 }
599
600 #[test]
603 fn multiple_blanks_all_removed() {
604 let content = "- Item 1\n\n\n- Item 2\n";
605 let fixed = fix(content, ListItemSpacingStyle::Tight);
606 assert_eq!(fixed, "- Item 1\n- Item 2\n");
607 }
608
609 #[test]
610 fn multiple_blanks_fix_is_idempotent() {
611 let content = "- Item 1\n\n\n\n- Item 2\n";
612 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
613 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
614 assert_eq!(fixed_once, fixed_twice);
615 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
616 }
617
618 #[test]
621 fn fix_adds_blank_lines() {
622 let content = "- Item 1\n- Item 2\n- Item 3\n";
623 let fixed = fix(content, ListItemSpacingStyle::Loose);
624 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
625 }
626
627 #[test]
628 fn fix_removes_blank_lines() {
629 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
630 let fixed = fix(content, ListItemSpacingStyle::Tight);
631 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
632 }
633
634 #[test]
635 fn fix_consistent_adds_blank() {
636 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
638 let fixed = fix(content, ListItemSpacingStyle::Consistent);
639 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
640 }
641
642 #[test]
643 fn fix_idempotent_loose() {
644 let content = "- Item 1\n- Item 2\n";
645 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
646 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
647 assert_eq!(fixed_once, fixed_twice);
648 }
649
650 #[test]
651 fn fix_idempotent_tight() {
652 let content = "- Item 1\n\n- Item 2\n";
653 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
654 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
655 assert_eq!(fixed_once, fixed_twice);
656 }
657
658 #[test]
661 fn nested_list_does_not_affect_parent() {
662 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
664 let warnings = check(content, ListItemSpacingStyle::Tight);
665 assert!(
666 warnings.is_empty(),
667 "Nested items should not cause parent-level warnings"
668 );
669 }
670
671 #[test]
674 fn code_block_in_tight_list_no_false_positive() {
675 let content = "\
677- Item 1 with code:
678
679 ```python
680 print('hello')
681 ```
682
683- Item 2 simple.
684- Item 3 simple.
685";
686 assert!(
687 check(content, ListItemSpacingStyle::Consistent).is_empty(),
688 "Structural blank after code block should not make item 1 appear loose"
689 );
690 }
691
692 #[test]
693 fn table_in_tight_list_no_false_positive() {
694 let content = "\
696- Item 1 with table:
697
698 | Col 1 | Col 2 |
699 |-------|-------|
700 | A | B |
701
702- Item 2 simple.
703- Item 3 simple.
704";
705 assert!(
706 check(content, ListItemSpacingStyle::Consistent).is_empty(),
707 "Structural blank after table should not make item 1 appear loose"
708 );
709 }
710
711 #[test]
712 fn html_block_in_tight_list_no_false_positive() {
713 let content = "\
714- Item 1 with HTML:
715
716 <details>
717 <summary>Click</summary>
718 Content
719 </details>
720
721- Item 2 simple.
722- Item 3 simple.
723";
724 assert!(
725 check(content, ListItemSpacingStyle::Consistent).is_empty(),
726 "Structural blank after HTML block should not make item 1 appear loose"
727 );
728 }
729
730 #[test]
731 fn mixed_code_and_table_in_tight_list() {
732 let content = "\
7331. Item with code:
734
735 ```markdown
736 This is some Markdown
737 ```
738
7391. Simple item.
7401. Item with table:
741
742 | Col 1 | Col 2 |
743 |:------|:------|
744 | Row 1 | Row 1 |
745 | Row 2 | Row 2 |
746";
747 assert!(
748 check(content, ListItemSpacingStyle::Consistent).is_empty(),
749 "Mix of code blocks and tables should not cause false positives"
750 );
751 }
752
753 #[test]
754 fn code_block_with_genuinely_loose_gaps_still_warns() {
755 let content = "\
758- Item 1:
759
760 ```bash
761 echo hi
762 ```
763
764- Item 2
765
766- Item 3
767- Item 4
768";
769 let warnings = check(content, ListItemSpacingStyle::Consistent);
770 assert!(
771 !warnings.is_empty(),
772 "Genuine inconsistency with code blocks should still be flagged"
773 );
774 }
775
776 #[test]
777 fn all_items_have_code_blocks_no_warnings() {
778 let content = "\
779- Item 1:
780
781 ```python
782 print(1)
783 ```
784
785- Item 2:
786
787 ```python
788 print(2)
789 ```
790
791- Item 3:
792
793 ```python
794 print(3)
795 ```
796";
797 assert!(
798 check(content, ListItemSpacingStyle::Consistent).is_empty(),
799 "All items with code blocks should be consistently tight"
800 );
801 }
802
803 #[test]
804 fn tilde_fence_code_block_in_list() {
805 let content = "\
806- Item 1:
807
808 ~~~
809 code here
810 ~~~
811
812- Item 2 simple.
813- Item 3 simple.
814";
815 assert!(
816 check(content, ListItemSpacingStyle::Consistent).is_empty(),
817 "Tilde fences should be recognized as structural content"
818 );
819 }
820
821 #[test]
822 fn nested_list_with_code_block() {
823 let content = "\
824- Item 1
825 - Nested with code:
826
827 ```
828 nested code
829 ```
830
831 - Nested simple.
832- Item 2
833";
834 assert!(
835 check(content, ListItemSpacingStyle::Consistent).is_empty(),
836 "Nested list with code block should not cause false positives"
837 );
838 }
839
840 #[test]
841 fn tight_style_with_code_block_no_warnings() {
842 let content = "\
843- Item 1:
844
845 ```
846 code
847 ```
848
849- Item 2.
850- Item 3.
851";
852 assert!(
853 check(content, ListItemSpacingStyle::Tight).is_empty(),
854 "Tight style should not warn about structural blanks around code blocks"
855 );
856 }
857
858 #[test]
859 fn loose_style_with_code_block_missing_separator() {
860 let content = "\
863- Item 1:
864
865 ```
866 code
867 ```
868
869- Item 2.
870- Item 3.
871";
872 let warnings = check(content, ListItemSpacingStyle::Loose);
873 assert_eq!(
874 warnings.len(),
875 1,
876 "Loose style should still require blank between simple items"
877 );
878 assert!(warnings[0].message.contains("Missing"));
879 }
880
881 #[test]
882 fn blockquote_list_with_code_block() {
883 let content = "\
884> - Item 1:
885>
886> ```
887> code
888> ```
889>
890> - Item 2.
891> - Item 3.
892";
893 assert!(
894 check(content, ListItemSpacingStyle::Consistent).is_empty(),
895 "Blockquote-prefixed list with code block should not cause false positives"
896 );
897 }
898
899 #[test]
902 fn indented_code_block_in_list_no_false_positive() {
903 let content = "\
9061. Item with indented code:
907
908 some code here
909 more code
910
9111. Simple item
9121. Another item
913";
914 assert!(
915 check(content, ListItemSpacingStyle::Consistent).is_empty(),
916 "Structural blank after indented code block should not make item 1 appear loose"
917 );
918 }
919
920 #[test]
923 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
924 let content = "\
9291. Item with code in middle:
930
931 ```
932 code
933 ```
934
935 Some text after the code block.
936
9371. Simple item
9381. Another item
939";
940 let warnings = check(content, ListItemSpacingStyle::Consistent);
941 assert!(
942 !warnings.is_empty(),
943 "Blank line after regular text (not structural content) is a genuine loose gap"
944 );
945 }
946
947 #[test]
950 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
951 let content = "\
954- Item 1:
955
956 ```
957 code
958 ```
959
960- Item 2.
961- Item 3.
962";
963 let fixed = fix(content, ListItemSpacingStyle::Tight);
964 assert_eq!(
965 fixed, content,
966 "Tight fix should not remove structural blanks around code blocks"
967 );
968 }
969
970 #[test]
973 fn four_space_indented_fence_in_loose_list_no_false_positive() {
974 let content = "\
9791. First item
980
9811. Second item with code block:
982
983 ```json
984 {\"key\": \"value\"}
985 ```
986
9871. Third item
988";
989 assert!(
990 check(content, ListItemSpacingStyle::Consistent).is_empty(),
991 "Structural blank after 4-space indented code block should not cause false positive"
992 );
993 }
994
995 #[test]
996 fn four_space_indented_fence_tight_style_no_warnings() {
997 let content = "\
9981. First item
9991. Second item with code block:
1000
1001 ```json
1002 {\"key\": \"value\"}
1003 ```
1004
10051. Third item
1006";
1007 assert!(
1008 check(content, ListItemSpacingStyle::Tight).is_empty(),
1009 "Tight style should not warn about structural blanks with 4-space fences"
1010 );
1011 }
1012
1013 #[test]
1014 fn four_space_indented_fence_loose_style_no_warnings() {
1015 let content = "\
10171. First item
1018
10191. Second item with code block:
1020
1021 ```json
1022 {\"key\": \"value\"}
1023 ```
1024
10251. Third item
1026";
1027 assert!(
1028 check(content, ListItemSpacingStyle::Loose).is_empty(),
1029 "Loose style should not warn when structural gaps are the only non-loose gaps"
1030 );
1031 }
1032
1033 #[test]
1034 fn structural_gap_with_genuine_inconsistency_still_warns() {
1035 let content = "\
10381. First item with code:
1039
1040 ```json
1041 {\"key\": \"value\"}
1042 ```
1043
10441. Second item
1045
10461. Third item
10471. Fourth item
1048";
1049 let warnings = check(content, ListItemSpacingStyle::Consistent);
1050 assert!(
1051 !warnings.is_empty(),
1052 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1053 );
1054 }
1055
1056 #[test]
1057 fn four_space_fence_fix_is_idempotent() {
1058 let content = "\
10611. First item
1062
10631. Second item with code block:
1064
1065 ```json
1066 {\"key\": \"value\"}
1067 ```
1068
10691. Third item
1070";
1071 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1072 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1073 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1074 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1075 }
1076
1077 #[test]
1078 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1079 let content = "\
10821. First item
10831. Second item with code block:
1084
1085 ```json
1086 {\"key\": \"value\"}
1087 ```
1088
10891. Third item
1090";
1091 let fixed = fix(content, ListItemSpacingStyle::Tight);
1092 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1093 }
1094
1095 #[test]
1096 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1097 let content = "\
11001. First item
1101
11021. Second item with code block:
1103
1104 ```json
1105 {\"key\": \"value\"}
1106 ```
1107
11081. Third item
1109";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1111 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1112 let warnings = rule.check(&ctx).unwrap();
1113 assert!(
1114 warnings.is_empty(),
1115 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1116 );
1117 }
1118
1119 #[test]
1122 fn default_config_section_provides_style_key() {
1123 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1124 let section = rule.default_config_section();
1125 assert!(section.is_some());
1126 let (name, value) = section.unwrap();
1127 assert_eq!(name, "MD076");
1128 if let toml::Value::Table(map) = value {
1129 assert!(map.contains_key("style"));
1130 } else {
1131 panic!("Expected Table value from default_config_section");
1132 }
1133 }
1134}