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