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(&self, table_block: &TableBlock, lines: &[&str]) -> Option<&'static str> {
160        let mut leading_and_trailing_count = 0;
161        let mut no_leading_or_trailing_count = 0;
162        let mut leading_only_count = 0;
163        let mut trailing_only_count = 0;
164
165        // Count style of header row (table line index 0)
166        let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
167        if let Some(style) = TableUtils::determine_pipe_style(header_content) {
168            match style {
169                "leading_and_trailing" => leading_and_trailing_count += 1,
170                "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
171                "leading_only" => leading_only_count += 1,
172                "trailing_only" => trailing_only_count += 1,
173                _ => {}
174            }
175        }
176
177        // Count style of content rows (table line indices 2, 3, 4, ...)
178        for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
179            let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
180            if let Some(style) = TableUtils::determine_pipe_style(content) {
181                match style {
182                    "leading_and_trailing" => leading_and_trailing_count += 1,
183                    "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
184                    "leading_only" => leading_only_count += 1,
185                    "trailing_only" => trailing_only_count += 1,
186                    _ => {}
187                }
188            }
189        }
190
191        // Determine most prevalent style
192        // In case of tie, prefer leading_and_trailing (most common, widely supported)
193        let max_count = leading_and_trailing_count
194            .max(no_leading_or_trailing_count)
195            .max(leading_only_count)
196            .max(trailing_only_count);
197
198        if max_count > 0 {
199            if leading_and_trailing_count == max_count {
200                Some("leading_and_trailing")
201            } else if no_leading_or_trailing_count == max_count {
202                Some("no_leading_or_trailing")
203            } else if leading_only_count == max_count {
204                Some("leading_only")
205            } else if trailing_only_count == max_count {
206                Some("trailing_only")
207            } else {
208                None
209            }
210        } else {
211            None
212        }
213    }
214
215    /// Simple table row fix for tests - creates a dummy TableBlock without list context
216    #[cfg(test)]
217    fn fix_table_row(&self, line: &str, target_style: &str) -> String {
218        let dummy_block = TableBlock {
219            start_line: 0,
220            end_line: 0,
221            header_line: 0,
222            delimiter_line: 0,
223            content_lines: vec![],
224            list_context: None,
225        };
226        self.fix_table_row_with_context(line, target_style, &dummy_block, 0, MarkdownFlavor::Standard)
227    }
228
229    /// Fix a table row to match the target style, with full context for list tables
230    ///
231    /// This handles tables inside list items by stripping the list prefix,
232    /// fixing the table content, then restoring the appropriate prefix.
233    fn fix_table_row_with_context(
234        &self,
235        line: &str,
236        target_style: &str,
237        table_block: &TableBlock,
238        table_line_index: usize,
239        flavor: MarkdownFlavor,
240    ) -> String {
241        // Extract blockquote prefix first
242        let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
243
244        // Handle list context if present
245        if let Some(ref list_ctx) = table_block.list_context {
246            if table_line_index == 0 {
247                // Header line: strip list prefix (handles both markers and indentation)
248                let stripped = after_bq
249                    .strip_prefix(&list_ctx.list_prefix)
250                    .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
251                let fixed_content = self.fix_table_content(stripped.trim(), target_style);
252
253                // Restore prefixes: blockquote + list prefix + fixed content
254                let lp = &list_ctx.list_prefix;
255                if bq_prefix.is_empty() && lp.is_empty() {
256                    fixed_content
257                } else {
258                    format!("{bq_prefix}{lp}{fixed_content}")
259                }
260            } else {
261                // Continuation lines: strip indentation, then restore it
262                let content_indent = list_ctx.content_indent;
263                let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
264                let fixed_content = self.fix_table_content(stripped.trim(), target_style);
265
266                // Restore prefixes: blockquote + indentation + fixed content
267                let indent = " ".repeat(content_indent);
268                format!("{bq_prefix}{indent}{fixed_content}")
269            }
270        } else {
271            // No list context, just handle blockquote prefix
272            let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
273            // Gherkin recognizes a table row by its indent, so left-aligning one
274            // while restyling it would take the table out of the document that
275            // the restyle exists to preserve. MD060 still owns the indent's width.
276            let indent = if flavor == MarkdownFlavor::MDG {
277                &after_bq[..after_bq.len() - after_bq.trim_start().len()]
278            } else {
279                ""
280            };
281            if bq_prefix.is_empty() && indent.is_empty() {
282                fixed_content
283            } else {
284                format!("{bq_prefix}{indent}{fixed_content}")
285            }
286        }
287    }
288
289    /// Fix the table content (without any prefix handling)
290    fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
291        if !trimmed.contains('|') {
292            return trimmed.to_string();
293        }
294
295        let has_leading = trimmed.starts_with('|');
296        let has_trailing = TableUtils::has_unescaped_trailing_pipe(trimmed);
297
298        match target_style {
299            "leading_and_trailing" => {
300                let mut result = trimmed.to_string();
301
302                // Add leading pipe if missing
303                if !has_leading {
304                    result = format!("| {result}");
305                }
306
307                // Add trailing pipe if missing
308                if !has_trailing {
309                    result = format!("{result} |");
310                }
311
312                result
313            }
314            "no_leading_or_trailing" => {
315                let mut result = trimmed;
316
317                // Remove leading pipe if present
318                if has_leading {
319                    result = result.strip_prefix('|').unwrap_or(result);
320                    result = result.trim_start();
321                }
322
323                // Remove trailing pipe if present
324                if has_trailing {
325                    result = result.strip_suffix('|').unwrap_or(result);
326                    result = result.trim_end();
327                }
328
329                result.to_string()
330            }
331            "leading_only" => {
332                let mut result = trimmed.to_string();
333
334                // Add leading pipe if missing
335                if !has_leading {
336                    result = format!("| {result}");
337                }
338
339                // Remove trailing pipe if present
340                if has_trailing {
341                    result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
342                }
343
344                result
345            }
346            "trailing_only" => {
347                let mut result = trimmed;
348
349                // Remove leading pipe if present
350                if has_leading {
351                    result = result.strip_prefix('|').unwrap_or(result).trim_start();
352                }
353
354                let mut result = result.to_string();
355
356                // Add trailing pipe if missing
357                if !has_trailing {
358                    result = format!("{result} |");
359                }
360
361                result
362            }
363            _ => trimmed.to_string(),
364        }
365    }
366}
367
368impl Rule for MD055TablePipeStyle {
369    fn name(&self) -> &'static str {
370        "MD055"
371    }
372
373    fn description(&self) -> &'static str {
374        "Table pipe style should be consistent"
375    }
376
377    fn category(&self) -> RuleCategory {
378        RuleCategory::Table
379    }
380
381    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
382        // Skip if no tables present (uses cached pipe count)
383        !ctx.likely_has_tables()
384    }
385
386    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
387        let mut warnings = Vec::new();
388
389        // Early return handled by should_skip()
390
391        let lines = ctx.raw_lines();
392
393        let configured_style = self.effective_configured_style(ctx);
394
395        // Use pre-computed table blocks from context
396        let table_blocks = &ctx.table_blocks;
397
398        // Process each table block
399        for table_block in table_blocks {
400            // First pass: determine the table's style for "consistent" mode
401            // Count all rows to determine most prevalent style (prevalence-based approach)
402            let table_style = if configured_style == "consistent" {
403                self.determine_table_style(table_block, lines)
404            } else {
405                None
406            };
407
408            // Determine target style for this table
409            let target_style = if configured_style == "consistent" {
410                table_style.unwrap_or("leading_and_trailing")
411            } else {
412                configured_style
413            };
414
415            // Collect all table lines for processing
416            let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
417                .chain(std::iter::once(table_block.delimiter_line))
418                .chain(table_block.content_lines.iter().copied())
419                .collect();
420
421            // Check each row and emit a per-row fix. Per-row fixes ensure that
422            // inline-disabling one row does not cause the fix on another row to
423            // overwrite the disabled row's content.
424            for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
425                let line = lines[line_idx];
426                // Extract content to properly check pipe style (handles list/blockquote prefixes)
427                let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
428                if let Some(current_style) = TableUtils::determine_pipe_style(content) {
429                    // Only flag lines with actual style mismatches
430                    let needs_fixing = current_style != target_style;
431
432                    if needs_fixing {
433                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
434
435                        let message = format!(
436                            "Table pipe style should be {}",
437                            match target_style {
438                                "leading_and_trailing" => "leading and trailing",
439                                "no_leading_or_trailing" => "no leading or trailing",
440                                "leading_only" => "leading only",
441                                "trailing_only" => "trailing only",
442                                _ => target_style,
443                            }
444                        );
445
446                        // Build a per-row fix so inline-disabled rows are not
447                        // overwritten by fixes on other rows in the same table.
448                        let fixed_line = self.fix_table_row_with_context(
449                            line,
450                            target_style,
451                            table_block,
452                            table_line_idx,
453                            ctx.flavor,
454                        );
455                        let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
456
457                        warnings.push(LintWarning {
458                            rule_name: Some(self.name().to_string()),
459                            severity: Severity::Warning,
460                            message,
461                            line: start_line,
462                            column: start_col,
463                            end_line,
464                            end_column: end_col,
465                            fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
466                        });
467                    }
468                }
469            }
470        }
471
472        Ok(warnings)
473    }
474
475    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
476        if self.should_skip(ctx) {
477            return Ok(ctx.content.to_string());
478        }
479        let warnings = self.check(ctx)?;
480        if warnings.is_empty() {
481            return Ok(ctx.content.to_string());
482        }
483        let warnings =
484            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
485        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
486    }
487
488    fn as_any(&self) -> &dyn std::any::Any {
489        self
490    }
491
492    crate::impl_rule_config_sections!(MD055Config);
493
494    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
495    where
496        Self: Sized,
497    {
498        let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
499        let style_explicit = option_is_explicit(config, "MD055", "style");
500
501        Box::new(Self {
502            config: rule_config,
503            style_explicit,
504        })
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_pipe_styles_preserve_escaped_trailing_cell_content() {
514        let source = "名 | B\n--- | ---\nleft | right \\|\n";
515        for (style, expected) in [
516            ("consistent", source),
517            ("no_leading_or_trailing", source),
518            ("leading_only", "| 名 | B\n| --- | ---\n| left | right \\|\n"),
519            ("trailing_only", "名 | B |\n--- | --- |\nleft | right \\| |\n"),
520            (
521                "leading_and_trailing",
522                "| 名 | B |\n| --- | --- |\n| left | right \\| |\n",
523            ),
524        ] {
525            let rule = MD055TablePipeStyle::new(style.to_string());
526            let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
527            assert_eq!(
528                rule.fix(&ctx).unwrap(),
529                expected,
530                "cell content must remain intact: {style}"
531            );
532            let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::Standard, None);
533            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
534            assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
535            let mut original_html = String::new();
536            let mut fixed_html = String::new();
537            let options = pulldown_cmark::Options::ENABLE_TABLES;
538            pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new_ext(source, options));
539            pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new_ext(expected, options));
540            assert_eq!(original_html, fixed_html, "rendered table cells must be preserved");
541        }
542    }
543
544    #[test]
545    fn test_mdg_adds_a_delimiter_after_an_escaped_cell_pipe() {
546        let rule = MD055TablePipeStyle::default();
547        let source = "Feature: Tables\n  Scenario: Values\n    Given rows\n      | A | B |\n      | --- | --- |\n      | left | right \\|\n";
548        let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::MDG, None);
549        let warnings = rule.check(&ctx).unwrap();
550        assert_eq!(warnings.len(), 1);
551        assert_eq!(warnings[0].line, 6);
552        let expected = "Feature: Tables\n  Scenario: Values\n    Given rows\n      | A | B |\n      | --- | --- |\n      | left | right \\| |\n";
553        assert_eq!(rule.fix(&ctx).unwrap(), expected);
554        let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::MDG, None);
555        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
556        assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
557    }
558
559    // === Issue #611: kebab-case config values ignored, fallback to leading-and-trailing ===
560    //
561    // All style names must work identically whether the user writes kebab-case
562    // (no-leading-or-trailing) or snake_case (no_leading_or_trailing) in config.
563
564    fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
565        let config: md055_config::MD055Config =
566            toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
567        MD055TablePipeStyle::from_config_struct(config)
568    }
569
570    #[test]
571    fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
572        let rule = rule_from_toml_style("no-leading-or-trailing");
573        let content = "A | B\n--- | ---\n1 | 2";
574        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575        let warnings = rule.check(&ctx).unwrap();
576        assert!(
577            warnings.is_empty(),
578            "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
579        );
580    }
581
582    #[test]
583    fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
584        let rule = rule_from_toml_style("no-leading-or-trailing");
585        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
586        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let warnings = rule.check(&ctx).unwrap();
588        assert_eq!(
589            warnings.len(),
590            3,
591            "no-leading-or-trailing should flag all 3 rows with pipes"
592        );
593        assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
594    }
595
596    #[test]
597    fn test_leading_only_kebab_accepts_conforming_table() {
598        let rule = rule_from_toml_style("leading-only");
599        let content = "| A | B\n|---|---\n| 1 | 2";
600        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let warnings = rule.check(&ctx).unwrap();
602        assert!(
603            warnings.is_empty(),
604            "leading-only should accept a leading-only table: {warnings:?}"
605        );
606    }
607
608    #[test]
609    fn test_trailing_only_kebab_accepts_conforming_table() {
610        let rule = rule_from_toml_style("trailing-only");
611        let content = "A | B |\n---|---|\n1 | 2 |";
612        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let warnings = rule.check(&ctx).unwrap();
614        assert!(
615            warnings.is_empty(),
616            "trailing-only should accept a trailing-only table: {warnings:?}"
617        );
618    }
619
620    #[test]
621    fn test_trailing_only_kebab_rejects_nonconforming_table() {
622        let rule = rule_from_toml_style("trailing-only");
623        // leading-and-trailing table must be flagged — proves the table is actually detected
624        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
625        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let warnings = rule.check(&ctx).unwrap();
627        assert_eq!(
628            warnings.len(),
629            3,
630            "trailing-only should flag all 3 rows that have leading pipes"
631        );
632        assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
633    }
634
635    #[test]
636    fn test_leading_only_kebab_rejects_nonconforming_table() {
637        let rule = rule_from_toml_style("leading-only");
638        // trailing-only table must be flagged — proves the table is actually detected
639        let content = "A | B |\n---|---|\n1 | 2 |";
640        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641        let warnings = rule.check(&ctx).unwrap();
642        assert_eq!(
643            warnings.len(),
644            3,
645            "leading-only should flag all 3 rows that have trailing pipes"
646        );
647        assert!(warnings.iter().all(|w| w.message.contains("leading only")));
648    }
649
650    #[test]
651    fn test_leading_and_trailing_kebab_accepts_conforming_table() {
652        let rule = rule_from_toml_style("leading-and-trailing");
653        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
654        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655        let warnings = rule.check(&ctx).unwrap();
656        assert!(
657            warnings.is_empty(),
658            "leading-and-trailing should accept a fully-piped table: {warnings:?}"
659        );
660    }
661
662    #[test]
663    fn test_kebab_and_snake_case_styles_are_equivalent() {
664        // For every style, kebab and snake_case forms must produce identical warnings —
665        // same count, same messages, same line numbers.
666        let pairs = [
667            ("no-leading-or-trailing", "no_leading_or_trailing"),
668            ("leading-only", "leading_only"),
669            ("trailing-only", "trailing_only"),
670            ("leading-and-trailing", "leading_and_trailing"),
671        ];
672        // Mixed table so every style produces at least one warning, exercising the message path.
673        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
674        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675
676        for (kebab, snake) in pairs {
677            let kebab_rule = rule_from_toml_style(kebab);
678            let snake_rule = rule_from_toml_style(snake);
679            let kebab_warnings = kebab_rule.check(&ctx).unwrap();
680            let snake_warnings = snake_rule.check(&ctx).unwrap();
681
682            assert_eq!(
683                kebab_warnings.len(),
684                snake_warnings.len(),
685                "'{kebab}' and '{snake}' must produce the same number of warnings"
686            );
687            for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
688                assert_eq!(
689                    kw.message, sw.message,
690                    "warning[{i}] message differs between '{kebab}' and '{snake}'"
691                );
692                assert_eq!(
693                    kw.line, sw.line,
694                    "warning[{i}] line differs between '{kebab}' and '{snake}'"
695                );
696            }
697        }
698    }
699
700    fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
701        let rule = rule_from_toml_style(style);
702        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let fixed = rule.fix(&ctx).unwrap();
704        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
705        let remaining = rule.check(&ctx2).unwrap();
706        assert!(
707            remaining.is_empty(),
708            "style '{style}': after fix(), check() should find 0 violations.\n\
709             Original: {content:?}\n\
710             Fixed:    {fixed:?}\n\
711             Remaining: {remaining:?}"
712        );
713    }
714
715    #[test]
716    fn test_roundtrip_kebab_no_leading_or_trailing() {
717        assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
718    }
719
720    #[test]
721    fn test_roundtrip_kebab_leading_and_trailing() {
722        assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
723    }
724
725    #[test]
726    fn test_roundtrip_kebab_leading_only() {
727        assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
728    }
729
730    #[test]
731    fn test_roundtrip_kebab_trailing_only() {
732        assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
733    }
734
735    #[test]
736    fn test_md055_delimiter_row_handling() {
737        // Test with no_leading_or_trailing style
738        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
739
740        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
741        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742        let result = rule.fix(&ctx).unwrap();
743
744        // With the fixed implementation, the delimiter row should have pipes removed
745        // Spacing is preserved from original input
746        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
747
748        assert_eq!(result, expected);
749
750        // Test that the check method actually reports the delimiter row as an issue
751        let warnings = rule.check(&ctx).unwrap();
752        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
753        assert_eq!(delimiter_warning.line, 2);
754        assert_eq!(
755            delimiter_warning.message,
756            "Table pipe style should be no leading or trailing"
757        );
758
759        // Test with leading_and_trailing style
760        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
761
762        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
763        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764        let result = rule.fix(&ctx).unwrap();
765
766        // The delimiter row should have pipes added
767        // Spacing is preserved from original input
768        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
769
770        assert_eq!(result, expected);
771    }
772
773    #[test]
774    fn test_md055_check_finds_delimiter_row_issues() {
775        // Test that check() correctly identifies delimiter rows that don't match style
776        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
777
778        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
779        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let warnings = rule.check(&ctx).unwrap();
781
782        // Should have 3 warnings - header row, delimiter row, and data row
783        assert_eq!(warnings.len(), 3);
784
785        // Specifically verify the delimiter row warning (line 2)
786        let delimiter_warning = &warnings[1];
787        assert_eq!(delimiter_warning.line, 2);
788        assert_eq!(
789            delimiter_warning.message,
790            "Table pipe style should be no leading or trailing"
791        );
792    }
793
794    #[test]
795    fn test_md055_real_world_example() {
796        // Test with a real-world example having content before and after the table
797        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
798
799        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.";
800        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.fix(&ctx).unwrap();
802
803        // The table should be fixed, with pipes removed
804        // Spacing is preserved from original input
805        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.";
806
807        assert_eq!(result, expected);
808
809        // Ensure we get warnings for all table rows
810        let warnings = rule.check(&ctx).unwrap();
811        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
812
813        // The line numbers should match the correct positions in the original content
814        assert_eq!(warnings[0].line, 5); // Header row
815        assert_eq!(warnings[1].line, 6); // Delimiter row
816        assert_eq!(warnings[2].line, 7); // Data row 1
817        assert_eq!(warnings[3].line, 8); // Data row 2
818    }
819
820    #[test]
821    fn test_md055_invalid_style() {
822        // Test with an invalid style setting
823        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
824
825        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
826        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827        let result = rule.fix(&ctx).unwrap();
828
829        // Should default to "leading_and_trailing"
830        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
831        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
832
833        assert_eq!(result, expected);
834
835        // Now check a content that needs actual modification
836        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
837        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838        let result = rule.fix(&ctx2).unwrap();
839
840        // Should add pipes to match the default "leading_and_trailing" style
841        // Spacing is preserved from original input
842        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
843        assert_eq!(result, expected);
844
845        // Check that warning messages also work with the fallback style
846        let warnings = rule.check(&ctx2).unwrap();
847
848        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
849        // there should be warnings for all rows
850        assert_eq!(warnings.len(), 3);
851    }
852
853    #[test]
854    fn test_underflow_protection() {
855        // Test case to ensure no underflow when parts is empty
856        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
857
858        // Test with empty string (edge case)
859        let result = rule.fix_table_row("", "leading_and_trailing");
860        assert_eq!(result, "");
861
862        // Test with string that doesn't contain pipes
863        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
864        assert_eq!(result, "no pipes here");
865
866        // Test with minimal pipe content
867        let result = rule.fix_table_row("|", "leading_and_trailing");
868        // Should not panic and should handle gracefully
869        assert!(!result.is_empty());
870    }
871
872    // === Issue #305: Blockquote table tests ===
873
874    #[test]
875    fn test_fix_table_row_in_blockquote() {
876        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
877
878        // Blockquote table without leading pipe
879        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
880        assert_eq!(result, "> | H1 | H2 |");
881
882        // Blockquote table that already has pipes
883        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
884        assert_eq!(result, "> | H1 | H2 |");
885
886        // Removing pipes from blockquote table
887        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
888        assert_eq!(result, "> H1 | H2");
889    }
890
891    #[test]
892    fn test_fix_table_row_in_nested_blockquote() {
893        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
894
895        // Double-nested blockquote
896        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
897        assert_eq!(result, ">> | H1 | H2 |");
898
899        // Triple-nested blockquote
900        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
901        assert_eq!(result, ">>> | H1 | H2 |");
902    }
903
904    #[test]
905    fn test_blockquote_table_full_document() {
906        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
907
908        // Full table in blockquote (2 columns, matching delimiter)
909        let content = "> H1 | H2\n> ----|----\n> a  | b";
910        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911        let result = rule.fix(&ctx).unwrap();
912
913        // Each line should have the blockquote prefix preserved and pipes added
914        // The leading_and_trailing style adds "| " after blockquote prefix
915        assert!(
916            result.starts_with("> |"),
917            "Header should start with blockquote + pipe. Got:\n{result}"
918        );
919        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
920        assert!(
921            result.contains("> | ----"),
922            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
923        );
924    }
925
926    #[test]
927    fn test_blockquote_table_no_leading_trailing() {
928        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
929
930        // Table with pipes that should be removed
931        let content = "> | H1 | H2 |\n> |----|----|---|\n> | a  | b |";
932        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.fix(&ctx).unwrap();
934
935        // Pipes should be removed but blockquote prefix preserved
936        let lines: Vec<&str> = result.lines().collect();
937        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
938        assert!(
939            !lines[0].starts_with("> |"),
940            "Leading pipe should be removed. Got: {}",
941            lines[0]
942        );
943    }
944
945    #[test]
946    fn test_mixed_regular_and_blockquote_tables() {
947        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
948
949        // Document with both regular and blockquote tables
950        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
951        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952        let result = rule.fix(&ctx).unwrap();
953
954        // Both tables should be fixed
955        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
956        assert!(
957            result.contains("> | H3 | H4 |"),
958            "Blockquote table should have pipes added with prefix preserved"
959        );
960    }
961
962    // === Roundtrip safety tests ===
963
964    fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
965        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let fixed = rule.fix(&ctx).unwrap();
967        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
968        let remaining = rule.check(&ctx2).unwrap();
969        assert!(
970            remaining.is_empty(),
971            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
972        );
973    }
974
975    #[test]
976    fn test_roundtrip_leading_and_trailing() {
977        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
978        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
979    }
980
981    #[test]
982    fn test_roundtrip_no_leading_or_trailing() {
983        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
984        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
985    }
986
987    #[test]
988    fn test_roundtrip_consistent_mode() {
989        let rule = MD055TablePipeStyle::default();
990        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
991    }
992
993    #[test]
994    fn test_roundtrip_blockquote_table() {
995        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
996        assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
997    }
998
999    #[test]
1000    fn test_roundtrip_mixed_tables() {
1001        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1002        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
1003    }
1004
1005    #[test]
1006    fn test_roundtrip_with_surrounding_content() {
1007        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1008        assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
1009    }
1010
1011    #[test]
1012    fn test_roundtrip_clean_content() {
1013        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1014        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1015    }
1016
1017    // === Pandoc construct reachability tests ===
1018    //
1019    // These tests document that MD055 does not flag Pandoc-specific constructs
1020    // (grid tables, multi-line tables, line blocks, pipe-table captions) because
1021    // `ctx.table_blocks` excludes them at the source:
1022    //
1023    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
1024    //   returns false and no `TableBlock` is created.
1025    // - Multi-line table separators (`----------`) have no `|`, same exclusion.
1026    // - Line blocks (`| First line`) end without `|`, so `is_potential_table_row`
1027    //   requires `valid_parts >= 2` but finds only 1 — excluded.
1028    // - Pipe-table captions (`: caption`) have no `|` — excluded.
1029    //
1030    // No production guard is needed. These tests ensure that if `find_table_blocks`
1031    // ever changes to include these constructs, the failure is visible.
1032
1033    #[test]
1034    fn md055_pandoc_grid_tables_not_flagged() {
1035        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1036        let content = "\
1037+---+---+
1038| a | b |
1039+===+===+
1040| 1 | 2 |
1041+---+---+
1042";
1043        // Under Pandoc: grid tables are excluded from table_blocks (delimiter rows
1044        // use `+` not `|`), so no warnings are emitted.
1045        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1046        let result = rule.check(&ctx).unwrap();
1047        assert!(
1048            result.is_empty(),
1049            "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1050        );
1051
1052        // Under Standard: same content also produces no warnings because the
1053        // `+---+---+` lines are not recognized as pipe-table delimiters.
1054        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055        let result_std = rule.check(&ctx_std).unwrap();
1056        assert!(
1057            result_std.is_empty(),
1058            "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
1059        );
1060    }
1061
1062    #[test]
1063    fn md055_pandoc_multi_line_tables_not_flagged() {
1064        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1065        // Multi-line table (Pandoc extension): separator line has no `|`.
1066        let content = "\
1067--------- ----------- ------
1068Header 1   Header 2   Header 3
1069--------- ----------- ------
1070Cell 1     Cell 2     Cell 3
1071--------- ----------- ------
1072";
1073        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1074        let result = rule.check(&ctx).unwrap();
1075        assert!(
1076            result.is_empty(),
1077            "MD055 should not flag Pandoc multi-line tables: {result:?}"
1078        );
1079
1080        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081        let result_std = rule.check(&ctx_std).unwrap();
1082        assert!(
1083            result_std.is_empty(),
1084            "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1085        );
1086    }
1087
1088    #[test]
1089    fn md055_pandoc_line_blocks_not_flagged() {
1090        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1091        // Pandoc line blocks: `| text` that starts with `|` but does not end with `|`.
1092        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped lines.
1093        let content = "| First line\n| Second line\n";
1094        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1095        let result = rule.check(&ctx).unwrap();
1096        assert!(
1097            result.is_empty(),
1098            "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1099        );
1100
1101        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102        let result_std = rule.check(&ctx_std).unwrap();
1103        assert!(
1104            result_std.is_empty(),
1105            "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
1106        );
1107    }
1108
1109    #[test]
1110    fn md055_pandoc_pipe_table_captions_not_flagged() {
1111        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1112        // Pipe-table captions (`: caption`) have no `|`, so they are never included
1113        // in table_blocks.
1114        let content = "\
1115| H1 | H2 |
1116|----|-----|
1117| a  | b  |
1118
1119: My table caption
1120";
1121        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1122        let result = rule.check(&ctx).unwrap();
1123        assert!(
1124            result.is_empty(),
1125            "MD055 should not flag the pipe-table caption line: {result:?}"
1126        );
1127
1128        // Under Standard: same table rows are correctly checked; caption line is ignored.
1129        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130        let result_std = rule.check(&ctx_std).unwrap();
1131        assert!(
1132            result_std.is_empty(),
1133            "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1134        );
1135    }
1136
1137    // === MDG: the Gherkin form is enforced over an incompatible style ===
1138    //
1139    // Gherkin recognizes a Data Table or Examples table row only when an indent
1140    // is followed directly by a pipe, so a style that strips the leading pipe
1141    // does not restyle the table, it deletes it from the document.
1142
1143    /// Each style a Gherkin document cannot carry, paired with the rows a table
1144    /// written in that style would contain.
1145    const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1146        (
1147            "no_leading_or_trailing",
1148            ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1149        ),
1150        (
1151            "leading_only",
1152            ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1153        ),
1154        (
1155            "trailing_only",
1156            ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1157        ),
1158    ];
1159
1160    const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1161
1162    fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1163        let spaces = " ".repeat(indent);
1164        let [header, delimiter, body] = rows;
1165        format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1166    }
1167
1168    #[test]
1169    fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1170        // Two to five whitespace characters are all Gherkin accepts before the
1171        // pipe, so the whole range has to survive the correction.
1172        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1173            let rule = MD055TablePipeStyle::new(style.to_string());
1174
1175            for indent in [2, 3, 4, 5] {
1176                let content = examples_table(indent, rows);
1177                let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1178                let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1179
1180                assert_eq!(
1181                    rule.check(&ctx).unwrap().len(),
1182                    3,
1183                    "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1184                );
1185
1186                let fixed = rule.fix(&ctx).unwrap();
1187                assert_eq!(
1188                    fixed, expected,
1189                    "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1190                );
1191
1192                let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1193                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1194                assert_eq!(
1195                    rule.fix(&fixed_ctx).unwrap(),
1196                    fixed,
1197                    "style '{style}' at indent {indent}: MDG fix must be idempotent"
1198                );
1199            }
1200        }
1201    }
1202
1203    #[test]
1204    fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1205        // The reproducing case: rows Gherkin already recognizes must survive
1206        // every style the user could have configured.
1207        let content = examples_table(2, MDG_GHERKIN_ROWS);
1208
1209        for style in [
1210            "consistent",
1211            "leading_and_trailing",
1212            "no_leading_or_trailing",
1213            "leading_only",
1214            "trailing_only",
1215        ] {
1216            let rule = MD055TablePipeStyle::new(style.to_string());
1217            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1218
1219            assert!(
1220                rule.check(&ctx).unwrap().is_empty(),
1221                "style '{style}': MDG enforces this form, so it cannot be reported"
1222            );
1223            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1224        }
1225    }
1226
1227    #[test]
1228    fn test_mdg_consistent_ignores_prevalence() {
1229        // `consistent` asks for no particular form, so it is never warned about;
1230        // MDG still resolves it to the Gherkin form rather than to whichever
1231        // form the table happens to be written in.
1232        let defaulted = MD055TablePipeStyle::default();
1233        assert_eq!(defaulted.config.style, "consistent");
1234        let explicit = MD055TablePipeStyle::new("consistent".to_string());
1235
1236        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1237            let content = examples_table(2, rows);
1238            let expected = examples_table(2, MDG_GHERKIN_ROWS);
1239
1240            for rule in [&defaulted, &explicit] {
1241                let standard_ctx =
1242                    crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1243                assert!(
1244                    rule.check(&standard_ctx).unwrap().is_empty(),
1245                    "Standard resolves `consistent` to the table's own '{style}'"
1246                );
1247
1248                let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1249                assert_eq!(
1250                    rule.fix(&mdg_ctx).unwrap(),
1251                    expected,
1252                    "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1253                );
1254            }
1255        }
1256    }
1257
1258    #[test]
1259    fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1260        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1261            let rule = MD055TablePipeStyle::new(style.to_string());
1262            let content = examples_table(2, rows);
1263            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1264
1265            assert!(
1266                rule.check(&ctx).unwrap().is_empty(),
1267                "style '{style}': Standard must still honour it"
1268            );
1269            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1270
1271            // And it is still applied to a table written the other way round.
1272            let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1273            let mdg_form_ctx =
1274                crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1275            assert_eq!(
1276                rule.check(&mdg_form_ctx).unwrap().len(),
1277                3,
1278                "style '{style}': Standard must still correct the leading-and-trailing form away"
1279            );
1280        }
1281
1282        // Preserving the indent is a Gherkin concession; Standard keeps
1283        // left-aligning the rows it rewrites.
1284        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1285        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1286        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1287        assert_eq!(
1288            rule.fix(&ctx).unwrap(),
1289            examples_table(0, MDG_GHERKIN_ROWS),
1290            "Standard must keep stripping the indent"
1291        );
1292    }
1293
1294    #[test]
1295    fn test_from_config_records_whether_style_was_configured() {
1296        // The MDG override applies either way, but the warning is only for a
1297        // style the user actually asked for, so a configured style has to be
1298        // told apart from a defaulted one.
1299        use crate::config::Config;
1300        use std::collections::BTreeMap;
1301
1302        let mut values = BTreeMap::new();
1303        values.insert(
1304            "style".to_string(),
1305            toml::Value::String("no_leading_or_trailing".to_string()),
1306        );
1307        let mut config = Config::default();
1308        config.rules.insert(
1309            "MD055".to_string(),
1310            crate::config::RuleConfig { severity: None, values },
1311        );
1312
1313        let configured = MD055TablePipeStyle::from_config(&config);
1314        let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1315        assert_eq!(configured.config.style, "no_leading_or_trailing");
1316        assert!(configured.style_explicit);
1317
1318        let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1319        let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1320        assert_eq!(defaulted.config.style, "consistent");
1321        assert!(!defaulted.style_explicit);
1322
1323        // The override does not depend on the warning: a defaulted incompatible
1324        // style is enforced just the same.
1325        let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1326            style: "no_leading_or_trailing".to_string(),
1327        });
1328        assert!(!unreported.style_explicit);
1329        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1330        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1331        assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1332    }
1333}