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