rumdl_lib/rules/
md055_table_pipe_style.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::{TableBlock, TableUtils};
4
5mod md055_config;
6use md055_config::MD055Config;
7
8/// Rule MD055: Table pipe style
9///
10/// See [docs/md055.md](../../docs/md055.md) for full documentation, configuration, and examples.
11///
12/// This rule enforces consistent use of leading and trailing pipe characters in Markdown tables,
13/// which improves readability and ensures uniform document styling.
14///
15/// ## Purpose
16///
17/// - **Consistency**: Ensures uniform table formatting throughout documents
18/// - **Readability**: Well-formatted tables are easier to read and understand
19/// - **Maintainability**: Consistent table syntax makes documents easier to maintain
20/// - **Compatibility**: Some Markdown processors handle different table styles differently
21///
22/// ## Configuration Options
23///
24/// The rule supports the following configuration options:
25///
26/// ```yaml
27/// MD055:
28///   style: "consistent"  # Can be "consistent", "leading_and_trailing", or "no_leading_or_trailing"
29/// ```
30///
31/// ### Style Options
32///
33/// - **consistent**: All tables must use the same style (default)
34/// - **leading_and_trailing**: All tables must have both leading and trailing pipes
35/// - **no_leading_or_trailing**: Tables must not have leading or trailing pipes
36///
37/// ## Examples
38///
39/// ### Leading and Trailing Pipes
40///
41/// ```markdown
42/// | Header 1 | Header 2 | Header 3 |
43/// |----------|----------|----------|
44/// | Cell 1   | Cell 2   | Cell 3   |
45/// | Cell 4   | Cell 5   | Cell 6   |
46/// ```
47///
48/// ### No Leading or Trailing Pipes
49///
50/// ```markdown
51/// Header 1 | Header 2 | Header 3
52/// ---------|----------|---------
53/// Cell 1   | Cell 2   | Cell 3
54/// Cell 4   | Cell 5   | Cell 6
55/// ```
56///
57/// ## Behavior Details
58///
59/// - The rule analyzes each table in the document to determine its pipe style
60/// - With "consistent" style, the first table's style is used as the standard for all others
61/// - The rule handles both the header row, separator row, and content rows
62/// - Tables inside code blocks are ignored
63///
64/// ## Fix Behavior
65///
66/// When applying automatic fixes, this rule:
67/// - Adds or removes leading and trailing pipes as needed
68/// - Preserves the content and alignment of table cells
69/// - Maintains proper spacing around pipe characters
70/// - Updates both header and content rows to match the required style
71///
72/// ## Performance Considerations
73///
74/// The rule includes performance optimizations:
75/// - Efficient table detection with quick checks before detailed analysis
76/// - Smart line-by-line processing to avoid redundant operations
77/// - Optimized string manipulation for pipe character handling
78///
79/// Enforces consistent use of leading and trailing pipe characters in tables
80#[derive(Debug, Default, Clone)]
81pub struct MD055TablePipeStyle {
82    config: MD055Config,
83}
84
85impl MD055TablePipeStyle {
86    pub fn new(style: String) -> Self {
87        Self {
88            config: MD055Config { style },
89        }
90    }
91
92    pub fn from_config_struct(config: MD055Config) -> Self {
93        Self { config }
94    }
95
96    /// Determine the most prevalent table style in a table block
97    fn determine_table_style(&self, table_block: &TableBlock, lines: &[&str]) -> Option<&'static str> {
98        let mut leading_and_trailing_count = 0;
99        let mut no_leading_or_trailing_count = 0;
100        let mut leading_only_count = 0;
101        let mut trailing_only_count = 0;
102
103        // Count style of header row
104        if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
105            match style {
106                "leading_and_trailing" => leading_and_trailing_count += 1,
107                "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
108                "leading_only" => leading_only_count += 1,
109                "trailing_only" => trailing_only_count += 1,
110                _ => {}
111            }
112        }
113
114        // Count style of content rows
115        for &line_idx in &table_block.content_lines {
116            if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
117                match style {
118                    "leading_and_trailing" => leading_and_trailing_count += 1,
119                    "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
120                    "leading_only" => leading_only_count += 1,
121                    "trailing_only" => trailing_only_count += 1,
122                    _ => {}
123                }
124            }
125        }
126
127        // Determine most prevalent style
128        // In case of tie, prefer leading_and_trailing (most common, widely supported)
129        let max_count = leading_and_trailing_count
130            .max(no_leading_or_trailing_count)
131            .max(leading_only_count)
132            .max(trailing_only_count);
133
134        if max_count > 0 {
135            if leading_and_trailing_count == max_count {
136                Some("leading_and_trailing")
137            } else if no_leading_or_trailing_count == max_count {
138                Some("no_leading_or_trailing")
139            } else if leading_only_count == max_count {
140                Some("leading_only")
141            } else if trailing_only_count == max_count {
142                Some("trailing_only")
143            } else {
144                None
145            }
146        } else {
147            None
148        }
149    }
150
151    /// Fix a table row to match the target style
152    /// Uses surgical fixes: only adds/removes pipes, preserves all user formatting
153    ///
154    /// Handles tables inside blockquotes by stripping the blockquote prefix,
155    /// fixing the table content, then restoring the prefix.
156    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
157        // Extract blockquote prefix if present
158        let (prefix, content) = TableUtils::extract_blockquote_prefix(line);
159
160        let trimmed = content.trim();
161        if !trimmed.contains('|') {
162            return line.to_string();
163        }
164
165        let has_leading = trimmed.starts_with('|');
166        let has_trailing = trimmed.ends_with('|');
167
168        let fixed_content = match target_style {
169            "leading_and_trailing" => {
170                let mut result = trimmed.to_string();
171
172                // Add leading pipe if missing
173                if !has_leading {
174                    result = format!("| {result}");
175                }
176
177                // Add trailing pipe if missing
178                if !has_trailing {
179                    result = format!("{result} |");
180                }
181
182                result
183            }
184            "no_leading_or_trailing" => {
185                let mut result = trimmed;
186
187                // Remove leading pipe if present
188                if has_leading {
189                    result = result.strip_prefix('|').unwrap_or(result);
190                    result = result.trim_start();
191                }
192
193                // Remove trailing pipe if present
194                if has_trailing {
195                    result = result.strip_suffix('|').unwrap_or(result);
196                    result = result.trim_end();
197                }
198
199                result.to_string()
200            }
201            "leading_only" => {
202                let mut result = trimmed.to_string();
203
204                // Add leading pipe if missing
205                if !has_leading {
206                    result = format!("| {result}");
207                }
208
209                // Remove trailing pipe if present
210                if has_trailing {
211                    result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
212                }
213
214                result
215            }
216            "trailing_only" => {
217                let mut result = trimmed;
218
219                // Remove leading pipe if present
220                if has_leading {
221                    result = result.strip_prefix('|').unwrap_or(result).trim_start();
222                }
223
224                let mut result = result.to_string();
225
226                // Add trailing pipe if missing
227                if !has_trailing {
228                    result = format!("{result} |");
229                }
230
231                result
232            }
233            _ => return line.to_string(),
234        };
235
236        // Restore the blockquote prefix if there was one
237        if prefix.is_empty() {
238            fixed_content
239        } else {
240            format!("{prefix}{fixed_content}")
241        }
242    }
243}
244
245impl Rule for MD055TablePipeStyle {
246    fn name(&self) -> &'static str {
247        "MD055"
248    }
249
250    fn description(&self) -> &'static str {
251        "Table pipe style should be consistent"
252    }
253
254    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
255        // Skip if no tables present (uses cached pipe count)
256        !ctx.likely_has_tables()
257    }
258
259    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
260        let content = ctx.content;
261        let line_index = &ctx.line_index;
262        let mut warnings = Vec::new();
263
264        // Early return handled by should_skip()
265
266        let lines: Vec<&str> = content.lines().collect();
267
268        // Get the configured style explicitly and validate it
269        let configured_style = match self.config.style.as_str() {
270            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
271                self.config.style.as_str()
272            }
273            _ => {
274                // Invalid style provided, default to "leading_and_trailing"
275                "leading_and_trailing"
276            }
277        };
278
279        // Use pre-computed table blocks from context
280        let table_blocks = &ctx.table_blocks;
281
282        // Process each table block
283        for table_block in table_blocks {
284            // First pass: determine the table's style for "consistent" mode
285            // Count all rows to determine most prevalent style (prevalence-based approach)
286            let table_style = if configured_style == "consistent" {
287                self.determine_table_style(table_block, &lines)
288            } else {
289                None
290            };
291
292            // Determine target style for this table
293            let target_style = if configured_style == "consistent" {
294                table_style.unwrap_or("leading_and_trailing")
295            } else {
296                configured_style
297            };
298
299            // Collect all table lines for building the whole-table fix
300            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
301                .chain(std::iter::once(table_block.delimiter_line))
302                .chain(table_block.content_lines.iter().copied())
303                .collect();
304
305            // Build the whole-table fix once for all warnings in this table
306            // This ensures that applying Quick Fix on any row fixes the entire table
307            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
308            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
309
310            // Build the complete fixed table content
311            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
312            for &line_idx in &all_line_indices {
313                let line = lines[line_idx];
314                let fixed_line = self.fix_table_row(line, target_style);
315                if line_idx < lines.len() - 1 {
316                    fixed_table_lines.push(format!("{fixed_line}\n"));
317                } else {
318                    fixed_table_lines.push(fixed_line);
319                }
320            }
321            let table_replacement = fixed_table_lines.concat();
322            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
323
324            // Check all rows in the table
325            for &line_idx in &all_line_indices {
326                let line = lines[line_idx];
327                if let Some(current_style) = TableUtils::determine_pipe_style(line) {
328                    // Only flag lines with actual style mismatches
329                    let needs_fixing = current_style != target_style;
330
331                    if needs_fixing {
332                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
333
334                        let message = format!(
335                            "Table pipe style should be {}",
336                            match target_style {
337                                "leading_and_trailing" => "leading and trailing",
338                                "no_leading_or_trailing" => "no leading or trailing",
339                                "leading_only" => "leading only",
340                                "trailing_only" => "trailing only",
341                                _ => target_style,
342                            }
343                        );
344
345                        // Each warning uses the same whole-table fix
346                        // This ensures Quick Fix on any row fixes the entire table
347                        warnings.push(LintWarning {
348                            rule_name: Some(self.name().to_string()),
349                            severity: Severity::Warning,
350                            message,
351                            line: start_line,
352                            column: start_col,
353                            end_line,
354                            end_column: end_col,
355                            fix: Some(crate::rule::Fix {
356                                range: table_range.clone(),
357                                replacement: table_replacement.clone(),
358                            }),
359                        });
360                    }
361                }
362            }
363        }
364
365        Ok(warnings)
366    }
367
368    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
369        let content = ctx.content;
370        let lines: Vec<&str> = content.lines().collect();
371
372        // Use the configured style but validate it first
373        let configured_style = match self.config.style.as_str() {
374            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
375                self.config.style.as_str()
376            }
377            _ => {
378                // Invalid style provided, default to "leading_and_trailing"
379                "leading_and_trailing"
380            }
381        };
382
383        // Use pre-computed table blocks from context
384        let table_blocks = &ctx.table_blocks;
385
386        // Create a copy of lines that we can modify
387        let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
388
389        // Process each table block
390        for table_block in table_blocks {
391            // First pass: determine the table's style for "consistent" mode
392            // Count all rows to determine most prevalent style (prevalence-based approach)
393            let table_style = if configured_style == "consistent" {
394                self.determine_table_style(table_block, &lines)
395            } else {
396                None
397            };
398
399            // Determine target style for this table
400            let target_style = if configured_style == "consistent" {
401                table_style.unwrap_or("leading_and_trailing")
402            } else {
403                configured_style
404            };
405
406            // Fix all rows in the table
407            let all_lines = std::iter::once(table_block.header_line)
408                .chain(std::iter::once(table_block.delimiter_line))
409                .chain(table_block.content_lines.iter().copied());
410
411            for line_idx in all_lines {
412                let line = lines[line_idx];
413                let fixed_line = self.fix_table_row(line, target_style);
414                result_lines[line_idx] = fixed_line;
415            }
416        }
417
418        let mut fixed = result_lines.join("\n");
419        // Preserve trailing newline if original content had one
420        if content.ends_with('\n') && !fixed.ends_with('\n') {
421            fixed.push('\n');
422        }
423        Ok(fixed)
424    }
425
426    fn as_any(&self) -> &dyn std::any::Any {
427        self
428    }
429
430    fn default_config_section(&self) -> Option<(String, toml::Value)> {
431        let json_value = serde_json::to_value(&self.config).ok()?;
432        Some((
433            self.name().to_string(),
434            crate::rule_config_serde::json_to_toml_value(&json_value)?,
435        ))
436    }
437
438    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
439    where
440        Self: Sized,
441    {
442        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
443        Box::new(Self::from_config_struct(rule_config))
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_md055_delimiter_row_handling() {
453        // Test with no_leading_or_trailing style
454        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
455
456        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
457        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458        let result = rule.fix(&ctx).unwrap();
459
460        // With the fixed implementation, the delimiter row should have pipes removed
461        // Spacing is preserved from original input
462        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
463
464        assert_eq!(result, expected);
465
466        // Test that the check method actually reports the delimiter row as an issue
467        let warnings = rule.check(&ctx).unwrap();
468        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
469        assert_eq!(delimiter_warning.line, 2);
470        assert_eq!(
471            delimiter_warning.message,
472            "Table pipe style should be no leading or trailing"
473        );
474
475        // Test with leading_and_trailing style
476        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
477
478        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
479        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480        let result = rule.fix(&ctx).unwrap();
481
482        // The delimiter row should have pipes added
483        // Spacing is preserved from original input
484        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
485
486        assert_eq!(result, expected);
487    }
488
489    #[test]
490    fn test_md055_check_finds_delimiter_row_issues() {
491        // Test that check() correctly identifies delimiter rows that don't match style
492        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
493
494        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
495        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let warnings = rule.check(&ctx).unwrap();
497
498        // Should have 3 warnings - header row, delimiter row, and data row
499        assert_eq!(warnings.len(), 3);
500
501        // Specifically verify the delimiter row warning (line 2)
502        let delimiter_warning = &warnings[1];
503        assert_eq!(delimiter_warning.line, 2);
504        assert_eq!(
505            delimiter_warning.message,
506            "Table pipe style should be no leading or trailing"
507        );
508    }
509
510    #[test]
511    fn test_md055_real_world_example() {
512        // Test with a real-world example having content before and after the table
513        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
514
515        let content = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |\n| Data 4   | Data 5   | Data 6   |\n\nMore content after the table.";
516        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517        let result = rule.fix(&ctx).unwrap();
518
519        // The table should be fixed, with pipes removed
520        // Spacing is preserved from original input
521        let expected = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\nHeader 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3\nData 4   | Data 5   | Data 6\n\nMore content after the table.";
522
523        assert_eq!(result, expected);
524
525        // Ensure we get warnings for all table rows
526        let warnings = rule.check(&ctx).unwrap();
527        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
528
529        // The line numbers should match the correct positions in the original content
530        assert_eq!(warnings[0].line, 5); // Header row
531        assert_eq!(warnings[1].line, 6); // Delimiter row
532        assert_eq!(warnings[2].line, 7); // Data row 1
533        assert_eq!(warnings[3].line, 8); // Data row 2
534    }
535
536    #[test]
537    fn test_md055_invalid_style() {
538        // Test with an invalid style setting
539        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
540
541        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
542        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.fix(&ctx).unwrap();
544
545        // Should default to "leading_and_trailing"
546        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
547        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
548
549        assert_eq!(result, expected);
550
551        // Now check a content that needs actual modification
552        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
553        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let result = rule.fix(&ctx2).unwrap();
555
556        // Should add pipes to match the default "leading_and_trailing" style
557        // Spacing is preserved from original input
558        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
559        assert_eq!(result, expected);
560
561        // Check that warning messages also work with the fallback style
562        let warnings = rule.check(&ctx2).unwrap();
563
564        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
565        // there should be warnings for all rows
566        assert_eq!(warnings.len(), 3);
567    }
568
569    #[test]
570    fn test_underflow_protection() {
571        // Test case to ensure no underflow when parts is empty
572        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
573
574        // Test with empty string (edge case)
575        let result = rule.fix_table_row("", "leading_and_trailing");
576        assert_eq!(result, "");
577
578        // Test with string that doesn't contain pipes
579        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
580        assert_eq!(result, "no pipes here");
581
582        // Test with minimal pipe content
583        let result = rule.fix_table_row("|", "leading_and_trailing");
584        // Should not panic and should handle gracefully
585        assert!(!result.is_empty());
586    }
587
588    // === Issue #305: Blockquote table tests ===
589
590    #[test]
591    fn test_fix_table_row_in_blockquote() {
592        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
593
594        // Blockquote table without leading pipe
595        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
596        assert_eq!(result, "> | H1 | H2 |");
597
598        // Blockquote table that already has pipes
599        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
600        assert_eq!(result, "> | H1 | H2 |");
601
602        // Removing pipes from blockquote table
603        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
604        assert_eq!(result, "> H1 | H2");
605    }
606
607    #[test]
608    fn test_fix_table_row_in_nested_blockquote() {
609        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
610
611        // Double-nested blockquote
612        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
613        assert_eq!(result, ">> | H1 | H2 |");
614
615        // Triple-nested blockquote
616        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
617        assert_eq!(result, ">>> | H1 | H2 |");
618    }
619
620    #[test]
621    fn test_blockquote_table_full_document() {
622        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
623
624        // Full table in blockquote (2 columns, matching delimiter)
625        let content = "> H1 | H2\n> ----|----\n> a  | b";
626        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627        let result = rule.fix(&ctx).unwrap();
628
629        // Each line should have the blockquote prefix preserved and pipes added
630        // The leading_and_trailing style adds "| " after blockquote prefix
631        assert!(
632            result.starts_with("> |"),
633            "Header should start with blockquote + pipe. Got:\n{result}"
634        );
635        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
636        assert!(
637            result.contains("> | ----"),
638            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
639        );
640    }
641
642    #[test]
643    fn test_blockquote_table_no_leading_trailing() {
644        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
645
646        // Table with pipes that should be removed
647        let content = "> | H1 | H2 |\n> |----|----|---|\n> | a  | b |";
648        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649        let result = rule.fix(&ctx).unwrap();
650
651        // Pipes should be removed but blockquote prefix preserved
652        let lines: Vec<&str> = result.lines().collect();
653        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
654        assert!(
655            !lines[0].starts_with("> |"),
656            "Leading pipe should be removed. Got: {}",
657            lines[0]
658        );
659    }
660
661    #[test]
662    fn test_mixed_regular_and_blockquote_tables() {
663        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
664
665        // Document with both regular and blockquote tables
666        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
667        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668        let result = rule.fix(&ctx).unwrap();
669
670        // Both tables should be fixed
671        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
672        assert!(
673            result.contains("> | H3 | H4 |"),
674            "Blockquote table should have pipes added with prefix preserved"
675        );
676    }
677}