rumdl_lib/rules/
md055_table_pipe_style.rs1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::{LineIndex, 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 {
98 let trimmed = line.trim();
99 if !trimmed.contains('|') {
100 return line.to_string();
101 }
102
103 let is_delimiter_row = trimmed.contains('-')
105 && trimmed
106 .chars()
107 .all(|c| c == '-' || c == ':' || c == '|' || c.is_whitespace());
108
109 let parts: Vec<&str> = trimmed.split('|').collect();
111 let mut content_parts = Vec::new();
112
113 let start_idx = if parts.first().is_some_and(|p| p.trim().is_empty()) {
115 1
116 } else {
117 0
118 };
119 let end_idx = if parts.last().is_some_and(|p| p.trim().is_empty()) {
120 if !parts.is_empty() { parts.len() - 1 } else { 0 }
121 } else {
122 parts.len()
123 };
124
125 for part in parts.iter().take(end_idx).skip(start_idx) {
126 content_parts.push(part.trim());
128 }
129
130 match target_style {
132 "leading_and_trailing" => {
133 if is_delimiter_row {
134 format!("| {} |", content_parts.join("|"))
135 } else {
136 format!("| {} |", content_parts.join(" | "))
137 }
138 }
139 "leading_only" => {
140 if is_delimiter_row {
141 format!("| {}", content_parts.join("|"))
142 } else {
143 format!("| {}", content_parts.join(" | "))
144 }
145 }
146 "trailing_only" => {
147 if is_delimiter_row {
148 format!("{} |", content_parts.join("|"))
149 } else {
150 format!("{} |", content_parts.join(" | "))
151 }
152 }
153 "no_leading_or_trailing" => {
154 if is_delimiter_row {
155 content_parts.join("|")
156 } else {
157 content_parts.join(" | ")
158 }
159 }
160 _ => line.to_string(),
161 }
162 }
163}
164
165impl Rule for MD055TablePipeStyle {
166 fn name(&self) -> &'static str {
167 "MD055"
168 }
169
170 fn description(&self) -> &'static str {
171 "Table pipe style should be consistent"
172 }
173
174 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
175 !ctx.likely_has_tables()
177 }
178
179 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
180 let content = ctx.content;
181 let line_index = LineIndex::new(content.to_string());
182 let mut warnings = Vec::new();
183
184 let lines: Vec<&str> = content.lines().collect();
187
188 let configured_style = match self.config.style.as_str() {
190 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
191 self.config.style.as_str()
192 }
193 _ => {
194 "leading_and_trailing"
196 }
197 };
198
199 let table_blocks = TableUtils::find_table_blocks(content, ctx);
201
202 for table_block in table_blocks {
204 let mut table_style = None;
205
206 if configured_style == "consistent" {
208 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
210 table_style = Some(style);
211 } else {
212 for &line_idx in &table_block.content_lines {
214 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
215 table_style = Some(style);
216 break;
217 }
218 }
219 }
220 }
221
222 let target_style = if configured_style == "consistent" {
224 table_style.unwrap_or("leading_and_trailing")
225 } else {
226 configured_style
227 };
228
229 let all_lines = std::iter::once(table_block.header_line)
231 .chain(std::iter::once(table_block.delimiter_line))
232 .chain(table_block.content_lines.iter().copied());
233
234 for line_idx in all_lines {
235 let line = lines[line_idx];
236 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
237 let needs_fixing = current_style != target_style;
239
240 if needs_fixing {
241 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
242
243 let message = format!(
244 "Table pipe style should be {}",
245 match target_style {
246 "leading_and_trailing" => "leading and trailing",
247 "no_leading_or_trailing" => "no leading or trailing",
248 "leading_only" => "leading only",
249 "trailing_only" => "trailing only",
250 _ => target_style,
251 }
252 );
253
254 let fixed_line = self.fix_table_row(line, target_style);
255 warnings.push(LintWarning {
256 rule_name: Some(self.name().to_string()),
257 severity: Severity::Warning,
258 message,
259 line: start_line,
260 column: start_col,
261 end_line,
262 end_column: end_col,
263 fix: Some(crate::rule::Fix {
264 range: line_index.whole_line_range(line_idx + 1),
265 replacement: if line_idx < lines.len() - 1 {
266 format!("{fixed_line}\n")
267 } else {
268 fixed_line
269 },
270 }),
271 });
272 }
273 }
274 }
275 }
276
277 Ok(warnings)
278 }
279
280 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
281 let content = ctx.content;
282 let lines: Vec<&str> = content.lines().collect();
283
284 let configured_style = match self.config.style.as_str() {
286 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
287 self.config.style.as_str()
288 }
289 _ => {
290 "leading_and_trailing"
292 }
293 };
294
295 let table_blocks = TableUtils::find_table_blocks(content, ctx);
297
298 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
300
301 for table_block in table_blocks {
303 let mut table_style = None;
304
305 if configured_style == "consistent" {
307 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
309 table_style = Some(style);
310 } else {
311 for &line_idx in &table_block.content_lines {
313 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
314 table_style = Some(style);
315 break;
316 }
317 }
318 }
319 }
320
321 let target_style = if configured_style == "consistent" {
323 table_style.unwrap_or("leading_and_trailing")
324 } else {
325 configured_style
326 };
327
328 let all_lines = std::iter::once(table_block.header_line)
330 .chain(std::iter::once(table_block.delimiter_line))
331 .chain(table_block.content_lines.iter().copied());
332
333 for line_idx in all_lines {
334 let line = lines[line_idx];
335 let fixed_line = self.fix_table_row(line, target_style);
336 result_lines[line_idx] = fixed_line;
337 }
338 }
339
340 let mut fixed = result_lines.join("\n");
341 if content.ends_with('\n') && !fixed.ends_with('\n') {
343 fixed.push('\n');
344 }
345 Ok(fixed)
346 }
347
348 fn as_any(&self) -> &dyn std::any::Any {
349 self
350 }
351
352 fn default_config_section(&self) -> Option<(String, toml::Value)> {
353 let json_value = serde_json::to_value(&self.config).ok()?;
354 Some((
355 self.name().to_string(),
356 crate::rule_config_serde::json_to_toml_value(&json_value)?,
357 ))
358 }
359
360 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
361 where
362 Self: Sized,
363 {
364 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
365 Box::new(Self::from_config_struct(rule_config))
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_md055_delimiter_row_handling() {
375 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
377
378 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
379 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let result = rule.fix(&ctx).unwrap();
381
382 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
384
385 assert_eq!(result, expected);
386
387 let warnings = rule.check(&ctx).unwrap();
389 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
391 assert_eq!(
392 delimiter_warning.message,
393 "Table pipe style should be no leading or trailing"
394 );
395
396 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
398
399 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
400 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401 let result = rule.fix(&ctx).unwrap();
402
403 log::info!("Actual leading_and_trailing result:\n{}", result.replace('\n', "\\n"));
405
406 let expected =
408 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
409
410 assert_eq!(result, expected);
411 }
412
413 #[test]
414 fn test_md055_check_finds_delimiter_row_issues() {
415 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
417
418 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
419 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
420 let warnings = rule.check(&ctx).unwrap();
421
422 assert_eq!(warnings.len(), 3);
424
425 let delimiter_warning = &warnings[1];
427 assert_eq!(delimiter_warning.line, 2);
428 assert_eq!(
429 delimiter_warning.message,
430 "Table pipe style should be no leading or trailing"
431 );
432 }
433
434 #[test]
435 fn test_md055_real_world_example() {
436 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
438
439 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.";
440 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441 let result = rule.fix(&ctx).unwrap();
442
443 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.";
445
446 assert_eq!(result, expected);
447
448 let warnings = rule.check(&ctx).unwrap();
450 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); }
458
459 #[test]
460 fn test_md055_invalid_style() {
461 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 |";
465 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
466 let result = rule.fix(&ctx).unwrap();
467
468 log::info!("Actual result with invalid style:\n{}", result.replace('\n', "\\n"));
470
471 let expected =
473 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
474
475 assert_eq!(result, expected);
477
478 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
480 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.fix(&ctx2).unwrap();
482
483 let expected =
485 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
486 assert_eq!(result, expected);
487
488 let warnings = rule.check(&ctx2).unwrap();
490
491 assert_eq!(warnings.len(), 3);
494 }
495
496 #[test]
497 fn test_underflow_protection() {
498 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
500
501 let result = rule.fix_table_row("", "leading_and_trailing");
503 assert_eq!(result, "");
504
505 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
507 assert_eq!(result, "no pipes here");
508
509 let result = rule.fix_table_row("|", "leading_and_trailing");
511 assert!(!result.is_empty());
513 }
514}