Skip to main content

rumdl_lib/rules/
md055_table_pipe_style.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, 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 category(&self) -> RuleCategory {
307        RuleCategory::Table
308    }
309
310    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
311        // Skip if no tables present (uses cached pipe count)
312        !ctx.likely_has_tables()
313    }
314
315    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
316        let mut warnings = Vec::new();
317
318        // Early return handled by should_skip()
319
320        let lines = ctx.raw_lines();
321
322        // Get the configured style explicitly and validate it
323        let configured_style = match self.config.style.as_str() {
324            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
325                self.config.style.as_str()
326            }
327            _ => {
328                // Invalid style provided, default to "leading_and_trailing"
329                "leading_and_trailing"
330            }
331        };
332
333        // Use pre-computed table blocks from context
334        let table_blocks = &ctx.table_blocks;
335
336        // Process each table block
337        for table_block in table_blocks {
338            // First pass: determine the table's style for "consistent" mode
339            // Count all rows to determine most prevalent style (prevalence-based approach)
340            let table_style = if configured_style == "consistent" {
341                self.determine_table_style(table_block, lines)
342            } else {
343                None
344            };
345
346            // Determine target style for this table
347            let target_style = if configured_style == "consistent" {
348                table_style.unwrap_or("leading_and_trailing")
349            } else {
350                configured_style
351            };
352
353            // Collect all table lines for processing
354            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
355                .chain(std::iter::once(table_block.delimiter_line))
356                .chain(table_block.content_lines.iter().copied())
357                .collect();
358
359            // Check each row and emit a per-row fix. Per-row fixes ensure that
360            // inline-disabling one row does not cause the fix on another row to
361            // overwrite the disabled row's content.
362            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
363                let line = lines[line_idx];
364                // Extract content to properly check pipe style (handles list/blockquote prefixes)
365                let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
366                if let Some(current_style) = TableUtils::determine_pipe_style(content) {
367                    // Only flag lines with actual style mismatches
368                    let needs_fixing = current_style != target_style;
369
370                    if needs_fixing {
371                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
372
373                        let message = format!(
374                            "Table pipe style should be {}",
375                            match target_style {
376                                "leading_and_trailing" => "leading and trailing",
377                                "no_leading_or_trailing" => "no leading or trailing",
378                                "leading_only" => "leading only",
379                                "trailing_only" => "trailing only",
380                                _ => target_style,
381                            }
382                        );
383
384                        // Build a per-row fix so inline-disabled rows are not
385                        // overwritten by fixes on other rows in the same table.
386                        let fixed_line =
387                            self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
388                        let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
389
390                        warnings.push(LintWarning {
391                            rule_name: Some(self.name().to_string()),
392                            severity: Severity::Warning,
393                            message,
394                            line: start_line,
395                            column: start_col,
396                            end_line,
397                            end_column: end_col,
398                            fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
399                        });
400                    }
401                }
402            }
403        }
404
405        Ok(warnings)
406    }
407
408    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
409        if self.should_skip(ctx) {
410            return Ok(ctx.content.to_string());
411        }
412        let warnings = self.check(ctx)?;
413        if warnings.is_empty() {
414            return Ok(ctx.content.to_string());
415        }
416        let warnings =
417            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
418        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
419    }
420
421    fn as_any(&self) -> &dyn std::any::Any {
422        self
423    }
424
425    crate::impl_rule_config_methods!(MD055Config);
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    // === Issue #611: kebab-case config values ignored, fallback to leading-and-trailing ===
433    //
434    // All style names must work identically whether the user writes kebab-case
435    // (no-leading-or-trailing) or snake_case (no_leading_or_trailing) in config.
436
437    fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
438        let config: md055_config::MD055Config =
439            toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
440        MD055TablePipeStyle::from_config_struct(config)
441    }
442
443    #[test]
444    fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
445        let rule = rule_from_toml_style("no-leading-or-trailing");
446        let content = "A | B\n--- | ---\n1 | 2";
447        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448        let warnings = rule.check(&ctx).unwrap();
449        assert!(
450            warnings.is_empty(),
451            "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
452        );
453    }
454
455    #[test]
456    fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
457        let rule = rule_from_toml_style("no-leading-or-trailing");
458        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
459        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let warnings = rule.check(&ctx).unwrap();
461        assert_eq!(
462            warnings.len(),
463            3,
464            "no-leading-or-trailing should flag all 3 rows with pipes"
465        );
466        assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
467    }
468
469    #[test]
470    fn test_leading_only_kebab_accepts_conforming_table() {
471        let rule = rule_from_toml_style("leading-only");
472        let content = "| A | B\n|---|---\n| 1 | 2";
473        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let warnings = rule.check(&ctx).unwrap();
475        assert!(
476            warnings.is_empty(),
477            "leading-only should accept a leading-only table: {warnings:?}"
478        );
479    }
480
481    #[test]
482    fn test_trailing_only_kebab_accepts_conforming_table() {
483        let rule = rule_from_toml_style("trailing-only");
484        let content = "A | B |\n---|---|\n1 | 2 |";
485        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486        let warnings = rule.check(&ctx).unwrap();
487        assert!(
488            warnings.is_empty(),
489            "trailing-only should accept a trailing-only table: {warnings:?}"
490        );
491    }
492
493    #[test]
494    fn test_trailing_only_kebab_rejects_nonconforming_table() {
495        let rule = rule_from_toml_style("trailing-only");
496        // leading-and-trailing table must be flagged — proves the table is actually detected
497        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
498        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let warnings = rule.check(&ctx).unwrap();
500        assert_eq!(
501            warnings.len(),
502            3,
503            "trailing-only should flag all 3 rows that have leading pipes"
504        );
505        assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
506    }
507
508    #[test]
509    fn test_leading_only_kebab_rejects_nonconforming_table() {
510        let rule = rule_from_toml_style("leading-only");
511        // trailing-only table must be flagged — proves the table is actually detected
512        let content = "A | B |\n---|---|\n1 | 2 |";
513        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514        let warnings = rule.check(&ctx).unwrap();
515        assert_eq!(
516            warnings.len(),
517            3,
518            "leading-only should flag all 3 rows that have trailing pipes"
519        );
520        assert!(warnings.iter().all(|w| w.message.contains("leading only")));
521    }
522
523    #[test]
524    fn test_leading_and_trailing_kebab_accepts_conforming_table() {
525        let rule = rule_from_toml_style("leading-and-trailing");
526        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
527        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let warnings = rule.check(&ctx).unwrap();
529        assert!(
530            warnings.is_empty(),
531            "leading-and-trailing should accept a fully-piped table: {warnings:?}"
532        );
533    }
534
535    #[test]
536    fn test_kebab_and_snake_case_styles_are_equivalent() {
537        // For every style, kebab and snake_case forms must produce identical warnings —
538        // same count, same messages, same line numbers.
539        let pairs = [
540            ("no-leading-or-trailing", "no_leading_or_trailing"),
541            ("leading-only", "leading_only"),
542            ("trailing-only", "trailing_only"),
543            ("leading-and-trailing", "leading_and_trailing"),
544        ];
545        // Mixed table so every style produces at least one warning, exercising the message path.
546        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
547        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548
549        for (kebab, snake) in pairs {
550            let kebab_rule = rule_from_toml_style(kebab);
551            let snake_rule = rule_from_toml_style(snake);
552            let kebab_warnings = kebab_rule.check(&ctx).unwrap();
553            let snake_warnings = snake_rule.check(&ctx).unwrap();
554
555            assert_eq!(
556                kebab_warnings.len(),
557                snake_warnings.len(),
558                "'{kebab}' and '{snake}' must produce the same number of warnings"
559            );
560            for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
561                assert_eq!(
562                    kw.message, sw.message,
563                    "warning[{i}] message differs between '{kebab}' and '{snake}'"
564                );
565                assert_eq!(
566                    kw.line, sw.line,
567                    "warning[{i}] line differs between '{kebab}' and '{snake}'"
568                );
569            }
570        }
571    }
572
573    fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
574        let rule = rule_from_toml_style(style);
575        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576        let fixed = rule.fix(&ctx).unwrap();
577        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
578        let remaining = rule.check(&ctx2).unwrap();
579        assert!(
580            remaining.is_empty(),
581            "style '{style}': after fix(), check() should find 0 violations.\n\
582             Original: {content:?}\n\
583             Fixed:    {fixed:?}\n\
584             Remaining: {remaining:?}"
585        );
586    }
587
588    #[test]
589    fn test_roundtrip_kebab_no_leading_or_trailing() {
590        assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
591    }
592
593    #[test]
594    fn test_roundtrip_kebab_leading_and_trailing() {
595        assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
596    }
597
598    #[test]
599    fn test_roundtrip_kebab_leading_only() {
600        assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
601    }
602
603    #[test]
604    fn test_roundtrip_kebab_trailing_only() {
605        assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
606    }
607
608    #[test]
609    fn test_md055_delimiter_row_handling() {
610        // Test with no_leading_or_trailing style
611        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
612
613        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
614        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.fix(&ctx).unwrap();
616
617        // With the fixed implementation, the delimiter row should have pipes removed
618        // Spacing is preserved from original input
619        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
620
621        assert_eq!(result, expected);
622
623        // Test that the check method actually reports the delimiter row as an issue
624        let warnings = rule.check(&ctx).unwrap();
625        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
626        assert_eq!(delimiter_warning.line, 2);
627        assert_eq!(
628            delimiter_warning.message,
629            "Table pipe style should be no leading or trailing"
630        );
631
632        // Test with leading_and_trailing style
633        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
634
635        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
636        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637        let result = rule.fix(&ctx).unwrap();
638
639        // The delimiter row should have pipes added
640        // Spacing is preserved from original input
641        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
642
643        assert_eq!(result, expected);
644    }
645
646    #[test]
647    fn test_md055_check_finds_delimiter_row_issues() {
648        // Test that check() correctly identifies delimiter rows that don't match style
649        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
650
651        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
652        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653        let warnings = rule.check(&ctx).unwrap();
654
655        // Should have 3 warnings - header row, delimiter row, and data row
656        assert_eq!(warnings.len(), 3);
657
658        // Specifically verify the delimiter row warning (line 2)
659        let delimiter_warning = &warnings[1];
660        assert_eq!(delimiter_warning.line, 2);
661        assert_eq!(
662            delimiter_warning.message,
663            "Table pipe style should be no leading or trailing"
664        );
665    }
666
667    #[test]
668    fn test_md055_real_world_example() {
669        // Test with a real-world example having content before and after the table
670        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
671
672        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.";
673        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.fix(&ctx).unwrap();
675
676        // The table should be fixed, with pipes removed
677        // Spacing is preserved from original input
678        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.";
679
680        assert_eq!(result, expected);
681
682        // Ensure we get warnings for all table rows
683        let warnings = rule.check(&ctx).unwrap();
684        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
685
686        // The line numbers should match the correct positions in the original content
687        assert_eq!(warnings[0].line, 5); // Header row
688        assert_eq!(warnings[1].line, 6); // Delimiter row
689        assert_eq!(warnings[2].line, 7); // Data row 1
690        assert_eq!(warnings[3].line, 8); // Data row 2
691    }
692
693    #[test]
694    fn test_md055_invalid_style() {
695        // Test with an invalid style setting
696        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
697
698        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
699        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.fix(&ctx).unwrap();
701
702        // Should default to "leading_and_trailing"
703        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
704        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
705
706        assert_eq!(result, expected);
707
708        // Now check a content that needs actual modification
709        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
710        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let result = rule.fix(&ctx2).unwrap();
712
713        // Should add pipes to match the default "leading_and_trailing" style
714        // Spacing is preserved from original input
715        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
716        assert_eq!(result, expected);
717
718        // Check that warning messages also work with the fallback style
719        let warnings = rule.check(&ctx2).unwrap();
720
721        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
722        // there should be warnings for all rows
723        assert_eq!(warnings.len(), 3);
724    }
725
726    #[test]
727    fn test_underflow_protection() {
728        // Test case to ensure no underflow when parts is empty
729        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
730
731        // Test with empty string (edge case)
732        let result = rule.fix_table_row("", "leading_and_trailing");
733        assert_eq!(result, "");
734
735        // Test with string that doesn't contain pipes
736        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
737        assert_eq!(result, "no pipes here");
738
739        // Test with minimal pipe content
740        let result = rule.fix_table_row("|", "leading_and_trailing");
741        // Should not panic and should handle gracefully
742        assert!(!result.is_empty());
743    }
744
745    // === Issue #305: Blockquote table tests ===
746
747    #[test]
748    fn test_fix_table_row_in_blockquote() {
749        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
750
751        // Blockquote table without leading pipe
752        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
753        assert_eq!(result, "> | H1 | H2 |");
754
755        // Blockquote table that already has pipes
756        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
757        assert_eq!(result, "> | H1 | H2 |");
758
759        // Removing pipes from blockquote table
760        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
761        assert_eq!(result, "> H1 | H2");
762    }
763
764    #[test]
765    fn test_fix_table_row_in_nested_blockquote() {
766        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
767
768        // Double-nested blockquote
769        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
770        assert_eq!(result, ">> | H1 | H2 |");
771
772        // Triple-nested blockquote
773        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
774        assert_eq!(result, ">>> | H1 | H2 |");
775    }
776
777    #[test]
778    fn test_blockquote_table_full_document() {
779        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
780
781        // Full table in blockquote (2 columns, matching delimiter)
782        let content = "> H1 | H2\n> ----|----\n> a  | b";
783        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let result = rule.fix(&ctx).unwrap();
785
786        // Each line should have the blockquote prefix preserved and pipes added
787        // The leading_and_trailing style adds "| " after blockquote prefix
788        assert!(
789            result.starts_with("> |"),
790            "Header should start with blockquote + pipe. Got:\n{result}"
791        );
792        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
793        assert!(
794            result.contains("> | ----"),
795            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
796        );
797    }
798
799    #[test]
800    fn test_blockquote_table_no_leading_trailing() {
801        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
802
803        // Table with pipes that should be removed
804        let content = "> | H1 | H2 |\n> |----|----|---|\n> | a  | b |";
805        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806        let result = rule.fix(&ctx).unwrap();
807
808        // Pipes should be removed but blockquote prefix preserved
809        let lines: Vec<&str> = result.lines().collect();
810        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
811        assert!(
812            !lines[0].starts_with("> |"),
813            "Leading pipe should be removed. Got: {}",
814            lines[0]
815        );
816    }
817
818    #[test]
819    fn test_mixed_regular_and_blockquote_tables() {
820        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
821
822        // Document with both regular and blockquote tables
823        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
824        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825        let result = rule.fix(&ctx).unwrap();
826
827        // Both tables should be fixed
828        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
829        assert!(
830            result.contains("> | H3 | H4 |"),
831            "Blockquote table should have pipes added with prefix preserved"
832        );
833    }
834
835    // === Roundtrip safety tests ===
836
837    fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
838        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let fixed = rule.fix(&ctx).unwrap();
840        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
841        let remaining = rule.check(&ctx2).unwrap();
842        assert!(
843            remaining.is_empty(),
844            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
845        );
846    }
847
848    #[test]
849    fn test_roundtrip_leading_and_trailing() {
850        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
851        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
852    }
853
854    #[test]
855    fn test_roundtrip_no_leading_or_trailing() {
856        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
857        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
858    }
859
860    #[test]
861    fn test_roundtrip_consistent_mode() {
862        let rule = MD055TablePipeStyle::default();
863        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
864    }
865
866    #[test]
867    fn test_roundtrip_blockquote_table() {
868        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
869        assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
870    }
871
872    #[test]
873    fn test_roundtrip_mixed_tables() {
874        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
875        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
876    }
877
878    #[test]
879    fn test_roundtrip_with_surrounding_content() {
880        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
881        assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
882    }
883
884    #[test]
885    fn test_roundtrip_clean_content() {
886        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
887        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
888    }
889
890    // === Pandoc construct reachability tests ===
891    //
892    // These tests document that MD055 does not flag Pandoc-specific constructs
893    // (grid tables, multi-line tables, line blocks, pipe-table captions) because
894    // `ctx.table_blocks` excludes them at the source:
895    //
896    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
897    //   returns false and no `TableBlock` is created.
898    // - Multi-line table separators (`----------`) have no `|`, same exclusion.
899    // - Line blocks (`| First line`) end without `|`, so `is_potential_table_row`
900    //   requires `valid_parts >= 2` but finds only 1 — excluded.
901    // - Pipe-table captions (`: caption`) have no `|` — excluded.
902    //
903    // No production guard is needed. These tests ensure that if `find_table_blocks`
904    // ever changes to include these constructs, the failure is visible.
905
906    #[test]
907    fn md055_pandoc_grid_tables_not_flagged() {
908        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
909        let content = "\
910+---+---+
911| a | b |
912+===+===+
913| 1 | 2 |
914+---+---+
915";
916        // Under Pandoc: grid tables are excluded from table_blocks (delimiter rows
917        // use `+` not `|`), so no warnings are emitted.
918        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
919        let result = rule.check(&ctx).unwrap();
920        assert!(
921            result.is_empty(),
922            "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
923        );
924
925        // Under Standard: same content also produces no warnings because the
926        // `+---+---+` lines are not recognized as pipe-table delimiters.
927        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928        let result_std = rule.check(&ctx_std).unwrap();
929        assert!(
930            result_std.is_empty(),
931            "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
932        );
933    }
934
935    #[test]
936    fn md055_pandoc_multi_line_tables_not_flagged() {
937        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
938        // Multi-line table (Pandoc extension): separator line has no `|`.
939        let content = "\
940--------- ----------- ------
941Header 1   Header 2   Header 3
942--------- ----------- ------
943Cell 1     Cell 2     Cell 3
944--------- ----------- ------
945";
946        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
947        let result = rule.check(&ctx).unwrap();
948        assert!(
949            result.is_empty(),
950            "MD055 should not flag Pandoc multi-line tables: {result:?}"
951        );
952
953        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954        let result_std = rule.check(&ctx_std).unwrap();
955        assert!(
956            result_std.is_empty(),
957            "MD055 should not flag multi-line table content under Standard: {result_std:?}"
958        );
959    }
960
961    #[test]
962    fn md055_pandoc_line_blocks_not_flagged() {
963        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
964        // Pandoc line blocks: `| text` that starts with `|` but does not end with `|`.
965        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped lines.
966        let content = "| First line\n| Second line\n";
967        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
968        let result = rule.check(&ctx).unwrap();
969        assert!(
970            result.is_empty(),
971            "MD055 should not treat Pandoc line blocks as tables: {result:?}"
972        );
973
974        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let result_std = rule.check(&ctx_std).unwrap();
976        assert!(
977            result_std.is_empty(),
978            "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
979        );
980    }
981
982    #[test]
983    fn md055_pandoc_pipe_table_captions_not_flagged() {
984        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
985        // Pipe-table captions (`: caption`) have no `|`, so they are never included
986        // in table_blocks.
987        let content = "\
988| H1 | H2 |
989|----|-----|
990| a  | b  |
991
992: My table caption
993";
994        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
995        let result = rule.check(&ctx).unwrap();
996        assert!(
997            result.is_empty(),
998            "MD055 should not flag the pipe-table caption line: {result:?}"
999        );
1000
1001        // Under Standard: same table rows are correctly checked; caption line is ignored.
1002        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let result_std = rule.check(&ctx_std).unwrap();
1004        assert!(
1005            result_std.is_empty(),
1006            "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1007        );
1008    }
1009}