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            // Check all rows in the table
151            let all_lines = 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
155            for line_idx in all_lines {
156                let line = lines[line_idx];
157                let count = TableUtils::count_cells_with_flavor(line, flavor);
158
159                if count > 0 && count != expected_count {
160                    let fix_result = self.fix_table_row(line, expected_count, flavor);
161
162                    // Calculate precise character range for the entire table row
163                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
164
165                    warnings.push(LintWarning {
166                        rule_name: Some(self.name().to_string()),
167                        message: format!("Table row has {count} cells, but expected {expected_count}"),
168                        line: start_line,
169                        column: start_col,
170                        end_line,
171                        end_column: end_col,
172                        severity: Severity::Warning,
173                        fix: fix_result.map(|fixed_row| Fix {
174                            range: ctx.line_index.line_col_to_byte_range(line_idx + 1, 1),
175                            replacement: fixed_row,
176                        }),
177                    });
178                }
179            }
180        }
181
182        Ok(warnings)
183    }
184
185    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
186        let content = ctx.content;
187        let warnings = self.check(ctx)?;
188        if warnings.is_empty() {
189            return Ok(content.to_string());
190        }
191
192        // Build HashMap for O(1) lookup instead of O(n) linear search per line
193        let warning_by_line: std::collections::HashMap<usize, &LintWarning> = warnings
194            .iter()
195            .filter_map(|w| w.fix.as_ref().map(|_| (w.line, w)))
196            .collect();
197
198        let lines: Vec<&str> = content.lines().collect();
199        let mut result = Vec::new();
200
201        for (i, line) in lines.iter().enumerate() {
202            if let Some(warning) = warning_by_line.get(&(i + 1))
203                && let Some(fix) = &warning.fix
204            {
205                result.push(fix.replacement.clone());
206                continue;
207            }
208            result.push(line.to_string());
209        }
210
211        // Preserve the original line endings
212        if content.ends_with('\n') {
213            Ok(result.join("\n") + "\n")
214        } else {
215            Ok(result.join("\n"))
216        }
217    }
218
219    fn as_any(&self) -> &dyn std::any::Any {
220        self
221    }
222
223    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
224    where
225        Self: Sized,
226    {
227        Box::new(MD056TableColumnCount)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::lint_context::LintContext;
235
236    #[test]
237    fn test_valid_table() {
238        let rule = MD056TableColumnCount;
239        let content = "| Header 1 | Header 2 | Header 3 |
240|----------|----------|----------|
241| Cell 1   | Cell 2   | Cell 3   |
242| Cell 4   | Cell 5   | Cell 6   |";
243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
244        let result = rule.check(&ctx).unwrap();
245
246        assert_eq!(result.len(), 0);
247    }
248
249    #[test]
250    fn test_too_few_columns() {
251        let rule = MD056TableColumnCount;
252        let content = "| Header 1 | Header 2 | Header 3 |
253|----------|----------|----------|
254| Cell 1   | Cell 2   |
255| Cell 4   | Cell 5   | Cell 6   |";
256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
257        let result = rule.check(&ctx).unwrap();
258
259        assert_eq!(result.len(), 1);
260        assert_eq!(result[0].line, 3);
261        assert!(result[0].message.contains("has 2 cells, but expected 3"));
262    }
263
264    #[test]
265    fn test_too_many_columns() {
266        let rule = MD056TableColumnCount;
267        let content = "| Header 1 | Header 2 |
268|----------|----------|
269| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
270| Cell 5   | Cell 6   |";
271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
272        let result = rule.check(&ctx).unwrap();
273
274        assert_eq!(result.len(), 1);
275        assert_eq!(result[0].line, 3);
276        assert!(result[0].message.contains("has 4 cells, but expected 2"));
277    }
278
279    #[test]
280    fn test_delimiter_row_mismatch() {
281        let rule = MD056TableColumnCount;
282        let content = "| Header 1 | Header 2 | Header 3 |
283|----------|----------|
284| Cell 1   | Cell 2   | Cell 3   |";
285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
286        let result = rule.check(&ctx).unwrap();
287
288        assert_eq!(result.len(), 1);
289        assert_eq!(result[0].line, 2);
290        assert!(result[0].message.contains("has 2 cells, but expected 3"));
291    }
292
293    #[test]
294    fn test_fix_too_few_columns() {
295        let rule = MD056TableColumnCount;
296        let content = "| Header 1 | Header 2 | Header 3 |
297|----------|----------|----------|
298| Cell 1   | Cell 2   |
299| Cell 4   | Cell 5   | Cell 6   |";
300        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
301        let fixed = rule.fix(&ctx).unwrap();
302
303        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
304    }
305
306    #[test]
307    fn test_fix_too_many_columns() {
308        let rule = MD056TableColumnCount;
309        let content = "| Header 1 | Header 2 |
310|----------|----------|
311| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
312| Cell 5   | Cell 6   |";
313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314        let fixed = rule.fix(&ctx).unwrap();
315
316        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
317        assert!(!fixed.contains("Cell 3"));
318        assert!(!fixed.contains("Cell 4"));
319    }
320
321    #[test]
322    fn test_no_leading_pipe() {
323        let rule = MD056TableColumnCount;
324        let content = "Header 1 | Header 2 | Header 3 |
325---------|----------|----------|
326Cell 1   | Cell 2   |
327Cell 4   | Cell 5   | Cell 6   |";
328        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
329        let result = rule.check(&ctx).unwrap();
330
331        assert_eq!(result.len(), 1);
332        assert_eq!(result[0].line, 3);
333    }
334
335    #[test]
336    fn test_no_trailing_pipe() {
337        let rule = MD056TableColumnCount;
338        let content = "| Header 1 | Header 2 | Header 3
339|----------|----------|----------
340| Cell 1   | Cell 2
341| Cell 4   | Cell 5   | Cell 6";
342        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
343        let result = rule.check(&ctx).unwrap();
344
345        assert_eq!(result.len(), 1);
346        assert_eq!(result[0].line, 3);
347    }
348
349    #[test]
350    fn test_no_pipes_at_all() {
351        let rule = MD056TableColumnCount;
352        let content = "This is not a table
353Just regular text
354No pipes here";
355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
356        let result = rule.check(&ctx).unwrap();
357
358        assert_eq!(result.len(), 0);
359    }
360
361    #[test]
362    fn test_empty_cells() {
363        let rule = MD056TableColumnCount;
364        let content = "| Header 1 | Header 2 | Header 3 |
365|----------|----------|----------|
366|          |          |          |
367| Cell 1   |          | Cell 3   |";
368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369        let result = rule.check(&ctx).unwrap();
370
371        assert_eq!(result.len(), 0);
372    }
373
374    #[test]
375    fn test_multiple_tables() {
376        let rule = MD056TableColumnCount;
377        let content = "| Table 1 Col 1 | Table 1 Col 2 |
378|----------------|----------------|
379| Data 1         | Data 2         |
380
381Some text in between.
382
383| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
384|----------------|----------------|----------------|
385| Data 3         | Data 4         |
386| Data 5         | Data 6         | Data 7         |";
387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388        let result = rule.check(&ctx).unwrap();
389
390        assert_eq!(result.len(), 1);
391        assert_eq!(result[0].line, 9);
392        assert!(result[0].message.contains("has 2 cells, but expected 3"));
393    }
394
395    #[test]
396    fn test_table_with_escaped_pipes() {
397        let rule = MD056TableColumnCount;
398
399        // Single backslash escapes the pipe: \| keeps pipe as content (2 columns)
400        let content = "| Command | Description |
401|---------|-------------|
402| `echo \\| grep` | Pipe example |
403| `ls` | List files |";
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
405        let result = rule.check(&ctx).unwrap();
406        assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
407
408        // Double backslash + pipe: \\| means escaped backslash + pipe delimiter (3 columns)
409        let content_double = "| Command | Description |
410|---------|-------------|
411| `echo \\\\| grep` | Pipe example |
412| `ls` | List files |";
413        let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard);
414        let result2 = rule.check(&ctx2).unwrap();
415        // Line 3 has \\| which becomes 3 cells, but header expects 2
416        assert_eq!(result2.len(), 1, "double backslash \\\\| should split cells");
417    }
418
419    #[test]
420    fn test_empty_content() {
421        let rule = MD056TableColumnCount;
422        let content = "";
423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
424        let result = rule.check(&ctx).unwrap();
425
426        assert_eq!(result.len(), 0);
427    }
428
429    #[test]
430    fn test_code_block_with_table() {
431        let rule = MD056TableColumnCount;
432        let content = "```
433| This | Is | Code |
434|------|----|----|
435| Not  | A  | Table |
436```
437
438| Real | Table |
439|------|-------|
440| Data | Here  |";
441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442        let result = rule.check(&ctx).unwrap();
443
444        // Should not check tables inside code blocks
445        assert_eq!(result.len(), 0);
446    }
447
448    #[test]
449    fn test_fix_preserves_pipe_style() {
450        let rule = MD056TableColumnCount;
451        // Test with no trailing pipes
452        let content = "| Header 1 | Header 2 | Header 3
453|----------|----------|----------
454| Cell 1   | Cell 2";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456        let fixed = rule.fix(&ctx).unwrap();
457
458        let lines: Vec<&str> = fixed.lines().collect();
459        assert!(!lines[2].ends_with('|'));
460        assert!(lines[2].contains("Cell 1"));
461        assert!(lines[2].contains("Cell 2"));
462    }
463
464    #[test]
465    fn test_single_column_table() {
466        let rule = MD056TableColumnCount;
467        let content = "| Header |
468|---------|
469| Cell 1  |
470| Cell 2  |";
471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
472        let result = rule.check(&ctx).unwrap();
473
474        assert_eq!(result.len(), 0);
475    }
476
477    #[test]
478    fn test_complex_delimiter_row() {
479        let rule = MD056TableColumnCount;
480        let content = "| Left | Center | Right |
481|:-----|:------:|------:|
482| L    | C      | R     |
483| Left | Center |";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
485        let result = rule.check(&ctx).unwrap();
486
487        assert_eq!(result.len(), 1);
488        assert_eq!(result[0].line, 4);
489    }
490
491    #[test]
492    fn test_unicode_content() {
493        let rule = MD056TableColumnCount;
494        let content = "| 名前 | 年齢 | 都市 |
495|------|------|------|
496| 田中 | 25   | 東京 |
497| 佐藤 | 30   |";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
499        let result = rule.check(&ctx).unwrap();
500
501        assert_eq!(result.len(), 1);
502        assert_eq!(result[0].line, 4);
503    }
504
505    #[test]
506    fn test_very_long_cells() {
507        let rule = MD056TableColumnCount;
508        let content = "| Short | Very very very very very very very very very very long header | Another |
509|-------|--------------------------------------------------------------|---------|
510| Data  | This is an extremely long cell content that goes on and on   |";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512        let result = rule.check(&ctx).unwrap();
513
514        assert_eq!(result.len(), 1);
515        assert!(result[0].message.contains("has 2 cells, but expected 3"));
516    }
517
518    #[test]
519    fn test_fix_with_newline_ending() {
520        let rule = MD056TableColumnCount;
521        let content = "| A | B | C |
522|---|---|---|
523| 1 | 2 |
524";
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
526        let fixed = rule.fix(&ctx).unwrap();
527
528        assert!(fixed.ends_with('\n'));
529        assert!(fixed.contains("| 1 | 2 |  |"));
530    }
531
532    #[test]
533    fn test_fix_without_newline_ending() {
534        let rule = MD056TableColumnCount;
535        let content = "| A | B | C |
536|---|---|---|
537| 1 | 2 |";
538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539        let fixed = rule.fix(&ctx).unwrap();
540
541        assert!(!fixed.ends_with('\n'));
542        assert!(fixed.contains("| 1 | 2 |  |"));
543    }
544}