Skip to main content

rumdl_lib/rules/
md055_table_pipe_style.rs

1use crate::config::MarkdownFlavor;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
4use crate::utils::range_utils::calculate_line_range;
5use crate::utils::table_utils::{TableBlock, TableUtils};
6
7mod md055_config;
8use md055_config::MD055Config;
9
10/// Reports the MDG style override once per process; see [`FlavorOverrideNotice`].
11static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
12
13/// Rule MD055: Table pipe style
14///
15/// See [docs/md055.md](../../docs/md055.md) for full documentation, configuration, and examples.
16///
17/// This rule enforces consistent use of leading and trailing pipe characters in Markdown tables,
18/// which improves readability and ensures uniform document styling.
19///
20/// ## Purpose
21///
22/// - **Consistency**: Ensures uniform table formatting throughout documents
23/// - **Readability**: Well-formatted tables are easier to read and understand
24/// - **Maintainability**: Consistent table syntax makes documents easier to maintain
25/// - **Compatibility**: Some Markdown processors handle different table styles differently
26///
27/// ## Configuration Options
28///
29/// The rule supports the following configuration options:
30///
31/// ```yaml
32/// MD055:
33///   style: "consistent"  # Can be "consistent", "leading_and_trailing", or "no_leading_or_trailing"
34/// ```
35///
36/// ### Style Options
37///
38/// - **consistent**: All tables must use the same style (default)
39/// - **leading_and_trailing**: All tables must have both leading and trailing pipes
40/// - **no_leading_or_trailing**: Tables must not have leading or trailing pipes
41///
42/// ## Examples
43///
44/// ### Leading and Trailing Pipes
45///
46/// ```markdown
47/// | Header 1 | Header 2 | Header 3 |
48/// |----------|----------|----------|
49/// | Cell 1   | Cell 2   | Cell 3   |
50/// | Cell 4   | Cell 5   | Cell 6   |
51/// ```
52///
53/// ### No Leading or Trailing Pipes
54///
55/// ```markdown
56/// Header 1 | Header 2 | Header 3
57/// ---------|----------|---------
58/// Cell 1   | Cell 2   | Cell 3
59/// Cell 4   | Cell 5   | Cell 6
60/// ```
61///
62/// ## Behavior Details
63///
64/// - The rule analyzes each table in the document to determine its pipe style
65/// - With "consistent" style, the first table's style is used as the standard for all others
66/// - The rule handles both the header row, separator row, and content rows
67/// - Tables inside code blocks are ignored
68///
69/// ## Fix Behavior
70///
71/// When applying automatic fixes, this rule:
72/// - Adds or removes leading and trailing pipes as needed
73/// - Preserves the content and alignment of table cells
74/// - Maintains proper spacing around pipe characters
75/// - Updates both header and content rows to match the required style
76///
77/// ## Performance Considerations
78///
79/// The rule includes performance optimizations:
80/// - Efficient table detection with quick checks before detailed analysis
81/// - Smart line-by-line processing to avoid redundant operations
82/// - Optimized string manipulation for pipe character handling
83///
84/// Enforces consistent use of leading and trailing pipe characters in tables
85#[derive(Debug, Default, Clone)]
86pub struct MD055TablePipeStyle {
87    config: MD055Config,
88    /// Whether `style` came from the configuration rather than from the
89    /// default. MDG enforces leading-and-trailing either way; this only decides
90    /// whether the user is told that the style they asked for was not adopted.
91    style_explicit: bool,
92}
93
94impl MD055TablePipeStyle {
95    pub fn new(style: String) -> Self {
96        Self {
97            config: MD055Config { style },
98            style_explicit: true,
99        }
100    }
101
102    pub fn from_config_struct(config: MD055Config) -> Self {
103        Self {
104            config,
105            style_explicit: false,
106        }
107    }
108
109    /// Resolve the style MD055 should converge on.
110    ///
111    /// Gherkin recognizes a Data Table or Examples table row only when an indent
112    /// is followed directly by a pipe, so a style that strips the leading pipe
113    /// does not restyle the table, it deletes it from the document. MDG
114    /// therefore always converges on leading-and-trailing: `consistent` resolves
115    /// to it rather than to whichever form happens to be more prevalent, and an
116    /// explicit style that drops the leading pipe is not adopted.
117    fn effective_configured_style(&self, ctx: &crate::lint_context::LintContext) -> &str {
118        if ctx.flavor == MarkdownFlavor::MDG {
119            self.warn_once_about_overridden_style();
120            return "leading_and_trailing";
121        }
122
123        match self.config.style.as_str() {
124            "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
125                self.config.style.as_str()
126            }
127            _ => {
128                // Invalid style provided, default to "leading_and_trailing"
129                "leading_and_trailing"
130            }
131        }
132    }
133
134    /// Tell the user once that MDG did not adopt the style they configured.
135    ///
136    /// Only the three styles that drop a pipe are worth reporting: they are the
137    /// settings MDG cannot satisfy. `consistent` asks for no particular form,
138    /// and leading-and-trailing is what MDG picks for it anyway.
139    fn warn_once_about_overridden_style(&self) {
140        if !self.style_explicit
141            || !matches!(
142                self.config.style.as_str(),
143                "no_leading_or_trailing" | "leading_only" | "trailing_only"
144            )
145        {
146            return;
147        }
148
149        MDG_STYLE_OVERRIDE.report(
150            "MD055",
151            "style",
152            &self.config.style,
153            "leading_and_trailing",
154            "a Gherkin table row is an indent followed directly by a pipe",
155        );
156    }
157
158    /// Determine the most prevalent table style in a table block
159    fn determine_table_style(
160        &self,
161        table_block: &TableBlock,
162        lines: &[&str],
163        flavor: MarkdownFlavor,
164    ) -> Option<&'static str> {
165        let mut leading_and_trailing_count = 0;
166        let mut no_leading_or_trailing_count = 0;
167        let mut leading_only_count = 0;
168        let mut trailing_only_count = 0;
169
170        // Count style of header row (table line index 0)
171        let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
172        if let Some(style) = TableUtils::determine_pipe_style_with_flavor(header_content, flavor) {
173            match style {
174                "leading_and_trailing" => leading_and_trailing_count += 1,
175                "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
176                "leading_only" => leading_only_count += 1,
177                "trailing_only" => trailing_only_count += 1,
178                _ => {}
179            }
180        }
181
182        // Count style of content rows (table line indices 2, 3, 4, ...)
183        for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
184            let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
185            if let Some(style) = TableUtils::determine_pipe_style_with_flavor(content, flavor) {
186                match style {
187                    "leading_and_trailing" => leading_and_trailing_count += 1,
188                    "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
189                    "leading_only" => leading_only_count += 1,
190                    "trailing_only" => trailing_only_count += 1,
191                    _ => {}
192                }
193            }
194        }
195
196        // Determine most prevalent style
197        // In case of tie, prefer leading_and_trailing (most common, widely supported)
198        let max_count = leading_and_trailing_count
199            .max(no_leading_or_trailing_count)
200            .max(leading_only_count)
201            .max(trailing_only_count);
202
203        if max_count > 0 {
204            if leading_and_trailing_count == max_count {
205                Some("leading_and_trailing")
206            } else if no_leading_or_trailing_count == max_count {
207                Some("no_leading_or_trailing")
208            } else if leading_only_count == max_count {
209                Some("leading_only")
210            } else if trailing_only_count == max_count {
211                Some("trailing_only")
212            } else {
213                None
214            }
215        } else {
216            None
217        }
218    }
219
220    /// Simple table row fix for tests - creates a dummy TableBlock without list context
221    #[cfg(test)]
222    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
223        let dummy_block = TableBlock {
224            start_line: 0,
225            end_line: 0,
226            header_line: 0,
227            delimiter_line: 0,
228            content_lines: vec![],
229            list_context: None,
230            parser_confirmed: true,
231            repairable: false,
232            header_prefix_len: None,
233        };
234        self.fix_table_row_with_context(line, line, target_style, &dummy_block, 0, MarkdownFlavor::Standard)
235            .unwrap_or_else(|| line.to_string())
236    }
237
238    /// Fix a table row to match the target style, with full context for list tables
239    ///
240    /// This handles tables inside list items by stripping the list prefix,
241    /// fixing the table content, then restoring the appropriate prefix.
242    fn fix_table_row_with_context(
243        &self,
244        line: &str,
245        header: &str,
246        target_style: &str,
247        table_block: &TableBlock,
248        table_line_index: usize,
249        flavor: MarkdownFlavor,
250    ) -> Option<String> {
251        // Extract blockquote prefix first
252        let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
253
254        // Handle list context if present
255        if let Some(ref list_ctx) = table_block.list_context {
256            // The quote prefix swallows every space after the marker, so the optional
257            // space after `>` is the only part of it that is not already indentation.
258            let swallowed = (bq_prefix.len() - bq_prefix.trim_end_matches(' ').len()).saturating_sub(1);
259            if table_line_index == 0 {
260                // Header line: strip list prefix (handles both markers and indentation)
261                let stripped = after_bq
262                    .strip_prefix(&list_ctx.list_prefix)
263                    .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
264                let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
265
266                // Restore prefixes: blockquote + list prefix + fixed content
267                let lp = &list_ctx.list_prefix;
268                let lp = if lp.trim_matches(' ').is_empty() {
269                    &lp[swallowed.min(lp.len())..]
270                } else {
271                    lp
272                };
273                Some(if bq_prefix.is_empty() && lp.is_empty() {
274                    fixed_content
275                } else {
276                    format!("{bq_prefix}{lp}{fixed_content}")
277                })
278            } else {
279                // Continuation lines: strip indentation, then restore it
280                let content_indent = list_ctx.content_indent;
281                let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
282                let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
283
284                // Restore prefixes: blockquote + indentation + fixed content
285                let indent = " ".repeat(content_indent.saturating_sub(swallowed));
286                Some(format!("{bq_prefix}{indent}{fixed_content}"))
287            }
288        } else {
289            // No list context: the row keeps the quote and list markers it was written after
290            let prefix = TableUtils::row_prefix(line, header, table_block, table_line_index);
291            let after_bq = &line[prefix.len()..];
292            let fixed_content = self.fix_table_content(trim_row(after_bq), target_style, flavor)?;
293            // Gherkin recognizes a table row by its indent, so left-aligning one
294            // while restyling it would take the table out of the document that
295            // the restyle exists to preserve. MD060 still owns the indent's width.
296            let indent = if flavor == MarkdownFlavor::MDG {
297                &after_bq[..after_bq.len() - after_bq.trim_start_matches([' ', '\t']).len()]
298            } else {
299                ""
300            };
301            Some(if prefix.is_empty() && indent.is_empty() {
302                fixed_content
303            } else {
304                format!("{prefix}{indent}{fixed_content}")
305            })
306        }
307    }
308
309    /// Fix the table content (without any prefix handling).
310    ///
311    /// Returns `None` when no rewrite into `target_style` keeps the row a table
312    /// row that says what it said before. A pipe is not only decoration: it can
313    /// delimit an empty cell, close a one-column row, or keep a cell's first
314    /// characters from opening another block. The row is then left as written
315    /// and is not reported, so a warning always comes with a fix that is safe.
316    fn fix_table_content(&self, trimmed: &str, target_style: &str, flavor: MarkdownFlavor) -> Option<String> {
317        if !trimmed.contains('|') {
318            return Some(trimmed.to_string());
319        }
320
321        let has_leading = trimmed.starts_with('|');
322        let has_trailing = TableUtils::has_unescaped_trailing_pipe(trimmed, flavor);
323
324        let (drops_leading, drops_trailing) = match target_style {
325            "no_leading_or_trailing" => (has_leading, has_trailing),
326            "leading_only" => (false, has_trailing),
327            "trailing_only" => (has_leading, false),
328            _ => (false, false),
329        };
330
331        let fixed = match target_style {
332            "leading_and_trailing" => {
333                let mut result = trimmed.to_string();
334
335                // Add leading pipe if missing
336                if !has_leading {
337                    result = format!("| {result}");
338                }
339
340                // Add trailing pipe if missing
341                if !has_trailing {
342                    result = format!("{result} |");
343                }
344
345                result
346            }
347            "no_leading_or_trailing" => {
348                let mut result = trimmed;
349
350                // Remove leading pipe if present
351                if has_leading {
352                    result = result.strip_prefix('|').unwrap_or(result);
353                    result = result.trim_start_matches([' ', '\t']);
354                }
355
356                // Remove trailing pipe if present
357                if has_trailing {
358                    result = result.strip_suffix('|').unwrap_or(result);
359                    result = result.trim_end_matches([' ', '\t']);
360                }
361
362                result.to_string()
363            }
364            "leading_only" => {
365                let mut result = trimmed.to_string();
366
367                // Add leading pipe if missing
368                if !has_leading {
369                    result = format!("| {result}");
370                }
371
372                // Remove trailing pipe if present
373                if has_trailing {
374                    result = result
375                        .strip_suffix('|')
376                        .unwrap_or(&result)
377                        .trim_end_matches([' ', '\t'])
378                        .to_string();
379                }
380
381                result
382            }
383            "trailing_only" => {
384                let mut result = trimmed;
385
386                // Remove leading pipe if present
387                if has_leading {
388                    result = result
389                        .strip_prefix('|')
390                        .unwrap_or(result)
391                        .trim_start_matches([' ', '\t']);
392                }
393
394                let mut result = result.to_string();
395
396                // Add trailing pipe if missing
397                if !has_trailing {
398                    result = format!("{result} |");
399                }
400
401                result
402            }
403            _ => return Some(trimmed.to_string()),
404        };
405
406        if drops_leading || drops_trailing {
407            let cells = TableUtils::split_table_row_with_flavor(trimmed, flavor);
408            // A row with one cell has no pipe-free form: its pipe is what makes it a row.
409            if drops_leading && cells.len() == 1 {
410                return None;
411            }
412            // A pipe after a backslash is cell text for the parsers, whatever the
413            // count, so it is not a delimiter that can be dropped.
414            // Nor can one whose cell ends in a backslash be dropped: at the end of
415            // the line the backslash would become a hard break.
416            if drops_trailing && (trimmed[..trimmed.len() - 1].ends_with('\\') || fixed.ends_with('\\')) {
417                return None;
418            }
419            // Every cell must come out as it went in: an empty first or last cell
420            // exists only because of its pipe.
421            if !same_cells(&cells, &TableUtils::split_table_row_with_flavor(&fixed, flavor)) {
422                return None;
423            }
424            if drops_leading && opens_a_block(&fixed) {
425                return None;
426            }
427        }
428
429        Some(fixed)
430    }
431}
432
433/// A row without the line ending or the spaces and tabs around it. CommonMark and
434/// GFM trim nothing else, so any other whitespace is the row's content.
435fn trim_row(row: &str) -> &str {
436    row.trim_matches([' ', '\t', '\r'])
437}
438
439/// Whether two splits of a row hold the same cells, compared as the parsers do:
440/// without the spaces and tabs around each one.
441fn same_cells(a: &[String], b: &[String]) -> bool {
442    a.len() == b.len()
443        && a.iter()
444            .zip(b)
445            .all(|(x, y)| x.trim_matches([' ', '\t']) == y.trim_matches([' ', '\t']))
446}
447
448/// Whether `text`, written as the start of a line, could open a block other than
449/// a table row. Over-reports on purpose: a row left as written is harmless, one
450/// turned into a heading, list or quote is not.
451fn opens_a_block(text: &str) -> bool {
452    let rest = text.trim_start_matches(' ');
453    let Some(first) = rest.chars().next() else {
454        return false;
455    };
456    let after = |n: usize| rest.get(n..).unwrap_or("");
457    let blank_or_end = |tail: &str| tail.is_empty() || tail.starts_with([' ', '\t']);
458    match first {
459        '>' | '<' => true,
460        '#' => blank_or_end(rest.trim_start_matches('#')),
461        '`' | '~' => after(0).chars().take_while(|&c| c == first).count() >= 3,
462        '-' | '+' | '*' => blank_or_end(after(1)),
463        ':' => blank_or_end(after(1)),
464        '$' => rest.starts_with("$$"),
465        '[' => rest.contains("]:"),
466        '0'..='9' => {
467            let digits = rest.trim_start_matches(|c: char| c.is_ascii_digit());
468            digits.len() < rest.len() && digits.starts_with(['.', ')']) && blank_or_end(digits.get(1..).unwrap_or(""))
469        }
470        _ => false,
471    }
472}
473
474impl Rule for MD055TablePipeStyle {
475    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
476        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(55))
477    }
478
479    fn name(&self) -> &'static str {
480        "MD055"
481    }
482
483    fn description(&self) -> &'static str {
484        "Table pipe style should be consistent"
485    }
486
487    fn category(&self) -> RuleCategory {
488        RuleCategory::Table
489    }
490
491    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
492        // Skip if no tables present (uses cached pipe count)
493        !ctx.likely_has_tables()
494    }
495
496    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
497        let mut warnings = Vec::new();
498
499        // Early return handled by should_skip()
500
501        let lines = ctx.raw_lines();
502
503        let configured_style = self.effective_configured_style(ctx);
504
505        // Use pre-computed table blocks from context
506        let table_blocks = &ctx.table_blocks;
507
508        // Process each table block
509        for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
510            // First pass: determine the table's style for "consistent" mode
511            // Count all rows to determine most prevalent style (prevalence-based approach)
512            let table_style = if configured_style == "consistent" {
513                self.determine_table_style(table_block, lines, ctx.flavor)
514            } else {
515                None
516            };
517
518            // Determine target style for this table
519            let target_style = if configured_style == "consistent" {
520                table_style.unwrap_or("leading_and_trailing")
521            } else {
522                configured_style
523            };
524
525            // Collect all table lines for processing
526            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
527                .chain(std::iter::once(table_block.delimiter_line))
528                .chain(table_block.content_lines.iter().copied())
529                .collect();
530
531            // Check each row and emit a per-row fix. Per-row fixes ensure that
532            // inline-disabling one row does not cause the fix on another row to
533            // overwrite the disabled row's content.
534            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
535                let line = lines[line_idx];
536                // Extract content to properly check pipe style (handles list/blockquote prefixes)
537                let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
538                if let Some(current_style) = TableUtils::determine_pipe_style_with_flavor(content, ctx.flavor) {
539                    // Only flag lines with actual style mismatches
540                    let needs_fixing = current_style != target_style;
541
542                    if needs_fixing {
543                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
544
545                        // Build a per-row fix so inline-disabled rows are not
546                        // overwritten by fixes on other rows in the same table.
547                        // A row with no safe rewrite is not reported.
548                        let Some(fixed_line) = self.fix_table_row_with_context(
549                            line,
550                            lines[table_block.header_line],
551                            target_style,
552                            table_block,
553                            table_line_idx,
554                            ctx.flavor,
555                        ) else {
556                            continue;
557                        };
558
559                        let message = format!(
560                            "Table pipe style should be {}",
561                            match target_style {
562                                "leading_and_trailing" => "leading and trailing",
563                                "no_leading_or_trailing" => "no leading or trailing",
564                                "leading_only" => "leading only",
565                                "trailing_only" => "trailing only",
566                                _ => target_style,
567                            }
568                        );
569
570                        let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
571
572                        warnings.push(LintWarning {
573                            rule_name: Some(self.name().to_string()),
574                            severity: Severity::Warning,
575                            message,
576                            line: start_line,
577                            column: start_col,
578                            end_line,
579                            end_column: end_col,
580                            fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
581                        });
582                    }
583                }
584            }
585        }
586
587        Ok(warnings)
588    }
589
590    fn fixes_from_diagnostics(&self) -> bool {
591        true
592    }
593
594    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
595        if self.should_skip(ctx) {
596            return Ok(ctx.content.to_string());
597        }
598        let warnings = self.check(ctx)?;
599        if warnings.is_empty() {
600            return Ok(ctx.content.to_string());
601        }
602        let warnings =
603            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
604        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
605    }
606
607    fn as_any(&self) -> &dyn std::any::Any {
608        self
609    }
610
611    crate::impl_rule_config_sections!(MD055Config);
612
613    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
614    where
615        Self: Sized,
616    {
617        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
618        let style_explicit = option_is_explicit(config, "MD055", "style");
619
620        Box::new(Self {
621            config: rule_config,
622            style_explicit,
623        })
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_pipe_styles_preserve_escaped_trailing_cell_content() {
633        let source = "名 | B\n--- | ---\nleft | right \\|\n";
634        for (style, expected) in [
635            ("consistent", source),
636            ("no_leading_or_trailing", source),
637            ("leading_only", "| 名 | B\n| --- | ---\n| left | right \\|\n"),
638            ("trailing_only", "名 | B |\n--- | --- |\nleft | right \\| |\n"),
639            (
640                "leading_and_trailing",
641                "| 名 | B |\n| --- | --- |\n| left | right \\| |\n",
642            ),
643        ] {
644            let rule = MD055TablePipeStyle::new(style.to_string());
645            let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
646            assert_eq!(
647                rule.fix(&ctx).unwrap(),
648                expected,
649                "cell content must remain intact: {style}"
650            );
651            let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::Standard, None);
652            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
653            assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
654            let mut original_html = String::new();
655            let mut fixed_html = String::new();
656            let options = pulldown_cmark::Options::ENABLE_TABLES;
657            pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new_ext(source, options));
658            pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new_ext(expected, options));
659            assert_eq!(original_html, fixed_html, "rendered table cells must be preserved");
660        }
661    }
662
663    #[test]
664    fn test_mdg_adds_a_delimiter_after_an_escaped_cell_pipe() {
665        let rule = MD055TablePipeStyle::default();
666        let source = "Feature: Tables\n  Scenario: Values\n    Given rows\n    | A | B |\n    | --- | --- |\n    | left | right \\|\n";
667        let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::MDG, None);
668        let warnings = rule.check(&ctx).unwrap();
669        assert_eq!(warnings.len(), 1);
670        assert_eq!(warnings[0].line, 6);
671        let expected = "Feature: Tables\n  Scenario: Values\n    Given rows\n    | A | B |\n    | --- | --- |\n    | left | right \\| |\n";
672        assert_eq!(rule.fix(&ctx).unwrap(), expected);
673        let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::MDG, None);
674        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
675        assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
676    }
677
678    // === Issue #611: kebab-case config values ignored, fallback to leading-and-trailing ===
679    //
680    // All style names must work identically whether the user writes kebab-case
681    // (no-leading-or-trailing) or snake_case (no_leading_or_trailing) in config.
682
683    fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
684        let config: md055_config::MD055Config =
685            toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
686        MD055TablePipeStyle::from_config_struct(config)
687    }
688
689    #[test]
690    fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
691        let rule = rule_from_toml_style("no-leading-or-trailing");
692        let content = "A | B\n--- | ---\n1 | 2";
693        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let warnings = rule.check(&ctx).unwrap();
695        assert!(
696            warnings.is_empty(),
697            "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
698        );
699    }
700
701    #[test]
702    fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
703        let rule = rule_from_toml_style("no-leading-or-trailing");
704        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
705        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706        let warnings = rule.check(&ctx).unwrap();
707        assert_eq!(
708            warnings.len(),
709            3,
710            "no-leading-or-trailing should flag all 3 rows with pipes"
711        );
712        assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
713    }
714
715    #[test]
716    fn test_leading_only_kebab_accepts_conforming_table() {
717        let rule = rule_from_toml_style("leading-only");
718        let content = "| A | B\n|---|---\n| 1 | 2";
719        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720        let warnings = rule.check(&ctx).unwrap();
721        assert!(
722            warnings.is_empty(),
723            "leading-only should accept a leading-only table: {warnings:?}"
724        );
725    }
726
727    #[test]
728    fn test_trailing_only_kebab_accepts_conforming_table() {
729        let rule = rule_from_toml_style("trailing-only");
730        let content = "A | B |\n---|---|\n1 | 2 |";
731        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732        let warnings = rule.check(&ctx).unwrap();
733        assert!(
734            warnings.is_empty(),
735            "trailing-only should accept a trailing-only table: {warnings:?}"
736        );
737    }
738
739    #[test]
740    fn test_trailing_only_kebab_rejects_nonconforming_table() {
741        let rule = rule_from_toml_style("trailing-only");
742        // leading-and-trailing table must be flagged — proves the table is actually detected
743        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
744        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745        let warnings = rule.check(&ctx).unwrap();
746        assert_eq!(
747            warnings.len(),
748            3,
749            "trailing-only should flag all 3 rows that have leading pipes"
750        );
751        assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
752    }
753
754    #[test]
755    fn test_leading_only_kebab_rejects_nonconforming_table() {
756        let rule = rule_from_toml_style("leading-only");
757        // trailing-only table must be flagged — proves the table is actually detected
758        let content = "A | B |\n---|---|\n1 | 2 |";
759        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760        let warnings = rule.check(&ctx).unwrap();
761        assert_eq!(
762            warnings.len(),
763            3,
764            "leading-only should flag all 3 rows that have trailing pipes"
765        );
766        assert!(warnings.iter().all(|w| w.message.contains("leading only")));
767    }
768
769    #[test]
770    fn test_leading_and_trailing_kebab_accepts_conforming_table() {
771        let rule = rule_from_toml_style("leading-and-trailing");
772        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
773        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let warnings = rule.check(&ctx).unwrap();
775        assert!(
776            warnings.is_empty(),
777            "leading-and-trailing should accept a fully-piped table: {warnings:?}"
778        );
779    }
780
781    #[test]
782    fn test_kebab_and_snake_case_styles_are_equivalent() {
783        // For every style, kebab and snake_case forms must produce identical warnings —
784        // same count, same messages, same line numbers.
785        let pairs = [
786            ("no-leading-or-trailing", "no_leading_or_trailing"),
787            ("leading-only", "leading_only"),
788            ("trailing-only", "trailing_only"),
789            ("leading-and-trailing", "leading_and_trailing"),
790        ];
791        // Mixed table so every style produces at least one warning, exercising the message path.
792        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
793        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794
795        for (kebab, snake) in pairs {
796            let kebab_rule = rule_from_toml_style(kebab);
797            let snake_rule = rule_from_toml_style(snake);
798            let kebab_warnings = kebab_rule.check(&ctx).unwrap();
799            let snake_warnings = snake_rule.check(&ctx).unwrap();
800
801            assert_eq!(
802                kebab_warnings.len(),
803                snake_warnings.len(),
804                "'{kebab}' and '{snake}' must produce the same number of warnings"
805            );
806            for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
807                assert_eq!(
808                    kw.message, sw.message,
809                    "warning[{i}] message differs between '{kebab}' and '{snake}'"
810                );
811                assert_eq!(
812                    kw.line, sw.line,
813                    "warning[{i}] line differs between '{kebab}' and '{snake}'"
814                );
815            }
816        }
817    }
818
819    fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
820        let rule = rule_from_toml_style(style);
821        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822        let fixed = rule.fix(&ctx).unwrap();
823        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
824        let remaining = rule.check(&ctx2).unwrap();
825        assert!(
826            remaining.is_empty(),
827            "style '{style}': after fix(), check() should find 0 violations.\n\
828             Original: {content:?}\n\
829             Fixed:    {fixed:?}\n\
830             Remaining: {remaining:?}"
831        );
832    }
833
834    #[test]
835    fn test_roundtrip_kebab_no_leading_or_trailing() {
836        assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
837    }
838
839    #[test]
840    fn test_roundtrip_kebab_leading_and_trailing() {
841        assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
842    }
843
844    #[test]
845    fn test_roundtrip_kebab_leading_only() {
846        assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
847    }
848
849    #[test]
850    fn test_roundtrip_kebab_trailing_only() {
851        assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
852    }
853
854    #[test]
855    fn test_md055_delimiter_row_handling() {
856        // Test with no_leading_or_trailing style
857        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
858
859        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
860        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let result = rule.fix(&ctx).unwrap();
862
863        // With the fixed implementation, the delimiter row should have pipes removed
864        // Spacing is preserved from original input
865        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
866
867        assert_eq!(result, expected);
868
869        // Test that the check method actually reports the delimiter row as an issue
870        let warnings = rule.check(&ctx).unwrap();
871        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
872        assert_eq!(delimiter_warning.line, 2);
873        assert_eq!(
874            delimiter_warning.message,
875            "Table pipe style should be no leading or trailing"
876        );
877
878        // Test with leading_and_trailing style
879        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
880
881        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
882        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883        let result = rule.fix(&ctx).unwrap();
884
885        // The delimiter row should have pipes added
886        // Spacing is preserved from original input
887        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
888
889        assert_eq!(result, expected);
890    }
891
892    #[test]
893    fn test_md055_check_finds_delimiter_row_issues() {
894        // Test that check() correctly identifies delimiter rows that don't match style
895        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
896
897        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
898        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let warnings = rule.check(&ctx).unwrap();
900
901        // Should have 3 warnings - header row, delimiter row, and data row
902        assert_eq!(warnings.len(), 3);
903
904        // Specifically verify the delimiter row warning (line 2)
905        let delimiter_warning = &warnings[1];
906        assert_eq!(delimiter_warning.line, 2);
907        assert_eq!(
908            delimiter_warning.message,
909            "Table pipe style should be no leading or trailing"
910        );
911    }
912
913    #[test]
914    fn test_md055_real_world_example() {
915        // Test with a real-world example having content before and after the table
916        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
917
918        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.";
919        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920        let result = rule.fix(&ctx).unwrap();
921
922        // The table should be fixed, with pipes removed
923        // Spacing is preserved from original input
924        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.";
925
926        assert_eq!(result, expected);
927
928        // Ensure we get warnings for all table rows
929        let warnings = rule.check(&ctx).unwrap();
930        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
931
932        // The line numbers should match the correct positions in the original content
933        assert_eq!(warnings[0].line, 5); // Header row
934        assert_eq!(warnings[1].line, 6); // Delimiter row
935        assert_eq!(warnings[2].line, 7); // Data row 1
936        assert_eq!(warnings[3].line, 8); // Data row 2
937    }
938
939    #[test]
940    fn test_md055_invalid_style() {
941        // Test with an invalid style setting
942        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
943
944        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
945        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946        let result = rule.fix(&ctx).unwrap();
947
948        // Should default to "leading_and_trailing"
949        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
950        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
951
952        assert_eq!(result, expected);
953
954        // Now check a content that needs actual modification
955        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
956        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957        let result = rule.fix(&ctx2).unwrap();
958
959        // Should add pipes to match the default "leading_and_trailing" style
960        // Spacing is preserved from original input
961        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
962        assert_eq!(result, expected);
963
964        // Check that warning messages also work with the fallback style
965        let warnings = rule.check(&ctx2).unwrap();
966
967        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
968        // there should be warnings for all rows
969        assert_eq!(warnings.len(), 3);
970    }
971
972    #[test]
973    fn test_underflow_protection() {
974        // Test case to ensure no underflow when parts is empty
975        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
976
977        // Test with empty string (edge case)
978        let result = rule.fix_table_row("", "leading_and_trailing");
979        assert_eq!(result, "");
980
981        // Test with string that doesn't contain pipes
982        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
983        assert_eq!(result, "no pipes here");
984
985        // Test with minimal pipe content
986        let result = rule.fix_table_row("|", "leading_and_trailing");
987        // Should not panic and should handle gracefully
988        assert!(!result.is_empty());
989    }
990
991    // === Issue #305: Blockquote table tests ===
992
993    #[test]
994    fn test_fix_table_row_in_blockquote() {
995        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
996
997        // Blockquote table without leading pipe
998        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
999        assert_eq!(result, "> | H1 | H2 |");
1000
1001        // Blockquote table that already has pipes
1002        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
1003        assert_eq!(result, "> | H1 | H2 |");
1004
1005        // Removing pipes from blockquote table
1006        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
1007        assert_eq!(result, "> H1 | H2");
1008    }
1009
1010    #[test]
1011    fn test_fix_table_row_in_nested_blockquote() {
1012        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1013
1014        // Double-nested blockquote
1015        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
1016        assert_eq!(result, ">> | H1 | H2 |");
1017
1018        // Triple-nested blockquote
1019        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
1020        assert_eq!(result, ">>> | H1 | H2 |");
1021    }
1022
1023    #[test]
1024    fn test_blockquote_table_full_document() {
1025        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1026
1027        // Full table in blockquote (2 columns, matching delimiter)
1028        let content = "> H1 | H2\n> ----|----\n> a  | b";
1029        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030        let result = rule.fix(&ctx).unwrap();
1031
1032        // Each line should have the blockquote prefix preserved and pipes added
1033        // The leading_and_trailing style adds "| " after blockquote prefix
1034        assert!(
1035            result.starts_with("> |"),
1036            "Header should start with blockquote + pipe. Got:\n{result}"
1037        );
1038        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
1039        assert!(
1040            result.contains("> | ----"),
1041            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_blockquote_table_no_leading_trailing() {
1047        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1048
1049        // Table with pipes that should be removed
1050        let content = "> | H1 | H2 |\n> |----|----|\n> | a  | b |";
1051        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let result = rule.fix(&ctx).unwrap();
1053
1054        // Pipes should be removed but blockquote prefix preserved
1055        let lines: Vec<&str> = result.lines().collect();
1056        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
1057        assert!(
1058            !lines[0].starts_with("> |"),
1059            "Leading pipe should be removed. Got: {}",
1060            lines[0]
1061        );
1062    }
1063
1064    #[test]
1065    fn test_mixed_regular_and_blockquote_tables() {
1066        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1067
1068        // Document with both regular and blockquote tables
1069        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
1070        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071        let result = rule.fix(&ctx).unwrap();
1072
1073        // Both tables should be fixed
1074        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
1075        assert!(
1076            result.contains("> | H3 | H4 |"),
1077            "Blockquote table should have pipes added with prefix preserved"
1078        );
1079    }
1080
1081    // === Roundtrip safety tests ===
1082
1083    fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
1084        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085        let fixed = rule.fix(&ctx).unwrap();
1086        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1087        let remaining = rule.check(&ctx2).unwrap();
1088        assert!(
1089            remaining.is_empty(),
1090            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_roundtrip_leading_and_trailing() {
1096        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1097        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
1098    }
1099
1100    #[test]
1101    fn test_roundtrip_no_leading_or_trailing() {
1102        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1103        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1104    }
1105
1106    #[test]
1107    fn test_roundtrip_consistent_mode() {
1108        let rule = MD055TablePipeStyle::default();
1109        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
1110    }
1111
1112    #[test]
1113    fn test_roundtrip_blockquote_table() {
1114        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1115        assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
1116    }
1117
1118    #[test]
1119    fn test_roundtrip_mixed_tables() {
1120        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1121        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
1122    }
1123
1124    #[test]
1125    fn test_roundtrip_with_surrounding_content() {
1126        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1127        assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
1128    }
1129
1130    #[test]
1131    fn test_roundtrip_clean_content() {
1132        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1133        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1134    }
1135
1136    // === Pandoc construct reachability tests ===
1137    //
1138    // These tests document that MD055 does not flag Pandoc-specific constructs
1139    // (grid tables, multi-line tables, line blocks, pipe-table captions) because
1140    // `ctx.table_blocks` excludes them at the source:
1141    //
1142    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
1143    //   returns false and no `TableBlock` is created.
1144    // - Multi-line table separators (`----------`) have no `|`, same exclusion.
1145    // - Line blocks (`| First line`) end without `|`, so `is_potential_table_row`
1146    //   requires `valid_parts >= 2` but finds only 1 — excluded.
1147    // - Pipe-table captions (`: caption`) have no `|` — excluded.
1148    //
1149    // No production guard is needed. These tests ensure that if `find_table_blocks`
1150    // ever changes to include these constructs, the failure is visible.
1151
1152    #[test]
1153    fn md055_pandoc_grid_tables_not_flagged() {
1154        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1155        let content = "\
1156+---+---+
1157| a | b |
1158+===+===+
1159| 1 | 2 |
1160+---+---+
1161";
1162        // Under Pandoc: grid tables are excluded from table_blocks (delimiter rows
1163        // use `+` not `|`), so no warnings are emitted.
1164        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1165        let result = rule.check(&ctx).unwrap();
1166        assert!(
1167            result.is_empty(),
1168            "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1169        );
1170
1171        // Under Standard: same content also produces no warnings because the
1172        // `+---+---+` lines are not recognized as pipe-table delimiters.
1173        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174        let result_std = rule.check(&ctx_std).unwrap();
1175        assert!(
1176            result_std.is_empty(),
1177            "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
1178        );
1179    }
1180
1181    #[test]
1182    fn md055_pandoc_multi_line_tables_not_flagged() {
1183        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1184        // Multi-line table (Pandoc extension): separator line has no `|`.
1185        let content = "\
1186--------- ----------- ------
1187Header 1   Header 2   Header 3
1188--------- ----------- ------
1189Cell 1     Cell 2     Cell 3
1190--------- ----------- ------
1191";
1192        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1193        let result = rule.check(&ctx).unwrap();
1194        assert!(
1195            result.is_empty(),
1196            "MD055 should not flag Pandoc multi-line tables: {result:?}"
1197        );
1198
1199        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let result_std = rule.check(&ctx_std).unwrap();
1201        assert!(
1202            result_std.is_empty(),
1203            "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1204        );
1205    }
1206
1207    #[test]
1208    fn md055_pandoc_line_blocks_not_flagged() {
1209        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1210        // Pandoc line blocks: `| text` that starts with `|` but does not end with `|`.
1211        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped lines.
1212        let content = "| First line\n| Second line\n";
1213        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1214        let result = rule.check(&ctx).unwrap();
1215        assert!(
1216            result.is_empty(),
1217            "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1218        );
1219
1220        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result_std = rule.check(&ctx_std).unwrap();
1222        assert!(
1223            result_std.is_empty(),
1224            "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
1225        );
1226    }
1227
1228    #[test]
1229    fn md055_pandoc_pipe_table_captions_not_flagged() {
1230        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1231        // Pipe-table captions (`: caption`) have no `|`, so they are never included
1232        // in table_blocks.
1233        let content = "\
1234| H1 | H2 |
1235|----|-----|
1236| a  | b  |
1237
1238: My table caption
1239";
1240        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1241        let result = rule.check(&ctx).unwrap();
1242        assert!(
1243            result.is_empty(),
1244            "MD055 should not flag the pipe-table caption line: {result:?}"
1245        );
1246
1247        // Under Standard: same table rows are correctly checked; caption line is ignored.
1248        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249        let result_std = rule.check(&ctx_std).unwrap();
1250        assert!(
1251            result_std.is_empty(),
1252            "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1253        );
1254    }
1255
1256    // === MDG: the Gherkin form is enforced over an incompatible style ===
1257    //
1258    // Gherkin recognizes a Data Table or Examples table row only when an indent
1259    // is followed directly by a pipe, so a style that strips the leading pipe
1260    // does not restyle the table, it deletes it from the document.
1261
1262    /// Each style a Gherkin document cannot carry, paired with the rows a table
1263    /// written in that style would contain.
1264    const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1265        (
1266            "no_leading_or_trailing",
1267            ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1268        ),
1269        (
1270            "leading_only",
1271            ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1272        ),
1273        (
1274            "trailing_only",
1275            ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1276        ),
1277    ];
1278
1279    const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1280
1281    fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1282        let spaces = " ".repeat(indent);
1283        let [header, delimiter, body] = rows;
1284        format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1285    }
1286
1287    #[test]
1288    fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1289        // Two to five whitespace characters are all Gherkin accepts before the
1290        // pipe, so the whole range has to survive the correction.
1291        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1292            let rule = MD055TablePipeStyle::new(style.to_string());
1293
1294            for indent in [2, 3, 4, 5] {
1295                let content = examples_table(indent, rows);
1296                let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1297                let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1298
1299                assert_eq!(
1300                    rule.check(&ctx).unwrap().len(),
1301                    3,
1302                    "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1303                );
1304
1305                let fixed = rule.fix(&ctx).unwrap();
1306                assert_eq!(
1307                    fixed, expected,
1308                    "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1309                );
1310
1311                let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1312                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1313                assert_eq!(
1314                    rule.fix(&fixed_ctx).unwrap(),
1315                    fixed,
1316                    "style '{style}' at indent {indent}: MDG fix must be idempotent"
1317                );
1318            }
1319        }
1320    }
1321
1322    #[test]
1323    fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1324        // The reproducing case: rows Gherkin already recognizes must survive
1325        // every style the user could have configured.
1326        let content = examples_table(2, MDG_GHERKIN_ROWS);
1327
1328        for style in [
1329            "consistent",
1330            "leading_and_trailing",
1331            "no_leading_or_trailing",
1332            "leading_only",
1333            "trailing_only",
1334        ] {
1335            let rule = MD055TablePipeStyle::new(style.to_string());
1336            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1337
1338            assert!(
1339                rule.check(&ctx).unwrap().is_empty(),
1340                "style '{style}': MDG enforces this form, so it cannot be reported"
1341            );
1342            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1343        }
1344    }
1345
1346    #[test]
1347    fn test_mdg_consistent_ignores_prevalence() {
1348        // `consistent` asks for no particular form, so it is never warned about;
1349        // MDG still resolves it to the Gherkin form rather than to whichever
1350        // form the table happens to be written in.
1351        let defaulted = MD055TablePipeStyle::default();
1352        assert_eq!(defaulted.config.style, "consistent");
1353        let explicit = MD055TablePipeStyle::new("consistent".to_string());
1354
1355        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1356            let content = examples_table(2, rows);
1357            let expected = examples_table(2, MDG_GHERKIN_ROWS);
1358
1359            for rule in [&defaulted, &explicit] {
1360                let standard_ctx =
1361                    crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1362                assert!(
1363                    rule.check(&standard_ctx).unwrap().is_empty(),
1364                    "Standard resolves `consistent` to the table's own '{style}'"
1365                );
1366
1367                let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1368                assert_eq!(
1369                    rule.fix(&mdg_ctx).unwrap(),
1370                    expected,
1371                    "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1372                );
1373            }
1374        }
1375    }
1376
1377    #[test]
1378    fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1379        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1380            let rule = MD055TablePipeStyle::new(style.to_string());
1381            let content = examples_table(2, rows);
1382            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1383
1384            assert!(
1385                rule.check(&ctx).unwrap().is_empty(),
1386                "style '{style}': Standard must still honour it"
1387            );
1388            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1389
1390            // And it is still applied to a table written the other way round.
1391            let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1392            let mdg_form_ctx =
1393                crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1394            assert_eq!(
1395                rule.check(&mdg_form_ctx).unwrap().len(),
1396                3,
1397                "style '{style}': Standard must still correct the leading-and-trailing form away"
1398            );
1399        }
1400
1401        // Preserving the indent is a Gherkin concession; Standard keeps
1402        // left-aligning the rows it rewrites.
1403        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1404        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1405        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1406        assert_eq!(
1407            rule.fix(&ctx).unwrap(),
1408            examples_table(0, MDG_GHERKIN_ROWS),
1409            "Standard must keep stripping the indent"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_from_config_records_whether_style_was_configured() {
1415        // The MDG override applies either way, but the warning is only for a
1416        // style the user actually asked for, so a configured style has to be
1417        // told apart from a defaulted one.
1418        use crate::config::Config;
1419        use std::collections::BTreeMap;
1420
1421        let mut values = BTreeMap::new();
1422        values.insert(
1423            "style".to_string(),
1424            toml::Value::String("no_leading_or_trailing".to_string()),
1425        );
1426        let mut config = Config::default();
1427        config.rules.insert(
1428            "MD055".to_string(),
1429            crate::config::RuleConfig { severity: None, values },
1430        );
1431
1432        let configured = MD055TablePipeStyle::from_config(&config);
1433        let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1434        assert_eq!(configured.config.style, "no_leading_or_trailing");
1435        assert!(configured.style_explicit);
1436
1437        let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1438        let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1439        assert_eq!(defaulted.config.style, "consistent");
1440        assert!(!defaulted.style_explicit);
1441
1442        // The override does not depend on the warning: a defaulted incompatible
1443        // style is enforced just the same.
1444        let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1445            style: "no_leading_or_trailing".to_string(),
1446        });
1447        assert!(!unreported.style_explicit);
1448        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1449        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1450        assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1451    }
1452    fn fixed_by(style: &str, source: &str) -> (usize, String) {
1453        let rule = MD055TablePipeStyle::new(style.to_string());
1454        let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
1455        (rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
1456    }
1457
1458    /// A one-column row has no form without a leading pipe: dropping it turns
1459    /// `| a |` / `| - |` into a setext heading. Such a table is left alone and
1460    /// reported nowhere.
1461    #[test]
1462    fn test_one_column_table_is_not_reduced_to_pipe_free_rows() {
1463        for source in [
1464            "| a |\n| - |\n| x |\n| y |\n",
1465            "| a |\n|---|\n| x |\n| y |\n",
1466            "| a |\n| - |\n",
1467            "a |\n- |\nx |\n",
1468        ] {
1469            for style in ["no_leading_or_trailing", "trailing_only"] {
1470                assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}: {source:?}");
1471            }
1472        }
1473    }
1474
1475    #[test]
1476    fn test_one_column_table_keeps_the_leading_pipe_styles() {
1477        let full = "| a |\n| - |\n| x |\n";
1478        let leading = "| a\n| -\n| x\n";
1479        assert_eq!(fixed_by("leading_only", full), (3, leading.to_string()));
1480        assert_eq!(fixed_by("leading_only", leading), (0, leading.to_string()));
1481        // consistent keeps whatever a one-column table already uses
1482        for source in [full, leading] {
1483            assert_eq!(fixed_by("consistent", source), (0, source.to_string()));
1484        }
1485    }
1486
1487    /// A pipe that bounds an empty cell is part of the row.
1488    #[test]
1489    fn test_empty_edge_cells_keep_their_pipes() {
1490        let source = "| a | b |\n| --- | --- |\n| | b |\n| a | |\n| | |\n";
1491        let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
1492        assert_eq!(fixed, "a | b\n--- | ---\n| | b |\n| a | |\n| | |\n");
1493        assert_eq!(warnings, 2);
1494        let (warnings, fixed) = fixed_by("leading_only", source);
1495        assert_eq!(fixed, "| a | b\n| --- | ---\n| | b\n| a | |\n| | |\n");
1496        assert_eq!(warnings, 3);
1497        let (_, fixed) = fixed_by("trailing_only", source);
1498        assert_eq!(fixed, "a | b |\n--- | --- |\n| | b |\na | |\n| | |\n");
1499        // single empty cell
1500        let source = "||\n|-|\n||\n";
1501        for style in ["no_leading_or_trailing", "trailing_only"] {
1502            assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}");
1503        }
1504        assert_eq!(fixed_by("leading_only", source), (1, "||\n|-\n||\n".to_string()));
1505    }
1506
1507    /// Without its leading pipe a cell opening with a block marker is no longer
1508    /// a table row.
1509    #[test]
1510    fn test_cells_that_would_open_a_block_keep_the_leading_pipe() {
1511        let source = "| # a | b |\n| --- | --- |\n| x | y |\n";
1512        assert_eq!(
1513            fixed_by("no_leading_or_trailing", source),
1514            (2, "| # a | b |\n--- | ---\nx | y\n".to_string())
1515        );
1516        let source = "| a | b |\n| - | - |\n| x | y |\n";
1517        let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
1518        assert_eq!((warnings, fixed.as_str()), (2, "a | b\n| - | - |\nx | y\n"));
1519        // control: a delimiter row of dashes and colons is not a block start
1520        let source = "| a | b |\n|:---|---:|\n| x | y |\n";
1521        assert_eq!(
1522            fixed_by("no_leading_or_trailing", source),
1523            (3, "a | b\n:---|---:\nx | y\n".to_string())
1524        );
1525    }
1526
1527    /// Only spaces and tabs around a cell are padding; the rest is its text.
1528    #[test]
1529    fn test_other_whitespace_in_a_cell_is_kept() {
1530        let source = "| a | b |\n| --- | --- |\n| \u{2003}x | y\u{2003} |\n";
1531        let (_, fixed) = fixed_by("no_leading_or_trailing", source);
1532        assert_eq!(fixed, "a | b\n--- | ---\n\u{2003}x | y\u{2003}\n");
1533        // after the last pipe it is a cell of its own, not padding
1534        let source = "| a | b |\n| --- | --- |\n| x | y |\u{2003}\n";
1535        let (warnings, fixed) = fixed_by("leading_only", source);
1536        assert_eq!(warnings, 2);
1537        assert_eq!(fixed, "| a | b\n| --- | ---\n| x | y |\u{2003}\n");
1538    }
1539
1540    /// A pipe that follows a backslash is cell text, and a row cannot end in a
1541    /// backslash.
1542    #[test]
1543    fn test_escaped_and_backslash_ended_trailing_cells_keep_their_pipe() {
1544        for tail in ["b\\|", "b\\\\|", "b\\ |"] {
1545            let source = format!("| h | j |\n| --- | --- |\n| a | {tail}\n");
1546            let (_, fixed) = fixed_by("leading_only", &source);
1547            assert!(fixed.ends_with(&format!("| a | {tail}\n")), "{source:?} -> {fixed:?}");
1548        }
1549    }
1550
1551    /// Control: a plain multi-column table is still restyled.
1552    #[test]
1553    fn test_plain_tables_are_still_restyled() {
1554        let source = "| a | b |\n| --- | --- |\n| x | y |\n";
1555        assert_eq!(
1556            fixed_by("no_leading_or_trailing", source),
1557            (3, "a | b\n--- | ---\nx | y\n".to_string())
1558        );
1559        assert_eq!(
1560            fixed_by("trailing_only", source),
1561            (3, "a | b |\n--- | --- |\nx | y |\n".to_string())
1562        );
1563        assert_eq!(
1564            fixed_by("leading_only", "a | b\n--- | ---\nx | y\n"),
1565            (3, "| a | b\n| --- | ---\n| x | y\n".to_string())
1566        );
1567    }
1568    /// A row ending in `|` and then a non-ASCII space does not end in a pipe: the
1569    /// space is a cell of its own, so the row is already leading-only and a
1570    /// restyle that is applied leaves nothing to report.
1571    #[test]
1572    fn test_non_ascii_space_after_the_last_pipe_is_not_padding() {
1573        for tail in ["| \u{2003}", "|\u{2003}"] {
1574            let source = format!("| h | j | k |\n| - | - | - |\n| a | b {tail}\n");
1575            let (warnings, fixed) = fixed_by("leading_only", &source);
1576            assert_eq!(warnings, 2, "{source:?}");
1577            assert_eq!(fixed, format!("| h | j | k\n| - | - | -\n| a | b {tail}\n"));
1578            assert_eq!(fixed_by("leading_only", &fixed), (0, fixed.clone()));
1579        }
1580    }
1581}