mdbook_lint_core/rules/standard/
md056.rs

1//! MD056 - Table column count
2//!
3//! This rule is triggered when a GitHub Flavored Markdown table does not have
4//! the same number of cells in every row.
5//!
6//! ## Correct
7//!
8//! ```markdown
9//! | Header | Header |
10//! | ------ | ------ |
11//! | Cell   | Cell   |
12//! | Cell   | Cell   |
13//! ```
14//!
15//! ## Incorrect
16//!
17//! ```markdown
18//! | Header | Header |
19//! | ------ | ------ |
20//! | Cell   | Cell   |
21//! | Cell   |
22//! | Cell   | Cell   | Cell   |
23//! ```
24
25use crate::error::Result;
26use crate::{
27    Document, Violation,
28    rule::{Rule, RuleCategory, RuleMetadata},
29    violation::Severity,
30};
31use comrak::nodes::{AstNode, NodeValue};
32
33/// MD056 - Table column count
34pub struct MD056;
35
36impl Default for MD056 {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl MD056 {
43    /// Create a new MD056 rule instance
44    pub fn new() -> Self {
45        Self
46    }
47
48    /// Count cells in a table row
49    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    /// Check table column consistency
60    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    /// Traverse AST to find tables
67    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    /// Check a single table for column count consistency
78    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        // Collect all rows
83        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                // Set expected column count from the first row (header)
92                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        // Check each row against expected column count
101        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    /// Fallback method using manual parsing when no AST is available
135    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                    // First row of table (header)
147                    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                // End of table
187                in_table = false;
188                expected_columns = None;
189                table_row_index = 0;
190            }
191        }
192
193        violations
194    }
195
196    /// Check if a line is a table row without using regex
197    fn is_table_row(&self, line: &str) -> bool {
198        let trimmed = line.trim();
199
200        // Must start and end with pipe
201        if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
202            return false;
203        }
204
205        // Must have at least 2 pipes (start and end)
206        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            // Simplified regex-based fallback when no AST is available
237            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        // Test fallback implementation specifically
365        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        // Test when no AST is available
379        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        // This tests the fallback parsing with malformed structure
454        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        // Test with AST explicitly set to None to trigger fallback
556        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        // Test basic table functionality - use consistent column count
566        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        // Test tables without proper delimiters
578        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        // Test when header row has wrong column count
591        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        // Test internal cell counting logic with various scenarios
604        let rule = MD056::new();
605
606        // Test different pipe configurations
607        let scenarios = vec![
608            ("| A |", 1),
609            ("| A | B |", 2),
610            ("| A | B | C |", 3),
611            ("|A|B|", 2),
612            ("| | |", 2),
613        ];
614
615        // Since count_cells is private, we test through behavior
616        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                // Should produce violation
628                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        // Test is_table_row logic with various edge cases
642        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        // Should find violations for rows with wrong column counts
656        assert!(!violations.is_empty());
657    }
658
659    #[test]
660    fn test_fallback_table_detection() {
661        // Test the fallback parsing when AST is not available
662        let rule = MD056::new();
663
664        // Test table end detection on blank line
665        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        // Test passes if parsing completes without panic
677        let _ = violations;
678    }
679
680    #[test]
681    fn test_table_state_transitions() {
682        // Test in_table state transitions in fallback method
683        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        // Test different row type error messages - simplified to avoid multiple violations
704        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        // Test pipe counting with different scenarios
716        let rule = MD056::new();
717
718        // Test edge case where line has pipes but isn't a table
719        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        // Test how expected column count is determined
731        let scenarios = vec![
732            // Different header configurations
733            (
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}