Skip to main content

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