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