quickmark_core/rules/
md056.rs

1use std::rc::Rc;
2
3use tree_sitter::Node;
4
5use crate::{
6    linter::{range_from_tree_sitter, RuleViolation},
7    rules::{Context, Rule, RuleLinter, RuleType},
8};
9
10/// MD056 - Table column count
11///
12/// This rule checks that all rows in a table have the same number of columns.
13pub(crate) struct MD056Linter {
14    context: Rc<Context>,
15    violations: Vec<RuleViolation>,
16}
17
18impl MD056Linter {
19    pub fn new(context: Rc<Context>) -> Self {
20        Self {
21            context,
22            violations: Vec::new(),
23        }
24    }
25
26    fn check_table_column_count(&mut self, table_node: &Node) {
27        let mut cursor = table_node.walk();
28        let mut table_rows = table_node.children(&mut cursor).filter(|child| {
29            matches!(
30                child.kind(),
31                "pipe_table_header" | "pipe_table_row" | "pipe_table_delimiter_row"
32            )
33        });
34
35        let Some(first_row) = table_rows.next() else {
36            return;
37        };
38
39        let expected_column_count = self.count_table_cells(&first_row);
40
41        // The first row determines the expected count, so we only need to check subsequent rows.
42        for row in table_rows {
43            let actual_column_count = self.count_table_cells(&row);
44
45            if actual_column_count == expected_column_count {
46                continue;
47            }
48
49            let (message, column_offset) = if actual_column_count < expected_column_count {
50                (
51                    format!(
52                        "Too few cells, row will be missing data (expected {expected_column_count}, got {actual_column_count})"
53                    ),
54                    self.get_row_end_position(&row),
55                )
56            } else {
57                (
58                    format!(
59                        "Too many cells, extra data will be missing (expected {expected_column_count}, got {actual_column_count})"
60                    ),
61                    self.get_extra_cells_position(&row, expected_column_count),
62                )
63            };
64
65            let mut range = range_from_tree_sitter(&row.range());
66            range.start.character += column_offset;
67            range.end.character = range.start.character + 1;
68
69            self.violations.push(RuleViolation::new(
70                &MD056,
71                message,
72                self.context.file_path.clone(),
73                range,
74            ));
75        }
76    }
77
78    fn count_table_cells(&self, row_node: &Node) -> usize {
79        row_node
80            .children(&mut row_node.walk())
81            .filter(|child| {
82                matches!(
83                    child.kind(),
84                    "pipe_table_cell" | "pipe_table_delimiter_cell"
85                )
86            })
87            .count()
88    }
89
90    fn get_row_end_position(&self, row_node: &Node) -> usize {
91        let document_content = self.context.document_content.borrow();
92        let row_text = row_node
93            .utf8_text(document_content.as_bytes())
94            .unwrap_or("");
95
96        // Find the end of the actual content (excluding trailing whitespace) minus 1 to match original
97        row_text.trim_end().len().saturating_sub(1)
98    }
99
100    fn get_extra_cells_position(&self, row_node: &Node, expected_count: usize) -> usize {
101        row_node
102            .children(&mut row_node.walk())
103            .filter(|child| {
104                matches!(
105                    child.kind(),
106                    "pipe_table_cell" | "pipe_table_delimiter_cell"
107                )
108            })
109            .nth(expected_count)
110            .map(|extra_cell| extra_cell.start_position().column - row_node.start_position().column)
111            .unwrap_or(0)
112    }
113}
114
115impl RuleLinter for MD056Linter {
116    fn feed(&mut self, node: &Node) {
117        if node.kind() == "pipe_table" {
118            self.check_table_column_count(node);
119        }
120    }
121
122    fn finalize(&mut self) -> Vec<RuleViolation> {
123        std::mem::take(&mut self.violations)
124    }
125}
126
127pub const MD056: Rule = Rule {
128    id: "MD056",
129    alias: "table-column-count",
130    tags: &["table"],
131    description: "Table column count",
132    rule_type: RuleType::Token,
133    required_nodes: &["pipe_table"],
134    new_linter: |context| Box::new(MD056Linter::new(context)),
135};
136
137#[cfg(test)]
138mod test {
139    use std::path::PathBuf;
140
141    use crate::{
142        config::RuleSeverity, linter::MultiRuleLinter,
143        test_utils::test_helpers::test_config_with_rules,
144    };
145
146    fn test_config() -> crate::config::QuickmarkConfig {
147        test_config_with_rules(vec![("table-column-count", RuleSeverity::Error)])
148    }
149
150    #[test]
151    fn test_table_with_consistent_column_count() {
152        let input = r#"| Header 1 | Header 2 |
153| -------- | -------- |
154| Cell 1   | Cell 2   |
155| Cell 3   | Cell 4   |"#;
156        let config = test_config();
157        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
158        let violations = linter.analyze();
159        assert_eq!(0, violations.len());
160    }
161
162    #[test]
163    fn test_table_with_too_few_cells() {
164        let input = r#"| Header 1 | Header 2 |
165| -------- | -------- |
166| Cell 1   | Cell 2   |
167| Cell 3   |"#;
168        let config = test_config();
169        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
170        let violations = linter.analyze();
171        assert_eq!(1, violations.len());
172        assert!(violations[0].message().contains("Too few cells"));
173    }
174
175    #[test]
176    fn test_table_with_too_many_cells() {
177        let input = r#"| Header 1 | Header 2 |
178| -------- | -------- |
179| Cell 1   | Cell 2   |
180| Cell 3   | Cell 4   | Cell 5 |"#;
181        let config = test_config();
182        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
183        let violations = linter.analyze();
184        assert_eq!(1, violations.len());
185        assert!(violations[0].message().contains("Too many cells"));
186    }
187
188    #[test]
189    fn test_table_with_mixed_column_counts() {
190        let input = r#"| Header 1 | Header 2 | Header 3 |
191| -------- | -------- | -------- |
192| Cell 1   | Cell 2   |
193| Cell 3   | Cell 4   | Cell 5   | Cell 6 |"#;
194        let config = test_config();
195        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
196        let violations = linter.analyze();
197        assert_eq!(2, violations.len());
198        assert!(violations[0].message().contains("Too few cells"));
199        assert!(violations[1].message().contains("Too many cells"));
200    }
201
202    #[test]
203    fn test_table_header_only() {
204        let input = r#"| Header 1 | Header 2 |"#;
205        let config = test_config();
206        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
207        let violations = linter.analyze();
208        assert_eq!(0, violations.len());
209    }
210
211    #[test]
212    fn test_table_with_delimiter_row_only() {
213        let input = r#"| Header 1 | Header 2 |
214| -------- | -------- |"#;
215        let config = test_config();
216        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
217        let violations = linter.analyze();
218        assert_eq!(0, violations.len());
219    }
220
221    #[test]
222    fn test_empty_cells_in_table() {
223        let input = r#"| Header 1 | Header 2 |
224| -------- | -------- |
225|          | Cell 2   |
226| Cell 3   |          |"#;
227        let config = test_config();
228        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
229        let violations = linter.analyze();
230        assert_eq!(0, violations.len());
231    }
232
233    #[test]
234    fn test_table_with_one_column() {
235        let input = r#"| Header |
236| ------ |
237| Cell 1 |
238| Cell 2 |"#;
239        let config = test_config();
240        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
241        let violations = linter.analyze();
242        assert_eq!(0, violations.len());
243    }
244
245    #[test]
246    fn test_table_with_one_column_violation() {
247        let input = r#"| Header |
248| ------ |
249| Cell 1 | Cell 2 |"#;
250        let config = test_config();
251        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
252        let violations = linter.analyze();
253        assert_eq!(1, violations.len());
254        assert!(violations[0].message().contains("Too many cells"));
255    }
256
257    #[test]
258    fn test_multiple_tables_independent() {
259        let input = r#"| Table 1 | Header |
260| ------- | ------ |
261| Cell    | Value  |
262
263| Different | Table | Headers |
264| --------- | ----- | ------- |
265| More      | Data  | Here    |"#;
266        let config = test_config();
267        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
268        let violations = linter.analyze();
269        assert_eq!(0, violations.len());
270    }
271
272    #[test]
273    fn test_multiple_tables_with_violations() {
274        let input = r#"| Table 1 | Header |
275| ------- | ------ |
276| Cell    |
277
278| Different | Table |
279| --------- | ----- |
280| More      | Data  | Extra |"#;
281        let config = test_config();
282        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
283        let violations = linter.analyze();
284        assert_eq!(2, violations.len());
285        assert!(violations[0].message().contains("Too few cells"));
286        assert!(violations[1].message().contains("Too many cells"));
287    }
288}