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
41struct BlockAnalysis {
43 items: Vec<usize>,
45 gaps: Vec<bool>,
47 warn_loose_gaps: bool,
49 warn_tight_gaps: bool,
51}
52
53impl MD076ListItemSpacing {
54 pub fn new(style: ListItemSpacingStyle) -> Self {
55 Self {
56 config: MD076Config { style },
57 }
58 }
59
60 fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
65 if let Some(info) = ctx.line_info(line_num) {
66 let content = info.content(ctx.content);
67 if content.trim().is_empty() {
68 return true;
69 }
70 if let Some(ref bq) = info.blockquote {
72 return bq.content.trim().is_empty();
73 }
74 false
75 } else {
76 false
77 }
78 }
79
80 fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
83 if let Some(info) = ctx.line_info(line_num) {
84 if info.in_code_block {
86 return true;
87 }
88 if info.in_html_block {
90 return true;
91 }
92 let content = info.content(ctx.content);
94 let effective = if let Some(ref bq) = info.blockquote {
96 bq.content.as_str()
97 } else {
98 content
99 };
100 if is_table_line(effective.trim_start()) {
101 return true;
102 }
103 }
104 false
105 }
106
107 fn gap_is_loose(ctx: &LintContext, first: usize, next: usize) -> bool {
115 if next <= first + 1 {
116 return false;
117 }
118 if !Self::is_effectively_blank(ctx, next - 1) {
120 return false;
121 }
122 let mut scan = next - 1;
125 while scan > first && Self::is_effectively_blank(ctx, scan) {
126 scan -= 1;
127 }
128 if scan > first && Self::is_structural_content(ctx, scan) {
130 return false;
131 }
132 true
133 }
134
135 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
142 let mut blanks = Vec::new();
143 let mut line_num = next - 1;
144 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
145 blanks.push(line_num);
146 line_num -= 1;
147 }
148 if line_num > first && Self::is_structural_content(ctx, line_num) {
150 return Vec::new();
151 }
152 blanks.reverse();
153 blanks
154 }
155
156 fn analyze_block(
161 ctx: &LintContext,
162 block: &crate::lint_context::types::ListBlock,
163 style: &ListItemSpacingStyle,
164 ) -> Option<BlockAnalysis> {
165 let items: Vec<usize> = block
169 .item_lines
170 .iter()
171 .copied()
172 .filter(|&line_num| {
173 ctx.line_info(line_num)
174 .and_then(|li| li.list_item.as_ref())
175 .map(|item| item.marker_column / 2 == block.nesting_level)
176 .unwrap_or(false)
177 })
178 .collect();
179
180 if items.len() < 2 {
181 return None;
182 }
183
184 let gaps: Vec<bool> = items.windows(2).map(|w| Self::gap_is_loose(ctx, w[0], w[1])).collect();
186
187 let loose_count = gaps.iter().filter(|&&g| g).count();
188 let tight_count = gaps.len() - loose_count;
189
190 let (warn_loose_gaps, warn_tight_gaps) = match style {
191 ListItemSpacingStyle::Loose => (false, true),
192 ListItemSpacingStyle::Tight => (true, false),
193 ListItemSpacingStyle::Consistent => {
194 if loose_count == 0 || tight_count == 0 {
195 return None; }
197 if loose_count >= tight_count {
199 (false, true)
200 } else {
201 (true, false)
202 }
203 }
204 };
205
206 Some(BlockAnalysis {
207 items,
208 gaps,
209 warn_loose_gaps,
210 warn_tight_gaps,
211 })
212 }
213}
214
215impl Rule for MD076ListItemSpacing {
216 fn name(&self) -> &'static str {
217 "MD076"
218 }
219
220 fn description(&self) -> &'static str {
221 "List item spacing should be consistent"
222 }
223
224 fn check(&self, ctx: &LintContext) -> LintResult {
225 if ctx.content.is_empty() {
226 return Ok(Vec::new());
227 }
228
229 let mut warnings = Vec::new();
230
231 for block in &ctx.list_blocks {
232 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
233 continue;
234 };
235
236 for (i, &is_loose) in analysis.gaps.iter().enumerate() {
237 if is_loose && analysis.warn_loose_gaps {
238 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
240 if let Some(&blank_line) = blanks.first() {
241 let line_content = ctx
242 .line_info(blank_line)
243 .map(|li| li.content(ctx.content))
244 .unwrap_or("");
245 warnings.push(LintWarning {
246 rule_name: Some(self.name().to_string()),
247 line: blank_line,
248 column: 1,
249 end_line: blank_line,
250 end_column: line_content.len() + 1,
251 message: "Unexpected blank line between list items".to_string(),
252 severity: Severity::Warning,
253 fix: None,
254 });
255 }
256 } else if !is_loose && analysis.warn_tight_gaps {
257 let next_item = analysis.items[i + 1];
259 let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
260 warnings.push(LintWarning {
261 rule_name: Some(self.name().to_string()),
262 line: next_item,
263 column: 1,
264 end_line: next_item,
265 end_column: line_content.len() + 1,
266 message: "Missing blank line between list items".to_string(),
267 severity: Severity::Warning,
268 fix: None,
269 });
270 }
271 }
272 }
273
274 Ok(warnings)
275 }
276
277 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
278 if ctx.content.is_empty() {
279 return Ok(ctx.content.to_string());
280 }
281
282 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
284 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
285
286 for block in &ctx.list_blocks {
287 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
288 continue;
289 };
290
291 for (i, &is_loose) in analysis.gaps.iter().enumerate() {
292 if is_loose && analysis.warn_loose_gaps {
293 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
295 remove_lines.insert(blank_line);
296 }
297 } else if !is_loose && analysis.warn_tight_gaps {
298 insert_before.insert(analysis.items[i + 1]);
299 }
300 }
301 }
302
303 if insert_before.is_empty() && remove_lines.is_empty() {
304 return Ok(ctx.content.to_string());
305 }
306
307 let lines = ctx.raw_lines();
308 let mut result: Vec<String> = Vec::with_capacity(lines.len());
309
310 for (i, line) in lines.iter().enumerate() {
311 let line_num = i + 1;
312
313 if remove_lines.contains(&line_num) {
314 continue;
315 }
316
317 if insert_before.contains(&line_num) {
318 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
319 result.push(bq_prefix);
320 }
321
322 result.push((*line).to_string());
323 }
324
325 let mut output = result.join("\n");
326 if ctx.content.ends_with('\n') {
327 output.push('\n');
328 }
329 Ok(output)
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn default_config_section(&self) -> Option<(String, toml::Value)> {
337 let mut map = toml::map::Map::new();
338 let style_str = match self.config.style {
339 ListItemSpacingStyle::Consistent => "consistent",
340 ListItemSpacingStyle::Loose => "loose",
341 ListItemSpacingStyle::Tight => "tight",
342 };
343 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
344 Some((self.name().to_string(), toml::Value::Table(map)))
345 }
346
347 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
348 where
349 Self: Sized,
350 {
351 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
352 .unwrap_or_else(|| "consistent".to_string());
353 let style = match style.as_str() {
354 "loose" => ListItemSpacingStyle::Loose,
355 "tight" => ListItemSpacingStyle::Tight,
356 _ => ListItemSpacingStyle::Consistent,
357 };
358 Box::new(Self::new(style))
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let rule = MD076ListItemSpacing::new(style);
369 rule.check(&ctx).unwrap()
370 }
371
372 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374 let rule = MD076ListItemSpacing::new(style);
375 rule.fix(&ctx).unwrap()
376 }
377
378 #[test]
381 fn tight_list_tight_style_no_warnings() {
382 let content = "- Item 1\n- Item 2\n- Item 3\n";
383 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
384 }
385
386 #[test]
387 fn loose_list_loose_style_no_warnings() {
388 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
389 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
390 }
391
392 #[test]
393 fn tight_list_loose_style_warns() {
394 let content = "- Item 1\n- Item 2\n- Item 3\n";
395 let warnings = check(content, ListItemSpacingStyle::Loose);
396 assert_eq!(warnings.len(), 2);
397 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
398 }
399
400 #[test]
401 fn loose_list_tight_style_warns() {
402 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
403 let warnings = check(content, ListItemSpacingStyle::Tight);
404 assert_eq!(warnings.len(), 2);
405 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
406 }
407
408 #[test]
411 fn consistent_all_tight_no_warnings() {
412 let content = "- Item 1\n- Item 2\n- Item 3\n";
413 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
414 }
415
416 #[test]
417 fn consistent_all_loose_no_warnings() {
418 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
419 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
420 }
421
422 #[test]
423 fn consistent_mixed_majority_loose_warns_tight() {
424 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
426 let warnings = check(content, ListItemSpacingStyle::Consistent);
427 assert_eq!(warnings.len(), 1);
428 assert!(warnings[0].message.contains("Missing"));
429 }
430
431 #[test]
432 fn consistent_mixed_majority_tight_warns_loose() {
433 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
435 let warnings = check(content, ListItemSpacingStyle::Consistent);
436 assert_eq!(warnings.len(), 1);
437 assert!(warnings[0].message.contains("Unexpected"));
438 }
439
440 #[test]
441 fn consistent_tie_prefers_loose() {
442 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
443 let warnings = check(content, ListItemSpacingStyle::Consistent);
444 assert_eq!(warnings.len(), 1);
445 assert!(warnings[0].message.contains("Missing"));
446 }
447
448 #[test]
451 fn single_item_list_no_warnings() {
452 let content = "- Only item\n";
453 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
454 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
455 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
456 }
457
458 #[test]
459 fn empty_content_no_warnings() {
460 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
461 }
462
463 #[test]
464 fn ordered_list_tight_gaps_loose_style_warns() {
465 let content = "1. First\n2. Second\n3. Third\n";
466 let warnings = check(content, ListItemSpacingStyle::Loose);
467 assert_eq!(warnings.len(), 2);
468 }
469
470 #[test]
471 fn task_list_works() {
472 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
473 let warnings = check(content, ListItemSpacingStyle::Loose);
474 assert_eq!(warnings.len(), 2);
475 let fixed = fix(content, ListItemSpacingStyle::Loose);
476 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
477 }
478
479 #[test]
480 fn no_trailing_newline() {
481 let content = "- Item 1\n- Item 2";
482 let warnings = check(content, ListItemSpacingStyle::Loose);
483 assert_eq!(warnings.len(), 1);
484 let fixed = fix(content, ListItemSpacingStyle::Loose);
485 assert_eq!(fixed, "- Item 1\n\n- Item 2");
486 }
487
488 #[test]
489 fn two_separate_lists() {
490 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
491 let warnings = check(content, ListItemSpacingStyle::Loose);
492 assert_eq!(warnings.len(), 2);
493 let fixed = fix(content, ListItemSpacingStyle::Loose);
494 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
495 }
496
497 #[test]
498 fn no_list_content() {
499 let content = "Just a paragraph.\n\nAnother paragraph.\n";
500 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
501 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
502 }
503
504 #[test]
507 fn continuation_lines_tight_detected() {
508 let content = "- Item 1\n continuation\n- Item 2\n";
509 let warnings = check(content, ListItemSpacingStyle::Loose);
510 assert_eq!(warnings.len(), 1);
511 assert!(warnings[0].message.contains("Missing"));
512 }
513
514 #[test]
515 fn continuation_lines_loose_detected() {
516 let content = "- Item 1\n continuation\n\n- Item 2\n";
517 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
518 let warnings = check(content, ListItemSpacingStyle::Tight);
519 assert_eq!(warnings.len(), 1);
520 assert!(warnings[0].message.contains("Unexpected"));
521 }
522
523 #[test]
524 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
525 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
528 let warnings = check(content, ListItemSpacingStyle::Tight);
530 assert_eq!(
531 warnings.len(),
532 1,
533 "Should warn only on the inter-item blank, not the intra-item blank"
534 );
535 let fixed = fix(content, ListItemSpacingStyle::Tight);
538 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
539 }
540
541 #[test]
542 fn multi_paragraph_item_loose_style_no_warnings() {
543 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
545 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
546 }
547
548 #[test]
551 fn blockquote_tight_list_loose_style_warns() {
552 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
553 let warnings = check(content, ListItemSpacingStyle::Loose);
554 assert_eq!(warnings.len(), 2);
555 }
556
557 #[test]
558 fn blockquote_loose_list_detected() {
559 let content = "> - Item 1\n>\n> - Item 2\n";
561 let warnings = check(content, ListItemSpacingStyle::Tight);
562 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
563 assert!(warnings[0].message.contains("Unexpected"));
564 }
565
566 #[test]
567 fn blockquote_loose_list_no_warnings_when_loose() {
568 let content = "> - Item 1\n>\n> - Item 2\n";
569 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
570 }
571
572 #[test]
575 fn multiple_blanks_all_removed() {
576 let content = "- Item 1\n\n\n- Item 2\n";
577 let fixed = fix(content, ListItemSpacingStyle::Tight);
578 assert_eq!(fixed, "- Item 1\n- Item 2\n");
579 }
580
581 #[test]
582 fn multiple_blanks_fix_is_idempotent() {
583 let content = "- Item 1\n\n\n\n- Item 2\n";
584 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
585 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
586 assert_eq!(fixed_once, fixed_twice);
587 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
588 }
589
590 #[test]
593 fn fix_adds_blank_lines() {
594 let content = "- Item 1\n- Item 2\n- Item 3\n";
595 let fixed = fix(content, ListItemSpacingStyle::Loose);
596 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
597 }
598
599 #[test]
600 fn fix_removes_blank_lines() {
601 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
602 let fixed = fix(content, ListItemSpacingStyle::Tight);
603 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
604 }
605
606 #[test]
607 fn fix_consistent_adds_blank() {
608 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
610 let fixed = fix(content, ListItemSpacingStyle::Consistent);
611 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
612 }
613
614 #[test]
615 fn fix_idempotent_loose() {
616 let content = "- Item 1\n- Item 2\n";
617 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
618 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
619 assert_eq!(fixed_once, fixed_twice);
620 }
621
622 #[test]
623 fn fix_idempotent_tight() {
624 let content = "- Item 1\n\n- Item 2\n";
625 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
626 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
627 assert_eq!(fixed_once, fixed_twice);
628 }
629
630 #[test]
633 fn nested_list_does_not_affect_parent() {
634 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
636 let warnings = check(content, ListItemSpacingStyle::Tight);
637 assert!(
638 warnings.is_empty(),
639 "Nested items should not cause parent-level warnings"
640 );
641 }
642
643 #[test]
646 fn code_block_in_tight_list_no_false_positive() {
647 let content = "\
649- Item 1 with code:
650
651 ```python
652 print('hello')
653 ```
654
655- Item 2 simple.
656- Item 3 simple.
657";
658 assert!(
659 check(content, ListItemSpacingStyle::Consistent).is_empty(),
660 "Structural blank after code block should not make item 1 appear loose"
661 );
662 }
663
664 #[test]
665 fn table_in_tight_list_no_false_positive() {
666 let content = "\
668- Item 1 with table:
669
670 | Col 1 | Col 2 |
671 |-------|-------|
672 | A | B |
673
674- Item 2 simple.
675- Item 3 simple.
676";
677 assert!(
678 check(content, ListItemSpacingStyle::Consistent).is_empty(),
679 "Structural blank after table should not make item 1 appear loose"
680 );
681 }
682
683 #[test]
684 fn html_block_in_tight_list_no_false_positive() {
685 let content = "\
686- Item 1 with HTML:
687
688 <details>
689 <summary>Click</summary>
690 Content
691 </details>
692
693- Item 2 simple.
694- Item 3 simple.
695";
696 assert!(
697 check(content, ListItemSpacingStyle::Consistent).is_empty(),
698 "Structural blank after HTML block should not make item 1 appear loose"
699 );
700 }
701
702 #[test]
703 fn mixed_code_and_table_in_tight_list() {
704 let content = "\
7051. Item with code:
706
707 ```markdown
708 This is some Markdown
709 ```
710
7111. Simple item.
7121. Item with table:
713
714 | Col 1 | Col 2 |
715 |:------|:------|
716 | Row 1 | Row 1 |
717 | Row 2 | Row 2 |
718";
719 assert!(
720 check(content, ListItemSpacingStyle::Consistent).is_empty(),
721 "Mix of code blocks and tables should not cause false positives"
722 );
723 }
724
725 #[test]
726 fn code_block_with_genuinely_loose_gaps_still_warns() {
727 let content = "\
730- Item 1:
731
732 ```bash
733 echo hi
734 ```
735
736- Item 2
737
738- Item 3
739- Item 4
740";
741 let warnings = check(content, ListItemSpacingStyle::Consistent);
742 assert!(
743 !warnings.is_empty(),
744 "Genuine inconsistency with code blocks should still be flagged"
745 );
746 }
747
748 #[test]
749 fn all_items_have_code_blocks_no_warnings() {
750 let content = "\
751- Item 1:
752
753 ```python
754 print(1)
755 ```
756
757- Item 2:
758
759 ```python
760 print(2)
761 ```
762
763- Item 3:
764
765 ```python
766 print(3)
767 ```
768";
769 assert!(
770 check(content, ListItemSpacingStyle::Consistent).is_empty(),
771 "All items with code blocks should be consistently tight"
772 );
773 }
774
775 #[test]
776 fn tilde_fence_code_block_in_list() {
777 let content = "\
778- Item 1:
779
780 ~~~
781 code here
782 ~~~
783
784- Item 2 simple.
785- Item 3 simple.
786";
787 assert!(
788 check(content, ListItemSpacingStyle::Consistent).is_empty(),
789 "Tilde fences should be recognized as structural content"
790 );
791 }
792
793 #[test]
794 fn nested_list_with_code_block() {
795 let content = "\
796- Item 1
797 - Nested with code:
798
799 ```
800 nested code
801 ```
802
803 - Nested simple.
804- Item 2
805";
806 assert!(
807 check(content, ListItemSpacingStyle::Consistent).is_empty(),
808 "Nested list with code block should not cause false positives"
809 );
810 }
811
812 #[test]
813 fn tight_style_with_code_block_no_warnings() {
814 let content = "\
815- Item 1:
816
817 ```
818 code
819 ```
820
821- Item 2.
822- Item 3.
823";
824 assert!(
825 check(content, ListItemSpacingStyle::Tight).is_empty(),
826 "Tight style should not warn about structural blanks around code blocks"
827 );
828 }
829
830 #[test]
831 fn loose_style_with_code_block_missing_separator() {
832 let content = "\
835- Item 1:
836
837 ```
838 code
839 ```
840
841- Item 2.
842- Item 3.
843";
844 let warnings = check(content, ListItemSpacingStyle::Loose);
845 assert_eq!(
846 warnings.len(),
847 1,
848 "Loose style should still require blank between simple items"
849 );
850 assert!(warnings[0].message.contains("Missing"));
851 }
852
853 #[test]
854 fn blockquote_list_with_code_block() {
855 let content = "\
856> - Item 1:
857>
858> ```
859> code
860> ```
861>
862> - Item 2.
863> - Item 3.
864";
865 assert!(
866 check(content, ListItemSpacingStyle::Consistent).is_empty(),
867 "Blockquote-prefixed list with code block should not cause false positives"
868 );
869 }
870
871 #[test]
874 fn indented_code_block_in_list_no_false_positive() {
875 let content = "\
8781. Item with indented code:
879
880 some code here
881 more code
882
8831. Simple item
8841. Another item
885";
886 assert!(
887 check(content, ListItemSpacingStyle::Consistent).is_empty(),
888 "Structural blank after indented code block should not make item 1 appear loose"
889 );
890 }
891
892 #[test]
895 fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
896 let content = "\
9011. Item with code in middle:
902
903 ```
904 code
905 ```
906
907 Some text after the code block.
908
9091. Simple item
9101. Another item
911";
912 let warnings = check(content, ListItemSpacingStyle::Consistent);
913 assert!(
914 !warnings.is_empty(),
915 "Blank line after regular text (not structural content) is a genuine loose gap"
916 );
917 }
918
919 #[test]
922 fn tight_fix_preserves_structural_blanks_around_code_blocks() {
923 let content = "\
926- Item 1:
927
928 ```
929 code
930 ```
931
932- Item 2.
933- Item 3.
934";
935 let fixed = fix(content, ListItemSpacingStyle::Tight);
936 assert_eq!(
937 fixed, content,
938 "Tight fix should not remove structural blanks around code blocks"
939 );
940 }
941
942 #[test]
945 fn default_config_section_provides_style_key() {
946 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
947 let section = rule.default_config_section();
948 assert!(section.is_some());
949 let (name, value) = section.unwrap();
950 assert_eq!(name, "MD076");
951 if let toml::Value::Table(map) = value {
952 assert!(map.contains_key("style"));
953 } else {
954 panic!("Expected Table value from default_config_section");
955 }
956 }
957}