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" {
220 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
222 table_style = Some(style);
223 } else {
224 for &line_idx in &table_block.content_lines {
226 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
227 table_style = Some(style);
228 break;
229 }
230 }
231 }
232 }
233
234 let target_style = if configured_style == "consistent" {
236 table_style.unwrap_or("leading_and_trailing")
237 } else {
238 configured_style
239 };
240
241 let all_lines = std::iter::once(table_block.header_line)
243 .chain(std::iter::once(table_block.delimiter_line))
244 .chain(table_block.content_lines.iter().copied());
245
246 for line_idx in all_lines {
247 let line = lines[line_idx];
248 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
249 let needs_fixing = current_style != target_style;
251
252 if needs_fixing {
253 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
254
255 let message = format!(
256 "Table pipe style should be {}",
257 match target_style {
258 "leading_and_trailing" => "leading and trailing",
259 "no_leading_or_trailing" => "no leading or trailing",
260 "leading_only" => "leading only",
261 "trailing_only" => "trailing only",
262 _ => target_style,
263 }
264 );
265
266 let fixed_line = self.fix_table_row(line, target_style);
267 warnings.push(LintWarning {
268 rule_name: Some(self.name().to_string()),
269 severity: Severity::Warning,
270 message,
271 line: start_line,
272 column: start_col,
273 end_line,
274 end_column: end_col,
275 fix: Some(crate::rule::Fix {
276 range: line_index.whole_line_range(line_idx + 1),
277 replacement: if line_idx < lines.len() - 1 {
278 format!("{fixed_line}\n")
279 } else {
280 fixed_line
281 },
282 }),
283 });
284 }
285 }
286 }
287 }
288
289 Ok(warnings)
290 }
291
292 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
293 let content = ctx.content;
294 let lines: Vec<&str> = content.lines().collect();
295
296 let configured_style = match self.config.style.as_str() {
298 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
299 self.config.style.as_str()
300 }
301 _ => {
302 "leading_and_trailing"
304 }
305 };
306
307 let table_blocks = &ctx.table_blocks;
309
310 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
312
313 for table_block in table_blocks {
315 let mut table_style = None;
316
317 if configured_style == "consistent" {
319 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
321 table_style = Some(style);
322 } else {
323 for &line_idx in &table_block.content_lines {
325 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
326 table_style = Some(style);
327 break;
328 }
329 }
330 }
331 }
332
333 let target_style = if configured_style == "consistent" {
335 table_style.unwrap_or("leading_and_trailing")
336 } else {
337 configured_style
338 };
339
340 let all_lines = std::iter::once(table_block.header_line)
342 .chain(std::iter::once(table_block.delimiter_line))
343 .chain(table_block.content_lines.iter().copied());
344
345 for line_idx in all_lines {
346 let line = lines[line_idx];
347 let fixed_line = self.fix_table_row(line, target_style);
348 result_lines[line_idx] = fixed_line;
349 }
350 }
351
352 let mut fixed = result_lines.join("\n");
353 if content.ends_with('\n') && !fixed.ends_with('\n') {
355 fixed.push('\n');
356 }
357 Ok(fixed)
358 }
359
360 fn as_any(&self) -> &dyn std::any::Any {
361 self
362 }
363
364 fn default_config_section(&self) -> Option<(String, toml::Value)> {
365 let json_value = serde_json::to_value(&self.config).ok()?;
366 Some((
367 self.name().to_string(),
368 crate::rule_config_serde::json_to_toml_value(&json_value)?,
369 ))
370 }
371
372 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
373 where
374 Self: Sized,
375 {
376 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
377 Box::new(Self::from_config_struct(rule_config))
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn test_md055_delimiter_row_handling() {
387 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
389
390 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
391 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
392 let result = rule.fix(&ctx).unwrap();
393
394 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
397
398 assert_eq!(result, expected);
399
400 let warnings = rule.check(&ctx).unwrap();
402 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
404 assert_eq!(
405 delimiter_warning.message,
406 "Table pipe style should be no leading or trailing"
407 );
408
409 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
411
412 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
413 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let result = rule.fix(&ctx).unwrap();
415
416 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
419
420 assert_eq!(result, expected);
421 }
422
423 #[test]
424 fn test_md055_check_finds_delimiter_row_issues() {
425 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
427
428 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
429 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
430 let warnings = rule.check(&ctx).unwrap();
431
432 assert_eq!(warnings.len(), 3);
434
435 let delimiter_warning = &warnings[1];
437 assert_eq!(delimiter_warning.line, 2);
438 assert_eq!(
439 delimiter_warning.message,
440 "Table pipe style should be no leading or trailing"
441 );
442 }
443
444 #[test]
445 fn test_md055_real_world_example() {
446 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
448
449 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.";
450 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.fix(&ctx).unwrap();
452
453 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.";
456
457 assert_eq!(result, expected);
458
459 let warnings = rule.check(&ctx).unwrap();
461 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); }
469
470 #[test]
471 fn test_md055_invalid_style() {
472 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 |";
476 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477 let result = rule.fix(&ctx).unwrap();
478
479 let expected = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
482
483 assert_eq!(result, expected);
484
485 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
487 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
488 let result = rule.fix(&ctx2).unwrap();
489
490 let expected = "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
493 assert_eq!(result, expected);
494
495 let warnings = rule.check(&ctx2).unwrap();
497
498 assert_eq!(warnings.len(), 3);
501 }
502
503 #[test]
504 fn test_underflow_protection() {
505 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
507
508 let result = rule.fix_table_row("", "leading_and_trailing");
510 assert_eq!(result, "");
511
512 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
514 assert_eq!(result, "no pipes here");
515
516 let result = rule.fix_table_row("|", "leading_and_trailing");
518 assert!(!result.is_empty());
520 }
521}