Skip to main content

rumdl_lib/rules/
md075_orphaned_table_rows.rs

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