Skip to main content

rumdl_lib/rules/
md056_table_column_count.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::TableUtils;
4
5/// Rule MD056: Table column count
6///
7/// See [docs/md056.md](../../docs/md056.md) for full documentation, configuration, and examples.
8/// Ensures all rows in a table have the same number of cells
9#[derive(Debug, Clone)]
10pub struct MD056TableColumnCount;
11
12impl Default for MD056TableColumnCount {
13    fn default() -> Self {
14        MD056TableColumnCount
15    }
16}
17
18impl MD056TableColumnCount {
19    /// Try to fix a table row content (with list context awareness)
20    fn fix_table_row_content(
21        &self,
22        row_content: &str,
23        expected_count: usize,
24        flavor: crate::config::MarkdownFlavor,
25        table_block: &crate::utils::table_utils::TableBlock,
26        line_index: usize,
27        original_line: &str,
28    ) -> Option<String> {
29        let current_count = TableUtils::count_cells_with_flavor(row_content, flavor);
30
31        if current_count == expected_count || current_count == 0 {
32            return None;
33        }
34
35        let fixed = self.fix_row_by_truncation(row_content, expected_count, flavor)?;
36        Some(self.restore_prefixes(&fixed, table_block, line_index, original_line))
37    }
38
39    /// Restore list/blockquote prefixes to a fixed row
40    fn restore_prefixes(
41        &self,
42        fixed_content: &str,
43        table_block: &crate::utils::table_utils::TableBlock,
44        line_index: usize,
45        original_line: &str,
46    ) -> String {
47        // Extract blockquote prefix from original
48        let (blockquote_prefix, _) = TableUtils::extract_blockquote_prefix(original_line);
49
50        // Handle list context
51        if let Some(ref list_ctx) = table_block.list_context {
52            if line_index == 0 {
53                // Header line: use list prefix
54                format!("{blockquote_prefix}{}{fixed_content}", list_ctx.list_prefix)
55            } else {
56                // Continuation lines: use indentation
57                let indent = " ".repeat(list_ctx.content_indent);
58                format!("{blockquote_prefix}{indent}{fixed_content}")
59            }
60        } else {
61            // No list context, just blockquote
62            if blockquote_prefix.is_empty() {
63                fixed_content.to_string()
64            } else {
65                format!("{blockquote_prefix}{fixed_content}")
66            }
67        }
68    }
69
70    /// Fix a table row by truncating or adding cells
71    fn fix_row_by_truncation(
72        &self,
73        row: &str,
74        expected_count: usize,
75        flavor: crate::config::MarkdownFlavor,
76    ) -> Option<String> {
77        let current_count = TableUtils::count_cells_with_flavor(row, flavor);
78
79        if current_count == expected_count || current_count == 0 {
80            return None;
81        }
82
83        let trimmed = row.trim();
84        let has_leading_pipe = trimmed.starts_with('|');
85        let has_trailing_pipe = trimmed.ends_with('|');
86
87        // Delegate to shared cell splitting (returns only cell contents, no empty leading/trailing parts)
88        let cells = TableUtils::split_table_row_with_flavor(trimmed, flavor);
89        let mut cell_contents: Vec<&str> = cells.iter().map(|c| c.trim()).collect();
90
91        // Adjust cell count to match expected count
92        match current_count.cmp(&expected_count) {
93            std::cmp::Ordering::Greater => {
94                // Too many cells, remove excess
95                cell_contents.truncate(expected_count);
96            }
97            std::cmp::Ordering::Less => {
98                // Too few cells, add empty ones
99                while cell_contents.len() < expected_count {
100                    cell_contents.push("");
101                }
102            }
103            std::cmp::Ordering::Equal => {
104                // Perfect number of cells, no adjustment needed
105            }
106        }
107
108        // An empty last cell exists only before a closing pipe: without one it
109        // is trailing whitespace, and the row reads a cell short again.
110        let close_row = has_trailing_pipe || cell_contents.last().is_some_and(|cell| cell.is_empty());
111
112        // Reconstruct row
113        let mut result = String::new();
114        if has_leading_pipe {
115            result.push('|');
116        }
117
118        for (i, cell) in cell_contents.iter().enumerate() {
119            result.push_str(&format!(" {cell} "));
120            if i < cell_contents.len() - 1 || close_row {
121                result.push('|');
122            }
123        }
124
125        Some(result)
126    }
127}
128
129impl Rule for MD056TableColumnCount {
130    fn name(&self) -> &'static str {
131        "MD056"
132    }
133
134    fn description(&self) -> &'static str {
135        "Table column count should be consistent"
136    }
137
138    fn category(&self) -> RuleCategory {
139        RuleCategory::Table
140    }
141
142    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
143        // Skip if no tables present
144        !ctx.likely_has_tables()
145    }
146
147    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
148        let content = ctx.content;
149        let flavor = ctx.flavor;
150        let mut warnings = Vec::new();
151
152        // Early return for empty content or content without tables
153        if content.is_empty() || !content.contains('|') {
154            return Ok(Vec::new());
155        }
156
157        let lines = ctx.raw_lines();
158
159        // Use pre-computed table blocks from context
160        let table_blocks = &ctx.table_blocks;
161
162        for table_block in table_blocks {
163            // Collect all table lines for building the whole-table fix
164            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
165                .chain(std::iter::once(table_block.delimiter_line))
166                .chain(table_block.content_lines.iter().copied())
167                .collect();
168
169            // Determine expected column count from header row (strip list/blockquote prefix first)
170            let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
171            let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
172
173            if expected_count == 0 {
174                continue; // Skip invalid tables
175            }
176
177            // Check each row and emit a per-row fix. Per-row fixes ensure that
178            // inline-disabling one row does not cause the fix on another row to
179            // overwrite the disabled row's content.
180            for (i, &line_idx) in all_line_indices.iter().enumerate() {
181                let line = lines[line_idx];
182                let row_content = TableUtils::extract_table_row_content(line, table_block, i);
183                let count = TableUtils::count_cells_with_flavor(row_content, flavor);
184
185                if count > 0 && count != expected_count {
186                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
187
188                    // Build a per-row fix so inline-disabled rows are not
189                    // overwritten by fixes on other rows in the same table.
190                    let fixed_line = self
191                        .fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
192                        .unwrap_or_else(|| line.to_string());
193                    let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
194
195                    warnings.push(LintWarning {
196                        rule_name: Some(self.name().to_string()),
197                        message: format!("Table row has {count} cells, but expected {expected_count}"),
198                        line: start_line,
199                        column: start_col,
200                        end_line,
201                        end_column: end_col,
202                        severity: Severity::Warning,
203                        fix: Some(Fix::new(row_range, fixed_line)),
204                    });
205                }
206            }
207        }
208
209        Ok(warnings)
210    }
211
212    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
213        if self.should_skip(ctx) {
214            return Ok(ctx.content.to_string());
215        }
216        let warnings = self.check(ctx)?;
217        if warnings.is_empty() {
218            return Ok(ctx.content.to_string());
219        }
220        let warnings =
221            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
222        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
223    }
224
225    fn as_any(&self) -> &dyn std::any::Any {
226        self
227    }
228
229    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
230    where
231        Self: Sized,
232    {
233        Box::new(MD056TableColumnCount)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::lint_context::LintContext;
241
242    #[test]
243    fn test_valid_table() {
244        let rule = MD056TableColumnCount;
245        let content = "| Header 1 | Header 2 | Header 3 |
246|----------|----------|----------|
247| Cell 1   | Cell 2   | Cell 3   |
248| Cell 4   | Cell 5   | Cell 6   |";
249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
250        let result = rule.check(&ctx).unwrap();
251
252        assert_eq!(result.len(), 0);
253    }
254
255    #[test]
256    fn test_too_few_columns() {
257        let rule = MD056TableColumnCount;
258        let content = "| Header 1 | Header 2 | Header 3 |
259|----------|----------|----------|
260| Cell 1   | Cell 2   |
261| Cell 4   | Cell 5   | Cell 6   |";
262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
263        let result = rule.check(&ctx).unwrap();
264
265        assert_eq!(result.len(), 1);
266        assert_eq!(result[0].line, 3);
267        assert!(result[0].message.contains("has 2 cells, but expected 3"));
268    }
269
270    #[test]
271    fn test_too_many_columns() {
272        let rule = MD056TableColumnCount;
273        let content = "| Header 1 | Header 2 |
274|----------|----------|
275| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
276| Cell 5   | Cell 6   |";
277        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
278        let result = rule.check(&ctx).unwrap();
279
280        assert_eq!(result.len(), 1);
281        assert_eq!(result[0].line, 3);
282        assert!(result[0].message.contains("has 4 cells, but expected 2"));
283    }
284
285    #[test]
286    fn test_delimiter_row_mismatch() {
287        let rule = MD056TableColumnCount;
288        let content = "| Header 1 | Header 2 | Header 3 |
289|----------|----------|
290| Cell 1   | Cell 2   | Cell 3   |";
291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
292        let result = rule.check(&ctx).unwrap();
293
294        assert_eq!(result.len(), 1);
295        assert_eq!(result[0].line, 2);
296        assert!(result[0].message.contains("has 2 cells, but expected 3"));
297    }
298
299    #[test]
300    fn test_fix_too_few_columns() {
301        let rule = MD056TableColumnCount;
302        let content = "| Header 1 | Header 2 | Header 3 |
303|----------|----------|----------|
304| Cell 1   | Cell 2   |
305| Cell 4   | Cell 5   | Cell 6   |";
306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307        let fixed = rule.fix(&ctx).unwrap();
308
309        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
310    }
311
312    #[test]
313    fn test_fix_too_many_columns() {
314        let rule = MD056TableColumnCount;
315        let content = "| Header 1 | Header 2 |
316|----------|----------|
317| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
318| Cell 5   | Cell 6   |";
319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
320        let fixed = rule.fix(&ctx).unwrap();
321
322        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
323        assert!(!fixed.contains("Cell 3"));
324        assert!(!fixed.contains("Cell 4"));
325    }
326
327    #[test]
328    fn test_no_leading_pipe() {
329        let rule = MD056TableColumnCount;
330        let content = "Header 1 | Header 2 | Header 3 |
331---------|----------|----------|
332Cell 1   | Cell 2   |
333Cell 4   | Cell 5   | Cell 6   |";
334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335        let result = rule.check(&ctx).unwrap();
336
337        assert_eq!(result.len(), 1);
338        assert_eq!(result[0].line, 3);
339    }
340
341    #[test]
342    fn test_no_trailing_pipe() {
343        let rule = MD056TableColumnCount;
344        let content = "| Header 1 | Header 2 | Header 3
345|----------|----------|----------
346| Cell 1   | Cell 2
347| Cell 4   | Cell 5   | Cell 6";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
349        let result = rule.check(&ctx).unwrap();
350
351        assert_eq!(result.len(), 1);
352        assert_eq!(result[0].line, 3);
353    }
354
355    #[test]
356    fn test_no_pipes_at_all() {
357        let rule = MD056TableColumnCount;
358        let content = "This is not a table
359Just regular text
360No pipes here";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362        let result = rule.check(&ctx).unwrap();
363
364        assert_eq!(result.len(), 0);
365    }
366
367    #[test]
368    fn test_empty_cells() {
369        let rule = MD056TableColumnCount;
370        let content = "| Header 1 | Header 2 | Header 3 |
371|----------|----------|----------|
372|          |          |          |
373| Cell 1   |          | Cell 3   |";
374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375        let result = rule.check(&ctx).unwrap();
376
377        assert_eq!(result.len(), 0);
378    }
379
380    #[test]
381    fn test_multiple_tables() {
382        let rule = MD056TableColumnCount;
383        let content = "| Table 1 Col 1 | Table 1 Col 2 |
384|----------------|----------------|
385| Data 1         | Data 2         |
386
387Some text in between.
388
389| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
390|----------------|----------------|----------------|
391| Data 3         | Data 4         |
392| Data 5         | Data 6         | Data 7         |";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394        let result = rule.check(&ctx).unwrap();
395
396        assert_eq!(result.len(), 1);
397        assert_eq!(result[0].line, 9);
398        assert!(result[0].message.contains("has 2 cells, but expected 3"));
399    }
400
401    #[test]
402    fn test_table_with_escaped_pipes() {
403        let rule = MD056TableColumnCount;
404
405        // Single backslash escapes the pipe: \| keeps pipe as content (2 columns)
406        let content = "| Command | Description |
407|---------|-------------|
408| `echo \\| grep` | Pipe example |
409| `ls` | List files |";
410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411        let result = rule.check(&ctx).unwrap();
412        assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
413
414        // Double backslash + pipe inside code span: pipe is still masked by code span
415        let content_double = "| Command | Description |
416|---------|-------------|
417| `echo \\\\| grep` | Pipe example |
418| `ls` | List files |";
419        let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
420        let result2 = rule.check(&ctx2).unwrap();
421        // The \\| is inside backticks, so the pipe is content, not a delimiter
422        assert_eq!(result2.len(), 0, "pipes inside code spans should not split cells");
423    }
424
425    /// Padding a row that has no closing pipe adds one, since an empty last
426    /// cell without it is only trailing whitespace; a second fix pass then
427    /// finds nothing left to do.
428    #[test]
429    fn padding_a_row_without_a_closing_pipe_closes_it() {
430        let rule = MD056TableColumnCount;
431        for (content, expected) in [
432            ("a | b | c\n--|--|--\nx | y\n", "a | b | c\n--|--|--\n x | y |  |\n"),
433            ("|  |  |\n| --- | --- |\n`|`\n", "|  |  |\n| --- | --- |\n `|` |  |\n"),
434            (
435                "| a | b | c |\n|--|--|--|\n| x | y |\n",
436                "| a | b | c |\n|--|--|--|\n| x | y |  |\n",
437            ),
438        ] {
439            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440            let fixed = rule.fix(&ctx).unwrap();
441            assert_eq!(fixed, expected, "{content:?}");
442            let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
443            assert_eq!(rule.fix(&ctx).unwrap(), fixed, "second pass over {fixed:?}");
444        }
445    }
446
447    #[test]
448    fn test_empty_content() {
449        let rule = MD056TableColumnCount;
450        let content = "";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453
454        assert_eq!(result.len(), 0);
455    }
456
457    #[test]
458    fn test_code_block_with_table() {
459        let rule = MD056TableColumnCount;
460        let content = "```
461| This | Is | Code |
462|------|----|----|
463| Not  | A  | Table |
464```
465
466| Real | Table |
467|------|-------|
468| Data | Here  |";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470        let result = rule.check(&ctx).unwrap();
471
472        // Should not check tables inside code blocks
473        assert_eq!(result.len(), 0);
474    }
475
476    #[test]
477    fn test_fix_preserves_pipe_style() {
478        let rule = MD056TableColumnCount;
479        // Truncating a row with no trailing pipe keeps it open
480        let content = "| Header 1 | Header 2
481|----------|----------
482| Cell 1   | Cell 2   | Cell 3";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484        let fixed = rule.fix(&ctx).unwrap();
485
486        let lines: Vec<&str> = fixed.lines().collect();
487        assert_eq!(lines[2], "| Cell 1 | Cell 2 ");
488    }
489
490    #[test]
491    fn test_single_column_table() {
492        let rule = MD056TableColumnCount;
493        let content = "| Header |
494|---------|
495| Cell 1  |
496| Cell 2  |";
497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498        let result = rule.check(&ctx).unwrap();
499
500        assert_eq!(result.len(), 0);
501    }
502
503    #[test]
504    fn test_complex_delimiter_row() {
505        let rule = MD056TableColumnCount;
506        let content = "| Left | Center | Right |
507|:-----|:------:|------:|
508| L    | C      | R     |
509| Left | Center |";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let result = rule.check(&ctx).unwrap();
512
513        assert_eq!(result.len(), 1);
514        assert_eq!(result[0].line, 4);
515    }
516
517    #[test]
518    fn test_unicode_content() {
519        let rule = MD056TableColumnCount;
520        let content = "| 名前 | 年齢 | 都市 |
521|------|------|------|
522| 田中 | 25   | 東京 |
523| 佐藤 | 30   |";
524        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525        let result = rule.check(&ctx).unwrap();
526
527        assert_eq!(result.len(), 1);
528        assert_eq!(result[0].line, 4);
529    }
530
531    #[test]
532    fn test_very_long_cells() {
533        let rule = MD056TableColumnCount;
534        let content = "| Short | Very very very very very very very very very very long header | Another |
535|-------|--------------------------------------------------------------|---------|
536| Data  | This is an extremely long cell content that goes on and on   |";
537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538        let result = rule.check(&ctx).unwrap();
539
540        assert_eq!(result.len(), 1);
541        assert!(result[0].message.contains("has 2 cells, but expected 3"));
542    }
543
544    #[test]
545    fn test_fix_with_newline_ending() {
546        let rule = MD056TableColumnCount;
547        let content = "| A | B | C |
548|---|---|---|
549| 1 | 2 |
550";
551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552        let fixed = rule.fix(&ctx).unwrap();
553
554        assert!(fixed.ends_with('\n'));
555        assert!(fixed.contains("| 1 | 2 |  |"));
556    }
557
558    #[test]
559    fn test_fix_without_newline_ending() {
560        let rule = MD056TableColumnCount;
561        let content = "| A | B | C |
562|---|---|---|
563| 1 | 2 |";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let fixed = rule.fix(&ctx).unwrap();
566
567        assert!(!fixed.ends_with('\n'));
568        assert!(fixed.contains("| 1 | 2 |  |"));
569    }
570
571    #[test]
572    fn test_blockquote_table_column_mismatch() {
573        let rule = MD056TableColumnCount;
574        let content = "> | Header 1 | Header 2 | Header 3 |
575> |----------|----------|----------|
576> | Cell 1   | Cell 2   |";
577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578        let result = rule.check(&ctx).unwrap();
579
580        assert_eq!(result.len(), 1);
581        assert_eq!(result[0].line, 3);
582        assert!(result[0].message.contains("has 2 cells, but expected 3"));
583    }
584
585    #[test]
586    fn test_fix_blockquote_table_preserves_prefix() {
587        let rule = MD056TableColumnCount;
588        let content = "> | Header 1 | Header 2 | Header 3 |
589> |----------|----------|----------|
590> | Cell 1   | Cell 2   |
591> | Cell 4   | Cell 5   | Cell 6   |";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let fixed = rule.fix(&ctx).unwrap();
594
595        // Each line should still start with "> "
596        for line in fixed.lines() {
597            assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
598        }
599        // The fixed row should have 3 cells
600        assert!(fixed.contains("> | Cell 1 | Cell 2 |  |"));
601    }
602
603    #[test]
604    fn test_fix_nested_blockquote_table() {
605        let rule = MD056TableColumnCount;
606        let content = ">> | A | B | C |
607>> |---|---|---|
608>> | 1 | 2 |";
609        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610        let fixed = rule.fix(&ctx).unwrap();
611
612        // Each line should preserve the nested blockquote prefix
613        for line in fixed.lines() {
614            assert!(
615                line.starts_with(">> "),
616                "Line should preserve nested blockquote prefix: {line}"
617            );
618        }
619        assert!(fixed.contains(">> | 1 | 2 |  |"));
620    }
621
622    #[test]
623    fn test_blockquote_table_too_many_columns() {
624        let rule = MD056TableColumnCount;
625        let content = "> | A | B |
626> |---|---|
627> | 1 | 2 | 3 | 4 |";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let fixed = rule.fix(&ctx).unwrap();
630
631        // Should preserve blockquote prefix while truncating columns
632        assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
633        assert!(fixed.contains("> | 1 | 2 |"));
634        assert!(!fixed.contains("| 3 |"));
635    }
636
637    // === Roundtrip safety tests ===
638
639    fn assert_fix_roundtrip(content: &str) {
640        let rule = MD056TableColumnCount;
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let fixed = rule.fix(&ctx).unwrap();
643        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
644        let remaining = rule.check(&ctx2).unwrap();
645        assert!(
646            remaining.is_empty(),
647            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
648        );
649    }
650
651    #[test]
652    fn test_roundtrip_too_few_columns() {
653        assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |");
654    }
655
656    #[test]
657    fn test_roundtrip_too_many_columns() {
658        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 | 3 | 4 |");
659    }
660
661    #[test]
662    fn test_roundtrip_with_trailing_newline() {
663        assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |\n");
664    }
665
666    #[test]
667    fn test_roundtrip_blockquote_table() {
668        assert_fix_roundtrip("> | A | B | C |\n> |---|---|---|\n> | 1 | 2 |");
669    }
670
671    #[test]
672    fn test_roundtrip_clean_table() {
673        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |");
674    }
675
676    #[test]
677    fn test_roundtrip_multiple_tables() {
678        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |\n\nText\n\n| C | D | E |\n|---|---|---|\n| 3 | 4 |");
679    }
680
681    // === Pandoc construct reachability tests ===
682    //
683    // These tests document that MD056 does not flag Pandoc-specific constructs
684    // because `ctx.table_blocks` excludes them at the source:
685    //
686    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
687    //   returns false and no `TableBlock` is created.
688    // - Multi-line table separators have no `|`, same exclusion.
689    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
690    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
691    // - Pipe-table captions (`: caption`) have no `|` — excluded.
692    //
693    // No production guard is needed. If `find_table_blocks` ever changes to
694    // include these constructs, these tests will surface that.
695
696    #[test]
697    fn md056_pandoc_grid_tables_not_flagged() {
698        let rule = MD056TableColumnCount;
699        let content = "\
700+---+---+
701| a | b |
702+===+===+
703| 1 | 2 |
704+---+---+
705";
706        // Grid table delimiters (`+===+===+`) contain no `|`, so `is_delimiter_row`
707        // returns false and no TableBlock is created — no MD056 check runs.
708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
709        let result = rule.check(&ctx).unwrap();
710        assert!(
711            result.is_empty(),
712            "MD056 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
713        );
714
715        // Standard flavor: same content produces no warnings for the same reason.
716        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result_std = rule.check(&ctx_std).unwrap();
718        assert!(
719            result_std.is_empty(),
720            "MD056 should not flag grid-table-like content under Standard either: {result_std:?}"
721        );
722    }
723
724    #[test]
725    fn md056_pandoc_multi_line_tables_not_flagged() {
726        let rule = MD056TableColumnCount;
727        let content = "\
728--------- ----------- ------
729Header 1   Header 2   Header 3
730--------- ----------- ------
731Cell 1     Cell 2     Cell 3
732--------- ----------- ------
733";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
735        let result = rule.check(&ctx).unwrap();
736        assert!(
737            result.is_empty(),
738            "MD056 should not flag Pandoc multi-line tables: {result:?}"
739        );
740
741        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742        let result_std = rule.check(&ctx_std).unwrap();
743        assert!(
744            result_std.is_empty(),
745            "MD056 should not flag multi-line table content under Standard: {result_std:?}"
746        );
747    }
748
749    #[test]
750    fn md056_pandoc_line_blocks_not_flagged() {
751        let rule = MD056TableColumnCount;
752        // Pandoc line blocks: starts with `|` but no trailing `|`.
753        // is_potential_table_row excludes them (valid_parts < 2).
754        let content = "| First line\n| Second line\n";
755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
756        let result = rule.check(&ctx).unwrap();
757        assert!(
758            result.is_empty(),
759            "MD056 should not treat Pandoc line blocks as tables: {result:?}"
760        );
761
762        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result_std = rule.check(&ctx_std).unwrap();
764        assert!(
765            result_std.is_empty(),
766            "MD056 should not treat line-block-like content as tables under Standard: {result_std:?}"
767        );
768    }
769
770    #[test]
771    fn md056_pandoc_pipe_table_captions_not_flagged() {
772        let rule = MD056TableColumnCount;
773        // Pipe-table captions (`: caption`) have no `|` — excluded from table_blocks.
774        let content = "\
775| H1 | H2 |
776|----|-----|
777| a  | b  |
778
779: My table caption
780";
781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
782        let result = rule.check(&ctx).unwrap();
783        assert!(
784            result.is_empty(),
785            "MD056 should not flag the pipe-table caption line: {result:?}"
786        );
787
788        // Under Standard: caption line is ignored; valid table has no warnings.
789        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result_std = rule.check(&ctx_std).unwrap();
791        assert!(
792            result_std.is_empty(),
793            "MD056 already-valid table with caption should have no warnings under Standard: {result_std:?}"
794        );
795    }
796}