use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_line_range;
use crate::utils::table_utils::{TableBlock, TableUtils};
mod md055_config;
use md055_config::MD055Config;
#[derive(Debug, Default, Clone)]
pub struct MD055TablePipeStyle {
config: MD055Config,
}
impl MD055TablePipeStyle {
pub fn new(style: String) -> Self {
Self {
config: MD055Config { style },
}
}
pub fn from_config_struct(config: MD055Config) -> Self {
Self { config }
}
fn determine_table_style(&self, table_block: &TableBlock, lines: &[&str]) -> 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(header_content) {
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(content) {
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,
};
self.fix_table_row_with_context(line, target_style, &dummy_block, 0)
}
fn fix_table_row_with_context(
&self,
line: &str,
target_style: &str,
table_block: &TableBlock,
table_line_index: usize,
) -> String {
let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
if let Some(ref list_ctx) = table_block.list_context {
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(stripped.trim(), target_style);
let lp = &list_ctx.list_prefix;
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(stripped.trim(), target_style);
let indent = " ".repeat(content_indent);
format!("{bq_prefix}{indent}{fixed_content}")
}
} else {
let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
if bq_prefix.is_empty() {
fixed_content
} else {
format!("{bq_prefix}{fixed_content}")
}
}
}
fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
if !trimmed.contains('|') {
return trimmed.to_string();
}
let has_leading = trimmed.starts_with('|');
let has_trailing = trimmed.ends_with('|');
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();
}
if has_trailing {
result = result.strip_suffix('|').unwrap_or(result);
result = result.trim_end();
}
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().to_string();
}
result
}
"trailing_only" => {
let mut result = trimmed;
if has_leading {
result = result.strip_prefix('|').unwrap_or(result).trim_start();
}
let mut result = result.to_string();
if !has_trailing {
result = format!("{result} |");
}
result
}
_ => trimmed.to_string(),
}
}
}
impl Rule for MD055TablePipeStyle {
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 line_index = &ctx.line_index;
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let configured_style = 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"
}
};
let table_blocks = &ctx.table_blocks;
for table_block in table_blocks {
let table_style = if configured_style == "consistent" {
self.determine_table_style(table_block, lines)
} 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(content) {
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 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 fixed_line =
self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
let row_range =
line_index.line_col_to_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 {
range: row_range,
replacement: fixed_line,
}),
});
}
}
}
}
Ok(warnings)
}
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
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let json_value = serde_json::to_value(&self.config).ok()?;
Some((
self.name().to_string(),
crate::rule_config_serde::json_to_toml_value(&json_value)?,
))
}
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);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 |");
}
}