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 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 should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
135 !ctx.likely_has_tables()
137 }
138
139 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
140 let content = ctx.content;
141 let flavor = ctx.flavor;
142 let mut warnings = Vec::new();
143
144 if content.is_empty() || !content.contains('|') {
146 return Ok(Vec::new());
147 }
148
149 let lines = ctx.raw_lines();
150
151 let table_blocks = &ctx.table_blocks;
153
154 for table_block in table_blocks {
155 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
157 .chain(std::iter::once(table_block.delimiter_line))
158 .chain(table_block.content_lines.iter().copied())
159 .collect();
160
161 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
163 let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
164
165 if expected_count == 0 {
166 continue; }
168
169 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());
176 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 fixed_line = self
180 .fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
181 .unwrap_or_else(|| line.to_string());
182 if line_idx < lines.len() - 1 {
183 fixed_table_lines.push(format!("{fixed_line}\n"));
184 } else {
185 fixed_table_lines.push(fixed_line);
186 }
187 }
188 let table_replacement = fixed_table_lines.concat();
189 let table_range = ctx.line_index.multi_line_range(table_start_line, table_end_line);
190
191 for (i, &line_idx) in all_line_indices.iter().enumerate() {
193 let line = lines[line_idx];
194 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
195 let count = TableUtils::count_cells_with_flavor(row_content, flavor);
196
197 if count > 0 && count != expected_count {
198 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
200
201 warnings.push(LintWarning {
204 rule_name: Some(self.name().to_string()),
205 message: format!("Table row has {count} cells, but expected {expected_count}"),
206 line: start_line,
207 column: start_col,
208 end_line,
209 end_column: end_col,
210 severity: Severity::Warning,
211 fix: Some(Fix {
212 range: table_range.clone(),
213 replacement: table_replacement.clone(),
214 }),
215 });
216 }
217 }
218 }
219
220 Ok(warnings)
221 }
222
223 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
224 let content = ctx.content;
225 let flavor = ctx.flavor;
226 let lines = ctx.raw_lines();
227 let table_blocks = &ctx.table_blocks;
228
229 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
230
231 for table_block in table_blocks {
232 let all_line_indices: Vec<usize> = 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 .collect();
237
238 let header_content = TableUtils::extract_table_row_content(lines[table_block.header_line], table_block, 0);
240 let expected_count = TableUtils::count_cells_with_flavor(header_content, flavor);
241
242 if expected_count == 0 {
243 continue; }
245
246 for (i, &line_idx) in all_line_indices.iter().enumerate() {
248 let line_num = line_idx + 1;
249 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
250 continue;
251 }
252 let line = lines[line_idx];
253 let row_content = TableUtils::extract_table_row_content(line, table_block, i);
254 if let Some(fixed_line) =
255 self.fix_table_row_content(row_content, expected_count, flavor, table_block, i, line)
256 {
257 result_lines[line_idx] = fixed_line;
258 }
259 }
260 }
261
262 let mut fixed = result_lines.join("\n");
263 if content.ends_with('\n') && !fixed.ends_with('\n') {
265 fixed.push('\n');
266 }
267 Ok(fixed)
268 }
269
270 fn as_any(&self) -> &dyn std::any::Any {
271 self
272 }
273
274 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
275 where
276 Self: Sized,
277 {
278 Box::new(MD056TableColumnCount)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::lint_context::LintContext;
286
287 #[test]
288 fn test_valid_table() {
289 let rule = MD056TableColumnCount;
290 let content = "| Header 1 | Header 2 | Header 3 |
291|----------|----------|----------|
292| Cell 1 | Cell 2 | Cell 3 |
293| Cell 4 | Cell 5 | Cell 6 |";
294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
295 let result = rule.check(&ctx).unwrap();
296
297 assert_eq!(result.len(), 0);
298 }
299
300 #[test]
301 fn test_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 result = rule.check(&ctx).unwrap();
309
310 assert_eq!(result.len(), 1);
311 assert_eq!(result[0].line, 3);
312 assert!(result[0].message.contains("has 2 cells, but expected 3"));
313 }
314
315 #[test]
316 fn test_too_many_columns() {
317 let rule = MD056TableColumnCount;
318 let content = "| Header 1 | Header 2 |
319|----------|----------|
320| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
321| Cell 5 | Cell 6 |";
322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
323 let result = rule.check(&ctx).unwrap();
324
325 assert_eq!(result.len(), 1);
326 assert_eq!(result[0].line, 3);
327 assert!(result[0].message.contains("has 4 cells, but expected 2"));
328 }
329
330 #[test]
331 fn test_delimiter_row_mismatch() {
332 let rule = MD056TableColumnCount;
333 let content = "| Header 1 | Header 2 | Header 3 |
334|----------|----------|
335| Cell 1 | Cell 2 | Cell 3 |";
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337 let result = rule.check(&ctx).unwrap();
338
339 assert_eq!(result.len(), 1);
340 assert_eq!(result[0].line, 2);
341 assert!(result[0].message.contains("has 2 cells, but expected 3"));
342 }
343
344 #[test]
345 fn test_fix_too_few_columns() {
346 let rule = MD056TableColumnCount;
347 let content = "| Header 1 | Header 2 | Header 3 |
348|----------|----------|----------|
349| Cell 1 | Cell 2 |
350| Cell 4 | Cell 5 | Cell 6 |";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let fixed = rule.fix(&ctx).unwrap();
353
354 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
355 }
356
357 #[test]
358 fn test_fix_too_many_columns() {
359 let rule = MD056TableColumnCount;
360 let content = "| Header 1 | Header 2 |
361|----------|----------|
362| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
363| Cell 5 | Cell 6 |";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let fixed = rule.fix(&ctx).unwrap();
366
367 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
368 assert!(!fixed.contains("Cell 3"));
369 assert!(!fixed.contains("Cell 4"));
370 }
371
372 #[test]
373 fn test_no_leading_pipe() {
374 let rule = MD056TableColumnCount;
375 let content = "Header 1 | Header 2 | Header 3 |
376---------|----------|----------|
377Cell 1 | Cell 2 |
378Cell 4 | 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 }
385
386 #[test]
387 fn test_no_trailing_pipe() {
388 let rule = MD056TableColumnCount;
389 let content = "| Header 1 | Header 2 | Header 3
390|----------|----------|----------
391| Cell 1 | Cell 2
392| Cell 4 | Cell 5 | Cell 6";
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, 3);
398 }
399
400 #[test]
401 fn test_no_pipes_at_all() {
402 let rule = MD056TableColumnCount;
403 let content = "This is not a table
404Just regular text
405No pipes here";
406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407 let result = rule.check(&ctx).unwrap();
408
409 assert_eq!(result.len(), 0);
410 }
411
412 #[test]
413 fn test_empty_cells() {
414 let rule = MD056TableColumnCount;
415 let content = "| Header 1 | Header 2 | Header 3 |
416|----------|----------|----------|
417| | | |
418| Cell 1 | | Cell 3 |";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let result = rule.check(&ctx).unwrap();
421
422 assert_eq!(result.len(), 0);
423 }
424
425 #[test]
426 fn test_multiple_tables() {
427 let rule = MD056TableColumnCount;
428 let content = "| Table 1 Col 1 | Table 1 Col 2 |
429|----------------|----------------|
430| Data 1 | Data 2 |
431
432Some text in between.
433
434| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
435|----------------|----------------|----------------|
436| Data 3 | Data 4 |
437| Data 5 | Data 6 | Data 7 |";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440
441 assert_eq!(result.len(), 1);
442 assert_eq!(result[0].line, 9);
443 assert!(result[0].message.contains("has 2 cells, but expected 3"));
444 }
445
446 #[test]
447 fn test_table_with_escaped_pipes() {
448 let rule = MD056TableColumnCount;
449
450 let content = "| Command | Description |
452|---------|-------------|
453| `echo \\| grep` | Pipe example |
454| `ls` | List files |";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
458
459 let content_double = "| Command | Description |
461|---------|-------------|
462| `echo \\\\| grep` | Pipe example |
463| `ls` | List files |";
464 let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
465 let result2 = rule.check(&ctx2).unwrap();
466 assert_eq!(result2.len(), 0, "pipes inside code spans should not split cells");
468 }
469
470 #[test]
471 fn test_empty_content() {
472 let rule = MD056TableColumnCount;
473 let content = "";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476
477 assert_eq!(result.len(), 0);
478 }
479
480 #[test]
481 fn test_code_block_with_table() {
482 let rule = MD056TableColumnCount;
483 let content = "```
484| This | Is | Code |
485|------|----|----|
486| Not | A | Table |
487```
488
489| Real | Table |
490|------|-------|
491| Data | Here |";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494
495 assert_eq!(result.len(), 0);
497 }
498
499 #[test]
500 fn test_fix_preserves_pipe_style() {
501 let rule = MD056TableColumnCount;
502 let content = "| Header 1 | Header 2 | Header 3
504|----------|----------|----------
505| Cell 1 | Cell 2";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let fixed = rule.fix(&ctx).unwrap();
508
509 let lines: Vec<&str> = fixed.lines().collect();
510 assert!(!lines[2].ends_with('|'));
511 assert!(lines[2].contains("Cell 1"));
512 assert!(lines[2].contains("Cell 2"));
513 }
514
515 #[test]
516 fn test_single_column_table() {
517 let rule = MD056TableColumnCount;
518 let content = "| Header |
519|---------|
520| Cell 1 |
521| Cell 2 |";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524
525 assert_eq!(result.len(), 0);
526 }
527
528 #[test]
529 fn test_complex_delimiter_row() {
530 let rule = MD056TableColumnCount;
531 let content = "| Left | Center | Right |
532|:-----|:------:|------:|
533| L | C | R |
534| Left | Center |";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537
538 assert_eq!(result.len(), 1);
539 assert_eq!(result[0].line, 4);
540 }
541
542 #[test]
543 fn test_unicode_content() {
544 let rule = MD056TableColumnCount;
545 let content = "| 名前 | 年齢 | 都市 |
546|------|------|------|
547| 田中 | 25 | 東京 |
548| 佐藤 | 30 |";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551
552 assert_eq!(result.len(), 1);
553 assert_eq!(result[0].line, 4);
554 }
555
556 #[test]
557 fn test_very_long_cells() {
558 let rule = MD056TableColumnCount;
559 let content = "| Short | Very very very very very very very very very very long header | Another |
560|-------|--------------------------------------------------------------|---------|
561| Data | This is an extremely long cell content that goes on and on |";
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!(result[0].message.contains("has 2 cells, but expected 3"));
567 }
568
569 #[test]
570 fn test_fix_with_newline_ending() {
571 let rule = MD056TableColumnCount;
572 let content = "| A | B | C |
573|---|---|---|
574| 1 | 2 |
575";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let fixed = rule.fix(&ctx).unwrap();
578
579 assert!(fixed.ends_with('\n'));
580 assert!(fixed.contains("| 1 | 2 | |"));
581 }
582
583 #[test]
584 fn test_fix_without_newline_ending() {
585 let rule = MD056TableColumnCount;
586 let content = "| A | B | C |
587|---|---|---|
588| 1 | 2 |";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let fixed = rule.fix(&ctx).unwrap();
591
592 assert!(!fixed.ends_with('\n'));
593 assert!(fixed.contains("| 1 | 2 | |"));
594 }
595
596 #[test]
597 fn test_blockquote_table_column_mismatch() {
598 let rule = MD056TableColumnCount;
599 let content = "> | Header 1 | Header 2 | Header 3 |
600> |----------|----------|----------|
601> | Cell 1 | Cell 2 |";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604
605 assert_eq!(result.len(), 1);
606 assert_eq!(result[0].line, 3);
607 assert!(result[0].message.contains("has 2 cells, but expected 3"));
608 }
609
610 #[test]
611 fn test_fix_blockquote_table_preserves_prefix() {
612 let rule = MD056TableColumnCount;
613 let content = "> | Header 1 | Header 2 | Header 3 |
614> |----------|----------|----------|
615> | Cell 1 | Cell 2 |
616> | Cell 4 | Cell 5 | Cell 6 |";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let fixed = rule.fix(&ctx).unwrap();
619
620 for line in fixed.lines() {
622 assert!(line.starts_with("> "), "Line should preserve blockquote prefix: {line}");
623 }
624 assert!(fixed.contains("> | Cell 1 | Cell 2 | |"));
626 }
627
628 #[test]
629 fn test_fix_nested_blockquote_table() {
630 let rule = MD056TableColumnCount;
631 let content = ">> | A | B | C |
632>> |---|---|---|
633>> | 1 | 2 |";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let fixed = rule.fix(&ctx).unwrap();
636
637 for line in fixed.lines() {
639 assert!(
640 line.starts_with(">> "),
641 "Line should preserve nested blockquote prefix: {line}"
642 );
643 }
644 assert!(fixed.contains(">> | 1 | 2 | |"));
645 }
646
647 #[test]
648 fn test_blockquote_table_too_many_columns() {
649 let rule = MD056TableColumnCount;
650 let content = "> | A | B |
651> |---|---|
652> | 1 | 2 | 3 | 4 |";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let fixed = rule.fix(&ctx).unwrap();
655
656 assert!(fixed.lines().nth(2).unwrap().starts_with("> "));
658 assert!(fixed.contains("> | 1 | 2 |"));
659 assert!(!fixed.contains("| 3 |"));
660 }
661}