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 current_count = TableUtils::count_cells_with_flavor(row, flavor);
22
23 if current_count == expected_count || current_count == 0 {
24 return None;
25 }
26
27 if flavor == crate::config::MarkdownFlavor::Standard && current_count > expected_count {
30 let escaped_row = TableUtils::escape_pipes_in_inline_code(row);
31 let escaped_count = TableUtils::count_cells_with_flavor(&escaped_row, flavor);
32
33 if escaped_count == expected_count {
35 return Some(escaped_row.trim().to_string());
36 }
37
38 if escaped_count < current_count {
40 return self.fix_row_by_truncation(&escaped_row, expected_count, flavor);
41 }
42 }
43
44 self.fix_row_by_truncation(row, expected_count, flavor)
45 }
46
47 fn fix_row_by_truncation(
49 &self,
50 row: &str,
51 expected_count: usize,
52 flavor: crate::config::MarkdownFlavor,
53 ) -> Option<String> {
54 let current_count = TableUtils::count_cells_with_flavor(row, flavor);
55
56 if current_count == expected_count || current_count == 0 {
57 return None;
58 }
59
60 let trimmed = row.trim();
61 let has_leading_pipe = trimmed.starts_with('|');
62 let has_trailing_pipe = trimmed.ends_with('|');
63
64 let cells = Self::split_row_into_cells(trimmed, flavor);
66
67 let mut cell_contents: Vec<&str> = Vec::new();
68 for (i, cell) in cells.iter().enumerate() {
69 if (i == 0 && cell.trim().is_empty() && has_leading_pipe)
71 || (i == cells.len() - 1 && cell.trim().is_empty() && has_trailing_pipe)
72 {
73 continue;
74 }
75 cell_contents.push(cell.trim());
76 }
77
78 match current_count.cmp(&expected_count) {
80 std::cmp::Ordering::Greater => {
81 cell_contents.truncate(expected_count);
83 }
84 std::cmp::Ordering::Less => {
85 while cell_contents.len() < expected_count {
87 cell_contents.push("");
88 }
89 }
90 std::cmp::Ordering::Equal => {
91 }
93 }
94
95 let mut result = String::new();
97 if has_leading_pipe {
98 result.push('|');
99 }
100
101 for (i, cell) in cell_contents.iter().enumerate() {
102 result.push_str(&format!(" {cell} "));
103 if i < cell_contents.len() - 1 || has_trailing_pipe {
104 result.push('|');
105 }
106 }
107
108 Some(result)
109 }
110
111 fn split_row_into_cells(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
116 let masked = TableUtils::mask_pipes_for_table_parsing(row);
118
119 let final_masked = if flavor == crate::config::MarkdownFlavor::MkDocs {
121 TableUtils::mask_pipes_in_inline_code(&masked)
122 } else {
123 masked
124 };
125
126 let masked_parts: Vec<&str> = final_masked.split('|').collect();
129 let mut cells = Vec::new();
130 let mut pos = 0;
131
132 for masked_part in masked_parts {
133 let cell_len = masked_part.len();
134 if pos + cell_len <= row.len() {
135 cells.push(row[pos..pos + cell_len].to_string());
136 } else {
137 cells.push(masked_part.to_string());
138 }
139 pos += cell_len + 1; }
141
142 cells
143 }
144}
145
146impl Rule for MD056TableColumnCount {
147 fn name(&self) -> &'static str {
148 "MD056"
149 }
150
151 fn description(&self) -> &'static str {
152 "Table column count should be consistent"
153 }
154
155 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
156 !ctx.likely_has_tables()
158 }
159
160 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
161 let content = ctx.content;
162 let flavor = ctx.flavor;
163 let mut warnings = Vec::new();
164
165 if content.is_empty() || !content.contains('|') {
167 return Ok(Vec::new());
168 }
169
170 let lines: Vec<&str> = content.lines().collect();
171
172 let table_blocks = &ctx.table_blocks;
174
175 for table_block in table_blocks {
176 let expected_count = TableUtils::count_cells_with_flavor(lines[table_block.header_line], flavor);
178
179 if expected_count == 0 {
180 continue; }
182
183 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
185 .chain(std::iter::once(table_block.delimiter_line))
186 .chain(table_block.content_lines.iter().copied())
187 .collect();
188
189 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());
196 for &line_idx in &all_line_indices {
197 let line = lines[line_idx];
198 let fixed_line = self
199 .fix_table_row(line, expected_count, flavor)
200 .unwrap_or_else(|| line.to_string());
201 if line_idx < lines.len() - 1 {
202 fixed_table_lines.push(format!("{fixed_line}\n"));
203 } else {
204 fixed_table_lines.push(fixed_line);
205 }
206 }
207 let table_replacement = fixed_table_lines.concat();
208 let table_range = ctx.line_index.multi_line_range(table_start_line, table_end_line);
209
210 for &line_idx in &all_line_indices {
212 let line = lines[line_idx];
213 let count = TableUtils::count_cells_with_flavor(line, flavor);
214
215 if count > 0 && count != expected_count {
216 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
218
219 warnings.push(LintWarning {
222 rule_name: Some(self.name().to_string()),
223 message: format!("Table row has {count} cells, but expected {expected_count}"),
224 line: start_line,
225 column: start_col,
226 end_line,
227 end_column: end_col,
228 severity: Severity::Warning,
229 fix: Some(Fix {
230 range: table_range.clone(),
231 replacement: table_replacement.clone(),
232 }),
233 });
234 }
235 }
236 }
237
238 Ok(warnings)
239 }
240
241 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
242 let content = ctx.content;
243 let flavor = ctx.flavor;
244 let lines: Vec<&str> = content.lines().collect();
245 let table_blocks = &ctx.table_blocks;
246
247 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
248
249 for table_block in table_blocks {
250 let expected_count = TableUtils::count_cells_with_flavor(lines[table_block.header_line], flavor);
252
253 if expected_count == 0 {
254 continue; }
256
257 let all_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
259 .chain(std::iter::once(table_block.delimiter_line))
260 .chain(table_block.content_lines.iter().copied())
261 .collect();
262
263 for &line_idx in &all_line_indices {
264 let line = lines[line_idx];
265 if let Some(fixed_line) = self.fix_table_row(line, expected_count, flavor) {
266 result_lines[line_idx] = fixed_line;
267 }
268 }
269 }
270
271 let mut fixed = result_lines.join("\n");
272 if content.ends_with('\n') && !fixed.ends_with('\n') {
274 fixed.push('\n');
275 }
276 Ok(fixed)
277 }
278
279 fn as_any(&self) -> &dyn std::any::Any {
280 self
281 }
282
283 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
284 where
285 Self: Sized,
286 {
287 Box::new(MD056TableColumnCount)
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::lint_context::LintContext;
295
296 #[test]
297 fn test_valid_table() {
298 let rule = MD056TableColumnCount;
299 let content = "| Header 1 | Header 2 | Header 3 |
300|----------|----------|----------|
301| Cell 1 | Cell 2 | Cell 3 |
302| Cell 4 | Cell 5 | Cell 6 |";
303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
304 let result = rule.check(&ctx).unwrap();
305
306 assert_eq!(result.len(), 0);
307 }
308
309 #[test]
310 fn test_too_few_columns() {
311 let rule = MD056TableColumnCount;
312 let content = "| Header 1 | Header 2 | Header 3 |
313|----------|----------|----------|
314| Cell 1 | Cell 2 |
315| Cell 4 | Cell 5 | Cell 6 |";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
317 let result = rule.check(&ctx).unwrap();
318
319 assert_eq!(result.len(), 1);
320 assert_eq!(result[0].line, 3);
321 assert!(result[0].message.contains("has 2 cells, but expected 3"));
322 }
323
324 #[test]
325 fn test_too_many_columns() {
326 let rule = MD056TableColumnCount;
327 let content = "| Header 1 | Header 2 |
328|----------|----------|
329| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
330| Cell 5 | Cell 6 |";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let result = rule.check(&ctx).unwrap();
333
334 assert_eq!(result.len(), 1);
335 assert_eq!(result[0].line, 3);
336 assert!(result[0].message.contains("has 4 cells, but expected 2"));
337 }
338
339 #[test]
340 fn test_delimiter_row_mismatch() {
341 let rule = MD056TableColumnCount;
342 let content = "| Header 1 | Header 2 | Header 3 |
343|----------|----------|
344| Cell 1 | Cell 2 | Cell 3 |";
345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346 let result = rule.check(&ctx).unwrap();
347
348 assert_eq!(result.len(), 1);
349 assert_eq!(result[0].line, 2);
350 assert!(result[0].message.contains("has 2 cells, but expected 3"));
351 }
352
353 #[test]
354 fn test_fix_too_few_columns() {
355 let rule = MD056TableColumnCount;
356 let content = "| Header 1 | Header 2 | Header 3 |
357|----------|----------|----------|
358| Cell 1 | Cell 2 |
359| Cell 4 | Cell 5 | Cell 6 |";
360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
361 let fixed = rule.fix(&ctx).unwrap();
362
363 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
364 }
365
366 #[test]
367 fn test_fix_too_many_columns() {
368 let rule = MD056TableColumnCount;
369 let content = "| Header 1 | Header 2 |
370|----------|----------|
371| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
372| Cell 5 | Cell 6 |";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374 let fixed = rule.fix(&ctx).unwrap();
375
376 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
377 assert!(!fixed.contains("Cell 3"));
378 assert!(!fixed.contains("Cell 4"));
379 }
380
381 #[test]
382 fn test_no_leading_pipe() {
383 let rule = MD056TableColumnCount;
384 let content = "Header 1 | Header 2 | Header 3 |
385---------|----------|----------|
386Cell 1 | Cell 2 |
387Cell 4 | Cell 5 | Cell 6 |";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390
391 assert_eq!(result.len(), 1);
392 assert_eq!(result[0].line, 3);
393 }
394
395 #[test]
396 fn test_no_trailing_pipe() {
397 let rule = MD056TableColumnCount;
398 let content = "| Header 1 | Header 2 | Header 3
399|----------|----------|----------
400| Cell 1 | Cell 2
401| Cell 4 | Cell 5 | Cell 6";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404
405 assert_eq!(result.len(), 1);
406 assert_eq!(result[0].line, 3);
407 }
408
409 #[test]
410 fn test_no_pipes_at_all() {
411 let rule = MD056TableColumnCount;
412 let content = "This is not a table
413Just regular text
414No pipes here";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417
418 assert_eq!(result.len(), 0);
419 }
420
421 #[test]
422 fn test_empty_cells() {
423 let rule = MD056TableColumnCount;
424 let content = "| Header 1 | Header 2 | Header 3 |
425|----------|----------|----------|
426| | | |
427| Cell 1 | | Cell 3 |";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430
431 assert_eq!(result.len(), 0);
432 }
433
434 #[test]
435 fn test_multiple_tables() {
436 let rule = MD056TableColumnCount;
437 let content = "| Table 1 Col 1 | Table 1 Col 2 |
438|----------------|----------------|
439| Data 1 | Data 2 |
440
441Some text in between.
442
443| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
444|----------------|----------------|----------------|
445| Data 3 | Data 4 |
446| Data 5 | Data 6 | Data 7 |";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448 let result = rule.check(&ctx).unwrap();
449
450 assert_eq!(result.len(), 1);
451 assert_eq!(result[0].line, 9);
452 assert!(result[0].message.contains("has 2 cells, but expected 3"));
453 }
454
455 #[test]
456 fn test_table_with_escaped_pipes() {
457 let rule = MD056TableColumnCount;
458
459 let content = "| Command | Description |
461|---------|-------------|
462| `echo \\| grep` | Pipe example |
463| `ls` | List files |";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466 assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
467
468 let content_double = "| Command | Description |
470|---------|-------------|
471| `echo \\\\| grep` | Pipe example |
472| `ls` | List files |";
473 let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard, None);
474 let result2 = rule.check(&ctx2).unwrap();
475 assert_eq!(result2.len(), 1, "double backslash \\\\| should split cells");
477 }
478
479 #[test]
480 fn test_empty_content() {
481 let rule = MD056TableColumnCount;
482 let content = "";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485
486 assert_eq!(result.len(), 0);
487 }
488
489 #[test]
490 fn test_code_block_with_table() {
491 let rule = MD056TableColumnCount;
492 let content = "```
493| This | Is | Code |
494|------|----|----|
495| Not | A | Table |
496```
497
498| Real | Table |
499|------|-------|
500| Data | Here |";
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);
506 }
507
508 #[test]
509 fn test_fix_preserves_pipe_style() {
510 let rule = MD056TableColumnCount;
511 let content = "| Header 1 | Header 2 | Header 3
513|----------|----------|----------
514| Cell 1 | Cell 2";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let fixed = rule.fix(&ctx).unwrap();
517
518 let lines: Vec<&str> = fixed.lines().collect();
519 assert!(!lines[2].ends_with('|'));
520 assert!(lines[2].contains("Cell 1"));
521 assert!(lines[2].contains("Cell 2"));
522 }
523
524 #[test]
525 fn test_single_column_table() {
526 let rule = MD056TableColumnCount;
527 let content = "| Header |
528|---------|
529| Cell 1 |
530| Cell 2 |";
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_complex_delimiter_row() {
539 let rule = MD056TableColumnCount;
540 let content = "| Left | Center | Right |
541|:-----|:------:|------:|
542| L | C | R |
543| Left | Center |";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546
547 assert_eq!(result.len(), 1);
548 assert_eq!(result[0].line, 4);
549 }
550
551 #[test]
552 fn test_unicode_content() {
553 let rule = MD056TableColumnCount;
554 let content = "| 名前 | 年齢 | 都市 |
555|------|------|------|
556| 田中 | 25 | 東京 |
557| 佐藤 | 30 |";
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, 4);
563 }
564
565 #[test]
566 fn test_very_long_cells() {
567 let rule = MD056TableColumnCount;
568 let content = "| Short | Very very very very very very very very very very long header | Another |
569|-------|--------------------------------------------------------------|---------|
570| Data | This is an extremely long cell content that goes on and on |";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573
574 assert_eq!(result.len(), 1);
575 assert!(result[0].message.contains("has 2 cells, but expected 3"));
576 }
577
578 #[test]
579 fn test_fix_with_newline_ending() {
580 let rule = MD056TableColumnCount;
581 let content = "| A | B | C |
582|---|---|---|
583| 1 | 2 |
584";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let fixed = rule.fix(&ctx).unwrap();
587
588 assert!(fixed.ends_with('\n'));
589 assert!(fixed.contains("| 1 | 2 | |"));
590 }
591
592 #[test]
593 fn test_fix_without_newline_ending() {
594 let rule = MD056TableColumnCount;
595 let content = "| A | B | C |
596|---|---|---|
597| 1 | 2 |";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let fixed = rule.fix(&ctx).unwrap();
600
601 assert!(!fixed.ends_with('\n'));
602 assert!(fixed.contains("| 1 | 2 | |"));
603 }
604}