1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::TableUtils;
4
5#[derive(Debug, Clone)]
10pub struct MD056TableColumnCount;
11
12impl Default for MD056TableColumnCount {
13 fn default() -> Self {
14 MD056TableColumnCount
15 }
16}
17
18impl MD056TableColumnCount {
19 fn fix_table_row_content(
21 &self,
22 row_content: &str,
23 expected_count: usize,
24 flavor: crate::config::MarkdownFlavor,
25 table_block: &crate::utils::table_utils::TableBlock,
26 line_index: usize,
27 original_line: &str,
28 ) -> Option<String> {
29 let current_count = TableUtils::count_cells_with_flavor(row_content, flavor);
30
31 if current_count == expected_count || current_count == 0 {
32 return None;
33 }
34
35 if flavor == crate::config::MarkdownFlavor::Standard && current_count > expected_count {
37 let escaped_row = TableUtils::escape_pipes_in_inline_code(row_content);
38 let escaped_count = TableUtils::count_cells_with_flavor(&escaped_row, flavor);
39
40 if escaped_count == expected_count {
41 let fixed = escaped_row.trim().to_string();
42 return Some(self.restore_prefixes(&fixed, table_block, line_index, original_line));
43 }
44
45 if escaped_count < current_count
46 && let Some(fixed) = self.fix_row_by_truncation(&escaped_row, expected_count, flavor)
47 {
48 return Some(self.restore_prefixes(&fixed, table_block, line_index, original_line));
49 }
50 }
51
52 let fixed = self.fix_row_by_truncation(row_content, expected_count, flavor)?;
53 Some(self.restore_prefixes(&fixed, table_block, line_index, original_line))
54 }
55
56 fn restore_prefixes(
58 &self,
59 fixed_content: &str,
60 table_block: &crate::utils::table_utils::TableBlock,
61 line_index: usize,
62 original_line: &str,
63 ) -> String {
64 let (blockquote_prefix, _) = TableUtils::extract_blockquote_prefix(original_line);
66
67 if let Some(ref list_ctx) = table_block.list_context {
69 if line_index == 0 {
70 format!("{blockquote_prefix}{}{fixed_content}", list_ctx.list_prefix)
72 } else {
73 let indent = " ".repeat(list_ctx.content_indent);
75 format!("{blockquote_prefix}{indent}{fixed_content}")
76 }
77 } else {
78 if blockquote_prefix.is_empty() {
80 fixed_content.to_string()
81 } else {
82 format!("{blockquote_prefix}{fixed_content}")
83 }
84 }
85 }
86
87 fn fix_row_by_truncation(
89 &self,
90 row: &str,
91 expected_count: usize,
92 flavor: crate::config::MarkdownFlavor,
93 ) -> Option<String> {
94 let current_count = TableUtils::count_cells_with_flavor(row, flavor);
95
96 if current_count == expected_count || current_count == 0 {
97 return None;
98 }
99
100 let trimmed = row.trim();
101 let has_leading_pipe = trimmed.starts_with('|');
102 let has_trailing_pipe = trimmed.ends_with('|');
103
104 let cells = Self::split_row_into_cells(trimmed, flavor);
106
107 let mut cell_contents: Vec<&str> = Vec::new();
108 for (i, cell) in cells.iter().enumerate() {
109 if (i == 0 && cell.trim().is_empty() && has_leading_pipe)
111 || (i == cells.len() - 1 && cell.trim().is_empty() && has_trailing_pipe)
112 {
113 continue;
114 }
115 cell_contents.push(cell.trim());
116 }
117
118 match current_count.cmp(&expected_count) {
120 std::cmp::Ordering::Greater => {
121 cell_contents.truncate(expected_count);
123 }
124 std::cmp::Ordering::Less => {
125 while cell_contents.len() < expected_count {
127 cell_contents.push("");
128 }
129 }
130 std::cmp::Ordering::Equal => {
131 }
133 }
134
135 let mut result = String::new();
137 if has_leading_pipe {
138 result.push('|');
139 }
140
141 for (i, cell) in cell_contents.iter().enumerate() {
142 result.push_str(&format!(" {cell} "));
143 if i < cell_contents.len() - 1 || has_trailing_pipe {
144 result.push('|');
145 }
146 }
147
148 Some(result)
149 }
150
151 fn split_row_into_cells(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
156 let masked = TableUtils::mask_pipes_for_table_parsing(row);
158
159 let final_masked = if flavor == crate::config::MarkdownFlavor::MkDocs {
161 TableUtils::mask_pipes_in_inline_code(&masked)
162 } else {
163 masked
164 };
165
166 let masked_parts: Vec<&str> = final_masked.split('|').collect();
169 let mut cells = Vec::new();
170 let mut pos = 0;
171
172 for masked_part in masked_parts {
173 let cell_len = masked_part.len();
174 if pos + cell_len <= row.len() {
175 cells.push(row[pos..pos + cell_len].to_string());
176 } else {
177 cells.push(masked_part.to_string());
178 }
179 pos += cell_len + 1; }
181
182 cells
183 }
184}
185
186impl Rule for MD056TableColumnCount {
187 fn name(&self) -> &'static str {
188 "MD056"
189 }
190
191 fn description(&self) -> &'static str {
192 "Table column count should be consistent"
193 }
194
195 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
196 !ctx.likely_has_tables()
198 }
199
200 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
201 let content = ctx.content;
202 let flavor = ctx.flavor;
203 let mut warnings = Vec::new();
204
205 if content.is_empty() || !content.contains('|') {
207 return Ok(Vec::new());
208 }
209
210 let lines: Vec<&str> = content.lines().collect();
211
212 let table_blocks = &ctx.table_blocks;
214
215 for table_block in table_blocks {
216 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
218 .chain(std::iter::once(table_block.delimiter_line))
219 .chain(table_block.content_lines.iter().copied())
220 .collect();
221
222 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
224 let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
225
226 if expected_count == 0 {
227 continue; }
229
230 let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1; let mut fixed_table_lines: Vec<String> = Vec::with_capacity(all_line_indices.len());
237 for (i, &line_idx) in all_line_indices.iter().enumerate() {
238 let line = lines[line_idx];
239 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
240 let fixed_line = self
241 .fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
242 .unwrap_or_else(|| line.to_string());
243 if line_idx < lines.len() - 1 {
244 fixed_table_lines.push(format!("{fixed_line}\n"));
245 } else {
246 fixed_table_lines.push(fixed_line);
247 }
248 }
249 let table_replacement = fixed_table_lines.concat();
250 let table_range = ctx.line_index.multi_line_range(table_start_line, table_end_line);
251
252 for (i, &line_idx) in all_line_indices.iter().enumerate() {
254 let line = lines[line_idx];
255 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
256 let count = TableUtils::count_cells_with_flavor(row_content, flavor);
257
258 if count > 0 && count != expected_count {
259 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
261
262 warnings.push(LintWarning {
265 rule_name: Some(self.name().to_string()),
266 message: format!("Table row has {count} cells, but expected {expected_count}"),
267 line: start_line,
268 column: start_col,
269 end_line,
270 end_column: end_col,
271 severity: Severity::Warning,
272 fix: Some(Fix {
273 range: table_range.clone(),
274 replacement: table_replacement.clone(),
275 }),
276 });
277 }
278 }
279 }
280
281 Ok(warnings)
282 }
283
284 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
285 let content = ctx.content;
286 let flavor = ctx.flavor;
287 let lines: Vec<&str> = content.lines().collect();
288 let table_blocks = &ctx.table_blocks;
289
290 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
291
292 for table_block in table_blocks {
293 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
295 .chain(std::iter::once(table_block.delimiter_line))
296 .chain(table_block.content_lines.iter().copied())
297 .collect();
298
299 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
301 let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
302
303 if expected_count == 0 {
304 continue; }
306
307 for (i, &line_idx) in all_line_indices.iter().enumerate() {
309 let line = lines[line_idx];
310 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
311 if let Some(fixed_line) =
312 self.fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
313 {
314 result_lines[line_idx] = fixed_line;
315 }
316 }
317 }
318
319 let mut fixed = result_lines.join("\n");
320 if content.ends_with('\n') && !fixed.ends_with('\n') {
322 fixed.push('\n');
323 }
324 Ok(fixed)
325 }
326
327 fn as_any(&self) -> &dyn std::any::Any {
328 self
329 }
330
331 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
332 where
333 Self: Sized,
334 {
335 Box::new(MD056TableColumnCount)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::lint_context::LintContext;
343
344 #[test]
345 fn test_valid_table() {
346 let rule = MD056TableColumnCount;
347 let content = "| Header 1 | Header 2 | Header 3 |
348|----------|----------|----------|
349| Cell 1 | Cell 2 | Cell 3 |
350| Cell 4 | Cell 5 | Cell 6 |";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353
354 assert_eq!(result.len(), 0);
355 }
356
357 #[test]
358 fn test_too_few_columns() {
359 let rule = MD056TableColumnCount;
360 let content = "| Header 1 | Header 2 | Header 3 |
361|----------|----------|----------|
362| Cell 1 | Cell 2 |
363| Cell 4 | Cell 5 | Cell 6 |";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let result = rule.check(&ctx).unwrap();
366
367 assert_eq!(result.len(), 1);
368 assert_eq!(result[0].line, 3);
369 assert!(result[0].message.contains("has 2 cells, but expected 3"));
370 }
371
372 #[test]
373 fn test_too_many_columns() {
374 let rule = MD056TableColumnCount;
375 let content = "| Header 1 | Header 2 |
376|----------|----------|
377| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
378| Cell 5 | Cell 6 |";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381
382 assert_eq!(result.len(), 1);
383 assert_eq!(result[0].line, 3);
384 assert!(result[0].message.contains("has 4 cells, but expected 2"));
385 }
386
387 #[test]
388 fn test_delimiter_row_mismatch() {
389 let rule = MD056TableColumnCount;
390 let content = "| Header 1 | Header 2 | Header 3 |
391|----------|----------|
392| Cell 1 | Cell 2 | Cell 3 |";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394 let result = rule.check(&ctx).unwrap();
395
396 assert_eq!(result.len(), 1);
397 assert_eq!(result[0].line, 2);
398 assert!(result[0].message.contains("has 2 cells, but expected 3"));
399 }
400
401 #[test]
402 fn test_fix_too_few_columns() {
403 let rule = MD056TableColumnCount;
404 let content = "| Header 1 | Header 2 | Header 3 |
405|----------|----------|----------|
406| Cell 1 | Cell 2 |
407| Cell 4 | Cell 5 | Cell 6 |";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409 let fixed = rule.fix(&ctx).unwrap();
410
411 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
412 }
413
414 #[test]
415 fn test_fix_too_many_columns() {
416 let rule = MD056TableColumnCount;
417 let content = "| Header 1 | Header 2 |
418|----------|----------|
419| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
420| Cell 5 | Cell 6 |";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let fixed = rule.fix(&ctx).unwrap();
423
424 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
425 assert!(!fixed.contains("Cell 3"));
426 assert!(!fixed.contains("Cell 4"));
427 }
428
429 #[test]
430 fn test_no_leading_pipe() {
431 let rule = MD056TableColumnCount;
432 let content = "Header 1 | Header 2 | Header 3 |
433---------|----------|----------|
434Cell 1 | Cell 2 |
435Cell 4 | Cell 5 | Cell 6 |";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438
439 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 3);
441 }
442
443 #[test]
444 fn test_no_trailing_pipe() {
445 let rule = MD056TableColumnCount;
446 let content = "| Header 1 | Header 2 | Header 3
447|----------|----------|----------
448| Cell 1 | Cell 2
449| Cell 4 | Cell 5 | Cell 6";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452
453 assert_eq!(result.len(), 1);
454 assert_eq!(result[0].line, 3);
455 }
456
457 #[test]
458 fn test_no_pipes_at_all() {
459 let rule = MD056TableColumnCount;
460 let content = "This is not a table
461Just regular text
462No pipes here";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465
466 assert_eq!(result.len(), 0);
467 }
468
469 #[test]
470 fn test_empty_cells() {
471 let rule = MD056TableColumnCount;
472 let content = "| Header 1 | Header 2 | Header 3 |
473|----------|----------|----------|
474| | | |
475| Cell 1 | | Cell 3 |";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 0);
480 }
481
482 #[test]
483 fn test_multiple_tables() {
484 let rule = MD056TableColumnCount;
485 let content = "| Table 1 Col 1 | Table 1 Col 2 |
486|----------------|----------------|
487| Data 1 | Data 2 |
488
489Some text in between.
490
491| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
492|----------------|----------------|----------------|
493| Data 3 | Data 4 |
494| Data 5 | Data 6 | Data 7 |";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497
498 assert_eq!(result.len(), 1);
499 assert_eq!(result[0].line, 9);
500 assert!(result[0].message.contains("has 2 cells, but expected 3"));
501 }
502
503 #[test]
504 fn test_table_with_escaped_pipes() {
505 let rule = MD056TableColumnCount;
506
507 let content = "| Command | Description |
509|---------|-------------|
510| `echo \\| grep` | Pipe example |
511| `ls` | List files |";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let result = rule.check(&ctx).unwrap();
514 assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
515
516 let content_double = "| Command | Description |
518|---------|-------------|
519| `echo \\\\| grep` | Pipe example |
520| `ls` | List files |";
521 let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
522 let result2 = rule.check(&ctx2).unwrap();
523 assert_eq!(result2.len(), 1, "double backslash \\\\| should split cells");
525 }
526
527 #[test]
528 fn test_empty_content() {
529 let rule = MD056TableColumnCount;
530 let content = "";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533
534 assert_eq!(result.len(), 0);
535 }
536
537 #[test]
538 fn test_code_block_with_table() {
539 let rule = MD056TableColumnCount;
540 let content = "```
541| This | Is | Code |
542|------|----|----|
543| Not | A | Table |
544```
545
546| Real | Table |
547|------|-------|
548| Data | Here |";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551
552 assert_eq!(result.len(), 0);
554 }
555
556 #[test]
557 fn test_fix_preserves_pipe_style() {
558 let rule = MD056TableColumnCount;
559 let content = "| Header 1 | Header 2 | Header 3
561|----------|----------|----------
562| Cell 1 | Cell 2";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let fixed = rule.fix(&ctx).unwrap();
565
566 let lines: Vec<&str> = fixed.lines().collect();
567 assert!(!lines[2].ends_with('|'));
568 assert!(lines[2].contains("Cell 1"));
569 assert!(lines[2].contains("Cell 2"));
570 }
571
572 #[test]
573 fn test_single_column_table() {
574 let rule = MD056TableColumnCount;
575 let content = "| Header |
576|---------|
577| Cell 1 |
578| Cell 2 |";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581
582 assert_eq!(result.len(), 0);
583 }
584
585 #[test]
586 fn test_complex_delimiter_row() {
587 let rule = MD056TableColumnCount;
588 let content = "| Left | Center | Right |
589|:-----|:------:|------:|
590| L | C | R |
591| Left | Center |";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594
595 assert_eq!(result.len(), 1);
596 assert_eq!(result[0].line, 4);
597 }
598
599 #[test]
600 fn test_unicode_content() {
601 let rule = MD056TableColumnCount;
602 let content = "| 名前 | 年齢 | 都市 |
603|------|------|------|
604| 田中 | 25 | 東京 |
605| 佐藤 | 30 |";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608
609 assert_eq!(result.len(), 1);
610 assert_eq!(result[0].line, 4);
611 }
612
613 #[test]
614 fn test_very_long_cells() {
615 let rule = MD056TableColumnCount;
616 let content = "| Short | Very very very very very very very very very very long header | Another |
617|-------|--------------------------------------------------------------|---------|
618| Data | This is an extremely long cell content that goes on and on |";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621
622 assert_eq!(result.len(), 1);
623 assert!(result[0].message.contains("has 2 cells, but expected 3"));
624 }
625
626 #[test]
627 fn test_fix_with_newline_ending() {
628 let rule = MD056TableColumnCount;
629 let content = "| A | B | C |
630|---|---|---|
631| 1 | 2 |
632";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let fixed = rule.fix(&ctx).unwrap();
635
636 assert!(fixed.ends_with('\n'));
637 assert!(fixed.contains("| 1 | 2 | |"));
638 }
639
640 #[test]
641 fn test_fix_without_newline_ending() {
642 let rule = MD056TableColumnCount;
643 let content = "| A | B | C |
644|---|---|---|
645| 1 | 2 |";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let fixed = rule.fix(&ctx).unwrap();
648
649 assert!(!fixed.ends_with('\n'));
650 assert!(fixed.contains("| 1 | 2 | |"));
651 }
652
653 #[test]
654 fn test_blockquote_table_column_mismatch() {
655 let rule = MD056TableColumnCount;
656 let content = "> | Header 1 | Header 2 | Header 3 |
657> |----------|----------|----------|
658> | Cell 1 | Cell 2 |";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661
662 assert_eq!(result.len(), 1);
663 assert_eq!(result[0].line, 3);
664 assert!(result[0].message.contains("has 2 cells, but expected 3"));
665 }
666
667 #[test]
668 fn test_fix_blockquote_table_preserves_prefix() {
669 let rule = MD056TableColumnCount;
670 let content = "> | Header 1 | Header 2 | Header 3 |
671> |----------|----------|----------|
672> | Cell 1 | Cell 2 |
673> | Cell 4 | Cell 5 | Cell 6 |";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let fixed = rule.fix(&ctx).unwrap();
676
677 for line in fixed.lines() {
679 assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
680 }
681 assert!(fixed.contains("> | Cell 1 | Cell 2 | |"));
683 }
684
685 #[test]
686 fn test_fix_nested_blockquote_table() {
687 let rule = MD056TableColumnCount;
688 let content = ">> | A | B | C |
689>> |---|---|---|
690>> | 1 | 2 |";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let fixed = rule.fix(&ctx).unwrap();
693
694 for line in fixed.lines() {
696 assert!(
697 line.starts_with(">> "),
698 "Line should preserve nested blockquote prefix: {line}"
699 );
700 }
701 assert!(fixed.contains(">> | 1 | 2 | |"));
702 }
703
704 #[test]
705 fn test_blockquote_table_too_many_columns() {
706 let rule = MD056TableColumnCount;
707 let content = "> | A | B |
708> |---|---|
709> | 1 | 2 | 3 | 4 |";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let fixed = rule.fix(&ctx).unwrap();
712
713 assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
715 assert!(fixed.contains("> | 1 | 2 |"));
716 assert!(!fixed.contains("| 3 |"));
717 }
718}