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