Skip to main content

mdwright_lint/stdlib/
list_tightness_flipped.rs

1//! Tightness derived from the structural tree disagrees with tightness
2//! derived from the raw source bytes.
3//!
4//! CM §5.3 makes a list tight iff no item is separated from the next
5//! by a blank line and no item contains a direct paragraph child.
6//! The tree view reads "direct paragraph child" off parsed list items;
7//! the source view reads "blank line between items" off the bytes
8//! between consecutive item `raw_range`s. On well-formed input these
9//! always agree. Disagreement typically signals an unusual structural
10//! shape (e.g., an item whose last child is a fenced or indented code
11//! block whose body contains an empty line).
12//!
13//! Advisory: this is a detector for source/tree disagreement, not a
14//! formatter safety gate.
15
16use crate::diagnostic::Diagnostic;
17use crate::rule::LintRule;
18use mdwright_document::Document;
19use mdwright_document::ListGroup;
20
21pub struct ListTightnessFlipped;
22
23impl LintRule for ListTightnessFlipped {
24    fn name(&self) -> &str {
25        "list-tightness-flipped"
26    }
27
28    fn description(&self) -> &str {
29        "list tightness from the tree disagrees with tightness from source bytes"
30    }
31
32    fn explain(&self) -> &str {
33        include_str!("explain/list_tightness_flipped.md")
34    }
35
36    fn is_default(&self) -> bool {
37        false
38    }
39
40    fn is_advisory(&self) -> bool {
41        true
42    }
43
44    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
45        let source = doc.source();
46        for (group, tree_tight) in doc.list_tightness_view() {
47            let source_tight = source_view_tight(group, source);
48            if tree_tight == source_tight {
49                continue;
50            }
51            let message = if tree_tight {
52                "list reads as tight from parsed items but source has a blank line between items".to_owned()
53            } else {
54                "list reads as loose from parsed items but source has no blank line between items".to_owned()
55            };
56            let start = group.raw_range.start;
57            let local = 0..1;
58            if let Some(d) = Diagnostic::at(doc, start, local, message, None) {
59                out.push(d);
60            }
61        }
62    }
63}
64
65/// Source-bytes view of tightness: a list is tight from the source's
66/// perspective iff no two consecutive items are separated by a blank
67/// line (a line containing only whitespace).
68fn source_view_tight(group: &ListGroup, source: &str) -> bool {
69    let bytes = source.as_bytes();
70    for pair in group.items.windows(2) {
71        let [a, b] = pair else { continue };
72        let gap_start = a.raw_range.end.min(bytes.len());
73        let gap_end = b.raw_range.start.min(bytes.len());
74        let Some(gap) = bytes.get(gap_start..gap_end) else {
75            continue;
76        };
77        if has_blank_line(gap) {
78            return false;
79        }
80    }
81    true
82}
83
84/// `true` iff `bytes` contains a line whose only contents are spaces,
85/// tabs, or carriage returns.
86fn has_blank_line(bytes: &[u8]) -> bool {
87    let mut i = 0;
88    while i < bytes.len() {
89        let line_start = i;
90        while bytes.get(i).is_some_and(|b| *b != b'\n') {
91            i = i.saturating_add(1);
92        }
93        let line = bytes.get(line_start..i).unwrap_or(&[]);
94        // Skip the very first partial line (everything before the
95        // first '\n') — that's the trailing content of the previous
96        // item, not a blank gap.
97        if line_start > 0 && line.iter().all(|b| matches!(*b, b' ' | b'\t' | b'\r')) {
98            return true;
99        }
100        i = i.saturating_add(1);
101    }
102    false
103}