Skip to main content

mdwright_lint/stdlib/
table_pipe_spacing.rs

1//! Table cell separators that abut running text with no space before
2//! them, e.g. `| value| next |`.
3//!
4//! When a `|` cell separator directly follows plain text, pulldown (and
5//! GitHub) drop the whole row's column alignment: a `--:`/`:--` column
6//! stops emitting its `text-align` for that row, so the table renders
7//! ragged. The same source shape blocks `mdwright fmt`'s `align` style,
8//! because padding the cell would insert the missing space and so
9//! change the parsed output. The structural fix is a space before the
10//! separator.
11//!
12//! A pipe that follows a *closed* inline construct — a code span,
13//! emphasis run, or link — parses cleanly and is left alone. Plain text
14//! and a link both end in `)`, so the byte before the pipe cannot tell
15//! the two apart; the rule instead asks the parser directly whether
16//! inserting the space changes how the table parses.
17
18use crate::diagnostic::{Diagnostic, Fix};
19use crate::rule::LintRule;
20use mdwright_document::{Document, MarkdownSignature, ParseOptions, TableAlign, markdown_signature};
21
22pub struct TablePipeSpacing;
23
24impl LintRule for TablePipeSpacing {
25    fn name(&self) -> &str {
26        "table-pipe-spacing"
27    }
28
29    fn description(&self) -> &str {
30        "Table cell separator with no space before it, dropping the row's column alignment."
31    }
32
33    fn explain(&self) -> &str {
34        include_str!("explain/table_pipe_spacing.md")
35    }
36
37    fn produces_fix(&self) -> bool {
38        true
39    }
40
41    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
42        let source = doc.source();
43        let bytes = source.as_bytes();
44        let parse_options = doc.parse_options();
45        for table in doc.table_sites() {
46            // Only tables that declare an explicit column alignment have
47            // anything to lose: a colon-free delimiter row renders the
48            // same compact or spaced, so flagging it would be noise.
49            if !table.alignments().iter().any(|a| !matches!(a, TableAlign::None)) {
50                continue;
51            }
52            let table_range = table.raw_range();
53            let Some(table_slice) = source.get(table_range.clone()) else {
54                continue;
55            };
56            let Ok(baseline) = markdown_signature(table_slice, parse_options) else {
57                continue;
58            };
59            for (row_idx, row) in table.rows().iter().enumerate() {
60                // The delimiter row carries no cell content and parses
61                // correctly even fully compact, so it never triggers the
62                // alignment drop.
63                if row_idx == 1 {
64                    continue;
65                }
66                // Every cell except the last is followed by a separator
67                // pipe at the end of its source range; the trailing edge
68                // pipe (after the last cell) is not a separator.
69                let Some((_last, leading)) = row.cells().split_last() else {
70                    continue;
71                };
72                for cell in leading {
73                    let pipe = cell.raw_range().end;
74                    let abuts_content = pipe
75                        .checked_sub(1)
76                        .and_then(|i| bytes.get(i).copied())
77                        .is_some_and(|b| !b.is_ascii_whitespace());
78                    if !abuts_content {
79                        continue;
80                    }
81                    let Some(rel_pipe) = pipe.checked_sub(table_range.start) else {
82                        continue;
83                    };
84                    // The abutting separator is only a defect if it is
85                    // why the row loses its alignment: the space pulldown
86                    // needs must change how the table parses. A closed
87                    // inline span before the pipe parses cleanly, leaving
88                    // the signature unchanged, and is not flagged.
89                    if !separator_space_changes_parse(table_slice, rel_pipe, parse_options, &baseline) {
90                        continue;
91                    }
92                    let message = "table cell separator is not preceded by a space — the renderer \
93                         drops this row's column alignment; insert a space before the `|`"
94                        .to_owned();
95                    let fix = Some(Fix {
96                        replacement: " ".to_owned(),
97                        safe: true,
98                    });
99                    if let Some(d) = Diagnostic::at(doc, pipe, 0..0, message, fix) {
100                        out.push(d);
101                    }
102                }
103            }
104        }
105    }
106}
107
108/// Whether inserting a space before the separator at `rel_pipe` (a byte
109/// offset into `table_slice`) changes the table's parsed signature.
110fn separator_space_changes_parse(
111    table_slice: &str,
112    rel_pipe: usize,
113    opts: ParseOptions,
114    baseline: &MarkdownSignature,
115) -> bool {
116    let (Some(head), Some(tail)) = (table_slice.get(..rel_pipe), table_slice.get(rel_pipe..)) else {
117        return false;
118    };
119    let mut spaced = String::with_capacity(table_slice.len().saturating_add(1));
120    spaced.push_str(head);
121    spaced.push(' ');
122    spaced.push_str(tail);
123    markdown_signature(&spaced, opts).is_ok_and(|sig| &sig != baseline)
124}
125
126#[cfg(test)]
127mod tests {
128    use anyhow::Result;
129    use mdwright_document::Document;
130
131    use super::TablePipeSpacing;
132    use crate::apply_safe_fixes;
133    use crate::rule_set::RuleSet;
134
135    fn rules() -> Result<RuleSet> {
136        let mut rs = RuleSet::new();
137        rs.add(Box::new(TablePipeSpacing)).map_err(|e| anyhow::anyhow!("{e}"))?;
138        Ok(rs)
139    }
140
141    fn diagnostic_count(src: &str) -> Result<usize> {
142        Ok(rules()?.check(&Document::parse(src)?).len())
143    }
144
145    #[test]
146    fn flags_body_cell_separator_without_leading_space() -> Result<()> {
147        let src = "| File | Words |\n| --- | ---: |\n| a.md| 1.7k |\n";
148        assert_eq!(diagnostic_count(src)?, 1);
149        Ok(())
150    }
151
152    #[test]
153    fn flags_header_cell_separator_without_leading_space() -> Result<()> {
154        let src = "| File| Words |\n| --- | ---: |\n| a.md | 1.7k |\n";
155        assert_eq!(diagnostic_count(src)?, 1);
156        Ok(())
157    }
158
159    #[test]
160    fn ignores_well_spaced_aligned_table() -> Result<()> {
161        let src = "| File | Words |\n| --- | ---: |\n| a.md | 1.7k |\n";
162        assert_eq!(diagnostic_count(src)?, 0);
163        Ok(())
164    }
165
166    #[test]
167    fn ignores_compact_delimiter_row() -> Result<()> {
168        // The delimiter row may be fully compact without dropping
169        // alignment, so it must not be flagged.
170        let src = "| File | Words |\n|---|---:|\n| a.md | 1.7k |\n";
171        assert_eq!(diagnostic_count(src)?, 0);
172        Ok(())
173    }
174
175    #[test]
176    fn ignores_table_without_explicit_alignment() -> Result<()> {
177        let src = "| File | Words |\n| --- | --- |\n| a.md| 1.7k |\n";
178        assert_eq!(diagnostic_count(src)?, 0);
179        Ok(())
180    }
181
182    #[test]
183    fn ignores_escaped_pipe_inside_cell() -> Result<()> {
184        let src = "| File | Words |\n| --- | ---: |\n| a\\|b | 1.7k |\n";
185        assert_eq!(diagnostic_count(src)?, 0);
186        Ok(())
187    }
188
189    #[test]
190    fn ignores_code_span_before_separator() -> Result<()> {
191        // A closed code span before the pipe parses cleanly and keeps
192        // the row's alignment, so it is not a defect even without a
193        // space.
194        let src = "| File | Words |\n| --- | ---: |\n| `a.md`| 1.7k |\n";
195        assert_eq!(diagnostic_count(src)?, 0);
196        Ok(())
197    }
198
199    #[test]
200    fn fix_inserts_space_before_separator() -> Result<()> {
201        let src = "| File | Words |\n| --- | ---: |\n| a.md| 1.7k |\n";
202        let doc = Document::parse(src)?;
203        let diags = rules()?.check(&doc);
204        let fix = diags
205            .first()
206            .and_then(|d| d.fix.as_ref())
207            .ok_or_else(|| anyhow::anyhow!("fix"))?;
208        assert!(fix.safe);
209        assert_eq!(fix.replacement, " ");
210        let (fixed, applied) = apply_safe_fixes(&doc, &diags);
211        assert_eq!(applied, 1);
212        assert_eq!(fixed, "| File | Words |\n| --- | ---: |\n| a.md | 1.7k |\n");
213        let doc2 = Document::parse(&fixed)?;
214        assert!(rules()?.check(&doc2).is_empty());
215        Ok(())
216    }
217}