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