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 {
154 let trimmed = line.trim();
155 if !trimmed.contains('|') {
156 return line.to_string();
157 }
158
159 let has_leading = trimmed.starts_with('|');
160 let has_trailing = trimmed.ends_with('|');
161
162 match target_style {
163 "leading_and_trailing" => {
164 let mut result = trimmed.to_string();
165
166 if !has_leading {
168 result = format!("| {result}");
169 }
170
171 if !has_trailing {
173 result = format!("{result} |");
174 }
175
176 result
177 }
178 "no_leading_or_trailing" => {
179 let mut result = trimmed;
180
181 if has_leading {
183 result = result.strip_prefix('|').unwrap_or(result);
184 result = result.trim_start();
185 }
186
187 if has_trailing {
189 result = result.strip_suffix('|').unwrap_or(result);
190 result = result.trim_end();
191 }
192
193 result.to_string()
194 }
195 "leading_only" => {
196 let mut result = trimmed.to_string();
197
198 if !has_leading {
200 result = format!("| {result}");
201 }
202
203 if has_trailing {
205 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
206 }
207
208 result
209 }
210 "trailing_only" => {
211 let mut result = trimmed;
212
213 if has_leading {
215 result = result.strip_prefix('|').unwrap_or(result).trim_start();
216 }
217
218 let mut result = result.to_string();
219
220 if !has_trailing {
222 result = format!("{result} |");
223 }
224
225 result
226 }
227 _ => line.to_string(),
228 }
229 }
230}
231
232impl Rule for MD055TablePipeStyle {
233 fn name(&self) -> &'static str {
234 "MD055"
235 }
236
237 fn description(&self) -> &'static str {
238 "Table pipe style should be consistent"
239 }
240
241 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
242 !ctx.likely_has_tables()
244 }
245
246 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
247 let content = ctx.content;
248 let line_index = &ctx.line_index;
249 let mut warnings = Vec::new();
250
251 let lines: Vec<&str> = content.lines().collect();
254
255 let configured_style = match self.config.style.as_str() {
257 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
258 self.config.style.as_str()
259 }
260 _ => {
261 "leading_and_trailing"
263 }
264 };
265
266 let table_blocks = &ctx.table_blocks;
268
269 for table_block in table_blocks {
271 let table_style = if configured_style == "consistent" {
274 self.determine_table_style(table_block, &lines)
275 } else {
276 None
277 };
278
279 let target_style = if configured_style == "consistent" {
281 table_style.unwrap_or("leading_and_trailing")
282 } else {
283 configured_style
284 };
285
286 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
288 .chain(std::iter::once(table_block.delimiter_line))
289 .chain(table_block.content_lines.iter().copied())
290 .collect();
291
292 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());
299 for &line_idx in &all_line_indices {
300 let line = lines[line_idx];
301 let fixed_line = self.fix_table_row(line, target_style);
302 if line_idx < lines.len() - 1 {
303 fixed_table_lines.push(format!("{fixed_line}\n"));
304 } else {
305 fixed_table_lines.push(fixed_line);
306 }
307 }
308 let table_replacement = fixed_table_lines.concat();
309 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
310
311 for &line_idx in &all_line_indices {
313 let line = lines[line_idx];
314 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
315 let needs_fixing = current_style != target_style;
317
318 if needs_fixing {
319 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
320
321 let message = format!(
322 "Table pipe style should be {}",
323 match target_style {
324 "leading_and_trailing" => "leading and trailing",
325 "no_leading_or_trailing" => "no leading or trailing",
326 "leading_only" => "leading only",
327 "trailing_only" => "trailing only",
328 _ => target_style,
329 }
330 );
331
332 warnings.push(LintWarning {
335 rule_name: Some(self.name().to_string()),
336 severity: Severity::Warning,
337 message,
338 line: start_line,
339 column: start_col,
340 end_line,
341 end_column: end_col,
342 fix: Some(crate::rule::Fix {
343 range: table_range.clone(),
344 replacement: table_replacement.clone(),
345 }),
346 });
347 }
348 }
349 }
350 }
351
352 Ok(warnings)
353 }
354
355 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
356 let content = ctx.content;
357 let lines: Vec<&str> = content.lines().collect();
358
359 let configured_style = match self.config.style.as_str() {
361 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
362 self.config.style.as_str()
363 }
364 _ => {
365 "leading_and_trailing"
367 }
368 };
369
370 let table_blocks = &ctx.table_blocks;
372
373 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
375
376 for table_block in table_blocks {
378 let table_style = if configured_style == "consistent" {
381 self.determine_table_style(table_block, &lines)
382 } else {
383 None
384 };
385
386 let target_style = if configured_style == "consistent" {
388 table_style.unwrap_or("leading_and_trailing")
389 } else {
390 configured_style
391 };
392
393 let all_lines = std::iter::once(table_block.header_line)
395 .chain(std::iter::once(table_block.delimiter_line))
396 .chain(table_block.content_lines.iter().copied());
397
398 for line_idx in all_lines {
399 let line = lines[line_idx];
400 let fixed_line = self.fix_table_row(line, target_style);
401 result_lines[line_idx] = fixed_line;
402 }
403 }
404
405 let mut fixed = result_lines.join("\n");
406 if content.ends_with('\n') && !fixed.ends_with('\n') {
408 fixed.push('\n');
409 }
410 Ok(fixed)
411 }
412
413 fn as_any(&self) -> &dyn std::any::Any {
414 self
415 }
416
417 fn default_config_section(&self) -> Option<(String, toml::Value)> {
418 let json_value = serde_json::to_value(&self.config).ok()?;
419 Some((
420 self.name().to_string(),
421 crate::rule_config_serde::json_to_toml_value(&json_value)?,
422 ))
423 }
424
425 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
426 where
427 Self: Sized,
428 {
429 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
430 Box::new(Self::from_config_struct(rule_config))
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn test_md055_delimiter_row_handling() {
440 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
442
443 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
444 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.fix(&ctx).unwrap();
446
447 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
450
451 assert_eq!(result, expected);
452
453 let warnings = rule.check(&ctx).unwrap();
455 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
457 assert_eq!(
458 delimiter_warning.message,
459 "Table pipe style should be no leading or trailing"
460 );
461
462 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
464
465 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
466 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.fix(&ctx).unwrap();
468
469 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
472
473 assert_eq!(result, expected);
474 }
475
476 #[test]
477 fn test_md055_check_finds_delimiter_row_issues() {
478 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
480
481 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
482 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let warnings = rule.check(&ctx).unwrap();
484
485 assert_eq!(warnings.len(), 3);
487
488 let delimiter_warning = &warnings[1];
490 assert_eq!(delimiter_warning.line, 2);
491 assert_eq!(
492 delimiter_warning.message,
493 "Table pipe style should be no leading or trailing"
494 );
495 }
496
497 #[test]
498 fn test_md055_real_world_example() {
499 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
501
502 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.";
503 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504 let result = rule.fix(&ctx).unwrap();
505
506 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.";
509
510 assert_eq!(result, expected);
511
512 let warnings = rule.check(&ctx).unwrap();
514 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); }
522
523 #[test]
524 fn test_md055_invalid_style() {
525 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 |";
529 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.fix(&ctx).unwrap();
531
532 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
535
536 assert_eq!(result, expected);
537
538 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
540 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.fix(&ctx2).unwrap();
542
543 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
546 assert_eq!(result, expected);
547
548 let warnings = rule.check(&ctx2).unwrap();
550
551 assert_eq!(warnings.len(), 3);
554 }
555
556 #[test]
557 fn test_underflow_protection() {
558 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
560
561 let result = rule.fix_table_row("", "leading_and_trailing");
563 assert_eq!(result, "");
564
565 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
567 assert_eq!(result, "no pipes here");
568
569 let result = rule.fix_table_row("|", "leading_and_trailing");
571 assert!(!result.is_empty());
573 }
574}