use crate::config::MarkdownFlavor;
use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
use crate::utils::range_utils::calculate_line_range;
use crate::utils::table_utils::{TableBlock, TableUtils};
mod md055_config;
use md055_config::MD055Config;
static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
#[derive(Debug, Default, Clone)]
pub struct MD055TablePipeStyle {
config: MD055Config,
style_explicit: bool,
}
impl MD055TablePipeStyle {
pub fn new(style: String) -> Self {
Self {
config: MD055Config { style },
style_explicit: true,
}
}
pub fn from_config_struct(config: MD055Config) -> Self {
Self {
config,
style_explicit: false,
}
}
fn effective_configured_style(&self, ctx: &crate::lint_context::LintContext) -> &str {
if ctx.flavor == MarkdownFlavor::MDG {
self.warn_once_about_overridden_style();
return "leading_and_trailing";
}
match self.config.style.as_str() {
"leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
self.config.style.as_str()
}
_ => {
"leading_and_trailing"
}
}
}
fn warn_once_about_overridden_style(&self) {
if !self.style_explicit
|| !matches!(
self.config.style.as_str(),
"no_leading_or_trailing" | "leading_only" | "trailing_only"
)
{
return;
}
MDG_STYLE_OVERRIDE.report(
"MD055",
"style",
&self.config.style,
"leading_and_trailing",
"a Gherkin table row is an indent followed directly by a pipe",
);
}
fn determine_table_style(
&self,
table_block: &TableBlock,
lines: &[&str],
flavor: MarkdownFlavor,
) -> Option<&'static str> {
let mut leading_and_trailing_count = 0;
let mut no_leading_or_trailing_count = 0;
let mut leading_only_count = 0;
let mut trailing_only_count = 0;
let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
if let Some(style) = TableUtils::determine_pipe_style_with_flavor(header_content, flavor) {
match style {
"leading_and_trailing" => leading_and_trailing_count += 1,
"no_leading_or_trailing" => no_leading_or_trailing_count += 1,
"leading_only" => leading_only_count += 1,
"trailing_only" => trailing_only_count += 1,
_ => {}
}
}
for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
if let Some(style) = TableUtils::determine_pipe_style_with_flavor(content, flavor) {
match style {
"leading_and_trailing" => leading_and_trailing_count += 1,
"no_leading_or_trailing" => no_leading_or_trailing_count += 1,
"leading_only" => leading_only_count += 1,
"trailing_only" => trailing_only_count += 1,
_ => {}
}
}
}
let max_count = leading_and_trailing_count
.max(no_leading_or_trailing_count)
.max(leading_only_count)
.max(trailing_only_count);
if max_count > 0 {
if leading_and_trailing_count == max_count {
Some("leading_and_trailing")
} else if no_leading_or_trailing_count == max_count {
Some("no_leading_or_trailing")
} else if leading_only_count == max_count {
Some("leading_only")
} else if trailing_only_count == max_count {
Some("trailing_only")
} else {
None
}
} else {
None
}
}
#[cfg(test)]
fn fix_table_row(&self, line: &str, target_style: &str) -> String {
let dummy_block = TableBlock {
start_line: 0,
end_line: 0,
header_line: 0,
delimiter_line: 0,
content_lines: vec![],
list_context: None,
parser_confirmed: true,
repairable: false,
header_prefix_len: None,
};
self.fix_table_row_with_context(line, line, target_style, &dummy_block, 0, MarkdownFlavor::Standard)
.unwrap_or_else(|| line.to_string())
}
fn fix_table_row_with_context(
&self,
line: &str,
header: &str,
target_style: &str,
table_block: &TableBlock,
table_line_index: usize,
flavor: MarkdownFlavor,
) -> Option<String> {
let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
if let Some(ref list_ctx) = table_block.list_context {
let swallowed = (bq_prefix.len() - bq_prefix.trim_end_matches(' ').len()).saturating_sub(1);
if table_line_index == 0 {
let stripped = after_bq
.strip_prefix(&list_ctx.list_prefix)
.unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
let lp = &list_ctx.list_prefix;
let lp = if lp.trim_matches(' ').is_empty() {
&lp[swallowed.min(lp.len())..]
} else {
lp
};
Some(if bq_prefix.is_empty() && lp.is_empty() {
fixed_content
} else {
format!("{bq_prefix}{lp}{fixed_content}")
})
} else {
let content_indent = list_ctx.content_indent;
let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
let indent = " ".repeat(content_indent.saturating_sub(swallowed));
Some(format!("{bq_prefix}{indent}{fixed_content}"))
}
} else {
let prefix = TableUtils::row_prefix(line, header, table_block, table_line_index);
let after_bq = &line[prefix.len()..];
let fixed_content = self.fix_table_content(trim_row(after_bq), target_style, flavor)?;
let indent = if flavor == MarkdownFlavor::MDG {
&after_bq[..after_bq.len() - after_bq.trim_start_matches([' ', '\t']).len()]
} else {
""
};
Some(if prefix.is_empty() && indent.is_empty() {
fixed_content
} else {
format!("{prefix}{indent}{fixed_content}")
})
}
}
fn fix_table_content(&self, trimmed: &str, target_style: &str, flavor: MarkdownFlavor) -> Option<String> {
if !trimmed.contains('|') {
return Some(trimmed.to_string());
}
let has_leading = trimmed.starts_with('|');
let has_trailing = TableUtils::has_unescaped_trailing_pipe(trimmed, flavor);
let (drops_leading, drops_trailing) = match target_style {
"no_leading_or_trailing" => (has_leading, has_trailing),
"leading_only" => (false, has_trailing),
"trailing_only" => (has_leading, false),
_ => (false, false),
};
let fixed = match target_style {
"leading_and_trailing" => {
let mut result = trimmed.to_string();
if !has_leading {
result = format!("| {result}");
}
if !has_trailing {
result = format!("{result} |");
}
result
}
"no_leading_or_trailing" => {
let mut result = trimmed;
if has_leading {
result = result.strip_prefix('|').unwrap_or(result);
result = result.trim_start_matches([' ', '\t']);
}
if has_trailing {
result = result.strip_suffix('|').unwrap_or(result);
result = result.trim_end_matches([' ', '\t']);
}
result.to_string()
}
"leading_only" => {
let mut result = trimmed.to_string();
if !has_leading {
result = format!("| {result}");
}
if has_trailing {
result = result
.strip_suffix('|')
.unwrap_or(&result)
.trim_end_matches([' ', '\t'])
.to_string();
}
result
}
"trailing_only" => {
let mut result = trimmed;
if has_leading {
result = result
.strip_prefix('|')
.unwrap_or(result)
.trim_start_matches([' ', '\t']);
}
let mut result = result.to_string();
if !has_trailing {
result = format!("{result} |");
}
result
}
_ => return Some(trimmed.to_string()),
};
if drops_leading || drops_trailing {
let cells = TableUtils::split_table_row_with_flavor(trimmed, flavor);
if drops_leading && cells.len() == 1 {
return None;
}
if drops_trailing && (trimmed[..trimmed.len() - 1].ends_with('\\') || fixed.ends_with('\\')) {
return None;
}
if !same_cells(&cells, &TableUtils::split_table_row_with_flavor(&fixed, flavor)) {
return None;
}
if drops_leading && opens_a_block(&fixed) {
return None;
}
}
Some(fixed)
}
}
fn trim_row(row: &str) -> &str {
row.trim_matches([' ', '\t', '\r'])
}
fn same_cells(a: &[String], b: &[String]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x.trim_matches([' ', '\t']) == y.trim_matches([' ', '\t']))
}
fn opens_a_block(text: &str) -> bool {
let rest = text.trim_start_matches(' ');
let Some(first) = rest.chars().next() else {
return false;
};
let after = |n: usize| rest.get(n..).unwrap_or("");
let blank_or_end = |tail: &str| tail.is_empty() || tail.starts_with([' ', '\t']);
match first {
'>' | '<' => true,
'#' => blank_or_end(rest.trim_start_matches('#')),
'`' | '~' => after(0).chars().take_while(|&c| c == first).count() >= 3,
'-' | '+' | '*' => blank_or_end(after(1)),
':' => blank_or_end(after(1)),
'$' => rest.starts_with("$$"),
'[' => rest.contains("]:"),
'0'..='9' => {
let digits = rest.trim_start_matches(|c: char| c.is_ascii_digit());
digits.len() < rest.len() && digits.starts_with(['.', ')']) && blank_or_end(digits.get(1..).unwrap_or(""))
}
_ => false,
}
}
impl Rule for MD055TablePipeStyle {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
(self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(55))
}
fn name(&self) -> &'static str {
"MD055"
}
fn description(&self) -> &'static str {
"Table pipe style should be consistent"
}
fn category(&self) -> RuleCategory {
RuleCategory::Table
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_tables()
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let configured_style = self.effective_configured_style(ctx);
let table_blocks = &ctx.table_blocks;
for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
let table_style = if configured_style == "consistent" {
self.determine_table_style(table_block, lines, ctx.flavor)
} else {
None
};
let target_style = if configured_style == "consistent" {
table_style.unwrap_or("leading_and_trailing")
} else {
configured_style
};
let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
.chain(std::iter::once(table_block.delimiter_line))
.chain(table_block.content_lines.iter().copied())
.collect();
for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
let line = lines[line_idx];
let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
if let Some(current_style) = TableUtils::determine_pipe_style_with_flavor(content, ctx.flavor) {
let needs_fixing = current_style != target_style;
if needs_fixing {
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
let Some(fixed_line) = self.fix_table_row_with_context(
line,
lines[table_block.header_line],
target_style,
table_block,
table_line_idx,
ctx.flavor,
) else {
continue;
};
let message = format!(
"Table pipe style should be {}",
match target_style {
"leading_and_trailing" => "leading and trailing",
"no_leading_or_trailing" => "no leading or trailing",
"leading_only" => "leading only",
"trailing_only" => "trailing only",
_ => target_style,
}
);
let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
message,
line: start_line,
column: start_col,
end_line,
end_column: end_col,
fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
});
}
}
}
}
Ok(warnings)
}
fn fixes_from_diagnostics(&self) -> bool {
true
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD055Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
let style_explicit = option_is_explicit(config, "MD055", "style");
Box::new(Self {
config: rule_config,
style_explicit,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipe_styles_preserve_escaped_trailing_cell_content() {
let source = "名 | B\n--- | ---\nleft | right \\|\n";
for (style, expected) in [
("consistent", source),
("no_leading_or_trailing", source),
("leading_only", "| 名 | B\n| --- | ---\n| left | right \\|\n"),
("trailing_only", "名 | B |\n--- | --- |\nleft | right \\| |\n"),
(
"leading_and_trailing",
"| 名 | B |\n| --- | --- |\n| left | right \\| |\n",
),
] {
let rule = MD055TablePipeStyle::new(style.to_string());
let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
expected,
"cell content must remain intact: {style}"
);
let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
let mut original_html = String::new();
let mut fixed_html = String::new();
let options = pulldown_cmark::Options::ENABLE_TABLES;
pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new_ext(source, options));
pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new_ext(expected, options));
assert_eq!(original_html, fixed_html, "rendered table cells must be preserved");
}
}
#[test]
fn test_mdg_adds_a_delimiter_after_an_escaped_cell_pipe() {
let rule = MD055TablePipeStyle::default();
let source = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\|\n";
let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::MDG, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 6);
let expected = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\| |\n";
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
let config: md055_config::MD055Config =
toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
MD055TablePipeStyle::from_config_struct(config)
}
#[test]
fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
let rule = rule_from_toml_style("no-leading-or-trailing");
let content = "A | B\n--- | ---\n1 | 2";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
);
}
#[test]
fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
let rule = rule_from_toml_style("no-leading-or-trailing");
let content = "| A | B |\n|---|---|\n| 1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
3,
"no-leading-or-trailing should flag all 3 rows with pipes"
);
assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
}
#[test]
fn test_leading_only_kebab_accepts_conforming_table() {
let rule = rule_from_toml_style("leading-only");
let content = "| A | B\n|---|---\n| 1 | 2";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"leading-only should accept a leading-only table: {warnings:?}"
);
}
#[test]
fn test_trailing_only_kebab_accepts_conforming_table() {
let rule = rule_from_toml_style("trailing-only");
let content = "A | B |\n---|---|\n1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"trailing-only should accept a trailing-only table: {warnings:?}"
);
}
#[test]
fn test_trailing_only_kebab_rejects_nonconforming_table() {
let rule = rule_from_toml_style("trailing-only");
let content = "| A | B |\n|---|---|\n| 1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
3,
"trailing-only should flag all 3 rows that have leading pipes"
);
assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
}
#[test]
fn test_leading_only_kebab_rejects_nonconforming_table() {
let rule = rule_from_toml_style("leading-only");
let content = "A | B |\n---|---|\n1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
3,
"leading-only should flag all 3 rows that have trailing pipes"
);
assert!(warnings.iter().all(|w| w.message.contains("leading only")));
}
#[test]
fn test_leading_and_trailing_kebab_accepts_conforming_table() {
let rule = rule_from_toml_style("leading-and-trailing");
let content = "| A | B |\n|---|---|\n| 1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"leading-and-trailing should accept a fully-piped table: {warnings:?}"
);
}
#[test]
fn test_kebab_and_snake_case_styles_are_equivalent() {
let pairs = [
("no-leading-or-trailing", "no_leading_or_trailing"),
("leading-only", "leading_only"),
("trailing-only", "trailing_only"),
("leading-and-trailing", "leading_and_trailing"),
];
let content = "| A | B |\n|---|---|\n| 1 | 2 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
for (kebab, snake) in pairs {
let kebab_rule = rule_from_toml_style(kebab);
let snake_rule = rule_from_toml_style(snake);
let kebab_warnings = kebab_rule.check(&ctx).unwrap();
let snake_warnings = snake_rule.check(&ctx).unwrap();
assert_eq!(
kebab_warnings.len(),
snake_warnings.len(),
"'{kebab}' and '{snake}' must produce the same number of warnings"
);
for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
assert_eq!(
kw.message, sw.message,
"warning[{i}] message differs between '{kebab}' and '{snake}'"
);
assert_eq!(
kw.line, sw.line,
"warning[{i}] line differs between '{kebab}' and '{snake}'"
);
}
}
}
fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
let rule = rule_from_toml_style(style);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let remaining = rule.check(&ctx2).unwrap();
assert!(
remaining.is_empty(),
"style '{style}': after fix(), check() should find 0 violations.\n\
Original: {content:?}\n\
Fixed: {fixed:?}\n\
Remaining: {remaining:?}"
);
}
#[test]
fn test_roundtrip_kebab_no_leading_or_trailing() {
assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
}
#[test]
fn test_roundtrip_kebab_leading_and_trailing() {
assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
}
#[test]
fn test_roundtrip_kebab_leading_only() {
assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
}
#[test]
fn test_roundtrip_kebab_trailing_only() {
assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
}
#[test]
fn test_md055_delimiter_row_handling() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
assert_eq!(result, expected);
let warnings = rule.check(&ctx).unwrap();
let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
assert_eq!(
delimiter_warning.message,
"Table pipe style should be no leading or trailing"
);
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
assert_eq!(result, expected);
}
#[test]
fn test_md055_check_finds_delimiter_row_issues() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 3);
let delimiter_warning = &warnings[1];
assert_eq!(delimiter_warning.line, 2);
assert_eq!(
delimiter_warning.message,
"Table pipe style should be no leading or trailing"
);
}
#[test]
fn test_md055_real_world_example() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
let content = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |\n| Data 4 | Data 5 | Data 6 |\n\nMore content after the table.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\nHeader 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3\nData 4 | Data 5 | Data 6\n\nMore content after the table.";
assert_eq!(result, expected);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 4);
assert_eq!(warnings[0].line, 5); assert_eq!(warnings[1].line, 6); assert_eq!(warnings[2].line, 7); assert_eq!(warnings[3].line, 8); }
#[test]
fn test_md055_invalid_style() {
let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string());
let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
assert_eq!(result, expected);
let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx2).unwrap();
let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
assert_eq!(result, expected);
let warnings = rule.check(&ctx2).unwrap();
assert_eq!(warnings.len(), 3);
}
#[test]
fn test_underflow_protection() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let result = rule.fix_table_row("", "leading_and_trailing");
assert_eq!(result, "");
let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
assert_eq!(result, "no pipes here");
let result = rule.fix_table_row("|", "leading_and_trailing");
assert!(!result.is_empty());
}
#[test]
fn test_fix_table_row_in_blockquote() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
assert_eq!(result, "> | H1 | H2 |");
let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
assert_eq!(result, "> | H1 | H2 |");
let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
assert_eq!(result, "> H1 | H2");
}
#[test]
fn test_fix_table_row_in_nested_blockquote() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
assert_eq!(result, ">> | H1 | H2 |");
let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
assert_eq!(result, ">>> | H1 | H2 |");
}
#[test]
fn test_blockquote_table_full_document() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "> H1 | H2\n> ----|----\n> a | b";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
assert!(
result.starts_with("> |"),
"Header should start with blockquote + pipe. Got:\n{result}"
);
assert!(
result.contains("> | ----"),
"Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
);
}
#[test]
fn test_blockquote_table_no_leading_trailing() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
let content = "> | H1 | H2 |\n> |----|----|\n> | a | b |";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
assert!(
!lines[0].starts_with("> |"),
"Leading pipe should be removed. Got: {}",
lines[0]
);
}
#[test]
fn test_mixed_regular_and_blockquote_tables() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
assert!(
result.contains("> | H3 | H4 |"),
"Blockquote table should have pipes added with prefix preserved"
);
}
fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let remaining = rule.check(&ctx2).unwrap();
assert!(
remaining.is_empty(),
"After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
);
}
#[test]
fn test_roundtrip_leading_and_trailing() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
}
#[test]
fn test_roundtrip_no_leading_or_trailing() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
}
#[test]
fn test_roundtrip_consistent_mode() {
let rule = MD055TablePipeStyle::default();
assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
}
#[test]
fn test_roundtrip_blockquote_table() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
}
#[test]
fn test_roundtrip_mixed_tables() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
}
#[test]
fn test_roundtrip_with_surrounding_content() {
let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
}
#[test]
fn test_roundtrip_clean_content() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
}
#[test]
fn md055_pandoc_grid_tables_not_flagged() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "\
+---+---+
| a | b |
+===+===+
| 1 | 2 |
+---+---+
";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
);
let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result_std = rule.check(&ctx_std).unwrap();
assert!(
result_std.is_empty(),
"MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
);
}
#[test]
fn md055_pandoc_multi_line_tables_not_flagged() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "\
--------- ----------- ------
Header 1 Header 2 Header 3
--------- ----------- ------
Cell 1 Cell 2 Cell 3
--------- ----------- ------
";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD055 should not flag Pandoc multi-line tables: {result:?}"
);
let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result_std = rule.check(&ctx_std).unwrap();
assert!(
result_std.is_empty(),
"MD055 should not flag multi-line table content under Standard: {result_std:?}"
);
}
#[test]
fn md055_pandoc_line_blocks_not_flagged() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "| First line\n| Second line\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD055 should not treat Pandoc line blocks as tables: {result:?}"
);
let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result_std = rule.check(&ctx_std).unwrap();
assert!(
result_std.is_empty(),
"MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
);
}
#[test]
fn md055_pandoc_pipe_table_captions_not_flagged() {
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = "\
| H1 | H2 |
|----|-----|
| a | b |
: My table caption
";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD055 should not flag the pipe-table caption line: {result:?}"
);
let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result_std = rule.check(&ctx_std).unwrap();
assert!(
result_std.is_empty(),
"MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
);
}
const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
(
"no_leading_or_trailing",
["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
),
(
"leading_only",
["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
),
(
"trailing_only",
["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
),
];
const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
fn examples_table(indent: usize, rows: [&str; 3]) -> String {
let spaces = " ".repeat(indent);
let [header, delimiter, body] = rows;
format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
}
#[test]
fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
for (style, rows) in MDG_INCOMPATIBLE_STYLES {
let rule = MD055TablePipeStyle::new(style.to_string());
for indent in [2, 3, 4, 5] {
let content = examples_table(indent, rows);
let expected = examples_table(indent, MDG_GHERKIN_ROWS);
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
assert_eq!(
rule.check(&ctx).unwrap().len(),
3,
"style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, expected,
"style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
);
let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(
rule.fix(&fixed_ctx).unwrap(),
fixed,
"style '{style}' at indent {indent}: MDG fix must be idempotent"
);
}
}
}
#[test]
fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
let content = examples_table(2, MDG_GHERKIN_ROWS);
for style in [
"consistent",
"leading_and_trailing",
"no_leading_or_trailing",
"leading_only",
"trailing_only",
] {
let rule = MD055TablePipeStyle::new(style.to_string());
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
assert!(
rule.check(&ctx).unwrap().is_empty(),
"style '{style}': MDG enforces this form, so it cannot be reported"
);
assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
}
}
#[test]
fn test_mdg_consistent_ignores_prevalence() {
let defaulted = MD055TablePipeStyle::default();
assert_eq!(defaulted.config.style, "consistent");
let explicit = MD055TablePipeStyle::new("consistent".to_string());
for (style, rows) in MDG_INCOMPATIBLE_STYLES {
let content = examples_table(2, rows);
let expected = examples_table(2, MDG_GHERKIN_ROWS);
for rule in [&defaulted, &explicit] {
let standard_ctx =
crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.check(&standard_ctx).unwrap().is_empty(),
"Standard resolves `consistent` to the table's own '{style}'"
);
let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
assert_eq!(
rule.fix(&mdg_ctx).unwrap(),
expected,
"MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
);
}
}
}
#[test]
fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
for (style, rows) in MDG_INCOMPATIBLE_STYLES {
let rule = MD055TablePipeStyle::new(style.to_string());
let content = examples_table(2, rows);
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.check(&ctx).unwrap().is_empty(),
"style '{style}': Standard must still honour it"
);
assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
let mdg_form_ctx =
crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.check(&mdg_form_ctx).unwrap().len(),
3,
"style '{style}': Standard must still correct the leading-and-trailing form away"
);
}
let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
examples_table(0, MDG_GHERKIN_ROWS),
"Standard must keep stripping the indent"
);
}
#[test]
fn test_from_config_records_whether_style_was_configured() {
use crate::config::Config;
use std::collections::BTreeMap;
let mut values = BTreeMap::new();
values.insert(
"style".to_string(),
toml::Value::String("no_leading_or_trailing".to_string()),
);
let mut config = Config::default();
config.rules.insert(
"MD055".to_string(),
crate::config::RuleConfig { severity: None, values },
);
let configured = MD055TablePipeStyle::from_config(&config);
let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
assert_eq!(configured.config.style, "no_leading_or_trailing");
assert!(configured.style_explicit);
let defaulted = MD055TablePipeStyle::from_config(&Config::default());
let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
assert_eq!(defaulted.config.style, "consistent");
assert!(!defaulted.style_explicit);
let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
style: "no_leading_or_trailing".to_string(),
});
assert!(!unreported.style_explicit);
let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
}
fn fixed_by(style: &str, source: &str) -> (usize, String) {
let rule = MD055TablePipeStyle::new(style.to_string());
let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
(rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
}
#[test]
fn test_one_column_table_is_not_reduced_to_pipe_free_rows() {
for source in [
"| a |\n| - |\n| x |\n| y |\n",
"| a |\n|---|\n| x |\n| y |\n",
"| a |\n| - |\n",
"a |\n- |\nx |\n",
] {
for style in ["no_leading_or_trailing", "trailing_only"] {
assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}: {source:?}");
}
}
}
#[test]
fn test_one_column_table_keeps_the_leading_pipe_styles() {
let full = "| a |\n| - |\n| x |\n";
let leading = "| a\n| -\n| x\n";
assert_eq!(fixed_by("leading_only", full), (3, leading.to_string()));
assert_eq!(fixed_by("leading_only", leading), (0, leading.to_string()));
for source in [full, leading] {
assert_eq!(fixed_by("consistent", source), (0, source.to_string()));
}
}
#[test]
fn test_empty_edge_cells_keep_their_pipes() {
let source = "| a | b |\n| --- | --- |\n| | b |\n| a | |\n| | |\n";
let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
assert_eq!(fixed, "a | b\n--- | ---\n| | b |\n| a | |\n| | |\n");
assert_eq!(warnings, 2);
let (warnings, fixed) = fixed_by("leading_only", source);
assert_eq!(fixed, "| a | b\n| --- | ---\n| | b\n| a | |\n| | |\n");
assert_eq!(warnings, 3);
let (_, fixed) = fixed_by("trailing_only", source);
assert_eq!(fixed, "a | b |\n--- | --- |\n| | b |\na | |\n| | |\n");
let source = "||\n|-|\n||\n";
for style in ["no_leading_or_trailing", "trailing_only"] {
assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}");
}
assert_eq!(fixed_by("leading_only", source), (1, "||\n|-\n||\n".to_string()));
}
#[test]
fn test_cells_that_would_open_a_block_keep_the_leading_pipe() {
let source = "| # a | b |\n| --- | --- |\n| x | y |\n";
assert_eq!(
fixed_by("no_leading_or_trailing", source),
(2, "| # a | b |\n--- | ---\nx | y\n".to_string())
);
let source = "| a | b |\n| - | - |\n| x | y |\n";
let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
assert_eq!((warnings, fixed.as_str()), (2, "a | b\n| - | - |\nx | y\n"));
let source = "| a | b |\n|:---|---:|\n| x | y |\n";
assert_eq!(
fixed_by("no_leading_or_trailing", source),
(3, "a | b\n:---|---:\nx | y\n".to_string())
);
}
#[test]
fn test_other_whitespace_in_a_cell_is_kept() {
let source = "| a | b |\n| --- | --- |\n| \u{2003}x | y\u{2003} |\n";
let (_, fixed) = fixed_by("no_leading_or_trailing", source);
assert_eq!(fixed, "a | b\n--- | ---\n\u{2003}x | y\u{2003}\n");
let source = "| a | b |\n| --- | --- |\n| x | y |\u{2003}\n";
let (warnings, fixed) = fixed_by("leading_only", source);
assert_eq!(warnings, 2);
assert_eq!(fixed, "| a | b\n| --- | ---\n| x | y |\u{2003}\n");
}
#[test]
fn test_escaped_and_backslash_ended_trailing_cells_keep_their_pipe() {
for tail in ["b\\|", "b\\\\|", "b\\ |"] {
let source = format!("| h | j |\n| --- | --- |\n| a | {tail}\n");
let (_, fixed) = fixed_by("leading_only", &source);
assert!(fixed.ends_with(&format!("| a | {tail}\n")), "{source:?} -> {fixed:?}");
}
}
#[test]
fn test_plain_tables_are_still_restyled() {
let source = "| a | b |\n| --- | --- |\n| x | y |\n";
assert_eq!(
fixed_by("no_leading_or_trailing", source),
(3, "a | b\n--- | ---\nx | y\n".to_string())
);
assert_eq!(
fixed_by("trailing_only", source),
(3, "a | b |\n--- | --- |\nx | y |\n".to_string())
);
assert_eq!(
fixed_by("leading_only", "a | b\n--- | ---\nx | y\n"),
(3, "| a | b\n| --- | ---\n| x | y\n".to_string())
);
}
#[test]
fn test_non_ascii_space_after_the_last_pipe_is_not_padding() {
for tail in ["| \u{2003}", "|\u{2003}"] {
let source = format!("| h | j | k |\n| - | - | - |\n| a | b {tail}\n");
let (warnings, fixed) = fixed_by("leading_only", &source);
assert_eq!(warnings, 2, "{source:?}");
assert_eq!(fixed, format!("| h | j | k\n| - | - | -\n| a | b {tail}\n"));
assert_eq!(fixed_by("leading_only", &fixed), (0, fixed.clone()));
}
}
}