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