rumdl_lib/rules/
md056_table_column_count.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::{LineIndex, 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 = TableUtils::find_table_blocks(content, ctx);
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: LineIndex::new(content.to_string()).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 lines: Vec<&str> = content.lines().collect();
158 let mut result = Vec::new();
159
160 for (i, line) in lines.iter().enumerate() {
161 let warning_idx = warnings.iter().position(|w| w.line == i + 1);
162
163 if let Some(idx) = warning_idx
164 && let Some(fix) = &warnings[idx].fix
165 {
166 result.push(fix.replacement.clone());
167 continue;
168 }
169 result.push(line.to_string());
170 }
171
172 if content.ends_with('\n') {
174 Ok(result.join("\n") + "\n")
175 } else {
176 Ok(result.join("\n"))
177 }
178 }
179
180 fn as_any(&self) -> &dyn std::any::Any {
181 self
182 }
183
184 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
185 where
186 Self: Sized,
187 {
188 Box::new(MD056TableColumnCount)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::lint_context::LintContext;
196
197 #[test]
198 fn test_valid_table() {
199 let rule = MD056TableColumnCount;
200 let content = "| Header 1 | Header 2 | Header 3 |
201|----------|----------|----------|
202| Cell 1 | Cell 2 | Cell 3 |
203| Cell 4 | Cell 5 | Cell 6 |";
204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
205 let result = rule.check(&ctx).unwrap();
206
207 assert_eq!(result.len(), 0);
208 }
209
210 #[test]
211 fn test_too_few_columns() {
212 let rule = MD056TableColumnCount;
213 let content = "| Header 1 | Header 2 | Header 3 |
214|----------|----------|----------|
215| Cell 1 | Cell 2 |
216| Cell 4 | Cell 5 | Cell 6 |";
217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
218 let result = rule.check(&ctx).unwrap();
219
220 assert_eq!(result.len(), 1);
221 assert_eq!(result[0].line, 3);
222 assert!(result[0].message.contains("has 2 cells, but expected 3"));
223 }
224
225 #[test]
226 fn test_too_many_columns() {
227 let rule = MD056TableColumnCount;
228 let content = "| Header 1 | Header 2 |
229|----------|----------|
230| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
231| Cell 5 | Cell 6 |";
232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233 let result = rule.check(&ctx).unwrap();
234
235 assert_eq!(result.len(), 1);
236 assert_eq!(result[0].line, 3);
237 assert!(result[0].message.contains("has 4 cells, but expected 2"));
238 }
239
240 #[test]
241 fn test_delimiter_row_mismatch() {
242 let rule = MD056TableColumnCount;
243 let content = "| Header 1 | Header 2 | Header 3 |
244|----------|----------|
245| Cell 1 | Cell 2 | Cell 3 |";
246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
247 let result = rule.check(&ctx).unwrap();
248
249 assert_eq!(result.len(), 1);
250 assert_eq!(result[0].line, 2);
251 assert!(result[0].message.contains("has 2 cells, but expected 3"));
252 }
253
254 #[test]
255 fn test_fix_too_few_columns() {
256 let rule = MD056TableColumnCount;
257 let content = "| Header 1 | Header 2 | Header 3 |
258|----------|----------|----------|
259| Cell 1 | Cell 2 |
260| Cell 4 | Cell 5 | Cell 6 |";
261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
262 let fixed = rule.fix(&ctx).unwrap();
263
264 assert!(fixed.contains("| Cell 1 | Cell 2 | |"));
265 }
266
267 #[test]
268 fn test_fix_too_many_columns() {
269 let rule = MD056TableColumnCount;
270 let content = "| Header 1 | Header 2 |
271|----------|----------|
272| Cell 1 | Cell 2 | Cell 3 | Cell 4 |
273| Cell 5 | Cell 6 |";
274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
275 let fixed = rule.fix(&ctx).unwrap();
276
277 assert!(fixed.contains("| Cell 1 | Cell 2 |"));
278 assert!(!fixed.contains("Cell 3"));
279 assert!(!fixed.contains("Cell 4"));
280 }
281
282 #[test]
283 fn test_no_leading_pipe() {
284 let rule = MD056TableColumnCount;
285 let content = "Header 1 | Header 2 | Header 3 |
286---------|----------|----------|
287Cell 1 | Cell 2 |
288Cell 4 | Cell 5 | Cell 6 |";
289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
290 let result = rule.check(&ctx).unwrap();
291
292 assert_eq!(result.len(), 1);
293 assert_eq!(result[0].line, 3);
294 }
295
296 #[test]
297 fn test_no_trailing_pipe() {
298 let rule = MD056TableColumnCount;
299 let content = "| Header 1 | Header 2 | Header 3
300|----------|----------|----------
301| Cell 1 | Cell 2
302| Cell 4 | Cell 5 | Cell 6";
303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
304 let result = rule.check(&ctx).unwrap();
305
306 assert_eq!(result.len(), 1);
307 assert_eq!(result[0].line, 3);
308 }
309
310 #[test]
311 fn test_no_pipes_at_all() {
312 let rule = MD056TableColumnCount;
313 let content = "This is not a table
314Just regular text
315No pipes here";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
317 let result = rule.check(&ctx).unwrap();
318
319 assert_eq!(result.len(), 0);
320 }
321
322 #[test]
323 fn test_empty_cells() {
324 let rule = MD056TableColumnCount;
325 let content = "| Header 1 | Header 2 | Header 3 |
326|----------|----------|----------|
327| | | |
328| Cell 1 | | Cell 3 |";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330 let result = rule.check(&ctx).unwrap();
331
332 assert_eq!(result.len(), 0);
333 }
334
335 #[test]
336 fn test_multiple_tables() {
337 let rule = MD056TableColumnCount;
338 let content = "| Table 1 Col 1 | Table 1 Col 2 |
339|----------------|----------------|
340| Data 1 | Data 2 |
341
342Some text in between.
343
344| Table 2 Col 1 | Table 2 Col 2 | Table 2 Col 3 |
345|----------------|----------------|----------------|
346| Data 3 | Data 4 |
347| Data 5 | Data 6 | Data 7 |";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349 let result = rule.check(&ctx).unwrap();
350
351 assert_eq!(result.len(), 1);
352 assert_eq!(result[0].line, 9);
353 assert!(result[0].message.contains("has 2 cells, but expected 3"));
354 }
355
356 #[test]
357 #[ignore = "Table utils doesn't handle escaped pipes in code correctly yet"]
358 fn test_table_with_escaped_pipes() {
359 let rule = MD056TableColumnCount;
360 let content = "| Command | Description |
361|---------|-------------|
362| `echo \\| grep` | Pipe example |
363| `ls` | List files |";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
365 let result = rule.check(&ctx).unwrap();
366
367 assert_eq!(result.len(), 0);
369 }
370
371 #[test]
372 fn test_empty_content() {
373 let rule = MD056TableColumnCount;
374 let content = "";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376 let result = rule.check(&ctx).unwrap();
377
378 assert_eq!(result.len(), 0);
379 }
380
381 #[test]
382 fn test_code_block_with_table() {
383 let rule = MD056TableColumnCount;
384 let content = "```
385| This | Is | Code |
386|------|----|----|
387| Not | A | Table |
388```
389
390| Real | Table |
391|------|-------|
392| Data | Here |";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394 let result = rule.check(&ctx).unwrap();
395
396 assert_eq!(result.len(), 0);
398 }
399
400 #[test]
401 fn test_fix_preserves_pipe_style() {
402 let rule = MD056TableColumnCount;
403 let content = "| Header 1 | Header 2 | Header 3
405|----------|----------|----------
406| Cell 1 | Cell 2";
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
408 let fixed = rule.fix(&ctx).unwrap();
409
410 let lines: Vec<&str> = fixed.lines().collect();
411 assert!(!lines[2].ends_with('|'));
412 assert!(lines[2].contains("Cell 1"));
413 assert!(lines[2].contains("Cell 2"));
414 }
415
416 #[test]
417 fn test_single_column_table() {
418 let rule = MD056TableColumnCount;
419 let content = "| Header |
420|---------|
421| Cell 1 |
422| Cell 2 |";
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_complex_delimiter_row() {
431 let rule = MD056TableColumnCount;
432 let content = "| Left | Center | Right |
433|:-----|:------:|------:|
434| L | C | R |
435| Left | Center |";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437 let result = rule.check(&ctx).unwrap();
438
439 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 4);
441 }
442
443 #[test]
444 fn test_unicode_content() {
445 let rule = MD056TableColumnCount;
446 let content = "| 名前 | 年齢 | 都市 |
447|------|------|------|
448| 田中 | 25 | 東京 |
449| 佐藤 | 30 |";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452
453 assert_eq!(result.len(), 1);
454 assert_eq!(result[0].line, 4);
455 }
456
457 #[test]
458 fn test_very_long_cells() {
459 let rule = MD056TableColumnCount;
460 let content = "| Short | Very very very very very very very very very very long header | Another |
461|-------|--------------------------------------------------------------|---------|
462| Data | This is an extremely long cell content that goes on and on |";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.check(&ctx).unwrap();
465
466 assert_eq!(result.len(), 1);
467 assert!(result[0].message.contains("has 2 cells, but expected 3"));
468 }
469
470 #[test]
471 fn test_fix_with_newline_ending() {
472 let rule = MD056TableColumnCount;
473 let content = "| A | B | C |
474|---|---|---|
475| 1 | 2 |
476";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let fixed = rule.fix(&ctx).unwrap();
479
480 assert!(fixed.ends_with('\n'));
481 assert!(fixed.contains("| 1 | 2 | |"));
482 }
483
484 #[test]
485 fn test_fix_without_newline_ending() {
486 let rule = MD056TableColumnCount;
487 let content = "| A | B | C |
488|---|---|---|
489| 1 | 2 |";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491 let fixed = rule.fix(&ctx).unwrap();
492
493 assert!(!fixed.ends_with('\n'));
494 assert!(fixed.contains("| 1 | 2 | |"));
495 }
496}