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 for (i, &line_idx) in all_line_indices.iter().enumerate() {
177 let line = lines[line_idx];
178 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
179 let count = TableUtils::count_cells_with_flavor(row_content, flavor);
180
181 if count > 0 && count != expected_count {
182 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
183
184 let fixed_line = self
187 .fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
188 .unwrap_or_else(|| line.to_string());
189 let row_range =
190 ctx.line_index
191 .line_col_to_byte_range_with_length(line_idx + 1, 1, line.chars().count());
192
193 warnings.push(LintWarning {
194 rule_name: Some(self.name().to_string()),
195 message: format!("Table row has {count} cells, but expected {expected_count}"),
196 line: start_line,
197 column: start_col,
198 end_line,
199 end_column: end_col,
200 severity: Severity::Warning,
201 fix: Some(Fix {
202 range: row_range,
203 replacement: fixed_line,
204 }),
205 });
206 }
207 }
208 }
209
210 Ok(warnings)
211 }
212
213 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214 if self.should_skip(ctx) {
215 return Ok(ctx.content.to_string());
216 }
217 let warnings = self.check(ctx)?;
218 if warnings.is_empty() {
219 return Ok(ctx.content.to_string());
220 }
221 let warnings =
222 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
223 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
224 }
225
226 fn as_any(&self) -> &dyn std::any::Any {
227 self
228 }
229
230 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
231 where
232 Self: Sized,
233 {
234 Box::new(MD056TableColumnCount)
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::lint_context::LintContext;
242
243 #[test]
244 fn test_valid_table() {
245 let rule = MD056TableColumnCount;
246 let content = "| Header 1 | Header 2 | Header 3 |
247|----------|----------|----------|
248| Cell 1 | Cell 2 | Cell 3 |
249| Cell 4 | Cell 5 | Cell 6 |";
250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
251 let result = rule.check(&ctx).unwrap();
252
253 assert_eq!(result.len(), 0);
254 }
255
256 #[test]
257 fn test_too_few_columns() {
258 let rule = MD056TableColumnCount;
259 let content = "| Header 1 | Header 2 | Header 3 |
260|----------|----------|----------|
261| Cell 1 | Cell 2 |
262| Cell 4 | Cell 5 | Cell 6 |";
263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
264 let result = rule.check(&ctx).unwrap();
265
266 assert_eq!(result.len(), 1);
267 assert_eq!(result[0].line, 3);
268 assert!(result[0].message.contains("has 2 cells, but expected 3"));
269 }
270
271 #[test]
272 fn test_too_many_columns() {
273 let rule = MD056TableColumnCount;
274 let content = "| Header 1 | Header 2 |
275|----------|----------|
276| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
277| Cell 5 | Cell 6 |";
278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279 let result = rule.check(&ctx).unwrap();
280
281 assert_eq!(result.len(), 1);
282 assert_eq!(result[0].line, 3);
283 assert!(result[0].message.contains("has 4 cells, but expected 2"));
284 }
285
286 #[test]
287 fn test_delimiter_row_mismatch() {
288 let rule = MD056TableColumnCount;
289 let content = "| Header 1 | Header 2 | Header 3 |
290|----------|----------|
291| Cell 1 | Cell 2 | Cell 3 |";
292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
293 let result = rule.check(&ctx).unwrap();
294
295 assert_eq!(result.len(), 1);
296 assert_eq!(result[0].line, 2);
297 assert!(result[0].message.contains("has 2 cells, but expected 3"));
298 }
299
300 #[test]
301 fn test_fix_too_few_columns() {
302 let rule = MD056TableColumnCount;
303 let content = "| Header 1 | Header 2 | Header 3 |
304|----------|----------|----------|
305| Cell 1 | Cell 2 |
306| Cell 4 | Cell 5 | Cell 6 |";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let fixed = rule.fix(&ctx).unwrap();
309
310 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
311 }
312
313 #[test]
314 fn test_fix_too_many_columns() {
315 let rule = MD056TableColumnCount;
316 let content = "| Header 1 | Header 2 |
317|----------|----------|
318| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
319| Cell 5 | Cell 6 |";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321 let fixed = rule.fix(&ctx).unwrap();
322
323 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
324 assert!(!fixed.contains("Cell 3"));
325 assert!(!fixed.contains("Cell 4"));
326 }
327
328 #[test]
329 fn test_no_leading_pipe() {
330 let rule = MD056TableColumnCount;
331 let content = "Header 1 | Header 2 | Header 3 |
332---------|----------|----------|
333Cell 1 | Cell 2 |
334Cell 4 | Cell 5 | Cell 6 |";
335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336 let result = rule.check(&ctx).unwrap();
337
338 assert_eq!(result.len(), 1);
339 assert_eq!(result[0].line, 3);
340 }
341
342 #[test]
343 fn test_no_trailing_pipe() {
344 let rule = MD056TableColumnCount;
345 let content = "| Header 1 | Header 2 | Header 3
346|----------|----------|----------
347| Cell 1 | Cell 2
348| Cell 4 | 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 }
355
356 #[test]
357 fn test_no_pipes_at_all() {
358 let rule = MD056TableColumnCount;
359 let content = "This is not a table
360Just regular text
361No pipes here";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364
365 assert_eq!(result.len(), 0);
366 }
367
368 #[test]
369 fn test_empty_cells() {
370 let rule = MD056TableColumnCount;
371 let content = "| Header 1 | Header 2 | Header 3 |
372|----------|----------|----------|
373| | | |
374| Cell 1 | | Cell 3 |";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377
378 assert_eq!(result.len(), 0);
379 }
380
381 #[test]
382 fn test_multiple_tables() {
383 let rule = MD056TableColumnCount;
384 let content = "| Table 1 Col 1 | Table 1 Col 2 |
385|----------------|----------------|
386| Data 1 | Data 2 |
387
388Some text in between.
389
390| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
391|----------------|----------------|----------------|
392| Data 3 | Data 4 |
393| Data 5 | Data 6 | Data 7 |";
394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395 let result = rule.check(&ctx).unwrap();
396
397 assert_eq!(result.len(), 1);
398 assert_eq!(result[0].line, 9);
399 assert!(result[0].message.contains("has 2 cells, but expected 3"));
400 }
401
402 #[test]
403 fn test_table_with_escaped_pipes() {
404 let rule = MD056TableColumnCount;
405
406 let content = "| Command | Description |
408|---------|-------------|
409| `echo \\| grep` | Pipe example |
410| `ls` | List files |";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
414
415 let content_double = "| Command | Description |
417|---------|-------------|
418| `echo \\\\| grep` | Pipe example |
419| `ls` | List files |";
420 let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
421 let result2 = rule.check(&ctx2).unwrap();
422 assert_eq!(result2.len(), 0, "pipes inside code spans should not split cells");
424 }
425
426 #[test]
427 fn test_empty_content() {
428 let rule = MD056TableColumnCount;
429 let content = "";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432
433 assert_eq!(result.len(), 0);
434 }
435
436 #[test]
437 fn test_code_block_with_table() {
438 let rule = MD056TableColumnCount;
439 let content = "```
440| This | Is | Code |
441|------|----|----|
442| Not | A | Table |
443```
444
445| Real | Table |
446|------|-------|
447| Data | Here |";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450
451 assert_eq!(result.len(), 0);
453 }
454
455 #[test]
456 fn test_fix_preserves_pipe_style() {
457 let rule = MD056TableColumnCount;
458 let content = "| Header 1 | Header 2 | Header 3
460|----------|----------|----------
461| Cell 1 | Cell 2";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let fixed = rule.fix(&ctx).unwrap();
464
465 let lines: Vec<&str> = fixed.lines().collect();
466 assert!(!lines[2].ends_with('|'));
467 assert!(lines[2].contains("Cell 1"));
468 assert!(lines[2].contains("Cell 2"));
469 }
470
471 #[test]
472 fn test_single_column_table() {
473 let rule = MD056TableColumnCount;
474 let content = "| Header |
475|---------|
476| Cell 1 |
477| Cell 2 |";
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_complex_delimiter_row() {
486 let rule = MD056TableColumnCount;
487 let content = "| Left | Center | Right |
488|:-----|:------:|------:|
489| L | C | R |
490| Left | Center |";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493
494 assert_eq!(result.len(), 1);
495 assert_eq!(result[0].line, 4);
496 }
497
498 #[test]
499 fn test_unicode_content() {
500 let rule = MD056TableColumnCount;
501 let content = "| 名前 | 年齢 | 都市 |
502|------|------|------|
503| 田中 | 25 | 東京 |
504| 佐藤 | 30 |";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507
508 assert_eq!(result.len(), 1);
509 assert_eq!(result[0].line, 4);
510 }
511
512 #[test]
513 fn test_very_long_cells() {
514 let rule = MD056TableColumnCount;
515 let content = "| Short | Very very very very very very very very very very long header | Another |
516|-------|--------------------------------------------------------------|---------|
517| Data | This is an extremely long cell content that goes on and on |";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.check(&ctx).unwrap();
520
521 assert_eq!(result.len(), 1);
522 assert!(result[0].message.contains("has 2 cells, but expected 3"));
523 }
524
525 #[test]
526 fn test_fix_with_newline_ending() {
527 let rule = MD056TableColumnCount;
528 let content = "| A | B | C |
529|---|---|---|
530| 1 | 2 |
531";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let fixed = rule.fix(&ctx).unwrap();
534
535 assert!(fixed.ends_with('\n'));
536 assert!(fixed.contains("| 1 | 2 | |"));
537 }
538
539 #[test]
540 fn test_fix_without_newline_ending() {
541 let rule = MD056TableColumnCount;
542 let content = "| A | B | C |
543|---|---|---|
544| 1 | 2 |";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let fixed = rule.fix(&ctx).unwrap();
547
548 assert!(!fixed.ends_with('\n'));
549 assert!(fixed.contains("| 1 | 2 | |"));
550 }
551
552 #[test]
553 fn test_blockquote_table_column_mismatch() {
554 let rule = MD056TableColumnCount;
555 let content = "> | Header 1 | Header 2 | Header 3 |
556> |----------|----------|----------|
557> | Cell 1 | Cell 2 |";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559 let result = rule.check(&ctx).unwrap();
560
561 assert_eq!(result.len(), 1);
562 assert_eq!(result[0].line, 3);
563 assert!(result[0].message.contains("has 2 cells, but expected 3"));
564 }
565
566 #[test]
567 fn test_fix_blockquote_table_preserves_prefix() {
568 let rule = MD056TableColumnCount;
569 let content = "> | Header 1 | Header 2 | Header 3 |
570> |----------|----------|----------|
571> | Cell 1 | Cell 2 |
572> | Cell 4 | Cell 5 | Cell 6 |";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let fixed = rule.fix(&ctx).unwrap();
575
576 for line in fixed.lines() {
578 assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
579 }
580 assert!(fixed.contains("> | Cell 1 | Cell 2 | |"));
582 }
583
584 #[test]
585 fn test_fix_nested_blockquote_table() {
586 let rule = MD056TableColumnCount;
587 let content = ">> | A | B | C |
588>> |---|---|---|
589>> | 1 | 2 |";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let fixed = rule.fix(&ctx).unwrap();
592
593 for line in fixed.lines() {
595 assert!(
596 line.starts_with(">> "),
597 "Line should preserve nested blockquote prefix: {line}"
598 );
599 }
600 assert!(fixed.contains(">> | 1 | 2 | |"));
601 }
602
603 #[test]
604 fn test_blockquote_table_too_many_columns() {
605 let rule = MD056TableColumnCount;
606 let content = "> | A | B |
607> |---|---|
608> | 1 | 2 | 3 | 4 |";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let fixed = rule.fix(&ctx).unwrap();
611
612 assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
614 assert!(fixed.contains("> | 1 | 2 |"));
615 assert!(!fixed.contains("| 3 |"));
616 }
617
618 fn assert_fix_roundtrip(content: &str) {
621 let rule = MD056TableColumnCount;
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
625 let remaining = rule.check(&ctx2).unwrap();
626 assert!(
627 remaining.is_empty(),
628 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
629 );
630 }
631
632 #[test]
633 fn test_roundtrip_too_few_columns() {
634 assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |");
635 }
636
637 #[test]
638 fn test_roundtrip_too_many_columns() {
639 assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 | 3 | 4 |");
640 }
641
642 #[test]
643 fn test_roundtrip_with_trailing_newline() {
644 assert_fix_roundtrip("| A | B | C |\n|---|---|---|\n| 1 | 2 |\n");
645 }
646
647 #[test]
648 fn test_roundtrip_blockquote_table() {
649 assert_fix_roundtrip("> | A | B | C |\n> |---|---|---|\n> | 1 | 2 |");
650 }
651
652 #[test]
653 fn test_roundtrip_clean_table() {
654 assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |");
655 }
656
657 #[test]
658 fn test_roundtrip_multiple_tables() {
659 assert_fix_roundtrip("| A | B |\n|---|---|\n| 1 | 2 |\n\nText\n\n| C | D | E |\n|---|---|---|\n| 3 | 4 |");
660 }
661}