1use crate::lint_context::LintContext;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::skip_context::is_table_line;
4
5#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub enum ListItemSpacingStyle {
25 #[default]
26 Consistent,
27 Loose,
28 Tight,
29}
30
31#[derive(Debug, Clone, Default)]
32pub struct MD076Config {
33 pub style: ListItemSpacingStyle,
34}
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 if info.blockquote.is_some() {
106 return true;
107 }
108 let content = info.content(ctx.content);
110 let effective = if let Some(ref bq) = info.blockquote {
112 bq.content.as_str()
113 } else {
114 content
115 };
116 if is_table_line(effective.trim_start()) {
117 return true;
118 }
119 }
120 false
121 }
122
123 fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
129 if next <= first + 1 {
130 return GapKind::Tight;
131 }
132 if !Self::is_effectively_blank(ctx, next - 1) {
134 return GapKind::Tight;
135 }
136 let mut scan = next - 1;
139 while scan > first && Self::is_effectively_blank(ctx, scan) {
140 scan -= 1;
141 }
142 if scan > first && Self::is_structural_content(ctx, scan) {
144 return GapKind::Structural;
145 }
146 GapKind::Loose
147 }
148
149 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
156 let mut blanks = Vec::new();
157 let mut line_num = next - 1;
158 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
159 blanks.push(line_num);
160 line_num -= 1;
161 }
162 if line_num > first && Self::is_structural_content(ctx, line_num) {
164 return Vec::new();
165 }
166 blanks.reverse();
167 blanks
168 }
169
170 fn analyze_block(
175 ctx: &LintContext,
176 block: &crate::lint_context::types::ListBlock,
177 style: &ListItemSpacingStyle,
178 ) -> Option<BlockAnalysis> {
179 let items: Vec<usize> = block
183 .item_lines
184 .iter()
185 .copied()
186 .filter(|&line_num| {
187 ctx.line_info(line_num)
188 .and_then(|li| li.list_item.as_ref())
189 .map(|item| item.marker_column / 2 == block.nesting_level)
190 .unwrap_or(false)
191 })
192 .collect();
193
194 if items.len() < 2 {
195 return None;
196 }
197
198 let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
200
201 let loose_count = gaps.iter().filter(|&&g| g == GapKind::Loose).count();
205 let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
206
207 let (warn_loose_gaps, warn_tight_gaps) = match style {
208 ListItemSpacingStyle::Loose => (false, true),
209 ListItemSpacingStyle::Tight => (true, false),
210 ListItemSpacingStyle::Consistent => {
211 if loose_count == 0 || tight_count == 0 {
212 return None; }
214 if loose_count >= tight_count {
216 (false, true)
217 } else {
218 (true, false)
219 }
220 }
221 };
222
223 Some(BlockAnalysis {
224 items,
225 gaps,
226 warn_loose_gaps,
227 warn_tight_gaps,
228 })
229 }
230}
231
232impl Rule for MD076ListItemSpacing {
233 fn name(&self) -> &'static str {
234 "MD076"
235 }
236
237 fn description(&self) -> &'static str {
238 "List item spacing should be consistent"
239 }
240
241 fn category(&self) -> RuleCategory {
242 RuleCategory::List
243 }
244
245 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
246 ctx.content.is_empty() || ctx.list_blocks.is_empty()
247 }
248
249 fn check(&self, ctx: &LintContext) -> LintResult {
250 if ctx.content.is_empty() {
251 return Ok(Vec::new());
252 }
253
254 let mut warnings = Vec::new();
255
256 for block in &ctx.list_blocks {
257 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
258 continue;
259 };
260
261 for (i, &gap) in analysis.gaps.iter().enumerate() {
262 match gap {
263 GapKind::Structural => {
264 }
267 GapKind::Loose if analysis.warn_loose_gaps => {
268 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
270 if let Some(&blank_line) = blanks.first() {
271 let line_content = ctx
272 .line_info(blank_line)
273 .map(|li| li.content(ctx.content))
274 .unwrap_or("");
275 warnings.push(LintWarning {
276 rule_name: Some(self.name().to_string()),
277 line: blank_line,
278 column: 1,
279 end_line: blank_line,
280 end_column: line_content.len() + 1,
281 message: "Unexpected blank line between list items".to_string(),
282 severity: Severity::Warning,
283 fix: None,
284 });
285 }
286 }
287 GapKind::Tight if analysis.warn_tight_gaps => {
288 let next_item = analysis.items[i + 1];
290 let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
291 warnings.push(LintWarning {
292 rule_name: Some(self.name().to_string()),
293 line: next_item,
294 column: 1,
295 end_line: next_item,
296 end_column: line_content.len() + 1,
297 message: "Missing blank line between list items".to_string(),
298 severity: Severity::Warning,
299 fix: None,
300 });
301 }
302 _ => {}
303 }
304 }
305 }
306
307 Ok(warnings)
308 }
309
310 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
311 if ctx.content.is_empty() {
312 return Ok(ctx.content.to_string());
313 }
314
315 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
317 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
318
319 for block in &ctx.list_blocks {
320 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
321 continue;
322 };
323
324 for (i, &gap) in analysis.gaps.iter().enumerate() {
325 match gap {
326 GapKind::Structural => {
327 }
329 GapKind::Loose if analysis.warn_loose_gaps => {
330 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
332 remove_lines.insert(blank_line);
333 }
334 }
335 GapKind::Tight if analysis.warn_tight_gaps => {
336 insert_before.insert(analysis.items[i + 1]);
337 }
338 _ => {}
339 }
340 }
341 }
342
343 if insert_before.is_empty() && remove_lines.is_empty() {
344 return Ok(ctx.content.to_string());
345 }
346
347 let lines = ctx.raw_lines();
348 let mut result: Vec<String> = Vec::with_capacity(lines.len());
349
350 for (i, line) in lines.iter().enumerate() {
351 let line_num = i + 1;
352
353 if ctx.is_rule_disabled(self.name(), line_num) {
355 result.push((*line).to_string());
356 continue;
357 }
358
359 if remove_lines.contains(&line_num) {
360 continue;
361 }
362
363 if insert_before.contains(&line_num) {
364 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
365 result.push(bq_prefix);
366 }
367
368 result.push((*line).to_string());
369 }
370
371 let mut output = result.join("\n");
372 if ctx.content.ends_with('\n') {
373 output.push('\n');
374 }
375 Ok(output)
376 }
377
378 fn as_any(&self) -> &dyn std::any::Any {
379 self
380 }
381
382 fn default_config_section(&self) -> Option<(String, toml::Value)> {
383 let mut map = toml::map::Map::new();
384 let style_str = match self.config.style {
385 ListItemSpacingStyle::Consistent => "consistent",
386 ListItemSpacingStyle::Loose => "loose",
387 ListItemSpacingStyle::Tight => "tight",
388 };
389 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
390 Some((self.name().to_string(), toml::Value::Table(map)))
391 }
392
393 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394 where
395 Self: Sized,
396 {
397 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
398 .unwrap_or_else(|| "consistent".to_string());
399 let style = match style.as_str() {
400 "loose" => ListItemSpacingStyle::Loose,
401 "tight" => ListItemSpacingStyle::Tight,
402 _ => ListItemSpacingStyle::Consistent,
403 };
404 Box::new(Self::new(style))
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let rule = MD076ListItemSpacing::new(style);
415 rule.check(&ctx).unwrap()
416 }
417
418 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let rule = MD076ListItemSpacing::new(style);
421 rule.fix(&ctx).unwrap()
422 }
423
424 #[test]
427 fn tight_list_tight_style_no_warnings() {
428 let content = "- Item 1\n- Item 2\n- Item 3\n";
429 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
430 }
431
432 #[test]
433 fn loose_list_loose_style_no_warnings() {
434 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
435 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
436 }
437
438 #[test]
439 fn tight_list_loose_style_warns() {
440 let content = "- Item 1\n- Item 2\n- Item 3\n";
441 let warnings = check(content, ListItemSpacingStyle::Loose);
442 assert_eq!(warnings.len(), 2);
443 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
444 }
445
446 #[test]
447 fn loose_list_tight_style_warns() {
448 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
449 let warnings = check(content, ListItemSpacingStyle::Tight);
450 assert_eq!(warnings.len(), 2);
451 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
452 }
453
454 #[test]
457 fn consistent_all_tight_no_warnings() {
458 let content = "- Item 1\n- Item 2\n- Item 3\n";
459 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
460 }
461
462 #[test]
463 fn consistent_all_loose_no_warnings() {
464 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
465 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
466 }
467
468 #[test]
469 fn consistent_mixed_majority_loose_warns_tight() {
470 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
472 let warnings = check(content, ListItemSpacingStyle::Consistent);
473 assert_eq!(warnings.len(), 1);
474 assert!(warnings[0].message.contains("Missing"));
475 }
476
477 #[test]
478 fn consistent_mixed_majority_tight_warns_loose() {
479 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
481 let warnings = check(content, ListItemSpacingStyle::Consistent);
482 assert_eq!(warnings.len(), 1);
483 assert!(warnings[0].message.contains("Unexpected"));
484 }
485
486 #[test]
487 fn consistent_tie_prefers_loose() {
488 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
489 let warnings = check(content, ListItemSpacingStyle::Consistent);
490 assert_eq!(warnings.len(), 1);
491 assert!(warnings[0].message.contains("Missing"));
492 }
493
494 #[test]
497 fn single_item_list_no_warnings() {
498 let content = "- Only item\n";
499 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
500 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
501 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
502 }
503
504 #[test]
505 fn empty_content_no_warnings() {
506 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
507 }
508
509 #[test]
510 fn ordered_list_tight_gaps_loose_style_warns() {
511 let content = "1. First\n2. Second\n3. Third\n";
512 let warnings = check(content, ListItemSpacingStyle::Loose);
513 assert_eq!(warnings.len(), 2);
514 }
515
516 #[test]
517 fn task_list_works() {
518 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\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, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
523 }
524
525 #[test]
526 fn no_trailing_newline() {
527 let content = "- Item 1\n- Item 2";
528 let warnings = check(content, ListItemSpacingStyle::Loose);
529 assert_eq!(warnings.len(), 1);
530 let fixed = fix(content, ListItemSpacingStyle::Loose);
531 assert_eq!(fixed, "- Item 1\n\n- Item 2");
532 }
533
534 #[test]
535 fn two_separate_lists() {
536 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
537 let warnings = check(content, ListItemSpacingStyle::Loose);
538 assert_eq!(warnings.len(), 2);
539 let fixed = fix(content, ListItemSpacingStyle::Loose);
540 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
541 }
542
543 #[test]
544 fn no_list_content() {
545 let content = "Just a paragraph.\n\nAnother paragraph.\n";
546 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
547 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
548 }
549
550 #[test]
553 fn continuation_lines_tight_detected() {
554 let content = "- Item 1\n continuation\n- Item 2\n";
555 let warnings = check(content, ListItemSpacingStyle::Loose);
556 assert_eq!(warnings.len(), 1);
557 assert!(warnings[0].message.contains("Missing"));
558 }
559
560 #[test]
561 fn continuation_lines_loose_detected() {
562 let content = "- Item 1\n continuation\n\n- Item 2\n";
563 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
564 let warnings = check(content, ListItemSpacingStyle::Tight);
565 assert_eq!(warnings.len(), 1);
566 assert!(warnings[0].message.contains("Unexpected"));
567 }
568
569 #[test]
570 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
571 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
574 let warnings = check(content, ListItemSpacingStyle::Tight);
576 assert_eq!(
577 warnings.len(),
578 1,
579 "Should warn only on the inter-item blank, not the intra-item blank"
580 );
581 let fixed = fix(content, ListItemSpacingStyle::Tight);
584 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
585 }
586
587 #[test]
588 fn multi_paragraph_item_loose_style_no_warnings() {
589 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
591 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
592 }
593
594 #[test]
597 fn blockquote_tight_list_loose_style_warns() {
598 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
599 let warnings = check(content, ListItemSpacingStyle::Loose);
600 assert_eq!(warnings.len(), 2);
601 }
602
603 #[test]
604 fn blockquote_loose_list_detected() {
605 let content = "> - Item 1\n>\n> - Item 2\n";
607 let warnings = check(content, ListItemSpacingStyle::Tight);
608 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
609 assert!(warnings[0].message.contains("Unexpected"));
610 }
611
612 #[test]
613 fn blockquote_loose_list_no_warnings_when_loose() {
614 let content = "> - Item 1\n>\n> - Item 2\n";
615 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
616 }
617
618 #[test]
621 fn multiple_blanks_all_removed() {
622 let content = "- Item 1\n\n\n- Item 2\n";
623 let fixed = fix(content, ListItemSpacingStyle::Tight);
624 assert_eq!(fixed, "- Item 1\n- Item 2\n");
625 }
626
627 #[test]
628 fn multiple_blanks_fix_is_idempotent() {
629 let content = "- Item 1\n\n\n\n- Item 2\n";
630 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
631 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
632 assert_eq!(fixed_once, fixed_twice);
633 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
634 }
635
636 #[test]
639 fn fix_adds_blank_lines() {
640 let content = "- Item 1\n- Item 2\n- Item 3\n";
641 let fixed = fix(content, ListItemSpacingStyle::Loose);
642 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
643 }
644
645 #[test]
646 fn fix_removes_blank_lines() {
647 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
648 let fixed = fix(content, ListItemSpacingStyle::Tight);
649 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
650 }
651
652 #[test]
653 fn fix_consistent_adds_blank() {
654 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
656 let fixed = fix(content, ListItemSpacingStyle::Consistent);
657 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
658 }
659
660 #[test]
661 fn fix_idempotent_loose() {
662 let content = "- Item 1\n- Item 2\n";
663 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
664 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
665 assert_eq!(fixed_once, fixed_twice);
666 }
667
668 #[test]
669 fn fix_idempotent_tight() {
670 let content = "- Item 1\n\n- Item 2\n";
671 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
672 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
673 assert_eq!(fixed_once, fixed_twice);
674 }
675
676 #[test]
679 fn nested_list_does_not_affect_parent() {
680 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
682 let warnings = check(content, ListItemSpacingStyle::Tight);
683 assert!(
684 warnings.is_empty(),
685 "Nested items should not cause parent-level warnings"
686 );
687 }
688
689 #[test]
692 fn code_block_in_tight_list_no_false_positive() {
693 let content = "\
695- Item 1 with code:
696
697 ```python
698 print('hello')
699 ```
700
701- Item 2 simple.
702- Item 3 simple.
703";
704 assert!(
705 check(content, ListItemSpacingStyle::Consistent).is_empty(),
706 "Structural blank after code block should not make item 1 appear loose"
707 );
708 }
709
710 #[test]
711 fn table_in_tight_list_no_false_positive() {
712 let content = "\
714- Item 1 with table:
715
716 | Col 1 | Col 2 |
717 |-------|-------|
718 | A | B |
719
720- Item 2 simple.
721- Item 3 simple.
722";
723 assert!(
724 check(content, ListItemSpacingStyle::Consistent).is_empty(),
725 "Structural blank after table should not make item 1 appear loose"
726 );
727 }
728
729 #[test]
730 fn html_block_in_tight_list_no_false_positive() {
731 let content = "\
732- Item 1 with HTML:
733
734 <details>
735 <summary>Click</summary>
736 Content
737 </details>
738
739- Item 2 simple.
740- Item 3 simple.
741";
742 assert!(
743 check(content, ListItemSpacingStyle::Consistent).is_empty(),
744 "Structural blank after HTML block should not make item 1 appear loose"
745 );
746 }
747
748 #[test]
749 fn blockquote_in_tight_list_no_false_positive() {
750 let content = "\
752- Item 1 with quote:
753
754 > This is a blockquote
755 > with multiple lines.
756
757- Item 2 simple.
758- Item 3 simple.
759";
760 assert!(
761 check(content, ListItemSpacingStyle::Consistent).is_empty(),
762 "Structural blank around blockquote should not make item 1 appear loose"
763 );
764 assert!(
765 check(content, ListItemSpacingStyle::Tight).is_empty(),
766 "Blockquote in tight list should not trigger a violation"
767 );
768 }
769
770 #[test]
771 fn blockquote_multiple_items_with_quotes_tight() {
772 let content = "\
774- Item 1:
775
776 > Quote A
777
778- Item 2:
779
780 > Quote B
781
782- Item 3 plain.
783";
784 assert!(
785 check(content, ListItemSpacingStyle::Tight).is_empty(),
786 "Multiple items with blockquotes should remain tight"
787 );
788 }
789
790 #[test]
791 fn blockquote_mixed_with_genuine_loose_gap() {
792 let content = "\
794- Item 1:
795
796 > Quote
797
798- Item 2 plain.
799
800- Item 3 plain.
801";
802 let warnings = check(content, ListItemSpacingStyle::Tight);
803 assert!(
804 !warnings.is_empty(),
805 "Genuine loose gap between Item 2 and Item 3 should be flagged"
806 );
807 }
808
809 #[test]
810 fn blockquote_single_line_in_tight_list() {
811 let content = "\
812- Item 1:
813
814 > Single line quote.
815
816- Item 2.
817- Item 3.
818";
819 assert!(
820 check(content, ListItemSpacingStyle::Tight).is_empty(),
821 "Single-line blockquote should be structural"
822 );
823 }
824
825 #[test]
826 fn blockquote_in_ordered_list_tight() {
827 let content = "\
8281. Item 1:
829
830 > Quoted text in ordered list.
831
8321. Item 2.
8331. Item 3.
834";
835 assert!(
836 check(content, ListItemSpacingStyle::Tight).is_empty(),
837 "Blockquote in ordered list should be structural"
838 );
839 }
840
841 #[test]
842 fn nested_blockquote_in_tight_list() {
843 let content = "\
844- Item 1:
845
846 > Outer quote
847 > > Nested quote
848
849- Item 2.
850- Item 3.
851";
852 assert!(
853 check(content, ListItemSpacingStyle::Tight).is_empty(),
854 "Nested blockquote in tight list should be structural"
855 );
856 }
857
858 #[test]
859 fn blockquote_as_entire_item_is_loose() {
860 let content = "\
863- > Quote is the entire item content.
864
865- Item 2.
866- Item 3.
867";
868 let warnings = check(content, ListItemSpacingStyle::Tight);
869 assert!(
870 !warnings.is_empty(),
871 "Blank after blockquote-only item is a genuine loose gap"
872 );
873 }
874
875 #[test]
876 fn mixed_code_and_table_in_tight_list() {
877 let content = "\
8781. Item with code:
879
880 ```markdown
881 This is some Markdown
882 ```
883
8841. Simple item.
8851. Item with table:
886
887 | Col 1 | Col 2 |
888 |:------|:------|
889 | Row 1 | Row 1 |
890 | Row 2 | Row 2 |
891";
892 assert!(
893 check(content, ListItemSpacingStyle::Consistent).is_empty(),
894 "Mix of code blocks and tables should not cause false positives"
895 );
896 }
897
898 #[test]
899 fn code_block_with_genuinely_loose_gaps_still_warns() {
900 let content = "\
903- Item 1:
904
905 ```bash
906 echo hi
907 ```
908
909- Item 2
910
911- Item 3
912- Item 4
913";
914 let warnings = check(content, ListItemSpacingStyle::Consistent);
915 assert!(
916 !warnings.is_empty(),
917 "Genuine inconsistency with code blocks should still be flagged"
918 );
919 }
920
921 #[test]
922 fn all_items_have_code_blocks_no_warnings() {
923 let content = "\
924- Item 1:
925
926 ```python
927 print(1)
928 ```
929
930- Item 2:
931
932 ```python
933 print(2)
934 ```
935
936- Item 3:
937
938 ```python
939 print(3)
940 ```
941";
942 assert!(
943 check(content, ListItemSpacingStyle::Consistent).is_empty(),
944 "All items with code blocks should be consistently tight"
945 );
946 }
947
948 #[test]
949 fn tilde_fence_code_block_in_list() {
950 let content = "\
951- Item 1:
952
953 ~~~
954 code here
955 ~~~
956
957- Item 2 simple.
958- Item 3 simple.
959";
960 assert!(
961 check(content, ListItemSpacingStyle::Consistent).is_empty(),
962 "Tilde fences should be recognized as structural content"
963 );
964 }
965
966 #[test]
967 fn nested_list_with_code_block() {
968 let content = "\
969- Item 1
970 - Nested with code:
971
972 ```
973 nested code
974 ```
975
976 - Nested simple.
977- Item 2
978";
979 assert!(
980 check(content, ListItemSpacingStyle::Consistent).is_empty(),
981 "Nested list with code block should not cause false positives"
982 );
983 }
984
985 #[test]
986 fn tight_style_with_code_block_no_warnings() {
987 let content = "\
988- Item 1:
989
990 ```
991 code
992 ```
993
994- Item 2.
995- Item 3.
996";
997 assert!(
998 check(content, ListItemSpacingStyle::Tight).is_empty(),
999 "Tight style should not warn about structural blanks around code blocks"
1000 );
1001 }
1002
1003 #[test]
1004 fn loose_style_with_code_block_missing_separator() {
1005 let content = "\
1008- Item 1:
1009
1010 ```
1011 code
1012 ```
1013
1014- Item 2.
1015- Item 3.
1016";
1017 let warnings = check(content, ListItemSpacingStyle::Loose);
1018 assert_eq!(
1019 warnings.len(),
1020 1,
1021 "Loose style should still require blank between simple items"
1022 );
1023 assert!(warnings[0].message.contains("Missing"));
1024 }
1025
1026 #[test]
1027 fn blockquote_list_with_code_block() {
1028 let content = "\
1029> - Item 1:
1030>
1031> ```
1032> code
1033> ```
1034>
1035> - Item 2.
1036> - Item 3.
1037";
1038 assert!(
1039 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1040 "Blockquote-prefixed list with code block should not cause false positives"
1041 );
1042 }
1043
1044 #[test]
1047 fn indented_code_block_in_list_no_false_positive() {
1048 let content = "\
10511. Item with indented code:
1052
1053 some code here
1054 more code
1055
10561. Simple item
10571. Another item
1058";
1059 assert!(
1060 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1061 "Structural blank after indented code block should not make item 1 appear loose"
1062 );
1063 }
1064
1065 #[test]
1068 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1069 let content = "\
10741. Item with code in middle:
1075
1076 ```
1077 code
1078 ```
1079
1080 Some text after the code block.
1081
10821. Simple item
10831. Another item
1084";
1085 let warnings = check(content, ListItemSpacingStyle::Consistent);
1086 assert!(
1087 !warnings.is_empty(),
1088 "Blank line after regular text (not structural content) is a genuine loose gap"
1089 );
1090 }
1091
1092 #[test]
1095 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1096 let content = "\
1099- Item 1:
1100
1101 ```
1102 code
1103 ```
1104
1105- Item 2.
1106- Item 3.
1107";
1108 let fixed = fix(content, ListItemSpacingStyle::Tight);
1109 assert_eq!(
1110 fixed, content,
1111 "Tight fix should not remove structural blanks around code blocks"
1112 );
1113 }
1114
1115 #[test]
1118 fn four_space_indented_fence_in_loose_list_no_false_positive() {
1119 let content = "\
11241. First item
1125
11261. Second item with code block:
1127
1128 ```json
1129 {\"key\": \"value\"}
1130 ```
1131
11321. Third item
1133";
1134 assert!(
1135 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1136 "Structural blank after 4-space indented code block should not cause false positive"
1137 );
1138 }
1139
1140 #[test]
1141 fn four_space_indented_fence_tight_style_no_warnings() {
1142 let content = "\
11431. First item
11441. Second item with code block:
1145
1146 ```json
1147 {\"key\": \"value\"}
1148 ```
1149
11501. Third item
1151";
1152 assert!(
1153 check(content, ListItemSpacingStyle::Tight).is_empty(),
1154 "Tight style should not warn about structural blanks with 4-space fences"
1155 );
1156 }
1157
1158 #[test]
1159 fn four_space_indented_fence_loose_style_no_warnings() {
1160 let content = "\
11621. First item
1163
11641. Second item with code block:
1165
1166 ```json
1167 {\"key\": \"value\"}
1168 ```
1169
11701. Third item
1171";
1172 assert!(
1173 check(content, ListItemSpacingStyle::Loose).is_empty(),
1174 "Loose style should not warn when structural gaps are the only non-loose gaps"
1175 );
1176 }
1177
1178 #[test]
1179 fn structural_gap_with_genuine_inconsistency_still_warns() {
1180 let content = "\
11831. First item with code:
1184
1185 ```json
1186 {\"key\": \"value\"}
1187 ```
1188
11891. Second item
1190
11911. Third item
11921. Fourth item
1193";
1194 let warnings = check(content, ListItemSpacingStyle::Consistent);
1195 assert!(
1196 !warnings.is_empty(),
1197 "Genuine loose/tight inconsistency should still warn even with structural gaps"
1198 );
1199 }
1200
1201 #[test]
1202 fn four_space_fence_fix_is_idempotent() {
1203 let content = "\
12061. First item
1207
12081. Second item with code block:
1209
1210 ```json
1211 {\"key\": \"value\"}
1212 ```
1213
12141. Third item
1215";
1216 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1217 assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1218 let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1219 assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1220 }
1221
1222 #[test]
1223 fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1224 let content = "\
12271. First item
12281. Second item with code block:
1229
1230 ```json
1231 {\"key\": \"value\"}
1232 ```
1233
12341. Third item
1235";
1236 let fixed = fix(content, ListItemSpacingStyle::Tight);
1237 assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1238 }
1239
1240 #[test]
1241 fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1242 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 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1256 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1257 let warnings = rule.check(&ctx).unwrap();
1258 assert!(
1259 warnings.is_empty(),
1260 "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1261 );
1262 }
1263
1264 #[test]
1267 fn code_block_in_second_item_detects_inconsistency() {
1268 let content = "\
1271# Test
1272
1273- Lorem ipsum dolor sit amet.
1274- Lorem ipsum dolor sit amet.
1275
1276 ```yaml
1277 hello: world
1278 ```
1279
1280- Lorem ipsum dolor sit amet.
1281
1282- Lorem ipsum dolor sit amet.
1283";
1284 let warnings = check(content, ListItemSpacingStyle::Consistent);
1285 assert!(
1286 !warnings.is_empty(),
1287 "Should detect inconsistent spacing when code block is inside a list item"
1288 );
1289 }
1290
1291 #[test]
1292 fn code_block_in_item_all_tight_no_warnings() {
1293 let content = "\
1295- Item 1
1296- Item 2
1297
1298 ```yaml
1299 hello: world
1300 ```
1301
1302- Item 3
1303- Item 4
1304";
1305 assert!(
1306 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1307 "All tight gaps with structural code block should not warn"
1308 );
1309 }
1310
1311 #[test]
1312 fn code_block_in_item_all_loose_no_warnings() {
1313 let content = "\
1315- Item 1
1316
1317- Item 2
1318
1319 ```yaml
1320 hello: world
1321 ```
1322
1323- Item 3
1324
1325- Item 4
1326";
1327 assert!(
1328 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1329 "All loose gaps with structural code block should not warn"
1330 );
1331 }
1332
1333 #[test]
1334 fn code_block_in_ordered_list_detects_inconsistency() {
1335 let content = "\
13361. First item
13371. Second item
1338
1339 ```json
1340 {\"key\": \"value\"}
1341 ```
1342
13431. Third item
1344
13451. Fourth item
1346";
1347 let warnings = check(content, ListItemSpacingStyle::Consistent);
1348 assert!(
1349 !warnings.is_empty(),
1350 "Ordered list with code block should still detect inconsistency"
1351 );
1352 }
1353
1354 #[test]
1355 fn code_block_in_item_fix_adds_missing_blanks() {
1356 let content = "\
1358- Item 1
1359- Item 2
1360
1361 ```yaml
1362 code: here
1363 ```
1364
1365- Item 3
1366
1367- Item 4
1368";
1369 let fixed = fix(content, ListItemSpacingStyle::Consistent);
1370 assert!(
1371 fixed.contains("- Item 1\n\n- Item 2"),
1372 "Fix should add blank line between items 1 and 2"
1373 );
1374 }
1375
1376 #[test]
1377 fn tilde_code_block_in_item_detects_inconsistency() {
1378 let content = "\
1379- Item 1
1380- Item 2
1381
1382 ~~~
1383 code
1384 ~~~
1385
1386- Item 3
1387
1388- Item 4
1389";
1390 let warnings = check(content, ListItemSpacingStyle::Consistent);
1391 assert!(
1392 !warnings.is_empty(),
1393 "Tilde code block inside item should not prevent inconsistency detection"
1394 );
1395 }
1396
1397 #[test]
1398 fn multiple_code_blocks_all_tight_no_warnings() {
1399 let content = "\
1401- Item 1
1402
1403 ```
1404 code1
1405 ```
1406
1407- Item 2
1408
1409 ```
1410 code2
1411 ```
1412
1413- Item 3
1414- Item 4
1415";
1416 assert!(
1417 check(content, ListItemSpacingStyle::Consistent).is_empty(),
1418 "All non-structural gaps are tight, so list is consistent"
1419 );
1420 }
1421
1422 #[test]
1423 fn code_block_with_mixed_genuine_gaps_warns() {
1424 let content = "\
1426- Item 1
1427
1428 ```
1429 code1
1430 ```
1431
1432- Item 2
1433
1434- Item 3
1435- Item 4
1436";
1437 let warnings = check(content, ListItemSpacingStyle::Consistent);
1438 assert!(
1439 !warnings.is_empty(),
1440 "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1441 );
1442 }
1443
1444 #[test]
1447 fn default_config_section_provides_style_key() {
1448 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1449 let section = rule.default_config_section();
1450 assert!(section.is_some());
1451 let (name, value) = section.unwrap();
1452 assert_eq!(name, "MD076");
1453 if let toml::Value::Table(map) = value {
1454 assert!(map.contains_key("style"));
1455 } else {
1456 panic!("Expected Table value from default_config_section");
1457 }
1458 }
1459}