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 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
105 match style {
106 "leading_and_trailing" => leading_and_trailing_count += 1,
107 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
108 "leading_only" => leading_only_count += 1,
109 "trailing_only" => trailing_only_count += 1,
110 _ => {}
111 }
112 }
113
114 for &line_idx in &table_block.content_lines {
116 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
117 match style {
118 "leading_and_trailing" => leading_and_trailing_count += 1,
119 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
120 "leading_only" => leading_only_count += 1,
121 "trailing_only" => trailing_only_count += 1,
122 _ => {}
123 }
124 }
125 }
126
127 let max_count = leading_and_trailing_count
130 .max(no_leading_or_trailing_count)
131 .max(leading_only_count)
132 .max(trailing_only_count);
133
134 if max_count > 0 {
135 if leading_and_trailing_count == max_count {
136 Some("leading_and_trailing")
137 } else if no_leading_or_trailing_count == max_count {
138 Some("no_leading_or_trailing")
139 } else if leading_only_count == max_count {
140 Some("leading_only")
141 } else if trailing_only_count == max_count {
142 Some("trailing_only")
143 } else {
144 None
145 }
146 } else {
147 None
148 }
149 }
150
151 fn fix_table_row(&self, line: &str, target_style: &str) -> String {
157 let (prefix, content) = TableUtils::extract_blockquote_prefix(line);
159
160 let trimmed = content.trim();
161 if !trimmed.contains('|') {
162 return line.to_string();
163 }
164
165 let has_leading = trimmed.starts_with('|');
166 let has_trailing = trimmed.ends_with('|');
167
168 let fixed_content = match target_style {
169 "leading_and_trailing" => {
170 let mut result = trimmed.to_string();
171
172 if !has_leading {
174 result = format!("| {result}");
175 }
176
177 if !has_trailing {
179 result = format!("{result} |");
180 }
181
182 result
183 }
184 "no_leading_or_trailing" => {
185 let mut result = trimmed;
186
187 if has_leading {
189 result = result.strip_prefix('|').unwrap_or(result);
190 result = result.trim_start();
191 }
192
193 if has_trailing {
195 result = result.strip_suffix('|').unwrap_or(result);
196 result = result.trim_end();
197 }
198
199 result.to_string()
200 }
201 "leading_only" => {
202 let mut result = trimmed.to_string();
203
204 if !has_leading {
206 result = format!("| {result}");
207 }
208
209 if has_trailing {
211 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
212 }
213
214 result
215 }
216 "trailing_only" => {
217 let mut result = trimmed;
218
219 if has_leading {
221 result = result.strip_prefix('|').unwrap_or(result).trim_start();
222 }
223
224 let mut result = result.to_string();
225
226 if !has_trailing {
228 result = format!("{result} |");
229 }
230
231 result
232 }
233 _ => return line.to_string(),
234 };
235
236 if prefix.is_empty() {
238 fixed_content
239 } else {
240 format!("{prefix}{fixed_content}")
241 }
242 }
243}
244
245impl Rule for MD055TablePipeStyle {
246 fn name(&self) -> &'static str {
247 "MD055"
248 }
249
250 fn description(&self) -> &'static str {
251 "Table pipe style should be consistent"
252 }
253
254 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
255 !ctx.likely_has_tables()
257 }
258
259 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
260 let content = ctx.content;
261 let line_index = &ctx.line_index;
262 let mut warnings = Vec::new();
263
264 let lines: Vec<&str> = content.lines().collect();
267
268 let configured_style = match self.config.style.as_str() {
270 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
271 self.config.style.as_str()
272 }
273 _ => {
274 "leading_and_trailing"
276 }
277 };
278
279 let table_blocks = &ctx.table_blocks;
281
282 for table_block in table_blocks {
284 let table_style = if configured_style == "consistent" {
287 self.determine_table_style(table_block, &lines)
288 } else {
289 None
290 };
291
292 let target_style = if configured_style == "consistent" {
294 table_style.unwrap_or("leading_and_trailing")
295 } else {
296 configured_style
297 };
298
299 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
301 .chain(std::iter::once(table_block.delimiter_line))
302 .chain(table_block.content_lines.iter().copied())
303 .collect();
304
305 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());
312 for &line_idx in &all_line_indices {
313 let line = lines[line_idx];
314 let fixed_line = self.fix_table_row(line, target_style);
315 if line_idx < lines.len() - 1 {
316 fixed_table_lines.push(format!("{fixed_line}\n"));
317 } else {
318 fixed_table_lines.push(fixed_line);
319 }
320 }
321 let table_replacement = fixed_table_lines.concat();
322 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
323
324 for &line_idx in &all_line_indices {
326 let line = lines[line_idx];
327 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
328 let needs_fixing = current_style != target_style;
330
331 if needs_fixing {
332 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
333
334 let message = format!(
335 "Table pipe style should be {}",
336 match target_style {
337 "leading_and_trailing" => "leading and trailing",
338 "no_leading_or_trailing" => "no leading or trailing",
339 "leading_only" => "leading only",
340 "trailing_only" => "trailing only",
341 _ => target_style,
342 }
343 );
344
345 warnings.push(LintWarning {
348 rule_name: Some(self.name().to_string()),
349 severity: Severity::Warning,
350 message,
351 line: start_line,
352 column: start_col,
353 end_line,
354 end_column: end_col,
355 fix: Some(crate::rule::Fix {
356 range: table_range.clone(),
357 replacement: table_replacement.clone(),
358 }),
359 });
360 }
361 }
362 }
363 }
364
365 Ok(warnings)
366 }
367
368 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
369 let content = ctx.content;
370 let lines: Vec<&str> = content.lines().collect();
371
372 let configured_style = match self.config.style.as_str() {
374 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
375 self.config.style.as_str()
376 }
377 _ => {
378 "leading_and_trailing"
380 }
381 };
382
383 let table_blocks = &ctx.table_blocks;
385
386 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
388
389 for table_block in table_blocks {
391 let table_style = if configured_style == "consistent" {
394 self.determine_table_style(table_block, &lines)
395 } else {
396 None
397 };
398
399 let target_style = if configured_style == "consistent" {
401 table_style.unwrap_or("leading_and_trailing")
402 } else {
403 configured_style
404 };
405
406 let all_lines = std::iter::once(table_block.header_line)
408 .chain(std::iter::once(table_block.delimiter_line))
409 .chain(table_block.content_lines.iter().copied());
410
411 for line_idx in all_lines {
412 let line = lines[line_idx];
413 let fixed_line = self.fix_table_row(line, target_style);
414 result_lines[line_idx] = fixed_line;
415 }
416 }
417
418 let mut fixed = result_lines.join("\n");
419 if content.ends_with('\n') && !fixed.ends_with('\n') {
421 fixed.push('\n');
422 }
423 Ok(fixed)
424 }
425
426 fn as_any(&self) -> &dyn std::any::Any {
427 self
428 }
429
430 fn default_config_section(&self) -> Option<(String, toml::Value)> {
431 let json_value = serde_json::to_value(&self.config).ok()?;
432 Some((
433 self.name().to_string(),
434 crate::rule_config_serde::json_to_toml_value(&json_value)?,
435 ))
436 }
437
438 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
439 where
440 Self: Sized,
441 {
442 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
443 Box::new(Self::from_config_struct(rule_config))
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn test_md055_delimiter_row_handling() {
453 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
455
456 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
457 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.fix(&ctx).unwrap();
459
460 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
463
464 assert_eq!(result, expected);
465
466 let warnings = rule.check(&ctx).unwrap();
468 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
470 assert_eq!(
471 delimiter_warning.message,
472 "Table pipe style should be no leading or trailing"
473 );
474
475 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
477
478 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
479 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.fix(&ctx).unwrap();
481
482 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
485
486 assert_eq!(result, expected);
487 }
488
489 #[test]
490 fn test_md055_check_finds_delimiter_row_issues() {
491 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
493
494 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
495 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let warnings = rule.check(&ctx).unwrap();
497
498 assert_eq!(warnings.len(), 3);
500
501 let delimiter_warning = &warnings[1];
503 assert_eq!(delimiter_warning.line, 2);
504 assert_eq!(
505 delimiter_warning.message,
506 "Table pipe style should be no leading or trailing"
507 );
508 }
509
510 #[test]
511 fn test_md055_real_world_example() {
512 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
514
515 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.";
516 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517 let result = rule.fix(&ctx).unwrap();
518
519 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.";
522
523 assert_eq!(result, expected);
524
525 let warnings = rule.check(&ctx).unwrap();
527 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); }
535
536 #[test]
537 fn test_md055_invalid_style() {
538 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 |";
542 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.fix(&ctx).unwrap();
544
545 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
548
549 assert_eq!(result, expected);
550
551 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
553 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.fix(&ctx2).unwrap();
555
556 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
559 assert_eq!(result, expected);
560
561 let warnings = rule.check(&ctx2).unwrap();
563
564 assert_eq!(warnings.len(), 3);
567 }
568
569 #[test]
570 fn test_underflow_protection() {
571 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
573
574 let result = rule.fix_table_row("", "leading_and_trailing");
576 assert_eq!(result, "");
577
578 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
580 assert_eq!(result, "no pipes here");
581
582 let result = rule.fix_table_row("|", "leading_and_trailing");
584 assert!(!result.is_empty());
586 }
587
588 #[test]
591 fn test_fix_table_row_in_blockquote() {
592 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
593
594 let result = rule.fix_table_row("> H1 | H2", "leading_and_trailing");
596 assert_eq!(result, "> | H1 | H2 |");
597
598 let result = rule.fix_table_row("> | H1 | H2 |", "leading_and_trailing");
600 assert_eq!(result, "> | H1 | H2 |");
601
602 let result = rule.fix_table_row("> | H1 | H2 |", "no_leading_or_trailing");
604 assert_eq!(result, "> H1 | H2");
605 }
606
607 #[test]
608 fn test_fix_table_row_in_nested_blockquote() {
609 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
610
611 let result = rule.fix_table_row(">> H1 | H2", "leading_and_trailing");
613 assert_eq!(result, ">> | H1 | H2 |");
614
615 let result = rule.fix_table_row(">>> H1 | H2", "leading_and_trailing");
617 assert_eq!(result, ">>> | H1 | H2 |");
618 }
619
620 #[test]
621 fn test_blockquote_table_full_document() {
622 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
623
624 let content = "> H1 | H2\n> ----|----\n> a | b";
626 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.fix(&ctx).unwrap();
628
629 assert!(
632 result.starts_with("> |"),
633 "Header should start with blockquote + pipe. Got:\n{result}"
634 );
635 assert!(
637 result.contains("> | ----"),
638 "Delimiter should have blockquote prefix + leading pipe. Got:\n{result}"
639 );
640 }
641
642 #[test]
643 fn test_blockquote_table_no_leading_trailing() {
644 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
645
646 let content = "> | H1 | H2 |\n> |----|----|---|\n> | a | b |";
648 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.fix(&ctx).unwrap();
650
651 let lines: Vec<&str> = result.lines().collect();
653 assert!(lines[0].starts_with("> "), "Line should start with blockquote prefix");
654 assert!(
655 !lines[0].starts_with("> |"),
656 "Leading pipe should be removed. Got: {}",
657 lines[0]
658 );
659 }
660
661 #[test]
662 fn test_mixed_regular_and_blockquote_tables() {
663 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
664
665 let content = "H1 | H2\n---|---\na | b\n\n> H3 | H4\n> ---|---\n> c | d";
667 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.fix(&ctx).unwrap();
669
670 assert!(result.contains("| H1 | H2 |"), "Regular table should have pipes added");
672 assert!(
673 result.contains("> | H3 | H4 |"),
674 "Blockquote table should have pipes added with prefix preserved"
675 );
676 }
677}