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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
591        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(75))
592    }
593
594    fn name(&self) -> &'static str {
595        "MD075"
596    }
597
598    fn description(&self) -> &'static str {
599        "Orphaned table rows or headerless pipe content"
600    }
601
602    fn category(&self) -> RuleCategory {
603        RuleCategory::Table
604    }
605
606    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
607        // Need at least 2 pipe characters for two minimal rows like:
608        // a | b
609        // c | d
610        ctx.char_count('|') < 2
611    }
612
613    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
614        let content_lines = ctx.raw_lines();
615        let mut warnings = Vec::new();
616
617        // Build set of all lines belonging to existing table blocks
618        let mut table_line_set = HashSet::new();
619        for table_block in &ctx.table_blocks {
620            for line_idx in table_block.start_line..=table_block.end_line {
621                table_line_set.insert(line_idx);
622            }
623        }
624
625        // Case 1: Orphaned rows after tables
626        let orphaned_groups = self.detect_orphaned_rows(ctx, content_lines, &table_line_set);
627        let orphan_group_fixes: Vec<Option<Fix>> = orphaned_groups
628            .iter()
629            .map(|group| self.build_orphan_group_fix(ctx, content_lines, group))
630            .collect::<Result<Vec<_>, _>>()?;
631        let mut orphaned_line_set = HashSet::new();
632        for group in &orphaned_groups {
633            for &line_idx in &group.row_lines {
634                orphaned_line_set.insert(line_idx);
635            }
636            // Also mark blank lines as part of the orphan group for dedup
637            for line_idx in group.blank_start..=group.blank_end {
638                orphaned_line_set.insert(line_idx);
639            }
640        }
641        let continuation_line_set = self.detect_table_continuation_rows(ctx, content_lines, &table_line_set);
642
643        for (group, group_fix) in orphaned_groups.iter().zip(orphan_group_fixes.iter()) {
644            let first_orphan = group.row_lines[0];
645            let last_orphan = *group.row_lines.last().unwrap();
646            let num_blanks = group.blank_end - group.blank_start + 1;
647
648            warnings.push(LintWarning {
649                rule_name: Some(self.name().to_string()),
650                message: format!("Orphaned table row(s) separated from preceding table by {num_blanks} blank line(s)"),
651                line: first_orphan + 1,
652                column: 1,
653                end_line: last_orphan + 1,
654                end_column: content_lines[last_orphan].chars().count() + 1,
655                severity: Severity::Warning,
656                fix: group_fix.clone(),
657            });
658        }
659
660        // Case 2: Headerless pipe content
661        let headerless_groups = self.detect_headerless_tables(
662            ctx,
663            content_lines,
664            &table_line_set,
665            &orphaned_line_set,
666            &continuation_line_set,
667        );
668
669        for group in &headerless_groups {
670            let start = group.start_line;
671            let end = *group.lines.last().unwrap();
672
673            warnings.push(LintWarning {
674                rule_name: Some(self.name().to_string()),
675                message: "Pipe-formatted rows without a table header/delimiter row".to_string(),
676                line: start + 1,
677                column: 1,
678                end_line: end + 1,
679                end_column: content_lines[end].chars().count() + 1,
680                severity: Severity::Warning,
681                fix: None,
682            });
683        }
684
685        Ok(warnings)
686    }
687
688    fn fixes_from_diagnostics(&self) -> bool {
689        true
690    }
691
692    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
693        let warnings = self.check(ctx)?;
694        let warnings =
695            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
696        if warnings.iter().all(|warning| warning.fix.is_none()) {
697            return Ok(ctx.content.to_string());
698        }
699
700        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
701    }
702
703    fn fix_capability(&self) -> FixCapability {
704        FixCapability::ConditionallyFixable
705    }
706
707    fn as_any(&self) -> &dyn std::any::Any {
708        self
709    }
710
711    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
712    where
713        Self: Sized,
714    {
715        let mut md060_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
716        if md060_config.style == "any" {
717            // MD075 should normalize merged tables by default; "any" preserves broken alignment.
718            md060_config.style = "aligned".to_string();
719        }
720        let md013_config = MD013Config::from_document_config(config);
721        let md013_disabled = config
722            .global
723            .disable
724            .iter()
725            .chain(config.global.extend_disable.iter())
726            .any(|rule| rule.trim().eq_ignore_ascii_case("MD013"));
727        let formatter = MD060TableFormat::from_config_struct(md060_config, md013_config, md013_disabled);
728        Box::new(Self::with_formatter(formatter))
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use proptest::prelude::*;
735
736    use super::*;
737    use crate::config::MarkdownFlavor;
738    use crate::lint_context::LintContext;
739    use crate::utils::fix_utils::apply_warning_fixes;
740
741    // =========================================================================
742    // Case 1: Orphaned rows after a table
743    // =========================================================================
744
745    #[test]
746    fn test_case_1_requires_at_least_one_outer_pipe_all_flavors() {
747        let rule = MD075OrphanedTableRows::default();
748        let prose = "\
749# Test
750
751| A | B |
752| - | - |
753| x | y |
754
755Noise low|med|high.\n";
756        let orphan = "\
757| Value        | Description       |
758| ------------ | ----------------- |
759| `consistent` | Default style     |
760
761| `fenced`     | Fenced style      |
762| `indented`   | Indented style    |";
763        let expected = "\
764| Value        | Description       |
765| ------------ | ----------------- |
766| `consistent` | Default style     |
767| `fenced`     | Fenced style      |
768| `indented`   | Indented style    |";
769        let leading_only = "\
770| A | B
771| - | -
772| x | y
773
774| c | d";
775        let trailing_only = "\
776A | B |
777- | - |
778x | y |
779
780c | d |";
781
782        for flavor in all_flavors() {
783            let prose_ctx = LintContext::new(prose, flavor, None);
784            assert!(
785                rule.check(&prose_ctx).unwrap().is_empty(),
786                "Pipe-bearing prose without outer pipes must not be treated as an orphaned row for {}",
787                flavor.name()
788            );
789
790            let orphan_ctx = LintContext::new(orphan, flavor, None);
791            let warnings = rule.check(&orphan_ctx).unwrap();
792            assert_eq!(
793                warnings.len(),
794                1,
795                "Outer-pipe orphan rows must warn for {}",
796                flavor.name()
797            );
798            assert!(
799                warnings[0].fix.is_some(),
800                "Outer-pipe orphan rows must remain fixable for {}",
801                flavor.name()
802            );
803            assert_eq!(
804                rule.fix(&orphan_ctx).unwrap(),
805                expected,
806                "Outer-pipe orphan rows must fix correctly for {}",
807                flavor.name()
808            );
809
810            for (style, content) in [("leading-only", leading_only), ("trailing-only", trailing_only)] {
811                let ctx = LintContext::new(content, flavor, None);
812                let warnings = rule.check(&ctx).unwrap();
813                assert_eq!(warnings.len(), 1, "{style} orphan rows must warn for {}", flavor.name());
814                assert!(
815                    warnings[0].fix.is_some(),
816                    "{style} orphan rows must remain fixable for {}",
817                    flavor.name()
818                );
819            }
820        }
821    }
822
823    /// A row with no outer pipe is out of reach even when it genuinely lost its table.
824    ///
825    /// The row is byte-for-byte indistinguishable from a prose sentence containing a
826    /// pipe, so reporting it costs a false positive on prose and, when the cell count
827    /// matches, an automatic fix that merges the paragraph into the table. Losing this
828    /// detection is the deliberate price of that safety, and loosening the gate to
829    /// recover it reopens the whole prose class.
830    #[test]
831    fn test_bare_orphan_row_after_bare_table_is_deliberately_not_reported_all_flavors() {
832        let rule = MD075OrphanedTableRows::default();
833        let content = "\
834A | B
835--- | ---
836x | y
837
838p | q
839";
840
841        for flavor in all_flavors() {
842            let ctx = LintContext::new(content, flavor, None);
843            assert!(
844                rule.check(&ctx).unwrap().is_empty(),
845                "A bare orphan row must stay unreported for {}",
846                flavor.name()
847            );
848            assert_eq!(
849                rule.fix(&ctx).unwrap(),
850                content,
851                "A bare orphan row must be left byte-for-byte unchanged for {}",
852                flavor.name()
853            );
854        }
855    }
856
857    #[test]
858    fn test_two_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
859        let rule = MD075OrphanedTableRows::default();
860        let content = "\
861| A | B |
862| - | - |
863| x | y |
864
865Either ship it|or do not.\n";
866
867        for flavor in all_flavors() {
868            let ctx = LintContext::new(content, flavor, None);
869            assert!(
870                rule.check(&ctx).unwrap().is_empty(),
871                "Two-cell prose without outer pipes must not be reported for {}",
872                flavor.name()
873            );
874            assert_eq!(
875                rule.fix(&ctx).unwrap(),
876                content,
877                "Two-cell prose must remain byte-for-byte unchanged for {}",
878                flavor.name()
879            );
880        }
881    }
882
883    #[test]
884    fn test_three_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
885        let rule = MD075OrphanedTableRows::default();
886        let content = "\
887| A | B | C |
888| - | - | - |
889| x | y | z |
890
891Noise low|med|high.\n";
892
893        for flavor in all_flavors() {
894            let ctx = LintContext::new(content, flavor, None);
895            assert!(
896                rule.check(&ctx).unwrap().is_empty(),
897                "Three-cell prose without outer pipes must not be reported for {}",
898                flavor.name()
899            );
900            assert_eq!(
901                rule.fix(&ctx).unwrap(),
902                content,
903                "Three-cell prose must remain byte-for-byte unchanged for {}",
904                flavor.name()
905            );
906        }
907    }
908
909    #[test]
910    fn test_orphaned_rows_after_table() {
911        let rule = MD075OrphanedTableRows::default();
912        let content = "\
913| Value        | Description       |
914| ------------ | ----------------- |
915| `consistent` | Default style     |
916
917| `fenced`     | Fenced style      |
918| `indented`   | Indented style    |";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920        let result = rule.check(&ctx).unwrap();
921
922        assert_eq!(result.len(), 1);
923        assert!(result[0].message.contains("Orphaned table row"));
924        assert!(result[0].fix.is_some());
925    }
926
927    #[test]
928    fn test_orphaned_single_row_after_table() {
929        let rule = MD075OrphanedTableRows::default();
930        let content = "\
931| H1 | H2 |
932|----|-----|
933| a  | b   |
934
935| c  | d   |";
936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let result = rule.check(&ctx).unwrap();
938
939        assert_eq!(result.len(), 1);
940        assert!(result[0].message.contains("Orphaned table row"));
941    }
942
943    #[test]
944    fn test_orphaned_rows_multiple_blank_lines() {
945        let rule = MD075OrphanedTableRows::default();
946        let content = "\
947| H1 | H2 |
948|----|-----|
949| a  | b   |
950
951
952| c  | d   |
953| e  | f   |";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let result = rule.check(&ctx).unwrap();
956
957        assert_eq!(result.len(), 1);
958        assert!(result[0].message.contains("2 blank line(s)"));
959    }
960
961    #[test]
962    fn test_fix_orphaned_rows() {
963        let rule = MD075OrphanedTableRows::default();
964        let content = "\
965| Value        | Description       |
966| ------------ | ----------------- |
967| `consistent` | Default style     |
968
969| `fenced`     | Fenced style      |
970| `indented`   | Indented style    |";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let fixed = rule.fix(&ctx).unwrap();
973
974        let expected = "\
975| Value        | Description       |
976| ------------ | ----------------- |
977| `consistent` | Default style     |
978| `fenced`     | Fenced style      |
979| `indented`   | Indented style    |";
980        assert_eq!(fixed, expected);
981    }
982
983    #[test]
984    fn test_fix_orphaned_rows_multiple_blanks() {
985        let rule = MD075OrphanedTableRows::default();
986        let content = "\
987| H1 | H2 |
988|----|-----|
989| a  | b   |
990
991
992| c  | d   |";
993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994        let fixed = rule.fix(&ctx).unwrap();
995
996        let expected = "\
997| H1  | H2  |
998| --- | --- |
999| a   | b   |
1000| c   | d   |";
1001        assert_eq!(fixed, expected);
1002    }
1003
1004    #[test]
1005    fn test_no_orphan_with_text_between() {
1006        let rule = MD075OrphanedTableRows::default();
1007        let content = "\
1008| H1 | H2 |
1009|----|-----|
1010| a  | b   |
1011
1012Some text here.
1013
1014| c  | d   |
1015| e  | f   |";
1016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017        let result = rule.check(&ctx).unwrap();
1018
1019        // Non-blank content between table and pipe rows means not orphaned
1020        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1021        assert_eq!(orphan_warnings.len(), 0);
1022    }
1023
1024    #[test]
1025    fn test_valid_consecutive_tables_not_flagged() {
1026        let rule = MD075OrphanedTableRows::default();
1027        let content = "\
1028| H1 | H2 |
1029|----|-----|
1030| a  | b   |
1031
1032| H3 | H4 |
1033|----|-----|
1034| c  | d   |";
1035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036        let result = rule.check(&ctx).unwrap();
1037
1038        // Two valid tables separated by a blank line produce no warnings
1039        assert_eq!(result.len(), 0);
1040    }
1041
1042    #[test]
1043    fn test_orphaned_rows_with_different_column_count() {
1044        let rule = MD075OrphanedTableRows::default();
1045        let content = "\
1046| H1 | H2 | H3 |
1047|----|-----|-----|
1048| a  | b   | c   |
1049
1050| d  | e   |";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let result = rule.check(&ctx).unwrap();
1053
1054        // Different column count should still flag as orphaned
1055        assert_eq!(result.len(), 1);
1056        assert!(result[0].message.contains("Orphaned"));
1057        assert!(result[0].fix.is_none());
1058    }
1059
1060    // =========================================================================
1061    // Case 2: Headerless pipe content
1062    // =========================================================================
1063
1064    #[test]
1065    fn test_headerless_pipe_content() {
1066        let rule = MD075OrphanedTableRows::default();
1067        let content = "\
1068Some text.
1069
1070| value1 | description1 |
1071| value2 | description2 |
1072
1073More text.";
1074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075        let result = rule.check(&ctx).unwrap();
1076
1077        assert_eq!(result.len(), 1);
1078        assert!(result[0].message.contains("without a table header"));
1079        assert!(result[0].fix.is_none());
1080    }
1081
1082    #[test]
1083    fn test_single_pipe_row_not_flagged() {
1084        let rule = MD075OrphanedTableRows::default();
1085        let content = "\
1086Some text.
1087
1088| value1 | description1 |
1089
1090More text.";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092        let result = rule.check(&ctx).unwrap();
1093
1094        // Single standalone pipe row is not flagged (Case 2 requires 2+)
1095        assert_eq!(result.len(), 0);
1096    }
1097
1098    #[test]
1099    fn test_headerless_multiple_rows() {
1100        let rule = MD075OrphanedTableRows::default();
1101        let content = "\
1102| a | b |
1103| c | d |
1104| e | f |";
1105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106        let result = rule.check(&ctx).unwrap();
1107
1108        assert_eq!(result.len(), 1);
1109        assert!(result[0].message.contains("without a table header"));
1110    }
1111
1112    #[test]
1113    fn test_headerless_inconsistent_columns_not_flagged() {
1114        let rule = MD075OrphanedTableRows::default();
1115        let content = "\
1116| a | b |
1117| c | d | e |";
1118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119        let result = rule.check(&ctx).unwrap();
1120
1121        // Inconsistent column count is not flagged as headerless table
1122        assert_eq!(result.len(), 0);
1123    }
1124
1125    #[test]
1126    fn test_headerless_not_flagged_when_has_delimiter() {
1127        let rule = MD075OrphanedTableRows::default();
1128        let content = "\
1129| H1 | H2 |
1130|----|-----|
1131| a  | b   |";
1132        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133        let result = rule.check(&ctx).unwrap();
1134
1135        // Valid table with header/delimiter produces no warnings
1136        assert_eq!(result.len(), 0);
1137    }
1138
1139    // =========================================================================
1140    // Edge cases
1141    // =========================================================================
1142
1143    #[test]
1144    fn test_pipe_rows_in_code_block_ignored() {
1145        let rule = MD075OrphanedTableRows::default();
1146        let content = "\
1147```
1148| a | b |
1149| c | d |
1150```";
1151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152        let result = rule.check(&ctx).unwrap();
1153
1154        assert_eq!(result.len(), 0);
1155    }
1156
1157    #[test]
1158    fn test_pipe_rows_in_frontmatter_ignored() {
1159        let rule = MD075OrphanedTableRows::default();
1160        let content = "\
1161---
1162title: test
1163---
1164
1165| a | b |
1166| c | d |";
1167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168        let result = rule.check(&ctx).unwrap();
1169
1170        // Frontmatter is skipped, standalone pipe rows after it are flagged
1171        let warnings: Vec<_> = result
1172            .iter()
1173            .filter(|w| w.message.contains("without a table header"))
1174            .collect();
1175        assert_eq!(warnings.len(), 1);
1176    }
1177
1178    #[test]
1179    fn test_no_pipes_at_all() {
1180        let rule = MD075OrphanedTableRows::default();
1181        let content = "Just regular text.\nNo pipes here.\nOnly paragraphs.";
1182        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183        let result = rule.check(&ctx).unwrap();
1184
1185        assert_eq!(result.len(), 0);
1186    }
1187
1188    #[test]
1189    fn test_empty_content() {
1190        let rule = MD075OrphanedTableRows::default();
1191        let content = "";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193        let result = rule.check(&ctx).unwrap();
1194
1195        assert_eq!(result.len(), 0);
1196    }
1197
1198    #[test]
1199    fn test_orphaned_rows_in_blockquote() {
1200        let rule = MD075OrphanedTableRows::default();
1201        let content = "\
1202> | H1 | H2 |
1203> |----|-----|
1204> | a  | b   |
1205>
1206> | c  | d   |";
1207        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208        let result = rule.check(&ctx).unwrap();
1209
1210        assert_eq!(result.len(), 1);
1211        assert!(result[0].message.contains("Orphaned"));
1212    }
1213
1214    #[test]
1215    fn test_fix_orphaned_rows_in_blockquote() {
1216        let rule = MD075OrphanedTableRows::default();
1217        let content = "\
1218> | H1 | H2 |
1219> |----|-----|
1220> | a  | b   |
1221>
1222> | c  | d   |";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224        let fixed = rule.fix(&ctx).unwrap();
1225
1226        let expected = "\
1227> | H1  | H2  |
1228> | --- | --- |
1229> | a   | b   |
1230> | c   | d   |";
1231        assert_eq!(fixed, expected);
1232    }
1233
1234    #[test]
1235    fn test_table_at_end_of_document_no_orphans() {
1236        let rule = MD075OrphanedTableRows::default();
1237        let content = "\
1238| H1 | H2 |
1239|----|-----|
1240| a  | b   |";
1241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242        let result = rule.check(&ctx).unwrap();
1243
1244        assert_eq!(result.len(), 0);
1245    }
1246
1247    #[test]
1248    fn test_table_followed_by_text_no_orphans() {
1249        let rule = MD075OrphanedTableRows::default();
1250        let content = "\
1251| H1 | H2 |
1252|----|-----|
1253| a  | b   |
1254
1255Some text after the table.";
1256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257        let result = rule.check(&ctx).unwrap();
1258
1259        assert_eq!(result.len(), 0);
1260    }
1261
1262    #[test]
1263    fn test_fix_preserves_content_around_orphans() {
1264        let rule = MD075OrphanedTableRows::default();
1265        let content = "\
1266# Title
1267
1268| H1 | H2 |
1269|----|-----|
1270| a  | b   |
1271
1272| c  | d   |
1273
1274Some text after.";
1275        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276        let fixed = rule.fix(&ctx).unwrap();
1277
1278        let expected = "\
1279# Title
1280
1281| H1  | H2  |
1282| --- | --- |
1283| a   | b   |
1284| c   | d   |
1285
1286Some text after.";
1287        assert_eq!(fixed, expected);
1288    }
1289
1290    #[test]
1291    fn test_multiple_orphan_groups() {
1292        let rule = MD075OrphanedTableRows::default();
1293        let content = "\
1294| H1 | H2 |
1295|----|-----|
1296| a  | b   |
1297
1298| c  | d   |
1299
1300| H3 | H4 |
1301|----|-----|
1302| e  | f   |
1303
1304| g  | h   |";
1305        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306        let result = rule.check(&ctx).unwrap();
1307
1308        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1309        assert_eq!(orphan_warnings.len(), 2);
1310    }
1311
1312    #[test]
1313    fn test_fix_multiple_orphan_groups() {
1314        let rule = MD075OrphanedTableRows::default();
1315        let content = "\
1316| H1 | H2 |
1317|----|-----|
1318| a  | b   |
1319
1320| c  | d   |
1321
1322| H3 | H4 |
1323|----|-----|
1324| e  | f   |
1325
1326| g  | h   |";
1327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328        let fixed = rule.fix(&ctx).unwrap();
1329
1330        let expected = "\
1331| H1  | H2  |
1332| --- | --- |
1333| a   | b   |
1334| c   | d   |
1335
1336| H3  | H4  |
1337| --- | --- |
1338| e   | f   |
1339| g   | h   |";
1340        assert_eq!(fixed, expected);
1341    }
1342
1343    #[test]
1344    fn test_orphaned_rows_with_delimiter_form_new_table() {
1345        let rule = MD075OrphanedTableRows::default();
1346        // Rows after a blank that themselves form a valid table (header+delimiter)
1347        // are recognized as a separate table by table_blocks, not as orphans
1348        let content = "\
1349| H1 | H2 |
1350|----|-----|
1351| a  | b   |
1352
1353| c  | d   |
1354|----|-----|";
1355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        let result = rule.check(&ctx).unwrap();
1357
1358        // The second group forms a valid table, so no orphan warning
1359        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1360        assert_eq!(orphan_warnings.len(), 0);
1361    }
1362
1363    #[test]
1364    fn test_headerless_not_confused_with_orphaned() {
1365        let rule = MD075OrphanedTableRows::default();
1366        let content = "\
1367| H1 | H2 |
1368|----|-----|
1369| a  | b   |
1370
1371Some text.
1372
1373| c  | d   |
1374| e  | f   |";
1375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376        let result = rule.check(&ctx).unwrap();
1377
1378        // Non-blank content between table and pipe rows means not orphaned
1379        // The standalone rows should be flagged as headerless (Case 2)
1380        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1381        let headerless_warnings: Vec<_> = result
1382            .iter()
1383            .filter(|w| w.message.contains("without a table header"))
1384            .collect();
1385
1386        assert_eq!(orphan_warnings.len(), 0);
1387        assert_eq!(headerless_warnings.len(), 1);
1388    }
1389
1390    #[test]
1391    fn test_fix_does_not_modify_headerless() {
1392        let rule = MD075OrphanedTableRows::default();
1393        let content = "\
1394Some text.
1395
1396| value1 | description1 |
1397| value2 | description2 |
1398
1399More text.";
1400        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401        let fixed = rule.fix(&ctx).unwrap();
1402
1403        // Case 2 has no fix, so content should be unchanged
1404        assert_eq!(fixed, content);
1405    }
1406
1407    #[test]
1408    fn test_should_skip_few_pipes() {
1409        let rule = MD075OrphanedTableRows::default();
1410        let content = "a | b";
1411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412
1413        assert!(rule.should_skip(&ctx));
1414    }
1415
1416    #[test]
1417    fn test_should_not_skip_two_pipes_without_outer_pipes() {
1418        let rule = MD075OrphanedTableRows::default();
1419        let content = "\
1420a | b
1421c | d";
1422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423
1424        assert!(!rule.should_skip(&ctx));
1425        let result = rule.check(&ctx).unwrap();
1426        assert_eq!(result.len(), 1);
1427        assert!(result[0].message.contains("without a table header"));
1428    }
1429
1430    #[test]
1431    fn test_fix_capability() {
1432        let rule = MD075OrphanedTableRows::default();
1433        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1434    }
1435
1436    #[test]
1437    fn test_category() {
1438        let rule = MD075OrphanedTableRows::default();
1439        assert_eq!(rule.category(), RuleCategory::Table);
1440    }
1441
1442    #[test]
1443    fn test_issue_420_exact_example() {
1444        // The exact example from issue #420, including inline code fence markers.
1445        let rule = MD075OrphanedTableRows::default();
1446        let content = "\
1447| Value        | Description                                       |
1448| ------------ | ------------------------------------------------- |
1449| `consistent` | All code blocks must use the same style (default) |
1450
1451| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1452| `indented` | All code blocks must use indented style (4 spaces) |";
1453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454        let result = rule.check(&ctx).unwrap();
1455
1456        assert_eq!(result.len(), 1);
1457        assert!(result[0].message.contains("Orphaned"));
1458        assert_eq!(result[0].line, 5);
1459
1460        let fixed = rule.fix(&ctx).unwrap();
1461        let expected = "\
1462| Value        | Description                                        |
1463| ------------ | -------------------------------------------------- |
1464| `consistent` | All code blocks must use the same style (default)  |
1465| `fenced`     | All code blocks must use fenced style (``` or ~~~) |
1466| `indented`   | All code blocks must use indented style (4 spaces) |";
1467        assert_eq!(fixed, expected);
1468    }
1469
1470    #[test]
1471    fn test_display_math_block_with_pipes_not_flagged() {
1472        let rule = MD075OrphanedTableRows::default();
1473        let content = "# Math\n\n$$\n|A| + |B| = |A \\cup B|\n|A| + |B| = |A \\cup B|\n$$\n";
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475        let result = rule.check(&ctx).unwrap();
1476
1477        assert!(
1478            result.is_empty(),
1479            "Pipes inside display math blocks should not trigger MD075"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_math_absolute_value_bars_not_flagged() {
1485        let rule = MD075OrphanedTableRows::default();
1486        let content = "\
1487# Math
1488
1489Roughly (for privacy reasons, this isn't exactly what the student said),
1490the student talked about having done small cases on the size $|S|$,
1491and figuring out that $|S|$ was even, but then running out of ideas.";
1492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493        let result = rule.check(&ctx).unwrap();
1494
1495        assert!(result.is_empty(), "Math absolute value bars should not trigger MD075");
1496    }
1497
1498    #[test]
1499    fn test_prose_with_double_backticks_and_pipes_not_flagged() {
1500        let rule = MD075OrphanedTableRows::default();
1501        let content = "\
1502Use ``a|b`` or ``c|d`` in docs.
1503Prefer ``x|y`` and ``z|w`` examples.";
1504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505        let result = rule.check(&ctx).unwrap();
1506
1507        assert!(result.is_empty());
1508    }
1509
1510    #[test]
1511    fn test_liquid_filter_lines_not_flagged_as_headerless() {
1512        let rule = MD075OrphanedTableRows::default();
1513        let content = "\
1514If you encounter issues, see [Troubleshooting]({{ '/docs/troubleshooting/' | relative_url }}).
1515Use our [guides]({{ '/docs/installation/' | relative_url }}) for OS-specific steps.";
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517        let result = rule.check(&ctx).unwrap();
1518
1519        assert!(result.is_empty());
1520    }
1521
1522    #[test]
1523    fn test_rows_after_template_directive_not_flagged_as_headerless() {
1524        let rule = MD075OrphanedTableRows::default();
1525        let content = "\
1526{% data reusables.enterprise-migration-tool.placeholder-table %}
1527DESTINATION | The name you want the new organization to have.
1528ENTERPRISE | The slug for your destination enterprise.";
1529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530        let result = rule.check(&ctx).unwrap();
1531
1532        assert!(result.is_empty());
1533    }
1534
1535    #[test]
1536    fn test_templated_pipe_rows_not_flagged_as_headerless() {
1537        let rule = MD075OrphanedTableRows::default();
1538        let content = "\
1539| Feature{%- for version in group_versions %} | {{ version }}{%- endfor %} |
1540|:----{%- for version in group_versions %}|:----:{%- endfor %}|
1541| {{ feature }} | {{ value }} |";
1542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543        let result = rule.check(&ctx).unwrap();
1544
1545        assert!(result.is_empty());
1546    }
1547
1548    #[test]
1549    fn test_escaped_pipe_rows_in_table_not_flagged_as_headerless() {
1550        let rule = MD075OrphanedTableRows::default();
1551        let content = "\
1552Written as                             | Interpreted as
1553---------------------------------------|-----------------------------------------
1554`!foo && bar`                          | `(!foo) && bar`
1555<code>!foo \\|\\| bar </code>            | `(!foo) \\|\\| bar`
1556<code>foo \\|\\| bar && baz </code>      | <code>foo \\|\\| (bar && baz)</code>
1557<code>!foo && bar \\|\\| baz </code>     | <code>(!foo && bar) \\|\\| baz</code>";
1558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559        let result = rule.check(&ctx).unwrap();
1560
1561        assert!(result.is_empty());
1562    }
1563
1564    #[test]
1565    fn test_rows_after_sparse_section_row_in_table_not_flagged() {
1566        let rule = MD075OrphanedTableRows::default();
1567        let content = "\
1568Key|Command|Command id
1569---|-------|----------
1570Search||
1571`kb(history.showNext)`|Next Search Term|`history.showNext`
1572`kb(history.showPrevious)`|Previous Search Term|`history.showPrevious`
1573Extensions||
1574`unassigned`|Update All Extensions|`workbench.extensions.action.updateAllExtensions`";
1575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576        let result = rule.check(&ctx).unwrap();
1577
1578        assert!(result.is_empty());
1579    }
1580
1581    #[test]
1582    fn test_sparse_row_without_table_context_does_not_suppress_headerless() {
1583        let rule = MD075OrphanedTableRows::default();
1584        let content = "\
1585Notes ||
1586`alpha` | `beta`
1587`gamma` | `delta`";
1588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589        let result = rule.check(&ctx).unwrap();
1590
1591        assert_eq!(result.len(), 1);
1592        assert!(result[0].message.contains("without a table header"));
1593    }
1594
1595    #[test]
1596    fn test_reusable_three_column_fragment_not_flagged_as_headerless() {
1597        let rule = MD075OrphanedTableRows::default();
1598        let content = "\
1599`label` | `object` | The label added or removed from the issue.
1600`label[name]` | `string` | The name of the label.
1601`label[color]` | `string` | The hex color code.";
1602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603        let result = rule.check(&ctx).unwrap();
1604
1605        assert!(result.is_empty());
1606    }
1607
1608    #[test]
1609    fn test_orphan_detection_does_not_cross_blockquote_context() {
1610        let rule = MD075OrphanedTableRows::default();
1611        let content = "\
1612| H1 | H2 |
1613|----|-----|
1614| a  | b   |
1615
1616> | c  | d   |";
1617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618        let result = rule.check(&ctx).unwrap();
1619        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1620
1621        assert_eq!(orphan_warnings.len(), 0);
1622        assert_eq!(rule.fix(&ctx).unwrap(), content);
1623    }
1624
1625    #[test]
1626    fn test_orphan_fix_does_not_cross_list_context() {
1627        let rule = MD075OrphanedTableRows::default();
1628        let content = "\
1629- | H1 | H2 |
1630  |----|-----|
1631  | a  | b   |
1632
1633| c  | d   |";
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635        let result = rule.check(&ctx).unwrap();
1636        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1637
1638        assert_eq!(orphan_warnings.len(), 0);
1639        assert_eq!(rule.fix(&ctx).unwrap(), content);
1640    }
1641
1642    #[test]
1643    fn test_fix_normalizes_only_merged_table() {
1644        let rule = MD075OrphanedTableRows::default();
1645        let content = "\
1646| H1 | H2 |
1647|----|-----|
1648| a  | b   |
1649
1650| c  | d   |
1651
1652| Name | Age |
1653|---|---|
1654|alice|30|";
1655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656        let fixed = rule.fix(&ctx).unwrap();
1657
1658        assert!(fixed.contains("| H1  | H2  |"));
1659        assert!(fixed.contains("| c   | d   |"));
1660        // Unrelated second table should keep original compact formatting.
1661        assert!(fixed.contains("|---|---|"));
1662        assert!(fixed.contains("|alice|30|"));
1663    }
1664
1665    #[test]
1666    fn test_html_comment_pipe_rows_ignored() {
1667        let rule = MD075OrphanedTableRows::default();
1668        let content = "\
1669<!--
1670| a | b |
1671| c | d |
1672-->";
1673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674        let result = rule.check(&ctx).unwrap();
1675
1676        assert_eq!(result.len(), 0);
1677    }
1678
1679    #[test]
1680    fn test_orphan_detection_does_not_cross_skip_contexts() {
1681        let rule = MD075OrphanedTableRows::default();
1682        let content = "\
1683| H1 | H2 |
1684|----|-----|
1685| a  | b   |
1686
1687```
1688| c  | d   |
1689```";
1690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691        let result = rule.check(&ctx).unwrap();
1692
1693        // Pipe rows inside code block should not be flagged as orphaned
1694        assert_eq!(result.len(), 0);
1695    }
1696
1697    #[test]
1698    fn test_pipe_rows_in_esm_block_ignored() {
1699        let rule = MD075OrphanedTableRows::default();
1700        // ESM blocks use import/export statements; pipe rows inside should be skipped
1701        let content = "\
1702<script type=\"module\">
1703| a | b |
1704| c | d |
1705</script>";
1706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707        let result = rule.check(&ctx).unwrap();
1708
1709        // All pipe rows are inside an HTML/ESM block, no warnings expected
1710        assert_eq!(result.len(), 0);
1711    }
1712
1713    #[test]
1714    fn test_fix_range_covers_blank_lines_correctly() {
1715        let rule = MD075OrphanedTableRows::default();
1716        let content = "\
1717# Before
1718
1719| H1 | H2 |
1720|----|-----|
1721| a  | b   |
1722
1723| c  | d   |
1724
1725# After";
1726        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727        let warnings = rule.check(&ctx).unwrap();
1728        let expected = "\
1729# Before
1730
1731| H1  | H2  |
1732| --- | --- |
1733| a   | b   |
1734| c   | d   |
1735
1736# After";
1737
1738        assert_eq!(warnings.len(), 1);
1739        let fix = warnings[0].fix.as_ref().unwrap();
1740        assert!(fix.range.start > 0);
1741        assert!(fix.range.end < content.len());
1742
1743        let cli_fixed = rule.fix(&ctx).unwrap();
1744        assert_eq!(cli_fixed, expected);
1745
1746        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1747        assert_eq!(lsp_fixed, expected);
1748        assert_eq!(lsp_fixed, cli_fixed);
1749    }
1750
1751    #[test]
1752    fn test_fix_range_multiple_blanks() {
1753        let rule = MD075OrphanedTableRows::default();
1754        let content = "\
1755# Before
1756
1757| H1 | H2 |
1758|----|-----|
1759| a  | b   |
1760
1761
1762| c  | d   |";
1763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764        let warnings = rule.check(&ctx).unwrap();
1765        let expected = "\
1766# Before
1767
1768| H1  | H2  |
1769| --- | --- |
1770| a   | b   |
1771| c   | d   |";
1772
1773        assert_eq!(warnings.len(), 1);
1774        let fix = warnings[0].fix.as_ref().unwrap();
1775        assert!(fix.range.start > 0);
1776        assert_eq!(fix.range.end, content.len());
1777
1778        let cli_fixed = rule.fix(&ctx).unwrap();
1779        assert_eq!(cli_fixed, expected);
1780
1781        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1782        assert_eq!(lsp_fixed, expected);
1783        assert_eq!(lsp_fixed, cli_fixed);
1784    }
1785
1786    #[test]
1787    fn test_warning_fixes_match_rule_fix_for_multiple_orphan_groups() {
1788        let rule = MD075OrphanedTableRows::default();
1789        let content = "\
1790| H1 | H2 |
1791|----|-----|
1792| a  | b   |
1793
1794| c  | d   |
1795
1796| H3 | H4 |
1797|----|-----|
1798| e  | f   |
1799
1800| g  | h   |";
1801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802        let warnings = rule.check(&ctx).unwrap();
1803
1804        let orphan_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Orphaned")).collect();
1805        assert_eq!(orphan_warnings.len(), 2);
1806
1807        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1808        let cli_fixed = rule.fix(&ctx).unwrap();
1809
1810        assert_eq!(lsp_fixed, cli_fixed);
1811        assert_ne!(cli_fixed, content);
1812    }
1813
1814    #[test]
1815    fn test_issue_420_fix_is_idempotent() {
1816        let rule = MD075OrphanedTableRows::default();
1817        let content = "\
1818| Value        | Description                                       |
1819| ------------ | ------------------------------------------------- |
1820| `consistent` | All code blocks must use the same style (default) |
1821
1822| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1823| `indented` | All code blocks must use indented style (4 spaces) |";
1824
1825        let initial_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1826        let fixed_once = rule.fix(&initial_ctx).unwrap();
1827
1828        let fixed_ctx = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1829        let warnings_after_fix = rule.check(&fixed_ctx).unwrap();
1830        assert_eq!(warnings_after_fix.len(), 0);
1831
1832        let fixed_twice = rule.fix(&fixed_ctx).unwrap();
1833        assert_eq!(fixed_twice, fixed_once);
1834    }
1835
1836    #[test]
1837    fn test_from_config_respects_md060_compact_style_for_merged_table() {
1838        let mut config = crate::config::Config::default();
1839        let mut md060_rule_config = crate::config::RuleConfig::default();
1840        md060_rule_config
1841            .values
1842            .insert("style".to_string(), toml::Value::String("compact".to_string()));
1843        config.rules.insert("MD060".to_string(), md060_rule_config);
1844
1845        let rule = <MD075OrphanedTableRows as Rule>::from_config(&config);
1846        let content = "\
1847| H1 | H2 |
1848|----|-----|
1849| long value | b |
1850
1851| c | d |";
1852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853        let fixed = rule.fix(&ctx).unwrap();
1854
1855        let expected = "\
1856| H1 | H2 |
1857| ---- | ----- |
1858| long value | b |
1859| c | d |";
1860        assert_eq!(fixed, expected);
1861    }
1862
1863    #[test]
1864    fn test_from_config_honors_extend_disable_for_md013_case_insensitive() {
1865        let mut config_enabled = crate::config::Config::default();
1866
1867        let mut md060_rule_config = crate::config::RuleConfig::default();
1868        md060_rule_config
1869            .values
1870            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
1871        config_enabled.rules.insert("MD060".to_string(), md060_rule_config);
1872
1873        let mut md013_rule_config = crate::config::RuleConfig::default();
1874        md013_rule_config
1875            .values
1876            .insert("line-length".to_string(), toml::Value::Integer(40));
1877        md013_rule_config
1878            .values
1879            .insert("tables".to_string(), toml::Value::Boolean(true));
1880        config_enabled.rules.insert("MD013".to_string(), md013_rule_config);
1881
1882        let mut config_disabled = config_enabled.clone();
1883        config_disabled.global.extend_disable.push("md013".to_string());
1884
1885        let rule_enabled = <MD075OrphanedTableRows as Rule>::from_config(&config_enabled);
1886        let rule_disabled = <MD075OrphanedTableRows as Rule>::from_config(&config_disabled);
1887
1888        let content = "\
1889| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |
1890|---|---|---|
1891| data | data | data |
1892
1893| more | more | more |";
1894
1895        let ctx_enabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896        let fixed_enabled = rule_enabled.fix(&ctx_enabled).unwrap();
1897        let enabled_lines: Vec<&str> = fixed_enabled.lines().collect();
1898        assert!(
1899            enabled_lines.len() >= 4,
1900            "Expected merged table to contain at least 4 lines"
1901        );
1902        assert_ne!(
1903            enabled_lines[0].len(),
1904            enabled_lines[1].len(),
1905            "With MD013 active and inherited max-width, wide merged table should auto-compact"
1906        );
1907
1908        let ctx_disabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909        let fixed_disabled = rule_disabled.fix(&ctx_disabled).unwrap();
1910        let disabled_lines: Vec<&str> = fixed_disabled.lines().collect();
1911        assert!(
1912            disabled_lines.len() >= 4,
1913            "Expected merged table to contain at least 4 lines"
1914        );
1915        assert_eq!(
1916            disabled_lines[0].len(),
1917            disabled_lines[1].len(),
1918            "With MD013 disabled via extend-disable, inherited max-width should be unlimited (aligned table)"
1919        );
1920        assert_eq!(
1921            disabled_lines[1].len(),
1922            disabled_lines[2].len(),
1923            "Aligned table rows should share the same width"
1924        );
1925    }
1926
1927    fn all_flavors() -> [MarkdownFlavor; 6] {
1928        [
1929            MarkdownFlavor::Standard,
1930            MarkdownFlavor::MkDocs,
1931            MarkdownFlavor::MDX,
1932            MarkdownFlavor::Quarto,
1933            MarkdownFlavor::Obsidian,
1934            MarkdownFlavor::Kramdown,
1935        ]
1936    }
1937
1938    fn make_row(prefix: &str, cols: usize) -> String {
1939        let cells: Vec<String> = (1..=cols).map(|idx| format!("{prefix}{idx}")).collect();
1940        format!("| {} |", cells.join(" | "))
1941    }
1942
1943    #[test]
1944    fn test_issue_420_orphan_fix_matrix_all_flavors() {
1945        let rule = MD075OrphanedTableRows::default();
1946        let content = "\
1947| Value        | Description                                       |
1948| ------------ | ------------------------------------------------- |
1949| `consistent` | All code blocks must use the same style (default) |
1950
1951| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1952| `indented` | All code blocks must use indented style (4 spaces) |";
1953
1954        for flavor in all_flavors() {
1955            let ctx = LintContext::new(content, flavor, None);
1956            let warnings = rule.check(&ctx).unwrap();
1957            assert_eq!(warnings.len(), 1, "Expected one warning for flavor {}", flavor.name());
1958            assert!(
1959                warnings[0].fix.is_some(),
1960                "Expected fixable orphan warning for flavor {}",
1961                flavor.name()
1962            );
1963            let fixed = rule.fix(&ctx).unwrap();
1964            let fixed_ctx = LintContext::new(&fixed, flavor, None);
1965            assert!(
1966                rule.check(&fixed_ctx).unwrap().is_empty(),
1967                "Expected no remaining MD075 warnings after fix for flavor {}",
1968                flavor.name()
1969            );
1970        }
1971    }
1972
1973    #[test]
1974    fn test_column_mismatch_orphan_not_fixable_matrix_all_flavors() {
1975        let rule = MD075OrphanedTableRows::default();
1976        let content = "\
1977| H1 | H2 | H3 |
1978| --- | --- | --- |
1979| a | b | c |
1980
1981| d | e |";
1982
1983        for flavor in all_flavors() {
1984            let ctx = LintContext::new(content, flavor, None);
1985            let warnings = rule.check(&ctx).unwrap();
1986            assert_eq!(
1987                warnings.len(),
1988                1,
1989                "Expected one mismatch warning for flavor {}",
1990                flavor.name()
1991            );
1992            assert!(
1993                warnings[0].fix.is_none(),
1994                "Mismatch must never auto-fix for flavor {}",
1995                flavor.name()
1996            );
1997            assert_eq!(
1998                rule.fix(&ctx).unwrap(),
1999                content,
2000                "Mismatch fix must be no-op for flavor {}",
2001                flavor.name()
2002            );
2003        }
2004    }
2005
2006    proptest! {
2007        #![proptest_config(ProptestConfig::with_cases(64))]
2008
2009        #[test]
2010        fn prop_md075_fix_is_idempotent_for_orphaned_rows(
2011            cols in 2usize..6,
2012            base_rows in 1usize..5,
2013            orphan_rows in 1usize..4,
2014            blank_lines in 1usize..4,
2015            flavor in prop::sample::select(all_flavors().to_vec()),
2016        ) {
2017            let rule = MD075OrphanedTableRows::default();
2018
2019            let mut lines = Vec::new();
2020            lines.push(make_row("H", cols));
2021            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2022            for idx in 0..base_rows {
2023                lines.push(make_row(&format!("r{}c", idx + 1), cols));
2024            }
2025            for _ in 0..blank_lines {
2026                lines.push(String::new());
2027            }
2028            for idx in 0..orphan_rows {
2029                lines.push(make_row(&format!("o{}c", idx + 1), cols));
2030            }
2031
2032            let content = lines.join("\n");
2033            let ctx1 = LintContext::new(&content, flavor, None);
2034            let fixed_once = rule.fix(&ctx1).unwrap();
2035
2036            let ctx2 = LintContext::new(&fixed_once, flavor, None);
2037            let fixed_twice = rule.fix(&ctx2).unwrap();
2038
2039            prop_assert_eq!(fixed_once.as_str(), fixed_twice.as_str());
2040            prop_assert!(
2041                rule.check(&ctx2).unwrap().is_empty(),
2042                "MD075 warnings remained after fix in flavor {}",
2043                flavor.name()
2044            );
2045        }
2046
2047        #[test]
2048        fn prop_md075_cli_lsp_fix_consistency(
2049            cols in 2usize..6,
2050            base_rows in 1usize..4,
2051            orphan_rows in 1usize..3,
2052            blank_lines in 1usize..3,
2053            flavor in prop::sample::select(all_flavors().to_vec()),
2054        ) {
2055            let rule = MD075OrphanedTableRows::default();
2056
2057            let mut lines = Vec::new();
2058            lines.push(make_row("H", cols));
2059            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2060            for idx in 0..base_rows {
2061                lines.push(make_row(&format!("r{}c", idx + 1), cols));
2062            }
2063            for _ in 0..blank_lines {
2064                lines.push(String::new());
2065            }
2066            for idx in 0..orphan_rows {
2067                lines.push(make_row(&format!("o{}c", idx + 1), cols));
2068            }
2069            let content = lines.join("\n");
2070
2071            let ctx = LintContext::new(&content, flavor, None);
2072            let warnings = rule.check(&ctx).unwrap();
2073            prop_assert!(
2074                warnings.iter().any(|w| w.message.contains("Orphaned")),
2075                "Expected orphan warning for flavor {}",
2076                flavor.name()
2077            );
2078
2079            let lsp_fixed = apply_warning_fixes(&content, &warnings).unwrap();
2080            let cli_fixed = rule.fix(&ctx).unwrap();
2081            prop_assert_eq!(lsp_fixed, cli_fixed);
2082        }
2083
2084        #[test]
2085        fn prop_md075_column_mismatch_is_never_fixable(
2086            base_cols in 2usize..6,
2087            orphan_cols in 1usize..6,
2088            blank_lines in 1usize..4,
2089            flavor in prop::sample::select(all_flavors().to_vec()),
2090        ) {
2091            prop_assume!(base_cols != orphan_cols);
2092            let rule = MD075OrphanedTableRows::default();
2093
2094            let mut lines = vec![
2095                make_row("H", base_cols),
2096                format!("| {} |", (0..base_cols).map(|_| "---").collect::<Vec<_>>().join(" | ")),
2097                make_row("r", base_cols),
2098            ];
2099            for _ in 0..blank_lines {
2100                lines.push(String::new());
2101            }
2102            lines.push(make_row("o", orphan_cols));
2103
2104            let content = lines.join("\n");
2105            let ctx = LintContext::new(&content, flavor, None);
2106            let warnings = rule.check(&ctx).unwrap();
2107            prop_assert_eq!(warnings.len(), 1);
2108            prop_assert!(warnings[0].fix.is_none());
2109            prop_assert_eq!(rule.fix(&ctx).unwrap(), content);
2110        }
2111    }
2112
2113    // === Pandoc construct reachability tests ===
2114    //
2115    // These tests document that MD075 does not flag Pandoc-specific constructs
2116    // because `ctx.table_blocks` (used by detect_orphaned_rows and
2117    // detect_table_continuation_rows) and `is_table_row_line` (used by
2118    // detect_headerless_tables) both exclude them:
2119    //
2120    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
2121    //   returns false and no `TableBlock` is created. The interior rows
2122    //   (`| a | b |`) do look like table rows but since no table_block exists
2123    //   for them, they may trigger the "headerless" check — but the preceding
2124    //   `+---+---+` line is not a delimiter row, so no table context is built.
2125    // - Multi-line table separators have no `|`, same exclusion.
2126    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
2127    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
2128    // - Pipe-table captions (`: caption`) have no `|` — excluded.
2129    //
2130    // If `find_table_blocks` ever changes to include these constructs, these
2131    // tests will surface that.
2132
2133    #[test]
2134    fn md075_pandoc_grid_tables_not_flagged() {
2135        let rule = MD075OrphanedTableRows::default();
2136        let content = "\
2137+---+---+
2138| a | b |
2139+===+===+
2140| 1 | 2 |
2141+---+---+
2142";
2143        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2144        let result = rule.check(&ctx).unwrap();
2145        assert!(
2146            result.is_empty(),
2147            "MD075 should not flag Pandoc grid tables: {result:?}"
2148        );
2149
2150        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2151        let result_std = rule.check(&ctx_std).unwrap();
2152        assert!(
2153            result_std.is_empty(),
2154            "MD075 should not flag grid-table-like content under Standard: {result_std:?}"
2155        );
2156    }
2157
2158    #[test]
2159    fn md075_pandoc_multi_line_tables_not_flagged() {
2160        let rule = MD075OrphanedTableRows::default();
2161        let content = "\
2162--------- -----------
2163Header 1   Header 2
2164--------- -----------
2165Cell 1     Cell 2
2166--------- -----------
2167";
2168        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2169        let result = rule.check(&ctx).unwrap();
2170        assert!(
2171            result.is_empty(),
2172            "MD075 should not flag Pandoc multi-line tables: {result:?}"
2173        );
2174
2175        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2176        let result_std = rule.check(&ctx_std).unwrap();
2177        assert!(
2178            result_std.is_empty(),
2179            "MD075 should not flag multi-line table content under Standard: {result_std:?}"
2180        );
2181    }
2182
2183    #[test]
2184    fn md075_pandoc_line_blocks_not_flagged() {
2185        let rule = MD075OrphanedTableRows::default();
2186        // Pandoc line blocks: `| text` lines without trailing `|`.
2187        // is_potential_table_row requires valid_parts >= 2 for non-outer-piped
2188        // lines, so line blocks with a single cell are excluded.
2189        let content = "| First line\n| Second line\n";
2190        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2191        let result = rule.check(&ctx).unwrap();
2192        assert!(
2193            result.is_empty(),
2194            "MD075 should not treat Pandoc line blocks as orphaned table rows: {result:?}"
2195        );
2196
2197        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2198        let result_std = rule.check(&ctx_std).unwrap();
2199        assert!(
2200            result_std.is_empty(),
2201            "MD075 should not treat line-block-like content as orphaned rows under Standard: {result_std:?}"
2202        );
2203    }
2204
2205    #[test]
2206    fn md075_pandoc_pipe_table_captions_not_flagged() {
2207        let rule = MD075OrphanedTableRows::default();
2208        // Pipe-table captions (`: caption`) have no `|` — they are not pipe rows
2209        // and cannot appear in table_blocks or trigger is_table_row_line.
2210        let content = "\
2211| H1 | H2 |
2212|----|-----|
2213| a  | b  |
2214
2215: My table caption
2216";
2217        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2218        let result = rule.check(&ctx).unwrap();
2219        assert!(
2220            result.is_empty(),
2221            "MD075 should not flag the pipe-table caption line as orphaned: {result:?}"
2222        );
2223
2224        let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2225        let result_std = rule.check(&ctx_std).unwrap();
2226        assert!(
2227            result_std.is_empty(),
2228            "MD075 table with caption — caption not a pipe row under Standard: {result_std:?}"
2229        );
2230    }
2231
2232    #[test]
2233    fn md075_obsidian_wikilink_paragraph_not_orphaned_row() {
2234        let rule = MD075OrphanedTableRows::default();
2235        // The paragraph is prose rather than a row that lost its table
2236        let content = "\
2237| Character | Note     |
2238| --------- | -------- |
2239| Alice     | curious  |
2240
2241She saw [[White Rabbit|the Rabbit]] run past and went down the hole.
2242";
2243        for flavor in all_flavors() {
2244            let ctx = LintContext::new(content, flavor, None);
2245            let warnings = rule.check(&ctx).unwrap();
2246            assert!(
2247                warnings.is_empty(),
2248                "MD075 should not treat a wikilink paragraph as an orphaned row for {}: {warnings:?}",
2249                flavor.name()
2250            );
2251        }
2252    }
2253
2254    #[test]
2255    fn md075_obsidian_genuine_orphaned_row_still_flagged() {
2256        let rule = MD075OrphanedTableRows::default();
2257        // A real orphaned row is still reported under Obsidian
2258        let content = "\
2259| Character | Note     |
2260| --------- | -------- |
2261| Alice     | curious  |
2262
2263| Hatter    | mad      |
2264";
2265        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
2266        assert!(
2267            !rule.check(&ctx).unwrap().is_empty(),
2268            "MD075 should still flag a genuine orphaned row under Obsidian"
2269        );
2270    }
2271
2272    /// A headerless group admits its rows flavor-aware, so it has to count their
2273    /// columns the same way. Counting flavor-blind made the two answers disagree,
2274    /// in both directions.
2275    #[test]
2276    fn md075_headerless_group_counts_columns_in_the_documents_flavor() {
2277        let rule = MD075OrphanedTableRows::default();
2278
2279        // Two columns under Obsidian, so the group is consistent and orphaned.
2280        // Counted as GFM the first row has three cells and the group looks ragged.
2281        let consistent_only_in_obsidian = "\
2282| Character | Note     |
2283| --------- | -------- |
2284| Alice     | curious  |
2285
2286Prose between the table and the rows below it.
2287
2288| [[White Rabbit|the Rabbit]] | late |
2289| Hatter                      | mad  |
2290";
2291        let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Obsidian, None);
2292        assert!(
2293            !rule.check(&ctx).unwrap().is_empty(),
2294            "A two-column headerless group should be flagged under Obsidian"
2295        );
2296        let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Standard, None);
2297        assert!(
2298            rule.check(&ctx).unwrap().is_empty(),
2299            "Under Standard the same rows are 3 and 2 cells wide, so they are not a group"
2300        );
2301
2302        // The mirror image: consistent only when the wikilink pipe is a delimiter.
2303        let consistent_only_in_gfm = "\
2304| Character | Note     |
2305| --------- | -------- |
2306| Alice     | curious  |
2307
2308Prose between the table and the rows below it.
2309
2310| [[White Rabbit|the Rabbit]] | late |
2311| Hatter                      | mad  | tea |
2312";
2313        let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Obsidian, None);
2314        assert!(
2315            rule.check(&ctx).unwrap().is_empty(),
2316            "Under Obsidian the rows are 2 and 3 cells wide, so they are not a group"
2317        );
2318        let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Standard, None);
2319        assert!(
2320            !rule.check(&ctx).unwrap().is_empty(),
2321            "A three-column headerless group should be flagged under Standard"
2322        );
2323    }
2324}