1use crate::config::MarkdownFlavor;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
4use crate::utils::range_utils::calculate_line_range;
5use crate::utils::table_utils::{TableBlock, TableUtils};
6
7mod md055_config;
8use md055_config::MD055Config;
9
10static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
12
13#[derive(Debug, Default, Clone)]
86pub struct MD055TablePipeStyle {
87 config: MD055Config,
88 style_explicit: bool,
92}
93
94impl MD055TablePipeStyle {
95 pub fn new(style: String) -> Self {
96 Self {
97 config: MD055Config { style },
98 style_explicit: true,
99 }
100 }
101
102 pub fn from_config_struct(config: MD055Config) -> Self {
103 Self {
104 config,
105 style_explicit: false,
106 }
107 }
108
109 fn effective_configured_style(&self, ctx: &crate::lint_context::LintContext) -> &str {
118 if ctx.flavor == MarkdownFlavor::MDG {
119 self.warn_once_about_overridden_style();
120 return "leading_and_trailing";
121 }
122
123 match self.config.style.as_str() {
124 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
125 self.config.style.as_str()
126 }
127 _ => {
128 "leading_and_trailing"
130 }
131 }
132 }
133
134 fn warn_once_about_overridden_style(&self) {
140 if !self.style_explicit
141 || !matches!(
142 self.config.style.as_str(),
143 "no_leading_or_trailing" | "leading_only" | "trailing_only"
144 )
145 {
146 return;
147 }
148
149 MDG_STYLE_OVERRIDE.report(
150 "MD055",
151 "style",
152 &self.config.style,
153 "leading_and_trailing",
154 "a Gherkin table row is an indent followed directly by a pipe",
155 );
156 }
157
158 fn determine_table_style(&self, table_block: &TableBlock, lines: &[&str]) -> Option<&'static str> {
160 let mut leading_and_trailing_count = 0;
161 let mut no_leading_or_trailing_count = 0;
162 let mut leading_only_count = 0;
163 let mut trailing_only_count = 0;
164
165 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
167 if let Some(style) = TableUtils::determine_pipe_style(header_content) {
168 match style {
169 "leading_and_trailing" => leading_and_trailing_count += 1,
170 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
171 "leading_only" => leading_only_count += 1,
172 "trailing_only" => trailing_only_count += 1,
173 _ => {}
174 }
175 }
176
177 for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
179 let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
180 if let Some(style) = TableUtils::determine_pipe_style(content) {
181 match style {
182 "leading_and_trailing" => leading_and_trailing_count += 1,
183 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
184 "leading_only" => leading_only_count += 1,
185 "trailing_only" => trailing_only_count += 1,
186 _ => {}
187 }
188 }
189 }
190
191 let max_count = leading_and_trailing_count
194 .max(no_leading_or_trailing_count)
195 .max(leading_only_count)
196 .max(trailing_only_count);
197
198 if max_count > 0 {
199 if leading_and_trailing_count == max_count {
200 Some("leading_and_trailing")
201 } else if no_leading_or_trailing_count == max_count {
202 Some("no_leading_or_trailing")
203 } else if leading_only_count == max_count {
204 Some("leading_only")
205 } else if trailing_only_count == max_count {
206 Some("trailing_only")
207 } else {
208 None
209 }
210 } else {
211 None
212 }
213 }
214
215 #[cfg(test)]
217 fn fix_table_row(&self, line: &str, target_style: &str) -> String {
218 let dummy_block = TableBlock {
219 start_line: 0,
220 end_line: 0,
221 header_line: 0,
222 delimiter_line: 0,
223 content_lines: vec![],
224 list_context: None,
225 };
226 self.fix_table_row_with_context(line, target_style, &dummy_block, 0, MarkdownFlavor::Standard)
227 }
228
229 fn fix_table_row_with_context(
234 &self,
235 line: &str,
236 target_style: &str,
237 table_block: &TableBlock,
238 table_line_index: usize,
239 flavor: MarkdownFlavor,
240 ) -> String {
241 let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
243
244 if let Some(ref list_ctx) = table_block.list_context {
246 if table_line_index == 0 {
247 let stripped = after_bq
249 .strip_prefix(&list_ctx.list_prefix)
250 .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
251 let fixed_content = self.fix_table_content(stripped.trim(), target_style);
252
253 let lp = &list_ctx.list_prefix;
255 if bq_prefix.is_empty() && lp.is_empty() {
256 fixed_content
257 } else {
258 format!("{bq_prefix}{lp}{fixed_content}")
259 }
260 } else {
261 let content_indent = list_ctx.content_indent;
263 let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
264 let fixed_content = self.fix_table_content(stripped.trim(), target_style);
265
266 let indent = " ".repeat(content_indent);
268 format!("{bq_prefix}{indent}{fixed_content}")
269 }
270 } else {
271 let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
273 let indent = if flavor == MarkdownFlavor::MDG {
277 &after_bq[..after_bq.len() - after_bq.trim_start().len()]
278 } else {
279 ""
280 };
281 if bq_prefix.is_empty() && indent.is_empty() {
282 fixed_content
283 } else {
284 format!("{bq_prefix}{indent}{fixed_content}")
285 }
286 }
287 }
288
289 fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
291 if !trimmed.contains('|') {
292 return trimmed.to_string();
293 }
294
295 let has_leading = trimmed.starts_with('|');
296 let has_trailing = trimmed.ends_with('|');
297
298 match target_style {
299 "leading_and_trailing" => {
300 let mut result = trimmed.to_string();
301
302 if !has_leading {
304 result = format!("| {result}");
305 }
306
307 if !has_trailing {
309 result = format!("{result} |");
310 }
311
312 result
313 }
314 "no_leading_or_trailing" => {
315 let mut result = trimmed;
316
317 if has_leading {
319 result = result.strip_prefix('|').unwrap_or(result);
320 result = result.trim_start();
321 }
322
323 if has_trailing {
325 result = result.strip_suffix('|').unwrap_or(result);
326 result = result.trim_end();
327 }
328
329 result.to_string()
330 }
331 "leading_only" => {
332 let mut result = trimmed.to_string();
333
334 if !has_leading {
336 result = format!("| {result}");
337 }
338
339 if has_trailing {
341 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
342 }
343
344 result
345 }
346 "trailing_only" => {
347 let mut result = trimmed;
348
349 if has_leading {
351 result = result.strip_prefix('|').unwrap_or(result).trim_start();
352 }
353
354 let mut result = result.to_string();
355
356 if !has_trailing {
358 result = format!("{result} |");
359 }
360
361 result
362 }
363 _ => trimmed.to_string(),
364 }
365 }
366}
367
368impl Rule for MD055TablePipeStyle {
369 fn name(&self) -> &'static str {
370 "MD055"
371 }
372
373 fn description(&self) -> &'static str {
374 "Table pipe style should be consistent"
375 }
376
377 fn category(&self) -> RuleCategory {
378 RuleCategory::Table
379 }
380
381 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
382 !ctx.likely_has_tables()
384 }
385
386 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
387 let mut warnings = Vec::new();
388
389 let lines = ctx.raw_lines();
392
393 let configured_style = self.effective_configured_style(ctx);
394
395 let table_blocks = &ctx.table_blocks;
397
398 for table_block in table_blocks {
400 let table_style = if configured_style == "consistent" {
403 self.determine_table_style(table_block, lines)
404 } else {
405 None
406 };
407
408 let target_style = if configured_style == "consistent" {
410 table_style.unwrap_or("leading_and_trailing")
411 } else {
412 configured_style
413 };
414
415 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
417 .chain(std::iter::once(table_block.delimiter_line))
418 .chain(table_block.content_lines.iter().copied())
419 .collect();
420
421 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
425 let line = lines[line_idx];
426 let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
428 if let Some(current_style) = TableUtils::determine_pipe_style(content) {
429 let needs_fixing = current_style != target_style;
431
432 if needs_fixing {
433 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
434
435 let message = format!(
436 "Table pipe style should be {}",
437 match target_style {
438 "leading_and_trailing" => "leading and trailing",
439 "no_leading_or_trailing" => "no leading or trailing",
440 "leading_only" => "leading only",
441 "trailing_only" => "trailing only",
442 _ => target_style,
443 }
444 );
445
446 let fixed_line = self.fix_table_row_with_context(
449 line,
450 target_style,
451 table_block,
452 table_line_idx,
453 ctx.flavor,
454 );
455 let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
456
457 warnings.push(LintWarning {
458 rule_name: Some(self.name().to_string()),
459 severity: Severity::Warning,
460 message,
461 line: start_line,
462 column: start_col,
463 end_line,
464 end_column: end_col,
465 fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
466 });
467 }
468 }
469 }
470 }
471
472 Ok(warnings)
473 }
474
475 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
476 if self.should_skip(ctx) {
477 return Ok(ctx.content.to_string());
478 }
479 let warnings = self.check(ctx)?;
480 if warnings.is_empty() {
481 return Ok(ctx.content.to_string());
482 }
483 let warnings =
484 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
485 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
486 }
487
488 fn as_any(&self) -> &dyn std::any::Any {
489 self
490 }
491
492 crate::impl_rule_config_sections!(MD055Config);
493
494 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
495 where
496 Self: Sized,
497 {
498 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
499 let style_explicit = option_is_explicit(config, "MD055", "style");
500
501 Box::new(Self {
502 config: rule_config,
503 style_explicit,
504 })
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
518 let config: md055_config::MD055Config =
519 toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
520 MD055TablePipeStyle::from_config_struct(config)
521 }
522
523 #[test]
524 fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
525 let rule = rule_from_toml_style("no-leading-or-trailing");
526 let content = "A | B\n--- | ---\n1 | 2";
527 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let warnings = rule.check(&ctx).unwrap();
529 assert!(
530 warnings.is_empty(),
531 "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
532 );
533 }
534
535 #[test]
536 fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
537 let rule = rule_from_toml_style("no-leading-or-trailing");
538 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
539 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let warnings = rule.check(&ctx).unwrap();
541 assert_eq!(
542 warnings.len(),
543 3,
544 "no-leading-or-trailing should flag all 3 rows with pipes"
545 );
546 assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
547 }
548
549 #[test]
550 fn test_leading_only_kebab_accepts_conforming_table() {
551 let rule = rule_from_toml_style("leading-only");
552 let content = "| A | B\n|---|---\n| 1 | 2";
553 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let warnings = rule.check(&ctx).unwrap();
555 assert!(
556 warnings.is_empty(),
557 "leading-only should accept a leading-only table: {warnings:?}"
558 );
559 }
560
561 #[test]
562 fn test_trailing_only_kebab_accepts_conforming_table() {
563 let rule = rule_from_toml_style("trailing-only");
564 let content = "A | B |\n---|---|\n1 | 2 |";
565 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 let warnings = rule.check(&ctx).unwrap();
567 assert!(
568 warnings.is_empty(),
569 "trailing-only should accept a trailing-only table: {warnings:?}"
570 );
571 }
572
573 #[test]
574 fn test_trailing_only_kebab_rejects_nonconforming_table() {
575 let rule = rule_from_toml_style("trailing-only");
576 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
578 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let warnings = rule.check(&ctx).unwrap();
580 assert_eq!(
581 warnings.len(),
582 3,
583 "trailing-only should flag all 3 rows that have leading pipes"
584 );
585 assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
586 }
587
588 #[test]
589 fn test_leading_only_kebab_rejects_nonconforming_table() {
590 let rule = rule_from_toml_style("leading-only");
591 let content = "A | B |\n---|---|\n1 | 2 |";
593 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let warnings = rule.check(&ctx).unwrap();
595 assert_eq!(
596 warnings.len(),
597 3,
598 "leading-only should flag all 3 rows that have trailing pipes"
599 );
600 assert!(warnings.iter().all(|w| w.message.contains("leading only")));
601 }
602
603 #[test]
604 fn test_leading_and_trailing_kebab_accepts_conforming_table() {
605 let rule = rule_from_toml_style("leading-and-trailing");
606 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
607 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let warnings = rule.check(&ctx).unwrap();
609 assert!(
610 warnings.is_empty(),
611 "leading-and-trailing should accept a fully-piped table: {warnings:?}"
612 );
613 }
614
615 #[test]
616 fn test_kebab_and_snake_case_styles_are_equivalent() {
617 let pairs = [
620 ("no-leading-or-trailing", "no_leading_or_trailing"),
621 ("leading-only", "leading_only"),
622 ("trailing-only", "trailing_only"),
623 ("leading-and-trailing", "leading_and_trailing"),
624 ];
625 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628
629 for (kebab, snake) in pairs {
630 let kebab_rule = rule_from_toml_style(kebab);
631 let snake_rule = rule_from_toml_style(snake);
632 let kebab_warnings = kebab_rule.check(&ctx).unwrap();
633 let snake_warnings = snake_rule.check(&ctx).unwrap();
634
635 assert_eq!(
636 kebab_warnings.len(),
637 snake_warnings.len(),
638 "'{kebab}' and '{snake}' must produce the same number of warnings"
639 );
640 for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
641 assert_eq!(
642 kw.message, sw.message,
643 "warning[{i}] message differs between '{kebab}' and '{snake}'"
644 );
645 assert_eq!(
646 kw.line, sw.line,
647 "warning[{i}] line differs between '{kebab}' and '{snake}'"
648 );
649 }
650 }
651 }
652
653 fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
654 let rule = rule_from_toml_style(style);
655 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let fixed = rule.fix(&ctx).unwrap();
657 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
658 let remaining = rule.check(&ctx2).unwrap();
659 assert!(
660 remaining.is_empty(),
661 "style '{style}': after fix(), check() should find 0 violations.\n\
662 Original: {content:?}\n\
663 Fixed: {fixed:?}\n\
664 Remaining: {remaining:?}"
665 );
666 }
667
668 #[test]
669 fn test_roundtrip_kebab_no_leading_or_trailing() {
670 assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
671 }
672
673 #[test]
674 fn test_roundtrip_kebab_leading_and_trailing() {
675 assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
676 }
677
678 #[test]
679 fn test_roundtrip_kebab_leading_only() {
680 assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
681 }
682
683 #[test]
684 fn test_roundtrip_kebab_trailing_only() {
685 assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
686 }
687
688 #[test]
689 fn test_md055_delimiter_row_handling() {
690 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
692
693 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
694 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.fix(&ctx).unwrap();
696
697 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
700
701 assert_eq!(result, expected);
702
703 let warnings = rule.check(&ctx).unwrap();
705 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
707 assert_eq!(
708 delimiter_warning.message,
709 "Table pipe style should be no leading or trailing"
710 );
711
712 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
714
715 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
716 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.fix(&ctx).unwrap();
718
719 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
722
723 assert_eq!(result, expected);
724 }
725
726 #[test]
727 fn test_md055_check_finds_delimiter_row_issues() {
728 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
730
731 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
732 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let warnings = rule.check(&ctx).unwrap();
734
735 assert_eq!(warnings.len(), 3);
737
738 let delimiter_warning = &warnings[1];
740 assert_eq!(delimiter_warning.line, 2);
741 assert_eq!(
742 delimiter_warning.message,
743 "Table pipe style should be no leading or trailing"
744 );
745 }
746
747 #[test]
748 fn test_md055_real_world_example() {
749 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
751
752 let content = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |\n| Data 4 | Data 5 | Data 6 |\n\nMore content after the table.";
753 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.fix(&ctx).unwrap();
755
756 let expected = "# Table Example\n\nHere's a table with leading and trailing pipes:\n\nHeader 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3\nData 4 | Data 5 | Data 6\n\nMore content after the table.";
759
760 assert_eq!(result, expected);
761
762 let warnings = rule.check(&ctx).unwrap();
764 assert_eq!(warnings.len(), 4); assert_eq!(warnings[0].line, 5); assert_eq!(warnings[1].line, 6); assert_eq!(warnings[2].line, 7); assert_eq!(warnings[3].line, 8); }
772
773 #[test]
774 fn test_md055_invalid_style() {
775 let rule = MD055TablePipeStyle::new("leading_or_trailing".to_string()); let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
779 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.fix(&ctx).unwrap();
781
782 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
785
786 assert_eq!(result, expected);
787
788 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
790 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let result = rule.fix(&ctx2).unwrap();
792
793 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
796 assert_eq!(result, expected);
797
798 let warnings = rule.check(&ctx2).unwrap();
800
801 assert_eq!(warnings.len(), 3);
804 }
805
806 #[test]
807 fn test_underflow_protection() {
808 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
810
811 let result = rule.fix_table_row("", "leading_and_trailing");
813 assert_eq!(result, "");
814
815 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
817 assert_eq!(result, "no pipes here");
818
819 let result = rule.fix_table_row("|", "leading_and_trailing");
821 assert!(!result.is_empty());
823 }
824
825 #[test]
828 fn test_fix_table_row_in_blockquote() {
829 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
830
831 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
833 assert_eq!(result, "> | H1 | H2 |");
834
835 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
837 assert_eq!(result, "> | H1 | H2 |");
838
839 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
841 assert_eq!(result, "> H1 | H2");
842 }
843
844 #[test]
845 fn test_fix_table_row_in_nested_blockquote() {
846 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
847
848 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
850 assert_eq!(result, ">> | H1 | H2 |");
851
852 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
854 assert_eq!(result, ">>> | H1 | H2 |");
855 }
856
857 #[test]
858 fn test_blockquote_table_full_document() {
859 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
860
861 let content = "> H1 | H2\n> ----|----\n> a | b";
863 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.fix(&ctx).unwrap();
865
866 assert!(
869 result.starts_with("> |"),
870 "Header should start with blockquote + pipe. Got:\n{result}"
871 );
872 assert!(
874 result.contains("> | ----"),
875 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
876 );
877 }
878
879 #[test]
880 fn test_blockquote_table_no_leading_trailing() {
881 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
882
883 let content = "> | H1 | H2 |\n> |----|----|---|\n> | a | b |";
885 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.fix(&ctx).unwrap();
887
888 let lines: Vec<&str> = result.lines().collect();
890 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
891 assert!(
892 !lines[0].starts_with("> |"),
893 "Leading pipe should be removed. Got: {}",
894 lines[0]
895 );
896 }
897
898 #[test]
899 fn test_mixed_regular_and_blockquote_tables() {
900 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
901
902 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
904 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.fix(&ctx).unwrap();
906
907 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
909 assert!(
910 result.contains("> | H3 | H4 |"),
911 "Blockquote table should have pipes added with prefix preserved"
912 );
913 }
914
915 fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
918 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let fixed = rule.fix(&ctx).unwrap();
920 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
921 let remaining = rule.check(&ctx2).unwrap();
922 assert!(
923 remaining.is_empty(),
924 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
925 );
926 }
927
928 #[test]
929 fn test_roundtrip_leading_and_trailing() {
930 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
931 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
932 }
933
934 #[test]
935 fn test_roundtrip_no_leading_or_trailing() {
936 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
937 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
938 }
939
940 #[test]
941 fn test_roundtrip_consistent_mode() {
942 let rule = MD055TablePipeStyle::default();
943 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
944 }
945
946 #[test]
947 fn test_roundtrip_blockquote_table() {
948 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
949 assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
950 }
951
952 #[test]
953 fn test_roundtrip_mixed_tables() {
954 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
955 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
956 }
957
958 #[test]
959 fn test_roundtrip_with_surrounding_content() {
960 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
961 assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
962 }
963
964 #[test]
965 fn test_roundtrip_clean_content() {
966 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
967 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
968 }
969
970 #[test]
987 fn md055_pandoc_grid_tables_not_flagged() {
988 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
989 let content = "\
990+---+---+
991| a | b |
992+===+===+
993| 1 | 2 |
994+---+---+
995";
996 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(
1001 result.is_empty(),
1002 "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1003 );
1004
1005 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result_std = rule.check(&ctx_std).unwrap();
1009 assert!(
1010 result_std.is_empty(),
1011 "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
1012 );
1013 }
1014
1015 #[test]
1016 fn md055_pandoc_multi_line_tables_not_flagged() {
1017 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1018 let content = "\
1020--------- ----------- ------
1021Header 1 Header 2 Header 3
1022--------- ----------- ------
1023Cell 1 Cell 2 Cell 3
1024--------- ----------- ------
1025";
1026 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1027 let result = rule.check(&ctx).unwrap();
1028 assert!(
1029 result.is_empty(),
1030 "MD055 should not flag Pandoc multi-line tables: {result:?}"
1031 );
1032
1033 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result_std = rule.check(&ctx_std).unwrap();
1035 assert!(
1036 result_std.is_empty(),
1037 "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1038 );
1039 }
1040
1041 #[test]
1042 fn md055_pandoc_line_blocks_not_flagged() {
1043 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1044 let content = "| First line\n| Second line\n";
1047 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(
1050 result.is_empty(),
1051 "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1052 );
1053
1054 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result_std = rule.check(&ctx_std).unwrap();
1056 assert!(
1057 result_std.is_empty(),
1058 "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
1059 );
1060 }
1061
1062 #[test]
1063 fn md055_pandoc_pipe_table_captions_not_flagged() {
1064 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1065 let content = "\
1068| H1 | H2 |
1069|----|-----|
1070| a | b |
1071
1072: My table caption
1073";
1074 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1075 let result = rule.check(&ctx).unwrap();
1076 assert!(
1077 result.is_empty(),
1078 "MD055 should not flag the pipe-table caption line: {result:?}"
1079 );
1080
1081 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083 let result_std = rule.check(&ctx_std).unwrap();
1084 assert!(
1085 result_std.is_empty(),
1086 "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1087 );
1088 }
1089
1090 const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1099 (
1100 "no_leading_or_trailing",
1101 ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1102 ),
1103 (
1104 "leading_only",
1105 ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1106 ),
1107 (
1108 "trailing_only",
1109 ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1110 ),
1111 ];
1112
1113 const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1114
1115 fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1116 let spaces = " ".repeat(indent);
1117 let [header, delimiter, body] = rows;
1118 format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1119 }
1120
1121 #[test]
1122 fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1123 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1126 let rule = MD055TablePipeStyle::new(style.to_string());
1127
1128 for indent in [2, 3, 4, 5] {
1129 let content = examples_table(indent, rows);
1130 let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1131 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1132
1133 assert_eq!(
1134 rule.check(&ctx).unwrap().len(),
1135 3,
1136 "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1137 );
1138
1139 let fixed = rule.fix(&ctx).unwrap();
1140 assert_eq!(
1141 fixed, expected,
1142 "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1143 );
1144
1145 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1146 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1147 assert_eq!(
1148 rule.fix(&fixed_ctx).unwrap(),
1149 fixed,
1150 "style '{style}' at indent {indent}: MDG fix must be idempotent"
1151 );
1152 }
1153 }
1154 }
1155
1156 #[test]
1157 fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1158 let content = examples_table(2, MDG_GHERKIN_ROWS);
1161
1162 for style in [
1163 "consistent",
1164 "leading_and_trailing",
1165 "no_leading_or_trailing",
1166 "leading_only",
1167 "trailing_only",
1168 ] {
1169 let rule = MD055TablePipeStyle::new(style.to_string());
1170 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1171
1172 assert!(
1173 rule.check(&ctx).unwrap().is_empty(),
1174 "style '{style}': MDG enforces this form, so it cannot be reported"
1175 );
1176 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1177 }
1178 }
1179
1180 #[test]
1181 fn test_mdg_consistent_ignores_prevalence() {
1182 let defaulted = MD055TablePipeStyle::default();
1186 assert_eq!(defaulted.config.style, "consistent");
1187 let explicit = MD055TablePipeStyle::new("consistent".to_string());
1188
1189 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1190 let content = examples_table(2, rows);
1191 let expected = examples_table(2, MDG_GHERKIN_ROWS);
1192
1193 for rule in [&defaulted, &explicit] {
1194 let standard_ctx =
1195 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1196 assert!(
1197 rule.check(&standard_ctx).unwrap().is_empty(),
1198 "Standard resolves `consistent` to the table's own '{style}'"
1199 );
1200
1201 let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1202 assert_eq!(
1203 rule.fix(&mdg_ctx).unwrap(),
1204 expected,
1205 "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1206 );
1207 }
1208 }
1209 }
1210
1211 #[test]
1212 fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1213 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1214 let rule = MD055TablePipeStyle::new(style.to_string());
1215 let content = examples_table(2, rows);
1216 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1217
1218 assert!(
1219 rule.check(&ctx).unwrap().is_empty(),
1220 "style '{style}': Standard must still honour it"
1221 );
1222 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1223
1224 let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1226 let mdg_form_ctx =
1227 crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1228 assert_eq!(
1229 rule.check(&mdg_form_ctx).unwrap().len(),
1230 3,
1231 "style '{style}': Standard must still correct the leading-and-trailing form away"
1232 );
1233 }
1234
1235 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1238 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1239 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1240 assert_eq!(
1241 rule.fix(&ctx).unwrap(),
1242 examples_table(0, MDG_GHERKIN_ROWS),
1243 "Standard must keep stripping the indent"
1244 );
1245 }
1246
1247 #[test]
1248 fn test_from_config_records_whether_style_was_configured() {
1249 use crate::config::Config;
1253 use std::collections::BTreeMap;
1254
1255 let mut values = BTreeMap::new();
1256 values.insert(
1257 "style".to_string(),
1258 toml::Value::String("no_leading_or_trailing".to_string()),
1259 );
1260 let mut config = Config::default();
1261 config.rules.insert(
1262 "MD055".to_string(),
1263 crate::config::RuleConfig { severity: None, values },
1264 );
1265
1266 let configured = MD055TablePipeStyle::from_config(&config);
1267 let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1268 assert_eq!(configured.config.style, "no_leading_or_trailing");
1269 assert!(configured.style_explicit);
1270
1271 let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1272 let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1273 assert_eq!(defaulted.config.style, "consistent");
1274 assert!(!defaulted.style_explicit);
1275
1276 let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1279 style: "no_leading_or_trailing".to_string(),
1280 });
1281 assert!(!unreported.style_explicit);
1282 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1283 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1284 assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1285 }
1286}