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::ensure_consistent_line_endings;
7use crate::utils::fix_utils::apply_warning_fixes;
8use crate::utils::table_utils::TableUtils;
9
10/// Rule MD075: Orphaned table rows / headerless tables
11///
12/// See [docs/md075.md](../../docs/md075.md) for full documentation and examples.
13///
14/// Detects two cases:
15/// 1. Pipe-delimited rows separated from a preceding table by blank lines (auto-fixable)
16/// 2. Standalone pipe-formatted rows without a table header/delimiter (warn only)
17#[derive(Clone)]
18pub struct MD075OrphanedTableRows {
19    md060_formatter: MD060TableFormat,
20}
21
22/// Represents a group of orphaned rows after a table (Case 1)
23struct OrphanedGroup {
24    /// Start line of the preceding table block (0-indexed)
25    table_start: usize,
26    /// End line of the preceding table block (0-indexed)
27    table_end: usize,
28    /// Expected table column count derived from the original table header
29    expected_columns: usize,
30    /// First blank line separating orphaned rows from the table
31    blank_start: usize,
32    /// Last blank line before the orphaned rows
33    blank_end: usize,
34    /// The orphaned row lines (0-indexed)
35    row_lines: Vec<usize>,
36}
37
38/// Represents standalone headerless pipe content (Case 2)
39struct HeaderlessGroup {
40    /// The first line of the group (0-indexed)
41    start_line: usize,
42    /// All lines in the group (0-indexed)
43    lines: Vec<usize>,
44}
45
46impl MD075OrphanedTableRows {
47    fn with_formatter(md060_formatter: MD060TableFormat) -> Self {
48        Self { md060_formatter }
49    }
50
51    /// Check if a line should be skipped (frontmatter, code block, HTML, ESM, mkdocstrings)
52    fn should_skip_line(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
53        if let Some(line_info) = ctx.lines.get(line_idx) {
54            line_info.in_front_matter
55                || line_info.in_code_block
56                || line_info.in_html_block
57                || line_info.in_html_comment
58                || line_info.in_mdx_comment
59                || line_info.in_esm_block
60                || line_info.in_mkdocstrings
61        } else {
62            false
63        }
64    }
65
66    /// Check if a line is a potential table row, handling blockquote prefixes
67    fn is_table_row_line(&self, line: &str) -> bool {
68        let content = Self::strip_blockquote_prefix(line);
69        TableUtils::is_potential_table_row(content)
70    }
71
72    /// Check if a line is a delimiter row, handling blockquote prefixes
73    fn is_delimiter_line(&self, line: &str) -> bool {
74        let content = Self::strip_blockquote_prefix(line);
75        TableUtils::is_delimiter_row(content)
76    }
77
78    /// Strip blockquote prefix from a line, returning the content after it
79    fn strip_blockquote_prefix(line: &str) -> &str {
80        let trimmed = line.trim_start();
81        if !trimmed.starts_with('>') {
82            return line;
83        }
84        let mut rest = trimmed;
85        while rest.starts_with('>') {
86            rest = rest[1..].trim_start();
87        }
88        rest
89    }
90
91    /// Check if a line is blank (including blockquote continuation lines like ">")
92    fn is_blank_line(line: &str) -> bool {
93        crate::utils::regex_cache::is_blank_in_blockquote_context(line)
94    }
95
96    /// Heuristic to detect templating syntax (Liquid/Jinja-style markers).
97    fn contains_template_marker(line: &str) -> bool {
98        let trimmed = line.trim();
99        trimmed.contains("{%")
100            || trimmed.contains("%}")
101            || trimmed.contains("{{")
102            || trimmed.contains("}}")
103            || trimmed.contains("{#")
104            || trimmed.contains("#}")
105    }
106
107    /// Detect lines that are pure template directives (e.g., `{% data ... %}`).
108    fn is_template_directive_line(line: &str) -> bool {
109        let trimmed = line.trim();
110        (trimmed.starts_with("{%")
111            || trimmed.starts_with("{%-")
112            || trimmed.starts_with("{{")
113            || trimmed.starts_with("{{-"))
114            && (trimmed.ends_with("%}")
115                || trimmed.ends_with("-%}")
116                || trimmed.ends_with("}}")
117                || trimmed.ends_with("-}}"))
118    }
119
120    /// Pipe-bearing lines with template markers are often generated fragments, not literal tables.
121    fn is_templated_pipe_line(line: &str) -> bool {
122        let content = Self::strip_blockquote_prefix(line).trim();
123        content.contains('|') && Self::contains_template_marker(content)
124    }
125
126    /// Row-like line with pipes that is not itself a valid table row, often used
127    /// as an in-table section divider (for example: `Search||`).
128    fn is_sparse_table_row_hint(line: &str) -> bool {
129        let content = Self::strip_blockquote_prefix(line).trim();
130        if content.is_empty()
131            || !content.contains('|')
132            || Self::contains_template_marker(content)
133            || TableUtils::is_delimiter_row(content)
134            || TableUtils::is_potential_table_row(content)
135        {
136            return false;
137        }
138
139        let has_edge_pipe = content.starts_with('|') || content.ends_with('|');
140        let has_repeated_pipe = content.contains("||");
141        let non_empty_parts = content.split('|').filter(|part| !part.trim().is_empty()).count();
142
143        non_empty_parts >= 1 && (has_edge_pipe || has_repeated_pipe)
144    }
145
146    /// Headerless groups after a sparse row that is itself inside a table context
147    /// are likely false positives caused by parser table-block boundaries.
148    fn preceded_by_sparse_table_context(content_lines: &[&str], start_line: usize) -> bool {
149        let mut idx = start_line;
150        while idx > 0 {
151            idx -= 1;
152            let content = Self::strip_blockquote_prefix(content_lines[idx]).trim();
153            if content.is_empty() {
154                continue;
155            }
156
157            if !Self::is_sparse_table_row_hint(content) {
158                return false;
159            }
160
161            let mut scan = idx;
162            while scan > 0 {
163                scan -= 1;
164                let prev = Self::strip_blockquote_prefix(content_lines[scan]).trim();
165                if prev.is_empty() {
166                    break;
167                }
168                if TableUtils::is_delimiter_row(prev) {
169                    return true;
170                }
171            }
172
173            return false;
174        }
175
176        false
177    }
178
179    /// Headerless rows immediately following a template directive are likely generated table fragments.
180    fn preceded_by_template_directive(content_lines: &[&str], start_line: usize) -> bool {
181        let mut idx = start_line;
182        while idx > 0 {
183            idx -= 1;
184            let content = Self::strip_blockquote_prefix(content_lines[idx]).trim();
185            if content.is_empty() {
186                continue;
187            }
188
189            return Self::is_template_directive_line(content);
190        }
191
192        false
193    }
194
195    /// Count visual indentation width where tab is treated as 4 spaces.
196    fn indentation_width(line: &str) -> usize {
197        let mut width = 0;
198        for b in line.bytes() {
199            match b {
200                b' ' => width += 1,
201                b'\t' => width += 4,
202                _ => break,
203            }
204        }
205        width
206    }
207
208    /// Count blockquote nesting depth for context matching.
209    fn blockquote_depth(line: &str) -> usize {
210        let (prefix, _) = TableUtils::extract_blockquote_prefix(line);
211        prefix.bytes().filter(|&b| b == b'>').count()
212    }
213
214    /// Ensure candidate orphan rows are in the same render context as the table.
215    ///
216    /// This prevents removing blank lines across boundaries where merging is invalid,
217    /// such as table -> blockquote row transitions or list-context changes.
218    fn row_matches_table_context(
219        &self,
220        table_block: &crate::utils::table_utils::TableBlock,
221        content_lines: &[&str],
222        row_idx: usize,
223    ) -> bool {
224        let table_start_line = content_lines[table_block.start_line];
225        let candidate_line = content_lines[row_idx];
226
227        if Self::blockquote_depth(table_start_line) != Self::blockquote_depth(candidate_line) {
228            return false;
229        }
230
231        let (_, candidate_after_blockquote) = TableUtils::extract_blockquote_prefix(candidate_line);
232        let (candidate_list_prefix, _, _) = TableUtils::extract_list_prefix(candidate_after_blockquote);
233        let candidate_indent = Self::indentation_width(candidate_after_blockquote);
234
235        if let Some(list_ctx) = &table_block.list_context {
236            // Table continuation rows in lists must stay continuation rows, not new list items.
237            if !candidate_list_prefix.is_empty() {
238                return false;
239            }
240            candidate_indent >= list_ctx.content_indent && candidate_indent < list_ctx.content_indent + 4
241        } else {
242            // Avoid crossing into list/code contexts for non-list tables.
243            candidate_list_prefix.is_empty() && candidate_indent < 4
244        }
245    }
246
247    /// Detect Case 1: Orphaned rows after existing tables
248    fn detect_orphaned_rows(
249        &self,
250        ctx: &crate::lint_context::LintContext,
251        content_lines: &[&str],
252        table_line_set: &HashSet<usize>,
253    ) -> Vec<OrphanedGroup> {
254        let mut groups = Vec::new();
255
256        for table_block in &ctx.table_blocks {
257            let end = table_block.end_line;
258            let header_content =
259                TableUtils::extract_table_row_content(content_lines[table_block.start_line], table_block, 0);
260            let expected_columns = TableUtils::count_cells_with_flavor(header_content, ctx.flavor);
261
262            // Scan past end of table for blank lines followed by pipe rows
263            let mut i = end + 1;
264            let mut blank_start = None;
265            let mut blank_end = None;
266
267            // Find blank lines after the table
268            while i < content_lines.len() {
269                if self.should_skip_line(ctx, i) {
270                    break;
271                }
272                if Self::is_blank_line(content_lines[i]) {
273                    if blank_start.is_none() {
274                        blank_start = Some(i);
275                    }
276                    blank_end = Some(i);
277                    i += 1;
278                } else {
279                    break;
280                }
281            }
282
283            // If no blank lines found, no orphan scenario
284            let (Some(bs), Some(be)) = (blank_start, blank_end) else {
285                continue;
286            };
287
288            // Now check if the lines after the blanks are pipe rows not in any table
289            let mut orphan_rows = Vec::new();
290            let mut j = be + 1;
291            while j < content_lines.len() {
292                if self.should_skip_line(ctx, j) {
293                    break;
294                }
295                if table_line_set.contains(&j) {
296                    break;
297                }
298                if self.is_table_row_line(content_lines[j])
299                    && self.row_matches_table_context(table_block, content_lines, j)
300                {
301                    orphan_rows.push(j);
302                    j += 1;
303                } else {
304                    break;
305                }
306            }
307
308            if !orphan_rows.is_empty() {
309                groups.push(OrphanedGroup {
310                    table_start: table_block.start_line,
311                    table_end: table_block.end_line,
312                    expected_columns,
313                    blank_start: bs,
314                    blank_end: be,
315                    row_lines: orphan_rows,
316                });
317            }
318        }
319
320        groups
321    }
322
323    /// Detect pipe rows that directly continue a parsed table block but may not be
324    /// recognized by `table_blocks` (for example rows with inline fence markers).
325    ///
326    /// These rows should not be treated as standalone headerless tables (Case 2).
327    fn detect_table_continuation_rows(
328        &self,
329        ctx: &crate::lint_context::LintContext,
330        content_lines: &[&str],
331        table_line_set: &HashSet<usize>,
332    ) -> HashSet<usize> {
333        let mut continuation_rows = HashSet::new();
334
335        for table_block in &ctx.table_blocks {
336            let mut i = table_block.end_line + 1;
337            while i < content_lines.len() {
338                if self.should_skip_line(ctx, i) || table_line_set.contains(&i) {
339                    break;
340                }
341                if self.is_table_row_line(content_lines[i])
342                    && self.row_matches_table_context(table_block, content_lines, i)
343                {
344                    continuation_rows.insert(i);
345                    i += 1;
346                } else {
347                    break;
348                }
349            }
350        }
351
352        continuation_rows
353    }
354
355    /// Detect Case 2: Standalone headerless pipe content
356    fn detect_headerless_tables(
357        &self,
358        ctx: &crate::lint_context::LintContext,
359        content_lines: &[&str],
360        table_line_set: &HashSet<usize>,
361        orphaned_line_set: &HashSet<usize>,
362        continuation_line_set: &HashSet<usize>,
363    ) -> Vec<HeaderlessGroup> {
364        if self.is_probable_headerless_fragment_file(ctx, content_lines) {
365            return Vec::new();
366        }
367
368        let mut groups = Vec::new();
369        let mut i = 0;
370
371        while i < content_lines.len() {
372            // Skip lines in skip contexts, existing tables, or orphaned groups
373            if self.should_skip_line(ctx, i)
374                || table_line_set.contains(&i)
375                || orphaned_line_set.contains(&i)
376                || continuation_line_set.contains(&i)
377            {
378                i += 1;
379                continue;
380            }
381
382            // Look for consecutive pipe rows
383            if self.is_table_row_line(content_lines[i]) {
384                if Self::is_templated_pipe_line(content_lines[i]) {
385                    i += 1;
386                    continue;
387                }
388
389                // Suppress headerless detection for likely template-generated table fragments.
390                if Self::preceded_by_template_directive(content_lines, i) {
391                    i += 1;
392                    while i < content_lines.len()
393                        && !self.should_skip_line(ctx, i)
394                        && !table_line_set.contains(&i)
395                        && !orphaned_line_set.contains(&i)
396                        && !continuation_line_set.contains(&i)
397                        && self.is_table_row_line(content_lines[i])
398                    {
399                        i += 1;
400                    }
401                    continue;
402                }
403
404                // Suppress headerless detection for rows that likely continue an
405                // existing table through sparse section-divider rows.
406                if Self::preceded_by_sparse_table_context(content_lines, i) {
407                    i += 1;
408                    while i < content_lines.len()
409                        && !self.should_skip_line(ctx, i)
410                        && !table_line_set.contains(&i)
411                        && !orphaned_line_set.contains(&i)
412                        && !continuation_line_set.contains(&i)
413                        && self.is_table_row_line(content_lines[i])
414                    {
415                        i += 1;
416                    }
417                    continue;
418                }
419
420                let start = i;
421                let mut group_lines = vec![i];
422                i += 1;
423
424                while i < content_lines.len()
425                    && !self.should_skip_line(ctx, i)
426                    && !table_line_set.contains(&i)
427                    && !orphaned_line_set.contains(&i)
428                    && !continuation_line_set.contains(&i)
429                    && self.is_table_row_line(content_lines[i])
430                {
431                    if Self::is_templated_pipe_line(content_lines[i]) {
432                        break;
433                    }
434                    group_lines.push(i);
435                    i += 1;
436                }
437
438                // Need at least 2 consecutive pipe rows to flag
439                if group_lines.len() >= 2 {
440                    // Check that none of these lines is a delimiter row that would make
441                    // them a valid table header+delimiter combination
442                    let has_delimiter = group_lines
443                        .iter()
444                        .any(|&idx| self.is_delimiter_line(content_lines[idx]));
445
446                    if !has_delimiter {
447                        // Verify consistent column count
448                        let first_content = Self::strip_blockquote_prefix(content_lines[group_lines[0]]);
449                        let first_count = TableUtils::count_cells(first_content);
450                        let consistent = group_lines.iter().all(|&idx| {
451                            let content = Self::strip_blockquote_prefix(content_lines[idx]);
452                            TableUtils::count_cells(content) == first_count
453                        });
454
455                        if consistent && first_count > 0 {
456                            groups.push(HeaderlessGroup {
457                                start_line: start,
458                                lines: group_lines,
459                            });
460                        }
461                    }
462                }
463            } else {
464                i += 1;
465            }
466        }
467
468        groups
469    }
470
471    /// Some repositories store reusable table-row snippets as standalone files
472    /// (headerless by design). Suppress Case 2 warnings for those fragment files.
473    fn is_probable_headerless_fragment_file(
474        &self,
475        ctx: &crate::lint_context::LintContext,
476        content_lines: &[&str],
477    ) -> bool {
478        if !ctx.table_blocks.is_empty() {
479            return false;
480        }
481
482        let mut row_count = 0usize;
483
484        for (idx, line) in content_lines.iter().enumerate() {
485            if self.should_skip_line(ctx, idx) {
486                continue;
487            }
488
489            let content = Self::strip_blockquote_prefix(line).trim();
490            if content.is_empty() {
491                continue;
492            }
493
494            if Self::is_template_directive_line(content) {
495                continue;
496            }
497
498            if TableUtils::is_delimiter_row(content) {
499                return false;
500            }
501
502            // Allow inline template gate rows like `| {% ifversion ... %} |`.
503            if Self::contains_template_marker(content) && content.contains('|') {
504                continue;
505            }
506
507            if self.is_table_row_line(content) {
508                let cols = TableUtils::count_cells_with_flavor(content, ctx.flavor);
509                // Require 3+ columns to avoid suppressing common 2-column headerless issues.
510                if cols < 3 {
511                    return false;
512                }
513                row_count += 1;
514                continue;
515            }
516
517            return false;
518        }
519
520        row_count >= 2
521    }
522
523    /// Build fix edit for a single orphaned-row group by replacing the local table block.
524    fn build_orphan_group_fix(
525        &self,
526        ctx: &crate::lint_context::LintContext,
527        content_lines: &[&str],
528        group: &OrphanedGroup,
529    ) -> Result<Option<Fix>, LintError> {
530        if group.row_lines.is_empty() {
531            return Ok(None);
532        }
533
534        let last_orphan = *group
535            .row_lines
536            .last()
537            .expect("row_lines is non-empty after early return");
538
539        // Be conservative: only auto-merge when orphan rows match original table width.
540        let has_column_mismatch = group
541            .row_lines
542            .iter()
543            .any(|&idx| TableUtils::count_cells_with_flavor(content_lines[idx], ctx.flavor) != group.expected_columns);
544        if has_column_mismatch {
545            return Ok(None);
546        }
547
548        let replacement_range = ctx.line_index.multi_line_range(group.table_start + 1, last_orphan + 1);
549        let original_block = &ctx.content[replacement_range.clone()];
550        let block_has_trailing_newline = original_block.ends_with('\n');
551
552        let mut merged_table_lines: Vec<&str> = (group.table_start..=group.table_end)
553            .map(|idx| content_lines[idx])
554            .collect();
555        merged_table_lines.extend(group.row_lines.iter().map(|&idx| content_lines[idx]));
556
557        let mut merged_block = merged_table_lines.join("\n");
558        if block_has_trailing_newline {
559            merged_block.push('\n');
560        }
561
562        let block_ctx = crate::lint_context::LintContext::new(&merged_block, ctx.flavor, None);
563        let mut normalized_block = self.md060_formatter.fix(&block_ctx)?;
564
565        if !block_has_trailing_newline {
566            normalized_block = normalized_block.trim_end_matches('\n').to_string();
567        } else if !normalized_block.ends_with('\n') {
568            normalized_block.push('\n');
569        }
570
571        let replacement = ensure_consistent_line_endings(original_block, &normalized_block);
572
573        if replacement == original_block {
574            Ok(None)
575        } else {
576            Ok(Some(Fix {
577                range: replacement_range,
578                replacement,
579            }))
580        }
581    }
582}
583
584impl Default for MD075OrphanedTableRows {
585    fn default() -> Self {
586        Self {
587            // MD075 should normalize merged rows even when MD060 is not explicitly enabled.
588            md060_formatter: MD060TableFormat::new(true, "aligned".to_string()),
589        }
590    }
591}
592
593impl Rule for MD075OrphanedTableRows {
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].len() + 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].len() + 1,
680                severity: Severity::Warning,
681                fix: None,
682            });
683        }
684
685        Ok(warnings)
686    }
687
688    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
689        let warnings = self.check(ctx)?;
690        let warnings =
691            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
692        if warnings.iter().all(|warning| warning.fix.is_none()) {
693            return Ok(ctx.content.to_string());
694        }
695
696        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
697    }
698
699    fn fix_capability(&self) -> FixCapability {
700        FixCapability::ConditionallyFixable
701    }
702
703    fn as_any(&self) -> &dyn std::any::Any {
704        self
705    }
706
707    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
708    where
709        Self: Sized,
710    {
711        let mut md060_config = crate::rule_config_serde::load_rule_config::<MD060Config>(_config);
712        if md060_config.style == "any" {
713            // MD075 should normalize merged tables by default; "any" preserves broken alignment.
714            md060_config.style = "aligned".to_string();
715        }
716        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(_config);
717        let md013_disabled = _config
718            .global
719            .disable
720            .iter()
721            .chain(_config.global.extend_disable.iter())
722            .any(|rule| rule.trim().eq_ignore_ascii_case("MD013"));
723        let formatter = MD060TableFormat::from_config_struct(md060_config, md013_config, md013_disabled);
724        Box::new(Self::with_formatter(formatter))
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use proptest::prelude::*;
731
732    use super::*;
733    use crate::config::MarkdownFlavor;
734    use crate::lint_context::LintContext;
735    use crate::utils::fix_utils::apply_warning_fixes;
736
737    // =========================================================================
738    // Case 1: Orphaned rows after a table
739    // =========================================================================
740
741    #[test]
742    fn test_orphaned_rows_after_table() {
743        let rule = MD075OrphanedTableRows::default();
744        let content = "\
745| Value        | Description       |
746| ------------ | ----------------- |
747| `consistent` | Default style     |
748
749| `fenced`     | Fenced style      |
750| `indented`   | Indented style    |";
751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752        let result = rule.check(&ctx).unwrap();
753
754        assert_eq!(result.len(), 1);
755        assert!(result[0].message.contains("Orphaned table row"));
756        assert!(result[0].fix.is_some());
757    }
758
759    #[test]
760    fn test_orphaned_single_row_after_table() {
761        let rule = MD075OrphanedTableRows::default();
762        let content = "\
763| H1 | H2 |
764|----|-----|
765| a  | b   |
766
767| c  | d   |";
768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769        let result = rule.check(&ctx).unwrap();
770
771        assert_eq!(result.len(), 1);
772        assert!(result[0].message.contains("Orphaned table row"));
773    }
774
775    #[test]
776    fn test_orphaned_rows_multiple_blank_lines() {
777        let rule = MD075OrphanedTableRows::default();
778        let content = "\
779| H1 | H2 |
780|----|-----|
781| a  | b   |
782
783
784| c  | d   |
785| e  | f   |";
786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787        let result = rule.check(&ctx).unwrap();
788
789        assert_eq!(result.len(), 1);
790        assert!(result[0].message.contains("2 blank line(s)"));
791    }
792
793    #[test]
794    fn test_fix_orphaned_rows() {
795        let rule = MD075OrphanedTableRows::default();
796        let content = "\
797| Value        | Description       |
798| ------------ | ----------------- |
799| `consistent` | Default style     |
800
801| `fenced`     | Fenced style      |
802| `indented`   | Indented style    |";
803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804        let fixed = rule.fix(&ctx).unwrap();
805
806        let expected = "\
807| Value        | Description       |
808| ------------ | ----------------- |
809| `consistent` | Default style     |
810| `fenced`     | Fenced style      |
811| `indented`   | Indented style    |";
812        assert_eq!(fixed, expected);
813    }
814
815    #[test]
816    fn test_fix_orphaned_rows_multiple_blanks() {
817        let rule = MD075OrphanedTableRows::default();
818        let content = "\
819| H1 | H2 |
820|----|-----|
821| a  | b   |
822
823
824| c  | d   |";
825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826        let fixed = rule.fix(&ctx).unwrap();
827
828        let expected = "\
829| H1  | H2  |
830| --- | --- |
831| a   | b   |
832| c   | d   |";
833        assert_eq!(fixed, expected);
834    }
835
836    #[test]
837    fn test_no_orphan_with_text_between() {
838        let rule = MD075OrphanedTableRows::default();
839        let content = "\
840| H1 | H2 |
841|----|-----|
842| a  | b   |
843
844Some text here.
845
846| c  | d   |
847| e  | f   |";
848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849        let result = rule.check(&ctx).unwrap();
850
851        // Non-blank content between table and pipe rows means not orphaned
852        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
853        assert_eq!(orphan_warnings.len(), 0);
854    }
855
856    #[test]
857    fn test_valid_consecutive_tables_not_flagged() {
858        let rule = MD075OrphanedTableRows::default();
859        let content = "\
860| H1 | H2 |
861|----|-----|
862| a  | b   |
863
864| H3 | H4 |
865|----|-----|
866| c  | d   |";
867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868        let result = rule.check(&ctx).unwrap();
869
870        // Two valid tables separated by a blank line produce no warnings
871        assert_eq!(result.len(), 0);
872    }
873
874    #[test]
875    fn test_orphaned_rows_with_different_column_count() {
876        let rule = MD075OrphanedTableRows::default();
877        let content = "\
878| H1 | H2 | H3 |
879|----|-----|-----|
880| a  | b   | c   |
881
882| d  | e   |";
883        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884        let result = rule.check(&ctx).unwrap();
885
886        // Different column count should still flag as orphaned
887        assert_eq!(result.len(), 1);
888        assert!(result[0].message.contains("Orphaned"));
889        assert!(result[0].fix.is_none());
890    }
891
892    // =========================================================================
893    // Case 2: Headerless pipe content
894    // =========================================================================
895
896    #[test]
897    fn test_headerless_pipe_content() {
898        let rule = MD075OrphanedTableRows::default();
899        let content = "\
900Some text.
901
902| value1 | description1 |
903| value2 | description2 |
904
905More text.";
906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907        let result = rule.check(&ctx).unwrap();
908
909        assert_eq!(result.len(), 1);
910        assert!(result[0].message.contains("without a table header"));
911        assert!(result[0].fix.is_none());
912    }
913
914    #[test]
915    fn test_single_pipe_row_not_flagged() {
916        let rule = MD075OrphanedTableRows::default();
917        let content = "\
918Some text.
919
920| value1 | description1 |
921
922More text.";
923        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924        let result = rule.check(&ctx).unwrap();
925
926        // Single standalone pipe row is not flagged (Case 2 requires 2+)
927        assert_eq!(result.len(), 0);
928    }
929
930    #[test]
931    fn test_headerless_multiple_rows() {
932        let rule = MD075OrphanedTableRows::default();
933        let content = "\
934| a | b |
935| c | d |
936| e | f |";
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938        let result = rule.check(&ctx).unwrap();
939
940        assert_eq!(result.len(), 1);
941        assert!(result[0].message.contains("without a table header"));
942    }
943
944    #[test]
945    fn test_headerless_inconsistent_columns_not_flagged() {
946        let rule = MD075OrphanedTableRows::default();
947        let content = "\
948| a | b |
949| c | d | e |";
950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951        let result = rule.check(&ctx).unwrap();
952
953        // Inconsistent column count is not flagged as headerless table
954        assert_eq!(result.len(), 0);
955    }
956
957    #[test]
958    fn test_headerless_not_flagged_when_has_delimiter() {
959        let rule = MD075OrphanedTableRows::default();
960        let content = "\
961| H1 | H2 |
962|----|-----|
963| a  | b   |";
964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965        let result = rule.check(&ctx).unwrap();
966
967        // Valid table with header/delimiter produces no warnings
968        assert_eq!(result.len(), 0);
969    }
970
971    // =========================================================================
972    // Edge cases
973    // =========================================================================
974
975    #[test]
976    fn test_pipe_rows_in_code_block_ignored() {
977        let rule = MD075OrphanedTableRows::default();
978        let content = "\
979```
980| a | b |
981| c | d |
982```";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985
986        assert_eq!(result.len(), 0);
987    }
988
989    #[test]
990    fn test_pipe_rows_in_frontmatter_ignored() {
991        let rule = MD075OrphanedTableRows::default();
992        let content = "\
993---
994title: test
995---
996
997| a | b |
998| c | d |";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000        let result = rule.check(&ctx).unwrap();
1001
1002        // Frontmatter is skipped, standalone pipe rows after it are flagged
1003        let warnings: Vec<_> = result
1004            .iter()
1005            .filter(|w| w.message.contains("without a table header"))
1006            .collect();
1007        assert_eq!(warnings.len(), 1);
1008    }
1009
1010    #[test]
1011    fn test_no_pipes_at_all() {
1012        let rule = MD075OrphanedTableRows::default();
1013        let content = "Just regular text.\nNo pipes here.\nOnly paragraphs.";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let result = rule.check(&ctx).unwrap();
1016
1017        assert_eq!(result.len(), 0);
1018    }
1019
1020    #[test]
1021    fn test_empty_content() {
1022        let rule = MD075OrphanedTableRows::default();
1023        let content = "";
1024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025        let result = rule.check(&ctx).unwrap();
1026
1027        assert_eq!(result.len(), 0);
1028    }
1029
1030    #[test]
1031    fn test_orphaned_rows_in_blockquote() {
1032        let rule = MD075OrphanedTableRows::default();
1033        let content = "\
1034> | H1 | H2 |
1035> |----|-----|
1036> | a  | b   |
1037>
1038> | c  | d   |";
1039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040        let result = rule.check(&ctx).unwrap();
1041
1042        assert_eq!(result.len(), 1);
1043        assert!(result[0].message.contains("Orphaned"));
1044    }
1045
1046    #[test]
1047    fn test_fix_orphaned_rows_in_blockquote() {
1048        let rule = MD075OrphanedTableRows::default();
1049        let content = "\
1050> | H1 | H2 |
1051> |----|-----|
1052> | a  | b   |
1053>
1054> | c  | d   |";
1055        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056        let fixed = rule.fix(&ctx).unwrap();
1057
1058        let expected = "\
1059> | H1  | H2  |
1060> | --- | --- |
1061> | a   | b   |
1062> | c   | d   |";
1063        assert_eq!(fixed, expected);
1064    }
1065
1066    #[test]
1067    fn test_table_at_end_of_document_no_orphans() {
1068        let rule = MD075OrphanedTableRows::default();
1069        let content = "\
1070| H1 | H2 |
1071|----|-----|
1072| a  | b   |";
1073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074        let result = rule.check(&ctx).unwrap();
1075
1076        assert_eq!(result.len(), 0);
1077    }
1078
1079    #[test]
1080    fn test_table_followed_by_text_no_orphans() {
1081        let rule = MD075OrphanedTableRows::default();
1082        let content = "\
1083| H1 | H2 |
1084|----|-----|
1085| a  | b   |
1086
1087Some text after the table.";
1088        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089        let result = rule.check(&ctx).unwrap();
1090
1091        assert_eq!(result.len(), 0);
1092    }
1093
1094    #[test]
1095    fn test_fix_preserves_content_around_orphans() {
1096        let rule = MD075OrphanedTableRows::default();
1097        let content = "\
1098# Title
1099
1100| H1 | H2 |
1101|----|-----|
1102| a  | b   |
1103
1104| c  | d   |
1105
1106Some text after.";
1107        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108        let fixed = rule.fix(&ctx).unwrap();
1109
1110        let expected = "\
1111# Title
1112
1113| H1  | H2  |
1114| --- | --- |
1115| a   | b   |
1116| c   | d   |
1117
1118Some text after.";
1119        assert_eq!(fixed, expected);
1120    }
1121
1122    #[test]
1123    fn test_multiple_orphan_groups() {
1124        let rule = MD075OrphanedTableRows::default();
1125        let content = "\
1126| H1 | H2 |
1127|----|-----|
1128| a  | b   |
1129
1130| c  | d   |
1131
1132| H3 | H4 |
1133|----|-----|
1134| e  | f   |
1135
1136| g  | h   |";
1137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138        let result = rule.check(&ctx).unwrap();
1139
1140        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1141        assert_eq!(orphan_warnings.len(), 2);
1142    }
1143
1144    #[test]
1145    fn test_fix_multiple_orphan_groups() {
1146        let rule = MD075OrphanedTableRows::default();
1147        let content = "\
1148| H1 | H2 |
1149|----|-----|
1150| a  | b   |
1151
1152| c  | d   |
1153
1154| H3 | H4 |
1155|----|-----|
1156| e  | f   |
1157
1158| g  | h   |";
1159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160        let fixed = rule.fix(&ctx).unwrap();
1161
1162        let expected = "\
1163| H1  | H2  |
1164| --- | --- |
1165| a   | b   |
1166| c   | d   |
1167
1168| H3  | H4  |
1169| --- | --- |
1170| e   | f   |
1171| g   | h   |";
1172        assert_eq!(fixed, expected);
1173    }
1174
1175    #[test]
1176    fn test_orphaned_rows_with_delimiter_form_new_table() {
1177        let rule = MD075OrphanedTableRows::default();
1178        // Rows after a blank that themselves form a valid table (header+delimiter)
1179        // are recognized as a separate table by table_blocks, not as orphans
1180        let content = "\
1181| H1 | H2 |
1182|----|-----|
1183| a  | b   |
1184
1185| c  | d   |
1186|----|-----|";
1187        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188        let result = rule.check(&ctx).unwrap();
1189
1190        // The second group forms a valid table, so no orphan warning
1191        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1192        assert_eq!(orphan_warnings.len(), 0);
1193    }
1194
1195    #[test]
1196    fn test_headerless_not_confused_with_orphaned() {
1197        let rule = MD075OrphanedTableRows::default();
1198        let content = "\
1199| H1 | H2 |
1200|----|-----|
1201| a  | b   |
1202
1203Some text.
1204
1205| c  | d   |
1206| e  | f   |";
1207        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208        let result = rule.check(&ctx).unwrap();
1209
1210        // Non-blank content between table and pipe rows means not orphaned
1211        // The standalone rows should be flagged as headerless (Case 2)
1212        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1213        let headerless_warnings: Vec<_> = result
1214            .iter()
1215            .filter(|w| w.message.contains("without a table header"))
1216            .collect();
1217
1218        assert_eq!(orphan_warnings.len(), 0);
1219        assert_eq!(headerless_warnings.len(), 1);
1220    }
1221
1222    #[test]
1223    fn test_fix_does_not_modify_headerless() {
1224        let rule = MD075OrphanedTableRows::default();
1225        let content = "\
1226Some text.
1227
1228| value1 | description1 |
1229| value2 | description2 |
1230
1231More text.";
1232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233        let fixed = rule.fix(&ctx).unwrap();
1234
1235        // Case 2 has no fix, so content should be unchanged
1236        assert_eq!(fixed, content);
1237    }
1238
1239    #[test]
1240    fn test_should_skip_few_pipes() {
1241        let rule = MD075OrphanedTableRows::default();
1242        let content = "a | b";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244
1245        assert!(rule.should_skip(&ctx));
1246    }
1247
1248    #[test]
1249    fn test_should_not_skip_two_pipes_without_outer_pipes() {
1250        let rule = MD075OrphanedTableRows::default();
1251        let content = "\
1252a | b
1253c | d";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255
1256        assert!(!rule.should_skip(&ctx));
1257        let result = rule.check(&ctx).unwrap();
1258        assert_eq!(result.len(), 1);
1259        assert!(result[0].message.contains("without a table header"));
1260    }
1261
1262    #[test]
1263    fn test_fix_capability() {
1264        let rule = MD075OrphanedTableRows::default();
1265        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1266    }
1267
1268    #[test]
1269    fn test_category() {
1270        let rule = MD075OrphanedTableRows::default();
1271        assert_eq!(rule.category(), RuleCategory::Table);
1272    }
1273
1274    #[test]
1275    fn test_issue_420_exact_example() {
1276        // The exact example from issue #420, including inline code fence markers.
1277        let rule = MD075OrphanedTableRows::default();
1278        let content = "\
1279| Value        | Description                                       |
1280| ------------ | ------------------------------------------------- |
1281| `consistent` | All code blocks must use the same style (default) |
1282
1283| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1284| `indented` | All code blocks must use indented style (4 spaces) |";
1285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286        let result = rule.check(&ctx).unwrap();
1287
1288        assert_eq!(result.len(), 1);
1289        assert!(result[0].message.contains("Orphaned"));
1290        assert_eq!(result[0].line, 5);
1291
1292        let fixed = rule.fix(&ctx).unwrap();
1293        let expected = "\
1294| Value        | Description                                        |
1295| ------------ | -------------------------------------------------- |
1296| `consistent` | All code blocks must use the same style (default)  |
1297| `fenced`     | All code blocks must use fenced style (``` or ~~~) |
1298| `indented`   | All code blocks must use indented style (4 spaces) |";
1299        assert_eq!(fixed, expected);
1300    }
1301
1302    #[test]
1303    fn test_prose_with_double_backticks_and_pipes_not_flagged() {
1304        let rule = MD075OrphanedTableRows::default();
1305        let content = "\
1306Use ``a|b`` or ``c|d`` in docs.
1307Prefer ``x|y`` and ``z|w`` examples.";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let result = rule.check(&ctx).unwrap();
1310
1311        assert!(result.is_empty());
1312    }
1313
1314    #[test]
1315    fn test_liquid_filter_lines_not_flagged_as_headerless() {
1316        let rule = MD075OrphanedTableRows::default();
1317        let content = "\
1318If you encounter issues, see [Troubleshooting]({{ '/docs/troubleshooting/' | relative_url }}).
1319Use our [guides]({{ '/docs/installation/' | relative_url }}) for OS-specific steps.";
1320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321        let result = rule.check(&ctx).unwrap();
1322
1323        assert!(result.is_empty());
1324    }
1325
1326    #[test]
1327    fn test_rows_after_template_directive_not_flagged_as_headerless() {
1328        let rule = MD075OrphanedTableRows::default();
1329        let content = "\
1330{% data reusables.enterprise-migration-tool.placeholder-table %}
1331DESTINATION | The name you want the new organization to have.
1332ENTERPRISE | The slug for your destination enterprise.";
1333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334        let result = rule.check(&ctx).unwrap();
1335
1336        assert!(result.is_empty());
1337    }
1338
1339    #[test]
1340    fn test_templated_pipe_rows_not_flagged_as_headerless() {
1341        let rule = MD075OrphanedTableRows::default();
1342        let content = "\
1343| Feature{%- for version in group_versions %} | {{ version }}{%- endfor %} |
1344|:----{%- for version in group_versions %}|:----:{%- endfor %}|
1345| {{ feature }} | {{ value }} |";
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        let result = rule.check(&ctx).unwrap();
1348
1349        assert!(result.is_empty());
1350    }
1351
1352    #[test]
1353    fn test_escaped_pipe_rows_in_table_not_flagged_as_headerless() {
1354        let rule = MD075OrphanedTableRows::default();
1355        let content = "\
1356Written as                             | Interpreted as
1357---------------------------------------|-----------------------------------------
1358`!foo && bar`                          | `(!foo) && bar`
1359<code>!foo \\|\\| bar </code>            | `(!foo) \\|\\| bar`
1360<code>foo \\|\\| bar && baz </code>      | <code>foo \\|\\| (bar && baz)</code>
1361<code>!foo && bar \\|\\| baz </code>     | <code>(!foo && bar) \\|\\| baz</code>";
1362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363        let result = rule.check(&ctx).unwrap();
1364
1365        assert!(result.is_empty());
1366    }
1367
1368    #[test]
1369    fn test_rows_after_sparse_section_row_in_table_not_flagged() {
1370        let rule = MD075OrphanedTableRows::default();
1371        let content = "\
1372Key|Command|Command id
1373---|-------|----------
1374Search||
1375`kb(history.showNext)`|Next Search Term|`history.showNext`
1376`kb(history.showPrevious)`|Previous Search Term|`history.showPrevious`
1377Extensions||
1378`unassigned`|Update All Extensions|`workbench.extensions.action.updateAllExtensions`";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380        let result = rule.check(&ctx).unwrap();
1381
1382        assert!(result.is_empty());
1383    }
1384
1385    #[test]
1386    fn test_sparse_row_without_table_context_does_not_suppress_headerless() {
1387        let rule = MD075OrphanedTableRows::default();
1388        let content = "\
1389Notes ||
1390`alpha` | `beta`
1391`gamma` | `delta`";
1392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393        let result = rule.check(&ctx).unwrap();
1394
1395        assert_eq!(result.len(), 1);
1396        assert!(result[0].message.contains("without a table header"));
1397    }
1398
1399    #[test]
1400    fn test_reusable_three_column_fragment_not_flagged_as_headerless() {
1401        let rule = MD075OrphanedTableRows::default();
1402        let content = "\
1403`label` | `object` | The label added or removed from the issue.
1404`label[name]` | `string` | The name of the label.
1405`label[color]` | `string` | The hex color code.";
1406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407        let result = rule.check(&ctx).unwrap();
1408
1409        assert!(result.is_empty());
1410    }
1411
1412    #[test]
1413    fn test_orphan_detection_does_not_cross_blockquote_context() {
1414        let rule = MD075OrphanedTableRows::default();
1415        let content = "\
1416| H1 | H2 |
1417|----|-----|
1418| a  | b   |
1419
1420> | c  | d   |";
1421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422        let result = rule.check(&ctx).unwrap();
1423        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1424
1425        assert_eq!(orphan_warnings.len(), 0);
1426        assert_eq!(rule.fix(&ctx).unwrap(), content);
1427    }
1428
1429    #[test]
1430    fn test_orphan_fix_does_not_cross_list_context() {
1431        let rule = MD075OrphanedTableRows::default();
1432        let content = "\
1433- | H1 | H2 |
1434  |----|-----|
1435  | a  | b   |
1436
1437| c  | d   |";
1438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439        let result = rule.check(&ctx).unwrap();
1440        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1441
1442        assert_eq!(orphan_warnings.len(), 0);
1443        assert_eq!(rule.fix(&ctx).unwrap(), content);
1444    }
1445
1446    #[test]
1447    fn test_fix_normalizes_only_merged_table() {
1448        let rule = MD075OrphanedTableRows::default();
1449        let content = "\
1450| H1 | H2 |
1451|----|-----|
1452| a  | b   |
1453
1454| c  | d   |
1455
1456| Name | Age |
1457|---|---|
1458|alice|30|";
1459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460        let fixed = rule.fix(&ctx).unwrap();
1461
1462        assert!(fixed.contains("| H1  | H2  |"));
1463        assert!(fixed.contains("| c   | d   |"));
1464        // Unrelated second table should keep original compact formatting.
1465        assert!(fixed.contains("|---|---|"));
1466        assert!(fixed.contains("|alice|30|"));
1467    }
1468
1469    #[test]
1470    fn test_html_comment_pipe_rows_ignored() {
1471        let rule = MD075OrphanedTableRows::default();
1472        let content = "\
1473<!--
1474| a | b |
1475| c | d |
1476-->";
1477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478        let result = rule.check(&ctx).unwrap();
1479
1480        assert_eq!(result.len(), 0);
1481    }
1482
1483    #[test]
1484    fn test_orphan_detection_does_not_cross_skip_contexts() {
1485        let rule = MD075OrphanedTableRows::default();
1486        let content = "\
1487| H1 | H2 |
1488|----|-----|
1489| a  | b   |
1490
1491```
1492| c  | d   |
1493```";
1494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495        let result = rule.check(&ctx).unwrap();
1496
1497        // Pipe rows inside code block should not be flagged as orphaned
1498        assert_eq!(result.len(), 0);
1499    }
1500
1501    #[test]
1502    fn test_pipe_rows_in_esm_block_ignored() {
1503        let rule = MD075OrphanedTableRows::default();
1504        // ESM blocks use import/export statements; pipe rows inside should be skipped
1505        let content = "\
1506<script type=\"module\">
1507| a | b |
1508| c | d |
1509</script>";
1510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511        let result = rule.check(&ctx).unwrap();
1512
1513        // All pipe rows are inside an HTML/ESM block, no warnings expected
1514        assert_eq!(result.len(), 0);
1515    }
1516
1517    #[test]
1518    fn test_fix_range_covers_blank_lines_correctly() {
1519        let rule = MD075OrphanedTableRows::default();
1520        let content = "\
1521# Before
1522
1523| H1 | H2 |
1524|----|-----|
1525| a  | b   |
1526
1527| c  | d   |
1528
1529# After";
1530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531        let warnings = rule.check(&ctx).unwrap();
1532        let expected = "\
1533# Before
1534
1535| H1  | H2  |
1536| --- | --- |
1537| a   | b   |
1538| c   | d   |
1539
1540# After";
1541
1542        assert_eq!(warnings.len(), 1);
1543        let fix = warnings[0].fix.as_ref().unwrap();
1544        assert!(fix.range.start > 0);
1545        assert!(fix.range.end < content.len());
1546
1547        let cli_fixed = rule.fix(&ctx).unwrap();
1548        assert_eq!(cli_fixed, expected);
1549
1550        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1551        assert_eq!(lsp_fixed, expected);
1552        assert_eq!(lsp_fixed, cli_fixed);
1553    }
1554
1555    #[test]
1556    fn test_fix_range_multiple_blanks() {
1557        let rule = MD075OrphanedTableRows::default();
1558        let content = "\
1559# Before
1560
1561| H1 | H2 |
1562|----|-----|
1563| a  | b   |
1564
1565
1566| c  | d   |";
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let warnings = rule.check(&ctx).unwrap();
1569        let expected = "\
1570# Before
1571
1572| H1  | H2  |
1573| --- | --- |
1574| a   | b   |
1575| c   | d   |";
1576
1577        assert_eq!(warnings.len(), 1);
1578        let fix = warnings[0].fix.as_ref().unwrap();
1579        assert!(fix.range.start > 0);
1580        assert_eq!(fix.range.end, content.len());
1581
1582        let cli_fixed = rule.fix(&ctx).unwrap();
1583        assert_eq!(cli_fixed, expected);
1584
1585        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1586        assert_eq!(lsp_fixed, expected);
1587        assert_eq!(lsp_fixed, cli_fixed);
1588    }
1589
1590    #[test]
1591    fn test_warning_fixes_match_rule_fix_for_multiple_orphan_groups() {
1592        let rule = MD075OrphanedTableRows::default();
1593        let content = "\
1594| H1 | H2 |
1595|----|-----|
1596| a  | b   |
1597
1598| c  | d   |
1599
1600| H3 | H4 |
1601|----|-----|
1602| e  | f   |
1603
1604| g  | h   |";
1605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1606        let warnings = rule.check(&ctx).unwrap();
1607
1608        let orphan_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Orphaned")).collect();
1609        assert_eq!(orphan_warnings.len(), 2);
1610
1611        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1612        let cli_fixed = rule.fix(&ctx).unwrap();
1613
1614        assert_eq!(lsp_fixed, cli_fixed);
1615        assert_ne!(cli_fixed, content);
1616    }
1617
1618    #[test]
1619    fn test_issue_420_fix_is_idempotent() {
1620        let rule = MD075OrphanedTableRows::default();
1621        let content = "\
1622| Value        | Description                                       |
1623| ------------ | ------------------------------------------------- |
1624| `consistent` | All code blocks must use the same style (default) |
1625
1626| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1627| `indented` | All code blocks must use indented style (4 spaces) |";
1628
1629        let initial_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630        let fixed_once = rule.fix(&initial_ctx).unwrap();
1631
1632        let fixed_ctx = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1633        let warnings_after_fix = rule.check(&fixed_ctx).unwrap();
1634        assert_eq!(warnings_after_fix.len(), 0);
1635
1636        let fixed_twice = rule.fix(&fixed_ctx).unwrap();
1637        assert_eq!(fixed_twice, fixed_once);
1638    }
1639
1640    #[test]
1641    fn test_from_config_respects_md060_compact_style_for_merged_table() {
1642        let mut config = crate::config::Config::default();
1643        let mut md060_rule_config = crate::config::RuleConfig::default();
1644        md060_rule_config
1645            .values
1646            .insert("style".to_string(), toml::Value::String("compact".to_string()));
1647        config.rules.insert("MD060".to_string(), md060_rule_config);
1648
1649        let rule = <MD075OrphanedTableRows as Rule>::from_config(&config);
1650        let content = "\
1651| H1 | H2 |
1652|----|-----|
1653| long value | b |
1654
1655| c | d |";
1656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657        let fixed = rule.fix(&ctx).unwrap();
1658
1659        let expected = "\
1660| H1 | H2 |
1661| ---- | ----- |
1662| long value | b |
1663| c | d |";
1664        assert_eq!(fixed, expected);
1665    }
1666
1667    #[test]
1668    fn test_from_config_honors_extend_disable_for_md013_case_insensitive() {
1669        let mut config_enabled = crate::config::Config::default();
1670
1671        let mut md060_rule_config = crate::config::RuleConfig::default();
1672        md060_rule_config
1673            .values
1674            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
1675        config_enabled.rules.insert("MD060".to_string(), md060_rule_config);
1676
1677        let mut md013_rule_config = crate::config::RuleConfig::default();
1678        md013_rule_config
1679            .values
1680            .insert("line-length".to_string(), toml::Value::Integer(40));
1681        md013_rule_config
1682            .values
1683            .insert("tables".to_string(), toml::Value::Boolean(true));
1684        config_enabled.rules.insert("MD013".to_string(), md013_rule_config);
1685
1686        let mut config_disabled = config_enabled.clone();
1687        config_disabled.global.extend_disable.push("md013".to_string());
1688
1689        let rule_enabled = <MD075OrphanedTableRows as Rule>::from_config(&config_enabled);
1690        let rule_disabled = <MD075OrphanedTableRows as Rule>::from_config(&config_disabled);
1691
1692        let content = "\
1693| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |
1694|---|---|---|
1695| data | data | data |
1696
1697| more | more | more |";
1698
1699        let ctx_enabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700        let fixed_enabled = rule_enabled.fix(&ctx_enabled).unwrap();
1701        let enabled_lines: Vec<&str> = fixed_enabled.lines().collect();
1702        assert!(
1703            enabled_lines.len() >= 4,
1704            "Expected merged table to contain at least 4 lines"
1705        );
1706        assert_ne!(
1707            enabled_lines[0].len(),
1708            enabled_lines[1].len(),
1709            "With MD013 active and inherited max-width, wide merged table should auto-compact"
1710        );
1711
1712        let ctx_disabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713        let fixed_disabled = rule_disabled.fix(&ctx_disabled).unwrap();
1714        let disabled_lines: Vec<&str> = fixed_disabled.lines().collect();
1715        assert!(
1716            disabled_lines.len() >= 4,
1717            "Expected merged table to contain at least 4 lines"
1718        );
1719        assert_eq!(
1720            disabled_lines[0].len(),
1721            disabled_lines[1].len(),
1722            "With MD013 disabled via extend-disable, inherited max-width should be unlimited (aligned table)"
1723        );
1724        assert_eq!(
1725            disabled_lines[1].len(),
1726            disabled_lines[2].len(),
1727            "Aligned table rows should share the same width"
1728        );
1729    }
1730
1731    fn all_flavors() -> [MarkdownFlavor; 6] {
1732        [
1733            MarkdownFlavor::Standard,
1734            MarkdownFlavor::MkDocs,
1735            MarkdownFlavor::MDX,
1736            MarkdownFlavor::Quarto,
1737            MarkdownFlavor::Obsidian,
1738            MarkdownFlavor::Kramdown,
1739        ]
1740    }
1741
1742    fn make_row(prefix: &str, cols: usize) -> String {
1743        let cells: Vec<String> = (1..=cols).map(|idx| format!("{prefix}{idx}")).collect();
1744        format!("| {} |", cells.join(" | "))
1745    }
1746
1747    #[test]
1748    fn test_issue_420_orphan_fix_matrix_all_flavors() {
1749        let rule = MD075OrphanedTableRows::default();
1750        let content = "\
1751| Value        | Description                                       |
1752| ------------ | ------------------------------------------------- |
1753| `consistent` | All code blocks must use the same style (default) |
1754
1755| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1756| `indented` | All code blocks must use indented style (4 spaces) |";
1757
1758        for flavor in all_flavors() {
1759            let ctx = LintContext::new(content, flavor, None);
1760            let warnings = rule.check(&ctx).unwrap();
1761            assert_eq!(warnings.len(), 1, "Expected one warning for flavor {}", flavor.name());
1762            assert!(
1763                warnings[0].fix.is_some(),
1764                "Expected fixable orphan warning for flavor {}",
1765                flavor.name()
1766            );
1767            let fixed = rule.fix(&ctx).unwrap();
1768            let fixed_ctx = LintContext::new(&fixed, flavor, None);
1769            assert!(
1770                rule.check(&fixed_ctx).unwrap().is_empty(),
1771                "Expected no remaining MD075 warnings after fix for flavor {}",
1772                flavor.name()
1773            );
1774        }
1775    }
1776
1777    #[test]
1778    fn test_column_mismatch_orphan_not_fixable_matrix_all_flavors() {
1779        let rule = MD075OrphanedTableRows::default();
1780        let content = "\
1781| H1 | H2 | H3 |
1782| --- | --- | --- |
1783| a | b | c |
1784
1785| d | e |";
1786
1787        for flavor in all_flavors() {
1788            let ctx = LintContext::new(content, flavor, None);
1789            let warnings = rule.check(&ctx).unwrap();
1790            assert_eq!(
1791                warnings.len(),
1792                1,
1793                "Expected one mismatch warning for flavor {}",
1794                flavor.name()
1795            );
1796            assert!(
1797                warnings[0].fix.is_none(),
1798                "Mismatch must never auto-fix for flavor {}",
1799                flavor.name()
1800            );
1801            assert_eq!(
1802                rule.fix(&ctx).unwrap(),
1803                content,
1804                "Mismatch fix must be no-op for flavor {}",
1805                flavor.name()
1806            );
1807        }
1808    }
1809
1810    proptest! {
1811        #![proptest_config(ProptestConfig::with_cases(64))]
1812
1813        #[test]
1814        fn prop_md075_fix_is_idempotent_for_orphaned_rows(
1815            cols in 2usize..6,
1816            base_rows in 1usize..5,
1817            orphan_rows in 1usize..4,
1818            blank_lines in 1usize..4,
1819            flavor in prop::sample::select(all_flavors().to_vec()),
1820        ) {
1821            let rule = MD075OrphanedTableRows::default();
1822
1823            let mut lines = Vec::new();
1824            lines.push(make_row("H", cols));
1825            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
1826            for idx in 0..base_rows {
1827                lines.push(make_row(&format!("r{}c", idx + 1), cols));
1828            }
1829            for _ in 0..blank_lines {
1830                lines.push(String::new());
1831            }
1832            for idx in 0..orphan_rows {
1833                lines.push(make_row(&format!("o{}c", idx + 1), cols));
1834            }
1835
1836            let content = lines.join("\n");
1837            let ctx1 = LintContext::new(&content, flavor, None);
1838            let fixed_once = rule.fix(&ctx1).unwrap();
1839
1840            let ctx2 = LintContext::new(&fixed_once, flavor, None);
1841            let fixed_twice = rule.fix(&ctx2).unwrap();
1842
1843            prop_assert_eq!(fixed_once.as_str(), fixed_twice.as_str());
1844            prop_assert!(
1845                rule.check(&ctx2).unwrap().is_empty(),
1846                "MD075 warnings remained after fix in flavor {}",
1847                flavor.name()
1848            );
1849        }
1850
1851        #[test]
1852        fn prop_md075_cli_lsp_fix_consistency(
1853            cols in 2usize..6,
1854            base_rows in 1usize..4,
1855            orphan_rows in 1usize..3,
1856            blank_lines in 1usize..3,
1857            flavor in prop::sample::select(all_flavors().to_vec()),
1858        ) {
1859            let rule = MD075OrphanedTableRows::default();
1860
1861            let mut lines = Vec::new();
1862            lines.push(make_row("H", cols));
1863            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
1864            for idx in 0..base_rows {
1865                lines.push(make_row(&format!("r{}c", idx + 1), cols));
1866            }
1867            for _ in 0..blank_lines {
1868                lines.push(String::new());
1869            }
1870            for idx in 0..orphan_rows {
1871                lines.push(make_row(&format!("o{}c", idx + 1), cols));
1872            }
1873            let content = lines.join("\n");
1874
1875            let ctx = LintContext::new(&content, flavor, None);
1876            let warnings = rule.check(&ctx).unwrap();
1877            prop_assert!(
1878                warnings.iter().any(|w| w.message.contains("Orphaned")),
1879                "Expected orphan warning for flavor {}",
1880                flavor.name()
1881            );
1882
1883            let lsp_fixed = apply_warning_fixes(&content, &warnings).unwrap();
1884            let cli_fixed = rule.fix(&ctx).unwrap();
1885            prop_assert_eq!(lsp_fixed, cli_fixed);
1886        }
1887
1888        #[test]
1889        fn prop_md075_column_mismatch_is_never_fixable(
1890            base_cols in 2usize..6,
1891            orphan_cols in 1usize..6,
1892            blank_lines in 1usize..4,
1893            flavor in prop::sample::select(all_flavors().to_vec()),
1894        ) {
1895            prop_assume!(base_cols != orphan_cols);
1896            let rule = MD075OrphanedTableRows::default();
1897
1898            let mut lines = vec![
1899                make_row("H", base_cols),
1900                format!("| {} |", (0..base_cols).map(|_| "---").collect::<Vec<_>>().join(" | ")),
1901                make_row("r", base_cols),
1902            ];
1903            for _ in 0..blank_lines {
1904                lines.push(String::new());
1905            }
1906            lines.push(make_row("o", orphan_cols));
1907
1908            let content = lines.join("\n");
1909            let ctx = LintContext::new(&content, flavor, None);
1910            let warnings = rule.check(&ctx).unwrap();
1911            prop_assert_eq!(warnings.len(), 1);
1912            prop_assert!(warnings[0].fix.is_none());
1913            prop_assert_eq!(rule.fix(&ctx).unwrap(), content);
1914        }
1915    }
1916}