Skip to main content

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 (table line index 0)
104        let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
105        if let Some(style) = TableUtils::determine_pipe_style(header_content) {
106            match style {
107                "leading_and_trailing" => leading_and_trailing_count += 1,
108                "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
109                "leading_only" => leading_only_count += 1,
110                "trailing_only" => trailing_only_count += 1,
111                _ => {}
112            }
113        }
114
115        // Count style of content rows (table line indices 2, 3, 4, ...)
116        for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
117            let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
118            if let Some(style) = TableUtils::determine_pipe_style(content) {
119                match style {
120                    "leading_and_trailing" => leading_and_trailing_count += 1,
121                    "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
122                    "leading_only" => leading_only_count += 1,
123                    "trailing_only" => trailing_only_count += 1,
124                    _ => {}
125                }
126            }
127        }
128
129        // Determine most prevalent style
130        // In case of tie, prefer leading_and_trailing (most common, widely supported)
131        let max_count = leading_and_trailing_count
132            .max(no_leading_or_trailing_count)
133            .max(leading_only_count)
134            .max(trailing_only_count);
135
136        if max_count > 0 {
137            if leading_and_trailing_count == max_count {
138                Some("leading_and_trailing")
139            } else if no_leading_or_trailing_count == max_count {
140                Some("no_leading_or_trailing")
141            } else if leading_only_count == max_count {
142                Some("leading_only")
143            } else if trailing_only_count == max_count {
144                Some("trailing_only")
145            } else {
146                None
147            }
148        } else {
149            None
150        }
151    }
152
153    /// Simple table row fix for tests - creates a dummy TableBlock without list context
154    #[cfg(test)]
155    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
156        let dummy_block = TableBlock {
157            start_line: 0,
158            end_line: 0,
159            header_line: 0,
160            delimiter_line: 0,
161            content_lines: vec![],
162            list_context: None,
163        };
164        self.fix_table_row_with_context(line, target_style, &dummy_block, 0)
165    }
166
167    /// Fix a table row to match the target style, with full context for list tables
168    ///
169    /// This handles tables inside list items by stripping the list prefix,
170    /// fixing the table content, then restoring the appropriate prefix.
171    fn fix_table_row_with_context(
172        &self,
173        line: &str,
174        target_style: &str,
175        table_block: &TableBlock,
176        table_line_index: usize,
177    ) -> String {
178        // Extract blockquote prefix first
179        let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
180
181        // Handle list context if present
182        if let Some(ref list_ctx) = table_block.list_context {
183            if table_line_index == 0 {
184                // Header line: strip list prefix (handles both markers and indentation)
185                let stripped = after_bq
186                    .strip_prefix(&list_ctx.list_prefix)
187                    .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
188                let fixed_content = self.fix_table_content(stripped.trim(), target_style);
189
190                // Restore prefixes: blockquote + list prefix + fixed content
191                let lp = &list_ctx.list_prefix;
192                if bq_prefix.is_empty() && lp.is_empty() {
193                    fixed_content
194                } else {
195                    format!("{bq_prefix}{lp}{fixed_content}")
196                }
197            } else {
198                // Continuation lines: strip indentation, then restore it
199                let content_indent = list_ctx.content_indent;
200                let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
201                let fixed_content = self.fix_table_content(stripped.trim(), target_style);
202
203                // Restore prefixes: blockquote + indentation + fixed content
204                let indent = " ".repeat(content_indent);
205                format!("{bq_prefix}{indent}{fixed_content}")
206            }
207        } else {
208            // No list context, just handle blockquote prefix
209            let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
210            if bq_prefix.is_empty() {
211                fixed_content
212            } else {
213                format!("{bq_prefix}{fixed_content}")
214            }
215        }
216    }
217
218    /// Fix the table content (without any prefix handling)
219    fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
220        if !trimmed.contains('|') {
221            return trimmed.to_string();
222        }
223
224        let has_leading = trimmed.starts_with('|');
225        let has_trailing = trimmed.ends_with('|');
226
227        match target_style {
228            "leading_and_trailing" => {
229                let mut result = trimmed.to_string();
230
231                // Add leading pipe if missing
232                if !has_leading {
233                    result = format!("| {result}");
234                }
235
236                // Add trailing pipe if missing
237                if !has_trailing {
238                    result = format!("{result} |");
239                }
240
241                result
242            }
243            "no_leading_or_trailing" => {
244                let mut result = trimmed;
245
246                // Remove leading pipe if present
247                if has_leading {
248                    result = result.strip_prefix('|').unwrap_or(result);
249                    result = result.trim_start();
250                }
251
252                // Remove trailing pipe if present
253                if has_trailing {
254                    result = result.strip_suffix('|').unwrap_or(result);
255                    result = result.trim_end();
256                }
257
258                result.to_string()
259            }
260            "leading_only" => {
261                let mut result = trimmed.to_string();
262
263                // Add leading pipe if missing
264                if !has_leading {
265                    result = format!("| {result}");
266                }
267
268                // Remove trailing pipe if present
269                if has_trailing {
270                    result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
271                }
272
273                result
274            }
275            "trailing_only" => {
276                let mut result = trimmed;
277
278                // Remove leading pipe if present
279                if has_leading {
280                    result = result.strip_prefix('|').unwrap_or(result).trim_start();
281                }
282
283                let mut result = result.to_string();
284
285                // Add trailing pipe if missing
286                if !has_trailing {
287                    result = format!("{result} |");
288                }
289
290                result
291            }
292            _ => trimmed.to_string(),
293        }
294    }
295}
296
297impl Rule for MD055TablePipeStyle {
298    fn name(&self) -> &'static str {
299        "MD055"
300    }
301
302    fn description(&self) -> &'static str {
303        "Table pipe style should be consistent"
304    }
305
306    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307        // Skip if no tables present (uses cached pipe count)
308        !ctx.likely_has_tables()
309    }
310
311    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
312        let line_index = &ctx.line_index;
313        let mut warnings = Vec::new();
314
315        // Early return handled by should_skip()
316
317        let lines = ctx.raw_lines();
318
319        // Get the configured style explicitly and validate it
320        let configured_style = match self.config.style.as_str() {
321            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
322                self.config.style.as_str()
323            }
324            _ => {
325                // Invalid style provided, default to "leading_and_trailing"
326                "leading_and_trailing"
327            }
328        };
329
330        // Use pre-computed table blocks from context
331        let table_blocks = &ctx.table_blocks;
332
333        // Process each table block
334        for table_block in table_blocks {
335            // First pass: determine the table's style for "consistent" mode
336            // Count all rows to determine most prevalent style (prevalence-based approach)
337            let table_style = if configured_style == "consistent" {
338                self.determine_table_style(table_block, lines)
339            } else {
340                None
341            };
342
343            // Determine target style for this table
344            let target_style = if configured_style == "consistent" {
345                table_style.unwrap_or("leading_and_trailing")
346            } else {
347                configured_style
348            };
349
350            // Collect all table lines for building the whole-table fix
351            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
352                .chain(std::iter::once(table_block.delimiter_line))
353                .chain(table_block.content_lines.iter().copied())
354                .collect();
355
356            // Build the whole-table fix once for all warnings in this table
357            // This ensures that applying Quick Fix on any row fixes the entire table
358            let table_start_line = table_block.start_line + 1; // Convert to 1-indexed
359            let table_end_line = table_block.end_line + 1; // Convert to 1-indexed
360
361            // Build the complete fixed table content with proper table line indices
362            let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
363            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
364                let line = lines[line_idx];
365                let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
366                if line_idx < lines.len() - 1 {
367                    fixed_table_lines.push(format!("{fixed_line}\n"));
368                } else {
369                    fixed_table_lines.push(fixed_line);
370                }
371            }
372            let table_replacement = fixed_table_lines.concat();
373            let table_range = line_index.multi_line_range(table_start_line, table_end_line);
374
375            // Check all rows in the table
376            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
377                let line = lines[line_idx];
378                // Extract content to properly check pipe style (handles list/blockquote prefixes)
379                let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
380                if let Some(current_style) = TableUtils::determine_pipe_style(content) {
381                    // Only flag lines with actual style mismatches
382                    let needs_fixing = current_style != target_style;
383
384                    if needs_fixing {
385                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
386
387                        let message = format!(
388                            "Table pipe style should be {}",
389                            match target_style {
390                                "leading_and_trailing" => "leading and trailing",
391                                "no_leading_or_trailing" => "no leading or trailing",
392                                "leading_only" => "leading only",
393                                "trailing_only" => "trailing only",
394                                _ => target_style,
395                            }
396                        );
397
398                        // Each warning uses the same whole-table fix
399                        // This ensures Quick Fix on any row fixes the entire table
400                        warnings.push(LintWarning {
401                            rule_name: Some(self.name().to_string()),
402                            severity: Severity::Warning,
403                            message,
404                            line: start_line,
405                            column: start_col,
406                            end_line,
407                            end_column: end_col,
408                            fix: Some(crate::rule::Fix {
409                                range: table_range.clone(),
410                                replacement: table_replacement.clone(),
411                            }),
412                        });
413                    }
414                }
415            }
416        }
417
418        Ok(warnings)
419    }
420
421    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
422        let lines = ctx.raw_lines();
423
424        // Use the configured style but validate it first
425        let configured_style = match self.config.style.as_str() {
426            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
427                self.config.style.as_str()
428            }
429            _ => {
430                // Invalid style provided, default to "leading_and_trailing"
431                "leading_and_trailing"
432            }
433        };
434
435        // Use pre-computed table blocks from context
436        let table_blocks = &ctx.table_blocks;
437
438        // Create a copy of lines that we can modify
439        let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
440
441        // Process each table block
442        for table_block in table_blocks {
443            // First pass: determine the table's style for "consistent" mode
444            // Count all rows to determine most prevalent style (prevalence-based approach)
445            let table_style = if configured_style == "consistent" {
446                self.determine_table_style(table_block, lines)
447            } else {
448                None
449            };
450
451            // Determine target style for this table
452            let target_style = if configured_style == "consistent" {
453                table_style.unwrap_or("leading_and_trailing")
454            } else {
455                configured_style
456            };
457
458            // Fix all rows in the table with proper table line indices
459            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
460                .chain(std::iter::once(table_block.delimiter_line))
461                .chain(table_block.content_lines.iter().copied())
462                .collect();
463
464            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
465                let line_num = line_idx + 1;
466                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
467                    continue;
468                }
469                let line = lines[line_idx];
470                let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
471                result_lines[line_idx] = fixed_line;
472            }
473        }
474
475        let mut fixed = result_lines.join("\n");
476        // Preserve trailing newline if original content had one
477        if ctx.content.ends_with('\n') && !fixed.ends_with('\n') {
478            fixed.push('\n');
479        }
480        Ok(fixed)
481    }
482
483    fn as_any(&self) -> &dyn std::any::Any {
484        self
485    }
486
487    fn default_config_section(&self) -> Option<(String, toml::Value)> {
488        let json_value = serde_json::to_value(&self.config).ok()?;
489        Some((
490            self.name().to_string(),
491            crate::rule_config_serde::json_to_toml_value(&json_value)?,
492        ))
493    }
494
495    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
496    where
497        Self: Sized,
498    {
499        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
500        Box::new(Self::from_config_struct(rule_config))
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn test_md055_delimiter_row_handling() {
510        // Test with no_leading_or_trailing style
511        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
512
513        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
514        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515        let result = rule.fix(&ctx).unwrap();
516
517        // With the fixed implementation, the delimiter row should have pipes removed
518        // Spacing is preserved from original input
519        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
520
521        assert_eq!(result, expected);
522
523        // Test that the check method actually reports the delimiter row as an issue
524        let warnings = rule.check(&ctx).unwrap();
525        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
526        assert_eq!(delimiter_warning.line, 2);
527        assert_eq!(
528            delimiter_warning.message,
529            "Table pipe style should be no leading or trailing"
530        );
531
532        // Test with leading_and_trailing style
533        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
534
535        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
536        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537        let result = rule.fix(&ctx).unwrap();
538
539        // The delimiter row should have pipes added
540        // Spacing is preserved from original input
541        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
542
543        assert_eq!(result, expected);
544    }
545
546    #[test]
547    fn test_md055_check_finds_delimiter_row_issues() {
548        // Test that check() correctly identifies delimiter rows that don't match style
549        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
550
551        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
552        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let warnings = rule.check(&ctx).unwrap();
554
555        // Should have 3 warnings - header row, delimiter row, and data row
556        assert_eq!(warnings.len(), 3);
557
558        // Specifically verify the delimiter row warning (line 2)
559        let delimiter_warning = &warnings[1];
560        assert_eq!(delimiter_warning.line, 2);
561        assert_eq!(
562            delimiter_warning.message,
563            "Table pipe style should be no leading or trailing"
564        );
565    }
566
567    #[test]
568    fn test_md055_real_world_example() {
569        // Test with a real-world example having content before and after the table
570        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
571
572        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.";
573        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let result = rule.fix(&ctx).unwrap();
575
576        // The table should be fixed, with pipes removed
577        // Spacing is preserved from original input
578        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.";
579
580        assert_eq!(result, expected);
581
582        // Ensure we get warnings for all table rows
583        let warnings = rule.check(&ctx).unwrap();
584        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
585
586        // The line numbers should match the correct positions in the original content
587        assert_eq!(warnings[0].line, 5); // Header row
588        assert_eq!(warnings[1].line, 6); // Delimiter row
589        assert_eq!(warnings[2].line, 7); // Data row 1
590        assert_eq!(warnings[3].line, 8); // Data row 2
591    }
592
593    #[test]
594    fn test_md055_invalid_style() {
595        // Test with an invalid style setting
596        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
597
598        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
599        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600        let result = rule.fix(&ctx).unwrap();
601
602        // Should default to "leading_and_trailing"
603        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
604        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
605
606        assert_eq!(result, expected);
607
608        // Now check a content that needs actual modification
609        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
610        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611        let result = rule.fix(&ctx2).unwrap();
612
613        // Should add pipes to match the default "leading_and_trailing" style
614        // Spacing is preserved from original input
615        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
616        assert_eq!(result, expected);
617
618        // Check that warning messages also work with the fallback style
619        let warnings = rule.check(&ctx2).unwrap();
620
621        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
622        // there should be warnings for all rows
623        assert_eq!(warnings.len(), 3);
624    }
625
626    #[test]
627    fn test_underflow_protection() {
628        // Test case to ensure no underflow when parts is empty
629        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
630
631        // Test with empty string (edge case)
632        let result = rule.fix_table_row("", "leading_and_trailing");
633        assert_eq!(result, "");
634
635        // Test with string that doesn't contain pipes
636        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
637        assert_eq!(result, "no pipes here");
638
639        // Test with minimal pipe content
640        let result = rule.fix_table_row("|", "leading_and_trailing");
641        // Should not panic and should handle gracefully
642        assert!(!result.is_empty());
643    }
644
645    // === Issue #305: Blockquote table tests ===
646
647    #[test]
648    fn test_fix_table_row_in_blockquote() {
649        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
650
651        // Blockquote table without leading pipe
652        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
653        assert_eq!(result, "> | H1 | H2 |");
654
655        // Blockquote table that already has pipes
656        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
657        assert_eq!(result, "> | H1 | H2 |");
658
659        // Removing pipes from blockquote table
660        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
661        assert_eq!(result, "> H1 | H2");
662    }
663
664    #[test]
665    fn test_fix_table_row_in_nested_blockquote() {
666        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
667
668        // Double-nested blockquote
669        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
670        assert_eq!(result, ">> | H1 | H2 |");
671
672        // Triple-nested blockquote
673        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
674        assert_eq!(result, ">>> | H1 | H2 |");
675    }
676
677    #[test]
678    fn test_blockquote_table_full_document() {
679        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
680
681        // Full table in blockquote (2 columns, matching delimiter)
682        let content = "> H1 | H2\n> ----|----\n> a  | b";
683        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684        let result = rule.fix(&ctx).unwrap();
685
686        // Each line should have the blockquote prefix preserved and pipes added
687        // The leading_and_trailing style adds "| " after blockquote prefix
688        assert!(
689            result.starts_with("> |"),
690            "Header should start with blockquote + pipe. Got:\n{result}"
691        );
692        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
693        assert!(
694            result.contains("> | ----"),
695            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
696        );
697    }
698
699    #[test]
700    fn test_blockquote_table_no_leading_trailing() {
701        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
702
703        // Table with pipes that should be removed
704        let content = "> | H1 | H2 |\n> |----|----|---|\n> | a  | b |";
705        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706        let result = rule.fix(&ctx).unwrap();
707
708        // Pipes should be removed but blockquote prefix preserved
709        let lines: Vec<&str> = result.lines().collect();
710        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
711        assert!(
712            !lines[0].starts_with("> |"),
713            "Leading pipe should be removed. Got: {}",
714            lines[0]
715        );
716    }
717
718    #[test]
719    fn test_mixed_regular_and_blockquote_tables() {
720        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
721
722        // Document with both regular and blockquote tables
723        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
724        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725        let result = rule.fix(&ctx).unwrap();
726
727        // Both tables should be fixed
728        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
729        assert!(
730            result.contains("> | H3 | H4 |"),
731            "Blockquote table should have pipes added with prefix preserved"
732        );
733    }
734}