use crate::error::Result;
use crate::{
Document, Violation,
rule::{Rule, RuleCategory, RuleMetadata},
violation::Severity,
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD056;
impl Default for MD056 {
fn default() -> Self {
Self::new()
}
}
impl MD056 {
pub fn new() -> Self {
Self
}
fn count_cells<'a>(&self, node: &'a AstNode<'a>) -> usize {
let mut cell_count = 0;
for child in node.children() {
if matches!(child.data.borrow().value, NodeValue::TableCell) {
cell_count += 1;
}
}
cell_count
}
fn check_table_columns<'a>(&self, ast: &'a AstNode<'a>) -> Vec<Violation> {
let mut violations = Vec::new();
self.traverse_for_tables(ast, &mut violations);
violations
}
fn traverse_for_tables<'a>(&self, node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
if let NodeValue::Table(_) = &node.data.borrow().value {
self.check_table(node, violations);
}
for child in node.children() {
self.traverse_for_tables(child, violations);
}
}
fn check_table<'a>(&self, table_node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
let mut rows = Vec::new();
let mut expected_columns = None;
for child in table_node.children() {
if matches!(child.data.borrow().value, NodeValue::TableRow(..)) {
let cell_count = self.count_cells(child);
let pos = child.data.borrow().sourcepos;
let line = pos.start.line;
let column = pos.start.column;
rows.push((cell_count, line, column));
if expected_columns.is_none() {
expected_columns = Some(cell_count);
}
}
}
let expected = expected_columns.unwrap_or(0);
for (i, (cell_count, line, column)) in rows.iter().enumerate() {
if *cell_count != expected {
let row_type = if i == 0 {
"header row"
} else if i == 1 {
"delimiter row"
} else {
"data row"
};
let message = if *cell_count < expected {
format!(
"Table {} has {} cells, expected {} (missing {} cells)",
row_type,
cell_count,
expected,
expected - cell_count
)
} else {
format!(
"Table {} has {} cells, expected {} (extra {} cells)",
row_type,
cell_count,
expected,
cell_count - expected
)
};
violations.push(self.create_violation(message, *line, *column, Severity::Error));
}
}
}
fn check_tables_fallback(&self, document: &Document) -> Vec<Violation> {
let mut violations = Vec::new();
let mut in_table = false;
let mut expected_columns: Option<usize> = None;
let mut table_row_index = 0;
for (line_num, line) in document.content.lines().enumerate() {
if self.is_table_row(line) {
let cell_count = line.matches('|').count().saturating_sub(1);
if !in_table {
expected_columns = Some(cell_count);
in_table = true;
table_row_index = 0;
} else if let Some(expected) = expected_columns {
if cell_count != expected {
let row_type = if table_row_index == 1 {
"delimiter row"
} else {
"data row"
};
let message = if cell_count < expected {
format!(
"Table {} has {} cells, expected {} (missing {} cells)",
row_type,
cell_count,
expected,
expected - cell_count
)
} else {
format!(
"Table {} has {} cells, expected {} (extra {} cells)",
row_type,
cell_count,
expected,
cell_count - expected
)
};
violations.push(self.create_violation(
message,
line_num + 1,
1,
Severity::Error,
));
}
}
table_row_index += 1;
} else if in_table && line.trim().is_empty() {
in_table = false;
expected_columns = None;
table_row_index = 0;
}
}
violations
}
fn is_table_row(&self, line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
return false;
}
trimmed.matches('|').count() >= 2
}
}
impl Rule for MD056 {
fn id(&self) -> &'static str {
"MD056"
}
fn name(&self) -> &'static str {
"table-column-count"
}
fn description(&self) -> &'static str {
"Table column count"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure)
}
fn check_with_ast<'a>(
&self,
document: &Document,
ast: Option<&'a AstNode<'a>>,
) -> Result<Vec<Violation>> {
if let Some(ast) = ast {
let violations = self.check_table_columns(ast);
Ok(violations)
} else {
Ok(self.check_tables_fallback(document))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{
assert_no_violations, assert_single_violation, assert_violation_count,
};
#[test]
fn test_consistent_table() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell | Cell |
| Cell | Cell |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_missing_cells() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell | Cell |
| Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_extra_cells() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell | Cell |
| Cell | Cell | Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("extra 1 cells"));
}
#[test]
fn test_delimiter_row_mismatch() {
let content = r#"| Header | Header |
| ------ |
| Cell | Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 2);
assert!(violation.message.contains("delimiter row"));
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_multiple_violations() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell |
| Cell | Cell | Cell |
"#;
let violations = assert_violation_count(MD056::new(), content, 2);
assert_eq!(violations[0].line, 3);
assert!(violations[0].message.contains("missing 1 cells"));
assert_eq!(violations[1].line, 4);
assert!(violations[1].message.contains("extra 1 cells"));
}
#[test]
fn test_single_column_table() {
let content = r#"| Header |
| ------ |
| Cell |
| Cell |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_empty_table() {
let content = r#"| |
|---|
| |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_multiple_tables() {
let content = r#"| Table 1 | Header |
| ------- | ------ |
| Cell | Cell |
| Table 2 | Header |
| ------- | ------ |
| Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 7);
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_fallback_multiple_tables() {
let content = r#"| Table 1 | Header |
| ------- | ------ |
| Cell | Cell |
| Table 2 | Header |
| ------- | ------ |
| Cell |
"#;
use std::path::PathBuf;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD056::new();
let violations = rule.check_tables_fallback(&document);
assert_eq!(violations.len(), 1);
let violations = assert_violation_count(rule, content, 1);
assert_eq!(violations[0].line, 7);
assert!(violations[0].message.contains("missing 1 cells"));
}
#[test]
fn test_fallback_method() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell | Cell |
| Cell |
"#;
let rule = MD056::new();
let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 4);
assert!(violations[0].message.contains("missing 1 cells"));
}
#[test]
fn test_edge_case_empty_rows() {
let content = r#"| Header | Header |
| ------ | ------ |
| | |
| |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_table_with_varying_column_counts() {
let content = r#"| A | B | C |
| - | - | - |
| 1 | 2 |
| 4 | 5 | 6 | 7 |
| 8 | 9 | 10 |
"#;
let violations = assert_violation_count(MD056::new(), content, 2);
assert_eq!(violations[0].line, 3);
assert!(violations[0].message.contains("missing 1 cells"));
assert_eq!(violations[1].line, 4);
assert!(violations[1].message.contains("extra 1 cells"));
}
#[test]
fn test_complex_table_structure() {
let content = r#"| Column 1 | Column 2 | Column 3 | Column 4 |
| -------- | -------- | -------- | -------- |
| Data | Data | Data | Data |
| Data | Data | | |
| Data | | | |
| Data | Data | Data | |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_table_with_pipes_in_content() {
let content = r#"| Code | Description |
| ---- | ----------- |
| `a` | Pipe char |
| `b` | With pipe |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_malformed_table_structure() {
let content = r#"| Header | Header |
| Cell | Cell |
| ------ | ------ |
| Cell | Cell |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_table_cell_count_edge_cases() {
let content = r#"| A |
| - |
| |
| B |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_delimiter_row_variations() {
let content = r#"| Header1 | Header2 | Header3 |
|---------|---------|
| Cell | Cell | Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert_eq!(violation.line, 2);
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_no_tables_in_document() {
let content = r#"# Heading
This is just text with no tables.
Some more text here.
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_table_within_other_content() {
let content = r#"# Document Title
Some introductory text.
| Name | Age | City |
| ---- | --- | ---- |
| John | 30 | |
More text after the table.
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_multiple_delimiter_issues() {
let content = r#"| A | B | C |
| - | - |
| 1 | 2 | 3 |
| 4 | 5 |
"#;
let violations = assert_violation_count(MD056::new(), content, 2);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("missing 1 cells"));
assert_eq!(violations[1].line, 4);
assert!(violations[1].message.contains("missing 1 cells"));
}
#[test]
fn test_large_table_consistency() {
let content = r#"| C1 | C2 | C3 | C4 | C5 |
| -- | -- | -- | -- | -- |
| D1 | D2 | D3 | D4 | D5 |
| D1 | D2 | D3 | D4 | D5 |
| D1 | D2 | D3 | D4 | |
| D1 | D2 | D3 | D4 | D5 |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_table_row_parsing_edge_cases() {
let content = r#"| Header |
|--------|
| Cell |
| |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_ast_not_available_error_path() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell |
"#;
let rule = MD056::new();
let violations = rule
.check_with_ast(&crate::test_helpers::create_document(content), None)
.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 1 cells"));
}
#[test]
fn test_complex_table_scenarios() {
let content = r#"| Code | Description |
| ---- | ----------- |
| abc | Pipe char |
| def | Another value |
"#;
assert_no_violations(MD056::new(), content);
}
#[test]
fn test_malformed_table_detection() {
let content = r#"Not a table line
| Header | Header |
Not a table line
| Cell |
"#;
let violation = assert_single_violation(MD056::new(), content);
assert!(violation.message.contains("missing 1 cells"));
}
#[test]
fn test_header_row_edge_cases() {
let content = r#"| Too | Many | Headers | Here |
| --- | --- |
| One | Two |
"#;
let violations = assert_violation_count(MD056::new(), content, 2);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("delimiter row"));
}
#[test]
fn test_count_cells_functionality() {
let rule = MD056::new();
let scenarios = vec![
("| A |", 1),
("| A | B |", 2),
("| A | B | C |", 3),
("|A|B|", 2),
("| | |", 2),
];
for (line, expected_count) in scenarios {
let content = format!(
"{}\n|---|\n{}",
"| Header |"
.repeat(expected_count)
.replace(" |", " | ")
.trim_end(),
line
);
if line.matches('|').count() - 1 != expected_count {
let violations = rule
.check(&crate::test_helpers::create_document(&content))
.unwrap();
assert!(
!violations.is_empty(),
"Expected violation for line: {line}"
);
}
}
}
#[test]
fn test_table_row_detection_edge_cases() {
let content = r#"| Valid | Table | Row |
| ----- | ----- | --- |
Not a table row
| Valid | Row |
|Invalid|
||
| | | |
"#;
let rule = MD056::new();
let violations = rule
.check(&crate::test_helpers::create_document(content))
.unwrap();
assert!(!violations.is_empty());
}
#[test]
fn test_fallback_table_detection() {
let rule = MD056::new();
let content = r#"| Header | Header |
| ------ | ------ |
| Cell | Cell |
Not a table anymore
| Header |
| ------ |
| Cell |
"#;
let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
let _ = violations;
}
#[test]
fn test_table_state_transitions() {
let rule = MD056::new();
let content = r#"Regular text
| Start | Table |
| ----- | ----- |
| Row |
Back to regular text
| Another | Table |
| ------- | ----- |
| Cell | Cell |
"#;
let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("missing 1 cells"));
}
#[test]
fn test_row_type_messages() {
let content = r#"| Header | Header |
| ------ | ------ |
| Cell |"#;
let violation = assert_single_violation(MD056::new(), content);
assert!(violation.message.contains("data row"));
assert!(violation.message.contains("missing"));
}
#[test]
fn test_pipe_counting_edge_cases() {
let rule = MD056::new();
let content = r#"This line has | pipes but isn't a table
| Header | Header |
| ------ | ------ |
| Cell | Cell |
"#;
assert_no_violations(rule, content);
}
#[test]
fn test_expected_column_calculation() {
let scenarios = vec![
(
r#"| A |
| - |
| 1 | 2 |"#,
1,
),
(
r#"| A | B | C |
| - | - | - |
| 1 | 2 |"#,
1,
),
];
for (content, expected_violations) in scenarios {
let violations = assert_violation_count(MD056::new(), content, expected_violations);
assert!(!violations.is_empty());
}
}
}