1use crate::rule::{LintError, LintResult, LintWarning, Rule, 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 (list_prefix, content, _) = TableUtils::extract_list_prefix(after_bq);
186 let fixed_content = self.fix_table_content(content.trim(), target_style);
187
188 if bq_prefix.is_empty() && list_prefix.is_empty() {
190 fixed_content
191 } else {
192 format!("{bq_prefix}{list_prefix}{fixed_content}")
193 }
194 } else {
195 let content_indent = list_ctx.content_indent;
197 let stripped = TableUtils::extract_table_row_content(line, table_block, table_line_index);
198 let fixed_content = self.fix_table_content(stripped.trim(), target_style);
199
200 let indent = " ".repeat(content_indent);
202 format!("{bq_prefix}{indent}{fixed_content}")
203 }
204 } else {
205 let fixed_content = self.fix_table_content(after_bq.trim(), target_style);
207 if bq_prefix.is_empty() {
208 fixed_content
209 } else {
210 format!("{bq_prefix}{fixed_content}")
211 }
212 }
213 }
214
215 fn fix_table_content(&self, trimmed: &str, target_style: &str) -> String {
217 if !trimmed.contains('|') {
218 return trimmed.to_string();
219 }
220
221 let has_leading = trimmed.starts_with('|');
222 let has_trailing = trimmed.ends_with('|');
223
224 match target_style {
225 "leading_and_trailing" => {
226 let mut result = trimmed.to_string();
227
228 if !has_leading {
230 result = format!("| {result}");
231 }
232
233 if !has_trailing {
235 result = format!("{result} |");
236 }
237
238 result
239 }
240 "no_leading_or_trailing" => {
241 let mut result = trimmed;
242
243 if has_leading {
245 result = result.strip_prefix('|').unwrap_or(result);
246 result = result.trim_start();
247 }
248
249 if has_trailing {
251 result = result.strip_suffix('|').unwrap_or(result);
252 result = result.trim_end();
253 }
254
255 result.to_string()
256 }
257 "leading_only" => {
258 let mut result = trimmed.to_string();
259
260 if !has_leading {
262 result = format!("| {result}");
263 }
264
265 if has_trailing {
267 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
268 }
269
270 result
271 }
272 "trailing_only" => {
273 let mut result = trimmed;
274
275 if has_leading {
277 result = result.strip_prefix('|').unwrap_or(result).trim_start();
278 }
279
280 let mut result = result.to_string();
281
282 if !has_trailing {
284 result = format!("{result} |");
285 }
286
287 result
288 }
289 _ => trimmed.to_string(),
290 }
291 }
292}
293
294impl Rule for MD055TablePipeStyle {
295 fn name(&self) -> &'static str {
296 "MD055"
297 }
298
299 fn description(&self) -> &'static str {
300 "Table pipe style should be consistent"
301 }
302
303 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
304 !ctx.likely_has_tables()
306 }
307
308 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
309 let content = ctx.content;
310 let line_index = &ctx.line_index;
311 let mut warnings = Vec::new();
312
313 let lines: Vec<&str> = content.lines().collect();
316
317 let configured_style = match self.config.style.as_str() {
319 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
320 self.config.style.as_str()
321 }
322 _ => {
323 "leading_and_trailing"
325 }
326 };
327
328 let table_blocks = &ctx.table_blocks;
330
331 for table_block in table_blocks {
333 let table_style = if configured_style == "consistent" {
336 self.determine_table_style(table_block, &lines)
337 } else {
338 None
339 };
340
341 let target_style = if configured_style == "consistent" {
343 table_style.unwrap_or("leading_and_trailing")
344 } else {
345 configured_style
346 };
347
348 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
350 .chain(std::iter::once(table_block.delimiter_line))
351 .chain(table_block.content_lines.iter().copied())
352 .collect();
353
354 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());
361 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
362 let line = lines[line_idx];
363 let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
364 if line_idx < lines.len() - 1 {
365 fixed_table_lines.push(format!("{fixed_line}\n"));
366 } else {
367 fixed_table_lines.push(fixed_line);
368 }
369 }
370 let table_replacement = fixed_table_lines.concat();
371 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
372
373 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
375 let line = lines[line_idx];
376 let content = TableUtils::extract_table_row_content(line, table_block, table_line_idx);
378 if let Some(current_style) = TableUtils::determine_pipe_style(content) {
379 let needs_fixing = current_style != target_style;
381
382 if needs_fixing {
383 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
384
385 let message = format!(
386 "Table pipe style should be {}",
387 match target_style {
388 "leading_and_trailing" => "leading and trailing",
389 "no_leading_or_trailing" => "no leading or trailing",
390 "leading_only" => "leading only",
391 "trailing_only" => "trailing only",
392 _ => target_style,
393 }
394 );
395
396 warnings.push(LintWarning {
399 rule_name: Some(self.name().to_string()),
400 severity: Severity::Warning,
401 message,
402 line: start_line,
403 column: start_col,
404 end_line,
405 end_column: end_col,
406 fix: Some(crate::rule::Fix {
407 range: table_range.clone(),
408 replacement: table_replacement.clone(),
409 }),
410 });
411 }
412 }
413 }
414 }
415
416 Ok(warnings)
417 }
418
419 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
420 let content = ctx.content;
421 let lines: Vec<&str> = content.lines().collect();
422
423 let configured_style = match self.config.style.as_str() {
425 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
426 self.config.style.as_str()
427 }
428 _ => {
429 "leading_and_trailing"
431 }
432 };
433
434 let table_blocks = &ctx.table_blocks;
436
437 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
439
440 for table_block in table_blocks {
442 let table_style = if configured_style == "consistent" {
445 self.determine_table_style(table_block, &lines)
446 } else {
447 None
448 };
449
450 let target_style = if configured_style == "consistent" {
452 table_style.unwrap_or("leading_and_trailing")
453 } else {
454 configured_style
455 };
456
457 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
459 .chain(std::iter::once(table_block.delimiter_line))
460 .chain(table_block.content_lines.iter().copied())
461 .collect();
462
463 for (table_line_idx, &line_idx) in all_line_indices.iter().enumerate() {
464 let line = lines[line_idx];
465 let fixed_line = self.fix_table_row_with_context(line, target_style, table_block, table_line_idx);
466 result_lines[line_idx] = fixed_line;
467 }
468 }
469
470 let mut fixed = result_lines.join("\n");
471 if content.ends_with('\n') && !fixed.ends_with('\n') {
473 fixed.push('\n');
474 }
475 Ok(fixed)
476 }
477
478 fn as_any(&self) -> &dyn std::any::Any {
479 self
480 }
481
482 fn default_config_section(&self) -> Option<(String, toml::Value)> {
483 let json_value = serde_json::to_value(&self.config).ok()?;
484 Some((
485 self.name().to_string(),
486 crate::rule_config_serde::json_to_toml_value(&json_value)?,
487 ))
488 }
489
490 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
491 where
492 Self: Sized,
493 {
494 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
495 Box::new(Self::from_config_struct(rule_config))
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_md055_delimiter_row_handling() {
505 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
507
508 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
509 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.fix(&ctx).unwrap();
511
512 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
515
516 assert_eq!(result, expected);
517
518 let warnings = rule.check(&ctx).unwrap();
520 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
522 assert_eq!(
523 delimiter_warning.message,
524 "Table pipe style should be no leading or trailing"
525 );
526
527 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
529
530 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.fix(&ctx).unwrap();
533
534 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
537
538 assert_eq!(result, expected);
539 }
540
541 #[test]
542 fn test_md055_check_finds_delimiter_row_issues() {
543 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
545
546 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
547 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let warnings = rule.check(&ctx).unwrap();
549
550 assert_eq!(warnings.len(), 3);
552
553 let delimiter_warning = &warnings[1];
555 assert_eq!(delimiter_warning.line, 2);
556 assert_eq!(
557 delimiter_warning.message,
558 "Table pipe style should be no leading or trailing"
559 );
560 }
561
562 #[test]
563 fn test_md055_real_world_example() {
564 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
566
567 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.";
568 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.fix(&ctx).unwrap();
570
571 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.";
574
575 assert_eq!(result, expected);
576
577 let warnings = rule.check(&ctx).unwrap();
579 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); }
587
588 #[test]
589 fn test_md055_invalid_style() {
590 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 |";
594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.fix(&ctx).unwrap();
596
597 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
600
601 assert_eq!(result, expected);
602
603 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
605 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.fix(&ctx2).unwrap();
607
608 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
611 assert_eq!(result, expected);
612
613 let warnings = rule.check(&ctx2).unwrap();
615
616 assert_eq!(warnings.len(), 3);
619 }
620
621 #[test]
622 fn test_underflow_protection() {
623 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
625
626 let result = rule.fix_table_row("", "leading_and_trailing");
628 assert_eq!(result, "");
629
630 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
632 assert_eq!(result, "no pipes here");
633
634 let result = rule.fix_table_row("|", "leading_and_trailing");
636 assert!(!result.is_empty());
638 }
639
640 #[test]
643 fn test_fix_table_row_in_blockquote() {
644 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
645
646 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
648 assert_eq!(result, "> | H1 | H2 |");
649
650 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
652 assert_eq!(result, "> | H1 | H2 |");
653
654 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
656 assert_eq!(result, "> H1 | H2");
657 }
658
659 #[test]
660 fn test_fix_table_row_in_nested_blockquote() {
661 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
662
663 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
665 assert_eq!(result, ">> | H1 | H2 |");
666
667 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
669 assert_eq!(result, ">>> | H1 | H2 |");
670 }
671
672 #[test]
673 fn test_blockquote_table_full_document() {
674 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
675
676 let content = "> H1 | H2\n> ----|----\n> a | b";
678 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.fix(&ctx).unwrap();
680
681 assert!(
684 result.starts_with("> |"),
685 "Header should start with blockquote + pipe. Got:\n{result}"
686 );
687 assert!(
689 result.contains("> | ----"),
690 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
691 );
692 }
693
694 #[test]
695 fn test_blockquote_table_no_leading_trailing() {
696 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
697
698 let content = "> | H1 | H2 |\n> |----|----|---|\n> | a | b |";
700 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.fix(&ctx).unwrap();
702
703 let lines: Vec<&str> = result.lines().collect();
705 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
706 assert!(
707 !lines[0].starts_with("> |"),
708 "Leading pipe should be removed. Got: {}",
709 lines[0]
710 );
711 }
712
713 #[test]
714 fn test_mixed_regular_and_blockquote_tables() {
715 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
716
717 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.fix(&ctx).unwrap();
721
722 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
724 assert!(
725 result.contains("> | H3 | H4 |"),
726 "Blockquote table should have pipes added with prefix preserved"
727 );
728 }
729}