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 = trimmed.ends_with('|');
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    // === Issue #611: kebab-case config values ignored, fallback to leading-and-trailing ===
513    //
514    // All style names must work identically whether the user writes kebab-case
515    // (no-leading-or-trailing) or snake_case (no_leading_or_trailing) in config.
516
517    fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
518        let config: md055_config::MD055Config =
519            toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
520        MD055TablePipeStyle::from_config_struct(config)
521    }
522
523    #[test]
524    fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
525        let rule = rule_from_toml_style("no-leading-or-trailing");
526        let content = "A | B\n--- | ---\n1 | 2";
527        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let warnings = rule.check(&ctx).unwrap();
529        assert!(
530            warnings.is_empty(),
531            "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
532        );
533    }
534
535    #[test]
536    fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
537        let rule = rule_from_toml_style("no-leading-or-trailing");
538        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
539        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540        let warnings = rule.check(&ctx).unwrap();
541        assert_eq!(
542            warnings.len(),
543            3,
544            "no-leading-or-trailing should flag all 3 rows with pipes"
545        );
546        assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
547    }
548
549    #[test]
550    fn test_leading_only_kebab_accepts_conforming_table() {
551        let rule = rule_from_toml_style("leading-only");
552        let content = "| A | B\n|---|---\n| 1 | 2";
553        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let warnings = rule.check(&ctx).unwrap();
555        assert!(
556            warnings.is_empty(),
557            "leading-only should accept a leading-only table: {warnings:?}"
558        );
559    }
560
561    #[test]
562    fn test_trailing_only_kebab_accepts_conforming_table() {
563        let rule = rule_from_toml_style("trailing-only");
564        let content = "A | B |\n---|---|\n1 | 2 |";
565        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let warnings = rule.check(&ctx).unwrap();
567        assert!(
568            warnings.is_empty(),
569            "trailing-only should accept a trailing-only table: {warnings:?}"
570        );
571    }
572
573    #[test]
574    fn test_trailing_only_kebab_rejects_nonconforming_table() {
575        let rule = rule_from_toml_style("trailing-only");
576        // leading-and-trailing table must be flagged — proves the table is actually detected
577        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
578        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579        let warnings = rule.check(&ctx).unwrap();
580        assert_eq!(
581            warnings.len(),
582            3,
583            "trailing-only should flag all 3 rows that have leading pipes"
584        );
585        assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
586    }
587
588    #[test]
589    fn test_leading_only_kebab_rejects_nonconforming_table() {
590        let rule = rule_from_toml_style("leading-only");
591        // trailing-only table must be flagged — proves the table is actually detected
592        let content = "A | B |\n---|---|\n1 | 2 |";
593        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594        let warnings = rule.check(&ctx).unwrap();
595        assert_eq!(
596            warnings.len(),
597            3,
598            "leading-only should flag all 3 rows that have trailing pipes"
599        );
600        assert!(warnings.iter().all(|w| w.message.contains("leading only")));
601    }
602
603    #[test]
604    fn test_leading_and_trailing_kebab_accepts_conforming_table() {
605        let rule = rule_from_toml_style("leading-and-trailing");
606        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
607        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let warnings = rule.check(&ctx).unwrap();
609        assert!(
610            warnings.is_empty(),
611            "leading-and-trailing should accept a fully-piped table: {warnings:?}"
612        );
613    }
614
615    #[test]
616    fn test_kebab_and_snake_case_styles_are_equivalent() {
617        // For every style, kebab and snake_case forms must produce identical warnings —
618        // same count, same messages, same line numbers.
619        let pairs = [
620            ("no-leading-or-trailing", "no_leading_or_trailing"),
621            ("leading-only", "leading_only"),
622            ("trailing-only", "trailing_only"),
623            ("leading-and-trailing", "leading_and_trailing"),
624        ];
625        // Mixed table so every style produces at least one warning, exercising the message path.
626        let content = "| A | B |\n|---|---|\n| 1 | 2 |";
627        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628
629        for (kebab, snake) in pairs {
630            let kebab_rule = rule_from_toml_style(kebab);
631            let snake_rule = rule_from_toml_style(snake);
632            let kebab_warnings = kebab_rule.check(&ctx).unwrap();
633            let snake_warnings = snake_rule.check(&ctx).unwrap();
634
635            assert_eq!(
636                kebab_warnings.len(),
637                snake_warnings.len(),
638                "'{kebab}' and '{snake}' must produce the same number of warnings"
639            );
640            for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
641                assert_eq!(
642                    kw.message, sw.message,
643                    "warning[{i}] message differs between '{kebab}' and '{snake}'"
644                );
645                assert_eq!(
646                    kw.line, sw.line,
647                    "warning[{i}] line differs between '{kebab}' and '{snake}'"
648                );
649            }
650        }
651    }
652
653    fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
654        let rule = rule_from_toml_style(style);
655        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656        let fixed = rule.fix(&ctx).unwrap();
657        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
658        let remaining = rule.check(&ctx2).unwrap();
659        assert!(
660            remaining.is_empty(),
661            "style '{style}': after fix(), check() should find 0 violations.\n\
662             Original: {content:?}\n\
663             Fixed:    {fixed:?}\n\
664             Remaining: {remaining:?}"
665        );
666    }
667
668    #[test]
669    fn test_roundtrip_kebab_no_leading_or_trailing() {
670        assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
671    }
672
673    #[test]
674    fn test_roundtrip_kebab_leading_and_trailing() {
675        assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
676    }
677
678    #[test]
679    fn test_roundtrip_kebab_leading_only() {
680        assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
681    }
682
683    #[test]
684    fn test_roundtrip_kebab_trailing_only() {
685        assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
686    }
687
688    #[test]
689    fn test_md055_delimiter_row_handling() {
690        // Test with no_leading_or_trailing style
691        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
692
693        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
694        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let result = rule.fix(&ctx).unwrap();
696
697        // With the fixed implementation, the delimiter row should have pipes removed
698        // Spacing is preserved from original input
699        let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
700
701        assert_eq!(result, expected);
702
703        // Test that the check method actually reports the delimiter row as an issue
704        let warnings = rule.check(&ctx).unwrap();
705        let delimiter_warning = &warnings[1]; // Second warning should be for delimiter row
706        assert_eq!(delimiter_warning.line, 2);
707        assert_eq!(
708            delimiter_warning.message,
709            "Table pipe style should be no leading or trailing"
710        );
711
712        // Test with leading_and_trailing style
713        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
714
715        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
716        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.fix(&ctx).unwrap();
718
719        // The delimiter row should have pipes added
720        // Spacing is preserved from original input
721        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
722
723        assert_eq!(result, expected);
724    }
725
726    #[test]
727    fn test_md055_check_finds_delimiter_row_issues() {
728        // Test that check() correctly identifies delimiter rows that don't match style
729        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
730
731        let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
732        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let warnings = rule.check(&ctx).unwrap();
734
735        // Should have 3 warnings - header row, delimiter row, and data row
736        assert_eq!(warnings.len(), 3);
737
738        // Specifically verify the delimiter row warning (line 2)
739        let delimiter_warning = &warnings[1];
740        assert_eq!(delimiter_warning.line, 2);
741        assert_eq!(
742            delimiter_warning.message,
743            "Table pipe style should be no leading or trailing"
744        );
745    }
746
747    #[test]
748    fn test_md055_real_world_example() {
749        // Test with a real-world example having content before and after the table
750        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
751
752        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.";
753        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754        let result = rule.fix(&ctx).unwrap();
755
756        // The table should be fixed, with pipes removed
757        // Spacing is preserved from original input
758        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.";
759
760        assert_eq!(result, expected);
761
762        // Ensure we get warnings for all table rows
763        let warnings = rule.check(&ctx).unwrap();
764        assert_eq!(warnings.len(), 4); // All four table rows should have warnings
765
766        // The line numbers should match the correct positions in the original content
767        assert_eq!(warnings[0].line, 5); // Header row
768        assert_eq!(warnings[1].line, 6); // Delimiter row
769        assert_eq!(warnings[2].line, 7); // Data row 1
770        assert_eq!(warnings[3].line, 8); // Data row 2
771    }
772
773    #[test]
774    fn test_md055_invalid_style() {
775        // Test with an invalid style setting
776        let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); // Invalid style
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 result = rule.fix(&ctx).unwrap();
781
782        // Should default to "leading_and_trailing"
783        // Already has leading and trailing pipes, so no changes needed - spacing is preserved
784        let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1   | Data 2   | Data 3   |";
785
786        assert_eq!(result, expected);
787
788        // Now check a content that needs actual modification
789        let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1   | Data 2   | Data 3";
790        let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791        let result = rule.fix(&ctx2).unwrap();
792
793        // Should add pipes to match the default "leading_and_trailing" style
794        // Spacing is preserved from original input
795        let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1   | Data 2   | Data 3 |";
796        assert_eq!(result, expected);
797
798        // Check that warning messages also work with the fallback style
799        let warnings = rule.check(&ctx2).unwrap();
800
801        // Since content doesn't have leading/trailing pipes but defaults to "leading_and_trailing",
802        // there should be warnings for all rows
803        assert_eq!(warnings.len(), 3);
804    }
805
806    #[test]
807    fn test_underflow_protection() {
808        // Test case to ensure no underflow when parts is empty
809        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
810
811        // Test with empty string (edge case)
812        let result = rule.fix_table_row("", "leading_and_trailing");
813        assert_eq!(result, "");
814
815        // Test with string that doesn't contain pipes
816        let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
817        assert_eq!(result, "no pipes here");
818
819        // Test with minimal pipe content
820        let result = rule.fix_table_row("|", "leading_and_trailing");
821        // Should not panic and should handle gracefully
822        assert!(!result.is_empty());
823    }
824
825    // === Issue #305: Blockquote table tests ===
826
827    #[test]
828    fn test_fix_table_row_in_blockquote() {
829        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
830
831        // Blockquote table without leading pipe
832        let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
833        assert_eq!(result, "> | H1 | H2 |");
834
835        // Blockquote table that already has pipes
836        let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
837        assert_eq!(result, "> | H1 | H2 |");
838
839        // Removing pipes from blockquote table
840        let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
841        assert_eq!(result, "> H1 | H2");
842    }
843
844    #[test]
845    fn test_fix_table_row_in_nested_blockquote() {
846        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
847
848        // Double-nested blockquote
849        let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
850        assert_eq!(result, ">> | H1 | H2 |");
851
852        // Triple-nested blockquote
853        let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
854        assert_eq!(result, ">>> | H1 | H2 |");
855    }
856
857    #[test]
858    fn test_blockquote_table_full_document() {
859        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
860
861        // Full table in blockquote (2 columns, matching delimiter)
862        let content = "> H1 | H2\n> ----|----\n> a  | b";
863        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.fix(&ctx).unwrap();
865
866        // Each line should have the blockquote prefix preserved and pipes added
867        // The leading_and_trailing style adds "| " after blockquote prefix
868        assert!(
869            result.starts_with("> |"),
870            "Header should start with blockquote + pipe. Got:\n{result}"
871        );
872        // Delimiter row gets leading pipe added, so check for "> | ---" pattern
873        assert!(
874            result.contains("> | ----"),
875            "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
876        );
877    }
878
879    #[test]
880    fn test_blockquote_table_no_leading_trailing() {
881        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
882
883        // Table with pipes that should be removed
884        let content = "> | H1 | H2 |\n> |----|----|---|\n> | a  | b |";
885        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let result = rule.fix(&ctx).unwrap();
887
888        // Pipes should be removed but blockquote prefix preserved
889        let lines: Vec<&str> = result.lines().collect();
890        assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
891        assert!(
892            !lines[0].starts_with("> |"),
893            "Leading pipe should be removed. Got: {}",
894            lines[0]
895        );
896    }
897
898    #[test]
899    fn test_mixed_regular_and_blockquote_tables() {
900        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
901
902        // Document with both regular and blockquote tables
903        let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
904        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905        let result = rule.fix(&ctx).unwrap();
906
907        // Both tables should be fixed
908        assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
909        assert!(
910            result.contains("> | H3 | H4 |"),
911            "Blockquote table should have pipes added with prefix preserved"
912        );
913    }
914
915    // === Roundtrip safety tests ===
916
917    fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
918        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let fixed = rule.fix(&ctx).unwrap();
920        let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
921        let remaining = rule.check(&ctx2).unwrap();
922        assert!(
923            remaining.is_empty(),
924            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
925        );
926    }
927
928    #[test]
929    fn test_roundtrip_leading_and_trailing() {
930        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
931        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
932    }
933
934    #[test]
935    fn test_roundtrip_no_leading_or_trailing() {
936        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
937        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
938    }
939
940    #[test]
941    fn test_roundtrip_consistent_mode() {
942        let rule = MD055TablePipeStyle::default();
943        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
944    }
945
946    #[test]
947    fn test_roundtrip_blockquote_table() {
948        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
949        assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
950    }
951
952    #[test]
953    fn test_roundtrip_mixed_tables() {
954        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
955        assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
956    }
957
958    #[test]
959    fn test_roundtrip_with_surrounding_content() {
960        let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
961        assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
962    }
963
964    #[test]
965    fn test_roundtrip_clean_content() {
966        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
967        assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
968    }
969
970    // === Pandoc construct reachability tests ===
971    //
972    // These tests document that MD055 does not flag Pandoc-specific constructs
973    // (grid tables, multi-line tables, line blocks, pipe-table captions) because
974    // `ctx.table_blocks` excludes them at the source:
975    //
976    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
977    //   returns false and no `TableBlock` is created.
978    // - Multi-line table separators (`----------`) have no `|`, same exclusion.
979    // - Line blocks (`| First line`) end without `|`, so `is_potential_table_row`
980    //   requires `valid_parts >= 2` but finds only 1 — excluded.
981    // - Pipe-table captions (`: caption`) have no `|` — excluded.
982    //
983    // No production guard is needed. These tests ensure that if `find_table_blocks`
984    // ever changes to include these constructs, the failure is visible.
985
986    #[test]
987    fn md055_pandoc_grid_tables_not_flagged() {
988        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
989        let content = "\
990+---+---+
991| a | b |
992+===+===+
993| 1 | 2 |
994+---+---+
995";
996        // Under Pandoc: grid tables are excluded from table_blocks (delimiter rows
997        // use `+` not `|`), so no warnings are emitted.
998        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
999        let result = rule.check(&ctx).unwrap();
1000        assert!(
1001            result.is_empty(),
1002            "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1003        );
1004
1005        // Under Standard: same content also produces no warnings because the
1006        // `+---+---+` lines are not recognized as pipe-table delimiters.
1007        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result_std = rule.check(&ctx_std).unwrap();
1009        assert!(
1010            result_std.is_empty(),
1011            "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn md055_pandoc_multi_line_tables_not_flagged() {
1017        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1018        // Multi-line table (Pandoc extension): separator line has no `|`.
1019        let content = "\
1020--------- ----------- ------
1021Header 1   Header 2   Header 3
1022--------- ----------- ------
1023Cell 1     Cell 2     Cell 3
1024--------- ----------- ------
1025";
1026        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1027        let result = rule.check(&ctx).unwrap();
1028        assert!(
1029            result.is_empty(),
1030            "MD055 should not flag Pandoc multi-line tables: {result:?}"
1031        );
1032
1033        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034        let result_std = rule.check(&ctx_std).unwrap();
1035        assert!(
1036            result_std.is_empty(),
1037            "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn md055_pandoc_line_blocks_not_flagged() {
1043        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1044        // Pandoc line blocks: `| text` that starts with `|` but does not end with `|`.
1045        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped lines.
1046        let content = "| First line\n| Second line\n";
1047        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1048        let result = rule.check(&ctx).unwrap();
1049        assert!(
1050            result.is_empty(),
1051            "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1052        );
1053
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 treat line-block-like content as tables under Standard: {result_std:?}"
1059        );
1060    }
1061
1062    #[test]
1063    fn md055_pandoc_pipe_table_captions_not_flagged() {
1064        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1065        // Pipe-table captions (`: caption`) have no `|`, so they are never included
1066        // in table_blocks.
1067        let content = "\
1068| H1 | H2 |
1069|----|-----|
1070| a  | b  |
1071
1072: My table caption
1073";
1074        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1075        let result = rule.check(&ctx).unwrap();
1076        assert!(
1077            result.is_empty(),
1078            "MD055 should not flag the pipe-table caption line: {result:?}"
1079        );
1080
1081        // Under Standard: same table rows are correctly checked; caption line is ignored.
1082        let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083        let result_std = rule.check(&ctx_std).unwrap();
1084        assert!(
1085            result_std.is_empty(),
1086            "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1087        );
1088    }
1089
1090    // === MDG: the Gherkin form is enforced over an incompatible style ===
1091    //
1092    // Gherkin recognizes a Data Table or Examples table row only when an indent
1093    // is followed directly by a pipe, so a style that strips the leading pipe
1094    // does not restyle the table, it deletes it from the document.
1095
1096    /// Each style a Gherkin document cannot carry, paired with the rows a table
1097    /// written in that style would contain.
1098    const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1099        (
1100            "no_leading_or_trailing",
1101            ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1102        ),
1103        (
1104            "leading_only",
1105            ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1106        ),
1107        (
1108            "trailing_only",
1109            ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1110        ),
1111    ];
1112
1113    const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1114
1115    fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1116        let spaces = " ".repeat(indent);
1117        let [header, delimiter, body] = rows;
1118        format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1119    }
1120
1121    #[test]
1122    fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1123        // Two to five whitespace characters are all Gherkin accepts before the
1124        // pipe, so the whole range has to survive the correction.
1125        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1126            let rule = MD055TablePipeStyle::new(style.to_string());
1127
1128            for indent in [2, 3, 4, 5] {
1129                let content = examples_table(indent, rows);
1130                let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1131                let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1132
1133                assert_eq!(
1134                    rule.check(&ctx).unwrap().len(),
1135                    3,
1136                    "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1137                );
1138
1139                let fixed = rule.fix(&ctx).unwrap();
1140                assert_eq!(
1141                    fixed, expected,
1142                    "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1143                );
1144
1145                let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1146                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1147                assert_eq!(
1148                    rule.fix(&fixed_ctx).unwrap(),
1149                    fixed,
1150                    "style '{style}' at indent {indent}: MDG fix must be idempotent"
1151                );
1152            }
1153        }
1154    }
1155
1156    #[test]
1157    fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1158        // The reproducing case: rows Gherkin already recognizes must survive
1159        // every style the user could have configured.
1160        let content = examples_table(2, MDG_GHERKIN_ROWS);
1161
1162        for style in [
1163            "consistent",
1164            "leading_and_trailing",
1165            "no_leading_or_trailing",
1166            "leading_only",
1167            "trailing_only",
1168        ] {
1169            let rule = MD055TablePipeStyle::new(style.to_string());
1170            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1171
1172            assert!(
1173                rule.check(&ctx).unwrap().is_empty(),
1174                "style '{style}': MDG enforces this form, so it cannot be reported"
1175            );
1176            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1177        }
1178    }
1179
1180    #[test]
1181    fn test_mdg_consistent_ignores_prevalence() {
1182        // `consistent` asks for no particular form, so it is never warned about;
1183        // MDG still resolves it to the Gherkin form rather than to whichever
1184        // form the table happens to be written in.
1185        let defaulted = MD055TablePipeStyle::default();
1186        assert_eq!(defaulted.config.style, "consistent");
1187        let explicit = MD055TablePipeStyle::new("consistent".to_string());
1188
1189        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1190            let content = examples_table(2, rows);
1191            let expected = examples_table(2, MDG_GHERKIN_ROWS);
1192
1193            for rule in [&defaulted, &explicit] {
1194                let standard_ctx =
1195                    crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1196                assert!(
1197                    rule.check(&standard_ctx).unwrap().is_empty(),
1198                    "Standard resolves `consistent` to the table's own '{style}'"
1199                );
1200
1201                let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1202                assert_eq!(
1203                    rule.fix(&mdg_ctx).unwrap(),
1204                    expected,
1205                    "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1206                );
1207            }
1208        }
1209    }
1210
1211    #[test]
1212    fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1213        for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1214            let rule = MD055TablePipeStyle::new(style.to_string());
1215            let content = examples_table(2, rows);
1216            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1217
1218            assert!(
1219                rule.check(&ctx).unwrap().is_empty(),
1220                "style '{style}': Standard must still honour it"
1221            );
1222            assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1223
1224            // And it is still applied to a table written the other way round.
1225            let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1226            let mdg_form_ctx =
1227                crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1228            assert_eq!(
1229                rule.check(&mdg_form_ctx).unwrap().len(),
1230                3,
1231                "style '{style}': Standard must still correct the leading-and-trailing form away"
1232            );
1233        }
1234
1235        // Preserving the indent is a Gherkin concession; Standard keeps
1236        // left-aligning the rows it rewrites.
1237        let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1238        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1239        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1240        assert_eq!(
1241            rule.fix(&ctx).unwrap(),
1242            examples_table(0, MDG_GHERKIN_ROWS),
1243            "Standard must keep stripping the indent"
1244        );
1245    }
1246
1247    #[test]
1248    fn test_from_config_records_whether_style_was_configured() {
1249        // The MDG override applies either way, but the warning is only for a
1250        // style the user actually asked for, so a configured style has to be
1251        // told apart from a defaulted one.
1252        use crate::config::Config;
1253        use std::collections::BTreeMap;
1254
1255        let mut values = BTreeMap::new();
1256        values.insert(
1257            "style".to_string(),
1258            toml::Value::String("no_leading_or_trailing".to_string()),
1259        );
1260        let mut config = Config::default();
1261        config.rules.insert(
1262            "MD055".to_string(),
1263            crate::config::RuleConfig { severity: None, values },
1264        );
1265
1266        let configured = MD055TablePipeStyle::from_config(&config);
1267        let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1268        assert_eq!(configured.config.style, "no_leading_or_trailing");
1269        assert!(configured.style_explicit);
1270
1271        let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1272        let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1273        assert_eq!(defaulted.config.style, "consistent");
1274        assert!(!defaulted.style_explicit);
1275
1276        // The override does not depend on the warning: a defaulted incompatible
1277        // style is enforced just the same.
1278        let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1279            style: "no_leading_or_trailing".to_string(),
1280        });
1281        assert!(!unreported.style_explicit);
1282        let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1283        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1284        assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1285    }
1286}