Skip to main content

rumdl_lib/rules/
md075_orphaned_table_rows.rs

1use std::collections::HashSet;
2
3use super::md060_table_format::{MD060Config, MD060TableFormat};
4use crate::md013_line_length::MD013Config;
5use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::blockquote::strip_blockquote_prefix;
7use crate::utils::ensure_consistent_line_endings;
8use crate::utils::fix_utils::apply_warning_fixes;
9use crate::utils::table_utils::TableUtils;
10
11/// Rule MD075: Orphaned table rows / headerless tables
12///
13/// See [docs/md075.md](../../docs/md075.md) for full documentation and examples.
14///
15/// Detects two cases:
16/// 1. Pipe-delimited rows separated from a preceding table by blank lines (auto-fixable)
17/// 2. Standalone pipe-formatted rows without a table header/delimiter (warn only)
18#[derive(Clone)]
19pub struct MD075OrphanedTableRows {
20    md060_formatter: MD060TableFormat,
21}
22
23/// Represents a group of orphaned rows after a table (Case 1)
24struct OrphanedGroup {
25    /// Start line of the preceding table block (0-indexed)
26    table_start: usize,
27    /// End line of the preceding table block (0-indexed)
28    table_end: usize,
29    /// Expected table column count derived from the original table header
30    expected_columns: usize,
31    /// First blank line separating orphaned rows from the table
32    blank_start: usize,
33    /// Last blank line before the orphaned rows
34    blank_end: usize,
35    /// The orphaned row lines (0-indexed)
36    row_lines: Vec<usize>,
37}
38
39/// Represents standalone headerless pipe content (Case 2)
40struct HeaderlessGroup {
41    /// The first line of the group (0-indexed)
42    start_line: usize,
43    /// All lines in the group (0-indexed)
44    lines: Vec<usize>,
45}
46
47impl MD075OrphanedTableRows {
48    fn with_formatter(md060_formatter: MD060TableFormat) -> Self {
49        Self { md060_formatter }
50    }
51
52    /// Check if a line should be skipped (frontmatter, code block, HTML, ESM, mkdocstrings, math)
53    fn should_skip_line(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
54        if let Some(line_info) = ctx.lines.get(line_idx) {
55            line_info.in_front_matter
56                || line_info.in_code_block
57                || line_info.in_html_block
58                || line_info.in_html_comment
59                || line_info.in_mdx_comment
60                || line_info.in_esm_block
61                || line_info.in_mkdocstrings
62                || line_info.in_math_block
63        } else {
64            false
65        }
66    }
67
68    /// Check if a line is a potential table row, handling blockquote prefixes
69    fn is_table_row_line(&self, line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
70        let content = strip_blockquote_prefix(line);
71        TableUtils::is_potential_table_row_with_flavor(content, flavor)
72    }
73
74    /// Check if a line is a delimiter row, handling blockquote prefixes
75    fn is_delimiter_line(&self, line: &str) -> bool {
76        let content = strip_blockquote_prefix(line);
77        TableUtils::is_delimiter_row(content)
78    }
79
80    /// Check if a line is blank (including blockquote continuation lines like ">")
81    fn is_blank_line(line: &str) -> bool {
82        crate::utils::regex_cache::is_blank_in_blockquote_context(line)
83    }
84
85    /// Heuristic to detect templating syntax (Liquid/Jinja-style markers).
86    fn contains_template_marker(line: &str) -> bool {
87        let trimmed = line.trim();
88        trimmed.contains("{%")
89            || trimmed.contains("%}")
90            || trimmed.contains("{{")
91            || trimmed.contains("}}")
92            || trimmed.contains("{#")
93            || trimmed.contains("#}")
94    }
95
96    /// Detect lines that are pure template directives (e.g., `{% data ... %}`).
97    fn is_template_directive_line(line: &str) -> bool {
98        let trimmed = line.trim();
99        (trimmed.starts_with("{%")
100            || trimmed.starts_with("{%-")
101            || trimmed.starts_with("{{")
102            || trimmed.starts_with("{{-"))
103            && (trimmed.ends_with("%}")
104                || trimmed.ends_with("-%}")
105                || trimmed.ends_with("}}")
106                || trimmed.ends_with("-}}"))
107    }
108
109    /// Pipe-bearing lines with template markers are often generated fragments, not literal tables.
110    fn is_templated_pipe_line(line: &str) -> bool {
111        let content = strip_blockquote_prefix(line).trim();
112        content.contains('|') && Self::contains_template_marker(content)
113    }
114
115    /// Row-like line with pipes that is not itself a valid table row, often used
116    /// as an in-table section divider (for example: `Search||`).
117    fn is_sparse_table_row_hint(line: &str) -> bool {
118        let content = strip_blockquote_prefix(line).trim();
119        if content.is_empty()
120            || !content.contains('|')
121            || Self::contains_template_marker(content)
122            || TableUtils::is_delimiter_row(content)
123            || TableUtils::is_potential_table_row(content)
124        {
125            return false;
126        }
127
128        let has_edge_pipe = content.starts_with('|') || content.ends_with('|');
129        let has_repeated_pipe = content.contains("||");
130        let non_empty_parts = content.split('|').filter(|part| !part.trim().is_empty()).count();
131
132        non_empty_parts >= 1 && (has_edge_pipe || has_repeated_pipe)
133    }
134
135    /// Headerless groups after a sparse row that is itself inside a table context
136    /// are likely false positives caused by parser table-block boundaries.
137    fn preceded_by_sparse_table_context(content_lines: &[&str], start_line: usize) -> bool {
138        let mut idx = start_line;
139        while idx > 0 {
140            idx -= 1;
141            let content = strip_blockquote_prefix(content_lines[idx]).trim();
142            if content.is_empty() {
143                continue;
144            }
145
146            if !Self::is_sparse_table_row_hint(content) {
147                return false;
148            }
149
150            let mut scan = idx;
151            while scan > 0 {
152                scan -= 1;
153                let prev = strip_blockquote_prefix(content_lines[scan]).trim();
154                if prev.is_empty() {
155                    break;
156                }
157                if TableUtils::is_delimiter_row(prev) {
158                    return true;
159                }
160            }
161
162            return false;
163        }
164
165        false
166    }
167
168    /// Headerless rows immediately following a template directive are likely generated table fragments.
169    fn preceded_by_template_directive(content_lines: &[&str], start_line: usize) -> bool {
170        let mut idx = start_line;
171        while idx > 0 {
172            idx -= 1;
173            let content = strip_blockquote_prefix(content_lines[idx]).trim();
174            if content.is_empty() {
175                continue;
176            }
177
178            return Self::is_template_directive_line(content);
179        }
180
181        false
182    }
183
184    /// Count visual indentation width where tab is treated as 4 spaces.
185    fn indentation_width(line: &str) -> usize {
186        let mut width = 0;
187        for b in line.bytes() {
188            match b {
189                b' ' => width += 1,
190                b'\t' => width += 4,
191                _ => break,
192            }
193        }
194        width
195    }
196
197    /// Count blockquote nesting depth for context matching.
198    fn blockquote_depth(line: &str) -> usize {
199        let (prefix, _) = TableUtils::extract_blockquote_prefix(line);
200        prefix.bytes().filter(|&b| b == b'>').count()
201    }
202
203    /// Check whether a Case 1 candidate has at least one outer table pipe.
204    ///
205    /// An outer pipe preserves supported table styles while excluding bare pipe-delimited
206    /// prose that the formatter could otherwise merge into the preceding table.
207    fn has_outer_table_pipe(line: &str) -> bool {
208        let candidate = strip_blockquote_prefix(line).trim();
209        candidate.starts_with('|') || candidate.ends_with('|')
210    }
211
212    /// Ensure candidate orphan rows are in the same render context as the table.
213    ///
214    /// This prevents removing blank lines across boundaries where merging is invalid,
215    /// such as table -> blockquote row transitions or list-context changes.
216    fn row_matches_table_context(
217        &self,
218        table_block: &crate::utils::table_utils::TableBlock,
219        content_lines: &[&str],
220        row_idx: usize,
221    ) -> bool {
222        let table_start_line = content_lines[table_block.start_line];
223        let candidate_line = content_lines[row_idx];
224
225        if Self::blockquote_depth(table_start_line) != Self::blockquote_depth(candidate_line) {
226            return false;
227        }
228
229        let (_, candidate_after_blockquote) = TableUtils::extract_blockquote_prefix(candidate_line);
230        let (candidate_list_prefix, _, _) = TableUtils::extract_list_prefix(candidate_after_blockquote);
231        let candidate_indent = Self::indentation_width(candidate_after_blockquote);
232
233        if let Some(list_ctx) = &table_block.list_context {
234            // Table continuation rows in lists must stay continuation rows, not new list items.
235            if !candidate_list_prefix.is_empty() {
236                return false;
237            }
238            candidate_indent >= list_ctx.content_indent && candidate_indent < list_ctx.content_indent + 4
239        } else {
240            // Avoid crossing into list/code contexts for non-list tables.
241            candidate_list_prefix.is_empty() && candidate_indent < 4
242        }
243    }
244
245    /// Detect Case 1: Orphaned rows after existing tables
246    fn detect_orphaned_rows(
247        &self,
248        ctx: &crate::lint_context::LintContext,
249        content_lines: &[&str],
250        table_line_set: &HashSet<usize>,
251    ) -> Vec<OrphanedGroup> {
252        let mut groups = Vec::new();
253
254        for table_block in &ctx.table_blocks {
255            let end = table_block.end_line;
256            let header_content =
257                TableUtils::extract_table_row_content(content_lines[table_block.start_line], table_block, 0);
258            let expected_columns = TableUtils::count_cells_with_flavor(header_content, ctx.flavor);
259
260            // Scan past end of table for blank lines followed by pipe rows
261            let mut i = end + 1;
262            let mut blank_start = None;
263            let mut blank_end = None;
264
265            // Find blank lines after the table
266            while i < content_lines.len() {
267                if self.should_skip_line(ctx, i) {
268                    break;
269                }
270                if Self::is_blank_line(content_lines[i]) {
271                    if blank_start.is_none() {
272                        blank_start = Some(i);
273                    }
274                    blank_end = Some(i);
275                    i += 1;
276                } else {
277                    break;
278                }
279            }
280
281            // If no blank lines found, no orphan scenario
282            let (Some(bs), Some(be)) = (blank_start, blank_end) else {
283                continue;
284            };
285
286            // Now check if the lines after the blanks are pipe rows not in any table
287            let mut orphan_rows = Vec::new();
288            let mut j = be + 1;
289            while j < content_lines.len() {
290                if self.should_skip_line(ctx, j) {
291                    break;
292                }
293                if table_line_set.contains(&j) {
294                    break;
295                }
296                if Self::has_outer_table_pipe(content_lines[j])
297                    && self.is_table_row_line(content_lines[j], ctx.flavor)
298                    && self.row_matches_table_context(table_block, content_lines, j)
299                {
300                    orphan_rows.push(j);
301                    j += 1;
302                } else {
303                    break;
304                }
305            }
306
307            if !orphan_rows.is_empty() {
308                groups.push(OrphanedGroup {
309                    table_start: table_block.start_line,
310                    table_end: table_block.end_line,
311                    expected_columns,
312                    blank_start: bs,
313                    blank_end: be,
314                    row_lines: orphan_rows,
315                });
316            }
317        }
318
319        groups
320    }
321
322    /// Detect pipe rows that directly continue a parsed table block but may not be
323    /// recognized by `table_blocks` (for example rows with inline fence markers).
324    ///
325    /// These rows should not be treated as standalone headerless tables (Case 2).
326    fn detect_table_continuation_rows(
327        &self,
328        ctx: &crate::lint_context::LintContext,
329        content_lines: &[&str],
330        table_line_set: &HashSet<usize>,
331    ) -> HashSet<usize> {
332        let mut continuation_rows = HashSet::new();
333
334        for table_block in &ctx.table_blocks {
335            let mut i = table_block.end_line + 1;
336            while i < content_lines.len() {
337                if self.should_skip_line(ctx, i) || table_line_set.contains(&i) {
338                    break;
339                }
340                if self.is_table_row_line(content_lines[i], ctx.flavor)
341                    && self.row_matches_table_context(table_block, content_lines, i)
342                {
343                    continuation_rows.insert(i);
344                    i += 1;
345                } else {
346                    break;
347                }
348            }
349        }
350
351        continuation_rows
352    }
353
354    /// Detect Case 2: Standalone headerless pipe content
355    fn detect_headerless_tables(
356        &self,
357        ctx: &crate::lint_context::LintContext,
358        content_lines: &[&str],
359        table_line_set: &HashSet<usize>,
360        orphaned_line_set: &HashSet<usize>,
361        continuation_line_set: &HashSet<usize>,
362    ) -> Vec<HeaderlessGroup> {
363        if self.is_probable_headerless_fragment_file(ctx, content_lines) {
364            return Vec::new();
365        }
366
367        let mut groups = Vec::new();
368        let mut i = 0;
369
370        while i < content_lines.len() {
371            // Skip lines in skip contexts, existing tables, or orphaned groups
372            if self.should_skip_line(ctx, i)
373                || table_line_set.contains(&i)
374                || orphaned_line_set.contains(&i)
375                || continuation_line_set.contains(&i)
376            {
377                i += 1;
378                continue;
379            }
380
381            // Look for consecutive pipe rows
382            if self.is_table_row_line(content_lines[i], ctx.flavor) {
383                if Self::is_templated_pipe_line(content_lines[i]) {
384                    i += 1;
385                    continue;
386                }
387
388                // Suppress headerless detection for likely template-generated table fragments.
389                if Self::preceded_by_template_directive(content_lines, i) {
390                    i += 1;
391                    while i < content_lines.len()
392                        && !self.should_skip_line(ctx, i)
393                        && !table_line_set.contains(&i)
394                        && !orphaned_line_set.contains(&i)
395                        && !continuation_line_set.contains(&i)
396                        && self.is_table_row_line(content_lines[i], ctx.flavor)
397                    {
398                        i += 1;
399                    }
400                    continue;
401                }
402
403                // Suppress headerless detection for rows that likely continue an
404                // existing table through sparse section-divider rows.
405                if Self::preceded_by_sparse_table_context(content_lines, i) {
406                    i += 1;
407                    while i < content_lines.len()
408                        && !self.should_skip_line(ctx, i)
409                        && !table_line_set.contains(&i)
410                        && !orphaned_line_set.contains(&i)
411                        && !continuation_line_set.contains(&i)
412                        && self.is_table_row_line(content_lines[i], ctx.flavor)
413                    {
414                        i += 1;
415                    }
416                    continue;
417                }
418
419                let start = i;
420                let mut group_lines = vec![i];
421                i += 1;
422
423                while i < content_lines.len()
424                    && !self.should_skip_line(ctx, i)
425                    && !table_line_set.contains(&i)
426                    && !orphaned_line_set.contains(&i)
427                    && !continuation_line_set.contains(&i)
428                    && self.is_table_row_line(content_lines[i], ctx.flavor)
429                {
430                    if Self::is_templated_pipe_line(content_lines[i]) {
431                        break;
432                    }
433                    group_lines.push(i);
434                    i += 1;
435                }
436
437                // Need at least 2 consecutive pipe rows to flag
438                if group_lines.len() >= 2 {
439                    // Check that none of these lines is a delimiter row that would make
440                    // them a valid table header+delimiter combination
441                    let has_delimiter = group_lines
442                        .iter()
443                        .any(|&idx| self.is_delimiter_line(content_lines[idx]));
444
445                    if !has_delimiter {
446                        // Verify consistent column count
447                        let first_content = strip_blockquote_prefix(content_lines[group_lines[0]]);
448                        let first_count = TableUtils::count_cells_with_flavor(first_content, ctx.flavor);
449                        let consistent = group_lines.iter().all(|&idx| {
450                            let content = strip_blockquote_prefix(content_lines[idx]);
451                            TableUtils::count_cells_with_flavor(content, ctx.flavor) == first_count
452                        });
453
454                        if consistent && first_count > 0 {
455                            groups.push(HeaderlessGroup {
456                                start_line: start,
457                                lines: group_lines,
458                            });
459                        }
460                    }
461                }
462            } else {
463                i += 1;
464            }
465        }
466
467        groups
468    }
469
470    /// Some repositories store reusable table-row snippets as standalone files
471    /// (headerless by design). Suppress Case 2 warnings for those fragment files.
472    fn is_probable_headerless_fragment_file(
473        &self,
474        ctx: &crate::lint_context::LintContext,
475        content_lines: &[&str],
476    ) -> bool {
477        if !ctx.table_blocks.is_empty() {
478            return false;
479        }
480
481        let mut row_count = 0usize;
482
483        for (idx, line) in content_lines.iter().enumerate() {
484            if self.should_skip_line(ctx, idx) {
485                continue;
486            }
487
488            let content = strip_blockquote_prefix(line).trim();
489            if content.is_empty() {
490                continue;
491            }
492
493            if Self::is_template_directive_line(content) {
494                continue;
495            }
496
497            if TableUtils::is_delimiter_row(content) {
498                return false;
499            }
500
501            // Allow inline template gate rows like `| {% ifversion ... %} |`.
502            if Self::contains_template_marker(content) && content.contains('|') {
503                continue;
504            }
505
506            if self.is_table_row_line(content, ctx.flavor) {
507                let cols = TableUtils::count_cells_with_flavor(content, ctx.flavor);
508                // Require 3+ columns to avoid suppressing common 2-column headerless issues.
509                if cols < 3 {
510                    return false;
511                }
512                row_count += 1;
513                continue;
514            }
515
516            return false;
517        }
518
519        row_count >= 2
520    }
521
522    /// Build fix edit for a single orphaned-row group by replacing the local table block.
523    fn build_orphan_group_fix(
524        &self,
525        ctx: &crate::lint_context::LintContext,
526        content_lines: &[&str],
527        group: &OrphanedGroup,
528    ) -> Result<Option<Fix>, LintError> {
529        if group.row_lines.is_empty() {
530            return Ok(None);
531        }
532
533        let last_orphan = *group
534            .row_lines
535            .last()
536            .expect("row_lines is non-empty after early return");
537
538        // Be conservative: only auto-merge when orphan rows match original table width.
539        let has_column_mismatch = group
540            .row_lines
541            .iter()
542            .any(|&idx| TableUtils::count_cells_with_flavor(content_lines[idx], ctx.flavor) != group.expected_columns);
543        if has_column_mismatch {
544            return Ok(None);
545        }
546
547        let replacement_range = ctx.line_span_byte_range(group.table_start + 1, last_orphan + 1);
548        let original_block = &ctx.content[replacement_range.clone()];
549        let block_has_trailing_newline = original_block.ends_with('\n');
550
551        let mut merged_table_lines: Vec<&str> = (group.table_start..=group.table_end)
552            .map(|idx| content_lines[idx])
553            .collect();
554        merged_table_lines.extend(group.row_lines.iter().map(|&idx| content_lines[idx]));
555
556        let mut merged_block = merged_table_lines.join("\n");
557        if block_has_trailing_newline {
558            merged_block.push('\n');
559        }
560
561        let block_ctx = crate::lint_context::LintContext::new(&merged_block, ctx.flavor, None);
562        let mut normalized_block = self.md060_formatter.fix(&block_ctx)?;
563
564        if !block_has_trailing_newline {
565            normalized_block = normalized_block.trim_end_matches('\n').to_string();
566        } else if !normalized_block.ends_with('\n') {
567            normalized_block.push('\n');
568        }
569
570        let replacement = ensure_consistent_line_endings(original_block, &normalized_block);
571
572        if replacement == original_block {
573            Ok(None)
574        } else {
575            Ok(Some(Fix::new(replacement_range, replacement)))
576        }
577    }
578}
579
580impl Default for MD075OrphanedTableRows {
581    fn default() -> Self {
582        Self {
583            // MD075 should normalize merged rows even when MD060 is not explicitly enabled.
584            md060_formatter: MD060TableFormat::new(true, "aligned".to_string()),
585        }
586    }
587}
588
589impl Rule for MD075OrphanedTableRows {
590    fn name(&self) -> &'static str {
591        "MD075"
592    }
593
594    fn description(&self) -> &'static str {
595        "Orphaned table rows or headerless pipe content"
596    }
597
598    fn category(&self) -> RuleCategory {
599        RuleCategory::Table
600    }
601
602    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
603        // Need at least 2 pipe characters for two minimal rows like:
604        // a | b
605        // c | d
606        ctx.char_count('|') < 2
607    }
608
609    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
610        let content_lines = ctx.raw_lines();
611        let mut warnings = Vec::new();
612
613        // Build set of all lines belonging to existing table blocks
614        let mut table_line_set = HashSet::new();
615        for table_block in &ctx.table_blocks {
616            for line_idx in table_block.start_line..=table_block.end_line {
617                table_line_set.insert(line_idx);
618            }
619        }
620
621        // Case 1: Orphaned rows after tables
622        let orphaned_groups = self.detect_orphaned_rows(ctx, content_lines, &table_line_set);
623        let orphan_group_fixes: Vec<Option<Fix>> = orphaned_groups
624            .iter()
625            .map(|group| self.build_orphan_group_fix(ctx, content_lines, group))
626            .collect::<Result<Vec<_>, _>>()?;
627        let mut orphaned_line_set = HashSet::new();
628        for group in &orphaned_groups {
629            for &line_idx in &group.row_lines {
630                orphaned_line_set.insert(line_idx);
631            }
632            // Also mark blank lines as part of the orphan group for dedup
633            for line_idx in group.blank_start..=group.blank_end {
634                orphaned_line_set.insert(line_idx);
635            }
636        }
637        let continuation_line_set = self.detect_table_continuation_rows(ctx, content_lines, &table_line_set);
638
639        for (group, group_fix) in orphaned_groups.iter().zip(orphan_group_fixes.iter()) {
640            let first_orphan = group.row_lines[0];
641            let last_orphan = *group.row_lines.last().unwrap();
642            let num_blanks = group.blank_end - group.blank_start + 1;
643
644            warnings.push(LintWarning {
645                rule_name: Some(self.name().to_string()),
646                message: format!("Orphaned table row(s) separated from preceding table by {num_blanks} blank line(s)"),
647                line: first_orphan + 1,
648                column: 1,
649                end_line: last_orphan + 1,
650                end_column: content_lines[last_orphan].chars().count() + 1,
651                severity: Severity::Warning,
652                fix: group_fix.clone(),
653            });
654        }
655
656        // Case 2: Headerless pipe content
657        let headerless_groups = self.detect_headerless_tables(
658            ctx,
659            content_lines,
660            &table_line_set,
661            &orphaned_line_set,
662            &continuation_line_set,
663        );
664
665        for group in &headerless_groups {
666            let start = group.start_line;
667            let end = *group.lines.last().unwrap();
668
669            warnings.push(LintWarning {
670                rule_name: Some(self.name().to_string()),
671                message: "Pipe-formatted rows without a table header/delimiter row".to_string(),
672                line: start + 1,
673                column: 1,
674                end_line: end + 1,
675                end_column: content_lines[end].chars().count() + 1,
676                severity: Severity::Warning,
677                fix: None,
678            });
679        }
680
681        Ok(warnings)
682    }
683
684    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
685        let warnings = self.check(ctx)?;
686        let warnings =
687            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
688        if warnings.iter().all(|warning| warning.fix.is_none()) {
689            return Ok(ctx.content.to_string());
690        }
691
692        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
693    }
694
695    fn fix_capability(&self) -> FixCapability {
696        FixCapability::ConditionallyFixable
697    }
698
699    fn as_any(&self) -> &dyn std::any::Any {
700        self
701    }
702
703    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
704    where
705        Self: Sized,
706    {
707        let mut md060_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
708        if md060_config.style == "any" {
709            // MD075 should normalize merged tables by default; "any" preserves broken alignment.
710            md060_config.style = "aligned".to_string();
711        }
712        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
713        let md013_disabled = config
714            .global
715            .disable
716            .iter()
717            .chain(config.global.extend_disable.iter())
718            .any(|rule| rule.trim().eq_ignore_ascii_case("MD013"));
719        let formatter = MD060TableFormat::from_config_struct(md060_config, md013_config, md013_disabled);
720        Box::new(Self::with_formatter(formatter))
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use proptest::prelude::*;
727
728    use super::*;
729    use crate::config::MarkdownFlavor;
730    use crate::lint_context::LintContext;
731    use crate::utils::fix_utils::apply_warning_fixes;
732
733    // =========================================================================
734    // Case 1: Orphaned rows after a table
735    // =========================================================================
736
737    #[test]
738    fn test_case_1_requires_at_least_one_outer_pipe_all_flavors() {
739        let rule = MD075OrphanedTableRows::default();
740        let prose = "\
741# Test
742
743| A | B |
744| - | - |
745| x | y |
746
747Noise low|med|high.\n";
748        let orphan = "\
749| Value        | Description       |
750| ------------ | ----------------- |
751| `consistent` | Default style     |
752
753| `fenced`     | Fenced style      |
754| `indented`   | Indented style    |";
755        let expected = "\
756| Value        | Description       |
757| ------------ | ----------------- |
758| `consistent` | Default style     |
759| `fenced`     | Fenced style      |
760| `indented`   | Indented style    |";
761        let leading_only = "\
762| A | B
763| - | -
764| x | y
765
766| c | d";
767        let trailing_only = "\
768A | B |
769- | - |
770x | y |
771
772c | d |";
773
774        for flavor in all_flavors() {
775            let prose_ctx = LintContext::new(prose, flavor, None);
776            assert!(
777                rule.check(&prose_ctx).unwrap().is_empty(),
778                "Pipe-bearing prose without outer pipes must not be treated as an orphaned row for {}",
779                flavor.name()
780            );
781
782            let orphan_ctx = LintContext::new(orphan, flavor, None);
783            let warnings = rule.check(&orphan_ctx).unwrap();
784            assert_eq!(
785                warnings.len(),
786                1,
787                "Outer-pipe orphan rows must warn for {}",
788                flavor.name()
789            );
790            assert!(
791                warnings[0].fix.is_some(),
792                "Outer-pipe orphan rows must remain fixable for {}",
793                flavor.name()
794            );
795            assert_eq!(
796                rule.fix(&orphan_ctx).unwrap(),
797                expected,
798                "Outer-pipe orphan rows must fix correctly for {}",
799                flavor.name()
800            );
801
802            for (style, content) in [("leading-only", leading_only), ("trailing-only", trailing_only)] {
803                let ctx = LintContext::new(content, flavor, None);
804                let warnings = rule.check(&ctx).unwrap();
805                assert_eq!(warnings.len(), 1, "{style} orphan rows must warn for {}", flavor.name());
806                assert!(
807                    warnings[0].fix.is_some(),
808                    "{style} orphan rows must remain fixable for {}",
809                    flavor.name()
810                );
811            }
812        }
813    }
814
815    /// A row with no outer pipe is out of reach even when it genuinely lost its table.
816    ///
817    /// The row is byte-for-byte indistinguishable from a prose sentence containing a
818    /// pipe, so reporting it costs a false positive on prose and, when the cell count
819    /// matches, an automatic fix that merges the paragraph into the table. Losing this
820    /// detection is the deliberate price of that safety, and loosening the gate to
821    /// recover it reopens the whole prose class.
822    #[test]
823    fn test_bare_orphan_row_after_bare_table_is_deliberately_not_reported_all_flavors() {
824        let rule = MD075OrphanedTableRows::default();
825        let content = "\
826A | B
827--- | ---
828x | y
829
830p | q
831";
832
833        for flavor in all_flavors() {
834            let ctx = LintContext::new(content, flavor, None);
835            assert!(
836                rule.check(&ctx).unwrap().is_empty(),
837                "A bare orphan row must stay unreported for {}",
838                flavor.name()
839            );
840            assert_eq!(
841                rule.fix(&ctx).unwrap(),
842                content,
843                "A bare orphan row must be left byte-for-byte unchanged for {}",
844                flavor.name()
845            );
846        }
847    }
848
849    #[test]
850    fn test_two_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
851        let rule = MD075OrphanedTableRows::default();
852        let content = "\
853| A | B |
854| - | - |
855| x | y |
856
857Either ship it|or do not.\n";
858
859        for flavor in all_flavors() {
860            let ctx = LintContext::new(content, flavor, None);
861            assert!(
862                rule.check(&ctx).unwrap().is_empty(),
863                "Two-cell prose without outer pipes must not be reported for {}",
864                flavor.name()
865            );
866            assert_eq!(
867                rule.fix(&ctx).unwrap(),
868                content,
869                "Two-cell prose must remain byte-for-byte unchanged for {}",
870                flavor.name()
871            );
872        }
873    }
874
875    #[test]
876    fn test_three_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
877        let rule = MD075OrphanedTableRows::default();
878        let content = "\
879| A | B | C |
880| - | - | - |
881| x | y | z |
882
883Noise low|med|high.\n";
884
885        for flavor in all_flavors() {
886            let ctx = LintContext::new(content, flavor, None);
887            assert!(
888                rule.check(&ctx).unwrap().is_empty(),
889                "Three-cell prose without outer pipes must not be reported for {}",
890                flavor.name()
891            );
892            assert_eq!(
893                rule.fix(&ctx).unwrap(),
894                content,
895                "Three-cell prose must remain byte-for-byte unchanged for {}",
896                flavor.name()
897            );
898        }
899    }
900
901    #[test]
902    fn test_orphaned_rows_after_table() {
903        let rule = MD075OrphanedTableRows::default();
904        let content = "\
905| Value        | Description       |
906| ------------ | ----------------- |
907| `consistent` | Default style     |
908
909| `fenced`     | Fenced style      |
910| `indented`   | Indented style    |";
911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912        let result = rule.check(&ctx).unwrap();
913
914        assert_eq!(result.len(), 1);
915        assert!(result[0].message.contains("Orphaned table row"));
916        assert!(result[0].fix.is_some());
917    }
918
919    #[test]
920    fn test_orphaned_single_row_after_table() {
921        let rule = MD075OrphanedTableRows::default();
922        let content = "\
923| H1 | H2 |
924|----|-----|
925| a  | b   |
926
927| c  | d   |";
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929        let result = rule.check(&ctx).unwrap();
930
931        assert_eq!(result.len(), 1);
932        assert!(result[0].message.contains("Orphaned table row"));
933    }
934
935    #[test]
936    fn test_orphaned_rows_multiple_blank_lines() {
937        let rule = MD075OrphanedTableRows::default();
938        let content = "\
939| H1 | H2 |
940|----|-----|
941| a  | b   |
942
943
944| c  | d   |
945| e  | f   |";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let result = rule.check(&ctx).unwrap();
948
949        assert_eq!(result.len(), 1);
950        assert!(result[0].message.contains("2 blank line(s)"));
951    }
952
953    #[test]
954    fn test_fix_orphaned_rows() {
955        let rule = MD075OrphanedTableRows::default();
956        let content = "\
957| Value        | Description       |
958| ------------ | ----------------- |
959| `consistent` | Default style     |
960
961| `fenced`     | Fenced style      |
962| `indented`   | Indented style    |";
963        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964        let fixed = rule.fix(&ctx).unwrap();
965
966        let expected = "\
967| Value        | Description       |
968| ------------ | ----------------- |
969| `consistent` | Default style     |
970| `fenced`     | Fenced style      |
971| `indented`   | Indented style    |";
972        assert_eq!(fixed, expected);
973    }
974
975    #[test]
976    fn test_fix_orphaned_rows_multiple_blanks() {
977        let rule = MD075OrphanedTableRows::default();
978        let content = "\
979| H1 | H2 |
980|----|-----|
981| a  | b   |
982
983
984| c  | d   |";
985        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986        let fixed = rule.fix(&ctx).unwrap();
987
988        let expected = "\
989| H1  | H2  |
990| --- | --- |
991| a   | b   |
992| c   | d   |";
993        assert_eq!(fixed, expected);
994    }
995
996    #[test]
997    fn test_no_orphan_with_text_between() {
998        let rule = MD075OrphanedTableRows::default();
999        let content = "\
1000| H1 | H2 |
1001|----|-----|
1002| a  | b   |
1003
1004Some text here.
1005
1006| c  | d   |
1007| e  | f   |";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009        let result = rule.check(&ctx).unwrap();
1010
1011        // Non-blank content between table and pipe rows means not orphaned
1012        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1013        assert_eq!(orphan_warnings.len(), 0);
1014    }
1015
1016    #[test]
1017    fn test_valid_consecutive_tables_not_flagged() {
1018        let rule = MD075OrphanedTableRows::default();
1019        let content = "\
1020| H1 | H2 |
1021|----|-----|
1022| a  | b   |
1023
1024| H3 | H4 |
1025|----|-----|
1026| c  | d   |";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028        let result = rule.check(&ctx).unwrap();
1029
1030        // Two valid tables separated by a blank line produce no warnings
1031        assert_eq!(result.len(), 0);
1032    }
1033
1034    #[test]
1035    fn test_orphaned_rows_with_different_column_count() {
1036        let rule = MD075OrphanedTableRows::default();
1037        let content = "\
1038| H1 | H2 | H3 |
1039|----|-----|-----|
1040| a  | b   | c   |
1041
1042| d  | e   |";
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044        let result = rule.check(&ctx).unwrap();
1045
1046        // Different column count should still flag as orphaned
1047        assert_eq!(result.len(), 1);
1048        assert!(result[0].message.contains("Orphaned"));
1049        assert!(result[0].fix.is_none());
1050    }
1051
1052    // =========================================================================
1053    // Case 2: Headerless pipe content
1054    // =========================================================================
1055
1056    #[test]
1057    fn test_headerless_pipe_content() {
1058        let rule = MD075OrphanedTableRows::default();
1059        let content = "\
1060Some text.
1061
1062| value1 | description1 |
1063| value2 | description2 |
1064
1065More text.";
1066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067        let result = rule.check(&ctx).unwrap();
1068
1069        assert_eq!(result.len(), 1);
1070        assert!(result[0].message.contains("without a table header"));
1071        assert!(result[0].fix.is_none());
1072    }
1073
1074    #[test]
1075    fn test_single_pipe_row_not_flagged() {
1076        let rule = MD075OrphanedTableRows::default();
1077        let content = "\
1078Some text.
1079
1080| value1 | description1 |
1081
1082More text.";
1083        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084        let result = rule.check(&ctx).unwrap();
1085
1086        // Single standalone pipe row is not flagged (Case 2 requires 2+)
1087        assert_eq!(result.len(), 0);
1088    }
1089
1090    #[test]
1091    fn test_headerless_multiple_rows() {
1092        let rule = MD075OrphanedTableRows::default();
1093        let content = "\
1094| a | b |
1095| c | d |
1096| e | f |";
1097        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098        let result = rule.check(&ctx).unwrap();
1099
1100        assert_eq!(result.len(), 1);
1101        assert!(result[0].message.contains("without a table header"));
1102    }
1103
1104    #[test]
1105    fn test_headerless_inconsistent_columns_not_flagged() {
1106        let rule = MD075OrphanedTableRows::default();
1107        let content = "\
1108| a | b |
1109| c | d | e |";
1110        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111        let result = rule.check(&ctx).unwrap();
1112
1113        // Inconsistent column count is not flagged as headerless table
1114        assert_eq!(result.len(), 0);
1115    }
1116
1117    #[test]
1118    fn test_headerless_not_flagged_when_has_delimiter() {
1119        let rule = MD075OrphanedTableRows::default();
1120        let content = "\
1121| H1 | H2 |
1122|----|-----|
1123| a  | b   |";
1124        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125        let result = rule.check(&ctx).unwrap();
1126
1127        // Valid table with header/delimiter produces no warnings
1128        assert_eq!(result.len(), 0);
1129    }
1130
1131    // =========================================================================
1132    // Edge cases
1133    // =========================================================================
1134
1135    #[test]
1136    fn test_pipe_rows_in_code_block_ignored() {
1137        let rule = MD075OrphanedTableRows::default();
1138        let content = "\
1139```
1140| a | b |
1141| c | d |
1142```";
1143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144        let result = rule.check(&ctx).unwrap();
1145
1146        assert_eq!(result.len(), 0);
1147    }
1148
1149    #[test]
1150    fn test_pipe_rows_in_frontmatter_ignored() {
1151        let rule = MD075OrphanedTableRows::default();
1152        let content = "\
1153---
1154title: test
1155---
1156
1157| a | b |
1158| c | d |";
1159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160        let result = rule.check(&ctx).unwrap();
1161
1162        // Frontmatter is skipped, standalone pipe rows after it are flagged
1163        let warnings: Vec<_> = result
1164            .iter()
1165            .filter(|w| w.message.contains("without a table header"))
1166            .collect();
1167        assert_eq!(warnings.len(), 1);
1168    }
1169
1170    #[test]
1171    fn test_no_pipes_at_all() {
1172        let rule = MD075OrphanedTableRows::default();
1173        let content = "Just regular text.\nNo pipes here.\nOnly paragraphs.";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176
1177        assert_eq!(result.len(), 0);
1178    }
1179
1180    #[test]
1181    fn test_empty_content() {
1182        let rule = MD075OrphanedTableRows::default();
1183        let content = "";
1184        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185        let result = rule.check(&ctx).unwrap();
1186
1187        assert_eq!(result.len(), 0);
1188    }
1189
1190    #[test]
1191    fn test_orphaned_rows_in_blockquote() {
1192        let rule = MD075OrphanedTableRows::default();
1193        let content = "\
1194> | H1 | H2 |
1195> |----|-----|
1196> | a  | b   |
1197>
1198> | c  | d   |";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let result = rule.check(&ctx).unwrap();
1201
1202        assert_eq!(result.len(), 1);
1203        assert!(result[0].message.contains("Orphaned"));
1204    }
1205
1206    #[test]
1207    fn test_fix_orphaned_rows_in_blockquote() {
1208        let rule = MD075OrphanedTableRows::default();
1209        let content = "\
1210> | H1 | H2 |
1211> |----|-----|
1212> | a  | b   |
1213>
1214> | c  | d   |";
1215        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216        let fixed = rule.fix(&ctx).unwrap();
1217
1218        let expected = "\
1219> | H1  | H2  |
1220> | --- | --- |
1221> | a   | b   |
1222> | c   | d   |";
1223        assert_eq!(fixed, expected);
1224    }
1225
1226    #[test]
1227    fn test_table_at_end_of_document_no_orphans() {
1228        let rule = MD075OrphanedTableRows::default();
1229        let content = "\
1230| H1 | H2 |
1231|----|-----|
1232| a  | b   |";
1233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234        let result = rule.check(&ctx).unwrap();
1235
1236        assert_eq!(result.len(), 0);
1237    }
1238
1239    #[test]
1240    fn test_table_followed_by_text_no_orphans() {
1241        let rule = MD075OrphanedTableRows::default();
1242        let content = "\
1243| H1 | H2 |
1244|----|-----|
1245| a  | b   |
1246
1247Some text after the table.";
1248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249        let result = rule.check(&ctx).unwrap();
1250
1251        assert_eq!(result.len(), 0);
1252    }
1253
1254    #[test]
1255    fn test_fix_preserves_content_around_orphans() {
1256        let rule = MD075OrphanedTableRows::default();
1257        let content = "\
1258# Title
1259
1260| H1 | H2 |
1261|----|-----|
1262| a  | b   |
1263
1264| c  | d   |
1265
1266Some text after.";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let fixed = rule.fix(&ctx).unwrap();
1269
1270        let expected = "\
1271# Title
1272
1273| H1  | H2  |
1274| --- | --- |
1275| a   | b   |
1276| c   | d   |
1277
1278Some text after.";
1279        assert_eq!(fixed, expected);
1280    }
1281
1282    #[test]
1283    fn test_multiple_orphan_groups() {
1284        let rule = MD075OrphanedTableRows::default();
1285        let content = "\
1286| H1 | H2 |
1287|----|-----|
1288| a  | b   |
1289
1290| c  | d   |
1291
1292| H3 | H4 |
1293|----|-----|
1294| e  | f   |
1295
1296| g  | h   |";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299
1300        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1301        assert_eq!(orphan_warnings.len(), 2);
1302    }
1303
1304    #[test]
1305    fn test_fix_multiple_orphan_groups() {
1306        let rule = MD075OrphanedTableRows::default();
1307        let content = "\
1308| H1 | H2 |
1309|----|-----|
1310| a  | b   |
1311
1312| c  | d   |
1313
1314| H3 | H4 |
1315|----|-----|
1316| e  | f   |
1317
1318| g  | h   |";
1319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320        let fixed = rule.fix(&ctx).unwrap();
1321
1322        let expected = "\
1323| H1  | H2  |
1324| --- | --- |
1325| a   | b   |
1326| c   | d   |
1327
1328| H3  | H4  |
1329| --- | --- |
1330| e   | f   |
1331| g   | h   |";
1332        assert_eq!(fixed, expected);
1333    }
1334
1335    #[test]
1336    fn test_orphaned_rows_with_delimiter_form_new_table() {
1337        let rule = MD075OrphanedTableRows::default();
1338        // Rows after a blank that themselves form a valid table (header+delimiter)
1339        // are recognized as a separate table by table_blocks, not as orphans
1340        let content = "\
1341| H1 | H2 |
1342|----|-----|
1343| a  | b   |
1344
1345| c  | d   |
1346|----|-----|";
1347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348        let result = rule.check(&ctx).unwrap();
1349
1350        // The second group forms a valid table, so no orphan warning
1351        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1352        assert_eq!(orphan_warnings.len(), 0);
1353    }
1354
1355    #[test]
1356    fn test_headerless_not_confused_with_orphaned() {
1357        let rule = MD075OrphanedTableRows::default();
1358        let content = "\
1359| H1 | H2 |
1360|----|-----|
1361| a  | b   |
1362
1363Some text.
1364
1365| c  | d   |
1366| e  | f   |";
1367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368        let result = rule.check(&ctx).unwrap();
1369
1370        // Non-blank content between table and pipe rows means not orphaned
1371        // The standalone rows should be flagged as headerless (Case 2)
1372        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1373        let headerless_warnings: Vec<_> = result
1374            .iter()
1375            .filter(|w| w.message.contains("without a table header"))
1376            .collect();
1377
1378        assert_eq!(orphan_warnings.len(), 0);
1379        assert_eq!(headerless_warnings.len(), 1);
1380    }
1381
1382    #[test]
1383    fn test_fix_does_not_modify_headerless() {
1384        let rule = MD075OrphanedTableRows::default();
1385        let content = "\
1386Some text.
1387
1388| value1 | description1 |
1389| value2 | description2 |
1390
1391More text.";
1392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393        let fixed = rule.fix(&ctx).unwrap();
1394
1395        // Case 2 has no fix, so content should be unchanged
1396        assert_eq!(fixed, content);
1397    }
1398
1399    #[test]
1400    fn test_should_skip_few_pipes() {
1401        let rule = MD075OrphanedTableRows::default();
1402        let content = "a | b";
1403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405        assert!(rule.should_skip(&ctx));
1406    }
1407
1408    #[test]
1409    fn test_should_not_skip_two_pipes_without_outer_pipes() {
1410        let rule = MD075OrphanedTableRows::default();
1411        let content = "\
1412a | b
1413c | d";
1414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415
1416        assert!(!rule.should_skip(&ctx));
1417        let result = rule.check(&ctx).unwrap();
1418        assert_eq!(result.len(), 1);
1419        assert!(result[0].message.contains("without a table header"));
1420    }
1421
1422    #[test]
1423    fn test_fix_capability() {
1424        let rule = MD075OrphanedTableRows::default();
1425        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1426    }
1427
1428    #[test]
1429    fn test_category() {
1430        let rule = MD075OrphanedTableRows::default();
1431        assert_eq!(rule.category(), RuleCategory::Table);
1432    }
1433
1434    #[test]
1435    fn test_issue_420_exact_example() {
1436        // The exact example from issue #420, including inline code fence markers.
1437        let rule = MD075OrphanedTableRows::default();
1438        let content = "\
1439| Value        | Description                                       |
1440| ------------ | ------------------------------------------------- |
1441| `consistent` | All code blocks must use the same style (default) |
1442
1443| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1444| `indented` | All code blocks must use indented style (4 spaces) |";
1445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446        let result = rule.check(&ctx).unwrap();
1447
1448        assert_eq!(result.len(), 1);
1449        assert!(result[0].message.contains("Orphaned"));
1450        assert_eq!(result[0].line, 5);
1451
1452        let fixed = rule.fix(&ctx).unwrap();
1453        let expected = "\
1454| Value        | Description                                        |
1455| ------------ | -------------------------------------------------- |
1456| `consistent` | All code blocks must use the same style (default)  |
1457| `fenced`     | All code blocks must use fenced style (``` or ~~~) |
1458| `indented`   | All code blocks must use indented style (4 spaces) |";
1459        assert_eq!(fixed, expected);
1460    }
1461
1462    #[test]
1463    fn test_display_math_block_with_pipes_not_flagged() {
1464        let rule = MD075OrphanedTableRows::default();
1465        let content = "# Math\n\n$$\n|A| + |B| = |A \\cup B|\n|A| + |B| = |A \\cup B|\n$$\n";
1466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467        let result = rule.check(&ctx).unwrap();
1468
1469        assert!(
1470            result.is_empty(),
1471            "Pipes inside display math blocks should not trigger MD075"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_math_absolute_value_bars_not_flagged() {
1477        let rule = MD075OrphanedTableRows::default();
1478        let content = "\
1479# Math
1480
1481Roughly (for privacy reasons, this isn't exactly what the student said),
1482the student talked about having done small cases on the size $|S|$,
1483and figuring out that $|S|$ was even, but then running out of ideas.";
1484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485        let result = rule.check(&ctx).unwrap();
1486
1487        assert!(result.is_empty(), "Math absolute value bars should not trigger MD075");
1488    }
1489
1490    #[test]
1491    fn test_prose_with_double_backticks_and_pipes_not_flagged() {
1492        let rule = MD075OrphanedTableRows::default();
1493        let content = "\
1494Use ``a|b`` or ``c|d`` in docs.
1495Prefer ``x|y`` and ``z|w`` examples.";
1496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497        let result = rule.check(&ctx).unwrap();
1498
1499        assert!(result.is_empty());
1500    }
1501
1502    #[test]
1503    fn test_liquid_filter_lines_not_flagged_as_headerless() {
1504        let rule = MD075OrphanedTableRows::default();
1505        let content = "\
1506If you encounter issues, see [Troubleshooting]({{ '/docs/troubleshooting/' | relative_url }}).
1507Use our [guides]({{ '/docs/installation/' | relative_url }}) for OS-specific steps.";
1508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509        let result = rule.check(&ctx).unwrap();
1510
1511        assert!(result.is_empty());
1512    }
1513
1514    #[test]
1515    fn test_rows_after_template_directive_not_flagged_as_headerless() {
1516        let rule = MD075OrphanedTableRows::default();
1517        let content = "\
1518{% data reusables.enterprise-migration-tool.placeholder-table %}
1519DESTINATION | The name you want the new organization to have.
1520ENTERPRISE | The slug for your destination enterprise.";
1521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522        let result = rule.check(&ctx).unwrap();
1523
1524        assert!(result.is_empty());
1525    }
1526
1527    #[test]
1528    fn test_templated_pipe_rows_not_flagged_as_headerless() {
1529        let rule = MD075OrphanedTableRows::default();
1530        let content = "\
1531| Feature{%- for version in group_versions %} | {{ version }}{%- endfor %} |
1532|:----{%- for version in group_versions %}|:----:{%- endfor %}|
1533| {{ feature }} | {{ value }} |";
1534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535        let result = rule.check(&ctx).unwrap();
1536
1537        assert!(result.is_empty());
1538    }
1539
1540    #[test]
1541    fn test_escaped_pipe_rows_in_table_not_flagged_as_headerless() {
1542        let rule = MD075OrphanedTableRows::default();
1543        let content = "\
1544Written as                             | Interpreted as
1545---------------------------------------|-----------------------------------------
1546`!foo && bar`                          | `(!foo) && bar`
1547<code>!foo \\|\\| bar </code>            | `(!foo) \\|\\| bar`
1548<code>foo \\|\\| bar && baz </code>      | <code>foo \\|\\| (bar && baz)</code>
1549<code>!foo && bar \\|\\| baz </code>     | <code>(!foo && bar) \\|\\| baz</code>";
1550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551        let result = rule.check(&ctx).unwrap();
1552
1553        assert!(result.is_empty());
1554    }
1555
1556    #[test]
1557    fn test_rows_after_sparse_section_row_in_table_not_flagged() {
1558        let rule = MD075OrphanedTableRows::default();
1559        let content = "\
1560Key|Command|Command id
1561---|-------|----------
1562Search||
1563`kb(history.showNext)`|Next Search Term|`history.showNext`
1564`kb(history.showPrevious)`|Previous Search Term|`history.showPrevious`
1565Extensions||
1566`unassigned`|Update All Extensions|`workbench.extensions.action.updateAllExtensions`";
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let result = rule.check(&ctx).unwrap();
1569
1570        assert!(result.is_empty());
1571    }
1572
1573    #[test]
1574    fn test_sparse_row_without_table_context_does_not_suppress_headerless() {
1575        let rule = MD075OrphanedTableRows::default();
1576        let content = "\
1577Notes ||
1578`alpha` | `beta`
1579`gamma` | `delta`";
1580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581        let result = rule.check(&ctx).unwrap();
1582
1583        assert_eq!(result.len(), 1);
1584        assert!(result[0].message.contains("without a table header"));
1585    }
1586
1587    #[test]
1588    fn test_reusable_three_column_fragment_not_flagged_as_headerless() {
1589        let rule = MD075OrphanedTableRows::default();
1590        let content = "\
1591`label` | `object` | The label added or removed from the issue.
1592`label[name]` | `string` | The name of the label.
1593`label[color]` | `string` | The hex color code.";
1594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595        let result = rule.check(&ctx).unwrap();
1596
1597        assert!(result.is_empty());
1598    }
1599
1600    #[test]
1601    fn test_orphan_detection_does_not_cross_blockquote_context() {
1602        let rule = MD075OrphanedTableRows::default();
1603        let content = "\
1604| H1 | H2 |
1605|----|-----|
1606| a  | b   |
1607
1608> | c  | d   |";
1609        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1610        let result = rule.check(&ctx).unwrap();
1611        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1612
1613        assert_eq!(orphan_warnings.len(), 0);
1614        assert_eq!(rule.fix(&ctx).unwrap(), content);
1615    }
1616
1617    #[test]
1618    fn test_orphan_fix_does_not_cross_list_context() {
1619        let rule = MD075OrphanedTableRows::default();
1620        let content = "\
1621- | H1 | H2 |
1622  |----|-----|
1623  | a  | b   |
1624
1625| c  | d   |";
1626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627        let result = rule.check(&ctx).unwrap();
1628        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1629
1630        assert_eq!(orphan_warnings.len(), 0);
1631        assert_eq!(rule.fix(&ctx).unwrap(), content);
1632    }
1633
1634    #[test]
1635    fn test_fix_normalizes_only_merged_table() {
1636        let rule = MD075OrphanedTableRows::default();
1637        let content = "\
1638| H1 | H2 |
1639|----|-----|
1640| a  | b   |
1641
1642| c  | d   |
1643
1644| Name | Age |
1645|---|---|
1646|alice|30|";
1647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648        let fixed = rule.fix(&ctx).unwrap();
1649
1650        assert!(fixed.contains("| H1  | H2  |"));
1651        assert!(fixed.contains("| c   | d   |"));
1652        // Unrelated second table should keep original compact formatting.
1653        assert!(fixed.contains("|---|---|"));
1654        assert!(fixed.contains("|alice|30|"));
1655    }
1656
1657    #[test]
1658    fn test_html_comment_pipe_rows_ignored() {
1659        let rule = MD075OrphanedTableRows::default();
1660        let content = "\
1661<!--
1662| a | b |
1663| c | d |
1664-->";
1665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666        let result = rule.check(&ctx).unwrap();
1667
1668        assert_eq!(result.len(), 0);
1669    }
1670
1671    #[test]
1672    fn test_orphan_detection_does_not_cross_skip_contexts() {
1673        let rule = MD075OrphanedTableRows::default();
1674        let content = "\
1675| H1 | H2 |
1676|----|-----|
1677| a  | b   |
1678
1679```
1680| c  | d   |
1681```";
1682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683        let result = rule.check(&ctx).unwrap();
1684
1685        // Pipe rows inside code block should not be flagged as orphaned
1686        assert_eq!(result.len(), 0);
1687    }
1688
1689    #[test]
1690    fn test_pipe_rows_in_esm_block_ignored() {
1691        let rule = MD075OrphanedTableRows::default();
1692        // ESM blocks use import/export statements; pipe rows inside should be skipped
1693        let content = "\
1694<script type=\"module\">
1695| a | b |
1696| c | d |
1697</script>";
1698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1699        let result = rule.check(&ctx).unwrap();
1700
1701        // All pipe rows are inside an HTML/ESM block, no warnings expected
1702        assert_eq!(result.len(), 0);
1703    }
1704
1705    #[test]
1706    fn test_fix_range_covers_blank_lines_correctly() {
1707        let rule = MD075OrphanedTableRows::default();
1708        let content = "\
1709# Before
1710
1711| H1 | H2 |
1712|----|-----|
1713| a  | b   |
1714
1715| c  | d   |
1716
1717# After";
1718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719        let warnings = rule.check(&ctx).unwrap();
1720        let expected = "\
1721# Before
1722
1723| H1  | H2  |
1724| --- | --- |
1725| a   | b   |
1726| c   | d   |
1727
1728# After";
1729
1730        assert_eq!(warnings.len(), 1);
1731        let fix = warnings[0].fix.as_ref().unwrap();
1732        assert!(fix.range.start > 0);
1733        assert!(fix.range.end < content.len());
1734
1735        let cli_fixed = rule.fix(&ctx).unwrap();
1736        assert_eq!(cli_fixed, expected);
1737
1738        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1739        assert_eq!(lsp_fixed, expected);
1740        assert_eq!(lsp_fixed, cli_fixed);
1741    }
1742
1743    #[test]
1744    fn test_fix_range_multiple_blanks() {
1745        let rule = MD075OrphanedTableRows::default();
1746        let content = "\
1747# Before
1748
1749| H1 | H2 |
1750|----|-----|
1751| a  | b   |
1752
1753
1754| c  | d   |";
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1756        let warnings = rule.check(&ctx).unwrap();
1757        let expected = "\
1758# Before
1759
1760| H1  | H2  |
1761| --- | --- |
1762| a   | b   |
1763| c   | d   |";
1764
1765        assert_eq!(warnings.len(), 1);
1766        let fix = warnings[0].fix.as_ref().unwrap();
1767        assert!(fix.range.start > 0);
1768        assert_eq!(fix.range.end, content.len());
1769
1770        let cli_fixed = rule.fix(&ctx).unwrap();
1771        assert_eq!(cli_fixed, expected);
1772
1773        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1774        assert_eq!(lsp_fixed, expected);
1775        assert_eq!(lsp_fixed, cli_fixed);
1776    }
1777
1778    #[test]
1779    fn test_warning_fixes_match_rule_fix_for_multiple_orphan_groups() {
1780        let rule = MD075OrphanedTableRows::default();
1781        let content = "\
1782| H1 | H2 |
1783|----|-----|
1784| a  | b   |
1785
1786| c  | d   |
1787
1788| H3 | H4 |
1789|----|-----|
1790| e  | f   |
1791
1792| g  | h   |";
1793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1794        let warnings = rule.check(&ctx).unwrap();
1795
1796        let orphan_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Orphaned")).collect();
1797        assert_eq!(orphan_warnings.len(), 2);
1798
1799        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1800        let cli_fixed = rule.fix(&ctx).unwrap();
1801
1802        assert_eq!(lsp_fixed, cli_fixed);
1803        assert_ne!(cli_fixed, content);
1804    }
1805
1806    #[test]
1807    fn test_issue_420_fix_is_idempotent() {
1808        let rule = MD075OrphanedTableRows::default();
1809        let content = "\
1810| Value        | Description                                       |
1811| ------------ | ------------------------------------------------- |
1812| `consistent` | All code blocks must use the same style (default) |
1813
1814| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1815| `indented` | All code blocks must use indented style (4 spaces) |";
1816
1817        let initial_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818        let fixed_once = rule.fix(&initial_ctx).unwrap();
1819
1820        let fixed_ctx = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1821        let warnings_after_fix = rule.check(&fixed_ctx).unwrap();
1822        assert_eq!(warnings_after_fix.len(), 0);
1823
1824        let fixed_twice = rule.fix(&fixed_ctx).unwrap();
1825        assert_eq!(fixed_twice, fixed_once);
1826    }
1827
1828    #[test]
1829    fn test_from_config_respects_md060_compact_style_for_merged_table() {
1830        let mut config = crate::config::Config::default();
1831        let mut md060_rule_config = crate::config::RuleConfig::default();
1832        md060_rule_config
1833            .values
1834            .insert("style".to_string(), toml::Value::String("compact".to_string()));
1835        config.rules.insert("MD060".to_string(), md060_rule_config);
1836
1837        let rule = <MD075OrphanedTableRows as Rule>::from_config(&config);
1838        let content = "\
1839| H1 | H2 |
1840|----|-----|
1841| long value | b |
1842
1843| c | d |";
1844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845        let fixed = rule.fix(&ctx).unwrap();
1846
1847        let expected = "\
1848| H1 | H2 |
1849| ---- | ----- |
1850| long value | b |
1851| c | d |";
1852        assert_eq!(fixed, expected);
1853    }
1854
1855    #[test]
1856    fn test_from_config_honors_extend_disable_for_md013_case_insensitive() {
1857        let mut config_enabled = crate::config::Config::default();
1858
1859        let mut md060_rule_config = crate::config::RuleConfig::default();
1860        md060_rule_config
1861            .values
1862            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
1863        config_enabled.rules.insert("MD060".to_string(), md060_rule_config);
1864
1865        let mut md013_rule_config = crate::config::RuleConfig::default();
1866        md013_rule_config
1867            .values
1868            .insert("line-length".to_string(), toml::Value::Integer(40));
1869        md013_rule_config
1870            .values
1871            .insert("tables".to_string(), toml::Value::Boolean(true));
1872        config_enabled.rules.insert("MD013".to_string(), md013_rule_config);
1873
1874        let mut config_disabled = config_enabled.clone();
1875        config_disabled.global.extend_disable.push("md013".to_string());
1876
1877        let rule_enabled = <MD075OrphanedTableRows as Rule>::from_config(&config_enabled);
1878        let rule_disabled = <MD075OrphanedTableRows as Rule>::from_config(&config_disabled);
1879
1880        let content = "\
1881| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |
1882|---|---|---|
1883| data | data | data |
1884
1885| more | more | more |";
1886
1887        let ctx_enabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888        let fixed_enabled = rule_enabled.fix(&ctx_enabled).unwrap();
1889        let enabled_lines: Vec<&str> = fixed_enabled.lines().collect();
1890        assert!(
1891            enabled_lines.len() >= 4,
1892            "Expected merged table to contain at least 4 lines"
1893        );
1894        assert_ne!(
1895            enabled_lines[0].len(),
1896            enabled_lines[1].len(),
1897            "With MD013 active and inherited max-width, wide merged table should auto-compact"
1898        );
1899
1900        let ctx_disabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901        let fixed_disabled = rule_disabled.fix(&ctx_disabled).unwrap();
1902        let disabled_lines: Vec<&str> = fixed_disabled.lines().collect();
1903        assert!(
1904            disabled_lines.len() >= 4,
1905            "Expected merged table to contain at least 4 lines"
1906        );
1907        assert_eq!(
1908            disabled_lines[0].len(),
1909            disabled_lines[1].len(),
1910            "With MD013 disabled via extend-disable, inherited max-width should be unlimited (aligned table)"
1911        );
1912        assert_eq!(
1913            disabled_lines[1].len(),
1914            disabled_lines[2].len(),
1915            "Aligned table rows should share the same width"
1916        );
1917    }
1918
1919    fn all_flavors() -> [MarkdownFlavor; 6] {
1920        [
1921            MarkdownFlavor::Standard,
1922            MarkdownFlavor::MkDocs,
1923            MarkdownFlavor::MDX,
1924            MarkdownFlavor::Quarto,
1925            MarkdownFlavor::Obsidian,
1926            MarkdownFlavor::Kramdown,
1927        ]
1928    }
1929
1930    fn make_row(prefix: &str, cols: usize) -> String {
1931        let cells: Vec<String> = (1..=cols).map(|idx| format!("{prefix}{idx}")).collect();
1932        format!("| {} |", cells.join(" | "))
1933    }
1934
1935    #[test]
1936    fn test_issue_420_orphan_fix_matrix_all_flavors() {
1937        let rule = MD075OrphanedTableRows::default();
1938        let content = "\
1939| Value        | Description                                       |
1940| ------------ | ------------------------------------------------- |
1941| `consistent` | All code blocks must use the same style (default) |
1942
1943| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1944| `indented` | All code blocks must use indented style (4 spaces) |";
1945
1946        for flavor in all_flavors() {
1947            let ctx = LintContext::new(content, flavor, None);
1948            let warnings = rule.check(&ctx).unwrap();
1949            assert_eq!(warnings.len(), 1, "Expected one warning for flavor {}", flavor.name());
1950            assert!(
1951                warnings[0].fix.is_some(),
1952                "Expected fixable orphan warning for flavor {}",
1953                flavor.name()
1954            );
1955            let fixed = rule.fix(&ctx).unwrap();
1956            let fixed_ctx = LintContext::new(&fixed, flavor, None);
1957            assert!(
1958                rule.check(&fixed_ctx).unwrap().is_empty(),
1959                "Expected no remaining MD075 warnings after fix for flavor {}",
1960                flavor.name()
1961            );
1962        }
1963    }
1964
1965    #[test]
1966    fn test_column_mismatch_orphan_not_fixable_matrix_all_flavors() {
1967        let rule = MD075OrphanedTableRows::default();
1968        let content = "\
1969| H1 | H2 | H3 |
1970| --- | --- | --- |
1971| a | b | c |
1972
1973| d | e |";
1974
1975        for flavor in all_flavors() {
1976            let ctx = LintContext::new(content, flavor, None);
1977            let warnings = rule.check(&ctx).unwrap();
1978            assert_eq!(
1979                warnings.len(),
1980                1,
1981                "Expected one mismatch warning for flavor {}",
1982                flavor.name()
1983            );
1984            assert!(
1985                warnings[0].fix.is_none(),
1986                "Mismatch must never auto-fix for flavor {}",
1987                flavor.name()
1988            );
1989            assert_eq!(
1990                rule.fix(&ctx).unwrap(),
1991                content,
1992                "Mismatch fix must be no-op for flavor {}",
1993                flavor.name()
1994            );
1995        }
1996    }
1997
1998    proptest! {
1999        #![proptest_config(ProptestConfig::with_cases(64))]
2000
2001        #[test]
2002        fn prop_md075_fix_is_idempotent_for_orphaned_rows(
2003            cols in 2usize..6,
2004            base_rows in 1usize..5,
2005            orphan_rows in 1usize..4,
2006            blank_lines in 1usize..4,
2007            flavor in prop::sample::select(all_flavors().to_vec()),
2008        ) {
2009            let rule = MD075OrphanedTableRows::default();
2010
2011            let mut lines = Vec::new();
2012            lines.push(make_row("H", cols));
2013            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2014            for idx in 0..base_rows {
2015                lines.push(make_row(&format!("r{}c", idx + 1), cols));
2016            }
2017            for _ in 0..blank_lines {
2018                lines.push(String::new());
2019            }
2020            for idx in 0..orphan_rows {
2021                lines.push(make_row(&format!("o{}c", idx + 1), cols));
2022            }
2023
2024            let content = lines.join("\n");
2025            let ctx1 = LintContext::new(&content, flavor, None);
2026            let fixed_once = rule.fix(&ctx1).unwrap();
2027
2028            let ctx2 = LintContext::new(&fixed_once, flavor, None);
2029            let fixed_twice = rule.fix(&ctx2).unwrap();
2030
2031            prop_assert_eq!(fixed_once.as_str(), fixed_twice.as_str());
2032            prop_assert!(
2033                rule.check(&ctx2).unwrap().is_empty(),
2034                "MD075 warnings remained after fix in flavor {}",
2035                flavor.name()
2036            );
2037        }
2038
2039        #[test]
2040        fn prop_md075_cli_lsp_fix_consistency(
2041            cols in 2usize..6,
2042            base_rows in 1usize..4,
2043            orphan_rows in 1usize..3,
2044            blank_lines in 1usize..3,
2045            flavor in prop::sample::select(all_flavors().to_vec()),
2046        ) {
2047            let rule = MD075OrphanedTableRows::default();
2048
2049            let mut lines = Vec::new();
2050            lines.push(make_row("H", cols));
2051            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2052            for idx in 0..base_rows {
2053                lines.push(make_row(&format!("r{}c", idx + 1), cols));
2054            }
2055            for _ in 0..blank_lines {
2056                lines.push(String::new());
2057            }
2058            for idx in 0..orphan_rows {
2059                lines.push(make_row(&format!("o{}c", idx + 1), cols));
2060            }
2061            let content = lines.join("\n");
2062
2063            let ctx = LintContext::new(&content, flavor, None);
2064            let warnings = rule.check(&ctx).unwrap();
2065            prop_assert!(
2066                warnings.iter().any(|w| w.message.contains("Orphaned")),
2067                "Expected orphan warning for flavor {}",
2068                flavor.name()
2069            );
2070
2071            let lsp_fixed = apply_warning_fixes(&content, &warnings).unwrap();
2072            let cli_fixed = rule.fix(&ctx).unwrap();
2073            prop_assert_eq!(lsp_fixed, cli_fixed);
2074        }
2075
2076        #[test]
2077        fn prop_md075_column_mismatch_is_never_fixable(
2078            base_cols in 2usize..6,
2079            orphan_cols in 1usize..6,
2080            blank_lines in 1usize..4,
2081            flavor in prop::sample::select(all_flavors().to_vec()),
2082        ) {
2083            prop_assume!(base_cols != orphan_cols);
2084            let rule = MD075OrphanedTableRows::default();
2085
2086            let mut lines = vec![
2087                make_row("H", base_cols),
2088                format!("| {} |", (0..base_cols).map(|_| "---").collect::<Vec<_>>().join(" | ")),
2089                make_row("r", base_cols),
2090            ];
2091            for _ in 0..blank_lines {
2092                lines.push(String::new());
2093            }
2094            lines.push(make_row("o", orphan_cols));
2095
2096            let content = lines.join("\n");
2097            let ctx = LintContext::new(&content, flavor, None);
2098            let warnings = rule.check(&ctx).unwrap();
2099            prop_assert_eq!(warnings.len(), 1);
2100            prop_assert!(warnings[0].fix.is_none());
2101            prop_assert_eq!(rule.fix(&ctx).unwrap(), content);
2102        }
2103    }
2104
2105    // === Pandoc construct reachability tests ===
2106    //
2107    // These tests document that MD075 does not flag Pandoc-specific constructs
2108    // because `ctx.table_blocks` (used by detect_orphaned_rows and
2109    // detect_table_continuation_rows) and `is_table_row_line` (used by
2110    // detect_headerless_tables) both exclude them:
2111    //
2112    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
2113    //   returns false and no `TableBlock` is created. The interior rows
2114    //   (`| a | b |`) do look like table rows but since no table_block exists
2115    //   for them, they may trigger the "headerless" check — but the preceding
2116    //   `+---+---+` line is not a delimiter row, so no table context is built.
2117    // - Multi-line table separators have no `|`, same exclusion.
2118    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
2119    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
2120    // - Pipe-table captions (`: caption`) have no `|` — excluded.
2121    //
2122    // If `find_table_blocks` ever changes to include these constructs, these
2123    // tests will surface that.
2124
2125    #[test]
2126    fn md075_pandoc_grid_tables_not_flagged() {
2127        let rule = MD075OrphanedTableRows::default();
2128        let content = "\
2129+---+---+
2130| a | b |
2131+===+===+
2132| 1 | 2 |
2133+---+---+
2134";
2135        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2136        let result = rule.check(&ctx).unwrap();
2137        assert!(
2138            result.is_empty(),
2139            "MD075 should not flag Pandoc grid tables: {result:?}"
2140        );
2141
2142        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2143        let result_std = rule.check(&ctx_std).unwrap();
2144        assert!(
2145            result_std.is_empty(),
2146            "MD075 should not flag grid-table-like content under Standard: {result_std:?}"
2147        );
2148    }
2149
2150    #[test]
2151    fn md075_pandoc_multi_line_tables_not_flagged() {
2152        let rule = MD075OrphanedTableRows::default();
2153        let content = "\
2154--------- -----------
2155Header 1   Header 2
2156--------- -----------
2157Cell 1     Cell 2
2158--------- -----------
2159";
2160        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2161        let result = rule.check(&ctx).unwrap();
2162        assert!(
2163            result.is_empty(),
2164            "MD075 should not flag Pandoc multi-line tables: {result:?}"
2165        );
2166
2167        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2168        let result_std = rule.check(&ctx_std).unwrap();
2169        assert!(
2170            result_std.is_empty(),
2171            "MD075 should not flag multi-line table content under Standard: {result_std:?}"
2172        );
2173    }
2174
2175    #[test]
2176    fn md075_pandoc_line_blocks_not_flagged() {
2177        let rule = MD075OrphanedTableRows::default();
2178        // Pandoc line blocks: `| text` lines without trailing `|`.
2179        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped
2180        // lines, so line blocks with a single cell are excluded.
2181        let content = "| First line\n| Second line\n";
2182        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2183        let result = rule.check(&ctx).unwrap();
2184        assert!(
2185            result.is_empty(),
2186            "MD075 should not treat Pandoc line blocks as orphaned table rows: {result:?}"
2187        );
2188
2189        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2190        let result_std = rule.check(&ctx_std).unwrap();
2191        assert!(
2192            result_std.is_empty(),
2193            "MD075 should not treat line-block-like content as orphaned rows under Standard: {result_std:?}"
2194        );
2195    }
2196
2197    #[test]
2198    fn md075_pandoc_pipe_table_captions_not_flagged() {
2199        let rule = MD075OrphanedTableRows::default();
2200        // Pipe-table captions (`: caption`) have no `|` — they are not pipe rows
2201        // and cannot appear in table_blocks or trigger is_table_row_line.
2202        let content = "\
2203| H1 | H2 |
2204|----|-----|
2205| a  | b  |
2206
2207: My table caption
2208";
2209        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2210        let result = rule.check(&ctx).unwrap();
2211        assert!(
2212            result.is_empty(),
2213            "MD075 should not flag the pipe-table caption line as orphaned: {result:?}"
2214        );
2215
2216        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2217        let result_std = rule.check(&ctx_std).unwrap();
2218        assert!(
2219            result_std.is_empty(),
2220            "MD075 table with caption — caption not a pipe row under Standard: {result_std:?}"
2221        );
2222    }
2223
2224    #[test]
2225    fn md075_obsidian_wikilink_paragraph_not_orphaned_row() {
2226        let rule = MD075OrphanedTableRows::default();
2227        // The paragraph is prose rather than a row that lost its table
2228        let content = "\
2229| Character | Note     |
2230| --------- | -------- |
2231| Alice     | curious  |
2232
2233She saw [[White Rabbit|the Rabbit]] run past and went down the hole.
2234";
2235        for flavor in all_flavors() {
2236            let ctx = LintContext::new(content, flavor, None);
2237            let warnings = rule.check(&ctx).unwrap();
2238            assert!(
2239                warnings.is_empty(),
2240                "MD075 should not treat a wikilink paragraph as an orphaned row for {}: {warnings:?}",
2241                flavor.name()
2242            );
2243        }
2244    }
2245
2246    #[test]
2247    fn md075_obsidian_genuine_orphaned_row_still_flagged() {
2248        let rule = MD075OrphanedTableRows::default();
2249        // A real orphaned row is still reported under Obsidian
2250        let content = "\
2251| Character | Note     |
2252| --------- | -------- |
2253| Alice     | curious  |
2254
2255| Hatter    | mad      |
2256";
2257        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
2258        assert!(
2259            !rule.check(&ctx).unwrap().is_empty(),
2260            "MD075 should still flag a genuine orphaned row under Obsidian"
2261        );
2262    }
2263
2264    /// A headerless group admits its rows flavor-aware, so it has to count their
2265    /// columns the same way. Counting flavor-blind made the two answers disagree,
2266    /// in both directions.
2267    #[test]
2268    fn md075_headerless_group_counts_columns_in_the_documents_flavor() {
2269        let rule = MD075OrphanedTableRows::default();
2270
2271        // Two columns under Obsidian, so the group is consistent and orphaned.
2272        // Counted as GFM the first row has three cells and the group looks ragged.
2273        let consistent_only_in_obsidian = "\
2274| Character | Note     |
2275| --------- | -------- |
2276| Alice     | curious  |
2277
2278Prose between the table and the rows below it.
2279
2280| [[White Rabbit|the Rabbit]] | late |
2281| Hatter                      | mad  |
2282";
2283        let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Obsidian, None);
2284        assert!(
2285            !rule.check(&ctx).unwrap().is_empty(),
2286            "A two-column headerless group should be flagged under Obsidian"
2287        );
2288        let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Standard, None);
2289        assert!(
2290            rule.check(&ctx).unwrap().is_empty(),
2291            "Under Standard the same rows are 3 and 2 cells wide, so they are not a group"
2292        );
2293
2294        // The mirror image: consistent only when the wikilink pipe is a delimiter.
2295        let consistent_only_in_gfm = "\
2296| Character | Note     |
2297| --------- | -------- |
2298| Alice     | curious  |
2299
2300Prose between the table and the rows below it.
2301
2302| [[White Rabbit|the Rabbit]] | late |
2303| Hatter                      | mad  | tea |
2304";
2305        let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Obsidian, None);
2306        assert!(
2307            rule.check(&ctx).unwrap().is_empty(),
2308            "Under Obsidian the rows are 2 and 3 cells wide, so they are not a group"
2309        );
2310        let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Standard, None);
2311        assert!(
2312            !rule.check(&ctx).unwrap().is_empty(),
2313            "A three-column headerless group should be flagged under Standard"
2314        );
2315    }
2316}