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()),
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 Ok(result_lines.join("\n"))
341 }
342
343 fn as_any(&self) -> &dyn std::any::Any {
344 self
345 }
346
347 fn default_config_section(&self) -> Option<(String, toml::Value)> {
348 let json_value = serde_json::to_value(&self.config).ok()?;
349 Some((
350 self.name().to_string(),
351 crate::rule_config_serde::json_to_toml_value(&json_value)?,
352 ))
353 }
354
355 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
356 where
357 Self: Sized,
358 {
359 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
360 Box::new(Self::from_config_struct(rule_config))
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_md055_delimiter_row_handling() {
370 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
372
373 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
374 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
375 let result = rule.fix(&ctx).unwrap();
376
377 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
379
380 assert_eq!(result, expected);
381
382 let warnings = rule.check(&ctx).unwrap();
384 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
386 assert_eq!(
387 delimiter_warning.message,
388 "Table pipe style should be no leading or trailing"
389 );
390
391 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
393
394 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
395 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
396 let result = rule.fix(&ctx).unwrap();
397
398 log::info!("Actual leading_and_trailing result:\n{}", result.replace('\n', "\\n"));
400
401 let expected =
403 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
404
405 assert_eq!(result, expected);
406 }
407
408 #[test]
409 fn test_md055_check_finds_delimiter_row_issues() {
410 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
412
413 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
414 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
415 let warnings = rule.check(&ctx).unwrap();
416
417 assert_eq!(warnings.len(), 3);
419
420 let delimiter_warning = &warnings[1];
422 assert_eq!(delimiter_warning.line, 2);
423 assert_eq!(
424 delimiter_warning.message,
425 "Table pipe style should be no leading or trailing"
426 );
427 }
428
429 #[test]
430 fn test_md055_real_world_example() {
431 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
433
434 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.";
435 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
436 let result = rule.fix(&ctx).unwrap();
437
438 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.";
440
441 assert_eq!(result, expected);
442
443 let warnings = rule.check(&ctx).unwrap();
445 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); }
453
454 #[test]
455 fn test_md055_invalid_style() {
456 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 |";
460 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
461 let result = rule.fix(&ctx).unwrap();
462
463 log::info!("Actual result with invalid style:\n{}", result.replace('\n', "\\n"));
465
466 let expected =
468 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
469
470 assert_eq!(result, expected);
472
473 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
475 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
476 let result = rule.fix(&ctx2).unwrap();
477
478 let expected =
480 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
481 assert_eq!(result, expected);
482
483 let warnings = rule.check(&ctx2).unwrap();
485
486 assert_eq!(warnings.len(), 3);
489 }
490
491 #[test]
492 fn test_underflow_protection() {
493 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
495
496 let result = rule.fix_table_row("", "leading_and_trailing");
498 assert_eq!(result, "");
499
500 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
502 assert_eq!(result, "no pipes here");
503
504 let result = rule.fix_table_row("|", "leading_and_trailing");
506 assert!(!result.is_empty());
508 }
509}