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