rumdl_lib/rules/
md056_table_column_count.rs1use 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) -> Option<String> {
21 let current_count = TableUtils::count_cells(row);
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 parts: Vec<&str> = trimmed.split('|').collect();
32 let mut cells = Vec::new();
33
34 for (i, part) in parts.iter().enumerate() {
36 if (i == 0 && part.trim().is_empty() && has_leading_pipe)
38 || (i == parts.len() - 1 && part.trim().is_empty() && has_trailing_pipe)
39 {
40 continue;
41 }
42 cells.push(part.trim());
43 }
44
45 match current_count.cmp(&expected_count) {
47 std::cmp::Ordering::Greater => {
48 cells.truncate(expected_count);
50 }
51 std::cmp::Ordering::Less => {
52 while cells.len() < expected_count {
54 cells.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 cells.iter().enumerate() {
69 result.push_str(&format!(" {cell} "));
70 if i < cells.len() - 1 || has_trailing_pipe {
71 result.push('|');
72 }
73 }
74
75 Some(result)
76 }
77}
78
79impl Rule for MD056TableColumnCount {
80 fn name(&self) -> &'static str {
81 "MD056"
82 }
83
84 fn description(&self) -> &'static str {
85 "Table column count should be consistent"
86 }
87
88 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
89 !ctx.likely_has_tables()
91 }
92
93 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94 let content = ctx.content;
95 let mut warnings = Vec::new();
96
97 if content.is_empty() || !content.contains('|') {
99 return Ok(Vec::new());
100 }
101
102 let lines: Vec<&str> = content.lines().collect();
103
104 let table_blocks = &ctx.table_blocks;
106
107 for table_block in table_blocks {
108 let expected_count = TableUtils::count_cells(lines[table_block.header_line]);
110
111 if expected_count == 0 {
112 continue; }
114
115 let all_lines = std::iter::once(table_block.header_line)
117 .chain(std::iter::once(table_block.delimiter_line))
118 .chain(table_block.content_lines.iter().copied());
119
120 for line_idx in all_lines {
121 let line = lines[line_idx];
122 let count = TableUtils::count_cells(line);
123
124 if count > 0 && count != expected_count {
125 let fix_result = self.fix_table_row(line, expected_count);
126
127 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
129
130 warnings.push(LintWarning {
131 rule_name: Some(self.name().to_string()),
132 message: format!("Table row has {count} cells, but expected {expected_count}"),
133 line: start_line,
134 column: start_col,
135 end_line,
136 end_column: end_col,
137 severity: Severity::Warning,
138 fix: fix_result.map(|fixed_row| Fix {
139 range: ctx.line_index.line_col_to_byte_range(line_idx + 1, 1),
140 replacement: fixed_row,
141 }),
142 });
143 }
144 }
145 }
146
147 Ok(warnings)
148 }
149
150 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
151 let content = ctx.content;
152 let warnings = self.check(ctx)?;
153 if warnings.is_empty() {
154 return Ok(content.to_string());
155 }
156
157 let warning_by_line: std::collections::HashMap<usize, &LintWarning> = warnings
159 .iter()
160 .filter_map(|w| w.fix.as_ref().map(|_| (w.line, w)))
161 .collect();
162
163 let lines: Vec<&str> = content.lines().collect();
164 let mut result = Vec::new();
165
166 for (i, line) in lines.iter().enumerate() {
167 if let Some(warning) = warning_by_line.get(&(i + 1))
168 && let Some(fix) = &warning.fix
169 {
170 result.push(fix.replacement.clone());
171 continue;
172 }
173 result.push(line.to_string());
174 }
175
176 if content.ends_with('\n') {
178 Ok(result.join("\n") + "\n")
179 } else {
180 Ok(result.join("\n"))
181 }
182 }
183
184 fn as_any(&self) -> &dyn std::any::Any {
185 self
186 }
187
188 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
189 where
190 Self: Sized,
191 {
192 Box::new(MD056TableColumnCount)
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::lint_context::LintContext;
200
201 #[test]
202 fn test_valid_table() {
203 let rule = MD056TableColumnCount;
204 let content = "| Header 1 | Header 2 | Header 3 |
205|----------|----------|----------|
206| Cell 1 | Cell 2 | Cell 3 |
207| Cell 4 | Cell 5 | Cell 6 |";
208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
209 let result = rule.check(&ctx).unwrap();
210
211 assert_eq!(result.len(), 0);
212 }
213
214 #[test]
215 fn test_too_few_columns() {
216 let rule = MD056TableColumnCount;
217 let content = "| Header 1 | Header 2 | Header 3 |
218|----------|----------|----------|
219| Cell 1 | Cell 2 |
220| Cell 4 | Cell 5 | Cell 6 |";
221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
222 let result = rule.check(&ctx).unwrap();
223
224 assert_eq!(result.len(), 1);
225 assert_eq!(result[0].line, 3);
226 assert!(result[0].message.contains("has 2 cells, but expected 3"));
227 }
228
229 #[test]
230 fn test_too_many_columns() {
231 let rule = MD056TableColumnCount;
232 let content = "| Header 1 | Header 2 |
233|----------|----------|
234| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
235| Cell 5 | Cell 6 |";
236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
237 let result = rule.check(&ctx).unwrap();
238
239 assert_eq!(result.len(), 1);
240 assert_eq!(result[0].line, 3);
241 assert!(result[0].message.contains("has 4 cells, but expected 2"));
242 }
243
244 #[test]
245 fn test_delimiter_row_mismatch() {
246 let rule = MD056TableColumnCount;
247 let content = "| Header 1 | Header 2 | Header 3 |
248|----------|----------|
249| Cell 1 | Cell 2 | Cell 3 |";
250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
251 let result = rule.check(&ctx).unwrap();
252
253 assert_eq!(result.len(), 1);
254 assert_eq!(result[0].line, 2);
255 assert!(result[0].message.contains("has 2 cells, but expected 3"));
256 }
257
258 #[test]
259 fn test_fix_too_few_columns() {
260 let rule = MD056TableColumnCount;
261 let content = "| Header 1 | Header 2 | Header 3 |
262|----------|----------|----------|
263| Cell 1 | Cell 2 |
264| Cell 4 | Cell 5 | Cell 6 |";
265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
266 let fixed = rule.fix(&ctx).unwrap();
267
268 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
269 }
270
271 #[test]
272 fn test_fix_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);
279 let fixed = rule.fix(&ctx).unwrap();
280
281 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
282 assert!(!fixed.contains("Cell 3"));
283 assert!(!fixed.contains("Cell 4"));
284 }
285
286 #[test]
287 fn test_no_leading_pipe() {
288 let rule = MD056TableColumnCount;
289 let content = "Header 1 | Header 2 | Header 3 |
290---------|----------|----------|
291Cell 1 | Cell 2 |
292Cell 4 | Cell 5 | Cell 6 |";
293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294 let result = rule.check(&ctx).unwrap();
295
296 assert_eq!(result.len(), 1);
297 assert_eq!(result[0].line, 3);
298 }
299
300 #[test]
301 fn test_no_trailing_pipe() {
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);
308 let result = rule.check(&ctx).unwrap();
309
310 assert_eq!(result.len(), 1);
311 assert_eq!(result[0].line, 3);
312 }
313
314 #[test]
315 fn test_no_pipes_at_all() {
316 let rule = MD056TableColumnCount;
317 let content = "This is not a table
318Just regular text
319No pipes here";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321 let result = rule.check(&ctx).unwrap();
322
323 assert_eq!(result.len(), 0);
324 }
325
326 #[test]
327 fn test_empty_cells() {
328 let rule = MD056TableColumnCount;
329 let content = "| Header 1 | Header 2 | Header 3 |
330|----------|----------|----------|
331| | | |
332| Cell 1 | | Cell 3 |";
333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
334 let result = rule.check(&ctx).unwrap();
335
336 assert_eq!(result.len(), 0);
337 }
338
339 #[test]
340 fn test_multiple_tables() {
341 let rule = MD056TableColumnCount;
342 let content = "| Table 1 Col 1 | Table 1 Col 2 |
343|----------------|----------------|
344| Data 1 | Data 2 |
345
346Some text in between.
347
348| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
349|----------------|----------------|----------------|
350| Data 3 | Data 4 |
351| Data 5 | Data 6 | Data 7 |";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
353 let result = rule.check(&ctx).unwrap();
354
355 assert_eq!(result.len(), 1);
356 assert_eq!(result[0].line, 9);
357 assert!(result[0].message.contains("has 2 cells, but expected 3"));
358 }
359
360 #[test]
361 #[ignore = "Table utils doesn't handle escaped pipes in code correctly yet"]
362 fn test_table_with_escaped_pipes() {
363 let rule = MD056TableColumnCount;
364 let content = "| Command | Description |
365|---------|-------------|
366| `echo \\| grep` | Pipe example |
367| `ls` | List files |";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369 let result = rule.check(&ctx).unwrap();
370
371 assert_eq!(result.len(), 0);
373 }
374
375 #[test]
376 fn test_empty_content() {
377 let rule = MD056TableColumnCount;
378 let content = "";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let result = rule.check(&ctx).unwrap();
381
382 assert_eq!(result.len(), 0);
383 }
384
385 #[test]
386 fn test_code_block_with_table() {
387 let rule = MD056TableColumnCount;
388 let content = "```
389| This | Is | Code |
390|------|----|----|
391| Not | A | Table |
392```
393
394| Real | Table |
395|------|-------|
396| Data | Here |";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
398 let result = rule.check(&ctx).unwrap();
399
400 assert_eq!(result.len(), 0);
402 }
403
404 #[test]
405 fn test_fix_preserves_pipe_style() {
406 let rule = MD056TableColumnCount;
407 let content = "| Header 1 | Header 2 | Header 3
409|----------|----------|----------
410| Cell 1 | Cell 2";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
412 let fixed = rule.fix(&ctx).unwrap();
413
414 let lines: Vec<&str> = fixed.lines().collect();
415 assert!(!lines[2].ends_with('|'));
416 assert!(lines[2].contains("Cell 1"));
417 assert!(lines[2].contains("Cell 2"));
418 }
419
420 #[test]
421 fn test_single_column_table() {
422 let rule = MD056TableColumnCount;
423 let content = "| Header |
424|---------|
425| Cell 1 |
426| Cell 2 |";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429
430 assert_eq!(result.len(), 0);
431 }
432
433 #[test]
434 fn test_complex_delimiter_row() {
435 let rule = MD056TableColumnCount;
436 let content = "| Left | Center | Right |
437|:-----|:------:|------:|
438| L | C | R |
439| Left | Center |";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441 let result = rule.check(&ctx).unwrap();
442
443 assert_eq!(result.len(), 1);
444 assert_eq!(result[0].line, 4);
445 }
446
447 #[test]
448 fn test_unicode_content() {
449 let rule = MD056TableColumnCount;
450 let content = "| 名前 | 年齢 | 都市 |
451|------|------|------|
452| 田中 | 25 | 東京 |
453| 佐藤 | 30 |";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455 let result = rule.check(&ctx).unwrap();
456
457 assert_eq!(result.len(), 1);
458 assert_eq!(result[0].line, 4);
459 }
460
461 #[test]
462 fn test_very_long_cells() {
463 let rule = MD056TableColumnCount;
464 let content = "| Short | Very very very very very very very very very very long header | Another |
465|-------|--------------------------------------------------------------|---------|
466| Data | This is an extremely long cell content that goes on and on |";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468 let result = rule.check(&ctx).unwrap();
469
470 assert_eq!(result.len(), 1);
471 assert!(result[0].message.contains("has 2 cells, but expected 3"));
472 }
473
474 #[test]
475 fn test_fix_with_newline_ending() {
476 let rule = MD056TableColumnCount;
477 let content = "| A | B | C |
478|---|---|---|
479| 1 | 2 |
480";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
482 let fixed = rule.fix(&ctx).unwrap();
483
484 assert!(fixed.ends_with('\n'));
485 assert!(fixed.contains("| 1 | 2 | |"));
486 }
487
488 #[test]
489 fn test_fix_without_newline_ending() {
490 let rule = MD056TableColumnCount;
491 let content = "| A | B | C |
492|---|---|---|
493| 1 | 2 |";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
495 let fixed = rule.fix(&ctx).unwrap();
496
497 assert!(!fixed.ends_with('\n'));
498 assert!(fixed.contains("| 1 | 2 | |"));
499 }
500}