1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::{TableBlock, TableUtils};
4
5mod md055_config;
6use md055_config::MD055Config;
7
8#[derive(Debug, Default, Clone)]
81pub struct MD055TablePipeStyle {
82 config: MD055Config,
83}
84
85impl MD055TablePipeStyle {
86 pub fn new(style: String) -> Self {
87 Self {
88 config: MD055Config { style },
89 }
90 }
91
92 pub fn from_config_struct(config: MD055Config) -> Self {
93 Self { config }
94 }
95
96 fn determine_table_style(&self, table_block: &TableBlock, lines: &[&str]) -> Option<&'static str> {
98 let mut leading_and_trailing_count = 0;
99 let mut no_leading_or_trailing_count = 0;
100 let mut leading_only_count = 0;
101 let mut trailing_only_count = 0;
102
103 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
105 if let Some(style) = TableUtils::determine_pipe_style(header_content) {
106 match style {
107 "leading_and_trailing" => leading_and_trailing_count += 1,
108 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
109 "leading_only" => leading_only_count += 1,
110 "trailing_only" => trailing_only_count += 1,
111 _ => {}
112 }
113 }
114
115 for (i, &line_idx) in table_block.content_lines.iter().enumerate() {
117 let content = TableUtils::extract_table_row_content(lines[line_idx], table_block, 2 + i);
118 if let Some(style) = TableUtils::determine_pipe_style(content) {
119 match style {
120 "leading_and_trailing" => leading_and_trailing_count += 1,
121 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
122 "leading_only" => leading_only_count += 1,
123 "trailing_only" => trailing_only_count += 1,
124 _ => {}
125 }
126 }
127 }
128
129 let max_count = leading_and_trailing_count
132 .max(no_leading_or_trailing_count)
133 .max(leading_only_count)
134 .max(trailing_only_count);
135
136 if max_count > 0 {
137 if leading_and_trailing_count == max_count {
138 Some("leading_and_trailing")
139 } else if no_leading_or_trailing_count == max_count {
140 Some("no_leading_or_trailing")
141 } else if leading_only_count == max_count {
142 Some("leading_only")
143 } else if trailing_only_count == max_count {
144 Some("trailing_only")
145 } else {
146 None
147 }
148 } else {
149 None
150 }
151 }
152
153 #[cfg(test)]
155 fn fix_table_row(&self, line: &str, target_style: &str) -> String {
156 let dummy_block = TableBlock {
157 start_line: 0,
158 end_line: 0,
159 header_line: 0,
160 delimiter_line: 0,
161 content_lines: vec![],
162 list_context: None,
163 };
164 self.fix_table_row_with_context(line, target_style, &dummy_block, 0)
165 }
166
167 fn fix_table_row_with_context(
172 &self,
173 line: &str,
174 target_style: &str,
175 table_block: &TableBlock,
176 table_line_index: usize,
177 ) -> String {
178 let (bq_prefix, after_bq) = TableUtils::extract_blockquote_prefix(line);
180
181 if let Some(ref list_ctx) = table_block.list_context {
183 if table_line_index == 0 {
184 let stripped = after_bq
186 .strip_prefix(&list_ctx.list_prefix)
187 .unwrap_or_else(|| TableUtils::extract_list_prefix(after_bq).1);
188 let fixed_content = self.fix_table_content(stripped.trim(), target_style);
189
190 let lp = &list_ctx.list_prefix;
192 if bq_prefix.is_empty() && lp.is_empty() {
193 fixed_content
194 } else {
195 format!("{bq_prefix}{lp}{fixed_content}")
196 }
197 } else {
198 let content_indent = list_ctx.content_indent;
200 let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
201 let fixed_content = self.fix_table_content(stripped.trim(), target_style);
202
203 let indent = " ".repeat(content_indent);
205 format!("{bq_prefix}{indent}{fixed_content}")
206 }
207 } else {
208 let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
210 if bq_prefix.is_empty() {
211 fixed_content
212 } else {
213 format!("{bq_prefix}{fixed_content}")
214 }
215 }
216 }
217
218 fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
220 if !trimmed.contains('|') {
221 return trimmed.to_string();
222 }
223
224 let has_leading = trimmed.starts_with('|');
225 let has_trailing = trimmed.ends_with('|');
226
227 match target_style {
228 "leading_and_trailing" => {
229 let mut result = trimmed.to_string();
230
231 if !has_leading {
233 result = format!("| {result}");
234 }
235
236 if !has_trailing {
238 result = format!("{result} |");
239 }
240
241 result
242 }
243 "no_leading_or_trailing" => {
244 let mut result = trimmed;
245
246 if has_leading {
248 result = result.strip_prefix('|').unwrap_or(result);
249 result = result.trim_start();
250 }
251
252 if has_trailing {
254 result = result.strip_suffix('|').unwrap_or(result);
255 result = result.trim_end();
256 }
257
258 result.to_string()
259 }
260 "leading_only" => {
261 let mut result = trimmed.to_string();
262
263 if !has_leading {
265 result = format!("| {result}");
266 }
267
268 if has_trailing {
270 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
271 }
272
273 result
274 }
275 "trailing_only" => {
276 let mut result = trimmed;
277
278 if has_leading {
280 result = result.strip_prefix('|').unwrap_or(result).trim_start();
281 }
282
283 let mut result = result.to_string();
284
285 if !has_trailing {
287 result = format!("{result} |");
288 }
289
290 result
291 }
292 _ => trimmed.to_string(),
293 }
294 }
295}
296
297impl Rule for MD055TablePipeStyle {
298 fn name(&self) -> &'static str {
299 "MD055"
300 }
301
302 fn description(&self) -> &'static str {
303 "Table pipe style should be consistent"
304 }
305
306 fn category(&self) -> RuleCategory {
307 RuleCategory::Table
308 }
309
310 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
311 !ctx.likely_has_tables()
313 }
314
315 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
316 let line_index = &ctx.line_index;
317 let mut warnings = Vec::new();
318
319 let lines = ctx.raw_lines();
322
323 let configured_style = match self.config.style.as_str() {
325 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
326 self.config.style.as_str()
327 }
328 _ => {
329 "leading_and_trailing"
331 }
332 };
333
334 let table_blocks = &ctx.table_blocks;
336
337 for table_block in table_blocks {
339 let table_style = if configured_style == "consistent" {
342 self.determine_table_style(table_block, lines)
343 } else {
344 None
345 };
346
347 let target_style = if configured_style == "consistent" {
349 table_style.unwrap_or("leading_and_trailing")
350 } else {
351 configured_style
352 };
353
354 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
356 .chain(std::iter::once(table_block.delimiter_line))
357 .chain(table_block.content_lines.iter().copied())
358 .collect();
359
360 let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1; let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
367 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
368 let line = lines[line_idx];
369 let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
370 if line_idx < lines.len() - 1 {
371 fixed_table_lines.push(format!("{fixed_line}\n"));
372 } else {
373 fixed_table_lines.push(fixed_line);
374 }
375 }
376 let table_replacement = fixed_table_lines.concat();
377 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
378
379 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
381 let line = lines[line_idx];
382 let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
384 if let Some(current_style) = TableUtils::determine_pipe_style(content) {
385 let needs_fixing = current_style != target_style;
387
388 if needs_fixing {
389 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
390
391 let message = format!(
392 "Table pipe style should be {}",
393 match target_style {
394 "leading_and_trailing" => "leading and trailing",
395 "no_leading_or_trailing" => "no leading or trailing",
396 "leading_only" => "leading only",
397 "trailing_only" => "trailing only",
398 _ => target_style,
399 }
400 );
401
402 warnings.push(LintWarning {
405 rule_name: Some(self.name().to_string()),
406 severity: Severity::Warning,
407 message,
408 line: start_line,
409 column: start_col,
410 end_line,
411 end_column: end_col,
412 fix: Some(crate::rule::Fix {
413 range: table_range.clone(),
414 replacement: table_replacement.clone(),
415 }),
416 });
417 }
418 }
419 }
420 }
421
422 Ok(warnings)
423 }
424
425 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
426 let lines = ctx.raw_lines();
427
428 let configured_style = match self.config.style.as_str() {
430 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
431 self.config.style.as_str()
432 }
433 _ => {
434 "leading_and_trailing"
436 }
437 };
438
439 let table_blocks = &ctx.table_blocks;
441
442 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
444
445 for table_block in table_blocks {
447 let table_style = if configured_style == "consistent" {
450 self.determine_table_style(table_block, lines)
451 } else {
452 None
453 };
454
455 let target_style = if configured_style == "consistent" {
457 table_style.unwrap_or("leading_and_trailing")
458 } else {
459 configured_style
460 };
461
462 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
464 .chain(std::iter::once(table_block.delimiter_line))
465 .chain(table_block.content_lines.iter().copied())
466 .collect();
467
468 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
469 let line_num = line_idx + 1;
470 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
471 continue;
472 }
473 let line = lines[line_idx];
474 let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
475 result_lines[line_idx] = fixed_line;
476 }
477 }
478
479 let mut fixed = result_lines.join("\n");
480 if ctx.content.ends_with('\n') && !fixed.ends_with('\n') {
482 fixed.push('\n');
483 }
484 Ok(fixed)
485 }
486
487 fn as_any(&self) -> &dyn std::any::Any {
488 self
489 }
490
491 fn default_config_section(&self) -> Option<(String, toml::Value)> {
492 let json_value = serde_json::to_value(&self.config).ok()?;
493 Some((
494 self.name().to_string(),
495 crate::rule_config_serde::json_to_toml_value(&json_value)?,
496 ))
497 }
498
499 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
500 where
501 Self: Sized,
502 {
503 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
504 Box::new(Self::from_config_struct(rule_config))
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_md055_delimiter_row_handling() {
514 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
516
517 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
518 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.fix(&ctx).unwrap();
520
521 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
524
525 assert_eq!(result, expected);
526
527 let warnings = rule.check(&ctx).unwrap();
529 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
531 assert_eq!(
532 delimiter_warning.message,
533 "Table pipe style should be no leading or trailing"
534 );
535
536 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
538
539 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
540 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.fix(&ctx).unwrap();
542
543 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
546
547 assert_eq!(result, expected);
548 }
549
550 #[test]
551 fn test_md055_check_finds_delimiter_row_issues() {
552 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
554
555 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
556 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557 let warnings = rule.check(&ctx).unwrap();
558
559 assert_eq!(warnings.len(), 3);
561
562 let delimiter_warning = &warnings[1];
564 assert_eq!(delimiter_warning.line, 2);
565 assert_eq!(
566 delimiter_warning.message,
567 "Table pipe style should be no leading or trailing"
568 );
569 }
570
571 #[test]
572 fn test_md055_real_world_example() {
573 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
575
576 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.";
577 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.fix(&ctx).unwrap();
579
580 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.";
583
584 assert_eq!(result, expected);
585
586 let warnings = rule.check(&ctx).unwrap();
588 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); }
596
597 #[test]
598 fn test_md055_invalid_style() {
599 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 |";
603 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.fix(&ctx).unwrap();
605
606 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
609
610 assert_eq!(result, expected);
611
612 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
614 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.fix(&ctx2).unwrap();
616
617 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
620 assert_eq!(result, expected);
621
622 let warnings = rule.check(&ctx2).unwrap();
624
625 assert_eq!(warnings.len(), 3);
628 }
629
630 #[test]
631 fn test_underflow_protection() {
632 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
634
635 let result = rule.fix_table_row("", "leading_and_trailing");
637 assert_eq!(result, "");
638
639 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
641 assert_eq!(result, "no pipes here");
642
643 let result = rule.fix_table_row("|", "leading_and_trailing");
645 assert!(!result.is_empty());
647 }
648
649 #[test]
652 fn test_fix_table_row_in_blockquote() {
653 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
654
655 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
657 assert_eq!(result, "> | H1 | H2 |");
658
659 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
661 assert_eq!(result, "> | H1 | H2 |");
662
663 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
665 assert_eq!(result, "> H1 | H2");
666 }
667
668 #[test]
669 fn test_fix_table_row_in_nested_blockquote() {
670 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
671
672 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
674 assert_eq!(result, ">> | H1 | H2 |");
675
676 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
678 assert_eq!(result, ">>> | H1 | H2 |");
679 }
680
681 #[test]
682 fn test_blockquote_table_full_document() {
683 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
684
685 let content = "> H1 | H2\n> ----|----\n> a | b";
687 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.fix(&ctx).unwrap();
689
690 assert!(
693 result.starts_with("> |"),
694 "Header should start with blockquote + pipe. Got:\n{result}"
695 );
696 assert!(
698 result.contains("> | ----"),
699 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
700 );
701 }
702
703 #[test]
704 fn test_blockquote_table_no_leading_trailing() {
705 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
706
707 let content = "> | H1 | H2 |\n> |----|----|---|\n> | a | b |";
709 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.fix(&ctx).unwrap();
711
712 let lines: Vec<&str> = result.lines().collect();
714 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
715 assert!(
716 !lines[0].starts_with("> |"),
717 "Leading pipe should be removed. Got: {}",
718 lines[0]
719 );
720 }
721
722 #[test]
723 fn test_mixed_regular_and_blockquote_tables() {
724 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
725
726 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.fix(&ctx).unwrap();
730
731 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
733 assert!(
734 result.contains("> | H3 | H4 |"),
735 "Blockquote table should have pipes added with prefix preserved"
736 );
737 }
738}