rumdl_lib/rules/
md056_table_column_count.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::{LineIndex, 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 shared table detection for better performance
105        let table_blocks = TableUtils::find_table_blocks(content, ctx);
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: LineIndex::new(content.to_string()).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        let lines: Vec<&str> = content.lines().collect();
158        let mut result = Vec::new();
159
160        for (i, line) in lines.iter().enumerate() {
161            let warning_idx = warnings.iter().position(|w| w.line == i + 1);
162
163            if let Some(idx) = warning_idx
164                && let Some(fix) = &warnings[idx].fix
165            {
166                result.push(fix.replacement.clone());
167                continue;
168            }
169            result.push(line.to_string());
170        }
171
172        // Preserve the original line endings
173        if content.ends_with('\n') {
174            Ok(result.join("\n") + "\n")
175        } else {
176            Ok(result.join("\n"))
177        }
178    }
179
180    fn as_any(&self) -> &dyn std::any::Any {
181        self
182    }
183
184    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
185    where
186        Self: Sized,
187    {
188        Box::new(MD056TableColumnCount)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::lint_context::LintContext;
196
197    #[test]
198    fn test_valid_table() {
199        let rule = MD056TableColumnCount;
200        let content = "| Header 1 | Header 2 | Header 3 |
201|----------|----------|----------|
202| Cell 1   | Cell 2   | Cell 3   |
203| Cell 4   | Cell 5   | Cell 6   |";
204        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
205        let result = rule.check(&ctx).unwrap();
206
207        assert_eq!(result.len(), 0);
208    }
209
210    #[test]
211    fn test_too_few_columns() {
212        let rule = MD056TableColumnCount;
213        let content = "| Header 1 | Header 2 | Header 3 |
214|----------|----------|----------|
215| Cell 1   | Cell 2   |
216| Cell 4   | Cell 5   | Cell 6   |";
217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
218        let result = rule.check(&ctx).unwrap();
219
220        assert_eq!(result.len(), 1);
221        assert_eq!(result[0].line, 3);
222        assert!(result[0].message.contains("has 2 cells, but expected 3"));
223    }
224
225    #[test]
226    fn test_too_many_columns() {
227        let rule = MD056TableColumnCount;
228        let content = "| Header 1 | Header 2 |
229|----------|----------|
230| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
231| Cell 5   | Cell 6   |";
232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233        let result = rule.check(&ctx).unwrap();
234
235        assert_eq!(result.len(), 1);
236        assert_eq!(result[0].line, 3);
237        assert!(result[0].message.contains("has 4 cells, but expected 2"));
238    }
239
240    #[test]
241    fn test_delimiter_row_mismatch() {
242        let rule = MD056TableColumnCount;
243        let content = "| Header 1 | Header 2 | Header 3 |
244|----------|----------|
245| Cell 1   | Cell 2   | Cell 3   |";
246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
247        let result = rule.check(&ctx).unwrap();
248
249        assert_eq!(result.len(), 1);
250        assert_eq!(result[0].line, 2);
251        assert!(result[0].message.contains("has 2 cells, but expected 3"));
252    }
253
254    #[test]
255    fn test_fix_too_few_columns() {
256        let rule = MD056TableColumnCount;
257        let content = "| Header 1 | Header 2 | Header 3 |
258|----------|----------|----------|
259| Cell 1   | Cell 2   |
260| Cell 4   | Cell 5   | Cell 6   |";
261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
262        let fixed = rule.fix(&ctx).unwrap();
263
264        assert!(fixed.contains("| Cell 1 | Cell 2 |  |"));
265    }
266
267    #[test]
268    fn test_fix_too_many_columns() {
269        let rule = MD056TableColumnCount;
270        let content = "| Header 1 | Header 2 |
271|----------|----------|
272| Cell 1   | Cell 2   | Cell 3   | Cell 4   |
273| Cell 5   | Cell 6   |";
274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
275        let fixed = rule.fix(&ctx).unwrap();
276
277        assert!(fixed.contains("| Cell 1 | Cell 2 |"));
278        assert!(!fixed.contains("Cell 3"));
279        assert!(!fixed.contains("Cell 4"));
280    }
281
282    #[test]
283    fn test_no_leading_pipe() {
284        let rule = MD056TableColumnCount;
285        let content = "Header 1 | Header 2 | Header 3 |
286---------|----------|----------|
287Cell 1   | Cell 2   |
288Cell 4   | Cell 5   | Cell 6   |";
289        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
290        let result = rule.check(&ctx).unwrap();
291
292        assert_eq!(result.len(), 1);
293        assert_eq!(result[0].line, 3);
294    }
295
296    #[test]
297    fn test_no_trailing_pipe() {
298        let rule = MD056TableColumnCount;
299        let content = "| Header 1 | Header 2 | Header 3
300|----------|----------|----------
301| Cell 1   | Cell 2
302| Cell 4   | Cell 5   | Cell 6";
303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
304        let result = rule.check(&ctx).unwrap();
305
306        assert_eq!(result.len(), 1);
307        assert_eq!(result[0].line, 3);
308    }
309
310    #[test]
311    fn test_no_pipes_at_all() {
312        let rule = MD056TableColumnCount;
313        let content = "This is not a table
314Just regular text
315No pipes here";
316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
317        let result = rule.check(&ctx).unwrap();
318
319        assert_eq!(result.len(), 0);
320    }
321
322    #[test]
323    fn test_empty_cells() {
324        let rule = MD056TableColumnCount;
325        let content = "| Header 1 | Header 2 | Header 3 |
326|----------|----------|----------|
327|          |          |          |
328| Cell 1   |          | Cell 3   |";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330        let result = rule.check(&ctx).unwrap();
331
332        assert_eq!(result.len(), 0);
333    }
334
335    #[test]
336    fn test_multiple_tables() {
337        let rule = MD056TableColumnCount;
338        let content = "| Table 1 Col 1 | Table 1 Col 2 |
339|----------------|----------------|
340| Data 1         | Data 2         |
341
342Some text in between.
343
344| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
345|----------------|----------------|----------------|
346| Data 3         | Data 4         |
347| Data 5         | Data 6         | Data 7         |";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349        let result = rule.check(&ctx).unwrap();
350
351        assert_eq!(result.len(), 1);
352        assert_eq!(result[0].line, 9);
353        assert!(result[0].message.contains("has 2 cells, but expected 3"));
354    }
355
356    #[test]
357    #[ignore = "Table utils doesn't handle escaped pipes in code correctly yet"]
358    fn test_table_with_escaped_pipes() {
359        let rule = MD056TableColumnCount;
360        let content = "| Command | Description |
361|---------|-------------|
362| `echo \\| grep` | Pipe example |
363| `ls` | List files |";
364        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
365        let result = rule.check(&ctx).unwrap();
366
367        // Should handle escaped pipes correctly
368        assert_eq!(result.len(), 0);
369    }
370
371    #[test]
372    fn test_empty_content() {
373        let rule = MD056TableColumnCount;
374        let content = "";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376        let result = rule.check(&ctx).unwrap();
377
378        assert_eq!(result.len(), 0);
379    }
380
381    #[test]
382    fn test_code_block_with_table() {
383        let rule = MD056TableColumnCount;
384        let content = "```
385| This | Is | Code |
386|------|----|----|
387| Not  | A  | Table |
388```
389
390| Real | Table |
391|------|-------|
392| Data | Here  |";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394        let result = rule.check(&ctx).unwrap();
395
396        // Should not check tables inside code blocks
397        assert_eq!(result.len(), 0);
398    }
399
400    #[test]
401    fn test_fix_preserves_pipe_style() {
402        let rule = MD056TableColumnCount;
403        // Test with no trailing pipes
404        let content = "| Header 1 | Header 2 | Header 3
405|----------|----------|----------
406| Cell 1   | Cell 2";
407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
408        let fixed = rule.fix(&ctx).unwrap();
409
410        let lines: Vec<&str> = fixed.lines().collect();
411        assert!(!lines[2].ends_with('|'));
412        assert!(lines[2].contains("Cell 1"));
413        assert!(lines[2].contains("Cell 2"));
414    }
415
416    #[test]
417    fn test_single_column_table() {
418        let rule = MD056TableColumnCount;
419        let content = "| Header |
420|---------|
421| Cell 1  |
422| Cell 2  |";
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_complex_delimiter_row() {
431        let rule = MD056TableColumnCount;
432        let content = "| Left | Center | Right |
433|:-----|:------:|------:|
434| L    | C      | R     |
435| Left | Center |";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437        let result = rule.check(&ctx).unwrap();
438
439        assert_eq!(result.len(), 1);
440        assert_eq!(result[0].line, 4);
441    }
442
443    #[test]
444    fn test_unicode_content() {
445        let rule = MD056TableColumnCount;
446        let content = "| 名前 | 年齢 | 都市 |
447|------|------|------|
448| 田中 | 25   | 東京 |
449| 佐藤 | 30   |";
450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451        let result = rule.check(&ctx).unwrap();
452
453        assert_eq!(result.len(), 1);
454        assert_eq!(result[0].line, 4);
455    }
456
457    #[test]
458    fn test_very_long_cells() {
459        let rule = MD056TableColumnCount;
460        let content = "| Short | Very very very very very very very very very very long header | Another |
461|-------|--------------------------------------------------------------|---------|
462| Data  | This is an extremely long cell content that goes on and on   |";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464        let result = rule.check(&ctx).unwrap();
465
466        assert_eq!(result.len(), 1);
467        assert!(result[0].message.contains("has 2 cells, but expected 3"));
468    }
469
470    #[test]
471    fn test_fix_with_newline_ending() {
472        let rule = MD056TableColumnCount;
473        let content = "| A | B | C |
474|---|---|---|
475| 1 | 2 |
476";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478        let fixed = rule.fix(&ctx).unwrap();
479
480        assert!(fixed.ends_with('\n'));
481        assert!(fixed.contains("| 1 | 2 |  |"));
482    }
483
484    #[test]
485    fn test_fix_without_newline_ending() {
486        let rule = MD056TableColumnCount;
487        let content = "| A | B | C |
488|---|---|---|
489| 1 | 2 |";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491        let fixed = rule.fix(&ctx).unwrap();
492
493        assert!(!fixed.ends_with('\n'));
494        assert!(fixed.contains("| 1 | 2 |  |"));
495    }
496}