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 = TableUtils::has_unescaped_trailing_pipe(trimmed);
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 #[test]
513 fn test_pipe_styles_preserve_escaped_trailing_cell_content() {
514 let source = "名 | B\n--- | ---\nleft | right \\|\n";
515 for (style, expected) in [
516 ("consistent", source),
517 ("no_leading_or_trailing", source),
518 ("leading_only", "| 名 | B\n| --- | ---\n| left | right \\|\n"),
519 ("trailing_only", "名 | B |\n--- | --- |\nleft | right \\| |\n"),
520 (
521 "leading_and_trailing",
522 "| 名 | B |\n| --- | --- |\n| left | right \\| |\n",
523 ),
524 ] {
525 let rule = MD055TablePipeStyle::new(style.to_string());
526 let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
527 assert_eq!(
528 rule.fix(&ctx).unwrap(),
529 expected,
530 "cell content must remain intact: {style}"
531 );
532 let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::Standard, None);
533 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
534 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
535 let mut original_html = String::new();
536 let mut fixed_html = String::new();
537 let options = pulldown_cmark::Options::ENABLE_TABLES;
538 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new_ext(source, options));
539 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new_ext(expected, options));
540 assert_eq!(original_html, fixed_html, "rendered table cells must be preserved");
541 }
542 }
543
544 #[test]
545 fn test_mdg_adds_a_delimiter_after_an_escaped_cell_pipe() {
546 let rule = MD055TablePipeStyle::default();
547 let source = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\|\n";
548 let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::MDG, None);
549 let warnings = rule.check(&ctx).unwrap();
550 assert_eq!(warnings.len(), 1);
551 assert_eq!(warnings[0].line, 6);
552 let expected = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\| |\n";
553 assert_eq!(rule.fix(&ctx).unwrap(), expected);
554 let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::MDG, None);
555 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
556 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
557 }
558
559 fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
565 let config: md055_config::MD055Config =
566 toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
567 MD055TablePipeStyle::from_config_struct(config)
568 }
569
570 #[test]
571 fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
572 let rule = rule_from_toml_style("no-leading-or-trailing");
573 let content = "A | B\n--- | ---\n1 | 2";
574 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let warnings = rule.check(&ctx).unwrap();
576 assert!(
577 warnings.is_empty(),
578 "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
579 );
580 }
581
582 #[test]
583 fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
584 let rule = rule_from_toml_style("no-leading-or-trailing");
585 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let warnings = rule.check(&ctx).unwrap();
588 assert_eq!(
589 warnings.len(),
590 3,
591 "no-leading-or-trailing should flag all 3 rows with pipes"
592 );
593 assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
594 }
595
596 #[test]
597 fn test_leading_only_kebab_accepts_conforming_table() {
598 let rule = rule_from_toml_style("leading-only");
599 let content = "| A | B\n|---|---\n| 1 | 2";
600 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let warnings = rule.check(&ctx).unwrap();
602 assert!(
603 warnings.is_empty(),
604 "leading-only should accept a leading-only table: {warnings:?}"
605 );
606 }
607
608 #[test]
609 fn test_trailing_only_kebab_accepts_conforming_table() {
610 let rule = rule_from_toml_style("trailing-only");
611 let content = "A | B |\n---|---|\n1 | 2 |";
612 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let warnings = rule.check(&ctx).unwrap();
614 assert!(
615 warnings.is_empty(),
616 "trailing-only should accept a trailing-only table: {warnings:?}"
617 );
618 }
619
620 #[test]
621 fn test_trailing_only_kebab_rejects_nonconforming_table() {
622 let rule = rule_from_toml_style("trailing-only");
623 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
625 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let warnings = rule.check(&ctx).unwrap();
627 assert_eq!(
628 warnings.len(),
629 3,
630 "trailing-only should flag all 3 rows that have leading pipes"
631 );
632 assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
633 }
634
635 #[test]
636 fn test_leading_only_kebab_rejects_nonconforming_table() {
637 let rule = rule_from_toml_style("leading-only");
638 let content = "A | B |\n---|---|\n1 | 2 |";
640 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let warnings = rule.check(&ctx).unwrap();
642 assert_eq!(
643 warnings.len(),
644 3,
645 "leading-only should flag all 3 rows that have trailing pipes"
646 );
647 assert!(warnings.iter().all(|w| w.message.contains("leading only")));
648 }
649
650 #[test]
651 fn test_leading_and_trailing_kebab_accepts_conforming_table() {
652 let rule = rule_from_toml_style("leading-and-trailing");
653 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655 let warnings = rule.check(&ctx).unwrap();
656 assert!(
657 warnings.is_empty(),
658 "leading-and-trailing should accept a fully-piped table: {warnings:?}"
659 );
660 }
661
662 #[test]
663 fn test_kebab_and_snake_case_styles_are_equivalent() {
664 let pairs = [
667 ("no-leading-or-trailing", "no_leading_or_trailing"),
668 ("leading-only", "leading_only"),
669 ("trailing-only", "trailing_only"),
670 ("leading-and-trailing", "leading_and_trailing"),
671 ];
672 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
674 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675
676 for (kebab, snake) in pairs {
677 let kebab_rule = rule_from_toml_style(kebab);
678 let snake_rule = rule_from_toml_style(snake);
679 let kebab_warnings = kebab_rule.check(&ctx).unwrap();
680 let snake_warnings = snake_rule.check(&ctx).unwrap();
681
682 assert_eq!(
683 kebab_warnings.len(),
684 snake_warnings.len(),
685 "'{kebab}' and '{snake}' must produce the same number of warnings"
686 );
687 for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
688 assert_eq!(
689 kw.message, sw.message,
690 "warning[{i}] message differs between '{kebab}' and '{snake}'"
691 );
692 assert_eq!(
693 kw.line, sw.line,
694 "warning[{i}] line differs between '{kebab}' and '{snake}'"
695 );
696 }
697 }
698 }
699
700 fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
701 let rule = rule_from_toml_style(style);
702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let fixed = rule.fix(&ctx).unwrap();
704 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
705 let remaining = rule.check(&ctx2).unwrap();
706 assert!(
707 remaining.is_empty(),
708 "style '{style}': after fix(), check() should find 0 violations.\n\
709 Original: {content:?}\n\
710 Fixed: {fixed:?}\n\
711 Remaining: {remaining:?}"
712 );
713 }
714
715 #[test]
716 fn test_roundtrip_kebab_no_leading_or_trailing() {
717 assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
718 }
719
720 #[test]
721 fn test_roundtrip_kebab_leading_and_trailing() {
722 assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
723 }
724
725 #[test]
726 fn test_roundtrip_kebab_leading_only() {
727 assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
728 }
729
730 #[test]
731 fn test_roundtrip_kebab_trailing_only() {
732 assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
733 }
734
735 #[test]
736 fn test_md055_delimiter_row_handling() {
737 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
739
740 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.fix(&ctx).unwrap();
743
744 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
747
748 assert_eq!(result, expected);
749
750 let warnings = rule.check(&ctx).unwrap();
752 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
754 assert_eq!(
755 delimiter_warning.message,
756 "Table pipe style should be no leading or trailing"
757 );
758
759 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
761
762 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
763 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.fix(&ctx).unwrap();
765
766 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
769
770 assert_eq!(result, expected);
771 }
772
773 #[test]
774 fn test_md055_check_finds_delimiter_row_issues() {
775 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
777
778 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 warnings = rule.check(&ctx).unwrap();
781
782 assert_eq!(warnings.len(), 3);
784
785 let delimiter_warning = &warnings[1];
787 assert_eq!(delimiter_warning.line, 2);
788 assert_eq!(
789 delimiter_warning.message,
790 "Table pipe style should be no leading or trailing"
791 );
792 }
793
794 #[test]
795 fn test_md055_real_world_example() {
796 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
798
799 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.";
800 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.fix(&ctx).unwrap();
802
803 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.";
806
807 assert_eq!(result, expected);
808
809 let warnings = rule.check(&ctx).unwrap();
811 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); }
819
820 #[test]
821 fn test_md055_invalid_style() {
822 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 |";
826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.fix(&ctx).unwrap();
828
829 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
832
833 assert_eq!(result, expected);
834
835 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
837 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.fix(&ctx2).unwrap();
839
840 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
843 assert_eq!(result, expected);
844
845 let warnings = rule.check(&ctx2).unwrap();
847
848 assert_eq!(warnings.len(), 3);
851 }
852
853 #[test]
854 fn test_underflow_protection() {
855 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
857
858 let result = rule.fix_table_row("", "leading_and_trailing");
860 assert_eq!(result, "");
861
862 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
864 assert_eq!(result, "no pipes here");
865
866 let result = rule.fix_table_row("|", "leading_and_trailing");
868 assert!(!result.is_empty());
870 }
871
872 #[test]
875 fn test_fix_table_row_in_blockquote() {
876 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
877
878 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
880 assert_eq!(result, "> | H1 | H2 |");
881
882 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
884 assert_eq!(result, "> | H1 | H2 |");
885
886 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
888 assert_eq!(result, "> H1 | H2");
889 }
890
891 #[test]
892 fn test_fix_table_row_in_nested_blockquote() {
893 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
894
895 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
897 assert_eq!(result, ">> | H1 | H2 |");
898
899 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
901 assert_eq!(result, ">>> | H1 | H2 |");
902 }
903
904 #[test]
905 fn test_blockquote_table_full_document() {
906 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
907
908 let content = "> H1 | H2\n> ----|----\n> a | b";
910 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.fix(&ctx).unwrap();
912
913 assert!(
916 result.starts_with("> |"),
917 "Header should start with blockquote + pipe. Got:\n{result}"
918 );
919 assert!(
921 result.contains("> | ----"),
922 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
923 );
924 }
925
926 #[test]
927 fn test_blockquote_table_no_leading_trailing() {
928 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
929
930 let content = "> | H1 | H2 |\n> |----|----|---|\n> | a | b |";
932 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.fix(&ctx).unwrap();
934
935 let lines: Vec<&str> = result.lines().collect();
937 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
938 assert!(
939 !lines[0].starts_with("> |"),
940 "Leading pipe should be removed. Got: {}",
941 lines[0]
942 );
943 }
944
945 #[test]
946 fn test_mixed_regular_and_blockquote_tables() {
947 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
948
949 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
951 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.fix(&ctx).unwrap();
953
954 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
956 assert!(
957 result.contains("> | H3 | H4 |"),
958 "Blockquote table should have pipes added with prefix preserved"
959 );
960 }
961
962 fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
965 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let fixed = rule.fix(&ctx).unwrap();
967 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
968 let remaining = rule.check(&ctx2).unwrap();
969 assert!(
970 remaining.is_empty(),
971 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
972 );
973 }
974
975 #[test]
976 fn test_roundtrip_leading_and_trailing() {
977 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
978 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
979 }
980
981 #[test]
982 fn test_roundtrip_no_leading_or_trailing() {
983 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
984 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
985 }
986
987 #[test]
988 fn test_roundtrip_consistent_mode() {
989 let rule = MD055TablePipeStyle::default();
990 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
991 }
992
993 #[test]
994 fn test_roundtrip_blockquote_table() {
995 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
996 assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
997 }
998
999 #[test]
1000 fn test_roundtrip_mixed_tables() {
1001 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1002 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
1003 }
1004
1005 #[test]
1006 fn test_roundtrip_with_surrounding_content() {
1007 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1008 assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
1009 }
1010
1011 #[test]
1012 fn test_roundtrip_clean_content() {
1013 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1014 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1015 }
1016
1017 #[test]
1034 fn md055_pandoc_grid_tables_not_flagged() {
1035 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1036 let content = "\
1037+---+---+
1038| a | b |
1039+===+===+
1040| 1 | 2 |
1041+---+---+
1042";
1043 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1046 let result = rule.check(&ctx).unwrap();
1047 assert!(
1048 result.is_empty(),
1049 "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1050 );
1051
1052 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 flag grid-table-like content under Standard either: {result_std:?}"
1059 );
1060 }
1061
1062 #[test]
1063 fn md055_pandoc_multi_line_tables_not_flagged() {
1064 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1065 let content = "\
1067--------- ----------- ------
1068Header 1 Header 2 Header 3
1069--------- ----------- ------
1070Cell 1 Cell 2 Cell 3
1071--------- ----------- ------
1072";
1073 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert!(
1076 result.is_empty(),
1077 "MD055 should not flag Pandoc multi-line tables: {result:?}"
1078 );
1079
1080 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result_std = rule.check(&ctx_std).unwrap();
1082 assert!(
1083 result_std.is_empty(),
1084 "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1085 );
1086 }
1087
1088 #[test]
1089 fn md055_pandoc_line_blocks_not_flagged() {
1090 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1091 let content = "| First line\n| Second line\n";
1094 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1095 let result = rule.check(&ctx).unwrap();
1096 assert!(
1097 result.is_empty(),
1098 "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1099 );
1100
1101 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let result_std = rule.check(&ctx_std).unwrap();
1103 assert!(
1104 result_std.is_empty(),
1105 "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn md055_pandoc_pipe_table_captions_not_flagged() {
1111 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1112 let content = "\
1115| H1 | H2 |
1116|----|-----|
1117| a | b |
1118
1119: My table caption
1120";
1121 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1122 let result = rule.check(&ctx).unwrap();
1123 assert!(
1124 result.is_empty(),
1125 "MD055 should not flag the pipe-table caption line: {result:?}"
1126 );
1127
1128 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result_std = rule.check(&ctx_std).unwrap();
1131 assert!(
1132 result_std.is_empty(),
1133 "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1134 );
1135 }
1136
1137 const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1146 (
1147 "no_leading_or_trailing",
1148 ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1149 ),
1150 (
1151 "leading_only",
1152 ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1153 ),
1154 (
1155 "trailing_only",
1156 ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1157 ),
1158 ];
1159
1160 const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1161
1162 fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1163 let spaces = " ".repeat(indent);
1164 let [header, delimiter, body] = rows;
1165 format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1166 }
1167
1168 #[test]
1169 fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1170 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1173 let rule = MD055TablePipeStyle::new(style.to_string());
1174
1175 for indent in [2, 3, 4, 5] {
1176 let content = examples_table(indent, rows);
1177 let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1178 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1179
1180 assert_eq!(
1181 rule.check(&ctx).unwrap().len(),
1182 3,
1183 "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1184 );
1185
1186 let fixed = rule.fix(&ctx).unwrap();
1187 assert_eq!(
1188 fixed, expected,
1189 "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1190 );
1191
1192 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1193 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1194 assert_eq!(
1195 rule.fix(&fixed_ctx).unwrap(),
1196 fixed,
1197 "style '{style}' at indent {indent}: MDG fix must be idempotent"
1198 );
1199 }
1200 }
1201 }
1202
1203 #[test]
1204 fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1205 let content = examples_table(2, MDG_GHERKIN_ROWS);
1208
1209 for style in [
1210 "consistent",
1211 "leading_and_trailing",
1212 "no_leading_or_trailing",
1213 "leading_only",
1214 "trailing_only",
1215 ] {
1216 let rule = MD055TablePipeStyle::new(style.to_string());
1217 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1218
1219 assert!(
1220 rule.check(&ctx).unwrap().is_empty(),
1221 "style '{style}': MDG enforces this form, so it cannot be reported"
1222 );
1223 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1224 }
1225 }
1226
1227 #[test]
1228 fn test_mdg_consistent_ignores_prevalence() {
1229 let defaulted = MD055TablePipeStyle::default();
1233 assert_eq!(defaulted.config.style, "consistent");
1234 let explicit = MD055TablePipeStyle::new("consistent".to_string());
1235
1236 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1237 let content = examples_table(2, rows);
1238 let expected = examples_table(2, MDG_GHERKIN_ROWS);
1239
1240 for rule in [&defaulted, &explicit] {
1241 let standard_ctx =
1242 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1243 assert!(
1244 rule.check(&standard_ctx).unwrap().is_empty(),
1245 "Standard resolves `consistent` to the table's own '{style}'"
1246 );
1247
1248 let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1249 assert_eq!(
1250 rule.fix(&mdg_ctx).unwrap(),
1251 expected,
1252 "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1253 );
1254 }
1255 }
1256 }
1257
1258 #[test]
1259 fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1260 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1261 let rule = MD055TablePipeStyle::new(style.to_string());
1262 let content = examples_table(2, rows);
1263 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1264
1265 assert!(
1266 rule.check(&ctx).unwrap().is_empty(),
1267 "style '{style}': Standard must still honour it"
1268 );
1269 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1270
1271 let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1273 let mdg_form_ctx =
1274 crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1275 assert_eq!(
1276 rule.check(&mdg_form_ctx).unwrap().len(),
1277 3,
1278 "style '{style}': Standard must still correct the leading-and-trailing form away"
1279 );
1280 }
1281
1282 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1285 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1286 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1287 assert_eq!(
1288 rule.fix(&ctx).unwrap(),
1289 examples_table(0, MDG_GHERKIN_ROWS),
1290 "Standard must keep stripping the indent"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_from_config_records_whether_style_was_configured() {
1296 use crate::config::Config;
1300 use std::collections::BTreeMap;
1301
1302 let mut values = BTreeMap::new();
1303 values.insert(
1304 "style".to_string(),
1305 toml::Value::String("no_leading_or_trailing".to_string()),
1306 );
1307 let mut config = Config::default();
1308 config.rules.insert(
1309 "MD055".to_string(),
1310 crate::config::RuleConfig { severity: None, values },
1311 );
1312
1313 let configured = MD055TablePipeStyle::from_config(&config);
1314 let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1315 assert_eq!(configured.config.style, "no_leading_or_trailing");
1316 assert!(configured.style_explicit);
1317
1318 let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1319 let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1320 assert_eq!(defaulted.config.style, "consistent");
1321 assert!(!defaulted.style_explicit);
1322
1323 let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1326 style: "no_leading_or_trailing".to_string(),
1327 });
1328 assert!(!unreported.style_explicit);
1329 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1330 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1331 assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1332 }
1333}