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 ctx.is_rule_disabled(self.name(), line_num) {
343 result.push((*line).to_string());
344 continue;
345 }
346
347 if remove_lines.contains(&line_num) {
348 continue;
349 }
350
351 if insert_before.contains(&line_num) {
352 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
353 result.push(bq_prefix);
354 }
355
356 result.push((*line).to_string());
357 }
358
359 let mut output = result.join("\n");
360 if ctx.content.ends_with('\n') {
361 output.push('\n');
362 }
363 Ok(output)
364 }
365
366 fn as_any(&self) -> &dyn std::any::Any {
367 self
368 }
369
370 fn default_config_section(&self) -> Option<(String, toml::Value)> {
371 let mut map = toml::map::Map::new();
372 let style_str = match self.config.style {
373 ListItemSpacingStyle::Consistent => "consistent",
374 ListItemSpacingStyle::Loose => "loose",
375 ListItemSpacingStyle::Tight => "tight",
376 };
377 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
378 Some((self.name().to_string(), toml::Value::Table(map)))
379 }
380
381 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
382 where
383 Self: Sized,
384 {
385 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
386 .unwrap_or_else(|| "consistent".to_string());
387 let style = match style.as_str() {
388 "loose" => ListItemSpacingStyle::Loose,
389 "tight" => ListItemSpacingStyle::Tight,
390 _ => ListItemSpacingStyle::Consistent,
391 };
392 Box::new(Self::new(style))
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let rule = MD076ListItemSpacing::new(style);
403 rule.check(&ctx).unwrap()
404 }
405
406 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408 let rule = MD076ListItemSpacing::new(style);
409 rule.fix(&ctx).unwrap()
410 }
411
412 #[test]
415 fn tight_list_tight_style_no_warnings() {
416 let content = "- Item 1\n- Item 2\n- Item 3\n";
417 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
418 }
419
420 #[test]
421 fn loose_list_loose_style_no_warnings() {
422 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
423 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
424 }
425
426 #[test]
427 fn tight_list_loose_style_warns() {
428 let content = "- Item 1\n- Item 2\n- Item 3\n";
429 let warnings = check(content, ListItemSpacingStyle::Loose);
430 assert_eq!(warnings.len(), 2);
431 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
432 }
433
434 #[test]
435 fn loose_list_tight_style_warns() {
436 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
437 let warnings = check(content, ListItemSpacingStyle::Tight);
438 assert_eq!(warnings.len(), 2);
439 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
440 }
441
442 #[test]
445 fn consistent_all_tight_no_warnings() {
446 let content = "- Item 1\n- Item 2\n- Item 3\n";
447 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
448 }
449
450 #[test]
451 fn consistent_all_loose_no_warnings() {
452 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
453 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
454 }
455
456 #[test]
457 fn consistent_mixed_majority_loose_warns_tight() {
458 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
460 let warnings = check(content, ListItemSpacingStyle::Consistent);
461 assert_eq!(warnings.len(), 1);
462 assert!(warnings[0].message.contains("Missing"));
463 }
464
465 #[test]
466 fn consistent_mixed_majority_tight_warns_loose() {
467 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
469 let warnings = check(content, ListItemSpacingStyle::Consistent);
470 assert_eq!(warnings.len(), 1);
471 assert!(warnings[0].message.contains("Unexpected"));
472 }
473
474 #[test]
475 fn consistent_tie_prefers_loose() {
476 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
477 let warnings = check(content, ListItemSpacingStyle::Consistent);
478 assert_eq!(warnings.len(), 1);
479 assert!(warnings[0].message.contains("Missing"));
480 }
481
482 #[test]
485 fn single_item_list_no_warnings() {
486 let content = "- Only item\n";
487 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
488 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
489 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
490 }
491
492 #[test]
493 fn empty_content_no_warnings() {
494 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
495 }
496
497 #[test]
498 fn ordered_list_tight_gaps_loose_style_warns() {
499 let content = "1. First\n2. Second\n3. Third\n";
500 let warnings = check(content, ListItemSpacingStyle::Loose);
501 assert_eq!(warnings.len(), 2);
502 }
503
504 #[test]
505 fn task_list_works() {
506 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
507 let warnings = check(content, ListItemSpacingStyle::Loose);
508 assert_eq!(warnings.len(), 2);
509 let fixed = fix(content, ListItemSpacingStyle::Loose);
510 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
511 }
512
513 #[test]
514 fn no_trailing_newline() {
515 let content = "- Item 1\n- Item 2";
516 let warnings = check(content, ListItemSpacingStyle::Loose);
517 assert_eq!(warnings.len(), 1);
518 let fixed = fix(content, ListItemSpacingStyle::Loose);
519 assert_eq!(fixed, "- Item 1\n\n- Item 2");
520 }
521
522 #[test]
523 fn two_separate_lists() {
524 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
525 let warnings = check(content, ListItemSpacingStyle::Loose);
526 assert_eq!(warnings.len(), 2);
527 let fixed = fix(content, ListItemSpacingStyle::Loose);
528 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
529 }
530
531 #[test]
532 fn no_list_content() {
533 let content = "Just a paragraph.\n\nAnother paragraph.\n";
534 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
535 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
536 }
537
538 #[test]
541 fn continuation_lines_tight_detected() {
542 let content = "- Item 1\n continuation\n- Item 2\n";
543 let warnings = check(content, ListItemSpacingStyle::Loose);
544 assert_eq!(warnings.len(), 1);
545 assert!(warnings[0].message.contains("Missing"));
546 }
547
548 #[test]
549 fn continuation_lines_loose_detected() {
550 let content = "- Item 1\n continuation\n\n- Item 2\n";
551 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
552 let warnings = check(content, ListItemSpacingStyle::Tight);
553 assert_eq!(warnings.len(), 1);
554 assert!(warnings[0].message.contains("Unexpected"));
555 }
556
557 #[test]
558 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
559 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
562 let warnings = check(content, ListItemSpacingStyle::Tight);
564 assert_eq!(
565 warnings.len(),
566 1,
567 "Should warn only on the inter-item blank, not the intra-item blank"
568 );
569 let fixed = fix(content, ListItemSpacingStyle::Tight);
572 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
573 }
574
575 #[test]
576 fn multi_paragraph_item_loose_style_no_warnings() {
577 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
579 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
580 }
581
582 #[test]
585 fn blockquote_tight_list_loose_style_warns() {
586 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
587 let warnings = check(content, ListItemSpacingStyle::Loose);
588 assert_eq!(warnings.len(), 2);
589 }
590
591 #[test]
592 fn blockquote_loose_list_detected() {
593 let content = "> - Item 1\n>\n> - Item 2\n";
595 let warnings = check(content, ListItemSpacingStyle::Tight);
596 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
597 assert!(warnings[0].message.contains("Unexpected"));
598 }
599
600 #[test]
601 fn blockquote_loose_list_no_warnings_when_loose() {
602 let content = "> - Item 1\n>\n> - Item 2\n";
603 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
604 }
605
606 #[test]
609 fn multiple_blanks_all_removed() {
610 let content = "- Item 1\n\n\n- Item 2\n";
611 let fixed = fix(content, ListItemSpacingStyle::Tight);
612 assert_eq!(fixed, "- Item 1\n- Item 2\n");
613 }
614
615 #[test]
616 fn multiple_blanks_fix_is_idempotent() {
617 let content = "- Item 1\n\n\n\n- Item 2\n";
618 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
619 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
620 assert_eq!(fixed_once, fixed_twice);
621 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
622 }
623
624 #[test]
627 fn fix_adds_blank_lines() {
628 let content = "- Item 1\n- Item 2\n- Item 3\n";
629 let fixed = fix(content, ListItemSpacingStyle::Loose);
630 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
631 }
632
633 #[test]
634 fn fix_removes_blank_lines() {
635 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
636 let fixed = fix(content, ListItemSpacingStyle::Tight);
637 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
638 }
639
640 #[test]
641 fn fix_consistent_adds_blank() {
642 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
644 let fixed = fix(content, ListItemSpacingStyle::Consistent);
645 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
646 }
647
648 #[test]
649 fn fix_idempotent_loose() {
650 let content = "- Item 1\n- Item 2\n";
651 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
652 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
653 assert_eq!(fixed_once, fixed_twice);
654 }
655
656 #[test]
657 fn fix_idempotent_tight() {
658 let content = "- Item 1\n\n- Item 2\n";
659 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
660 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
661 assert_eq!(fixed_once, fixed_twice);
662 }
663
664 #[test]
667 fn nested_list_does_not_affect_parent() {
668 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
670 let warnings = check(content, ListItemSpacingStyle::Tight);
671 assert!(
672 warnings.is_empty(),
673 "Nested items should not cause parent-level warnings"
674 );
675 }
676
677 #[test]
680 fn code_block_in_tight_list_no_false_positive() {
681 let content = "\
683- Item 1 with code:
684
685 ```python
686 print('hello')
687 ```
688
689- Item 2 simple.
690- Item 3 simple.
691";
692 assert!(
693 check(content, ListItemSpacingStyle::Consistent).is_empty(),
694 "Structural blank after code block should not make item 1 appear loose"
695 );
696 }
697
698 #[test]
699 fn table_in_tight_list_no_false_positive() {
700 let content = "\
702- Item 1 with table:
703
704 | Col 1 | Col 2 |
705 |-------|-------|
706 | A | B |
707
708- Item 2 simple.
709- Item 3 simple.
710";
711 assert!(
712 check(content, ListItemSpacingStyle::Consistent).is_empty(),
713 "Structural blank after table should not make item 1 appear loose"
714 );
715 }
716
717 #[test]
718 fn html_block_in_tight_list_no_false_positive() {
719 let content = "\
720- Item 1 with HTML:
721
722 <details>
723 <summary>Click</summary>
724 Content
725 </details>
726
727- Item 2 simple.
728- Item 3 simple.
729";
730 assert!(
731 check(content, ListItemSpacingStyle::Consistent).is_empty(),
732 "Structural blank after HTML block should not make item 1 appear loose"
733 );
734 }
735
736 #[test]
737 fn mixed_code_and_table_in_tight_list() {
738 let content = "\
7391. Item with code:
740
741 ```markdown
742 This is some Markdown
743 ```
744
7451. Simple item.
7461. Item with table:
747
748 | Col 1 | Col 2 |
749 |:------|:------|
750 | Row 1 | Row 1 |
751 | Row 2 | Row 2 |
752";
753 assert!(
754 check(content, ListItemSpacingStyle::Consistent).is_empty(),
755 "Mix of code blocks and tables should not cause false positives"
756 );
757 }
758
759 #[test]
760 fn code_block_with_genuinely_loose_gaps_still_warns() {
761 let content = "\
764- Item 1:
765
766 ```bash
767 echo hi
768 ```
769
770- Item 2
771
772- Item 3
773- Item 4
774";
775 let warnings = check(content, ListItemSpacingStyle::Consistent);
776 assert!(
777 !warnings.is_empty(),
778 "Genuine inconsistency with code blocks should still be flagged"
779 );
780 }
781
782 #[test]
783 fn all_items_have_code_blocks_no_warnings() {
784 let content = "\
785- Item 1:
786
787 ```python
788 print(1)
789 ```
790
791- Item 2:
792
793 ```python
794 print(2)
795 ```
796
797- Item 3:
798
799 ```python
800 print(3)
801 ```
802";
803 assert!(
804 check(content, ListItemSpacingStyle::Consistent).is_empty(),
805 "All items with code blocks should be consistently tight"
806 );
807 }
808
809 #[test]
810 fn tilde_fence_code_block_in_list() {
811 let content = "\
812- Item 1:
813
814 ~~~
815 code here
816 ~~~
817
818- Item 2 simple.
819- Item 3 simple.
820";
821 assert!(
822 check(content, ListItemSpacingStyle::Consistent).is_empty(),
823 "Tilde fences should be recognized as structural content"
824 );
825 }
826
827 #[test]
828 fn nested_list_with_code_block() {
829 let content = "\
830- Item 1
831 - Nested with code:
832
833 ```
834 nested code
835 ```
836
837 - Nested simple.
838- Item 2
839";
840 assert!(
841 check(content, ListItemSpacingStyle::Consistent).is_empty(),
842 "Nested list with code block should not cause false positives"
843 );
844 }
845
846 #[test]
847 fn tight_style_with_code_block_no_warnings() {
848 let content = "\
849- Item 1:
850
851 ```
852 code
853 ```
854
855- Item 2.
856- Item 3.
857";
858 assert!(
859 check(content, ListItemSpacingStyle::Tight).is_empty(),
860 "Tight style should not warn about structural blanks around code blocks"
861 );
862 }
863
864 #[test]
865 fn loose_style_with_code_block_missing_separator() {
866 let content = "\
869- Item 1:
870
871 ```
872 code
873 ```
874
875- Item 2.
876- Item 3.
877";
878 let warnings = check(content, ListItemSpacingStyle::Loose);
879 assert_eq!(
880 warnings.len(),
881 1,
882 "Loose style should still require blank between simple items"
883 );
884 assert!(warnings[0].message.contains("Missing"));
885 }
886
887 #[test]
888 fn blockquote_list_with_code_block() {
889 let content = "\
890> - Item 1:
891>
892> ```
893> code
894> ```
895>
896> - Item 2.
897> - Item 3.
898";
899 assert!(
900 check(content, ListItemSpacingStyle::Consistent).is_empty(),
901 "Blockquote-prefixed list with code block should not cause false positives"
902 );
903 }
904
905 #[test]
908 fn indented_code_block_in_list_no_false_positive() {
909 let content = "\
9121. Item with indented code:
913
914 some code here
915 more code
916
9171. Simple item
9181. Another item
919";
920 assert!(
921 check(content, ListItemSpacingStyle::Consistent).is_empty(),
922 "Structural blank after indented code block should not make item 1 appear loose"
923 );
924 }
925
926 #[test]
929 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
930 let content = "\
9351. Item with code in middle:
936
937 ```
938 code
939 ```
940
941 Some text after the code block.
942
9431. Simple item
9441. Another item
945";
946 let warnings = check(content, ListItemSpacingStyle::Consistent);
947 assert!(
948 !warnings.is_empty(),
949 "Blank line after regular text (not structural content) is a genuine loose gap"
950 );
951 }
952
953 #[test]
956 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
957 let content = "\
960- Item 1:
961
962 ```
963 code
964 ```
965
966- Item 2.
967- Item 3.
968";
969 let fixed = fix(content, ListItemSpacingStyle::Tight);
970 assert_eq!(
971 fixed, content,
972 "Tight fix should not remove structural blanks around code blocks"
973 );
974 }
975
976 #[test]
979 fn four_space_indented_fence_in_loose_list_no_false_positive() {
980 let content = "\
9851. First item
986
9871. Second item with code block:
988
989 ```json
990 {\"key\": \"value\"}
991 ```
992
9931. Third item
994";
995 assert!(
996 check(content, ListItemSpacingStyle::Consistent).is_empty(),
997 "Structural blank after 4-space indented code block should not cause false positive"
998 );
999 }
1000
1001 #[test]
1002 fn four_space_indented_fence_tight_style_no_warnings() {
1003 let content = "\
10041. First item
10051. Second item with code block:
1006
1007 ```json
1008 {\"key\": \"value\"}
1009 ```
1010
10111. Third item
1012";
1013 assert!(
1014 check(content, ListItemSpacingStyle::Tight).is_empty(),
1015 "Tight style should not warn about structural blanks with 4-space fences"
1016 );
1017 }
1018
1019 #[test]
1020 fn four_space_indented_fence_loose_style_no_warnings() {
1021 let content = "\
10231. First item
1024
10251. Second item with code block:
1026
1027 ```json
1028 {\"key\": \"value\"}
1029 ```
1030
10311. Third item
1032";
1033 assert!(
1034 check(content, ListItemSpacingStyle::Loose).is_empty(),
1035 "Loose style should not warn when structural gaps are the only non-loose gaps"
1036 );
1037 }
1038
1039 #[test]
1040 fn structural_gap_with_genuine_inconsistency_still_warns() {
1041 let content = "\
10441. First item with code:
1045
1046 ```json
1047 {\"key\": \"value\"}
1048 ```
1049
10501. Second item
1051
10521. Third item
10531. Fourth item
1054";
1055 let warnings = check(content, ListItemSpacingStyle::Consistent);
1056 assert!(
1057 !warnings.is_empty(),
1058 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1059 );
1060 }
1061
1062 #[test]
1063 fn four_space_fence_fix_is_idempotent() {
1064 let content = "\
10671. First item
1068
10691. Second item with code block:
1070
1071 ```json
1072 {\"key\": \"value\"}
1073 ```
1074
10751. Third item
1076";
1077 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1078 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1079 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1080 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1081 }
1082
1083 #[test]
1084 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1085 let content = "\
10881. First item
10891. Second item with code block:
1090
1091 ```json
1092 {\"key\": \"value\"}
1093 ```
1094
10951. Third item
1096";
1097 let fixed = fix(content, ListItemSpacingStyle::Tight);
1098 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1099 }
1100
1101 #[test]
1102 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1103 let content = "\
11061. First item
1107
11081. Second item with code block:
1109
1110 ```json
1111 {\"key\": \"value\"}
1112 ```
1113
11141. Third item
1115";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1117 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1118 let warnings = rule.check(&ctx).unwrap();
1119 assert!(
1120 warnings.is_empty(),
1121 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1122 );
1123 }
1124
1125 #[test]
1128 fn code_block_in_second_item_detects_inconsistency() {
1129 let content = "\
1132# Test
1133
1134- Lorem ipsum dolor sit amet.
1135- Lorem ipsum dolor sit amet.
1136
1137 ```yaml
1138 hello: world
1139 ```
1140
1141- Lorem ipsum dolor sit amet.
1142
1143- Lorem ipsum dolor sit amet.
1144";
1145 let warnings = check(content, ListItemSpacingStyle::Consistent);
1146 assert!(
1147 !warnings.is_empty(),
1148 "Should detect inconsistent spacing when code block is inside a list item"
1149 );
1150 }
1151
1152 #[test]
1153 fn code_block_in_item_all_tight_no_warnings() {
1154 let content = "\
1156- Item 1
1157- Item 2
1158
1159 ```yaml
1160 hello: world
1161 ```
1162
1163- Item 3
1164- Item 4
1165";
1166 assert!(
1167 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1168 "All tight gaps with structural code block should not warn"
1169 );
1170 }
1171
1172 #[test]
1173 fn code_block_in_item_all_loose_no_warnings() {
1174 let content = "\
1176- Item 1
1177
1178- Item 2
1179
1180 ```yaml
1181 hello: world
1182 ```
1183
1184- Item 3
1185
1186- Item 4
1187";
1188 assert!(
1189 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1190 "All loose gaps with structural code block should not warn"
1191 );
1192 }
1193
1194 #[test]
1195 fn code_block_in_ordered_list_detects_inconsistency() {
1196 let content = "\
11971. First item
11981. Second item
1199
1200 ```json
1201 {\"key\": \"value\"}
1202 ```
1203
12041. Third item
1205
12061. Fourth item
1207";
1208 let warnings = check(content, ListItemSpacingStyle::Consistent);
1209 assert!(
1210 !warnings.is_empty(),
1211 "Ordered list with code block should still detect inconsistency"
1212 );
1213 }
1214
1215 #[test]
1216 fn code_block_in_item_fix_adds_missing_blanks() {
1217 let content = "\
1219- Item 1
1220- Item 2
1221
1222 ```yaml
1223 code: here
1224 ```
1225
1226- Item 3
1227
1228- Item 4
1229";
1230 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1231 assert!(
1232 fixed.contains("- Item 1\n\n- Item 2"),
1233 "Fix should add blank line between items 1 and 2"
1234 );
1235 }
1236
1237 #[test]
1238 fn tilde_code_block_in_item_detects_inconsistency() {
1239 let content = "\
1240- Item 1
1241- Item 2
1242
1243 ~~~
1244 code
1245 ~~~
1246
1247- Item 3
1248
1249- Item 4
1250";
1251 let warnings = check(content, ListItemSpacingStyle::Consistent);
1252 assert!(
1253 !warnings.is_empty(),
1254 "Tilde code block inside item should not prevent inconsistency detection"
1255 );
1256 }
1257
1258 #[test]
1259 fn multiple_code_blocks_all_tight_no_warnings() {
1260 let content = "\
1262- Item 1
1263
1264 ```
1265 code1
1266 ```
1267
1268- Item 2
1269
1270 ```
1271 code2
1272 ```
1273
1274- Item 3
1275- Item 4
1276";
1277 assert!(
1278 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1279 "All non-structural gaps are tight, so list is consistent"
1280 );
1281 }
1282
1283 #[test]
1284 fn code_block_with_mixed_genuine_gaps_warns() {
1285 let content = "\
1287- Item 1
1288
1289 ```
1290 code1
1291 ```
1292
1293- Item 2
1294
1295- Item 3
1296- Item 4
1297";
1298 let warnings = check(content, ListItemSpacingStyle::Consistent);
1299 assert!(
1300 !warnings.is_empty(),
1301 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1302 );
1303 }
1304
1305 #[test]
1308 fn default_config_section_provides_style_key() {
1309 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1310 let section = rule.default_config_section();
1311 assert!(section.is_some());
1312 let (name, value) = section.unwrap();
1313 assert_eq!(name, "MD076");
1314 if let toml::Value::Table(map) = value {
1315 assert!(map.contains_key("style"));
1316 } else {
1317 panic!("Expected Table value from default_config_section");
1318 }
1319 }
1320}