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