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