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