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