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