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