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