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