rumdl_lib/rules/
md056_table_column_count.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, 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 to match the expected column count
20    fn fix_table_row(&self, row: &str, expected_count: usize, flavor: crate::config::MarkdownFlavor) -> Option<String> {
21        let current_count = TableUtils::count_cells_with_flavor(row, flavor);
22
23        if current_count == expected_count || current_count == 0 {
24            return None;
25        }
26
27        // For standard flavor with too many cells, first try escaping pipes in inline code.
28        // This preserves content and produces valid GitHub-compatible output.
29        if flavor == crate::config::MarkdownFlavor::Standard && current_count > expected_count {
30            let escaped_row = TableUtils::escape_pipes_in_inline_code(row);
31            let escaped_count = TableUtils::count_cells_with_flavor(&escaped_row, flavor);
32
33            // If escaping pipes in inline code fixes the cell count, use that
34            if escaped_count == expected_count {
35                return Some(escaped_row.trim().to_string());
36            }
37
38            // If escaping reduced cell count, continue fixing with escaped version
39            if escaped_count < current_count {
40                return self.fix_row_by_truncation(&escaped_row, expected_count, flavor);
41            }
42        }
43
44        self.fix_row_by_truncation(row, expected_count, flavor)
45    }
46
47    /// Fix a table row by truncating or adding cells
48    fn fix_row_by_truncation(
49        &self,
50        row: &str,
51        expected_count: usize,
52        flavor: crate::config::MarkdownFlavor,
53    ) -> Option<String> {
54        let current_count = TableUtils::count_cells_with_flavor(row, flavor);
55
56        if current_count == expected_count || current_count == 0 {
57            return None;
58        }
59
60        let trimmed = row.trim();
61        let has_leading_pipe = trimmed.starts_with('|');
62        let has_trailing_pipe = trimmed.ends_with('|');
63
64        // Use flavor-aware cell splitting
65        let cells = Self::split_row_into_cells(trimmed, flavor);
66
67        let mut cell_contents: Vec<&str> = Vec::new();
68        for (i, cell) in cells.iter().enumerate() {
69            // Skip empty leading/trailing parts
70            if (i == 0 && cell.trim().is_empty() && has_leading_pipe)
71                || (i == cells.len() - 1 && cell.trim().is_empty() && has_trailing_pipe)
72            {
73                continue;
74            }
75            cell_contents.push(cell.trim());
76        }
77
78        // Adjust cell count to match expected count
79        match current_count.cmp(&expected_count) {
80            std::cmp::Ordering::Greater => {
81                // Too many cells, remove excess
82                cell_contents.truncate(expected_count);
83            }
84            std::cmp::Ordering::Less => {
85                // Too few cells, add empty ones
86                while cell_contents.len() < expected_count {
87                    cell_contents.push("");
88                }
89            }
90            std::cmp::Ordering::Equal => {
91                // Perfect number of cells, no adjustment needed
92            }
93        }
94
95        // Reconstruct row
96        let mut result = String::new();
97        if has_leading_pipe {
98            result.push('|');
99        }
100
101        for (i, cell) in cell_contents.iter().enumerate() {
102            result.push_str(&format!(" {cell} "));
103            if i < cell_contents.len() - 1 || has_trailing_pipe {
104                result.push('|');
105            }
106        }
107
108        Some(result)
109    }
110
111    /// Split a table row into cells, respecting flavor-specific behavior
112    ///
113    /// For Standard/GFM flavor, pipes in inline code ARE cell delimiters.
114    /// For MkDocs flavor, pipes in inline code are NOT cell delimiters.
115    fn split_row_into_cells(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
116        // First, mask escaped pipes (same for all flavors)
117        let masked = TableUtils::mask_pipes_for_table_parsing(row);
118
119        // For MkDocs flavor, also mask pipes inside inline code
120        let final_masked = if flavor == crate::config::MarkdownFlavor::MkDocs {
121            TableUtils::mask_pipes_in_inline_code(&masked)
122        } else {
123            masked
124        };
125
126        // Split by pipes on the masked string, then extract corresponding
127        // original content from the unmasked row
128        let masked_parts: Vec<&str> = final_masked.split('|').collect();
129        let mut cells = Vec::new();
130        let mut pos = 0;
131
132        for masked_part in masked_parts {
133            let cell_len = masked_part.len();
134            if pos + cell_len <= row.len() {
135                cells.push(row[pos..pos + cell_len].to_string());
136            } else {
137                cells.push(masked_part.to_string());
138            }
139            pos += cell_len + 1; // +1 for the pipe delimiter
140        }
141
142        cells
143    }
144}
145
146impl Rule for MD056TableColumnCount {
147    fn name(&self) -> &'static str {
148        "MD056"
149    }
150
151    fn description(&self) -> &'static str {
152        "Table column count should be consistent"
153    }
154
155    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
156        // Skip if no tables present
157        !ctx.likely_has_tables()
158    }
159
160    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
161        let content = ctx.content;
162        let flavor = ctx.flavor;
163        let mut warnings = Vec::new();
164
165        // Early return for empty content or content without tables
166        if content.is_empty() || !content.contains('|') {
167            return Ok(Vec::new());
168        }
169
170        let lines: Vec<&str> = content.lines().collect();
171
172        // Use pre-computed table blocks from context
173        let table_blocks = &ctx.table_blocks;
174
175        for table_block in table_blocks {
176            // Determine expected column count from header row
177            let expected_count = TableUtils::count_cells_with_flavor(lines[table_block.header_line], flavor);
178
179            if expected_count == 0 {
180                continue; // Skip invalid tables
181            }
182
183            // Collect all table lines for building the whole-table fix
184            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
185                .chain(std::iter::once(table_block.delimiter_line))
186                .chain(table_block.content_lines.iter().copied())
187                .collect();
188
189            // Build the whole-table fix once for all warnings in this table
190            // This ensures that applying Quick Fix on any row fixes the entire table
191            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
192            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
193
194            // Build the complete fixed table content
195            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
196            for &line_idx in &all_line_indices {
197                let line = lines[line_idx];
198                let fixed_line = self
199                    .fix_table_row(line, expected_count, flavor)
200                    .unwrap_or_else(|| line.to_string());
201                if line_idx < lines.len() - 1 {
202                    fixed_table_lines.push(format!("{fixed_line}\n"));
203                } else {
204                    fixed_table_lines.push(fixed_line);
205                }
206            }
207            let table_replacement = fixed_table_lines.concat();
208            let table_range = ctx.line_index.multi_line_range(table_start_line, table_end_line);
209
210            // Check all rows in the table
211            for &line_idx in &all_line_indices {
212                let line = lines[line_idx];
213                let count = TableUtils::count_cells_with_flavor(line, flavor);
214
215                if count > 0 && count != expected_count {
216                    // Calculate precise character range for the entire table row
217                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
218
219                    // Each warning uses the same whole-table fix
220                    // This ensures Quick Fix on any row fixes the entire table
221                    warnings.push(LintWarning {
222                        rule_name: Some(self.name().to_string()),
223                        message: format!("Table row has {count} cells, but expected {expected_count}"),
224                        line: start_line,
225                        column: start_col,
226                        end_line,
227                        end_column: end_col,
228                        severity: Severity::Warning,
229                        fix: Some(Fix {
230                            range: table_range.clone(),
231                            replacement: table_replacement.clone(),
232                        }),
233                    });
234                }
235            }
236        }
237
238        Ok(warnings)
239    }
240
241    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
242        let content = ctx.content;
243        let flavor = ctx.flavor;
244        let lines: Vec<&str> = content.lines().collect();
245        let table_blocks = &ctx.table_blocks;
246
247        let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
248
249        for table_block in table_blocks {
250            // Determine expected column count from header row
251            let expected_count = TableUtils::count_cells_with_flavor(lines[table_block.header_line], flavor);
252
253            if expected_count == 0 {
254                continue; // Skip invalid tables
255            }
256
257            // Fix all rows in the table
258            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
259                .chain(std::iter::once(table_block.delimiter_line))
260                .chain(table_block.content_lines.iter().copied())
261                .collect();
262
263            for &line_idx in &all_line_indices {
264                let line = lines[line_idx];
265                if let Some(fixed_line) = self.fix_table_row(line, expected_count, flavor) {
266                    result_lines[line_idx] = fixed_line;
267                }
268            }
269        }
270
271        let mut fixed = result_lines.join("\n");
272        // Preserve trailing newline if original content had one
273        if content.ends_with('\n') && !fixed.ends_with('\n') {
274            fixed.push('\n');
275        }
276        Ok(fixed)
277    }
278
279    fn as_any(&self) -> &dyn std::any::Any {
280        self
281    }
282
283    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
284    where
285        Self: Sized,
286    {
287        Box::new(MD056TableColumnCount)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::lint_context::LintContext;
295
296    #[test]
297    fn test_valid_table() {
298        let rule = MD056TableColumnCount;
299        let content = "| Header 1 | Header 2 | Header 3 |
300|----------|----------|----------|
301| Cell 1   | Cell 2   | Cell 3   |
302| Cell 4   | Cell 5   | Cell 6   |";
303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
304        let result = rule.check(&ctx).unwrap();
305
306        assert_eq!(result.len(), 0);
307    }
308
309    #[test]
310    fn test_too_few_columns() {
311        let rule = MD056TableColumnCount;
312        let content = "| Header 1 | Header 2 | Header 3 |
313|----------|----------|----------|
314| Cell 1   | Cell 2   |
315| Cell 4   | Cell 5   | Cell 6   |";
316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
317        let result = rule.check(&ctx).unwrap();
318
319        assert_eq!(result.len(), 1);
320        assert_eq!(result[0].line, 3);
321        assert!(result[0].message.contains("has 2 cells, but expected 3"));
322    }
323
324    #[test]
325    fn test_too_many_columns() {
326        let rule = MD056TableColumnCount;
327        let content = "| Header 1 | Header 2 |
328|----------|----------|
329| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
330| Cell 5   | Cell 6   |";
331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332        let result = rule.check(&ctx).unwrap();
333
334        assert_eq!(result.len(), 1);
335        assert_eq!(result[0].line, 3);
336        assert!(result[0].message.contains("has 4 cells, but expected 2"));
337    }
338
339    #[test]
340    fn test_delimiter_row_mismatch() {
341        let rule = MD056TableColumnCount;
342        let content = "| Header 1 | Header 2 | Header 3 |
343|----------|----------|
344| Cell 1   | Cell 2   | Cell 3   |";
345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346        let result = rule.check(&ctx).unwrap();
347
348        assert_eq!(result.len(), 1);
349        assert_eq!(result[0].line, 2);
350        assert!(result[0].message.contains("has 2 cells, but expected 3"));
351    }
352
353    #[test]
354    fn test_fix_too_few_columns() {
355        let rule = MD056TableColumnCount;
356        let content = "| Header 1 | Header 2 | Header 3 |
357|----------|----------|----------|
358| Cell 1   | Cell 2   |
359| Cell 4   | Cell 5   | Cell 6   |";
360        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
361        let fixed = rule.fix(&ctx).unwrap();
362
363        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
364    }
365
366    #[test]
367    fn test_fix_too_many_columns() {
368        let rule = MD056TableColumnCount;
369        let content = "| Header 1 | Header 2 |
370|----------|----------|
371| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
372| Cell 5   | Cell 6   |";
373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374        let fixed = rule.fix(&ctx).unwrap();
375
376        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
377        assert!(!fixed.contains("Cell 3"));
378        assert!(!fixed.contains("Cell 4"));
379    }
380
381    #[test]
382    fn test_no_leading_pipe() {
383        let rule = MD056TableColumnCount;
384        let content = "Header 1 | Header 2 | Header 3 |
385---------|----------|----------|
386Cell 1   | Cell 2   |
387Cell 4   | Cell 5   | Cell 6   |";
388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389        let result = rule.check(&ctx).unwrap();
390
391        assert_eq!(result.len(), 1);
392        assert_eq!(result[0].line, 3);
393    }
394
395    #[test]
396    fn test_no_trailing_pipe() {
397        let rule = MD056TableColumnCount;
398        let content = "| Header 1 | Header 2 | Header 3
399|----------|----------|----------
400| Cell 1   | Cell 2
401| Cell 4   | Cell 5   | Cell 6";
402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403        let result = rule.check(&ctx).unwrap();
404
405        assert_eq!(result.len(), 1);
406        assert_eq!(result[0].line, 3);
407    }
408
409    #[test]
410    fn test_no_pipes_at_all() {
411        let rule = MD056TableColumnCount;
412        let content = "This is not a table
413Just regular text
414No pipes here";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416        let result = rule.check(&ctx).unwrap();
417
418        assert_eq!(result.len(), 0);
419    }
420
421    #[test]
422    fn test_empty_cells() {
423        let rule = MD056TableColumnCount;
424        let content = "| Header 1 | Header 2 | Header 3 |
425|----------|----------|----------|
426|          |          |          |
427| Cell 1   |          | Cell 3   |";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let result = rule.check(&ctx).unwrap();
430
431        assert_eq!(result.len(), 0);
432    }
433
434    #[test]
435    fn test_multiple_tables() {
436        let rule = MD056TableColumnCount;
437        let content = "| Table 1 Col 1 | Table 1 Col 2 |
438|----------------|----------------|
439| Data 1         | Data 2         |
440
441Some text in between.
442
443| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
444|----------------|----------------|----------------|
445| Data 3         | Data 4         |
446| Data 5         | Data 6         | Data 7         |";
447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448        let result = rule.check(&ctx).unwrap();
449
450        assert_eq!(result.len(), 1);
451        assert_eq!(result[0].line, 9);
452        assert!(result[0].message.contains("has 2 cells, but expected 3"));
453    }
454
455    #[test]
456    fn test_table_with_escaped_pipes() {
457        let rule = MD056TableColumnCount;
458
459        // Single backslash escapes the pipe: \| keeps pipe as content (2 columns)
460        let content = "| Command | Description |
461|---------|-------------|
462| `echo \\| grep` | Pipe example |
463| `ls` | List files |";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let result = rule.check(&ctx).unwrap();
466        assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
467
468        // Double backslash + pipe: \\| means escaped backslash + pipe delimiter (3 columns)
469        let content_double = "| Command | Description |
470|---------|-------------|
471| `echo \\\\| grep` | Pipe example |
472| `ls` | List files |";
473        let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
474        let result2 = rule.check(&ctx2).unwrap();
475        // Line 3 has \\| which becomes 3 cells, but header expects 2
476        assert_eq!(result2.len(), 1, "double backslash \\\\| should split cells");
477    }
478
479    #[test]
480    fn test_empty_content() {
481        let rule = MD056TableColumnCount;
482        let content = "";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484        let result = rule.check(&ctx).unwrap();
485
486        assert_eq!(result.len(), 0);
487    }
488
489    #[test]
490    fn test_code_block_with_table() {
491        let rule = MD056TableColumnCount;
492        let content = "```
493| This | Is | Code |
494|------|----|----|
495| Not  | A  | Table |
496```
497
498| Real | Table |
499|------|-------|
500| Data | Here  |";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let result = rule.check(&ctx).unwrap();
503
504        // Should not check tables inside code blocks
505        assert_eq!(result.len(), 0);
506    }
507
508    #[test]
509    fn test_fix_preserves_pipe_style() {
510        let rule = MD056TableColumnCount;
511        // Test with no trailing pipes
512        let content = "| Header 1 | Header 2 | Header 3
513|----------|----------|----------
514| Cell 1   | Cell 2";
515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516        let fixed = rule.fix(&ctx).unwrap();
517
518        let lines: Vec<&str> = fixed.lines().collect();
519        assert!(!lines[2].ends_with('|'));
520        assert!(lines[2].contains("Cell 1"));
521        assert!(lines[2].contains("Cell 2"));
522    }
523
524    #[test]
525    fn test_single_column_table() {
526        let rule = MD056TableColumnCount;
527        let content = "| Header |
528|---------|
529| Cell 1  |
530| Cell 2  |";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533
534        assert_eq!(result.len(), 0);
535    }
536
537    #[test]
538    fn test_complex_delimiter_row() {
539        let rule = MD056TableColumnCount;
540        let content = "| Left | Center | Right |
541|:-----|:------:|------:|
542| L    | C      | R     |
543| Left | Center |";
544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545        let result = rule.check(&ctx).unwrap();
546
547        assert_eq!(result.len(), 1);
548        assert_eq!(result[0].line, 4);
549    }
550
551    #[test]
552    fn test_unicode_content() {
553        let rule = MD056TableColumnCount;
554        let content = "| 名前 | 年齢 | 都市 |
555|------|------|------|
556| 田中 | 25   | 東京 |
557| 佐藤 | 30   |";
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, 4);
563    }
564
565    #[test]
566    fn test_very_long_cells() {
567        let rule = MD056TableColumnCount;
568        let content = "| Short | Very very very very very very very very very very long header | Another |
569|-------|--------------------------------------------------------------|---------|
570| Data  | This is an extremely long cell content that goes on and on   |";
571        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572        let result = rule.check(&ctx).unwrap();
573
574        assert_eq!(result.len(), 1);
575        assert!(result[0].message.contains("has 2 cells, but expected 3"));
576    }
577
578    #[test]
579    fn test_fix_with_newline_ending() {
580        let rule = MD056TableColumnCount;
581        let content = "| A | B | C |
582|---|---|---|
583| 1 | 2 |
584";
585        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586        let fixed = rule.fix(&ctx).unwrap();
587
588        assert!(fixed.ends_with('\n'));
589        assert!(fixed.contains("| 1 | 2 |  |"));
590    }
591
592    #[test]
593    fn test_fix_without_newline_ending() {
594        let rule = MD056TableColumnCount;
595        let content = "| A | B | C |
596|---|---|---|
597| 1 | 2 |";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599        let fixed = rule.fix(&ctx).unwrap();
600
601        assert!(!fixed.ends_with('\n'));
602        assert!(fixed.contains("| 1 | 2 |  |"));
603    }
604}