mdwright_lint/stdlib/
table_pipe_spacing.rs1use 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 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 if row_idx == 1 {
64 continue;
65 }
66 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 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
108fn 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 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 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}