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