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 {
566                range: replacement_range,
567                replacement,
568            }))
569        }
570    }
571}
572
573impl Default for MD075OrphanedTableRows {
574    fn default() -> Self {
575        Self {
576            // MD075 should normalize merged rows even when MD060 is not explicitly enabled.
577            md060_formatter: MD060TableFormat::new(true, "aligned".to_string()),
578        }
579    }
580}
581
582impl Rule for MD075OrphanedTableRows {
583    fn name(&self) -> &'static str {
584        "MD075"
585    }
586
587    fn description(&self) -> &'static str {
588        "Orphaned table rows or headerless pipe content"
589    }
590
591    fn category(&self) -> RuleCategory {
592        RuleCategory::Table
593    }
594
595    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
596        // Need at least 2 pipe characters for two minimal rows like:
597        // a | b
598        // c | d
599        ctx.char_count('|') < 2
600    }
601
602    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
603        let content_lines = ctx.raw_lines();
604        let mut warnings = Vec::new();
605
606        // Build set of all lines belonging to existing table blocks
607        let mut table_line_set = HashSet::new();
608        for table_block in &ctx.table_blocks {
609            for line_idx in table_block.start_line..=table_block.end_line {
610                table_line_set.insert(line_idx);
611            }
612        }
613
614        // Case 1: Orphaned rows after tables
615        let orphaned_groups = self.detect_orphaned_rows(ctx, content_lines, &table_line_set);
616        let orphan_group_fixes: Vec<Option<Fix>> = orphaned_groups
617            .iter()
618            .map(|group| self.build_orphan_group_fix(ctx, content_lines, group))
619            .collect::<Result<Vec<_>, _>>()?;
620        let mut orphaned_line_set = HashSet::new();
621        for group in &orphaned_groups {
622            for &line_idx in &group.row_lines {
623                orphaned_line_set.insert(line_idx);
624            }
625            // Also mark blank lines as part of the orphan group for dedup
626            for line_idx in group.blank_start..=group.blank_end {
627                orphaned_line_set.insert(line_idx);
628            }
629        }
630        let continuation_line_set = self.detect_table_continuation_rows(ctx, content_lines, &table_line_set);
631
632        for (group, group_fix) in orphaned_groups.iter().zip(orphan_group_fixes.iter()) {
633            let first_orphan = group.row_lines[0];
634            let last_orphan = *group.row_lines.last().unwrap();
635            let num_blanks = group.blank_end - group.blank_start + 1;
636
637            warnings.push(LintWarning {
638                rule_name: Some(self.name().to_string()),
639                message: format!("Orphaned table row(s) separated from preceding table by {num_blanks} blank line(s)"),
640                line: first_orphan + 1,
641                column: 1,
642                end_line: last_orphan + 1,
643                end_column: content_lines[last_orphan].len() + 1,
644                severity: Severity::Warning,
645                fix: group_fix.clone(),
646            });
647        }
648
649        // Case 2: Headerless pipe content
650        let headerless_groups = self.detect_headerless_tables(
651            ctx,
652            content_lines,
653            &table_line_set,
654            &orphaned_line_set,
655            &continuation_line_set,
656        );
657
658        for group in &headerless_groups {
659            let start = group.start_line;
660            let end = *group.lines.last().unwrap();
661
662            warnings.push(LintWarning {
663                rule_name: Some(self.name().to_string()),
664                message: "Pipe-formatted rows without a table header/delimiter row".to_string(),
665                line: start + 1,
666                column: 1,
667                end_line: end + 1,
668                end_column: content_lines[end].len() + 1,
669                severity: Severity::Warning,
670                fix: None,
671            });
672        }
673
674        Ok(warnings)
675    }
676
677    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
678        let warnings = self.check(ctx)?;
679        let warnings =
680            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
681        if warnings.iter().all(|warning| warning.fix.is_none()) {
682            return Ok(ctx.content.to_string());
683        }
684
685        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
686    }
687
688    fn fix_capability(&self) -> FixCapability {
689        FixCapability::ConditionallyFixable
690    }
691
692    fn as_any(&self) -> &dyn std::any::Any {
693        self
694    }
695
696    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
697    where
698        Self: Sized,
699    {
700        let mut md060_config = crate::rule_config_serde::load_rule_config::<MD060Config>(_config);
701        if md060_config.style == "any" {
702            // MD075 should normalize merged tables by default; "any" preserves broken alignment.
703            md060_config.style = "aligned".to_string();
704        }
705        let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(_config);
706        let md013_disabled = _config
707            .global
708            .disable
709            .iter()
710            .chain(_config.global.extend_disable.iter())
711            .any(|rule| rule.trim().eq_ignore_ascii_case("MD013"));
712        let formatter = MD060TableFormat::from_config_struct(md060_config, md013_config, md013_disabled);
713        Box::new(Self::with_formatter(formatter))
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use proptest::prelude::*;
720
721    use super::*;
722    use crate::config::MarkdownFlavor;
723    use crate::lint_context::LintContext;
724    use crate::utils::fix_utils::apply_warning_fixes;
725
726    // =========================================================================
727    // Case 1: Orphaned rows after a table
728    // =========================================================================
729
730    #[test]
731    fn test_orphaned_rows_after_table() {
732        let rule = MD075OrphanedTableRows::default();
733        let content = "\
734| Value        | Description       |
735| ------------ | ----------------- |
736| `consistent` | Default style     |
737
738| `fenced`     | Fenced style      |
739| `indented`   | Indented style    |";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742
743        assert_eq!(result.len(), 1);
744        assert!(result[0].message.contains("Orphaned table row"));
745        assert!(result[0].fix.is_some());
746    }
747
748    #[test]
749    fn test_orphaned_single_row_after_table() {
750        let rule = MD075OrphanedTableRows::default();
751        let content = "\
752| H1 | H2 |
753|----|-----|
754| a  | b   |
755
756| c  | d   |";
757        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758        let result = rule.check(&ctx).unwrap();
759
760        assert_eq!(result.len(), 1);
761        assert!(result[0].message.contains("Orphaned table row"));
762    }
763
764    #[test]
765    fn test_orphaned_rows_multiple_blank_lines() {
766        let rule = MD075OrphanedTableRows::default();
767        let content = "\
768| H1 | H2 |
769|----|-----|
770| a  | b   |
771
772
773| c  | d   |
774| e  | f   |";
775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776        let result = rule.check(&ctx).unwrap();
777
778        assert_eq!(result.len(), 1);
779        assert!(result[0].message.contains("2 blank line(s)"));
780    }
781
782    #[test]
783    fn test_fix_orphaned_rows() {
784        let rule = MD075OrphanedTableRows::default();
785        let content = "\
786| Value        | Description       |
787| ------------ | ----------------- |
788| `consistent` | Default style     |
789
790| `fenced`     | Fenced style      |
791| `indented`   | Indented style    |";
792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793        let fixed = rule.fix(&ctx).unwrap();
794
795        let expected = "\
796| Value        | Description       |
797| ------------ | ----------------- |
798| `consistent` | Default style     |
799| `fenced`     | Fenced style      |
800| `indented`   | Indented style    |";
801        assert_eq!(fixed, expected);
802    }
803
804    #[test]
805    fn test_fix_orphaned_rows_multiple_blanks() {
806        let rule = MD075OrphanedTableRows::default();
807        let content = "\
808| H1 | H2 |
809|----|-----|
810| a  | b   |
811
812
813| c  | d   |";
814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815        let fixed = rule.fix(&ctx).unwrap();
816
817        let expected = "\
818| H1  | H2  |
819| --- | --- |
820| a   | b   |
821| c   | d   |";
822        assert_eq!(fixed, expected);
823    }
824
825    #[test]
826    fn test_no_orphan_with_text_between() {
827        let rule = MD075OrphanedTableRows::default();
828        let content = "\
829| H1 | H2 |
830|----|-----|
831| a  | b   |
832
833Some text here.
834
835| c  | d   |
836| e  | f   |";
837        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838        let result = rule.check(&ctx).unwrap();
839
840        // Non-blank content between table and pipe rows means not orphaned
841        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
842        assert_eq!(orphan_warnings.len(), 0);
843    }
844
845    #[test]
846    fn test_valid_consecutive_tables_not_flagged() {
847        let rule = MD075OrphanedTableRows::default();
848        let content = "\
849| H1 | H2 |
850|----|-----|
851| a  | b   |
852
853| H3 | H4 |
854|----|-----|
855| c  | d   |";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let result = rule.check(&ctx).unwrap();
858
859        // Two valid tables separated by a blank line produce no warnings
860        assert_eq!(result.len(), 0);
861    }
862
863    #[test]
864    fn test_orphaned_rows_with_different_column_count() {
865        let rule = MD075OrphanedTableRows::default();
866        let content = "\
867| H1 | H2 | H3 |
868|----|-----|-----|
869| a  | b   | c   |
870
871| d  | e   |";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873        let result = rule.check(&ctx).unwrap();
874
875        // Different column count should still flag as orphaned
876        assert_eq!(result.len(), 1);
877        assert!(result[0].message.contains("Orphaned"));
878        assert!(result[0].fix.is_none());
879    }
880
881    // =========================================================================
882    // Case 2: Headerless pipe content
883    // =========================================================================
884
885    #[test]
886    fn test_headerless_pipe_content() {
887        let rule = MD075OrphanedTableRows::default();
888        let content = "\
889Some text.
890
891| value1 | description1 |
892| value2 | description2 |
893
894More text.";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896        let result = rule.check(&ctx).unwrap();
897
898        assert_eq!(result.len(), 1);
899        assert!(result[0].message.contains("without a table header"));
900        assert!(result[0].fix.is_none());
901    }
902
903    #[test]
904    fn test_single_pipe_row_not_flagged() {
905        let rule = MD075OrphanedTableRows::default();
906        let content = "\
907Some text.
908
909| value1 | description1 |
910
911More text.";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913        let result = rule.check(&ctx).unwrap();
914
915        // Single standalone pipe row is not flagged (Case 2 requires 2+)
916        assert_eq!(result.len(), 0);
917    }
918
919    #[test]
920    fn test_headerless_multiple_rows() {
921        let rule = MD075OrphanedTableRows::default();
922        let content = "\
923| a | b |
924| c | d |
925| e | f |";
926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let result = rule.check(&ctx).unwrap();
928
929        assert_eq!(result.len(), 1);
930        assert!(result[0].message.contains("without a table header"));
931    }
932
933    #[test]
934    fn test_headerless_inconsistent_columns_not_flagged() {
935        let rule = MD075OrphanedTableRows::default();
936        let content = "\
937| a | b |
938| c | d | e |";
939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940        let result = rule.check(&ctx).unwrap();
941
942        // Inconsistent column count is not flagged as headerless table
943        assert_eq!(result.len(), 0);
944    }
945
946    #[test]
947    fn test_headerless_not_flagged_when_has_delimiter() {
948        let rule = MD075OrphanedTableRows::default();
949        let content = "\
950| H1 | H2 |
951|----|-----|
952| a  | b   |";
953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954        let result = rule.check(&ctx).unwrap();
955
956        // Valid table with header/delimiter produces no warnings
957        assert_eq!(result.len(), 0);
958    }
959
960    // =========================================================================
961    // Edge cases
962    // =========================================================================
963
964    #[test]
965    fn test_pipe_rows_in_code_block_ignored() {
966        let rule = MD075OrphanedTableRows::default();
967        let content = "\
968```
969| a | b |
970| c | d |
971```";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973        let result = rule.check(&ctx).unwrap();
974
975        assert_eq!(result.len(), 0);
976    }
977
978    #[test]
979    fn test_pipe_rows_in_frontmatter_ignored() {
980        let rule = MD075OrphanedTableRows::default();
981        let content = "\
982---
983title: test
984---
985
986| a | b |
987| c | d |";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989        let result = rule.check(&ctx).unwrap();
990
991        // Frontmatter is skipped, standalone pipe rows after it are flagged
992        let warnings: Vec<_> = result
993            .iter()
994            .filter(|w| w.message.contains("without a table header"))
995            .collect();
996        assert_eq!(warnings.len(), 1);
997    }
998
999    #[test]
1000    fn test_no_pipes_at_all() {
1001        let rule = MD075OrphanedTableRows::default();
1002        let content = "Just regular text.\nNo pipes here.\nOnly paragraphs.";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004        let result = rule.check(&ctx).unwrap();
1005
1006        assert_eq!(result.len(), 0);
1007    }
1008
1009    #[test]
1010    fn test_empty_content() {
1011        let rule = MD075OrphanedTableRows::default();
1012        let content = "";
1013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014        let result = rule.check(&ctx).unwrap();
1015
1016        assert_eq!(result.len(), 0);
1017    }
1018
1019    #[test]
1020    fn test_orphaned_rows_in_blockquote() {
1021        let rule = MD075OrphanedTableRows::default();
1022        let content = "\
1023> | H1 | H2 |
1024> |----|-----|
1025> | a  | b   |
1026>
1027> | c  | d   |";
1028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029        let result = rule.check(&ctx).unwrap();
1030
1031        assert_eq!(result.len(), 1);
1032        assert!(result[0].message.contains("Orphaned"));
1033    }
1034
1035    #[test]
1036    fn test_fix_orphaned_rows_in_blockquote() {
1037        let rule = MD075OrphanedTableRows::default();
1038        let content = "\
1039> | H1 | H2 |
1040> |----|-----|
1041> | a  | b   |
1042>
1043> | c  | d   |";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let fixed = rule.fix(&ctx).unwrap();
1046
1047        let expected = "\
1048> | H1  | H2  |
1049> | --- | --- |
1050> | a   | b   |
1051> | c   | d   |";
1052        assert_eq!(fixed, expected);
1053    }
1054
1055    #[test]
1056    fn test_table_at_end_of_document_no_orphans() {
1057        let rule = MD075OrphanedTableRows::default();
1058        let content = "\
1059| H1 | H2 |
1060|----|-----|
1061| a  | b   |";
1062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063        let result = rule.check(&ctx).unwrap();
1064
1065        assert_eq!(result.len(), 0);
1066    }
1067
1068    #[test]
1069    fn test_table_followed_by_text_no_orphans() {
1070        let rule = MD075OrphanedTableRows::default();
1071        let content = "\
1072| H1 | H2 |
1073|----|-----|
1074| a  | b   |
1075
1076Some text after the table.";
1077        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078        let result = rule.check(&ctx).unwrap();
1079
1080        assert_eq!(result.len(), 0);
1081    }
1082
1083    #[test]
1084    fn test_fix_preserves_content_around_orphans() {
1085        let rule = MD075OrphanedTableRows::default();
1086        let content = "\
1087# Title
1088
1089| H1 | H2 |
1090|----|-----|
1091| a  | b   |
1092
1093| c  | d   |
1094
1095Some text after.";
1096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097        let fixed = rule.fix(&ctx).unwrap();
1098
1099        let expected = "\
1100# Title
1101
1102| H1  | H2  |
1103| --- | --- |
1104| a   | b   |
1105| c   | d   |
1106
1107Some text after.";
1108        assert_eq!(fixed, expected);
1109    }
1110
1111    #[test]
1112    fn test_multiple_orphan_groups() {
1113        let rule = MD075OrphanedTableRows::default();
1114        let content = "\
1115| H1 | H2 |
1116|----|-----|
1117| a  | b   |
1118
1119| c  | d   |
1120
1121| H3 | H4 |
1122|----|-----|
1123| e  | f   |
1124
1125| g  | h   |";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127        let result = rule.check(&ctx).unwrap();
1128
1129        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1130        assert_eq!(orphan_warnings.len(), 2);
1131    }
1132
1133    #[test]
1134    fn test_fix_multiple_orphan_groups() {
1135        let rule = MD075OrphanedTableRows::default();
1136        let content = "\
1137| H1 | H2 |
1138|----|-----|
1139| a  | b   |
1140
1141| c  | d   |
1142
1143| H3 | H4 |
1144|----|-----|
1145| e  | f   |
1146
1147| g  | h   |";
1148        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149        let fixed = rule.fix(&ctx).unwrap();
1150
1151        let expected = "\
1152| H1  | H2  |
1153| --- | --- |
1154| a   | b   |
1155| c   | d   |
1156
1157| H3  | H4  |
1158| --- | --- |
1159| e   | f   |
1160| g   | h   |";
1161        assert_eq!(fixed, expected);
1162    }
1163
1164    #[test]
1165    fn test_orphaned_rows_with_delimiter_form_new_table() {
1166        let rule = MD075OrphanedTableRows::default();
1167        // Rows after a blank that themselves form a valid table (header+delimiter)
1168        // are recognized as a separate table by table_blocks, not as orphans
1169        let content = "\
1170| H1 | H2 |
1171|----|-----|
1172| a  | b   |
1173
1174| c  | d   |
1175|----|-----|";
1176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177        let result = rule.check(&ctx).unwrap();
1178
1179        // The second group forms a valid table, so no orphan warning
1180        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1181        assert_eq!(orphan_warnings.len(), 0);
1182    }
1183
1184    #[test]
1185    fn test_headerless_not_confused_with_orphaned() {
1186        let rule = MD075OrphanedTableRows::default();
1187        let content = "\
1188| H1 | H2 |
1189|----|-----|
1190| a  | b   |
1191
1192Some text.
1193
1194| c  | d   |
1195| e  | f   |";
1196        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1197        let result = rule.check(&ctx).unwrap();
1198
1199        // Non-blank content between table and pipe rows means not orphaned
1200        // The standalone rows should be flagged as headerless (Case 2)
1201        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1202        let headerless_warnings: Vec<_> = result
1203            .iter()
1204            .filter(|w| w.message.contains("without a table header"))
1205            .collect();
1206
1207        assert_eq!(orphan_warnings.len(), 0);
1208        assert_eq!(headerless_warnings.len(), 1);
1209    }
1210
1211    #[test]
1212    fn test_fix_does_not_modify_headerless() {
1213        let rule = MD075OrphanedTableRows::default();
1214        let content = "\
1215Some text.
1216
1217| value1 | description1 |
1218| value2 | description2 |
1219
1220More text.";
1221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222        let fixed = rule.fix(&ctx).unwrap();
1223
1224        // Case 2 has no fix, so content should be unchanged
1225        assert_eq!(fixed, content);
1226    }
1227
1228    #[test]
1229    fn test_should_skip_few_pipes() {
1230        let rule = MD075OrphanedTableRows::default();
1231        let content = "a | b";
1232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233
1234        assert!(rule.should_skip(&ctx));
1235    }
1236
1237    #[test]
1238    fn test_should_not_skip_two_pipes_without_outer_pipes() {
1239        let rule = MD075OrphanedTableRows::default();
1240        let content = "\
1241a | b
1242c | d";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244
1245        assert!(!rule.should_skip(&ctx));
1246        let result = rule.check(&ctx).unwrap();
1247        assert_eq!(result.len(), 1);
1248        assert!(result[0].message.contains("without a table header"));
1249    }
1250
1251    #[test]
1252    fn test_fix_capability() {
1253        let rule = MD075OrphanedTableRows::default();
1254        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1255    }
1256
1257    #[test]
1258    fn test_category() {
1259        let rule = MD075OrphanedTableRows::default();
1260        assert_eq!(rule.category(), RuleCategory::Table);
1261    }
1262
1263    #[test]
1264    fn test_issue_420_exact_example() {
1265        // The exact example from issue #420, including inline code fence markers.
1266        let rule = MD075OrphanedTableRows::default();
1267        let content = "\
1268| Value        | Description                                       |
1269| ------------ | ------------------------------------------------- |
1270| `consistent` | All code blocks must use the same style (default) |
1271
1272| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1273| `indented` | All code blocks must use indented style (4 spaces) |";
1274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275        let result = rule.check(&ctx).unwrap();
1276
1277        assert_eq!(result.len(), 1);
1278        assert!(result[0].message.contains("Orphaned"));
1279        assert_eq!(result[0].line, 5);
1280
1281        let fixed = rule.fix(&ctx).unwrap();
1282        let expected = "\
1283| Value        | Description                                        |
1284| ------------ | -------------------------------------------------- |
1285| `consistent` | All code blocks must use the same style (default)  |
1286| `fenced`     | All code blocks must use fenced style (``` or ~~~) |
1287| `indented`   | All code blocks must use indented style (4 spaces) |";
1288        assert_eq!(fixed, expected);
1289    }
1290
1291    #[test]
1292    fn test_display_math_block_with_pipes_not_flagged() {
1293        let rule = MD075OrphanedTableRows::default();
1294        let content = "# Math\n\n$$\n|A| + |B| = |A \\cup B|\n|A| + |B| = |A \\cup B|\n$$\n";
1295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296        let result = rule.check(&ctx).unwrap();
1297
1298        assert!(
1299            result.is_empty(),
1300            "Pipes inside display math blocks should not trigger MD075"
1301        );
1302    }
1303
1304    #[test]
1305    fn test_math_absolute_value_bars_not_flagged() {
1306        let rule = MD075OrphanedTableRows::default();
1307        let content = "\
1308# Math
1309
1310Roughly (for privacy reasons, this isn't exactly what the student said),
1311the student talked about having done small cases on the size $|S|$,
1312and figuring out that $|S|$ was even, but then running out of ideas.";
1313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314        let result = rule.check(&ctx).unwrap();
1315
1316        assert!(result.is_empty(), "Math absolute value bars should not trigger MD075");
1317    }
1318
1319    #[test]
1320    fn test_prose_with_double_backticks_and_pipes_not_flagged() {
1321        let rule = MD075OrphanedTableRows::default();
1322        let content = "\
1323Use ``a|b`` or ``c|d`` in docs.
1324Prefer ``x|y`` and ``z|w`` examples.";
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326        let result = rule.check(&ctx).unwrap();
1327
1328        assert!(result.is_empty());
1329    }
1330
1331    #[test]
1332    fn test_liquid_filter_lines_not_flagged_as_headerless() {
1333        let rule = MD075OrphanedTableRows::default();
1334        let content = "\
1335If you encounter issues, see [Troubleshooting]({{ '/docs/troubleshooting/' | relative_url }}).
1336Use our [guides]({{ '/docs/installation/' | relative_url }}) for OS-specific steps.";
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_rows_after_template_directive_not_flagged_as_headerless() {
1345        let rule = MD075OrphanedTableRows::default();
1346        let content = "\
1347{% data reusables.enterprise-migration-tool.placeholder-table %}
1348DESTINATION | The name you want the new organization to have.
1349ENTERPRISE | The slug for your destination enterprise.";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352
1353        assert!(result.is_empty());
1354    }
1355
1356    #[test]
1357    fn test_templated_pipe_rows_not_flagged_as_headerless() {
1358        let rule = MD075OrphanedTableRows::default();
1359        let content = "\
1360| Feature{%- for version in group_versions %} | {{ version }}{%- endfor %} |
1361|:----{%- for version in group_versions %}|:----:{%- endfor %}|
1362| {{ feature }} | {{ value }} |";
1363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364        let result = rule.check(&ctx).unwrap();
1365
1366        assert!(result.is_empty());
1367    }
1368
1369    #[test]
1370    fn test_escaped_pipe_rows_in_table_not_flagged_as_headerless() {
1371        let rule = MD075OrphanedTableRows::default();
1372        let content = "\
1373Written as                             | Interpreted as
1374---------------------------------------|-----------------------------------------
1375`!foo && bar`                          | `(!foo) && bar`
1376<code>!foo \\|\\| bar </code>            | `(!foo) \\|\\| bar`
1377<code>foo \\|\\| bar && baz </code>      | <code>foo \\|\\| (bar && baz)</code>
1378<code>!foo && bar \\|\\| baz </code>     | <code>(!foo && bar) \\|\\| baz</code>";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380        let result = rule.check(&ctx).unwrap();
1381
1382        assert!(result.is_empty());
1383    }
1384
1385    #[test]
1386    fn test_rows_after_sparse_section_row_in_table_not_flagged() {
1387        let rule = MD075OrphanedTableRows::default();
1388        let content = "\
1389Key|Command|Command id
1390---|-------|----------
1391Search||
1392`kb(history.showNext)`|Next Search Term|`history.showNext`
1393`kb(history.showPrevious)`|Previous Search Term|`history.showPrevious`
1394Extensions||
1395`unassigned`|Update All Extensions|`workbench.extensions.action.updateAllExtensions`";
1396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397        let result = rule.check(&ctx).unwrap();
1398
1399        assert!(result.is_empty());
1400    }
1401
1402    #[test]
1403    fn test_sparse_row_without_table_context_does_not_suppress_headerless() {
1404        let rule = MD075OrphanedTableRows::default();
1405        let content = "\
1406Notes ||
1407`alpha` | `beta`
1408`gamma` | `delta`";
1409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410        let result = rule.check(&ctx).unwrap();
1411
1412        assert_eq!(result.len(), 1);
1413        assert!(result[0].message.contains("without a table header"));
1414    }
1415
1416    #[test]
1417    fn test_reusable_three_column_fragment_not_flagged_as_headerless() {
1418        let rule = MD075OrphanedTableRows::default();
1419        let content = "\
1420`label` | `object` | The label added or removed from the issue.
1421`label[name]` | `string` | The name of the label.
1422`label[color]` | `string` | The hex color code.";
1423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424        let result = rule.check(&ctx).unwrap();
1425
1426        assert!(result.is_empty());
1427    }
1428
1429    #[test]
1430    fn test_orphan_detection_does_not_cross_blockquote_context() {
1431        let rule = MD075OrphanedTableRows::default();
1432        let content = "\
1433| H1 | H2 |
1434|----|-----|
1435| a  | b   |
1436
1437> | c  | d   |";
1438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439        let result = rule.check(&ctx).unwrap();
1440        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1441
1442        assert_eq!(orphan_warnings.len(), 0);
1443        assert_eq!(rule.fix(&ctx).unwrap(), content);
1444    }
1445
1446    #[test]
1447    fn test_orphan_fix_does_not_cross_list_context() {
1448        let rule = MD075OrphanedTableRows::default();
1449        let content = "\
1450- | H1 | H2 |
1451  |----|-----|
1452  | a  | b   |
1453
1454| c  | d   |";
1455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456        let result = rule.check(&ctx).unwrap();
1457        let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1458
1459        assert_eq!(orphan_warnings.len(), 0);
1460        assert_eq!(rule.fix(&ctx).unwrap(), content);
1461    }
1462
1463    #[test]
1464    fn test_fix_normalizes_only_merged_table() {
1465        let rule = MD075OrphanedTableRows::default();
1466        let content = "\
1467| H1 | H2 |
1468|----|-----|
1469| a  | b   |
1470
1471| c  | d   |
1472
1473| Name | Age |
1474|---|---|
1475|alice|30|";
1476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477        let fixed = rule.fix(&ctx).unwrap();
1478
1479        assert!(fixed.contains("| H1  | H2  |"));
1480        assert!(fixed.contains("| c   | d   |"));
1481        // Unrelated second table should keep original compact formatting.
1482        assert!(fixed.contains("|---|---|"));
1483        assert!(fixed.contains("|alice|30|"));
1484    }
1485
1486    #[test]
1487    fn test_html_comment_pipe_rows_ignored() {
1488        let rule = MD075OrphanedTableRows::default();
1489        let content = "\
1490<!--
1491| a | b |
1492| c | d |
1493-->";
1494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495        let result = rule.check(&ctx).unwrap();
1496
1497        assert_eq!(result.len(), 0);
1498    }
1499
1500    #[test]
1501    fn test_orphan_detection_does_not_cross_skip_contexts() {
1502        let rule = MD075OrphanedTableRows::default();
1503        let content = "\
1504| H1 | H2 |
1505|----|-----|
1506| a  | b   |
1507
1508```
1509| c  | d   |
1510```";
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let result = rule.check(&ctx).unwrap();
1513
1514        // Pipe rows inside code block should not be flagged as orphaned
1515        assert_eq!(result.len(), 0);
1516    }
1517
1518    #[test]
1519    fn test_pipe_rows_in_esm_block_ignored() {
1520        let rule = MD075OrphanedTableRows::default();
1521        // ESM blocks use import/export statements; pipe rows inside should be skipped
1522        let content = "\
1523<script type=\"module\">
1524| a | b |
1525| c | d |
1526</script>";
1527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528        let result = rule.check(&ctx).unwrap();
1529
1530        // All pipe rows are inside an HTML/ESM block, no warnings expected
1531        assert_eq!(result.len(), 0);
1532    }
1533
1534    #[test]
1535    fn test_fix_range_covers_blank_lines_correctly() {
1536        let rule = MD075OrphanedTableRows::default();
1537        let content = "\
1538# Before
1539
1540| H1 | H2 |
1541|----|-----|
1542| a  | b   |
1543
1544| c  | d   |
1545
1546# After";
1547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548        let warnings = rule.check(&ctx).unwrap();
1549        let expected = "\
1550# Before
1551
1552| H1  | H2  |
1553| --- | --- |
1554| a   | b   |
1555| c   | d   |
1556
1557# After";
1558
1559        assert_eq!(warnings.len(), 1);
1560        let fix = warnings[0].fix.as_ref().unwrap();
1561        assert!(fix.range.start > 0);
1562        assert!(fix.range.end < content.len());
1563
1564        let cli_fixed = rule.fix(&ctx).unwrap();
1565        assert_eq!(cli_fixed, expected);
1566
1567        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1568        assert_eq!(lsp_fixed, expected);
1569        assert_eq!(lsp_fixed, cli_fixed);
1570    }
1571
1572    #[test]
1573    fn test_fix_range_multiple_blanks() {
1574        let rule = MD075OrphanedTableRows::default();
1575        let content = "\
1576# Before
1577
1578| H1 | H2 |
1579|----|-----|
1580| a  | b   |
1581
1582
1583| c  | d   |";
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585        let warnings = rule.check(&ctx).unwrap();
1586        let expected = "\
1587# Before
1588
1589| H1  | H2  |
1590| --- | --- |
1591| a   | b   |
1592| c   | d   |";
1593
1594        assert_eq!(warnings.len(), 1);
1595        let fix = warnings[0].fix.as_ref().unwrap();
1596        assert!(fix.range.start > 0);
1597        assert_eq!(fix.range.end, content.len());
1598
1599        let cli_fixed = rule.fix(&ctx).unwrap();
1600        assert_eq!(cli_fixed, expected);
1601
1602        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1603        assert_eq!(lsp_fixed, expected);
1604        assert_eq!(lsp_fixed, cli_fixed);
1605    }
1606
1607    #[test]
1608    fn test_warning_fixes_match_rule_fix_for_multiple_orphan_groups() {
1609        let rule = MD075OrphanedTableRows::default();
1610        let content = "\
1611| H1 | H2 |
1612|----|-----|
1613| a  | b   |
1614
1615| c  | d   |
1616
1617| H3 | H4 |
1618|----|-----|
1619| e  | f   |
1620
1621| g  | h   |";
1622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623        let warnings = rule.check(&ctx).unwrap();
1624
1625        let orphan_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Orphaned")).collect();
1626        assert_eq!(orphan_warnings.len(), 2);
1627
1628        let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1629        let cli_fixed = rule.fix(&ctx).unwrap();
1630
1631        assert_eq!(lsp_fixed, cli_fixed);
1632        assert_ne!(cli_fixed, content);
1633    }
1634
1635    #[test]
1636    fn test_issue_420_fix_is_idempotent() {
1637        let rule = MD075OrphanedTableRows::default();
1638        let content = "\
1639| Value        | Description                                       |
1640| ------------ | ------------------------------------------------- |
1641| `consistent` | All code blocks must use the same style (default) |
1642
1643| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1644| `indented` | All code blocks must use indented style (4 spaces) |";
1645
1646        let initial_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647        let fixed_once = rule.fix(&initial_ctx).unwrap();
1648
1649        let fixed_ctx = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1650        let warnings_after_fix = rule.check(&fixed_ctx).unwrap();
1651        assert_eq!(warnings_after_fix.len(), 0);
1652
1653        let fixed_twice = rule.fix(&fixed_ctx).unwrap();
1654        assert_eq!(fixed_twice, fixed_once);
1655    }
1656
1657    #[test]
1658    fn test_from_config_respects_md060_compact_style_for_merged_table() {
1659        let mut config = crate::config::Config::default();
1660        let mut md060_rule_config = crate::config::RuleConfig::default();
1661        md060_rule_config
1662            .values
1663            .insert("style".to_string(), toml::Value::String("compact".to_string()));
1664        config.rules.insert("MD060".to_string(), md060_rule_config);
1665
1666        let rule = <MD075OrphanedTableRows as Rule>::from_config(&config);
1667        let content = "\
1668| H1 | H2 |
1669|----|-----|
1670| long value | b |
1671
1672| c | d |";
1673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674        let fixed = rule.fix(&ctx).unwrap();
1675
1676        let expected = "\
1677| H1 | H2 |
1678| ---- | ----- |
1679| long value | b |
1680| c | d |";
1681        assert_eq!(fixed, expected);
1682    }
1683
1684    #[test]
1685    fn test_from_config_honors_extend_disable_for_md013_case_insensitive() {
1686        let mut config_enabled = crate::config::Config::default();
1687
1688        let mut md060_rule_config = crate::config::RuleConfig::default();
1689        md060_rule_config
1690            .values
1691            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
1692        config_enabled.rules.insert("MD060".to_string(), md060_rule_config);
1693
1694        let mut md013_rule_config = crate::config::RuleConfig::default();
1695        md013_rule_config
1696            .values
1697            .insert("line-length".to_string(), toml::Value::Integer(40));
1698        md013_rule_config
1699            .values
1700            .insert("tables".to_string(), toml::Value::Boolean(true));
1701        config_enabled.rules.insert("MD013".to_string(), md013_rule_config);
1702
1703        let mut config_disabled = config_enabled.clone();
1704        config_disabled.global.extend_disable.push("md013".to_string());
1705
1706        let rule_enabled = <MD075OrphanedTableRows as Rule>::from_config(&config_enabled);
1707        let rule_disabled = <MD075OrphanedTableRows as Rule>::from_config(&config_disabled);
1708
1709        let content = "\
1710| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |
1711|---|---|---|
1712| data | data | data |
1713
1714| more | more | more |";
1715
1716        let ctx_enabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717        let fixed_enabled = rule_enabled.fix(&ctx_enabled).unwrap();
1718        let enabled_lines: Vec<&str> = fixed_enabled.lines().collect();
1719        assert!(
1720            enabled_lines.len() >= 4,
1721            "Expected merged table to contain at least 4 lines"
1722        );
1723        assert_ne!(
1724            enabled_lines[0].len(),
1725            enabled_lines[1].len(),
1726            "With MD013 active and inherited max-width, wide merged table should auto-compact"
1727        );
1728
1729        let ctx_disabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1730        let fixed_disabled = rule_disabled.fix(&ctx_disabled).unwrap();
1731        let disabled_lines: Vec<&str> = fixed_disabled.lines().collect();
1732        assert!(
1733            disabled_lines.len() >= 4,
1734            "Expected merged table to contain at least 4 lines"
1735        );
1736        assert_eq!(
1737            disabled_lines[0].len(),
1738            disabled_lines[1].len(),
1739            "With MD013 disabled via extend-disable, inherited max-width should be unlimited (aligned table)"
1740        );
1741        assert_eq!(
1742            disabled_lines[1].len(),
1743            disabled_lines[2].len(),
1744            "Aligned table rows should share the same width"
1745        );
1746    }
1747
1748    fn all_flavors() -> [MarkdownFlavor; 6] {
1749        [
1750            MarkdownFlavor::Standard,
1751            MarkdownFlavor::MkDocs,
1752            MarkdownFlavor::MDX,
1753            MarkdownFlavor::Quarto,
1754            MarkdownFlavor::Obsidian,
1755            MarkdownFlavor::Kramdown,
1756        ]
1757    }
1758
1759    fn make_row(prefix: &str, cols: usize) -> String {
1760        let cells: Vec<String> = (1..=cols).map(|idx| format!("{prefix}{idx}")).collect();
1761        format!("| {} |", cells.join(" | "))
1762    }
1763
1764    #[test]
1765    fn test_issue_420_orphan_fix_matrix_all_flavors() {
1766        let rule = MD075OrphanedTableRows::default();
1767        let content = "\
1768| Value        | Description                                       |
1769| ------------ | ------------------------------------------------- |
1770| `consistent` | All code blocks must use the same style (default) |
1771
1772| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1773| `indented` | All code blocks must use indented style (4 spaces) |";
1774
1775        for flavor in all_flavors() {
1776            let ctx = LintContext::new(content, flavor, None);
1777            let warnings = rule.check(&ctx).unwrap();
1778            assert_eq!(warnings.len(), 1, "Expected one warning for flavor {}", flavor.name());
1779            assert!(
1780                warnings[0].fix.is_some(),
1781                "Expected fixable orphan warning for flavor {}",
1782                flavor.name()
1783            );
1784            let fixed = rule.fix(&ctx).unwrap();
1785            let fixed_ctx = LintContext::new(&fixed, flavor, None);
1786            assert!(
1787                rule.check(&fixed_ctx).unwrap().is_empty(),
1788                "Expected no remaining MD075 warnings after fix for flavor {}",
1789                flavor.name()
1790            );
1791        }
1792    }
1793
1794    #[test]
1795    fn test_column_mismatch_orphan_not_fixable_matrix_all_flavors() {
1796        let rule = MD075OrphanedTableRows::default();
1797        let content = "\
1798| H1 | H2 | H3 |
1799| --- | --- | --- |
1800| a | b | c |
1801
1802| d | e |";
1803
1804        for flavor in all_flavors() {
1805            let ctx = LintContext::new(content, flavor, None);
1806            let warnings = rule.check(&ctx).unwrap();
1807            assert_eq!(
1808                warnings.len(),
1809                1,
1810                "Expected one mismatch warning for flavor {}",
1811                flavor.name()
1812            );
1813            assert!(
1814                warnings[0].fix.is_none(),
1815                "Mismatch must never auto-fix for flavor {}",
1816                flavor.name()
1817            );
1818            assert_eq!(
1819                rule.fix(&ctx).unwrap(),
1820                content,
1821                "Mismatch fix must be no-op for flavor {}",
1822                flavor.name()
1823            );
1824        }
1825    }
1826
1827    proptest! {
1828        #![proptest_config(ProptestConfig::with_cases(64))]
1829
1830        #[test]
1831        fn prop_md075_fix_is_idempotent_for_orphaned_rows(
1832            cols in 2usize..6,
1833            base_rows in 1usize..5,
1834            orphan_rows in 1usize..4,
1835            blank_lines in 1usize..4,
1836            flavor in prop::sample::select(all_flavors().to_vec()),
1837        ) {
1838            let rule = MD075OrphanedTableRows::default();
1839
1840            let mut lines = Vec::new();
1841            lines.push(make_row("H", cols));
1842            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
1843            for idx in 0..base_rows {
1844                lines.push(make_row(&format!("r{}c", idx + 1), cols));
1845            }
1846            for _ in 0..blank_lines {
1847                lines.push(String::new());
1848            }
1849            for idx in 0..orphan_rows {
1850                lines.push(make_row(&format!("o{}c", idx + 1), cols));
1851            }
1852
1853            let content = lines.join("\n");
1854            let ctx1 = LintContext::new(&content, flavor, None);
1855            let fixed_once = rule.fix(&ctx1).unwrap();
1856
1857            let ctx2 = LintContext::new(&fixed_once, flavor, None);
1858            let fixed_twice = rule.fix(&ctx2).unwrap();
1859
1860            prop_assert_eq!(fixed_once.as_str(), fixed_twice.as_str());
1861            prop_assert!(
1862                rule.check(&ctx2).unwrap().is_empty(),
1863                "MD075 warnings remained after fix in flavor {}",
1864                flavor.name()
1865            );
1866        }
1867
1868        #[test]
1869        fn prop_md075_cli_lsp_fix_consistency(
1870            cols in 2usize..6,
1871            base_rows in 1usize..4,
1872            orphan_rows in 1usize..3,
1873            blank_lines in 1usize..3,
1874            flavor in prop::sample::select(all_flavors().to_vec()),
1875        ) {
1876            let rule = MD075OrphanedTableRows::default();
1877
1878            let mut lines = Vec::new();
1879            lines.push(make_row("H", cols));
1880            lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
1881            for idx in 0..base_rows {
1882                lines.push(make_row(&format!("r{}c", idx + 1), cols));
1883            }
1884            for _ in 0..blank_lines {
1885                lines.push(String::new());
1886            }
1887            for idx in 0..orphan_rows {
1888                lines.push(make_row(&format!("o{}c", idx + 1), cols));
1889            }
1890            let content = lines.join("\n");
1891
1892            let ctx = LintContext::new(&content, flavor, None);
1893            let warnings = rule.check(&ctx).unwrap();
1894            prop_assert!(
1895                warnings.iter().any(|w| w.message.contains("Orphaned")),
1896                "Expected orphan warning for flavor {}",
1897                flavor.name()
1898            );
1899
1900            let lsp_fixed = apply_warning_fixes(&content, &warnings).unwrap();
1901            let cli_fixed = rule.fix(&ctx).unwrap();
1902            prop_assert_eq!(lsp_fixed, cli_fixed);
1903        }
1904
1905        #[test]
1906        fn prop_md075_column_mismatch_is_never_fixable(
1907            base_cols in 2usize..6,
1908            orphan_cols in 1usize..6,
1909            blank_lines in 1usize..4,
1910            flavor in prop::sample::select(all_flavors().to_vec()),
1911        ) {
1912            prop_assume!(base_cols != orphan_cols);
1913            let rule = MD075OrphanedTableRows::default();
1914
1915            let mut lines = vec![
1916                make_row("H", base_cols),
1917                format!("| {} |", (0..base_cols).map(|_| "---").collect::<Vec<_>>().join(" | ")),
1918                make_row("r", base_cols),
1919            ];
1920            for _ in 0..blank_lines {
1921                lines.push(String::new());
1922            }
1923            lines.push(make_row("o", orphan_cols));
1924
1925            let content = lines.join("\n");
1926            let ctx = LintContext::new(&content, flavor, None);
1927            let warnings = rule.check(&ctx).unwrap();
1928            prop_assert_eq!(warnings.len(), 1);
1929            prop_assert!(warnings[0].fix.is_none());
1930            prop_assert_eq!(rule.fix(&ctx).unwrap(), content);
1931        }
1932    }
1933}