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.content.contains('|')
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 if content.is_empty() || !content.contains('|') {
186 return Ok(Vec::new());
187 }
188
189 let lines: Vec<&str> = content.lines().collect();
190
191 let configured_style = match self.config.style.as_str() {
193 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
194 self.config.style.as_str()
195 }
196 _ => {
197 "leading_and_trailing"
199 }
200 };
201
202 let table_blocks = TableUtils::find_table_blocks(content, ctx);
204
205 for table_block in table_blocks {
207 let mut table_style = None;
208
209 if configured_style == "consistent" {
211 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
213 table_style = Some(style);
214 } else {
215 for &line_idx in &table_block.content_lines {
217 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
218 table_style = Some(style);
219 break;
220 }
221 }
222 }
223 }
224
225 let target_style = if configured_style == "consistent" {
227 table_style.unwrap_or("leading_and_trailing")
228 } else {
229 configured_style
230 };
231
232 let all_lines = std::iter::once(table_block.header_line)
234 .chain(std::iter::once(table_block.delimiter_line))
235 .chain(table_block.content_lines.iter().copied());
236
237 for line_idx in all_lines {
238 let line = lines[line_idx];
239 if let Some(current_style) = TableUtils::determine_pipe_style(line) {
240 let needs_fixing = current_style != target_style;
242
243 if needs_fixing {
244 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
245
246 let message = format!(
247 "Table pipe style should be {}",
248 match target_style {
249 "leading_and_trailing" => "leading and trailing",
250 "no_leading_or_trailing" => "no leading or trailing",
251 "leading_only" => "leading only",
252 "trailing_only" => "trailing only",
253 _ => target_style,
254 }
255 );
256
257 let fixed_line = self.fix_table_row(line, target_style);
258 warnings.push(LintWarning {
259 rule_name: Some(self.name()),
260 severity: Severity::Warning,
261 message,
262 line: start_line,
263 column: start_col,
264 end_line,
265 end_column: end_col,
266 fix: Some(crate::rule::Fix {
267 range: line_index.whole_line_range(line_idx + 1),
268 replacement: if line_idx < lines.len() - 1 {
269 format!("{fixed_line}\n")
270 } else {
271 fixed_line
272 },
273 }),
274 });
275 }
276 }
277 }
278 }
279
280 Ok(warnings)
281 }
282
283 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
284 let content = ctx.content;
285 let lines: Vec<&str> = content.lines().collect();
286
287 let configured_style = match self.config.style.as_str() {
289 "leading_and_trailing" | "no_leading_or_trailing" | "leading_only" | "trailing_only" | "consistent" => {
290 self.config.style.as_str()
291 }
292 _ => {
293 "leading_and_trailing"
295 }
296 };
297
298 let table_blocks = TableUtils::find_table_blocks(content, ctx);
300
301 let mut result_lines = lines.iter().map(|&s| s.to_string()).collect::<Vec<String>>();
303
304 for table_block in table_blocks {
306 let mut table_style = None;
307
308 if configured_style == "consistent" {
310 if let Some(style) = TableUtils::determine_pipe_style(lines[table_block.header_line]) {
312 table_style = Some(style);
313 } else {
314 for &line_idx in &table_block.content_lines {
316 if let Some(style) = TableUtils::determine_pipe_style(lines[line_idx]) {
317 table_style = Some(style);
318 break;
319 }
320 }
321 }
322 }
323
324 let target_style = if configured_style == "consistent" {
326 table_style.unwrap_or("leading_and_trailing")
327 } else {
328 configured_style
329 };
330
331 let all_lines = std::iter::once(table_block.header_line)
333 .chain(std::iter::once(table_block.delimiter_line))
334 .chain(table_block.content_lines.iter().copied());
335
336 for line_idx in all_lines {
337 let line = lines[line_idx];
338 let fixed_line = self.fix_table_row(line, target_style);
339 result_lines[line_idx] = fixed_line;
340 }
341 }
342
343 Ok(result_lines.join("\n"))
344 }
345
346 fn as_any(&self) -> &dyn std::any::Any {
347 self
348 }
349
350 fn default_config_section(&self) -> Option<(String, toml::Value)> {
351 let json_value = serde_json::to_value(&self.config).ok()?;
352 Some((
353 self.name().to_string(),
354 crate::rule_config_serde::json_to_toml_value(&json_value)?,
355 ))
356 }
357
358 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
359 where
360 Self: Sized,
361 {
362 let rule_config = crate::rule_config_serde::load_rule_config::<MD055Config>(config);
363 Box::new(Self::from_config_struct(rule_config))
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn test_md055_delimiter_row_handling() {
373 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
375
376 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
377 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
378 let result = rule.fix(&ctx).unwrap();
379
380 let expected = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
382
383 assert_eq!(result, expected);
384
385 let warnings = rule.check(&ctx).unwrap();
387 let delimiter_warning = &warnings[1]; assert_eq!(delimiter_warning.line, 2);
389 assert_eq!(
390 delimiter_warning.message,
391 "Table pipe style should be no leading or trailing"
392 );
393
394 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
396
397 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
398 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
399 let result = rule.fix(&ctx).unwrap();
400
401 log::info!("Actual leading_and_trailing result:\n{}", result.replace('\n', "\\n"));
403
404 let expected =
406 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
407
408 assert_eq!(result, expected);
409 }
410
411 #[test]
412 fn test_md055_check_finds_delimiter_row_issues() {
413 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
415
416 let content = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Data 1 | Data 2 | Data 3 |";
417 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
418 let warnings = rule.check(&ctx).unwrap();
419
420 assert_eq!(warnings.len(), 3);
422
423 let delimiter_warning = &warnings[1];
425 assert_eq!(delimiter_warning.line, 2);
426 assert_eq!(
427 delimiter_warning.message,
428 "Table pipe style should be no leading or trailing"
429 );
430 }
431
432 #[test]
433 fn test_md055_real_world_example() {
434 let rule = MD055TablePipeStyle::new("no_leading_or_trailing".to_string());
436
437 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.";
438 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
439 let result = rule.fix(&ctx).unwrap();
440
441 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.";
443
444 assert_eq!(result, expected);
445
446 let warnings = rule.check(&ctx).unwrap();
448 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); }
456
457 #[test]
458 fn test_md055_invalid_style() {
459 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 |";
463 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.fix(&ctx).unwrap();
465
466 log::info!("Actual result with invalid style:\n{}", result.replace('\n', "\\n"));
468
469 let expected =
471 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
472
473 assert_eq!(result, expected);
475
476 let content = "Header 1 | Header 2 | Header 3\n----------|----------|----------\nData 1 | Data 2 | Data 3";
478 let ctx2 = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
479 let result = rule.fix(&ctx2).unwrap();
480
481 let expected =
483 "| Header 1 | Header 2 | Header 3 |\n| ----------|----------|---------- |\n| Data 1 | Data 2 | Data 3 |";
484 assert_eq!(result, expected);
485
486 let warnings = rule.check(&ctx2).unwrap();
488
489 assert_eq!(warnings.len(), 3);
492 }
493
494 #[test]
495 fn test_underflow_protection() {
496 let rule = MD055TablePipeStyle::new("leading_and_trailing".to_string());
498
499 let result = rule.fix_table_row("", "leading_and_trailing");
501 assert_eq!(result, "");
502
503 let result = rule.fix_table_row("no pipes here", "leading_and_trailing");
505 assert_eq!(result, "no pipes here");
506
507 let result = rule.fix_table_row("|", "leading_and_trailing");
509 assert!(!result.is_empty());
511 }
512}