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