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