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, flavor: crate::config::MarkdownFlavor) -> bool {
70 let content = strip_blockquote_prefix(line);
71 TableUtils::is_potential_table_row_with_flavor(content, flavor)
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 has_outer_table_pipe(line: &str) -> bool {
208 let candidate = strip_blockquote_prefix(line).trim();
209 candidate.starts_with('|') || candidate.ends_with('|')
210 }
211
212 fn row_matches_table_context(
217 &self,
218 table_block: &crate::utils::table_utils::TableBlock,
219 content_lines: &[&str],
220 row_idx: usize,
221 ) -> bool {
222 let table_start_line = content_lines[table_block.start_line];
223 let candidate_line = content_lines[row_idx];
224
225 if Self::blockquote_depth(table_start_line) != Self::blockquote_depth(candidate_line) {
226 return false;
227 }
228
229 let (_, candidate_after_blockquote) = TableUtils::extract_blockquote_prefix(candidate_line);
230 let (candidate_list_prefix, _, _) = TableUtils::extract_list_prefix(candidate_after_blockquote);
231 let candidate_indent = Self::indentation_width(candidate_after_blockquote);
232
233 if let Some(list_ctx) = &table_block.list_context {
234 if !candidate_list_prefix.is_empty() {
236 return false;
237 }
238 candidate_indent >= list_ctx.content_indent && candidate_indent < list_ctx.content_indent + 4
239 } else {
240 candidate_list_prefix.is_empty() && candidate_indent < 4
242 }
243 }
244
245 fn detect_orphaned_rows(
247 &self,
248 ctx: &crate::lint_context::LintContext,
249 content_lines: &[&str],
250 table_line_set: &HashSet<usize>,
251 ) -> Vec<OrphanedGroup> {
252 let mut groups = Vec::new();
253
254 for table_block in &ctx.table_blocks {
255 let end = table_block.end_line;
256 let header_content =
257 TableUtils::extract_table_row_content(content_lines[table_block.start_line], table_block, 0);
258 let expected_columns = TableUtils::count_cells_with_flavor(header_content, ctx.flavor);
259
260 let mut i = end + 1;
262 let mut blank_start = None;
263 let mut blank_end = None;
264
265 while i < content_lines.len() {
267 if self.should_skip_line(ctx, i) {
268 break;
269 }
270 if Self::is_blank_line(content_lines[i]) {
271 if blank_start.is_none() {
272 blank_start = Some(i);
273 }
274 blank_end = Some(i);
275 i += 1;
276 } else {
277 break;
278 }
279 }
280
281 let (Some(bs), Some(be)) = (blank_start, blank_end) else {
283 continue;
284 };
285
286 let mut orphan_rows = Vec::new();
288 let mut j = be + 1;
289 while j < content_lines.len() {
290 if self.should_skip_line(ctx, j) {
291 break;
292 }
293 if table_line_set.contains(&j) {
294 break;
295 }
296 if Self::has_outer_table_pipe(content_lines[j])
297 && self.is_table_row_line(content_lines[j], ctx.flavor)
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], ctx.flavor)
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], ctx.flavor) {
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], ctx.flavor)
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], ctx.flavor)
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], ctx.flavor)
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 = strip_blockquote_prefix(content_lines[group_lines[0]]);
448 let first_count = TableUtils::count_cells_with_flavor(first_content, ctx.flavor);
449 let consistent = group_lines.iter().all(|&idx| {
450 let content = strip_blockquote_prefix(content_lines[idx]);
451 TableUtils::count_cells_with_flavor(content, ctx.flavor) == 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 = 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, ctx.flavor) {
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_span_byte_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::new(replacement_range, replacement)))
576 }
577 }
578}
579
580impl Default for MD075OrphanedTableRows {
581 fn default() -> Self {
582 Self {
583 md060_formatter: MD060TableFormat::new(true, "aligned".to_string()),
585 }
586 }
587}
588
589impl Rule for MD075OrphanedTableRows {
590 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
591 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(75))
592 }
593
594 fn name(&self) -> &'static str {
595 "MD075"
596 }
597
598 fn description(&self) -> &'static str {
599 "Orphaned table rows or headerless pipe content"
600 }
601
602 fn category(&self) -> RuleCategory {
603 RuleCategory::Table
604 }
605
606 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
607 ctx.char_count('|') < 2
611 }
612
613 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
614 let content_lines = ctx.raw_lines();
615 let mut warnings = Vec::new();
616
617 let mut table_line_set = HashSet::new();
619 for table_block in &ctx.table_blocks {
620 for line_idx in table_block.start_line..=table_block.end_line {
621 table_line_set.insert(line_idx);
622 }
623 }
624
625 let orphaned_groups = self.detect_orphaned_rows(ctx, content_lines, &table_line_set);
627 let orphan_group_fixes: Vec<Option<Fix>> = orphaned_groups
628 .iter()
629 .map(|group| self.build_orphan_group_fix(ctx, content_lines, group))
630 .collect::<Result<Vec<_>, _>>()?;
631 let mut orphaned_line_set = HashSet::new();
632 for group in &orphaned_groups {
633 for &line_idx in &group.row_lines {
634 orphaned_line_set.insert(line_idx);
635 }
636 for line_idx in group.blank_start..=group.blank_end {
638 orphaned_line_set.insert(line_idx);
639 }
640 }
641 let continuation_line_set = self.detect_table_continuation_rows(ctx, content_lines, &table_line_set);
642
643 for (group, group_fix) in orphaned_groups.iter().zip(orphan_group_fixes.iter()) {
644 let first_orphan = group.row_lines[0];
645 let last_orphan = *group.row_lines.last().unwrap();
646 let num_blanks = group.blank_end - group.blank_start + 1;
647
648 warnings.push(LintWarning {
649 rule_name: Some(self.name().to_string()),
650 message: format!("Orphaned table row(s) separated from preceding table by {num_blanks} blank line(s)"),
651 line: first_orphan + 1,
652 column: 1,
653 end_line: last_orphan + 1,
654 end_column: content_lines[last_orphan].chars().count() + 1,
655 severity: Severity::Warning,
656 fix: group_fix.clone(),
657 });
658 }
659
660 let headerless_groups = self.detect_headerless_tables(
662 ctx,
663 content_lines,
664 &table_line_set,
665 &orphaned_line_set,
666 &continuation_line_set,
667 );
668
669 for group in &headerless_groups {
670 let start = group.start_line;
671 let end = *group.lines.last().unwrap();
672
673 warnings.push(LintWarning {
674 rule_name: Some(self.name().to_string()),
675 message: "Pipe-formatted rows without a table header/delimiter row".to_string(),
676 line: start + 1,
677 column: 1,
678 end_line: end + 1,
679 end_column: content_lines[end].chars().count() + 1,
680 severity: Severity::Warning,
681 fix: None,
682 });
683 }
684
685 Ok(warnings)
686 }
687
688 fn fixes_from_diagnostics(&self) -> bool {
689 true
690 }
691
692 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
693 let warnings = self.check(ctx)?;
694 let warnings =
695 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
696 if warnings.iter().all(|warning| warning.fix.is_none()) {
697 return Ok(ctx.content.to_string());
698 }
699
700 apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
701 }
702
703 fn fix_capability(&self) -> FixCapability {
704 FixCapability::ConditionallyFixable
705 }
706
707 fn as_any(&self) -> &dyn std::any::Any {
708 self
709 }
710
711 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
712 where
713 Self: Sized,
714 {
715 let mut md060_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
716 if md060_config.style == "any" {
717 md060_config.style = "aligned".to_string();
719 }
720 let md013_config = MD013Config::from_document_config(config);
721 let md013_disabled = config
722 .global
723 .disable
724 .iter()
725 .chain(config.global.extend_disable.iter())
726 .any(|rule| rule.trim().eq_ignore_ascii_case("MD013"));
727 let formatter = MD060TableFormat::from_config_struct(md060_config, md013_config, md013_disabled);
728 Box::new(Self::with_formatter(formatter))
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use proptest::prelude::*;
735
736 use super::*;
737 use crate::config::MarkdownFlavor;
738 use crate::lint_context::LintContext;
739 use crate::utils::fix_utils::apply_warning_fixes;
740
741 #[test]
746 fn test_case_1_requires_at_least_one_outer_pipe_all_flavors() {
747 let rule = MD075OrphanedTableRows::default();
748 let prose = "\
749# Test
750
751| A | B |
752| - | - |
753| x | y |
754
755Noise low|med|high.\n";
756 let orphan = "\
757| Value | Description |
758| ------------ | ----------------- |
759| `consistent` | Default style |
760
761| `fenced` | Fenced style |
762| `indented` | Indented style |";
763 let expected = "\
764| Value | Description |
765| ------------ | ----------------- |
766| `consistent` | Default style |
767| `fenced` | Fenced style |
768| `indented` | Indented style |";
769 let leading_only = "\
770| A | B
771| - | -
772| x | y
773
774| c | d";
775 let trailing_only = "\
776A | B |
777- | - |
778x | y |
779
780c | d |";
781
782 for flavor in all_flavors() {
783 let prose_ctx = LintContext::new(prose, flavor, None);
784 assert!(
785 rule.check(&prose_ctx).unwrap().is_empty(),
786 "Pipe-bearing prose without outer pipes must not be treated as an orphaned row for {}",
787 flavor.name()
788 );
789
790 let orphan_ctx = LintContext::new(orphan, flavor, None);
791 let warnings = rule.check(&orphan_ctx).unwrap();
792 assert_eq!(
793 warnings.len(),
794 1,
795 "Outer-pipe orphan rows must warn for {}",
796 flavor.name()
797 );
798 assert!(
799 warnings[0].fix.is_some(),
800 "Outer-pipe orphan rows must remain fixable for {}",
801 flavor.name()
802 );
803 assert_eq!(
804 rule.fix(&orphan_ctx).unwrap(),
805 expected,
806 "Outer-pipe orphan rows must fix correctly for {}",
807 flavor.name()
808 );
809
810 for (style, content) in [("leading-only", leading_only), ("trailing-only", trailing_only)] {
811 let ctx = LintContext::new(content, flavor, None);
812 let warnings = rule.check(&ctx).unwrap();
813 assert_eq!(warnings.len(), 1, "{style} orphan rows must warn for {}", flavor.name());
814 assert!(
815 warnings[0].fix.is_some(),
816 "{style} orphan rows must remain fixable for {}",
817 flavor.name()
818 );
819 }
820 }
821 }
822
823 #[test]
831 fn test_bare_orphan_row_after_bare_table_is_deliberately_not_reported_all_flavors() {
832 let rule = MD075OrphanedTableRows::default();
833 let content = "\
834A | B
835--- | ---
836x | y
837
838p | q
839";
840
841 for flavor in all_flavors() {
842 let ctx = LintContext::new(content, flavor, None);
843 assert!(
844 rule.check(&ctx).unwrap().is_empty(),
845 "A bare orphan row must stay unreported for {}",
846 flavor.name()
847 );
848 assert_eq!(
849 rule.fix(&ctx).unwrap(),
850 content,
851 "A bare orphan row must be left byte-for-byte unchanged for {}",
852 flavor.name()
853 );
854 }
855 }
856
857 #[test]
858 fn test_two_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
859 let rule = MD075OrphanedTableRows::default();
860 let content = "\
861| A | B |
862| - | - |
863| x | y |
864
865Either ship it|or do not.\n";
866
867 for flavor in all_flavors() {
868 let ctx = LintContext::new(content, flavor, None);
869 assert!(
870 rule.check(&ctx).unwrap().is_empty(),
871 "Two-cell prose without outer pipes must not be reported for {}",
872 flavor.name()
873 );
874 assert_eq!(
875 rule.fix(&ctx).unwrap(),
876 content,
877 "Two-cell prose must remain byte-for-byte unchanged for {}",
878 flavor.name()
879 );
880 }
881 }
882
883 #[test]
884 fn test_three_cell_prose_after_table_is_not_reported_or_fixed_all_flavors() {
885 let rule = MD075OrphanedTableRows::default();
886 let content = "\
887| A | B | C |
888| - | - | - |
889| x | y | z |
890
891Noise low|med|high.\n";
892
893 for flavor in all_flavors() {
894 let ctx = LintContext::new(content, flavor, None);
895 assert!(
896 rule.check(&ctx).unwrap().is_empty(),
897 "Three-cell prose without outer pipes must not be reported for {}",
898 flavor.name()
899 );
900 assert_eq!(
901 rule.fix(&ctx).unwrap(),
902 content,
903 "Three-cell prose must remain byte-for-byte unchanged for {}",
904 flavor.name()
905 );
906 }
907 }
908
909 #[test]
910 fn test_orphaned_rows_after_table() {
911 let rule = MD075OrphanedTableRows::default();
912 let content = "\
913| Value | Description |
914| ------------ | ----------------- |
915| `consistent` | Default style |
916
917| `fenced` | Fenced style |
918| `indented` | Indented style |";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921
922 assert_eq!(result.len(), 1);
923 assert!(result[0].message.contains("Orphaned table row"));
924 assert!(result[0].fix.is_some());
925 }
926
927 #[test]
928 fn test_orphaned_single_row_after_table() {
929 let rule = MD075OrphanedTableRows::default();
930 let content = "\
931| H1 | H2 |
932|----|-----|
933| a | b |
934
935| c | d |";
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("Orphaned table row"));
941 }
942
943 #[test]
944 fn test_orphaned_rows_multiple_blank_lines() {
945 let rule = MD075OrphanedTableRows::default();
946 let content = "\
947| H1 | H2 |
948|----|-----|
949| a | b |
950
951
952| c | d |
953| e | f |";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956
957 assert_eq!(result.len(), 1);
958 assert!(result[0].message.contains("2 blank line(s)"));
959 }
960
961 #[test]
962 fn test_fix_orphaned_rows() {
963 let rule = MD075OrphanedTableRows::default();
964 let content = "\
965| Value | Description |
966| ------------ | ----------------- |
967| `consistent` | Default style |
968
969| `fenced` | Fenced style |
970| `indented` | Indented style |";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let fixed = rule.fix(&ctx).unwrap();
973
974 let expected = "\
975| Value | Description |
976| ------------ | ----------------- |
977| `consistent` | Default style |
978| `fenced` | Fenced style |
979| `indented` | Indented style |";
980 assert_eq!(fixed, expected);
981 }
982
983 #[test]
984 fn test_fix_orphaned_rows_multiple_blanks() {
985 let rule = MD075OrphanedTableRows::default();
986 let content = "\
987| H1 | H2 |
988|----|-----|
989| a | b |
990
991
992| c | d |";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let fixed = rule.fix(&ctx).unwrap();
995
996 let expected = "\
997| H1 | H2 |
998| --- | --- |
999| a | b |
1000| c | d |";
1001 assert_eq!(fixed, expected);
1002 }
1003
1004 #[test]
1005 fn test_no_orphan_with_text_between() {
1006 let rule = MD075OrphanedTableRows::default();
1007 let content = "\
1008| H1 | H2 |
1009|----|-----|
1010| a | b |
1011
1012Some text here.
1013
1014| c | d |
1015| e | f |";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018
1019 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1021 assert_eq!(orphan_warnings.len(), 0);
1022 }
1023
1024 #[test]
1025 fn test_valid_consecutive_tables_not_flagged() {
1026 let rule = MD075OrphanedTableRows::default();
1027 let content = "\
1028| H1 | H2 |
1029|----|-----|
1030| a | b |
1031
1032| H3 | H4 |
1033|----|-----|
1034| c | d |";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037
1038 assert_eq!(result.len(), 0);
1040 }
1041
1042 #[test]
1043 fn test_orphaned_rows_with_different_column_count() {
1044 let rule = MD075OrphanedTableRows::default();
1045 let content = "\
1046| H1 | H2 | H3 |
1047|----|-----|-----|
1048| a | b | c |
1049
1050| d | e |";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.check(&ctx).unwrap();
1053
1054 assert_eq!(result.len(), 1);
1056 assert!(result[0].message.contains("Orphaned"));
1057 assert!(result[0].fix.is_none());
1058 }
1059
1060 #[test]
1065 fn test_headerless_pipe_content() {
1066 let rule = MD075OrphanedTableRows::default();
1067 let content = "\
1068Some text.
1069
1070| value1 | description1 |
1071| value2 | description2 |
1072
1073More text.";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 1);
1078 assert!(result[0].message.contains("without a table header"));
1079 assert!(result[0].fix.is_none());
1080 }
1081
1082 #[test]
1083 fn test_single_pipe_row_not_flagged() {
1084 let rule = MD075OrphanedTableRows::default();
1085 let content = "\
1086Some text.
1087
1088| value1 | description1 |
1089
1090More text.";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092 let result = rule.check(&ctx).unwrap();
1093
1094 assert_eq!(result.len(), 0);
1096 }
1097
1098 #[test]
1099 fn test_headerless_multiple_rows() {
1100 let rule = MD075OrphanedTableRows::default();
1101 let content = "\
1102| a | b |
1103| c | d |
1104| e | f |";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 1);
1109 assert!(result[0].message.contains("without a table header"));
1110 }
1111
1112 #[test]
1113 fn test_headerless_inconsistent_columns_not_flagged() {
1114 let rule = MD075OrphanedTableRows::default();
1115 let content = "\
1116| a | b |
1117| c | d | e |";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120
1121 assert_eq!(result.len(), 0);
1123 }
1124
1125 #[test]
1126 fn test_headerless_not_flagged_when_has_delimiter() {
1127 let rule = MD075OrphanedTableRows::default();
1128 let content = "\
1129| H1 | H2 |
1130|----|-----|
1131| a | b |";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 assert_eq!(result.len(), 0);
1137 }
1138
1139 #[test]
1144 fn test_pipe_rows_in_code_block_ignored() {
1145 let rule = MD075OrphanedTableRows::default();
1146 let content = "\
1147```
1148| a | b |
1149| c | d |
1150```";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert_eq!(result.len(), 0);
1155 }
1156
1157 #[test]
1158 fn test_pipe_rows_in_frontmatter_ignored() {
1159 let rule = MD075OrphanedTableRows::default();
1160 let content = "\
1161---
1162title: test
1163---
1164
1165| a | b |
1166| c | d |";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169
1170 let warnings: Vec<_> = result
1172 .iter()
1173 .filter(|w| w.message.contains("without a table header"))
1174 .collect();
1175 assert_eq!(warnings.len(), 1);
1176 }
1177
1178 #[test]
1179 fn test_no_pipes_at_all() {
1180 let rule = MD075OrphanedTableRows::default();
1181 let content = "Just regular text.\nNo pipes here.\nOnly paragraphs.";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184
1185 assert_eq!(result.len(), 0);
1186 }
1187
1188 #[test]
1189 fn test_empty_content() {
1190 let rule = MD075OrphanedTableRows::default();
1191 let content = "";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194
1195 assert_eq!(result.len(), 0);
1196 }
1197
1198 #[test]
1199 fn test_orphaned_rows_in_blockquote() {
1200 let rule = MD075OrphanedTableRows::default();
1201 let content = "\
1202> | H1 | H2 |
1203> |----|-----|
1204> | a | b |
1205>
1206> | c | d |";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209
1210 assert_eq!(result.len(), 1);
1211 assert!(result[0].message.contains("Orphaned"));
1212 }
1213
1214 #[test]
1215 fn test_fix_orphaned_rows_in_blockquote() {
1216 let rule = MD075OrphanedTableRows::default();
1217 let content = "\
1218> | H1 | H2 |
1219> |----|-----|
1220> | a | b |
1221>
1222> | c | d |";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let fixed = rule.fix(&ctx).unwrap();
1225
1226 let expected = "\
1227> | H1 | H2 |
1228> | --- | --- |
1229> | a | b |
1230> | c | d |";
1231 assert_eq!(fixed, expected);
1232 }
1233
1234 #[test]
1235 fn test_table_at_end_of_document_no_orphans() {
1236 let rule = MD075OrphanedTableRows::default();
1237 let content = "\
1238| H1 | H2 |
1239|----|-----|
1240| a | b |";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert_eq!(result.len(), 0);
1245 }
1246
1247 #[test]
1248 fn test_table_followed_by_text_no_orphans() {
1249 let rule = MD075OrphanedTableRows::default();
1250 let content = "\
1251| H1 | H2 |
1252|----|-----|
1253| a | b |
1254
1255Some text after the table.";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258
1259 assert_eq!(result.len(), 0);
1260 }
1261
1262 #[test]
1263 fn test_fix_preserves_content_around_orphans() {
1264 let rule = MD075OrphanedTableRows::default();
1265 let content = "\
1266# Title
1267
1268| H1 | H2 |
1269|----|-----|
1270| a | b |
1271
1272| c | d |
1273
1274Some text after.";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let fixed = rule.fix(&ctx).unwrap();
1277
1278 let expected = "\
1279# Title
1280
1281| H1 | H2 |
1282| --- | --- |
1283| a | b |
1284| c | d |
1285
1286Some text after.";
1287 assert_eq!(fixed, expected);
1288 }
1289
1290 #[test]
1291 fn test_multiple_orphan_groups() {
1292 let rule = MD075OrphanedTableRows::default();
1293 let content = "\
1294| H1 | H2 |
1295|----|-----|
1296| a | b |
1297
1298| c | d |
1299
1300| H3 | H4 |
1301|----|-----|
1302| e | f |
1303
1304| g | h |";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1309 assert_eq!(orphan_warnings.len(), 2);
1310 }
1311
1312 #[test]
1313 fn test_fix_multiple_orphan_groups() {
1314 let rule = MD075OrphanedTableRows::default();
1315 let content = "\
1316| H1 | H2 |
1317|----|-----|
1318| a | b |
1319
1320| c | d |
1321
1322| H3 | H4 |
1323|----|-----|
1324| e | f |
1325
1326| g | h |";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let fixed = rule.fix(&ctx).unwrap();
1329
1330 let expected = "\
1331| H1 | H2 |
1332| --- | --- |
1333| a | b |
1334| c | d |
1335
1336| H3 | H4 |
1337| --- | --- |
1338| e | f |
1339| g | h |";
1340 assert_eq!(fixed, expected);
1341 }
1342
1343 #[test]
1344 fn test_orphaned_rows_with_delimiter_form_new_table() {
1345 let rule = MD075OrphanedTableRows::default();
1346 let content = "\
1349| H1 | H2 |
1350|----|-----|
1351| a | b |
1352
1353| c | d |
1354|----|-----|";
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357
1358 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1360 assert_eq!(orphan_warnings.len(), 0);
1361 }
1362
1363 #[test]
1364 fn test_headerless_not_confused_with_orphaned() {
1365 let rule = MD075OrphanedTableRows::default();
1366 let content = "\
1367| H1 | H2 |
1368|----|-----|
1369| a | b |
1370
1371Some text.
1372
1373| c | d |
1374| e | f |";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let result = rule.check(&ctx).unwrap();
1377
1378 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1381 let headerless_warnings: Vec<_> = result
1382 .iter()
1383 .filter(|w| w.message.contains("without a table header"))
1384 .collect();
1385
1386 assert_eq!(orphan_warnings.len(), 0);
1387 assert_eq!(headerless_warnings.len(), 1);
1388 }
1389
1390 #[test]
1391 fn test_fix_does_not_modify_headerless() {
1392 let rule = MD075OrphanedTableRows::default();
1393 let content = "\
1394Some text.
1395
1396| value1 | description1 |
1397| value2 | description2 |
1398
1399More text.";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let fixed = rule.fix(&ctx).unwrap();
1402
1403 assert_eq!(fixed, content);
1405 }
1406
1407 #[test]
1408 fn test_should_skip_few_pipes() {
1409 let rule = MD075OrphanedTableRows::default();
1410 let content = "a | b";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412
1413 assert!(rule.should_skip(&ctx));
1414 }
1415
1416 #[test]
1417 fn test_should_not_skip_two_pipes_without_outer_pipes() {
1418 let rule = MD075OrphanedTableRows::default();
1419 let content = "\
1420a | b
1421c | d";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423
1424 assert!(!rule.should_skip(&ctx));
1425 let result = rule.check(&ctx).unwrap();
1426 assert_eq!(result.len(), 1);
1427 assert!(result[0].message.contains("without a table header"));
1428 }
1429
1430 #[test]
1431 fn test_fix_capability() {
1432 let rule = MD075OrphanedTableRows::default();
1433 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1434 }
1435
1436 #[test]
1437 fn test_category() {
1438 let rule = MD075OrphanedTableRows::default();
1439 assert_eq!(rule.category(), RuleCategory::Table);
1440 }
1441
1442 #[test]
1443 fn test_issue_420_exact_example() {
1444 let rule = MD075OrphanedTableRows::default();
1446 let content = "\
1447| Value | Description |
1448| ------------ | ------------------------------------------------- |
1449| `consistent` | All code blocks must use the same style (default) |
1450
1451| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1452| `indented` | All code blocks must use indented style (4 spaces) |";
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 let result = rule.check(&ctx).unwrap();
1455
1456 assert_eq!(result.len(), 1);
1457 assert!(result[0].message.contains("Orphaned"));
1458 assert_eq!(result[0].line, 5);
1459
1460 let fixed = rule.fix(&ctx).unwrap();
1461 let expected = "\
1462| Value | Description |
1463| ------------ | -------------------------------------------------- |
1464| `consistent` | All code blocks must use the same style (default) |
1465| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1466| `indented` | All code blocks must use indented style (4 spaces) |";
1467 assert_eq!(fixed, expected);
1468 }
1469
1470 #[test]
1471 fn test_display_math_block_with_pipes_not_flagged() {
1472 let rule = MD075OrphanedTableRows::default();
1473 let content = "# Math\n\n$$\n|A| + |B| = |A \\cup B|\n|A| + |B| = |A \\cup B|\n$$\n";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let result = rule.check(&ctx).unwrap();
1476
1477 assert!(
1478 result.is_empty(),
1479 "Pipes inside display math blocks should not trigger MD075"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_math_absolute_value_bars_not_flagged() {
1485 let rule = MD075OrphanedTableRows::default();
1486 let content = "\
1487# Math
1488
1489Roughly (for privacy reasons, this isn't exactly what the student said),
1490the student talked about having done small cases on the size $|S|$,
1491and figuring out that $|S|$ was even, but then running out of ideas.";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493 let result = rule.check(&ctx).unwrap();
1494
1495 assert!(result.is_empty(), "Math absolute value bars should not trigger MD075");
1496 }
1497
1498 #[test]
1499 fn test_prose_with_double_backticks_and_pipes_not_flagged() {
1500 let rule = MD075OrphanedTableRows::default();
1501 let content = "\
1502Use ``a|b`` or ``c|d`` in docs.
1503Prefer ``x|y`` and ``z|w`` examples.";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert!(result.is_empty());
1508 }
1509
1510 #[test]
1511 fn test_liquid_filter_lines_not_flagged_as_headerless() {
1512 let rule = MD075OrphanedTableRows::default();
1513 let content = "\
1514If you encounter issues, see [Troubleshooting]({{ '/docs/troubleshooting/' | relative_url }}).
1515Use our [guides]({{ '/docs/installation/' | relative_url }}) for OS-specific steps.";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert!(result.is_empty());
1520 }
1521
1522 #[test]
1523 fn test_rows_after_template_directive_not_flagged_as_headerless() {
1524 let rule = MD075OrphanedTableRows::default();
1525 let content = "\
1526{% data reusables.enterprise-migration-tool.placeholder-table %}
1527DESTINATION | The name you want the new organization to have.
1528ENTERPRISE | The slug for your destination enterprise.";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let result = rule.check(&ctx).unwrap();
1531
1532 assert!(result.is_empty());
1533 }
1534
1535 #[test]
1536 fn test_templated_pipe_rows_not_flagged_as_headerless() {
1537 let rule = MD075OrphanedTableRows::default();
1538 let content = "\
1539| Feature{%- for version in group_versions %} | {{ version }}{%- endfor %} |
1540|:----{%- for version in group_versions %}|:----:{%- endfor %}|
1541| {{ feature }} | {{ value }} |";
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543 let result = rule.check(&ctx).unwrap();
1544
1545 assert!(result.is_empty());
1546 }
1547
1548 #[test]
1549 fn test_escaped_pipe_rows_in_table_not_flagged_as_headerless() {
1550 let rule = MD075OrphanedTableRows::default();
1551 let content = "\
1552Written as | Interpreted as
1553---------------------------------------|-----------------------------------------
1554`!foo && bar` | `(!foo) && bar`
1555<code>!foo \\|\\| bar </code> | `(!foo) \\|\\| bar`
1556<code>foo \\|\\| bar && baz </code> | <code>foo \\|\\| (bar && baz)</code>
1557<code>!foo && bar \\|\\| baz </code> | <code>(!foo && bar) \\|\\| baz</code>";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let result = rule.check(&ctx).unwrap();
1560
1561 assert!(result.is_empty());
1562 }
1563
1564 #[test]
1565 fn test_rows_after_sparse_section_row_in_table_not_flagged() {
1566 let rule = MD075OrphanedTableRows::default();
1567 let content = "\
1568Key|Command|Command id
1569---|-------|----------
1570Search||
1571`kb(history.showNext)`|Next Search Term|`history.showNext`
1572`kb(history.showPrevious)`|Previous Search Term|`history.showPrevious`
1573Extensions||
1574`unassigned`|Update All Extensions|`workbench.extensions.action.updateAllExtensions`";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let result = rule.check(&ctx).unwrap();
1577
1578 assert!(result.is_empty());
1579 }
1580
1581 #[test]
1582 fn test_sparse_row_without_table_context_does_not_suppress_headerless() {
1583 let rule = MD075OrphanedTableRows::default();
1584 let content = "\
1585Notes ||
1586`alpha` | `beta`
1587`gamma` | `delta`";
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 let result = rule.check(&ctx).unwrap();
1590
1591 assert_eq!(result.len(), 1);
1592 assert!(result[0].message.contains("without a table header"));
1593 }
1594
1595 #[test]
1596 fn test_reusable_three_column_fragment_not_flagged_as_headerless() {
1597 let rule = MD075OrphanedTableRows::default();
1598 let content = "\
1599`label` | `object` | The label added or removed from the issue.
1600`label[name]` | `string` | The name of the label.
1601`label[color]` | `string` | The hex color code.";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603 let result = rule.check(&ctx).unwrap();
1604
1605 assert!(result.is_empty());
1606 }
1607
1608 #[test]
1609 fn test_orphan_detection_does_not_cross_blockquote_context() {
1610 let rule = MD075OrphanedTableRows::default();
1611 let content = "\
1612| H1 | H2 |
1613|----|-----|
1614| a | b |
1615
1616> | c | d |";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1620
1621 assert_eq!(orphan_warnings.len(), 0);
1622 assert_eq!(rule.fix(&ctx).unwrap(), content);
1623 }
1624
1625 #[test]
1626 fn test_orphan_fix_does_not_cross_list_context() {
1627 let rule = MD075OrphanedTableRows::default();
1628 let content = "\
1629- | H1 | H2 |
1630 |----|-----|
1631 | a | b |
1632
1633| c | d |";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let result = rule.check(&ctx).unwrap();
1636 let orphan_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Orphaned")).collect();
1637
1638 assert_eq!(orphan_warnings.len(), 0);
1639 assert_eq!(rule.fix(&ctx).unwrap(), content);
1640 }
1641
1642 #[test]
1643 fn test_fix_normalizes_only_merged_table() {
1644 let rule = MD075OrphanedTableRows::default();
1645 let content = "\
1646| H1 | H2 |
1647|----|-----|
1648| a | b |
1649
1650| c | d |
1651
1652| Name | Age |
1653|---|---|
1654|alice|30|";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let fixed = rule.fix(&ctx).unwrap();
1657
1658 assert!(fixed.contains("| H1 | H2 |"));
1659 assert!(fixed.contains("| c | d |"));
1660 assert!(fixed.contains("|---|---|"));
1662 assert!(fixed.contains("|alice|30|"));
1663 }
1664
1665 #[test]
1666 fn test_html_comment_pipe_rows_ignored() {
1667 let rule = MD075OrphanedTableRows::default();
1668 let content = "\
1669<!--
1670| a | b |
1671| c | d |
1672-->";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674 let result = rule.check(&ctx).unwrap();
1675
1676 assert_eq!(result.len(), 0);
1677 }
1678
1679 #[test]
1680 fn test_orphan_detection_does_not_cross_skip_contexts() {
1681 let rule = MD075OrphanedTableRows::default();
1682 let content = "\
1683| H1 | H2 |
1684|----|-----|
1685| a | b |
1686
1687```
1688| c | d |
1689```";
1690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691 let result = rule.check(&ctx).unwrap();
1692
1693 assert_eq!(result.len(), 0);
1695 }
1696
1697 #[test]
1698 fn test_pipe_rows_in_esm_block_ignored() {
1699 let rule = MD075OrphanedTableRows::default();
1700 let content = "\
1702<script type=\"module\">
1703| a | b |
1704| c | d |
1705</script>";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let result = rule.check(&ctx).unwrap();
1708
1709 assert_eq!(result.len(), 0);
1711 }
1712
1713 #[test]
1714 fn test_fix_range_covers_blank_lines_correctly() {
1715 let rule = MD075OrphanedTableRows::default();
1716 let content = "\
1717# Before
1718
1719| H1 | H2 |
1720|----|-----|
1721| a | b |
1722
1723| c | d |
1724
1725# After";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let warnings = rule.check(&ctx).unwrap();
1728 let expected = "\
1729# Before
1730
1731| H1 | H2 |
1732| --- | --- |
1733| a | b |
1734| c | d |
1735
1736# After";
1737
1738 assert_eq!(warnings.len(), 1);
1739 let fix = warnings[0].fix.as_ref().unwrap();
1740 assert!(fix.range.start > 0);
1741 assert!(fix.range.end < content.len());
1742
1743 let cli_fixed = rule.fix(&ctx).unwrap();
1744 assert_eq!(cli_fixed, expected);
1745
1746 let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1747 assert_eq!(lsp_fixed, expected);
1748 assert_eq!(lsp_fixed, cli_fixed);
1749 }
1750
1751 #[test]
1752 fn test_fix_range_multiple_blanks() {
1753 let rule = MD075OrphanedTableRows::default();
1754 let content = "\
1755# Before
1756
1757| H1 | H2 |
1758|----|-----|
1759| a | b |
1760
1761
1762| c | d |";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let warnings = rule.check(&ctx).unwrap();
1765 let expected = "\
1766# Before
1767
1768| H1 | H2 |
1769| --- | --- |
1770| a | b |
1771| c | d |";
1772
1773 assert_eq!(warnings.len(), 1);
1774 let fix = warnings[0].fix.as_ref().unwrap();
1775 assert!(fix.range.start > 0);
1776 assert_eq!(fix.range.end, content.len());
1777
1778 let cli_fixed = rule.fix(&ctx).unwrap();
1779 assert_eq!(cli_fixed, expected);
1780
1781 let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1782 assert_eq!(lsp_fixed, expected);
1783 assert_eq!(lsp_fixed, cli_fixed);
1784 }
1785
1786 #[test]
1787 fn test_warning_fixes_match_rule_fix_for_multiple_orphan_groups() {
1788 let rule = MD075OrphanedTableRows::default();
1789 let content = "\
1790| H1 | H2 |
1791|----|-----|
1792| a | b |
1793
1794| c | d |
1795
1796| H3 | H4 |
1797|----|-----|
1798| e | f |
1799
1800| g | h |";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let warnings = rule.check(&ctx).unwrap();
1803
1804 let orphan_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Orphaned")).collect();
1805 assert_eq!(orphan_warnings.len(), 2);
1806
1807 let lsp_fixed = apply_warning_fixes(content, &warnings).unwrap();
1808 let cli_fixed = rule.fix(&ctx).unwrap();
1809
1810 assert_eq!(lsp_fixed, cli_fixed);
1811 assert_ne!(cli_fixed, content);
1812 }
1813
1814 #[test]
1815 fn test_issue_420_fix_is_idempotent() {
1816 let rule = MD075OrphanedTableRows::default();
1817 let content = "\
1818| Value | Description |
1819| ------------ | ------------------------------------------------- |
1820| `consistent` | All code blocks must use the same style (default) |
1821
1822| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1823| `indented` | All code blocks must use indented style (4 spaces) |";
1824
1825 let initial_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1826 let fixed_once = rule.fix(&initial_ctx).unwrap();
1827
1828 let fixed_ctx = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1829 let warnings_after_fix = rule.check(&fixed_ctx).unwrap();
1830 assert_eq!(warnings_after_fix.len(), 0);
1831
1832 let fixed_twice = rule.fix(&fixed_ctx).unwrap();
1833 assert_eq!(fixed_twice, fixed_once);
1834 }
1835
1836 #[test]
1837 fn test_from_config_respects_md060_compact_style_for_merged_table() {
1838 let mut config = crate::config::Config::default();
1839 let mut md060_rule_config = crate::config::RuleConfig::default();
1840 md060_rule_config
1841 .values
1842 .insert("style".to_string(), toml::Value::String("compact".to_string()));
1843 config.rules.insert("MD060".to_string(), md060_rule_config);
1844
1845 let rule = <MD075OrphanedTableRows as Rule>::from_config(&config);
1846 let content = "\
1847| H1 | H2 |
1848|----|-----|
1849| long value | b |
1850
1851| c | d |";
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 let fixed = rule.fix(&ctx).unwrap();
1854
1855 let expected = "\
1856| H1 | H2 |
1857| ---- | ----- |
1858| long value | b |
1859| c | d |";
1860 assert_eq!(fixed, expected);
1861 }
1862
1863 #[test]
1864 fn test_from_config_honors_extend_disable_for_md013_case_insensitive() {
1865 let mut config_enabled = crate::config::Config::default();
1866
1867 let mut md060_rule_config = crate::config::RuleConfig::default();
1868 md060_rule_config
1869 .values
1870 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
1871 config_enabled.rules.insert("MD060".to_string(), md060_rule_config);
1872
1873 let mut md013_rule_config = crate::config::RuleConfig::default();
1874 md013_rule_config
1875 .values
1876 .insert("line-length".to_string(), toml::Value::Integer(40));
1877 md013_rule_config
1878 .values
1879 .insert("tables".to_string(), toml::Value::Boolean(true));
1880 config_enabled.rules.insert("MD013".to_string(), md013_rule_config);
1881
1882 let mut config_disabled = config_enabled.clone();
1883 config_disabled.global.extend_disable.push("md013".to_string());
1884
1885 let rule_enabled = <MD075OrphanedTableRows as Rule>::from_config(&config_enabled);
1886 let rule_disabled = <MD075OrphanedTableRows as Rule>::from_config(&config_disabled);
1887
1888 let content = "\
1889| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |
1890|---|---|---|
1891| data | data | data |
1892
1893| more | more | more |";
1894
1895 let ctx_enabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let fixed_enabled = rule_enabled.fix(&ctx_enabled).unwrap();
1897 let enabled_lines: Vec<&str> = fixed_enabled.lines().collect();
1898 assert!(
1899 enabled_lines.len() >= 4,
1900 "Expected merged table to contain at least 4 lines"
1901 );
1902 assert_ne!(
1903 enabled_lines[0].len(),
1904 enabled_lines[1].len(),
1905 "With MD013 active and inherited max-width, wide merged table should auto-compact"
1906 );
1907
1908 let ctx_disabled = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let fixed_disabled = rule_disabled.fix(&ctx_disabled).unwrap();
1910 let disabled_lines: Vec<&str> = fixed_disabled.lines().collect();
1911 assert!(
1912 disabled_lines.len() >= 4,
1913 "Expected merged table to contain at least 4 lines"
1914 );
1915 assert_eq!(
1916 disabled_lines[0].len(),
1917 disabled_lines[1].len(),
1918 "With MD013 disabled via extend-disable, inherited max-width should be unlimited (aligned table)"
1919 );
1920 assert_eq!(
1921 disabled_lines[1].len(),
1922 disabled_lines[2].len(),
1923 "Aligned table rows should share the same width"
1924 );
1925 }
1926
1927 fn all_flavors() -> [MarkdownFlavor; 6] {
1928 [
1929 MarkdownFlavor::Standard,
1930 MarkdownFlavor::MkDocs,
1931 MarkdownFlavor::MDX,
1932 MarkdownFlavor::Quarto,
1933 MarkdownFlavor::Obsidian,
1934 MarkdownFlavor::Kramdown,
1935 ]
1936 }
1937
1938 fn make_row(prefix: &str, cols: usize) -> String {
1939 let cells: Vec<String> = (1..=cols).map(|idx| format!("{prefix}{idx}")).collect();
1940 format!("| {} |", cells.join(" | "))
1941 }
1942
1943 #[test]
1944 fn test_issue_420_orphan_fix_matrix_all_flavors() {
1945 let rule = MD075OrphanedTableRows::default();
1946 let content = "\
1947| Value | Description |
1948| ------------ | ------------------------------------------------- |
1949| `consistent` | All code blocks must use the same style (default) |
1950
1951| `fenced` | All code blocks must use fenced style (``` or ~~~) |
1952| `indented` | All code blocks must use indented style (4 spaces) |";
1953
1954 for flavor in all_flavors() {
1955 let ctx = LintContext::new(content, flavor, None);
1956 let warnings = rule.check(&ctx).unwrap();
1957 assert_eq!(warnings.len(), 1, "Expected one warning for flavor {}", flavor.name());
1958 assert!(
1959 warnings[0].fix.is_some(),
1960 "Expected fixable orphan warning for flavor {}",
1961 flavor.name()
1962 );
1963 let fixed = rule.fix(&ctx).unwrap();
1964 let fixed_ctx = LintContext::new(&fixed, flavor, None);
1965 assert!(
1966 rule.check(&fixed_ctx).unwrap().is_empty(),
1967 "Expected no remaining MD075 warnings after fix for flavor {}",
1968 flavor.name()
1969 );
1970 }
1971 }
1972
1973 #[test]
1974 fn test_column_mismatch_orphan_not_fixable_matrix_all_flavors() {
1975 let rule = MD075OrphanedTableRows::default();
1976 let content = "\
1977| H1 | H2 | H3 |
1978| --- | --- | --- |
1979| a | b | c |
1980
1981| d | e |";
1982
1983 for flavor in all_flavors() {
1984 let ctx = LintContext::new(content, flavor, None);
1985 let warnings = rule.check(&ctx).unwrap();
1986 assert_eq!(
1987 warnings.len(),
1988 1,
1989 "Expected one mismatch warning for flavor {}",
1990 flavor.name()
1991 );
1992 assert!(
1993 warnings[0].fix.is_none(),
1994 "Mismatch must never auto-fix for flavor {}",
1995 flavor.name()
1996 );
1997 assert_eq!(
1998 rule.fix(&ctx).unwrap(),
1999 content,
2000 "Mismatch fix must be no-op for flavor {}",
2001 flavor.name()
2002 );
2003 }
2004 }
2005
2006 proptest! {
2007 #![proptest_config(ProptestConfig::with_cases(64))]
2008
2009 #[test]
2010 fn prop_md075_fix_is_idempotent_for_orphaned_rows(
2011 cols in 2usize..6,
2012 base_rows in 1usize..5,
2013 orphan_rows in 1usize..4,
2014 blank_lines in 1usize..4,
2015 flavor in prop::sample::select(all_flavors().to_vec()),
2016 ) {
2017 let rule = MD075OrphanedTableRows::default();
2018
2019 let mut lines = Vec::new();
2020 lines.push(make_row("H", cols));
2021 lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2022 for idx in 0..base_rows {
2023 lines.push(make_row(&format!("r{}c", idx + 1), cols));
2024 }
2025 for _ in 0..blank_lines {
2026 lines.push(String::new());
2027 }
2028 for idx in 0..orphan_rows {
2029 lines.push(make_row(&format!("o{}c", idx + 1), cols));
2030 }
2031
2032 let content = lines.join("\n");
2033 let ctx1 = LintContext::new(&content, flavor, None);
2034 let fixed_once = rule.fix(&ctx1).unwrap();
2035
2036 let ctx2 = LintContext::new(&fixed_once, flavor, None);
2037 let fixed_twice = rule.fix(&ctx2).unwrap();
2038
2039 prop_assert_eq!(fixed_once.as_str(), fixed_twice.as_str());
2040 prop_assert!(
2041 rule.check(&ctx2).unwrap().is_empty(),
2042 "MD075 warnings remained after fix in flavor {}",
2043 flavor.name()
2044 );
2045 }
2046
2047 #[test]
2048 fn prop_md075_cli_lsp_fix_consistency(
2049 cols in 2usize..6,
2050 base_rows in 1usize..4,
2051 orphan_rows in 1usize..3,
2052 blank_lines in 1usize..3,
2053 flavor in prop::sample::select(all_flavors().to_vec()),
2054 ) {
2055 let rule = MD075OrphanedTableRows::default();
2056
2057 let mut lines = Vec::new();
2058 lines.push(make_row("H", cols));
2059 lines.push(format!("| {} |", (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")));
2060 for idx in 0..base_rows {
2061 lines.push(make_row(&format!("r{}c", idx + 1), cols));
2062 }
2063 for _ in 0..blank_lines {
2064 lines.push(String::new());
2065 }
2066 for idx in 0..orphan_rows {
2067 lines.push(make_row(&format!("o{}c", idx + 1), cols));
2068 }
2069 let content = lines.join("\n");
2070
2071 let ctx = LintContext::new(&content, flavor, None);
2072 let warnings = rule.check(&ctx).unwrap();
2073 prop_assert!(
2074 warnings.iter().any(|w| w.message.contains("Orphaned")),
2075 "Expected orphan warning for flavor {}",
2076 flavor.name()
2077 );
2078
2079 let lsp_fixed = apply_warning_fixes(&content, &warnings).unwrap();
2080 let cli_fixed = rule.fix(&ctx).unwrap();
2081 prop_assert_eq!(lsp_fixed, cli_fixed);
2082 }
2083
2084 #[test]
2085 fn prop_md075_column_mismatch_is_never_fixable(
2086 base_cols in 2usize..6,
2087 orphan_cols in 1usize..6,
2088 blank_lines in 1usize..4,
2089 flavor in prop::sample::select(all_flavors().to_vec()),
2090 ) {
2091 prop_assume!(base_cols != orphan_cols);
2092 let rule = MD075OrphanedTableRows::default();
2093
2094 let mut lines = vec![
2095 make_row("H", base_cols),
2096 format!("| {} |", (0..base_cols).map(|_| "---").collect::<Vec<_>>().join(" | ")),
2097 make_row("r", base_cols),
2098 ];
2099 for _ in 0..blank_lines {
2100 lines.push(String::new());
2101 }
2102 lines.push(make_row("o", orphan_cols));
2103
2104 let content = lines.join("\n");
2105 let ctx = LintContext::new(&content, flavor, None);
2106 let warnings = rule.check(&ctx).unwrap();
2107 prop_assert_eq!(warnings.len(), 1);
2108 prop_assert!(warnings[0].fix.is_none());
2109 prop_assert_eq!(rule.fix(&ctx).unwrap(), content);
2110 }
2111 }
2112
2113 #[test]
2134 fn md075_pandoc_grid_tables_not_flagged() {
2135 let rule = MD075OrphanedTableRows::default();
2136 let content = "\
2137+---+---+
2138| a | b |
2139+===+===+
2140| 1 | 2 |
2141+---+---+
2142";
2143 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2144 let result = rule.check(&ctx).unwrap();
2145 assert!(
2146 result.is_empty(),
2147 "MD075 should not flag Pandoc grid tables: {result:?}"
2148 );
2149
2150 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2151 let result_std = rule.check(&ctx_std).unwrap();
2152 assert!(
2153 result_std.is_empty(),
2154 "MD075 should not flag grid-table-like content under Standard: {result_std:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn md075_pandoc_multi_line_tables_not_flagged() {
2160 let rule = MD075OrphanedTableRows::default();
2161 let content = "\
2162--------- -----------
2163Header 1 Header 2
2164--------- -----------
2165Cell 1 Cell 2
2166--------- -----------
2167";
2168 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2169 let result = rule.check(&ctx).unwrap();
2170 assert!(
2171 result.is_empty(),
2172 "MD075 should not flag Pandoc multi-line tables: {result:?}"
2173 );
2174
2175 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2176 let result_std = rule.check(&ctx_std).unwrap();
2177 assert!(
2178 result_std.is_empty(),
2179 "MD075 should not flag multi-line table content under Standard: {result_std:?}"
2180 );
2181 }
2182
2183 #[test]
2184 fn md075_pandoc_line_blocks_not_flagged() {
2185 let rule = MD075OrphanedTableRows::default();
2186 let content = "| First line\n| Second line\n";
2190 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2191 let result = rule.check(&ctx).unwrap();
2192 assert!(
2193 result.is_empty(),
2194 "MD075 should not treat Pandoc line blocks as orphaned table rows: {result:?}"
2195 );
2196
2197 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2198 let result_std = rule.check(&ctx_std).unwrap();
2199 assert!(
2200 result_std.is_empty(),
2201 "MD075 should not treat line-block-like content as orphaned rows under Standard: {result_std:?}"
2202 );
2203 }
2204
2205 #[test]
2206 fn md075_pandoc_pipe_table_captions_not_flagged() {
2207 let rule = MD075OrphanedTableRows::default();
2208 let content = "\
2211| H1 | H2 |
2212|----|-----|
2213| a | b |
2214
2215: My table caption
2216";
2217 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
2218 let result = rule.check(&ctx).unwrap();
2219 assert!(
2220 result.is_empty(),
2221 "MD075 should not flag the pipe-table caption line as orphaned: {result:?}"
2222 );
2223
2224 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
2225 let result_std = rule.check(&ctx_std).unwrap();
2226 assert!(
2227 result_std.is_empty(),
2228 "MD075 table with caption — caption not a pipe row under Standard: {result_std:?}"
2229 );
2230 }
2231
2232 #[test]
2233 fn md075_obsidian_wikilink_paragraph_not_orphaned_row() {
2234 let rule = MD075OrphanedTableRows::default();
2235 let content = "\
2237| Character | Note |
2238| --------- | -------- |
2239| Alice | curious |
2240
2241She saw [[White Rabbit|the Rabbit]] run past and went down the hole.
2242";
2243 for flavor in all_flavors() {
2244 let ctx = LintContext::new(content, flavor, None);
2245 let warnings = rule.check(&ctx).unwrap();
2246 assert!(
2247 warnings.is_empty(),
2248 "MD075 should not treat a wikilink paragraph as an orphaned row for {}: {warnings:?}",
2249 flavor.name()
2250 );
2251 }
2252 }
2253
2254 #[test]
2255 fn md075_obsidian_genuine_orphaned_row_still_flagged() {
2256 let rule = MD075OrphanedTableRows::default();
2257 let content = "\
2259| Character | Note |
2260| --------- | -------- |
2261| Alice | curious |
2262
2263| Hatter | mad |
2264";
2265 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
2266 assert!(
2267 !rule.check(&ctx).unwrap().is_empty(),
2268 "MD075 should still flag a genuine orphaned row under Obsidian"
2269 );
2270 }
2271
2272 #[test]
2276 fn md075_headerless_group_counts_columns_in_the_documents_flavor() {
2277 let rule = MD075OrphanedTableRows::default();
2278
2279 let consistent_only_in_obsidian = "\
2282| Character | Note |
2283| --------- | -------- |
2284| Alice | curious |
2285
2286Prose between the table and the rows below it.
2287
2288| [[White Rabbit|the Rabbit]] | late |
2289| Hatter | mad |
2290";
2291 let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Obsidian, None);
2292 assert!(
2293 !rule.check(&ctx).unwrap().is_empty(),
2294 "A two-column headerless group should be flagged under Obsidian"
2295 );
2296 let ctx = LintContext::new(consistent_only_in_obsidian, MarkdownFlavor::Standard, None);
2297 assert!(
2298 rule.check(&ctx).unwrap().is_empty(),
2299 "Under Standard the same rows are 3 and 2 cells wide, so they are not a group"
2300 );
2301
2302 let consistent_only_in_gfm = "\
2304| Character | Note |
2305| --------- | -------- |
2306| Alice | curious |
2307
2308Prose between the table and the rows below it.
2309
2310| [[White Rabbit|the Rabbit]] | late |
2311| Hatter | mad | tea |
2312";
2313 let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Obsidian, None);
2314 assert!(
2315 rule.check(&ctx).unwrap().is_empty(),
2316 "Under Obsidian the rows are 2 and 3 cells wide, so they are not a group"
2317 );
2318 let ctx = LintContext::new(consistent_only_in_gfm, MarkdownFlavor::Standard, None);
2319 assert!(
2320 !rule.check(&ctx).unwrap().is_empty(),
2321 "A three-column headerless group should be flagged under Standard"
2322 );
2323 }
2324}