rumdl_lib/rules/
md055_table_pipe_style.rs1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::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 fix_table_row(&self, line: &str, target_style: &str) -> String {
99 let trimmed = line.trim();
100 if !trimmed.contains('|') {
101 return line.to_string();
102 }
103
104 let has_leading = trimmed.starts_with('|');
105 let has_trailing = trimmed.ends_with('|');
106
107 match target_style {
108 "leading_and_trailing" => {
109 let mut result = trimmed.to_string();
110
111 if !has_leading {
113 result = format!("| {result}");
114 }
115
116 if !has_trailing {
118 result = format!("{result} |");
119 }
120
121 result
122 }
123 "no_leading_or_trailing" => {
124 let mut result = trimmed;
125
126 if has_leading {
128 result = result.strip_prefix('|').unwrap_or(result);
129 result = result.trim_start();
130 }
131
132 if has_trailing {
134 result = result.strip_suffix('|').unwrap_or(result);
135 result = result.trim_end();
136 }
137
138 result.to_string()
139 }
140 "leading_only" => {
141 let mut result = trimmed.to_string();
142
143 if !has_leading {
145 result = format!("| {result}");
146 }
147
148 if has_trailing {
150 result = result.strip_suffix('|').unwrap_or(&result).trim_end().to_string();
151 }
152
153 result
154 }
155 "trailing_only" => {
156 let mut result = trimmed;
157
158 if has_leading {
160 result = result.strip_prefix('|').unwrap_or(result).trim_start();
161 }
162
163 let mut result = result.to_string();
164
165 if !has_trailing {
167 result = format!("{result} |");
168 }
169
170 result
171 }
172 _ => line.to_string(),
173 }
174 }
175}
176
177impl Rule for MD055TablePipeStyle {
178 fn name(&self) -> &'static str {
179 "MD055"
180 }
181
182 fn description(&self) -> &'static str {
183 "Table pipe style should be consistent"
184 }
185
186 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
187 !ctx.likely_has_tables()
189 }
190
191 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
192 let content = ctx.content;
193 let line_index = &ctx.line_index;
194 let mut warnings = Vec::new();
195
196 let lines: Vec<&str> = content.lines().collect();
199
200 let configured_style = match self.config.style.as_str() {
202 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
203 self.config.style.as_str()
204 }
205 _ => {
206 "leading_and_trailing"
208 }
209 };
210
211 let table_blocks = &ctx.table_blocks;
213
214 for table_block in table_blocks {
216 let mut table_style = None;
217
218 if configured_style == "consistent" {
221 let mut leading_and_trailing_count = 0;
222 let mut no_leading_or_trailing_count = 0;
223 let mut leading_only_count = 0;
224 let mut trailing_only_count = 0;
225
226 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
228 match style {
229 "leading_and_trailing" => leading_and_trailing_count += 1,
230 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
231 "leading_only" => leading_only_count += 1,
232 "trailing_only" => trailing_only_count += 1,
233 _ => {}
234 }
235 }
236
237 for &line_idx in &table_block.content_lines {
239 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
240 match style {
241 "leading_and_trailing" => leading_and_trailing_count += 1,
242 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
243 "leading_only" => leading_only_count += 1,
244 "trailing_only" => trailing_only_count += 1,
245 _ => {}
246 }
247 }
248 }
249
250 let max_count = leading_and_trailing_count
253 .max(no_leading_or_trailing_count)
254 .max(leading_only_count)
255 .max(trailing_only_count);
256
257 if max_count > 0 {
258 if leading_and_trailing_count == max_count {
259 table_style = Some("leading_and_trailing");
260 } else if no_leading_or_trailing_count == max_count {
261 table_style = Some("no_leading_or_trailing");
262 } else if leading_only_count == max_count {
263 table_style = Some("leading_only");
264 } else if trailing_only_count == max_count {
265 table_style = Some("trailing_only");
266 }
267 }
268 }
269
270 let target_style = if configured_style == "consistent" {
272 table_style.unwrap_or("leading_and_trailing")
273 } else {
274 configured_style
275 };
276
277 let all_lines = std::iter::once(table_block.header_line)
279 .chain(std::iter::once(table_block.delimiter_line))
280 .chain(table_block.content_lines.iter().copied());
281
282 for line_idx in all_lines {
283 let line = lines[line_idx];
284 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
285 let needs_fixing = current_style != target_style;
287
288 if needs_fixing {
289 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
290
291 let message = format!(
292 "Table pipe style should be {}",
293 match target_style {
294 "leading_and_trailing" => "leading and trailing",
295 "no_leading_or_trailing" => "no leading or trailing",
296 "leading_only" => "leading only",
297 "trailing_only" => "trailing only",
298 _ => target_style,
299 }
300 );
301
302 let fixed_line = self.fix_table_row(line, target_style);
303 warnings.push(LintWarning {
304 rule_name: Some(self.name().to_string()),
305 severity: Severity::Warning,
306 message,
307 line: start_line,
308 column: start_col,
309 end_line,
310 end_column: end_col,
311 fix: Some(crate::rule::Fix {
312 range: line_index.whole_line_range(line_idx + 1),
313 replacement: if line_idx < lines.len() - 1 {
314 format!("{fixed_line}\n")
315 } else {
316 fixed_line
317 },
318 }),
319 });
320 }
321 }
322 }
323 }
324
325 Ok(warnings)
326 }
327
328 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329 let content = ctx.content;
330 let lines: Vec<&str> = content.lines().collect();
331
332 let configured_style = match self.config.style.as_str() {
334 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
335 self.config.style.as_str()
336 }
337 _ => {
338 "leading_and_trailing"
340 }
341 };
342
343 let table_blocks = &ctx.table_blocks;
345
346 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
348
349 for table_block in table_blocks {
351 let mut table_style = None;
352
353 if configured_style == "consistent" {
356 let mut leading_and_trailing_count = 0;
357 let mut no_leading_or_trailing_count = 0;
358 let mut leading_only_count = 0;
359 let mut trailing_only_count = 0;
360
361 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
363 match style {
364 "leading_and_trailing" => leading_and_trailing_count += 1,
365 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
366 "leading_only" => leading_only_count += 1,
367 "trailing_only" => trailing_only_count += 1,
368 _ => {}
369 }
370 }
371
372 for &line_idx in &table_block.content_lines {
374 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
375 match style {
376 "leading_and_trailing" => leading_and_trailing_count += 1,
377 "no_leading_or_trailing" => no_leading_or_trailing_count += 1,
378 "leading_only" => leading_only_count += 1,
379 "trailing_only" => trailing_only_count += 1,
380 _ => {}
381 }
382 }
383 }
384
385 let max_count = leading_and_trailing_count
388 .max(no_leading_or_trailing_count)
389 .max(leading_only_count)
390 .max(trailing_only_count);
391
392 if max_count > 0 {
393 if leading_and_trailing_count == max_count {
394 table_style = Some("leading_and_trailing");
395 } else if no_leading_or_trailing_count == max_count {
396 table_style = Some("no_leading_or_trailing");
397 } else if leading_only_count == max_count {
398 table_style = Some("leading_only");
399 } else if trailing_only_count == max_count {
400 table_style = Some("trailing_only");
401 }
402 }
403 }
404
405 let target_style = if configured_style == "consistent" {
407 table_style.unwrap_or("leading_and_trailing")
408 } else {
409 configured_style
410 };
411
412 let all_lines = std::iter::once(table_block.header_line)
414 .chain(std::iter::once(table_block.delimiter_line))
415 .chain(table_block.content_lines.iter().copied());
416
417 for line_idx in all_lines {
418 let line = lines[line_idx];
419 let fixed_line = self.fix_table_row(line, target_style);
420 result_lines[line_idx] = fixed_line;
421 }
422 }
423
424 let mut fixed = result_lines.join("\n");
425 if content.ends_with('\n') && !fixed.ends_with('\n') {
427 fixed.push('\n');
428 }
429 Ok(fixed)
430 }
431
432 fn as_any(&self) -> &dyn std::any::Any {
433 self
434 }
435
436 fn default_config_section(&self) -> Option<(String, toml::Value)> {
437 let json_value = serde_json::to_value(&self.config).ok()?;
438 Some((
439 self.name().to_string(),
440 crate::rule_config_serde::json_to_toml_value(&json_value)?,
441 ))
442 }
443
444 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
445 where
446 Self: Sized,
447 {
448 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
449 Box::new(Self::from_config_struct(rule_config))
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn test_md055_delimiter_row_handling() {
459 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
461
462 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
463 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.fix(&ctx).unwrap();
465
466 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
469
470 assert_eq!(result, expected);
471
472 let warnings = rule.check(&ctx).unwrap();
474 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
476 assert_eq!(
477 delimiter_warning.message,
478 "Table pipe style should be no leading or trailing"
479 );
480
481 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
483
484 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486 let result = rule.fix(&ctx).unwrap();
487
488 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
491
492 assert_eq!(result, expected);
493 }
494
495 #[test]
496 fn test_md055_check_finds_delimiter_row_issues() {
497 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
499
500 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
501 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502 let warnings = rule.check(&ctx).unwrap();
503
504 assert_eq!(warnings.len(), 3);
506
507 let delimiter_warning = &warnings[1];
509 assert_eq!(delimiter_warning.line, 2);
510 assert_eq!(
511 delimiter_warning.message,
512 "Table pipe style should be no leading or trailing"
513 );
514 }
515
516 #[test]
517 fn test_md055_real_world_example() {
518 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
520
521 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.";
522 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523 let result = rule.fix(&ctx).unwrap();
524
525 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.";
528
529 assert_eq!(result, expected);
530
531 let warnings = rule.check(&ctx).unwrap();
533 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); }
541
542 #[test]
543 fn test_md055_invalid_style() {
544 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 |";
548 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
549 let result = rule.fix(&ctx).unwrap();
550
551 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
554
555 assert_eq!(result, expected);
556
557 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
559 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560 let result = rule.fix(&ctx2).unwrap();
561
562 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
565 assert_eq!(result, expected);
566
567 let warnings = rule.check(&ctx2).unwrap();
569
570 assert_eq!(warnings.len(), 3);
573 }
574
575 #[test]
576 fn test_underflow_protection() {
577 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
579
580 let result = rule.fix_table_row("", "leading_and_trailing");
582 assert_eq!(result, "");
583
584 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
586 assert_eq!(result, "no pipes here");
587
588 let result = rule.fix_table_row("|", "leading_and_trailing");
590 assert!(!result.is_empty());
592 }
593}