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(
160 &self,
161 table_block: &TableBlock,
162 lines: &[&str],
163 flavor: MarkdownFlavor,
164 ) -> Option<&'static str> {
165 let mut leading_and_trailing_count = 0;
166 let mut no_leading_or_trailing_count = 0;
167 let mut leading_only_count = 0;
168 let mut trailing_only_count = 0;
169
170 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
172 if let Some(style) = TableUtils::determine_pipe_style_with_flavor(header_content, flavor) {
173 match style {
174 "leading_and_trailing" => leading_and_trailing_count += 1,
175 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
176 "leading_only" => leading_only_count += 1,
177 "trailing_only" => trailing_only_count += 1,
178 _ => {}
179 }
180 }
181
182 for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
184 let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
185 if let Some(style) = TableUtils::determine_pipe_style_with_flavor(content, flavor) {
186 match style {
187 "leading_and_trailing" => leading_and_trailing_count += 1,
188 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
189 "leading_only" => leading_only_count += 1,
190 "trailing_only" => trailing_only_count += 1,
191 _ => {}
192 }
193 }
194 }
195
196 let max_count = leading_and_trailing_count
199 .max(no_leading_or_trailing_count)
200 .max(leading_only_count)
201 .max(trailing_only_count);
202
203 if max_count > 0 {
204 if leading_and_trailing_count == max_count {
205 Some("leading_and_trailing")
206 } else if no_leading_or_trailing_count == max_count {
207 Some("no_leading_or_trailing")
208 } else if leading_only_count == max_count {
209 Some("leading_only")
210 } else if trailing_only_count == max_count {
211 Some("trailing_only")
212 } else {
213 None
214 }
215 } else {
216 None
217 }
218 }
219
220 #[cfg(test)]
222 fn fix_table_row(&self, line: &str, target_style: &str) -> String {
223 let dummy_block = TableBlock {
224 start_line: 0,
225 end_line: 0,
226 header_line: 0,
227 delimiter_line: 0,
228 content_lines: vec![],
229 list_context: None,
230 parser_confirmed: true,
231 repairable: false,
232 header_prefix_len: None,
233 };
234 self.fix_table_row_with_context(line, line, target_style, &dummy_block, 0, MarkdownFlavor::Standard)
235 .unwrap_or_else(|| line.to_string())
236 }
237
238 fn fix_table_row_with_context(
243 &self,
244 line: &str,
245 header: &str,
246 target_style: &str,
247 table_block: &TableBlock,
248 table_line_index: usize,
249 flavor: MarkdownFlavor,
250 ) -> Option<String> {
251 let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
253
254 if let Some(ref list_ctx) = table_block.list_context {
256 let swallowed = (bq_prefix.len() - bq_prefix.trim_end_matches(' ').len()).saturating_sub(1);
259 if table_line_index == 0 {
260 let stripped = after_bq
262 .strip_prefix(&list_ctx.list_prefix)
263 .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
264 let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
265
266 let lp = &list_ctx.list_prefix;
268 let lp = if lp.trim_matches(' ').is_empty() {
269 &lp[swallowed.min(lp.len())..]
270 } else {
271 lp
272 };
273 Some(if bq_prefix.is_empty() && lp.is_empty() {
274 fixed_content
275 } else {
276 format!("{bq_prefix}{lp}{fixed_content}")
277 })
278 } else {
279 let content_indent = list_ctx.content_indent;
281 let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
282 let fixed_content = self.fix_table_content(trim_row(stripped), target_style, flavor)?;
283
284 let indent = " ".repeat(content_indent.saturating_sub(swallowed));
286 Some(format!("{bq_prefix}{indent}{fixed_content}"))
287 }
288 } else {
289 let prefix = TableUtils::row_prefix(line, header, table_block, table_line_index);
291 let after_bq = &line[prefix.len()..];
292 let fixed_content = self.fix_table_content(trim_row(after_bq), target_style, flavor)?;
293 let indent = if flavor == MarkdownFlavor::MDG {
297 &after_bq[..after_bq.len() - after_bq.trim_start_matches([' ', '\t']).len()]
298 } else {
299 ""
300 };
301 Some(if prefix.is_empty() && indent.is_empty() {
302 fixed_content
303 } else {
304 format!("{prefix}{indent}{fixed_content}")
305 })
306 }
307 }
308
309 fn fix_table_content(&self, trimmed: &str, target_style: &str, flavor: MarkdownFlavor) -> Option<String> {
317 if !trimmed.contains('|') {
318 return Some(trimmed.to_string());
319 }
320
321 let has_leading = trimmed.starts_with('|');
322 let has_trailing = TableUtils::has_unescaped_trailing_pipe(trimmed, flavor);
323
324 let (drops_leading, drops_trailing) = match target_style {
325 "no_leading_or_trailing" => (has_leading, has_trailing),
326 "leading_only" => (false, has_trailing),
327 "trailing_only" => (has_leading, false),
328 _ => (false, false),
329 };
330
331 let fixed = match target_style {
332 "leading_and_trailing" => {
333 let mut result = trimmed.to_string();
334
335 if !has_leading {
337 result = format!("| {result}");
338 }
339
340 if !has_trailing {
342 result = format!("{result} |");
343 }
344
345 result
346 }
347 "no_leading_or_trailing" => {
348 let mut result = trimmed;
349
350 if has_leading {
352 result = result.strip_prefix('|').unwrap_or(result);
353 result = result.trim_start_matches([' ', '\t']);
354 }
355
356 if has_trailing {
358 result = result.strip_suffix('|').unwrap_or(result);
359 result = result.trim_end_matches([' ', '\t']);
360 }
361
362 result.to_string()
363 }
364 "leading_only" => {
365 let mut result = trimmed.to_string();
366
367 if !has_leading {
369 result = format!("| {result}");
370 }
371
372 if has_trailing {
374 result = result
375 .strip_suffix('|')
376 .unwrap_or(&result)
377 .trim_end_matches([' ', '\t'])
378 .to_string();
379 }
380
381 result
382 }
383 "trailing_only" => {
384 let mut result = trimmed;
385
386 if has_leading {
388 result = result
389 .strip_prefix('|')
390 .unwrap_or(result)
391 .trim_start_matches([' ', '\t']);
392 }
393
394 let mut result = result.to_string();
395
396 if !has_trailing {
398 result = format!("{result} |");
399 }
400
401 result
402 }
403 _ => return Some(trimmed.to_string()),
404 };
405
406 if drops_leading || drops_trailing {
407 let cells = TableUtils::split_table_row_with_flavor(trimmed, flavor);
408 if drops_leading && cells.len() == 1 {
410 return None;
411 }
412 if drops_trailing && (trimmed[..trimmed.len() - 1].ends_with('\\') || fixed.ends_with('\\')) {
417 return None;
418 }
419 if !same_cells(&cells, &TableUtils::split_table_row_with_flavor(&fixed, flavor)) {
422 return None;
423 }
424 if drops_leading && opens_a_block(&fixed) {
425 return None;
426 }
427 }
428
429 Some(fixed)
430 }
431}
432
433fn trim_row(row: &str) -> &str {
436 row.trim_matches([' ', '\t', '\r'])
437}
438
439fn same_cells(a: &[String], b: &[String]) -> bool {
442 a.len() == b.len()
443 && a.iter()
444 .zip(b)
445 .all(|(x, y)| x.trim_matches([' ', '\t']) == y.trim_matches([' ', '\t']))
446}
447
448fn opens_a_block(text: &str) -> bool {
452 let rest = text.trim_start_matches(' ');
453 let Some(first) = rest.chars().next() else {
454 return false;
455 };
456 let after = |n: usize| rest.get(n..).unwrap_or("");
457 let blank_or_end = |tail: &str| tail.is_empty() || tail.starts_with([' ', '\t']);
458 match first {
459 '>' | '<' => true,
460 '#' => blank_or_end(rest.trim_start_matches('#')),
461 '`' | '~' => after(0).chars().take_while(|&c| c == first).count() >= 3,
462 '-' | '+' | '*' => blank_or_end(after(1)),
463 ':' => blank_or_end(after(1)),
464 '$' => rest.starts_with("$$"),
465 '[' => rest.contains("]:"),
466 '0'..='9' => {
467 let digits = rest.trim_start_matches(|c: char| c.is_ascii_digit());
468 digits.len() < rest.len() && digits.starts_with(['.', ')']) && blank_or_end(digits.get(1..).unwrap_or(""))
469 }
470 _ => false,
471 }
472}
473
474impl Rule for MD055TablePipeStyle {
475 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
476 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(55))
477 }
478
479 fn name(&self) -> &'static str {
480 "MD055"
481 }
482
483 fn description(&self) -> &'static str {
484 "Table pipe style should be consistent"
485 }
486
487 fn category(&self) -> RuleCategory {
488 RuleCategory::Table
489 }
490
491 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
492 !ctx.likely_has_tables()
494 }
495
496 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
497 let mut warnings = Vec::new();
498
499 let lines = ctx.raw_lines();
502
503 let configured_style = self.effective_configured_style(ctx);
504
505 let table_blocks = &ctx.table_blocks;
507
508 for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
510 let table_style = if configured_style == "consistent" {
513 self.determine_table_style(table_block, lines, ctx.flavor)
514 } else {
515 None
516 };
517
518 let target_style = if configured_style == "consistent" {
520 table_style.unwrap_or("leading_and_trailing")
521 } else {
522 configured_style
523 };
524
525 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
527 .chain(std::iter::once(table_block.delimiter_line))
528 .chain(table_block.content_lines.iter().copied())
529 .collect();
530
531 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
535 let line = lines[line_idx];
536 let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
538 if let Some(current_style) = TableUtils::determine_pipe_style_with_flavor(content, ctx.flavor) {
539 let needs_fixing = current_style != target_style;
541
542 if needs_fixing {
543 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
544
545 let Some(fixed_line) = self.fix_table_row_with_context(
549 line,
550 lines[table_block.header_line],
551 target_style,
552 table_block,
553 table_line_idx,
554 ctx.flavor,
555 ) else {
556 continue;
557 };
558
559 let message = format!(
560 "Table pipe style should be {}",
561 match target_style {
562 "leading_and_trailing" => "leading and trailing",
563 "no_leading_or_trailing" => "no leading or trailing",
564 "leading_only" => "leading only",
565 "trailing_only" => "trailing only",
566 _ => target_style,
567 }
568 );
569
570 let row_range = ctx.line_column_byte_range_with_length(line_idx + 1, 1, line.chars().count());
571
572 warnings.push(LintWarning {
573 rule_name: Some(self.name().to_string()),
574 severity: Severity::Warning,
575 message,
576 line: start_line,
577 column: start_col,
578 end_line,
579 end_column: end_col,
580 fix: Some(crate::rule::Fix::new(row_range, fixed_line)),
581 });
582 }
583 }
584 }
585 }
586
587 Ok(warnings)
588 }
589
590 fn fixes_from_diagnostics(&self) -> bool {
591 true
592 }
593
594 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
595 if self.should_skip(ctx) {
596 return Ok(ctx.content.to_string());
597 }
598 let warnings = self.check(ctx)?;
599 if warnings.is_empty() {
600 return Ok(ctx.content.to_string());
601 }
602 let warnings =
603 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
604 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
605 }
606
607 fn as_any(&self) -> &dyn std::any::Any {
608 self
609 }
610
611 crate::impl_rule_config_sections!(MD055Config);
612
613 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
614 where
615 Self: Sized,
616 {
617 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
618 let style_explicit = option_is_explicit(config, "MD055", "style");
619
620 Box::new(Self {
621 config: rule_config,
622 style_explicit,
623 })
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn test_pipe_styles_preserve_escaped_trailing_cell_content() {
633 let source = "名 | B\n--- | ---\nleft | right \\|\n";
634 for (style, expected) in [
635 ("consistent", source),
636 ("no_leading_or_trailing", source),
637 ("leading_only", "| 名 | B\n| --- | ---\n| left | right \\|\n"),
638 ("trailing_only", "名 | B |\n--- | --- |\nleft | right \\| |\n"),
639 (
640 "leading_and_trailing",
641 "| 名 | B |\n| --- | --- |\n| left | right \\| |\n",
642 ),
643 ] {
644 let rule = MD055TablePipeStyle::new(style.to_string());
645 let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
646 assert_eq!(
647 rule.fix(&ctx).unwrap(),
648 expected,
649 "cell content must remain intact: {style}"
650 );
651 let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::Standard, None);
652 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
653 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
654 let mut original_html = String::new();
655 let mut fixed_html = String::new();
656 let options = pulldown_cmark::Options::ENABLE_TABLES;
657 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new_ext(source, options));
658 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new_ext(expected, options));
659 assert_eq!(original_html, fixed_html, "rendered table cells must be preserved");
660 }
661 }
662
663 #[test]
664 fn test_mdg_adds_a_delimiter_after_an_escaped_cell_pipe() {
665 let rule = MD055TablePipeStyle::default();
666 let source = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\|\n";
667 let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::MDG, None);
668 let warnings = rule.check(&ctx).unwrap();
669 assert_eq!(warnings.len(), 1);
670 assert_eq!(warnings[0].line, 6);
671 let expected = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n | left | right \\| |\n";
672 assert_eq!(rule.fix(&ctx).unwrap(), expected);
673 let fixed_ctx = crate::lint_context::LintContext::new(expected, MarkdownFlavor::MDG, None);
674 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
675 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
676 }
677
678 fn rule_from_toml_style(style: &str) -> MD055TablePipeStyle {
684 let config: md055_config::MD055Config =
685 toml::from_str(&format!("style = \"{style}\"")).expect("valid style value");
686 MD055TablePipeStyle::from_config_struct(config)
687 }
688
689 #[test]
690 fn test_no_leading_or_trailing_kebab_accepts_conforming_table() {
691 let rule = rule_from_toml_style("no-leading-or-trailing");
692 let content = "A | B\n--- | ---\n1 | 2";
693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let warnings = rule.check(&ctx).unwrap();
695 assert!(
696 warnings.is_empty(),
697 "no-leading-or-trailing should accept a table with no pipes: {warnings:?}"
698 );
699 }
700
701 #[test]
702 fn test_no_leading_or_trailing_kebab_rejects_nonconforming_table() {
703 let rule = rule_from_toml_style("no-leading-or-trailing");
704 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
705 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let warnings = rule.check(&ctx).unwrap();
707 assert_eq!(
708 warnings.len(),
709 3,
710 "no-leading-or-trailing should flag all 3 rows with pipes"
711 );
712 assert!(warnings.iter().all(|w| w.message.contains("no leading or trailing")));
713 }
714
715 #[test]
716 fn test_leading_only_kebab_accepts_conforming_table() {
717 let rule = rule_from_toml_style("leading-only");
718 let content = "| A | B\n|---|---\n| 1 | 2";
719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let warnings = rule.check(&ctx).unwrap();
721 assert!(
722 warnings.is_empty(),
723 "leading-only should accept a leading-only table: {warnings:?}"
724 );
725 }
726
727 #[test]
728 fn test_trailing_only_kebab_accepts_conforming_table() {
729 let rule = rule_from_toml_style("trailing-only");
730 let content = "A | B |\n---|---|\n1 | 2 |";
731 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let warnings = rule.check(&ctx).unwrap();
733 assert!(
734 warnings.is_empty(),
735 "trailing-only should accept a trailing-only table: {warnings:?}"
736 );
737 }
738
739 #[test]
740 fn test_trailing_only_kebab_rejects_nonconforming_table() {
741 let rule = rule_from_toml_style("trailing-only");
742 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
744 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let warnings = rule.check(&ctx).unwrap();
746 assert_eq!(
747 warnings.len(),
748 3,
749 "trailing-only should flag all 3 rows that have leading pipes"
750 );
751 assert!(warnings.iter().all(|w| w.message.contains("trailing only")));
752 }
753
754 #[test]
755 fn test_leading_only_kebab_rejects_nonconforming_table() {
756 let rule = rule_from_toml_style("leading-only");
757 let content = "A | B |\n---|---|\n1 | 2 |";
759 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let warnings = rule.check(&ctx).unwrap();
761 assert_eq!(
762 warnings.len(),
763 3,
764 "leading-only should flag all 3 rows that have trailing pipes"
765 );
766 assert!(warnings.iter().all(|w| w.message.contains("leading only")));
767 }
768
769 #[test]
770 fn test_leading_and_trailing_kebab_accepts_conforming_table() {
771 let rule = rule_from_toml_style("leading-and-trailing");
772 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
773 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let warnings = rule.check(&ctx).unwrap();
775 assert!(
776 warnings.is_empty(),
777 "leading-and-trailing should accept a fully-piped table: {warnings:?}"
778 );
779 }
780
781 #[test]
782 fn test_kebab_and_snake_case_styles_are_equivalent() {
783 let pairs = [
786 ("no-leading-or-trailing", "no_leading_or_trailing"),
787 ("leading-only", "leading_only"),
788 ("trailing-only", "trailing_only"),
789 ("leading-and-trailing", "leading_and_trailing"),
790 ];
791 let content = "| A | B |\n|---|---|\n| 1 | 2 |";
793 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794
795 for (kebab, snake) in pairs {
796 let kebab_rule = rule_from_toml_style(kebab);
797 let snake_rule = rule_from_toml_style(snake);
798 let kebab_warnings = kebab_rule.check(&ctx).unwrap();
799 let snake_warnings = snake_rule.check(&ctx).unwrap();
800
801 assert_eq!(
802 kebab_warnings.len(),
803 snake_warnings.len(),
804 "'{kebab}' and '{snake}' must produce the same number of warnings"
805 );
806 for (i, (kw, sw)) in kebab_warnings.iter().zip(snake_warnings.iter()).enumerate() {
807 assert_eq!(
808 kw.message, sw.message,
809 "warning[{i}] message differs between '{kebab}' and '{snake}'"
810 );
811 assert_eq!(
812 kw.line, sw.line,
813 "warning[{i}] line differs between '{kebab}' and '{snake}'"
814 );
815 }
816 }
817 }
818
819 fn assert_fix_roundtrip_from_toml(style: &str, content: &str) {
820 let rule = rule_from_toml_style(style);
821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let fixed = rule.fix(&ctx).unwrap();
823 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
824 let remaining = rule.check(&ctx2).unwrap();
825 assert!(
826 remaining.is_empty(),
827 "style '{style}': after fix(), check() should find 0 violations.\n\
828 Original: {content:?}\n\
829 Fixed: {fixed:?}\n\
830 Remaining: {remaining:?}"
831 );
832 }
833
834 #[test]
835 fn test_roundtrip_kebab_no_leading_or_trailing() {
836 assert_fix_roundtrip_from_toml("no-leading-or-trailing", "| H1 | H2 |\n|---|---|\n| a | b |");
837 }
838
839 #[test]
840 fn test_roundtrip_kebab_leading_and_trailing() {
841 assert_fix_roundtrip_from_toml("leading-and-trailing", "H1 | H2\n---|---\na | b");
842 }
843
844 #[test]
845 fn test_roundtrip_kebab_leading_only() {
846 assert_fix_roundtrip_from_toml("leading-only", "| H1 | H2 |\n|---|---|\n| a | b |");
847 }
848
849 #[test]
850 fn test_roundtrip_kebab_trailing_only() {
851 assert_fix_roundtrip_from_toml("trailing-only", "| H1 | H2 |\n|---|---|\n| a | b |");
852 }
853
854 #[test]
855 fn test_md055_delimiter_row_handling() {
856 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
858
859 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
860 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.fix(&ctx).unwrap();
862
863 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
866
867 assert_eq!(result, expected);
868
869 let warnings = rule.check(&ctx).unwrap();
871 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
873 assert_eq!(
874 delimiter_warning.message,
875 "Table pipe style should be no leading or trailing"
876 );
877
878 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
880
881 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.fix(&ctx).unwrap();
884
885 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
888
889 assert_eq!(result, expected);
890 }
891
892 #[test]
893 fn test_md055_check_finds_delimiter_row_issues() {
894 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
896
897 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let warnings = rule.check(&ctx).unwrap();
900
901 assert_eq!(warnings.len(), 3);
903
904 let delimiter_warning = &warnings[1];
906 assert_eq!(delimiter_warning.line, 2);
907 assert_eq!(
908 delimiter_warning.message,
909 "Table pipe style should be no leading or trailing"
910 );
911 }
912
913 #[test]
914 fn test_md055_real_world_example() {
915 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
917
918 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.";
919 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.fix(&ctx).unwrap();
921
922 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.";
925
926 assert_eq!(result, expected);
927
928 let warnings = rule.check(&ctx).unwrap();
930 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); }
938
939 #[test]
940 fn test_md055_invalid_style() {
941 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 |";
945 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.fix(&ctx).unwrap();
947
948 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
951
952 assert_eq!(result, expected);
953
954 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
956 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let result = rule.fix(&ctx2).unwrap();
958
959 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
962 assert_eq!(result, expected);
963
964 let warnings = rule.check(&ctx2).unwrap();
966
967 assert_eq!(warnings.len(), 3);
970 }
971
972 #[test]
973 fn test_underflow_protection() {
974 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
976
977 let result = rule.fix_table_row("", "leading_and_trailing");
979 assert_eq!(result, "");
980
981 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
983 assert_eq!(result, "no pipes here");
984
985 let result = rule.fix_table_row("|", "leading_and_trailing");
987 assert!(!result.is_empty());
989 }
990
991 #[test]
994 fn test_fix_table_row_in_blockquote() {
995 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
996
997 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
999 assert_eq!(result, "> | H1 | H2 |");
1000
1001 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
1003 assert_eq!(result, "> | H1 | H2 |");
1004
1005 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
1007 assert_eq!(result, "> H1 | H2");
1008 }
1009
1010 #[test]
1011 fn test_fix_table_row_in_nested_blockquote() {
1012 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1013
1014 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
1016 assert_eq!(result, ">> | H1 | H2 |");
1017
1018 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
1020 assert_eq!(result, ">>> | H1 | H2 |");
1021 }
1022
1023 #[test]
1024 fn test_blockquote_table_full_document() {
1025 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1026
1027 let content = "> H1 | H2\n> ----|----\n> a | b";
1029 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.fix(&ctx).unwrap();
1031
1032 assert!(
1035 result.starts_with("> |"),
1036 "Header should start with blockquote + pipe. Got:\n{result}"
1037 );
1038 assert!(
1040 result.contains("> | ----"),
1041 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_blockquote_table_no_leading_trailing() {
1047 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1048
1049 let content = "> | H1 | H2 |\n> |----|----|\n> | a | b |";
1051 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.fix(&ctx).unwrap();
1053
1054 let lines: Vec<&str> = result.lines().collect();
1056 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
1057 assert!(
1058 !lines[0].starts_with("> |"),
1059 "Leading pipe should be removed. Got: {}",
1060 lines[0]
1061 );
1062 }
1063
1064 #[test]
1065 fn test_mixed_regular_and_blockquote_tables() {
1066 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1067
1068 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
1070 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.fix(&ctx).unwrap();
1072
1073 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
1075 assert!(
1076 result.contains("> | H3 | H4 |"),
1077 "Blockquote table should have pipes added with prefix preserved"
1078 );
1079 }
1080
1081 fn assert_fix_roundtrip(rule: &MD055TablePipeStyle, content: &str) {
1084 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let fixed = rule.fix(&ctx).unwrap();
1086 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1087 let remaining = rule.check(&ctx2).unwrap();
1088 assert!(
1089 remaining.is_empty(),
1090 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_roundtrip_leading_and_trailing() {
1096 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1097 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b");
1098 }
1099
1100 #[test]
1101 fn test_roundtrip_no_leading_or_trailing() {
1102 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1103 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1104 }
1105
1106 #[test]
1107 fn test_roundtrip_consistent_mode() {
1108 let rule = MD055TablePipeStyle::default();
1109 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\nCell 1 | Cell 2");
1110 }
1111
1112 #[test]
1113 fn test_roundtrip_blockquote_table() {
1114 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1115 assert_fix_roundtrip(&rule, "> H1 | H2\n> ---|---\n> a | b");
1116 }
1117
1118 #[test]
1119 fn test_roundtrip_mixed_tables() {
1120 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1121 assert_fix_roundtrip(&rule, "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d");
1122 }
1123
1124 #[test]
1125 fn test_roundtrip_with_surrounding_content() {
1126 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
1127 assert_fix_roundtrip(&rule, "# Title\n\n| H1 | H2 |\n|---|---|\n| a | b |\n\nMore text.");
1128 }
1129
1130 #[test]
1131 fn test_roundtrip_clean_content() {
1132 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1133 assert_fix_roundtrip(&rule, "| H1 | H2 |\n|---|---|\n| a | b |");
1134 }
1135
1136 #[test]
1153 fn md055_pandoc_grid_tables_not_flagged() {
1154 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1155 let content = "\
1156+---+---+
1157| a | b |
1158+===+===+
1159| 1 | 2 |
1160+---+---+
1161";
1162 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert!(
1167 result.is_empty(),
1168 "MD055 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
1169 );
1170
1171 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let result_std = rule.check(&ctx_std).unwrap();
1175 assert!(
1176 result_std.is_empty(),
1177 "MD055 should not flag grid-table-like content under Standard either: {result_std:?}"
1178 );
1179 }
1180
1181 #[test]
1182 fn md055_pandoc_multi_line_tables_not_flagged() {
1183 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1184 let content = "\
1186--------- ----------- ------
1187Header 1 Header 2 Header 3
1188--------- ----------- ------
1189Cell 1 Cell 2 Cell 3
1190--------- ----------- ------
1191";
1192 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert!(
1195 result.is_empty(),
1196 "MD055 should not flag Pandoc multi-line tables: {result:?}"
1197 );
1198
1199 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result_std = rule.check(&ctx_std).unwrap();
1201 assert!(
1202 result_std.is_empty(),
1203 "MD055 should not flag multi-line table content under Standard: {result_std:?}"
1204 );
1205 }
1206
1207 #[test]
1208 fn md055_pandoc_line_blocks_not_flagged() {
1209 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1210 let content = "| First line\n| Second line\n";
1213 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1214 let result = rule.check(&ctx).unwrap();
1215 assert!(
1216 result.is_empty(),
1217 "MD055 should not treat Pandoc line blocks as tables: {result:?}"
1218 );
1219
1220 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result_std = rule.check(&ctx_std).unwrap();
1222 assert!(
1223 result_std.is_empty(),
1224 "MD055 should not treat line-block-like content as tables under Standard: {result_std:?}"
1225 );
1226 }
1227
1228 #[test]
1229 fn md055_pandoc_pipe_table_captions_not_flagged() {
1230 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1231 let content = "\
1234| H1 | H2 |
1235|----|-----|
1236| a | b |
1237
1238: My table caption
1239";
1240 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1241 let result = rule.check(&ctx).unwrap();
1242 assert!(
1243 result.is_empty(),
1244 "MD055 should not flag the pipe-table caption line: {result:?}"
1245 );
1246
1247 let ctx_std = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result_std = rule.check(&ctx_std).unwrap();
1250 assert!(
1251 result_std.is_empty(),
1252 "MD055 already-valid table with caption should have no warnings under Standard: {result_std:?}"
1253 );
1254 }
1255
1256 const MDG_INCOMPATIBLE_STYLES: [(&str, [&str; 3]); 3] = [
1265 (
1266 "no_leading_or_trailing",
1267 ["start | eat | left", "----- | --- | ----", "12 | 5 | 7"],
1268 ),
1269 (
1270 "leading_only",
1271 ["| start | eat | left", "| ----- | --- | ----", "| 12 | 5 | 7"],
1272 ),
1273 (
1274 "trailing_only",
1275 ["start | eat | left |", "----- | --- | ---- |", "12 | 5 | 7 |"],
1276 ),
1277 ];
1278
1279 const MDG_GHERKIN_ROWS: [&str; 3] = ["| start | eat | left |", "| ----- | --- | ---- |", "| 12 | 5 | 7 |"];
1280
1281 fn examples_table(indent: usize, rows: [&str; 3]) -> String {
1282 let spaces = " ".repeat(indent);
1283 let [header, delimiter, body] = rows;
1284 format!("# Feature: Eating\n\n#### Examples:\n\n{spaces}{header}\n{spaces}{delimiter}\n{spaces}{body}\n")
1285 }
1286
1287 #[test]
1288 fn test_mdg_enforces_leading_and_trailing_over_incompatible_styles() {
1289 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1292 let rule = MD055TablePipeStyle::new(style.to_string());
1293
1294 for indent in [2, 3, 4, 5] {
1295 let content = examples_table(indent, rows);
1296 let expected = examples_table(indent, MDG_GHERKIN_ROWS);
1297 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1298
1299 assert_eq!(
1300 rule.check(&ctx).unwrap().len(),
1301 3,
1302 "style '{style}' at indent {indent}: every row is in a form MDG cannot accept"
1303 );
1304
1305 let fixed = rule.fix(&ctx).unwrap();
1306 assert_eq!(
1307 fixed, expected,
1308 "style '{style}' at indent {indent}: MDG must enforce the Gherkin form and leave the indent alone"
1309 );
1310
1311 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1312 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1313 assert_eq!(
1314 rule.fix(&fixed_ctx).unwrap(),
1315 fixed,
1316 "style '{style}' at indent {indent}: MDG fix must be idempotent"
1317 );
1318 }
1319 }
1320 }
1321
1322 #[test]
1323 fn test_mdg_leaves_a_table_already_in_the_required_form_alone() {
1324 let content = examples_table(2, MDG_GHERKIN_ROWS);
1327
1328 for style in [
1329 "consistent",
1330 "leading_and_trailing",
1331 "no_leading_or_trailing",
1332 "leading_only",
1333 "trailing_only",
1334 ] {
1335 let rule = MD055TablePipeStyle::new(style.to_string());
1336 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1337
1338 assert!(
1339 rule.check(&ctx).unwrap().is_empty(),
1340 "style '{style}': MDG enforces this form, so it cannot be reported"
1341 );
1342 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1343 }
1344 }
1345
1346 #[test]
1347 fn test_mdg_consistent_ignores_prevalence() {
1348 let defaulted = MD055TablePipeStyle::default();
1352 assert_eq!(defaulted.config.style, "consistent");
1353 let explicit = MD055TablePipeStyle::new("consistent".to_string());
1354
1355 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1356 let content = examples_table(2, rows);
1357 let expected = examples_table(2, MDG_GHERKIN_ROWS);
1358
1359 for rule in [&defaulted, &explicit] {
1360 let standard_ctx =
1361 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1362 assert!(
1363 rule.check(&standard_ctx).unwrap().is_empty(),
1364 "Standard resolves `consistent` to the table's own '{style}'"
1365 );
1366
1367 let mdg_ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1368 assert_eq!(
1369 rule.fix(&mdg_ctx).unwrap(),
1370 expected,
1371 "MDG must resolve `consistent` to the Gherkin form over a '{style}' table"
1372 );
1373 }
1374 }
1375 }
1376
1377 #[test]
1378 fn test_standard_flavor_is_untouched_by_the_mdg_enforcement() {
1379 for (style, rows) in MDG_INCOMPATIBLE_STYLES {
1380 let rule = MD055TablePipeStyle::new(style.to_string());
1381 let content = examples_table(2, rows);
1382 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1383
1384 assert!(
1385 rule.check(&ctx).unwrap().is_empty(),
1386 "style '{style}': Standard must still honour it"
1387 );
1388 assert_eq!(rule.fix(&ctx).unwrap(), content, "style '{style}': nothing to correct");
1389
1390 let mdg_form = examples_table(2, MDG_GHERKIN_ROWS);
1392 let mdg_form_ctx =
1393 crate::lint_context::LintContext::new(&mdg_form, crate::config::MarkdownFlavor::Standard, None);
1394 assert_eq!(
1395 rule.check(&mdg_form_ctx).unwrap().len(),
1396 3,
1397 "style '{style}': Standard must still correct the leading-and-trailing form away"
1398 );
1399 }
1400
1401 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
1404 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1405 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1406 assert_eq!(
1407 rule.fix(&ctx).unwrap(),
1408 examples_table(0, MDG_GHERKIN_ROWS),
1409 "Standard must keep stripping the indent"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_from_config_records_whether_style_was_configured() {
1415 use crate::config::Config;
1419 use std::collections::BTreeMap;
1420
1421 let mut values = BTreeMap::new();
1422 values.insert(
1423 "style".to_string(),
1424 toml::Value::String("no_leading_or_trailing".to_string()),
1425 );
1426 let mut config = Config::default();
1427 config.rules.insert(
1428 "MD055".to_string(),
1429 crate::config::RuleConfig { severity: None, values },
1430 );
1431
1432 let configured = MD055TablePipeStyle::from_config(&config);
1433 let configured = configured.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1434 assert_eq!(configured.config.style, "no_leading_or_trailing");
1435 assert!(configured.style_explicit);
1436
1437 let defaulted = MD055TablePipeStyle::from_config(&Config::default());
1438 let defaulted = defaulted.as_any().downcast_ref::<MD055TablePipeStyle>().unwrap();
1439 assert_eq!(defaulted.config.style, "consistent");
1440 assert!(!defaulted.style_explicit);
1441
1442 let unreported = MD055TablePipeStyle::from_config_struct(MD055Config {
1445 style: "no_leading_or_trailing".to_string(),
1446 });
1447 assert!(!unreported.style_explicit);
1448 let content = examples_table(2, MDG_INCOMPATIBLE_STYLES[0].1);
1449 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
1450 assert_eq!(unreported.fix(&ctx).unwrap(), examples_table(2, MDG_GHERKIN_ROWS));
1451 }
1452 fn fixed_by(style: &str, source: &str) -> (usize, String) {
1453 let rule = MD055TablePipeStyle::new(style.to_string());
1454 let ctx = crate::lint_context::LintContext::new(source, MarkdownFlavor::Standard, None);
1455 (rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
1456 }
1457
1458 #[test]
1462 fn test_one_column_table_is_not_reduced_to_pipe_free_rows() {
1463 for source in [
1464 "| a |\n| - |\n| x |\n| y |\n",
1465 "| a |\n|---|\n| x |\n| y |\n",
1466 "| a |\n| - |\n",
1467 "a |\n- |\nx |\n",
1468 ] {
1469 for style in ["no_leading_or_trailing", "trailing_only"] {
1470 assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}: {source:?}");
1471 }
1472 }
1473 }
1474
1475 #[test]
1476 fn test_one_column_table_keeps_the_leading_pipe_styles() {
1477 let full = "| a |\n| - |\n| x |\n";
1478 let leading = "| a\n| -\n| x\n";
1479 assert_eq!(fixed_by("leading_only", full), (3, leading.to_string()));
1480 assert_eq!(fixed_by("leading_only", leading), (0, leading.to_string()));
1481 for source in [full, leading] {
1483 assert_eq!(fixed_by("consistent", source), (0, source.to_string()));
1484 }
1485 }
1486
1487 #[test]
1489 fn test_empty_edge_cells_keep_their_pipes() {
1490 let source = "| a | b |\n| --- | --- |\n| | b |\n| a | |\n| | |\n";
1491 let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
1492 assert_eq!(fixed, "a | b\n--- | ---\n| | b |\n| a | |\n| | |\n");
1493 assert_eq!(warnings, 2);
1494 let (warnings, fixed) = fixed_by("leading_only", source);
1495 assert_eq!(fixed, "| a | b\n| --- | ---\n| | b\n| a | |\n| | |\n");
1496 assert_eq!(warnings, 3);
1497 let (_, fixed) = fixed_by("trailing_only", source);
1498 assert_eq!(fixed, "a | b |\n--- | --- |\n| | b |\na | |\n| | |\n");
1499 let source = "||\n|-|\n||\n";
1501 for style in ["no_leading_or_trailing", "trailing_only"] {
1502 assert_eq!(fixed_by(style, source), (0, source.to_string()), "{style}");
1503 }
1504 assert_eq!(fixed_by("leading_only", source), (1, "||\n|-\n||\n".to_string()));
1505 }
1506
1507 #[test]
1510 fn test_cells_that_would_open_a_block_keep_the_leading_pipe() {
1511 let source = "| # a | b |\n| --- | --- |\n| x | y |\n";
1512 assert_eq!(
1513 fixed_by("no_leading_or_trailing", source),
1514 (2, "| # a | b |\n--- | ---\nx | y\n".to_string())
1515 );
1516 let source = "| a | b |\n| - | - |\n| x | y |\n";
1517 let (warnings, fixed) = fixed_by("no_leading_or_trailing", source);
1518 assert_eq!((warnings, fixed.as_str()), (2, "a | b\n| - | - |\nx | y\n"));
1519 let source = "| a | b |\n|:---|---:|\n| x | y |\n";
1521 assert_eq!(
1522 fixed_by("no_leading_or_trailing", source),
1523 (3, "a | b\n:---|---:\nx | y\n".to_string())
1524 );
1525 }
1526
1527 #[test]
1529 fn test_other_whitespace_in_a_cell_is_kept() {
1530 let source = "| a | b |\n| --- | --- |\n| \u{2003}x | y\u{2003} |\n";
1531 let (_, fixed) = fixed_by("no_leading_or_trailing", source);
1532 assert_eq!(fixed, "a | b\n--- | ---\n\u{2003}x | y\u{2003}\n");
1533 let source = "| a | b |\n| --- | --- |\n| x | y |\u{2003}\n";
1535 let (warnings, fixed) = fixed_by("leading_only", source);
1536 assert_eq!(warnings, 2);
1537 assert_eq!(fixed, "| a | b\n| --- | ---\n| x | y |\u{2003}\n");
1538 }
1539
1540 #[test]
1543 fn test_escaped_and_backslash_ended_trailing_cells_keep_their_pipe() {
1544 for tail in ["b\\|", "b\\\\|", "b\\ |"] {
1545 let source = format!("| h | j |\n| --- | --- |\n| a | {tail}\n");
1546 let (_, fixed) = fixed_by("leading_only", &source);
1547 assert!(fixed.ends_with(&format!("| a | {tail}\n")), "{source:?} -> {fixed:?}");
1548 }
1549 }
1550
1551 #[test]
1553 fn test_plain_tables_are_still_restyled() {
1554 let source = "| a | b |\n| --- | --- |\n| x | y |\n";
1555 assert_eq!(
1556 fixed_by("no_leading_or_trailing", source),
1557 (3, "a | b\n--- | ---\nx | y\n".to_string())
1558 );
1559 assert_eq!(
1560 fixed_by("trailing_only", source),
1561 (3, "a | b |\n--- | --- |\nx | y |\n".to_string())
1562 );
1563 assert_eq!(
1564 fixed_by("leading_only", "a | b\n--- | ---\nx | y\n"),
1565 (3, "| a | b\n| --- | ---\n| x | y\n".to_string())
1566 );
1567 }
1568 #[test]
1572 fn test_non_ascii_space_after_the_last_pipe_is_not_padding() {
1573 for tail in ["| \u{2003}", "|\u{2003}"] {
1574 let source = format!("| h | j | k |\n| - | - | - |\n| a | b {tail}\n");
1575 let (warnings, fixed) = fixed_by("leading_only", &source);
1576 assert_eq!(warnings, 2, "{source:?}");
1577 assert_eq!(fixed, format!("| h | j | k\n| - | - | -\n| a | b {tail}\n"));
1578 assert_eq!(fixed_by("leading_only", &fixed), (0, fixed.clone()));
1579 }
1580 }
1581}