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; }
let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1;
let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
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 fixed_line = self
.fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
.unwrap_or_else(|| line.to_string());
if line_idx < lines.len() - 1 {
fixed_table_lines.push(format!("{fixed_line}\n"));
} else {
fixed_table_lines.push(fixed_line);
}
}
let table_replacement = fixed_table_lines.concat();
let table_range = ctx.line_index.multi_line_range(table_start_line, table_end_line);
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);
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: table_range.clone(),
replacement: table_replacement.clone(),
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let flavor = ctx.flavor;
let lines = ctx.raw_lines();
let table_blocks = &ctx.table_blocks;
let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
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_num = line_idx + 1;
if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
continue;
}
let line = lines[line_idx];
let row_content = TableUtils::extract_table_row_content(line, table_block, i);
if let Some(fixed_line) =
self.fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
{
result_lines[line_idx] = fixed_line;
}
}
}
let mut fixed = result_lines.join("\n");
if content.ends_with('\n') && !fixed.ends_with('\n') {
fixed.push('\n');
}
Ok(fixed)
}
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 |"));
}
}