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 =
190                        ctx.line_index
191                            .line_col_to_byte_range_with_length(line_idx + 1, 1, line.chars().count());
192
193                    warnings.push(LintWarning {
194                        rule_name: Some(self.name().to_string()),
195                        message: format!("Table row has {count} cells, but expected {expected_count}"),
196                        line: start_line,
197                        column: start_col,
198                        end_line,
199                        end_column: end_col,
200                        severity: Severity::Warning,
201                        fix: Some(Fix {
202                            range: row_range,
203                            replacement: fixed_line,
204                        }),
205                    });
206                }
207            }
208        }
209
210        Ok(warnings)
211    }
212
213    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214        if self.should_skip(ctx) {
215            return Ok(ctx.content.to_string());
216        }
217        let warnings = self.check(ctx)?;
218        if warnings.is_empty() {
219            return Ok(ctx.content.to_string());
220        }
221        let warnings =
222            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
223        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
224    }
225
226    fn as_any(&self) -> &dyn std::any::Any {
227        self
228    }
229
230    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
231    where
232        Self: Sized,
233    {
234        Box::new(MD056TableColumnCount)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::lint_context::LintContext;
242
243    #[test]
244    fn test_valid_table() {
245        let rule = MD056TableColumnCount;
246        let content = "| Header 1 | Header 2 | Header 3 |
247|----------|----------|----------|
248| Cell 1   | Cell 2   | Cell 3   |
249| Cell 4   | Cell 5   | Cell 6   |";
250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
251        let result = rule.check(&ctx).unwrap();
252
253        assert_eq!(result.len(), 0);
254    }
255
256    #[test]
257    fn test_too_few_columns() {
258        let rule = MD056TableColumnCount;
259        let content = "| Header 1 | Header 2 | Header 3 |
260|----------|----------|----------|
261| Cell 1   | Cell 2   |
262| Cell 4   | Cell 5   | Cell 6   |";
263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
264        let result = rule.check(&ctx).unwrap();
265
266        assert_eq!(result.len(), 1);
267        assert_eq!(result[0].line, 3);
268        assert!(result[0].message.contains("has 2 cells, but expected 3"));
269    }
270
271    #[test]
272    fn test_too_many_columns() {
273        let rule = MD056TableColumnCount;
274        let content = "| Header 1 | Header 2 |
275|----------|----------|
276| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
277| Cell 5   | Cell 6   |";
278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279        let result = rule.check(&ctx).unwrap();
280
281        assert_eq!(result.len(), 1);
282        assert_eq!(result[0].line, 3);
283        assert!(result[0].message.contains("has 4 cells, but expected 2"));
284    }
285
286    #[test]
287    fn test_delimiter_row_mismatch() {
288        let rule = MD056TableColumnCount;
289        let content = "| Header 1 | Header 2 | Header 3 |
290|----------|----------|
291| Cell 1   | Cell 2   | Cell 3   |";
292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
293        let result = rule.check(&ctx).unwrap();
294
295        assert_eq!(result.len(), 1);
296        assert_eq!(result[0].line, 2);
297        assert!(result[0].message.contains("has 2 cells, but expected 3"));
298    }
299
300    #[test]
301    fn test_fix_too_few_columns() {
302        let rule = MD056TableColumnCount;
303        let content = "| Header 1 | Header 2 | Header 3 |
304|----------|----------|----------|
305| Cell 1   | Cell 2   |
306| Cell 4   | Cell 5   | Cell 6   |";
307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308        let fixed = rule.fix(&ctx).unwrap();
309
310        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
311    }
312
313    #[test]
314    fn test_fix_too_many_columns() {
315        let rule = MD056TableColumnCount;
316        let content = "| Header 1 | Header 2 |
317|----------|----------|
318| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
319| Cell 5   | Cell 6   |";
320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321        let fixed = rule.fix(&ctx).unwrap();
322
323        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
324        assert!(!fixed.contains("Cell 3"));
325        assert!(!fixed.contains("Cell 4"));
326    }
327
328    #[test]
329    fn test_no_leading_pipe() {
330        let rule = MD056TableColumnCount;
331        let content = "Header 1 | Header 2 | Header 3 |
332---------|----------|----------|
333Cell 1   | Cell 2   |
334Cell 4   | Cell 5   | Cell 6   |";
335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336        let result = rule.check(&ctx).unwrap();
337
338        assert_eq!(result.len(), 1);
339        assert_eq!(result[0].line, 3);
340    }
341
342    #[test]
343    fn test_no_trailing_pipe() {
344        let rule = MD056TableColumnCount;
345        let content = "| Header 1 | Header 2 | Header 3
346|----------|----------|----------
347| Cell 1   | Cell 2
348| Cell 4   | Cell 5   | Cell 6";
349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350        let result = rule.check(&ctx).unwrap();
351
352        assert_eq!(result.len(), 1);
353        assert_eq!(result[0].line, 3);
354    }
355
356    #[test]
357    fn test_no_pipes_at_all() {
358        let rule = MD056TableColumnCount;
359        let content = "This is not a table
360Just regular text
361No pipes here";
362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363        let result = rule.check(&ctx).unwrap();
364
365        assert_eq!(result.len(), 0);
366    }
367
368    #[test]
369    fn test_empty_cells() {
370        let rule = MD056TableColumnCount;
371        let content = "| Header 1 | Header 2 | Header 3 |
372|----------|----------|----------|
373|          |          |          |
374| Cell 1   |          | Cell 3   |";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376        let result = rule.check(&ctx).unwrap();
377
378        assert_eq!(result.len(), 0);
379    }
380
381    #[test]
382    fn test_multiple_tables() {
383        let rule = MD056TableColumnCount;
384        let content = "| Table 1 Col 1 | Table 1 Col 2 |
385|----------------|----------------|
386| Data 1         | Data 2         |
387
388Some text in between.
389
390| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
391|----------------|----------------|----------------|
392| Data 3         | Data 4         |
393| Data 5         | Data 6         | Data 7         |";
394        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395        let result = rule.check(&ctx).unwrap();
396
397        assert_eq!(result.len(), 1);
398        assert_eq!(result[0].line, 9);
399        assert!(result[0].message.contains("has 2 cells, but expected 3"));
400    }
401
402    #[test]
403    fn test_table_with_escaped_pipes() {
404        let rule = MD056TableColumnCount;
405
406        // Single backslash escapes the pipe: \| keeps pipe as content (2 columns)
407        let content = "| Command | Description |
408|---------|-------------|
409| `echo \\| grep` | Pipe example |
410| `ls` | List files |";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412        let result = rule.check(&ctx).unwrap();
413        assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
414
415        // Double backslash + pipe inside code span: pipe is still masked by code span
416        let content_double = "| Command | Description |
417|---------|-------------|
418| `echo \\\\| grep` | Pipe example |
419| `ls` | List files |";
420        let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
421        let result2 = rule.check(&ctx2).unwrap();
422        // The \\| is inside backticks, so the pipe is content, not a delimiter
423        assert_eq!(result2.len(), 0, "pipes inside code spans should not split cells");
424    }
425
426    #[test]
427    fn test_empty_content() {
428        let rule = MD056TableColumnCount;
429        let content = "";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432
433        assert_eq!(result.len(), 0);
434    }
435
436    #[test]
437    fn test_code_block_with_table() {
438        let rule = MD056TableColumnCount;
439        let content = "```
440| This | Is | Code |
441|------|----|----|
442| Not  | A  | Table |
443```
444
445| Real | Table |
446|------|-------|
447| Data | Here  |";
448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449        let result = rule.check(&ctx).unwrap();
450
451        // Should not check tables inside code blocks
452        assert_eq!(result.len(), 0);
453    }
454
455    #[test]
456    fn test_fix_preserves_pipe_style() {
457        let rule = MD056TableColumnCount;
458        // Test with no trailing pipes
459        let content = "| Header 1 | Header 2 | Header 3
460|----------|----------|----------
461| Cell 1   | Cell 2";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let fixed = rule.fix(&ctx).unwrap();
464
465        let lines: Vec<&str> = fixed.lines().collect();
466        assert!(!lines[2].ends_with('|'));
467        assert!(lines[2].contains("Cell 1"));
468        assert!(lines[2].contains("Cell 2"));
469    }
470
471    #[test]
472    fn test_single_column_table() {
473        let rule = MD056TableColumnCount;
474        let content = "| Header |
475|---------|
476| Cell 1  |
477| Cell 2  |";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        assert_eq!(result.len(), 0);
482    }
483
484    #[test]
485    fn test_complex_delimiter_row() {
486        let rule = MD056TableColumnCount;
487        let content = "| Left | Center | Right |
488|:-----|:------:|------:|
489| L    | C      | R     |
490| Left | Center |";
491        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492        let result = rule.check(&ctx).unwrap();
493
494        assert_eq!(result.len(), 1);
495        assert_eq!(result[0].line, 4);
496    }
497
498    #[test]
499    fn test_unicode_content() {
500        let rule = MD056TableColumnCount;
501        let content = "| 名前 | 年齢 | 都市 |
502|------|------|------|
503| 田中 | 25   | 東京 |
504| 佐藤 | 30   |";
505        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506        let result = rule.check(&ctx).unwrap();
507
508        assert_eq!(result.len(), 1);
509        assert_eq!(result[0].line, 4);
510    }
511
512    #[test]
513    fn test_very_long_cells() {
514        let rule = MD056TableColumnCount;
515        let content = "| Short | Very very very very very very very very very very long header | Another |
516|-------|--------------------------------------------------------------|---------|
517| Data  | This is an extremely long cell content that goes on and on   |";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520
521        assert_eq!(result.len(), 1);
522        assert!(result[0].message.contains("has 2 cells, but expected 3"));
523    }
524
525    #[test]
526    fn test_fix_with_newline_ending() {
527        let rule = MD056TableColumnCount;
528        let content = "| A | B | C |
529|---|---|---|
530| 1 | 2 |
531";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let fixed = rule.fix(&ctx).unwrap();
534
535        assert!(fixed.ends_with('\n'));
536        assert!(fixed.contains("| 1 | 2 |  |"));
537    }
538
539    #[test]
540    fn test_fix_without_newline_ending() {
541        let rule = MD056TableColumnCount;
542        let content = "| A | B | C |
543|---|---|---|
544| 1 | 2 |";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let fixed = rule.fix(&ctx).unwrap();
547
548        assert!(!fixed.ends_with('\n'));
549        assert!(fixed.contains("| 1 | 2 |  |"));
550    }
551
552    #[test]
553    fn test_blockquote_table_column_mismatch() {
554        let rule = MD056TableColumnCount;
555        let content = "> | Header 1 | Header 2 | Header 3 |
556> |----------|----------|----------|
557> | Cell 1   | Cell 2   |";
558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559        let result = rule.check(&ctx).unwrap();
560
561        assert_eq!(result.len(), 1);
562        assert_eq!(result[0].line, 3);
563        assert!(result[0].message.contains("has 2 cells, but expected 3"));
564    }
565
566    #[test]
567    fn test_fix_blockquote_table_preserves_prefix() {
568        let rule = MD056TableColumnCount;
569        let content = "> | Header 1 | Header 2 | Header 3 |
570> |----------|----------|----------|
571> | Cell 1   | Cell 2   |
572> | Cell 4   | Cell 5   | Cell 6   |";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let fixed = rule.fix(&ctx).unwrap();
575
576        // Each line should still start with "> "
577        for line in fixed.lines() {
578            assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
579        }
580        // The fixed row should have 3 cells
581        assert!(fixed.contains("> | Cell 1 | Cell 2 |  |"));
582    }
583
584    #[test]
585    fn test_fix_nested_blockquote_table() {
586        let rule = MD056TableColumnCount;
587        let content = ">> | A | B | C |
588>> |---|---|---|
589>> | 1 | 2 |";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591        let fixed = rule.fix(&ctx).unwrap();
592
593        // Each line should preserve the nested blockquote prefix
594        for line in fixed.lines() {
595            assert!(
596                line.starts_with(">> "),
597                "Line should preserve nested blockquote prefix: {line}"
598            );
599        }
600        assert!(fixed.contains(">> | 1 | 2 |  |"));
601    }
602
603    #[test]
604    fn test_blockquote_table_too_many_columns() {
605        let rule = MD056TableColumnCount;
606        let content = "> | A | B |
607> |---|---|
608> | 1 | 2 | 3 | 4 |";
609        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610        let fixed = rule.fix(&ctx).unwrap();
611
612        // Should preserve blockquote prefix while truncating columns
613        assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
614        assert!(fixed.contains("> | 1 | 2 |"));
615        assert!(!fixed.contains("| 3 |"));
616    }
617
618    // === Roundtrip safety tests ===
619
620    fn assert_fix_roundtrip(content: &str) {
621        let rule = MD056TableColumnCount;
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let fixed = rule.fix(&ctx).unwrap();
624        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
625        let remaining = rule.check(&ctx2).unwrap();
626        assert!(
627            remaining.is_empty(),
628            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
629        );
630    }
631
632    #[test]
633    fn test_roundtrip_too_few_columns() {
634        assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |");
635    }
636
637    #[test]
638    fn test_roundtrip_too_many_columns() {
639        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 | 3 | 4 |");
640    }
641
642    #[test]
643    fn test_roundtrip_with_trailing_newline() {
644        assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |\n");
645    }
646
647    #[test]
648    fn test_roundtrip_blockquote_table() {
649        assert_fix_roundtrip("> | A | B | C |\n> |---|---|---|\n> | 1 | 2 |");
650    }
651
652    #[test]
653    fn test_roundtrip_clean_table() {
654        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |");
655    }
656
657    #[test]
658    fn test_roundtrip_multiple_tables() {
659        assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |\n\nText\n\n| C | D | E |\n|---|---|---|\n| 3 | 4 |");
660    }
661}