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) -> Option<String> {
21        let current_count = TableUtils::count_cells(row);
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        let parts: Vec<&str> = trimmed.split('|').collect();
32        let mut cells = Vec::new();
33
34        // Extract actual cell content
35        for (i, part) in parts.iter().enumerate() {
36            // Skip empty leading/trailing parts
37            if (i == 0 && part.trim().is_empty() && has_leading_pipe)
38                || (i == parts.len() - 1 && part.trim().is_empty() && has_trailing_pipe)
39            {
40                continue;
41            }
42            cells.push(part.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                cells.truncate(expected_count);
50            }
51            std::cmp::Ordering::Less => {
52                // Too few cells, add empty ones
53                while cells.len() < expected_count {
54                    cells.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 cells.iter().enumerate() {
69            result.push_str(&format!(" {cell} "));
70            if i < cells.len() - 1 || has_trailing_pipe {
71                result.push('|');
72            }
73        }
74
75        Some(result)
76    }
77}
78
79impl Rule for MD056TableColumnCount {
80    fn name(&self) -> &'static str {
81        "MD056"
82    }
83
84    fn description(&self) -> &'static str {
85        "Table column count should be consistent"
86    }
87
88    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
89        // Skip if no tables present
90        !ctx.likely_has_tables()
91    }
92
93    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94        let content = ctx.content;
95        let mut warnings = Vec::new();
96
97        // Early return for empty content or content without tables
98        if content.is_empty() || !content.contains('|') {
99            return Ok(Vec::new());
100        }
101
102        let lines: Vec<&str> = content.lines().collect();
103
104        // Use pre-computed table blocks from context
105        let table_blocks = &ctx.table_blocks;
106
107        for table_block in table_blocks {
108            // Determine expected column count from header row
109            let expected_count = TableUtils::count_cells(lines[table_block.header_line]);
110
111            if expected_count == 0 {
112                continue; // Skip invalid tables
113            }
114
115            // Check all rows in the table
116            let all_lines = std::iter::once(table_block.header_line)
117                .chain(std::iter::once(table_block.delimiter_line))
118                .chain(table_block.content_lines.iter().copied());
119
120            for line_idx in all_lines {
121                let line = lines[line_idx];
122                let count = TableUtils::count_cells(line);
123
124                if count > 0 && count != expected_count {
125                    let fix_result = self.fix_table_row(line, expected_count);
126
127                    // Calculate precise character range for the entire table row
128                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
129
130                    warnings.push(LintWarning {
131                        rule_name: Some(self.name().to_string()),
132                        message: format!("Table row has {count} cells, but expected {expected_count}"),
133                        line: start_line,
134                        column: start_col,
135                        end_line,
136                        end_column: end_col,
137                        severity: Severity::Warning,
138                        fix: fix_result.map(|fixed_row| Fix {
139                            range: ctx.line_index.line_col_to_byte_range(line_idx + 1, 1),
140                            replacement: fixed_row,
141                        }),
142                    });
143                }
144            }
145        }
146
147        Ok(warnings)
148    }
149
150    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
151        let content = ctx.content;
152        let warnings = self.check(ctx)?;
153        if warnings.is_empty() {
154            return Ok(content.to_string());
155        }
156
157        // Build HashMap for O(1) lookup instead of O(n) linear search per line
158        let warning_by_line: std::collections::HashMap<usize, &LintWarning> = warnings
159            .iter()
160            .filter_map(|w| w.fix.as_ref().map(|_| (w.line, w)))
161            .collect();
162
163        let lines: Vec<&str> = content.lines().collect();
164        let mut result = Vec::new();
165
166        for (i, line) in lines.iter().enumerate() {
167            if let Some(warning) = warning_by_line.get(&(i + 1))
168                && let Some(fix) = &warning.fix
169            {
170                result.push(fix.replacement.clone());
171                continue;
172            }
173            result.push(line.to_string());
174        }
175
176        // Preserve the original line endings
177        if content.ends_with('\n') {
178            Ok(result.join("\n") + "\n")
179        } else {
180            Ok(result.join("\n"))
181        }
182    }
183
184    fn as_any(&self) -> &dyn std::any::Any {
185        self
186    }
187
188    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
189    where
190        Self: Sized,
191    {
192        Box::new(MD056TableColumnCount)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::lint_context::LintContext;
200
201    #[test]
202    fn test_valid_table() {
203        let rule = MD056TableColumnCount;
204        let content = "| Header 1 | Header 2 | Header 3 |
205|----------|----------|----------|
206| Cell 1   | Cell 2   | Cell 3   |
207| Cell 4   | Cell 5   | Cell 6   |";
208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
209        let result = rule.check(&ctx).unwrap();
210
211        assert_eq!(result.len(), 0);
212    }
213
214    #[test]
215    fn test_too_few_columns() {
216        let rule = MD056TableColumnCount;
217        let content = "| Header 1 | Header 2 | Header 3 |
218|----------|----------|----------|
219| Cell 1   | Cell 2   |
220| Cell 4   | Cell 5   | Cell 6   |";
221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
222        let result = rule.check(&ctx).unwrap();
223
224        assert_eq!(result.len(), 1);
225        assert_eq!(result[0].line, 3);
226        assert!(result[0].message.contains("has 2 cells, but expected 3"));
227    }
228
229    #[test]
230    fn test_too_many_columns() {
231        let rule = MD056TableColumnCount;
232        let content = "| Header 1 | Header 2 |
233|----------|----------|
234| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
235| Cell 5   | Cell 6   |";
236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
237        let result = rule.check(&ctx).unwrap();
238
239        assert_eq!(result.len(), 1);
240        assert_eq!(result[0].line, 3);
241        assert!(result[0].message.contains("has 4 cells, but expected 2"));
242    }
243
244    #[test]
245    fn test_delimiter_row_mismatch() {
246        let rule = MD056TableColumnCount;
247        let content = "| Header 1 | Header 2 | Header 3 |
248|----------|----------|
249| Cell 1   | Cell 2   | Cell 3   |";
250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
251        let result = rule.check(&ctx).unwrap();
252
253        assert_eq!(result.len(), 1);
254        assert_eq!(result[0].line, 2);
255        assert!(result[0].message.contains("has 2 cells, but expected 3"));
256    }
257
258    #[test]
259    fn test_fix_too_few_columns() {
260        let rule = MD056TableColumnCount;
261        let content = "| Header 1 | Header 2 | Header 3 |
262|----------|----------|----------|
263| Cell 1   | Cell 2   |
264| Cell 4   | Cell 5   | Cell 6   |";
265        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
266        let fixed = rule.fix(&ctx).unwrap();
267
268        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
269    }
270
271    #[test]
272    fn test_fix_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);
279        let fixed = rule.fix(&ctx).unwrap();
280
281        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
282        assert!(!fixed.contains("Cell 3"));
283        assert!(!fixed.contains("Cell 4"));
284    }
285
286    #[test]
287    fn test_no_leading_pipe() {
288        let rule = MD056TableColumnCount;
289        let content = "Header 1 | Header 2 | Header 3 |
290---------|----------|----------|
291Cell 1   | Cell 2   |
292Cell 4   | Cell 5   | Cell 6   |";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294        let result = rule.check(&ctx).unwrap();
295
296        assert_eq!(result.len(), 1);
297        assert_eq!(result[0].line, 3);
298    }
299
300    #[test]
301    fn test_no_trailing_pipe() {
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);
308        let result = rule.check(&ctx).unwrap();
309
310        assert_eq!(result.len(), 1);
311        assert_eq!(result[0].line, 3);
312    }
313
314    #[test]
315    fn test_no_pipes_at_all() {
316        let rule = MD056TableColumnCount;
317        let content = "This is not a table
318Just regular text
319No pipes here";
320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321        let result = rule.check(&ctx).unwrap();
322
323        assert_eq!(result.len(), 0);
324    }
325
326    #[test]
327    fn test_empty_cells() {
328        let rule = MD056TableColumnCount;
329        let content = "| Header 1 | Header 2 | Header 3 |
330|----------|----------|----------|
331|          |          |          |
332| Cell 1   |          | Cell 3   |";
333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
334        let result = rule.check(&ctx).unwrap();
335
336        assert_eq!(result.len(), 0);
337    }
338
339    #[test]
340    fn test_multiple_tables() {
341        let rule = MD056TableColumnCount;
342        let content = "| Table 1 Col 1 | Table 1 Col 2 |
343|----------------|----------------|
344| Data 1         | Data 2         |
345
346Some text in between.
347
348| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
349|----------------|----------------|----------------|
350| Data 3         | Data 4         |
351| Data 5         | Data 6         | Data 7         |";
352        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
353        let result = rule.check(&ctx).unwrap();
354
355        assert_eq!(result.len(), 1);
356        assert_eq!(result[0].line, 9);
357        assert!(result[0].message.contains("has 2 cells, but expected 3"));
358    }
359
360    #[test]
361    #[ignore = "Table utils doesn't handle escaped pipes in code correctly yet"]
362    fn test_table_with_escaped_pipes() {
363        let rule = MD056TableColumnCount;
364        let content = "| Command | Description |
365|---------|-------------|
366| `echo \\| grep` | Pipe example |
367| `ls` | List files |";
368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369        let result = rule.check(&ctx).unwrap();
370
371        // Should handle escaped pipes correctly
372        assert_eq!(result.len(), 0);
373    }
374
375    #[test]
376    fn test_empty_content() {
377        let rule = MD056TableColumnCount;
378        let content = "";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380        let result = rule.check(&ctx).unwrap();
381
382        assert_eq!(result.len(), 0);
383    }
384
385    #[test]
386    fn test_code_block_with_table() {
387        let rule = MD056TableColumnCount;
388        let content = "```
389| This | Is | Code |
390|------|----|----|
391| Not  | A  | Table |
392```
393
394| Real | Table |
395|------|-------|
396| Data | Here  |";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
398        let result = rule.check(&ctx).unwrap();
399
400        // Should not check tables inside code blocks
401        assert_eq!(result.len(), 0);
402    }
403
404    #[test]
405    fn test_fix_preserves_pipe_style() {
406        let rule = MD056TableColumnCount;
407        // Test with no trailing pipes
408        let content = "| Header 1 | Header 2 | Header 3
409|----------|----------|----------
410| Cell 1   | Cell 2";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
412        let fixed = rule.fix(&ctx).unwrap();
413
414        let lines: Vec<&str> = fixed.lines().collect();
415        assert!(!lines[2].ends_with('|'));
416        assert!(lines[2].contains("Cell 1"));
417        assert!(lines[2].contains("Cell 2"));
418    }
419
420    #[test]
421    fn test_single_column_table() {
422        let rule = MD056TableColumnCount;
423        let content = "| Header |
424|---------|
425| Cell 1  |
426| Cell 2  |";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429
430        assert_eq!(result.len(), 0);
431    }
432
433    #[test]
434    fn test_complex_delimiter_row() {
435        let rule = MD056TableColumnCount;
436        let content = "| Left | Center | Right |
437|:-----|:------:|------:|
438| L    | C      | R     |
439| Left | Center |";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441        let result = rule.check(&ctx).unwrap();
442
443        assert_eq!(result.len(), 1);
444        assert_eq!(result[0].line, 4);
445    }
446
447    #[test]
448    fn test_unicode_content() {
449        let rule = MD056TableColumnCount;
450        let content = "| 名前 | 年齢 | 都市 |
451|------|------|------|
452| 田中 | 25   | 東京 |
453| 佐藤 | 30   |";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455        let result = rule.check(&ctx).unwrap();
456
457        assert_eq!(result.len(), 1);
458        assert_eq!(result[0].line, 4);
459    }
460
461    #[test]
462    fn test_very_long_cells() {
463        let rule = MD056TableColumnCount;
464        let content = "| Short | Very very very very very very very very very very long header | Another |
465|-------|--------------------------------------------------------------|---------|
466| Data  | This is an extremely long cell content that goes on and on   |";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468        let result = rule.check(&ctx).unwrap();
469
470        assert_eq!(result.len(), 1);
471        assert!(result[0].message.contains("has 2 cells, but expected 3"));
472    }
473
474    #[test]
475    fn test_fix_with_newline_ending() {
476        let rule = MD056TableColumnCount;
477        let content = "| A | B | C |
478|---|---|---|
479| 1 | 2 |
480";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
482        let fixed = rule.fix(&ctx).unwrap();
483
484        assert!(fixed.ends_with('\n'));
485        assert!(fixed.contains("| 1 | 2 |  |"));
486    }
487
488    #[test]
489    fn test_fix_without_newline_ending() {
490        let rule = MD056TableColumnCount;
491        let content = "| A | B | C |
492|---|---|---|
493| 1 | 2 |";
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
495        let fixed = rule.fix(&ctx).unwrap();
496
497        assert!(!fixed.ends_with('\n'));
498        assert!(fixed.contains("| 1 | 2 |  |"));
499    }
500}