Skip to main content

rumdl_lib/rules/
md075_orphaned_table_rows.rs

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