quickmark_core/rules/
md056.rs1use 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
10pub(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 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 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}