Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

1use crate::lint_context::LintContext;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
3
4/// Rule MD076: Enforce consistent blank lines between list items
5///
6/// See [docs/md076.md](../../docs/md076.md) for full documentation and examples.
7///
8/// Enforces that the spacing between consecutive list items is consistent
9/// within each list: either all gaps have a blank line (loose) or none do (tight).
10///
11/// ## Configuration
12///
13/// ```toml
14/// [MD076]
15/// style = "consistent"  # "loose", "tight", or "consistent" (default)
16/// ```
17///
18/// - `"consistent"` — within each list, all gaps must use the same style (majority wins)
19/// - `"loose"` — blank line required between every pair of items
20/// - `"tight"` — no blank lines allowed between any items
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
23pub enum ListItemSpacingStyle {
24    #[default]
25    Consistent,
26    Loose,
27    Tight,
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct MD076Config {
32    pub style: ListItemSpacingStyle,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct MD076ListItemSpacing {
37    config: MD076Config,
38}
39
40/// Per-block analysis result shared by check() and fix().
41struct BlockAnalysis {
42    /// 1-indexed line numbers of items at this block's nesting level.
43    items: Vec<usize>,
44    /// Whether each inter-item gap is loose (has a blank separator line).
45    gaps: Vec<bool>,
46    /// Whether loose gaps are violations (should have blank lines removed).
47    warn_loose_gaps: bool,
48    /// Whether tight gaps are violations (should have blank lines inserted).
49    warn_tight_gaps: bool,
50}
51
52impl MD076ListItemSpacing {
53    pub fn new(style: ListItemSpacingStyle) -> Self {
54        Self {
55            config: MD076Config { style },
56        }
57    }
58
59    /// Check whether a line is effectively blank, accounting for blockquote markers.
60    ///
61    /// A line like `>` or `> ` is considered blank in blockquote context even though
62    /// its raw content is non-empty.
63    fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
64        if let Some(info) = ctx.line_info(line_num) {
65            let content = info.content(ctx.content);
66            if content.trim().is_empty() {
67                return true;
68            }
69            // In a blockquote, a line containing only markers (e.g., ">", "> ") is blank
70            if let Some(ref bq) = info.blockquote {
71                return bq.content.trim().is_empty();
72            }
73            false
74        } else {
75            false
76        }
77    }
78
79    /// Determine whether the inter-item gap between two consecutive items is loose.
80    ///
81    /// Only considers blank lines that are actual inter-item separators: the
82    /// consecutive blank lines immediately preceding the next item's marker.
83    /// Blank lines within a multi-paragraph item (followed by indented continuation
84    /// content) are not counted.
85    fn gap_is_loose(ctx: &LintContext, first: usize, next: usize) -> bool {
86        if next <= first + 1 {
87            return false;
88        }
89        // The gap is loose if the line immediately before the next item is blank.
90        // This correctly ignores blank lines within multi-paragraph items that
91        // are followed by continuation content rather than the next item marker.
92        Self::is_effectively_blank(ctx, next - 1)
93    }
94
95    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
96    ///
97    /// Walks backwards from the line before `next` collecting consecutive blank lines.
98    /// These are the actual separator lines between items, not blank lines within
99    /// multi-paragraph items.
100    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
101        let mut blanks = Vec::new();
102        let mut line_num = next - 1;
103        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
104            blanks.push(line_num);
105            line_num -= 1;
106        }
107        blanks.reverse();
108        blanks
109    }
110
111    /// Analyze a single list block to determine which gaps need fixing.
112    ///
113    /// Returns `None` if the block has fewer than 2 items at its nesting level
114    /// or if no gaps violate the configured style.
115    fn analyze_block(
116        ctx: &LintContext,
117        block: &crate::lint_context::types::ListBlock,
118        style: &ListItemSpacingStyle,
119    ) -> Option<BlockAnalysis> {
120        // Only compare items at this block's own nesting level.
121        // item_lines may include nested list items (higher marker_column) that belong
122        // to a child list — those must not affect spacing analysis.
123        let items: Vec<usize> = block
124            .item_lines
125            .iter()
126            .copied()
127            .filter(|&line_num| {
128                ctx.line_info(line_num)
129                    .and_then(|li| li.list_item.as_ref())
130                    .map(|item| item.marker_column / 2 == block.nesting_level)
131                    .unwrap_or(false)
132            })
133            .collect();
134
135        if items.len() < 2 {
136            return None;
137        }
138
139        // Compute whether each inter-item gap is loose (has blank separator).
140        let gaps: Vec<bool> = items.windows(2).map(|w| Self::gap_is_loose(ctx, w[0], w[1])).collect();
141
142        let loose_count = gaps.iter().filter(|&&g| g).count();
143        let tight_count = gaps.len() - loose_count;
144
145        let (warn_loose_gaps, warn_tight_gaps) = match style {
146            ListItemSpacingStyle::Loose => (false, true),
147            ListItemSpacingStyle::Tight => (true, false),
148            ListItemSpacingStyle::Consistent => {
149                if loose_count == 0 || tight_count == 0 {
150                    return None; // Already consistent
151                }
152                // Majority wins; on a tie, prefer loose (warn tight).
153                if loose_count >= tight_count {
154                    (false, true)
155                } else {
156                    (true, false)
157                }
158            }
159        };
160
161        Some(BlockAnalysis {
162            items,
163            gaps,
164            warn_loose_gaps,
165            warn_tight_gaps,
166        })
167    }
168}
169
170impl Rule for MD076ListItemSpacing {
171    fn name(&self) -> &'static str {
172        "MD076"
173    }
174
175    fn description(&self) -> &'static str {
176        "List item spacing should be consistent"
177    }
178
179    fn check(&self, ctx: &LintContext) -> LintResult {
180        if ctx.content.is_empty() {
181            return Ok(Vec::new());
182        }
183
184        let mut warnings = Vec::new();
185
186        for block in &ctx.list_blocks {
187            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
188                continue;
189            };
190
191            for (i, &is_loose) in analysis.gaps.iter().enumerate() {
192                if is_loose && analysis.warn_loose_gaps {
193                    // Warn on the first inter-item blank line in this gap.
194                    let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
195                    if let Some(&blank_line) = blanks.first() {
196                        let line_content = ctx
197                            .line_info(blank_line)
198                            .map(|li| li.content(ctx.content))
199                            .unwrap_or("");
200                        warnings.push(LintWarning {
201                            rule_name: Some(self.name().to_string()),
202                            line: blank_line,
203                            column: 1,
204                            end_line: blank_line,
205                            end_column: line_content.len() + 1,
206                            message: "Unexpected blank line between list items".to_string(),
207                            severity: Severity::Warning,
208                            fix: None,
209                        });
210                    }
211                } else if !is_loose && analysis.warn_tight_gaps {
212                    // Warn on the next item line (a blank line should precede it).
213                    let next_item = analysis.items[i + 1];
214                    let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
215                    warnings.push(LintWarning {
216                        rule_name: Some(self.name().to_string()),
217                        line: next_item,
218                        column: 1,
219                        end_line: next_item,
220                        end_column: line_content.len() + 1,
221                        message: "Missing blank line between list items".to_string(),
222                        severity: Severity::Warning,
223                        fix: None,
224                    });
225                }
226            }
227        }
228
229        Ok(warnings)
230    }
231
232    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
233        if ctx.content.is_empty() {
234            return Ok(ctx.content.to_string());
235        }
236
237        // Collect all inter-item blank lines to remove and lines to insert before.
238        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
239        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
240
241        for block in &ctx.list_blocks {
242            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
243                continue;
244            };
245
246            for (i, &is_loose) in analysis.gaps.iter().enumerate() {
247                if is_loose && analysis.warn_loose_gaps {
248                    // Remove ALL inter-item blank lines in this gap
249                    for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
250                        remove_lines.insert(blank_line);
251                    }
252                } else if !is_loose && analysis.warn_tight_gaps {
253                    insert_before.insert(analysis.items[i + 1]);
254                }
255            }
256        }
257
258        if insert_before.is_empty() && remove_lines.is_empty() {
259            return Ok(ctx.content.to_string());
260        }
261
262        let lines = ctx.raw_lines();
263        let mut result: Vec<String> = Vec::with_capacity(lines.len());
264
265        for (i, line) in lines.iter().enumerate() {
266            let line_num = i + 1;
267
268            if remove_lines.contains(&line_num) {
269                continue;
270            }
271
272            if insert_before.contains(&line_num) {
273                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
274                result.push(bq_prefix);
275            }
276
277            result.push((*line).to_string());
278        }
279
280        let mut output = result.join("\n");
281        if ctx.content.ends_with('\n') {
282            output.push('\n');
283        }
284        Ok(output)
285    }
286
287    fn as_any(&self) -> &dyn std::any::Any {
288        self
289    }
290
291    fn default_config_section(&self) -> Option<(String, toml::Value)> {
292        let mut map = toml::map::Map::new();
293        let style_str = match self.config.style {
294            ListItemSpacingStyle::Consistent => "consistent",
295            ListItemSpacingStyle::Loose => "loose",
296            ListItemSpacingStyle::Tight => "tight",
297        };
298        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
299        Some((self.name().to_string(), toml::Value::Table(map)))
300    }
301
302    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
303    where
304        Self: Sized,
305    {
306        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
307            .unwrap_or_else(|| "consistent".to_string());
308        let style = match style.as_str() {
309            "loose" => ListItemSpacingStyle::Loose,
310            "tight" => ListItemSpacingStyle::Tight,
311            _ => ListItemSpacingStyle::Consistent,
312        };
313        Box::new(Self::new(style))
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
323        let rule = MD076ListItemSpacing::new(style);
324        rule.check(&ctx).unwrap()
325    }
326
327    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
328        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
329        let rule = MD076ListItemSpacing::new(style);
330        rule.fix(&ctx).unwrap()
331    }
332
333    // ── Basic style detection ──────────────────────────────────────────
334
335    #[test]
336    fn tight_list_tight_style_no_warnings() {
337        let content = "- Item 1\n- Item 2\n- Item 3\n";
338        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
339    }
340
341    #[test]
342    fn loose_list_loose_style_no_warnings() {
343        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
344        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
345    }
346
347    #[test]
348    fn tight_list_loose_style_warns() {
349        let content = "- Item 1\n- Item 2\n- Item 3\n";
350        let warnings = check(content, ListItemSpacingStyle::Loose);
351        assert_eq!(warnings.len(), 2);
352        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
353    }
354
355    #[test]
356    fn loose_list_tight_style_warns() {
357        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
358        let warnings = check(content, ListItemSpacingStyle::Tight);
359        assert_eq!(warnings.len(), 2);
360        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
361    }
362
363    // ── Consistent mode ────────────────────────────────────────────────
364
365    #[test]
366    fn consistent_all_tight_no_warnings() {
367        let content = "- Item 1\n- Item 2\n- Item 3\n";
368        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
369    }
370
371    #[test]
372    fn consistent_all_loose_no_warnings() {
373        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
374        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
375    }
376
377    #[test]
378    fn consistent_mixed_majority_loose_warns_tight() {
379        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
380        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
381        let warnings = check(content, ListItemSpacingStyle::Consistent);
382        assert_eq!(warnings.len(), 1);
383        assert!(warnings[0].message.contains("Missing"));
384    }
385
386    #[test]
387    fn consistent_mixed_majority_tight_warns_loose() {
388        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
389        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
390        let warnings = check(content, ListItemSpacingStyle::Consistent);
391        assert_eq!(warnings.len(), 1);
392        assert!(warnings[0].message.contains("Unexpected"));
393    }
394
395    #[test]
396    fn consistent_tie_prefers_loose() {
397        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
398        let warnings = check(content, ListItemSpacingStyle::Consistent);
399        assert_eq!(warnings.len(), 1);
400        assert!(warnings[0].message.contains("Missing"));
401    }
402
403    // ── Edge cases ─────────────────────────────────────────────────────
404
405    #[test]
406    fn single_item_list_no_warnings() {
407        let content = "- Only item\n";
408        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
409        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
410        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
411    }
412
413    #[test]
414    fn empty_content_no_warnings() {
415        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
416    }
417
418    #[test]
419    fn ordered_list_tight_gaps_loose_style_warns() {
420        let content = "1. First\n2. Second\n3. Third\n";
421        let warnings = check(content, ListItemSpacingStyle::Loose);
422        assert_eq!(warnings.len(), 2);
423    }
424
425    #[test]
426    fn task_list_works() {
427        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
428        let warnings = check(content, ListItemSpacingStyle::Loose);
429        assert_eq!(warnings.len(), 2);
430        let fixed = fix(content, ListItemSpacingStyle::Loose);
431        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
432    }
433
434    #[test]
435    fn no_trailing_newline() {
436        let content = "- Item 1\n- Item 2";
437        let warnings = check(content, ListItemSpacingStyle::Loose);
438        assert_eq!(warnings.len(), 1);
439        let fixed = fix(content, ListItemSpacingStyle::Loose);
440        assert_eq!(fixed, "- Item 1\n\n- Item 2");
441    }
442
443    #[test]
444    fn two_separate_lists() {
445        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
446        let warnings = check(content, ListItemSpacingStyle::Loose);
447        assert_eq!(warnings.len(), 2);
448        let fixed = fix(content, ListItemSpacingStyle::Loose);
449        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
450    }
451
452    #[test]
453    fn no_list_content() {
454        let content = "Just a paragraph.\n\nAnother paragraph.\n";
455        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
456        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
457    }
458
459    // ── Multi-line and continuation items ──────────────────────────────
460
461    #[test]
462    fn continuation_lines_tight_detected() {
463        let content = "- Item 1\n  continuation\n- Item 2\n";
464        let warnings = check(content, ListItemSpacingStyle::Loose);
465        assert_eq!(warnings.len(), 1);
466        assert!(warnings[0].message.contains("Missing"));
467    }
468
469    #[test]
470    fn continuation_lines_loose_detected() {
471        let content = "- Item 1\n  continuation\n\n- Item 2\n";
472        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
473        let warnings = check(content, ListItemSpacingStyle::Tight);
474        assert_eq!(warnings.len(), 1);
475        assert!(warnings[0].message.contains("Unexpected"));
476    }
477
478    #[test]
479    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
480        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
481        // Only the blank line immediately before Item 2 is an inter-item separator.
482        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
483        // Both gaps are loose (blank before Item 2), so tight should warn once
484        let warnings = check(content, ListItemSpacingStyle::Tight);
485        assert_eq!(
486            warnings.len(),
487            1,
488            "Should warn only on the inter-item blank, not the intra-item blank"
489        );
490        // The fix should remove only the inter-item blank (line 4), preserving the
491        // multi-paragraph structure
492        let fixed = fix(content, ListItemSpacingStyle::Tight);
493        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
494    }
495
496    #[test]
497    fn multi_paragraph_item_loose_style_no_warnings() {
498        // A loose list with multi-paragraph items is already loose — no warnings
499        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
500        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
501    }
502
503    // ── Blockquote lists ───────────────────────────────────────────────
504
505    #[test]
506    fn blockquote_tight_list_loose_style_warns() {
507        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
508        let warnings = check(content, ListItemSpacingStyle::Loose);
509        assert_eq!(warnings.len(), 2);
510    }
511
512    #[test]
513    fn blockquote_loose_list_detected() {
514        // A line with only `>` is effectively blank in blockquote context
515        let content = "> - Item 1\n>\n> - Item 2\n";
516        let warnings = check(content, ListItemSpacingStyle::Tight);
517        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
518        assert!(warnings[0].message.contains("Unexpected"));
519    }
520
521    #[test]
522    fn blockquote_loose_list_no_warnings_when_loose() {
523        let content = "> - Item 1\n>\n> - Item 2\n";
524        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
525    }
526
527    // ── Multiple blank lines ───────────────────────────────────────────
528
529    #[test]
530    fn multiple_blanks_all_removed() {
531        let content = "- Item 1\n\n\n- Item 2\n";
532        let fixed = fix(content, ListItemSpacingStyle::Tight);
533        assert_eq!(fixed, "- Item 1\n- Item 2\n");
534    }
535
536    #[test]
537    fn multiple_blanks_fix_is_idempotent() {
538        let content = "- Item 1\n\n\n\n- Item 2\n";
539        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
540        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
541        assert_eq!(fixed_once, fixed_twice);
542        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
543    }
544
545    // ── Fix correctness ────────────────────────────────────────────────
546
547    #[test]
548    fn fix_adds_blank_lines() {
549        let content = "- Item 1\n- Item 2\n- Item 3\n";
550        let fixed = fix(content, ListItemSpacingStyle::Loose);
551        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
552    }
553
554    #[test]
555    fn fix_removes_blank_lines() {
556        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
557        let fixed = fix(content, ListItemSpacingStyle::Tight);
558        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
559    }
560
561    #[test]
562    fn fix_consistent_adds_blank() {
563        // 2 loose gaps, 1 tight gap → add blank before Item 3
564        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
565        let fixed = fix(content, ListItemSpacingStyle::Consistent);
566        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
567    }
568
569    #[test]
570    fn fix_idempotent_loose() {
571        let content = "- Item 1\n- Item 2\n";
572        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
573        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
574        assert_eq!(fixed_once, fixed_twice);
575    }
576
577    #[test]
578    fn fix_idempotent_tight() {
579        let content = "- Item 1\n\n- Item 2\n";
580        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
581        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
582        assert_eq!(fixed_once, fixed_twice);
583    }
584
585    // ── Nested lists ───────────────────────────────────────────────────
586
587    #[test]
588    fn nested_list_does_not_affect_parent() {
589        // Nested items should not trigger warnings for the parent list
590        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
591        let warnings = check(content, ListItemSpacingStyle::Tight);
592        assert!(
593            warnings.is_empty(),
594            "Nested items should not cause parent-level warnings"
595        );
596    }
597
598    // ── Config schema ──────────────────────────────────────────────────
599
600    #[test]
601    fn default_config_section_provides_style_key() {
602        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
603        let section = rule.default_config_section();
604        assert!(section.is_some());
605        let (name, value) = section.unwrap();
606        assert_eq!(name, "MD076");
607        if let toml::Value::Table(map) = value {
608            assert!(map.contains_key("style"));
609        } else {
610            panic!("Expected Table value from default_config_section");
611        }
612    }
613}