use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_line_range;
use crate::utils::table_utils::TableUtils;
#[derive(Debug, Clone)]
pub struct MD056TableColumnCount;
impl Default for MD056TableColumnCount {
fn default() -> Self {
MD056TableColumnCount
}
}
impl MD056TableColumnCount {
fn fix_table_row_content(
&self,
row_content: &str,
expected_count: usize,
flavor: crate::config::MarkdownFlavor,
table_block: &crate::utils::table_utils::TableBlock,
line_index: usize,
original_line: &str,
) -> Option<String> {
let current_count = TableUtils::count_cells_with_flavor(row_content, flavor);
if current_count == expected_count || current_count == 0 {
return None;
}
let fixed = self.fix_row_by_truncation(row_content, expected_count, flavor)?;
Some(self.restore_prefixes(&fixed, table_block, line_index, original_line))
}
fn restore_prefixes(
&self,
fixed_content: &str,
table_block: &crate::utils::table_utils::TableBlock,
line_index: usize,
original_line: &str,
) -> String {
let (blockquote_prefix, _) = TableUtils::extract_blockquote_prefix(original_line);
if let Some(ref list_ctx) = table_block.list_context {
if line_index == 0 {
format!("{blockquote_prefix}{}{fixed_content}", list_ctx.list_prefix)
} else {
let indent = " ".repeat(list_ctx.content_indent);
format!("{blockquote_prefix}{indent}{fixed_content}")
}
} else {
if blockquote_prefix.is_empty() {
fixed_content.to_string()
} else {
format!("{blockquote_prefix}{fixed_content}")
}
}
}
fn fix_row_by_truncation(
&self,
row: &str,
expected_count: usize,
flavor: crate::config::MarkdownFlavor,
) -> Option<String> {
let current_count = TableUtils::count_cells_with_flavor(row, flavor);
if current_count == expected_count || current_count == 0 {
return None;
}
let trimmed = row.trim();
let has_leading_pipe = trimmed.starts_with('|');
let has_trailing_pipe = trimmed.ends_with('|');
let cells = TableUtils::split_table_row_with_flavor(trimmed, flavor);
let mut cell_contents: Vec<&str> = cells.iter().map(|c| c.trim()).collect();
match current_count.cmp(&expected_count) {
std::cmp::Ordering::Greater => {
cell_contents.truncate(expected_count);
}
std::cmp::Ordering::Less => {
while cell_contents.len() < expected_count {
cell_contents.push("");
}
}
std::cmp::Ordering::Equal => {
}
}
let mut result = String::new();
if has_leading_pipe {
result.push('|');
}
for (i, cell) in cell_contents.iter().enumerate() {
result.push_str(&format!(" {cell} "));
if i < cell_contents.len() - 1 || has_trailing_pipe {
result.push('|');
}
}
Some(result)
}
}
impl Rule for MD056TableColumnCount {
fn name(&self) -> &'static str {
"MD056"
}
fn description(&self) -> &'static str {
"Table column count 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 content = ctx.content;
let flavor = ctx.flavor;
let mut warnings = Vec::new();
if content.is_empty() || !content.contains('|') {
return Ok(Vec::new());
}
let lines = ctx.raw_lines();
let table_blocks = &ctx.table_blocks;
for table_block in table_blocks {
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();
let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
if expected_count == 0 {
continue; }
for (i, &line_idx) in all_line_indices.iter().enumerate() {
let line = lines[line_idx];
let row_content = TableUtils::extract_table_row_content(line, table_block, i);
let count = TableUtils::count_cells_with_flavor(row_content, flavor);
if count > 0 && count != expected_count {
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
let fixed_line = self
.fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
.unwrap_or_else(|| line.to_string());
let row_range =
ctx.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()),
message: format!("Table row has {count} cells, but expected {expected_count}"),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(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 from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD056TableColumnCount)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_valid_table() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_too_few_columns() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 |
| Cell 4 | Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
assert!(result[0].message.contains("has 2 cells, but expected 3"));
}
#[test]
fn test_too_many_columns() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
| Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
assert!(result[0].message.contains("has 4 cells, but expected 2"));
}
#[test]
fn test_delimiter_row_mismatch() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3 |
|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
assert!(result[0].message.contains("has 2 cells, but expected 3"));
}
#[test]
fn test_fix_too_few_columns() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 |
| Cell 4 | Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
}
#[test]
fn test_fix_too_many_columns() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
| Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("| Cell 1 | Cell 2 |"));
assert!(!fixed.contains("Cell 3"));
assert!(!fixed.contains("Cell 4"));
}
#[test]
fn test_no_leading_pipe() {
let rule = MD056TableColumnCount;
let content = "Header 1 | Header 2 | Header 3 |
---------|----------|----------|
Cell 1 | Cell 2 |
Cell 4 | Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
}
#[test]
fn test_no_trailing_pipe() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3
|----------|----------|----------
| Cell 1 | Cell 2
| Cell 4 | Cell 5 | Cell 6";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
}
#[test]
fn test_no_pipes_at_all() {
let rule = MD056TableColumnCount;
let content = "This is not a table
Just regular text
No pipes here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_empty_cells() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| | | |
| Cell 1 | | Cell 3 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_multiple_tables() {
let rule = MD056TableColumnCount;
let content = "| Table 1 Col 1 | Table 1 Col 2 |
|----------------|----------------|
| Data 1 | Data 2 |
Some text in between.
| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
|----------------|----------------|----------------|
| Data 3 | Data 4 |
| Data 5 | Data 6 | Data 7 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 9);
assert!(result[0].message.contains("has 2 cells, but expected 3"));
}
#[test]
fn test_table_with_escaped_pipes() {
let rule = MD056TableColumnCount;
let content = "| Command | Description |
|---------|-------------|
| `echo \\| grep` | Pipe example |
| `ls` | List files |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
let content_double = "| Command | Description |
|---------|-------------|
| `echo \\\\| grep` | Pipe example |
| `ls` | List files |";
let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 0, "pipes inside code spans should not split cells");
}
#[test]
fn test_empty_content() {
let rule = MD056TableColumnCount;
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_code_block_with_table() {
let rule = MD056TableColumnCount;
let content = "```
| This | Is | Code |
|------|----|----|
| Not | A | Table |
```
| Real | Table |
|------|-------|
| Data | Here |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fix_preserves_pipe_style() {
let rule = MD056TableColumnCount;
let content = "| Header 1 | Header 2 | Header 3
|----------|----------|----------
| Cell 1 | Cell 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert!(!lines[2].ends_with('|'));
assert!(lines[2].contains("Cell 1"));
assert!(lines[2].contains("Cell 2"));
}
#[test]
fn test_single_column_table() {
let rule = MD056TableColumnCount;
let content = "| Header |
|---------|
| Cell 1 |
| Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_complex_delimiter_row() {
let rule = MD056TableColumnCount;
let content = "| Left | Center | Right |
|:-----|:------:|------:|
| L | C | R |
| Left | Center |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 4);
}
#[test]
fn test_unicode_content() {
let rule = MD056TableColumnCount;
let content = "| 名前 | 年齢 | 都市 |
|------|------|------|
| 田中 | 25 | 東京 |
| 佐藤 | 30 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 4);
}
#[test]
fn test_very_long_cells() {
let rule = MD056TableColumnCount;
let content = "| Short | Very very very very very very very very very very long header | Another |
|-------|--------------------------------------------------------------|---------|
| Data | This is an extremely long cell content that goes on and on |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("has 2 cells, but expected 3"));
}
#[test]
fn test_fix_with_newline_ending() {
let rule = MD056TableColumnCount;
let content = "| A | B | C |
|---|---|---|
| 1 | 2 |
";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.ends_with('\n'));
assert!(fixed.contains("| 1 | 2 | |"));
}
#[test]
fn test_fix_without_newline_ending() {
let rule = MD056TableColumnCount;
let content = "| A | B | C |
|---|---|---|
| 1 | 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(!fixed.ends_with('\n'));
assert!(fixed.contains("| 1 | 2 | |"));
}
#[test]
fn test_blockquote_table_column_mismatch() {
let rule = MD056TableColumnCount;
let content = "> | Header 1 | Header 2 | Header 3 |
> |----------|----------|----------|
> | Cell 1 | Cell 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3);
assert!(result[0].message.contains("has 2 cells, but expected 3"));
}
#[test]
fn test_fix_blockquote_table_preserves_prefix() {
let rule = MD056TableColumnCount;
let content = "> | Header 1 | Header 2 | Header 3 |
> |----------|----------|----------|
> | Cell 1 | Cell 2 |
> | Cell 4 | Cell 5 | Cell 6 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
for line in fixed.lines() {
assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
}
assert!(fixed.contains("> | Cell 1 | Cell 2 | |"));
}
#[test]
fn test_fix_nested_blockquote_table() {
let rule = MD056TableColumnCount;
let content = ">> | A | B | C |
>> |---|---|---|
>> | 1 | 2 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
for line in fixed.lines() {
assert!(
line.starts_with(">> "),
"Line should preserve nested blockquote prefix: {line}"
);
}
assert!(fixed.contains(">> | 1 | 2 | |"));
}
#[test]
fn test_blockquote_table_too_many_columns() {
let rule = MD056TableColumnCount;
let content = "> | A | B |
> |---|---|
> | 1 | 2 | 3 | 4 |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
assert!(fixed.contains("> | 1 | 2 |"));
assert!(!fixed.contains("| 3 |"));
}
fn assert_fix_roundtrip(content: &str) {
let rule = MD056TableColumnCount;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = 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_too_few_columns() {
assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |");
}
#[test]
fn test_roundtrip_too_many_columns() {
assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 | 3 | 4 |");
}
#[test]
fn test_roundtrip_with_trailing_newline() {
assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |\n");
}
#[test]
fn test_roundtrip_blockquote_table() {
assert_fix_roundtrip("> | A | B | C |\n> |---|---|---|\n> | 1 | 2 |");
}
#[test]
fn test_roundtrip_clean_table() {
assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |");
}
#[test]
fn test_roundtrip_multiple_tables() {
assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |\n\nText\n\n| C | D | E |\n|---|---|---|\n| 3 | 4 |");
}
}