mdbook_lint_core/rules/standard/
md056.rs1use crate::error::Result;
26use crate::{
27 Document, Violation,
28 rule::{Rule, RuleCategory, RuleMetadata},
29 violation::Severity,
30};
31use comrak::nodes::{AstNode, NodeValue};
32
33pub struct MD056;
35
36impl Default for MD056 {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl MD056 {
43 pub fn new() -> Self {
45 Self
46 }
47
48 fn count_cells<'a>(&self, node: &'a AstNode<'a>) -> usize {
50 let mut cell_count = 0;
51 for child in node.children() {
52 if matches!(child.data.borrow().value, NodeValue::TableCell) {
53 cell_count += 1;
54 }
55 }
56 cell_count
57 }
58
59 fn check_table_columns<'a>(&self, ast: &'a AstNode<'a>) -> Vec<Violation> {
61 let mut violations = Vec::new();
62 self.traverse_for_tables(ast, &mut violations);
63 violations
64 }
65
66 fn traverse_for_tables<'a>(&self, node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
68 if let NodeValue::Table(_) = &node.data.borrow().value {
69 self.check_table(node, violations);
70 }
71
72 for child in node.children() {
73 self.traverse_for_tables(child, violations);
74 }
75 }
76
77 fn check_table<'a>(&self, table_node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
79 let mut rows = Vec::new();
80 let mut expected_columns = None;
81
82 for child in table_node.children() {
84 if matches!(child.data.borrow().value, NodeValue::TableRow(..)) {
85 let cell_count = self.count_cells(child);
86 let pos = child.data.borrow().sourcepos;
87 let line = pos.start.line;
88 let column = pos.start.column;
89 rows.push((cell_count, line, column));
90
91 if expected_columns.is_none() {
93 expected_columns = Some(cell_count);
94 }
95 }
96 }
97
98 let expected = expected_columns.unwrap_or(0);
99
100 for (i, (cell_count, line, column)) in rows.iter().enumerate() {
102 if *cell_count != expected {
103 let row_type = if i == 0 {
104 "header row"
105 } else if i == 1 {
106 "delimiter row"
107 } else {
108 "data row"
109 };
110
111 let message = if *cell_count < expected {
112 format!(
113 "Table {} has {} cells, expected {} (missing {} cells)",
114 row_type,
115 cell_count,
116 expected,
117 expected - cell_count
118 )
119 } else {
120 format!(
121 "Table {} has {} cells, expected {} (extra {} cells)",
122 row_type,
123 cell_count,
124 expected,
125 cell_count - expected
126 )
127 };
128
129 violations.push(self.create_violation(message, *line, *column, Severity::Error));
130 }
131 }
132 }
133
134 fn check_tables_fallback(&self, document: &Document) -> Vec<Violation> {
136 let mut violations = Vec::new();
137 let mut in_table = false;
138 let mut expected_columns: Option<usize> = None;
139 let mut table_row_index = 0;
140
141 for (line_num, line) in document.content.lines().enumerate() {
142 if self.is_table_row(line) {
143 let cell_count = line.matches('|').count().saturating_sub(1);
144
145 if !in_table {
146 expected_columns = Some(cell_count);
148 in_table = true;
149 table_row_index = 0;
150 } else if let Some(expected) = expected_columns
151 && cell_count != expected
152 {
153 let row_type = if table_row_index == 1 {
154 "delimiter row"
155 } else {
156 "data row"
157 };
158
159 let message = if cell_count < expected {
160 format!(
161 "Table {} has {} cells, expected {} (missing {} cells)",
162 row_type,
163 cell_count,
164 expected,
165 expected - cell_count
166 )
167 } else {
168 format!(
169 "Table {} has {} cells, expected {} (extra {} cells)",
170 row_type,
171 cell_count,
172 expected,
173 cell_count - expected
174 )
175 };
176
177 violations.push(self.create_violation(
178 message,
179 line_num + 1,
180 1,
181 Severity::Error,
182 ));
183 }
184 table_row_index += 1;
185 } else if in_table && line.trim().is_empty() {
186 in_table = false;
188 expected_columns = None;
189 table_row_index = 0;
190 }
191 }
192
193 violations
194 }
195
196 fn is_table_row(&self, line: &str) -> bool {
198 let trimmed = line.trim();
199
200 if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
202 return false;
203 }
204
205 trimmed.matches('|').count() >= 2
207 }
208}
209
210impl Rule for MD056 {
211 fn id(&self) -> &'static str {
212 "MD056"
213 }
214
215 fn name(&self) -> &'static str {
216 "table-column-count"
217 }
218
219 fn description(&self) -> &'static str {
220 "Table column count"
221 }
222
223 fn metadata(&self) -> RuleMetadata {
224 RuleMetadata::stable(RuleCategory::Structure)
225 }
226
227 fn check_with_ast<'a>(
228 &self,
229 document: &Document,
230 ast: Option<&'a AstNode<'a>>,
231 ) -> Result<Vec<Violation>> {
232 if let Some(ast) = ast {
233 let violations = self.check_table_columns(ast);
234 Ok(violations)
235 } else {
236 Ok(self.check_tables_fallback(document))
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::test_helpers::{
246 assert_no_violations, assert_single_violation, assert_violation_count,
247 };
248
249 #[test]
250 fn test_consistent_table() {
251 let content = r#"| Header | Header |
252| ------ | ------ |
253| Cell | Cell |
254| Cell | Cell |
255"#;
256
257 assert_no_violations(MD056::new(), content);
258 }
259
260 #[test]
261 fn test_missing_cells() {
262 let content = r#"| Header | Header |
263| ------ | ------ |
264| Cell | Cell |
265| Cell |
266"#;
267
268 let violation = assert_single_violation(MD056::new(), content);
269 assert_eq!(violation.line, 4);
270 assert!(violation.message.contains("missing 1 cells"));
271 }
272
273 #[test]
274 fn test_extra_cells() {
275 let content = r#"| Header | Header |
276| ------ | ------ |
277| Cell | Cell |
278| Cell | Cell | Cell |
279"#;
280
281 let violation = assert_single_violation(MD056::new(), content);
282 assert_eq!(violation.line, 4);
283 assert!(violation.message.contains("extra 1 cells"));
284 }
285
286 #[test]
287 fn test_delimiter_row_mismatch() {
288 let content = r#"| Header | Header |
289| ------ |
290| Cell | Cell |
291"#;
292
293 let violation = assert_single_violation(MD056::new(), content);
294 assert_eq!(violation.line, 2);
295 assert!(violation.message.contains("delimiter row"));
296 assert!(violation.message.contains("missing 1 cells"));
297 }
298
299 #[test]
300 fn test_multiple_violations() {
301 let content = r#"| Header | Header |
302| ------ | ------ |
303| Cell |
304| Cell | Cell | Cell |
305"#;
306
307 let violations = assert_violation_count(MD056::new(), content, 2);
308
309 assert_eq!(violations[0].line, 3);
310 assert!(violations[0].message.contains("missing 1 cells"));
311
312 assert_eq!(violations[1].line, 4);
313 assert!(violations[1].message.contains("extra 1 cells"));
314 }
315
316 #[test]
317 fn test_single_column_table() {
318 let content = r#"| Header |
319| ------ |
320| Cell |
321| Cell |
322"#;
323
324 assert_no_violations(MD056::new(), content);
325 }
326
327 #[test]
328 fn test_empty_table() {
329 let content = r#"| |
330|---|
331| |
332"#;
333
334 assert_no_violations(MD056::new(), content);
335 }
336
337 #[test]
338 fn test_multiple_tables() {
339 let content = r#"| Table 1 | Header |
340| ------- | ------ |
341| Cell | Cell |
342
343| Table 2 | Header |
344| ------- | ------ |
345| Cell |
346"#;
347
348 let violation = assert_single_violation(MD056::new(), content);
349 assert_eq!(violation.line, 7);
350 assert!(violation.message.contains("missing 1 cells"));
351 }
352
353 #[test]
354 fn test_fallback_multiple_tables() {
355 let content = r#"| Table 1 | Header |
356| ------- | ------ |
357| Cell | Cell |
358
359| Table 2 | Header |
360| ------- | ------ |
361| Cell |
362"#;
363
364 use std::path::PathBuf;
366 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
367 let rule = MD056::new();
368 let violations = rule.check_tables_fallback(&document);
369
370 assert_eq!(violations.len(), 1);
371 let violations = assert_violation_count(rule, content, 1);
372 assert_eq!(violations[0].line, 7);
373 assert!(violations[0].message.contains("missing 1 cells"));
374 }
375
376 #[test]
377 fn test_fallback_method() {
378 let content = r#"| Header | Header |
380| ------ | ------ |
381| Cell | Cell |
382| Cell |
383"#;
384
385 let rule = MD056::new();
386 let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
387 assert_eq!(violations.len(), 1);
388 assert_eq!(violations[0].line, 4);
389 assert!(violations[0].message.contains("missing 1 cells"));
390 }
391
392 #[test]
393 fn test_edge_case_empty_rows() {
394 let content = r#"| Header | Header |
395| ------ | ------ |
396| | |
397| |
398"#;
399
400 let violation = assert_single_violation(MD056::new(), content);
401 assert_eq!(violation.line, 4);
402 assert!(violation.message.contains("missing 1 cells"));
403 }
404
405 #[test]
406 fn test_table_with_varying_column_counts() {
407 let content = r#"| A | B | C |
408| - | - | - |
409| 1 | 2 |
410| 4 | 5 | 6 | 7 |
411| 8 | 9 | 10 |
412"#;
413
414 let violations = assert_violation_count(MD056::new(), content, 2);
415 assert_eq!(violations[0].line, 3);
416 assert!(violations[0].message.contains("missing 1 cells"));
417 assert_eq!(violations[1].line, 4);
418 assert!(violations[1].message.contains("extra 1 cells"));
419 }
420
421 #[test]
422 fn test_complex_table_structure() {
423 let content = r#"| Column 1 | Column 2 | Column 3 | Column 4 |
424| -------- | -------- | -------- | -------- |
425| Data | Data | Data | Data |
426| Data | Data | | |
427| Data | | | |
428| Data | Data | Data | |
429"#;
430
431 assert_no_violations(MD056::new(), content);
432 }
433
434 #[test]
435 fn test_table_with_pipes_in_content() {
436 let content = r#"| Code | Description |
437| ---- | ----------- |
438| `a` | Pipe char |
439| `b` | With pipe |
440"#;
441
442 assert_no_violations(MD056::new(), content);
443 }
444
445 #[test]
446 fn test_malformed_table_structure() {
447 let content = r#"| Header | Header |
448| Cell | Cell |
449| ------ | ------ |
450| Cell | Cell |
451"#;
452
453 assert_no_violations(MD056::new(), content);
455 }
456
457 #[test]
458 fn test_table_cell_count_edge_cases() {
459 let content = r#"| A |
460| - |
461| |
462| B |
463"#;
464
465 assert_no_violations(MD056::new(), content);
466 }
467
468 #[test]
469 fn test_delimiter_row_variations() {
470 let content = r#"| Header1 | Header2 | Header3 |
471|---------|---------|
472| Cell | Cell | Cell |
473"#;
474
475 let violation = assert_single_violation(MD056::new(), content);
476 assert_eq!(violation.line, 2);
477 assert!(violation.message.contains("missing 1 cells"));
478 }
479
480 #[test]
481 fn test_no_tables_in_document() {
482 let content = r#"# Heading
483
484This is just text with no tables.
485
486Some more text here.
487"#;
488
489 assert_no_violations(MD056::new(), content);
490 }
491
492 #[test]
493 fn test_table_within_other_content() {
494 let content = r#"# Document Title
495
496Some introductory text.
497
498| Name | Age | City |
499| ---- | --- | ---- |
500| John | 30 | |
501
502More text after the table.
503"#;
504
505 assert_no_violations(MD056::new(), content);
506 }
507
508 #[test]
509 fn test_multiple_delimiter_issues() {
510 let content = r#"| A | B | C |
511| - | - |
512| 1 | 2 | 3 |
513| 4 | 5 |
514"#;
515
516 let violations = assert_violation_count(MD056::new(), content, 2);
517 assert_eq!(violations[0].line, 2);
518 assert!(violations[0].message.contains("missing 1 cells"));
519 assert_eq!(violations[1].line, 4);
520 assert!(violations[1].message.contains("missing 1 cells"));
521 }
522
523 #[test]
524 fn test_large_table_consistency() {
525 let content = r#"| C1 | C2 | C3 | C4 | C5 |
526| -- | -- | -- | -- | -- |
527| D1 | D2 | D3 | D4 | D5 |
528| D1 | D2 | D3 | D4 | D5 |
529| D1 | D2 | D3 | D4 | |
530| D1 | D2 | D3 | D4 | D5 |
531"#;
532
533 assert_no_violations(MD056::new(), content);
534 }
535
536 #[test]
537 fn test_table_row_parsing_edge_cases() {
538 let content = r#"| Header |
539|--------|
540| Cell |
541| |
542"#;
543
544 assert_no_violations(MD056::new(), content);
545 }
546
547 #[test]
548 fn test_ast_not_available_error_path() {
549 let content = r#"| Header | Header |
550| ------ | ------ |
551| Cell |
552"#;
553
554 let rule = MD056::new();
555 let violations = rule
557 .check_with_ast(&crate::test_helpers::create_document(content), None)
558 .unwrap();
559 assert_eq!(violations.len(), 1);
560 assert!(violations[0].message.contains("missing 1 cells"));
561 }
562
563 #[test]
564 fn test_complex_table_scenarios() {
565 let content = r#"| Code | Description |
567| ---- | ----------- |
568| abc | Pipe char |
569| def | Another value |
570"#;
571
572 assert_no_violations(MD056::new(), content);
573 }
574
575 #[test]
576 fn test_malformed_table_detection() {
577 let content = r#"Not a table line
579| Header | Header |
580Not a table line
581| Cell |
582"#;
583
584 let violation = assert_single_violation(MD056::new(), content);
585 assert!(violation.message.contains("missing 1 cells"));
586 }
587
588 #[test]
589 fn test_header_row_edge_cases() {
590 let content = r#"| Too | Many | Headers | Here |
592| --- | --- |
593| One | Two |
594"#;
595
596 let violations = assert_violation_count(MD056::new(), content, 2);
597 assert_eq!(violations[0].line, 2);
598 assert!(violations[0].message.contains("delimiter row"));
599 }
600
601 #[test]
602 fn test_count_cells_functionality() {
603 let rule = MD056::new();
605
606 let scenarios = vec![
608 ("| A |", 1),
609 ("| A | B |", 2),
610 ("| A | B | C |", 3),
611 ("|A|B|", 2),
612 ("| | |", 2),
613 ];
614
615 for (line, expected_count) in scenarios {
617 let content = format!(
618 "{}\n|---|\n{}",
619 "| Header |"
620 .repeat(expected_count)
621 .replace(" |", " | ")
622 .trim_end(),
623 line
624 );
625
626 if line.matches('|').count() - 1 != expected_count {
627 let violations = rule
629 .check(&crate::test_helpers::create_document(&content))
630 .unwrap();
631 assert!(
632 !violations.is_empty(),
633 "Expected violation for line: {line}"
634 );
635 }
636 }
637 }
638
639 #[test]
640 fn test_table_row_detection_edge_cases() {
641 let content = r#"| Valid | Table | Row |
643| ----- | ----- | --- |
644Not a table row
645| Valid | Row |
646|Invalid|
647||
648| | | |
649"#;
650
651 let rule = MD056::new();
652 let violations = rule
653 .check(&crate::test_helpers::create_document(content))
654 .unwrap();
655 assert!(!violations.is_empty());
657 }
658
659 #[test]
660 fn test_fallback_table_detection() {
661 let rule = MD056::new();
663
664 let content = r#"| Header | Header |
666| ------ | ------ |
667| Cell | Cell |
668
669Not a table anymore
670| Header |
671| ------ |
672| Cell |
673"#;
674
675 let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
676 let _ = violations;
678 }
679
680 #[test]
681 fn test_table_state_transitions() {
682 let rule = MD056::new();
684
685 let content = r#"Regular text
686| Start | Table |
687| ----- | ----- |
688| Row |
689
690Back to regular text
691| Another | Table |
692| ------- | ----- |
693| Cell | Cell |
694"#;
695
696 let violations = rule.check_tables_fallback(&crate::test_helpers::create_document(content));
697 assert_eq!(violations.len(), 1);
698 assert!(violations[0].message.contains("missing 1 cells"));
699 }
700
701 #[test]
702 fn test_row_type_messages() {
703 let content = r#"| Header | Header |
705| ------ | ------ |
706| Cell |"#;
707
708 let violation = assert_single_violation(MD056::new(), content);
709 assert!(violation.message.contains("data row"));
710 assert!(violation.message.contains("missing"));
711 }
712
713 #[test]
714 fn test_pipe_counting_edge_cases() {
715 let rule = MD056::new();
717
718 let content = r#"This line has | pipes but isn't a table
720| Header | Header |
721| ------ | ------ |
722| Cell | Cell |
723"#;
724
725 assert_no_violations(rule, content);
726 }
727
728 #[test]
729 fn test_expected_column_calculation() {
730 let scenarios = vec![
732 (
734 r#"| A |
735| - |
736| 1 | 2 |"#,
737 1,
738 ),
739 (
740 r#"| A | B | C |
741| - | - | - |
742| 1 | 2 |"#,
743 1,
744 ),
745 ];
746
747 for (content, expected_violations) in scenarios {
748 let violations = assert_violation_count(MD056::new(), content, expected_violations);
749 assert!(!violations.is_empty());
750 }
751 }
752}