Skip to main content

rumdl_lib/rules/
md056_table_column_count.rs

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