use crate::diagnostic::{Diagnostic, Fix};
use crate::rule::LintRule;
use mdwright_document::{Document, MarkdownSignature, ParseOptions, TableAlign, markdown_signature};
pub struct TablePipeSpacing;
impl LintRule for TablePipeSpacing {
fn name(&self) -> &str {
"table-pipe-spacing"
}
fn description(&self) -> &str {
"Table cell separator with no space before it, dropping the row's column alignment."
}
fn explain(&self) -> &str {
include_str!("explain/table_pipe_spacing.md")
}
fn produces_fix(&self) -> bool {
true
}
fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
let source = doc.source();
let bytes = source.as_bytes();
let parse_options = doc.parse_options();
for table in doc.table_sites() {
if !table.alignments().iter().any(|a| !matches!(a, TableAlign::None)) {
continue;
}
let table_range = table.raw_range();
let Some(table_slice) = source.get(table_range.clone()) else {
continue;
};
let Ok(baseline) = markdown_signature(table_slice, parse_options) else {
continue;
};
for (row_idx, row) in table.rows().iter().enumerate() {
if row_idx == 1 {
continue;
}
let Some((_last, leading)) = row.cells().split_last() else {
continue;
};
for cell in leading {
let pipe = cell.raw_range().end;
let abuts_content = pipe
.checked_sub(1)
.and_then(|i| bytes.get(i).copied())
.is_some_and(|b| !b.is_ascii_whitespace());
if !abuts_content {
continue;
}
let Some(rel_pipe) = pipe.checked_sub(table_range.start) else {
continue;
};
if !separator_space_changes_parse(table_slice, rel_pipe, parse_options, &baseline) {
continue;
}
let message = "table cell separator is not preceded by a space — the renderer \
drops this row's column alignment; insert a space before the `|`"
.to_owned();
let fix = Some(Fix {
replacement: " ".to_owned(),
safe: true,
});
if let Some(d) = Diagnostic::at(doc, pipe, 0..0, message, fix) {
out.push(d);
}
}
}
}
}
}
fn separator_space_changes_parse(
table_slice: &str,
rel_pipe: usize,
opts: ParseOptions,
baseline: &MarkdownSignature,
) -> bool {
let (Some(head), Some(tail)) = (table_slice.get(..rel_pipe), table_slice.get(rel_pipe..)) else {
return false;
};
let mut spaced = String::with_capacity(table_slice.len().saturating_add(1));
spaced.push_str(head);
spaced.push(' ');
spaced.push_str(tail);
markdown_signature(&spaced, opts).is_ok_and(|sig| &sig != baseline)
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use mdwright_document::Document;
use super::TablePipeSpacing;
use crate::apply_safe_fixes;
use crate::rule_set::RuleSet;
fn rules() -> Result<RuleSet> {
let mut rs = RuleSet::new();
rs.add(Box::new(TablePipeSpacing)).map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(rs)
}
fn diagnostic_count(src: &str) -> Result<usize> {
Ok(rules()?.check(&Document::parse(src)?).len())
}
#[test]
fn flags_body_cell_separator_without_leading_space() -> Result<()> {
let src = "| File | Words |\n| --- | ---: |\n| a.md| 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 1);
Ok(())
}
#[test]
fn flags_header_cell_separator_without_leading_space() -> Result<()> {
let src = "| File| Words |\n| --- | ---: |\n| a.md | 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 1);
Ok(())
}
#[test]
fn ignores_well_spaced_aligned_table() -> Result<()> {
let src = "| File | Words |\n| --- | ---: |\n| a.md | 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 0);
Ok(())
}
#[test]
fn ignores_compact_delimiter_row() -> Result<()> {
let src = "| File | Words |\n|---|---:|\n| a.md | 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 0);
Ok(())
}
#[test]
fn ignores_table_without_explicit_alignment() -> Result<()> {
let src = "| File | Words |\n| --- | --- |\n| a.md| 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 0);
Ok(())
}
#[test]
fn ignores_escaped_pipe_inside_cell() -> Result<()> {
let src = "| File | Words |\n| --- | ---: |\n| a\\|b | 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 0);
Ok(())
}
#[test]
fn ignores_code_span_before_separator() -> Result<()> {
let src = "| File | Words |\n| --- | ---: |\n| `a.md`| 1.7k |\n";
assert_eq!(diagnostic_count(src)?, 0);
Ok(())
}
#[test]
fn fix_inserts_space_before_separator() -> Result<()> {
let src = "| File | Words |\n| --- | ---: |\n| a.md| 1.7k |\n";
let doc = Document::parse(src)?;
let diags = rules()?.check(&doc);
let fix = diags
.first()
.and_then(|d| d.fix.as_ref())
.ok_or_else(|| anyhow::anyhow!("fix"))?;
assert!(fix.safe);
assert_eq!(fix.replacement, " ");
let (fixed, applied) = apply_safe_fixes(&doc, &diags);
assert_eq!(applied, 1);
assert_eq!(fixed, "| File | Words |\n| --- | ---: |\n| a.md | 1.7k |\n");
let doc2 = Document::parse(&fixed)?;
assert!(rules()?.check(&doc2).is_empty());
Ok(())
}
}