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::{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_lines = 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
291 for line_idx in all_lines {
292 let line = lines[line_idx];
293 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
294 let needs_fixing = current_style != target_style;
296
297 if needs_fixing {
298 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
299
300 let message = format!(
301 "Table pipe style should be {}",
302 match target_style {
303 "leading_and_trailing" => "leading and trailing",
304 "no_leading_or_trailing" => "no leading or trailing",
305 "leading_only" => "leading only",
306 "trailing_only" => "trailing only",
307 _ => target_style,
308 }
309 );
310
311 let fixed_line = self.fix_table_row(line, target_style);
312 warnings.push(LintWarning {
313 rule_name: Some(self.name().to_string()),
314 severity: Severity::Warning,
315 message,
316 line: start_line,
317 column: start_col,
318 end_line,
319 end_column: end_col,
320 fix: Some(crate::rule::Fix {
321 range: line_index.whole_line_range(line_idx + 1),
322 replacement: if line_idx < lines.len() - 1 {
323 format!("{fixed_line}\n")
324 } else {
325 fixed_line
326 },
327 }),
328 });
329 }
330 }
331 }
332 }
333
334 Ok(warnings)
335 }
336
337 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
338 let content = ctx.content;
339 let lines: Vec<&str> = content.lines().collect();
340
341 let configured_style = match self.config.style.as_str() {
343 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
344 self.config.style.as_str()
345 }
346 _ => {
347 "leading_and_trailing"
349 }
350 };
351
352 let table_blocks = &ctx.table_blocks;
354
355 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
357
358 for table_block in table_blocks {
360 let table_style = if configured_style == "consistent" {
363 self.determine_table_style(table_block, &lines)
364 } else {
365 None
366 };
367
368 let target_style = if configured_style == "consistent" {
370 table_style.unwrap_or("leading_and_trailing")
371 } else {
372 configured_style
373 };
374
375 let all_lines = std::iter::once(table_block.header_line)
377 .chain(std::iter::once(table_block.delimiter_line))
378 .chain(table_block.content_lines.iter().copied());
379
380 for line_idx in all_lines {
381 let line = lines[line_idx];
382 let fixed_line = self.fix_table_row(line, target_style);
383 result_lines[line_idx] = fixed_line;
384 }
385 }
386
387 let mut fixed = result_lines.join("\n");
388 if content.ends_with('\n') && !fixed.ends_with('\n') {
390 fixed.push('\n');
391 }
392 Ok(fixed)
393 }
394
395 fn as_any(&self) -> &dyn std::any::Any {
396 self
397 }
398
399 fn default_config_section(&self) -> Option<(String, toml::Value)> {
400 let json_value = serde_json::to_value(&self.config).ok()?;
401 Some((
402 self.name().to_string(),
403 crate::rule_config_serde::json_to_toml_value(&json_value)?,
404 ))
405 }
406
407 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
408 where
409 Self: Sized,
410 {
411 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
412 Box::new(Self::from_config_struct(rule_config))
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_md055_delimiter_row_handling() {
422 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
424
425 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
426 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
427 let result = rule.fix(&ctx).unwrap();
428
429 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
432
433 assert_eq!(result, expected);
434
435 let warnings = rule.check(&ctx).unwrap();
437 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
439 assert_eq!(
440 delimiter_warning.message,
441 "Table pipe style should be no leading or trailing"
442 );
443
444 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
446
447 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
448 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
449 let result = rule.fix(&ctx).unwrap();
450
451 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
454
455 assert_eq!(result, expected);
456 }
457
458 #[test]
459 fn test_md055_check_finds_delimiter_row_issues() {
460 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
462
463 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
464 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
465 let warnings = rule.check(&ctx).unwrap();
466
467 assert_eq!(warnings.len(), 3);
469
470 let delimiter_warning = &warnings[1];
472 assert_eq!(delimiter_warning.line, 2);
473 assert_eq!(
474 delimiter_warning.message,
475 "Table pipe style should be no leading or trailing"
476 );
477 }
478
479 #[test]
480 fn test_md055_real_world_example() {
481 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
483
484 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.";
485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486 let result = rule.fix(&ctx).unwrap();
487
488 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.";
491
492 assert_eq!(result, expected);
493
494 let warnings = rule.check(&ctx).unwrap();
496 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); }
504
505 #[test]
506 fn test_md055_invalid_style() {
507 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 |";
511 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.fix(&ctx).unwrap();
513
514 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
517
518 assert_eq!(result, expected);
519
520 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
522 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523 let result = rule.fix(&ctx2).unwrap();
524
525 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
528 assert_eq!(result, expected);
529
530 let warnings = rule.check(&ctx2).unwrap();
532
533 assert_eq!(warnings.len(), 3);
536 }
537
538 #[test]
539 fn test_underflow_protection() {
540 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
542
543 let result = rule.fix_table_row("", "leading_and_trailing");
545 assert_eq!(result, "");
546
547 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
549 assert_eq!(result, "no pipes here");
550
551 let result = rule.fix_table_row("|", "leading_and_trailing");
553 assert!(!result.is_empty());
555 }
556}