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_lines = 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
155 for line_idx in all_lines {
156 let line = lines[line_idx];
157 let count = TableUtils::count_cells_with_flavor(line, flavor);
158
159 if count > 0 && count != expected_count {
160 let fix_result = self.fix_table_row(line, expected_count, flavor);
161
162 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
164
165 warnings.push(LintWarning {
166 rule_name: Some(self.name().to_string()),
167 message: format!("Table row has {count} cells, but expected {expected_count}"),
168 line: start_line,
169 column: start_col,
170 end_line,
171 end_column: end_col,
172 severity: Severity::Warning,
173 fix: fix_result.map(|fixed_row| Fix {
174 range: ctx.line_index.line_col_to_byte_range(line_idx + 1, 1),
175 replacement: fixed_row,
176 }),
177 });
178 }
179 }
180 }
181
182 Ok(warnings)
183 }
184
185 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
186 let content = ctx.content;
187 let warnings = self.check(ctx)?;
188 if warnings.is_empty() {
189 return Ok(content.to_string());
190 }
191
192 let warning_by_line: std::collections::HashMap<usize, &LintWarning> = warnings
194 .iter()
195 .filter_map(|w| w.fix.as_ref().map(|_| (w.line, w)))
196 .collect();
197
198 let lines: Vec<&str> = content.lines().collect();
199 let mut result = Vec::new();
200
201 for (i, line) in lines.iter().enumerate() {
202 if let Some(warning) = warning_by_line.get(&(i + 1))
203 && let Some(fix) = &warning.fix
204 {
205 result.push(fix.replacement.clone());
206 continue;
207 }
208 result.push(line.to_string());
209 }
210
211 if content.ends_with('\n') {
213 Ok(result.join("\n") + "\n")
214 } else {
215 Ok(result.join("\n"))
216 }
217 }
218
219 fn as_any(&self) -> &dyn std::any::Any {
220 self
221 }
222
223 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
224 where
225 Self: Sized,
226 {
227 Box::new(MD056TableColumnCount)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::lint_context::LintContext;
235
236 #[test]
237 fn test_valid_table() {
238 let rule = MD056TableColumnCount;
239 let content = "| Header 1 | Header 2 | Header 3 |
240|----------|----------|----------|
241| Cell 1 | Cell 2 | Cell 3 |
242| Cell 4 | Cell 5 | Cell 6 |";
243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
244 let result = rule.check(&ctx).unwrap();
245
246 assert_eq!(result.len(), 0);
247 }
248
249 #[test]
250 fn test_too_few_columns() {
251 let rule = MD056TableColumnCount;
252 let content = "| Header 1 | Header 2 | Header 3 |
253|----------|----------|----------|
254| Cell 1 | Cell 2 |
255| Cell 4 | Cell 5 | Cell 6 |";
256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
257 let result = rule.check(&ctx).unwrap();
258
259 assert_eq!(result.len(), 1);
260 assert_eq!(result[0].line, 3);
261 assert!(result[0].message.contains("has 2 cells, but expected 3"));
262 }
263
264 #[test]
265 fn test_too_many_columns() {
266 let rule = MD056TableColumnCount;
267 let content = "| Header 1 | Header 2 |
268|----------|----------|
269| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
270| Cell 5 | Cell 6 |";
271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
272 let result = rule.check(&ctx).unwrap();
273
274 assert_eq!(result.len(), 1);
275 assert_eq!(result[0].line, 3);
276 assert!(result[0].message.contains("has 4 cells, but expected 2"));
277 }
278
279 #[test]
280 fn test_delimiter_row_mismatch() {
281 let rule = MD056TableColumnCount;
282 let content = "| Header 1 | Header 2 | Header 3 |
283|----------|----------|
284| Cell 1 | Cell 2 | Cell 3 |";
285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
286 let result = rule.check(&ctx).unwrap();
287
288 assert_eq!(result.len(), 1);
289 assert_eq!(result[0].line, 2);
290 assert!(result[0].message.contains("has 2 cells, but expected 3"));
291 }
292
293 #[test]
294 fn test_fix_too_few_columns() {
295 let rule = MD056TableColumnCount;
296 let content = "| Header 1 | Header 2 | Header 3 |
297|----------|----------|----------|
298| Cell 1 | Cell 2 |
299| Cell 4 | Cell 5 | Cell 6 |";
300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
301 let fixed = rule.fix(&ctx).unwrap();
302
303 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
304 }
305
306 #[test]
307 fn test_fix_too_many_columns() {
308 let rule = MD056TableColumnCount;
309 let content = "| Header 1 | Header 2 |
310|----------|----------|
311| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
312| Cell 5 | Cell 6 |";
313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314 let fixed = rule.fix(&ctx).unwrap();
315
316 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
317 assert!(!fixed.contains("Cell 3"));
318 assert!(!fixed.contains("Cell 4"));
319 }
320
321 #[test]
322 fn test_no_leading_pipe() {
323 let rule = MD056TableColumnCount;
324 let content = "Header 1 | Header 2 | Header 3 |
325---------|----------|----------|
326Cell 1 | Cell 2 |
327Cell 4 | Cell 5 | Cell 6 |";
328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
329 let result = rule.check(&ctx).unwrap();
330
331 assert_eq!(result.len(), 1);
332 assert_eq!(result[0].line, 3);
333 }
334
335 #[test]
336 fn test_no_trailing_pipe() {
337 let rule = MD056TableColumnCount;
338 let content = "| Header 1 | Header 2 | Header 3
339|----------|----------|----------
340| Cell 1 | Cell 2
341| Cell 4 | Cell 5 | Cell 6";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
343 let result = rule.check(&ctx).unwrap();
344
345 assert_eq!(result.len(), 1);
346 assert_eq!(result[0].line, 3);
347 }
348
349 #[test]
350 fn test_no_pipes_at_all() {
351 let rule = MD056TableColumnCount;
352 let content = "This is not a table
353Just regular text
354No pipes here";
355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
356 let result = rule.check(&ctx).unwrap();
357
358 assert_eq!(result.len(), 0);
359 }
360
361 #[test]
362 fn test_empty_cells() {
363 let rule = MD056TableColumnCount;
364 let content = "| Header 1 | Header 2 | Header 3 |
365|----------|----------|----------|
366| | | |
367| Cell 1 | | Cell 3 |";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369 let result = rule.check(&ctx).unwrap();
370
371 assert_eq!(result.len(), 0);
372 }
373
374 #[test]
375 fn test_multiple_tables() {
376 let rule = MD056TableColumnCount;
377 let content = "| Table 1 Col 1 | Table 1 Col 2 |
378|----------------|----------------|
379| Data 1 | Data 2 |
380
381Some text in between.
382
383| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
384|----------------|----------------|----------------|
385| Data 3 | Data 4 |
386| Data 5 | Data 6 | Data 7 |";
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388 let result = rule.check(&ctx).unwrap();
389
390 assert_eq!(result.len(), 1);
391 assert_eq!(result[0].line, 9);
392 assert!(result[0].message.contains("has 2 cells, but expected 3"));
393 }
394
395 #[test]
396 fn test_table_with_escaped_pipes() {
397 let rule = MD056TableColumnCount;
398
399 let content = "| Command | Description |
401|---------|-------------|
402| `echo \\| grep` | Pipe example |
403| `ls` | List files |";
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
405 let result = rule.check(&ctx).unwrap();
406 assert_eq!(result.len(), 0, "escaped pipe \\| should not split cells");
407
408 let content_double = "| Command | Description |
410|---------|-------------|
411| `echo \\\\| grep` | Pipe example |
412| `ls` | List files |";
413 let ctx2 = LintContext::new(content_double, crate::config::MarkdownFlavor::Standard);
414 let result2 = rule.check(&ctx2).unwrap();
415 assert_eq!(result2.len(), 1, "double backslash \\\\| should split cells");
417 }
418
419 #[test]
420 fn test_empty_content() {
421 let rule = MD056TableColumnCount;
422 let content = "";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
424 let result = rule.check(&ctx).unwrap();
425
426 assert_eq!(result.len(), 0);
427 }
428
429 #[test]
430 fn test_code_block_with_table() {
431 let rule = MD056TableColumnCount;
432 let content = "```
433| This | Is | Code |
434|------|----|----|
435| Not | A | Table |
436```
437
438| Real | Table |
439|------|-------|
440| Data | Here |";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442 let result = rule.check(&ctx).unwrap();
443
444 assert_eq!(result.len(), 0);
446 }
447
448 #[test]
449 fn test_fix_preserves_pipe_style() {
450 let rule = MD056TableColumnCount;
451 let content = "| Header 1 | Header 2 | Header 3
453|----------|----------|----------
454| Cell 1 | Cell 2";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456 let fixed = rule.fix(&ctx).unwrap();
457
458 let lines: Vec<&str> = fixed.lines().collect();
459 assert!(!lines[2].ends_with('|'));
460 assert!(lines[2].contains("Cell 1"));
461 assert!(lines[2].contains("Cell 2"));
462 }
463
464 #[test]
465 fn test_single_column_table() {
466 let rule = MD056TableColumnCount;
467 let content = "| Header |
468|---------|
469| Cell 1 |
470| Cell 2 |";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
472 let result = rule.check(&ctx).unwrap();
473
474 assert_eq!(result.len(), 0);
475 }
476
477 #[test]
478 fn test_complex_delimiter_row() {
479 let rule = MD056TableColumnCount;
480 let content = "| Left | Center | Right |
481|:-----|:------:|------:|
482| L | C | R |
483| Left | Center |";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
485 let result = rule.check(&ctx).unwrap();
486
487 assert_eq!(result.len(), 1);
488 assert_eq!(result[0].line, 4);
489 }
490
491 #[test]
492 fn test_unicode_content() {
493 let rule = MD056TableColumnCount;
494 let content = "| 名前 | 年齢 | 都市 |
495|------|------|------|
496| 田中 | 25 | 東京 |
497| 佐藤 | 30 |";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
499 let result = rule.check(&ctx).unwrap();
500
501 assert_eq!(result.len(), 1);
502 assert_eq!(result[0].line, 4);
503 }
504
505 #[test]
506 fn test_very_long_cells() {
507 let rule = MD056TableColumnCount;
508 let content = "| Short | Very very very very very very very very very very long header | Another |
509|-------|--------------------------------------------------------------|---------|
510| Data | This is an extremely long cell content that goes on and on |";
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!(result[0].message.contains("has 2 cells, but expected 3"));
516 }
517
518 #[test]
519 fn test_fix_with_newline_ending() {
520 let rule = MD056TableColumnCount;
521 let content = "| A | B | C |
522|---|---|---|
523| 1 | 2 |
524";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
526 let fixed = rule.fix(&ctx).unwrap();
527
528 assert!(fixed.ends_with('\n'));
529 assert!(fixed.contains("| 1 | 2 | |"));
530 }
531
532 #[test]
533 fn test_fix_without_newline_ending() {
534 let rule = MD056TableColumnCount;
535 let content = "| A | B | C |
536|---|---|---|
537| 1 | 2 |";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539 let fixed = rule.fix(&ctx).unwrap();
540
541 assert!(!fixed.ends_with('\n'));
542 assert!(fixed.contains("| 1 | 2 | |"));
543 }
544}