1#[derive(Debug, Clone)]
7pub struct TableBlock {
8 pub start_line: usize,
9 pub end_line: usize,
10 pub header_line: usize,
11 pub delimiter_line: usize,
12 pub content_lines: Vec<usize>,
13}
14
15pub struct TableUtils;
17
18impl TableUtils {
19 pub fn is_potential_table_row(line: &str) -> bool {
21 let trimmed = line.trim();
22 if trimmed.is_empty() || !trimmed.contains('|') {
23 return false;
24 }
25
26 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
28 return false;
29 }
30
31 if trimmed.starts_with("`") || trimmed.contains("``") {
33 return false;
34 }
35
36 let parts: Vec<&str> = trimmed.split('|').collect();
38 if parts.len() < 2 {
39 return false;
40 }
41
42 let mut valid_parts = 0;
44 let mut total_non_empty_parts = 0;
45
46 for part in &parts {
47 let part_trimmed = part.trim();
48 if part_trimmed.is_empty() {
50 continue;
51 }
52 total_non_empty_parts += 1;
53
54 if !part_trimmed.contains('\n') {
56 valid_parts += 1;
57 }
58 }
59
60 if total_non_empty_parts == 0 {
62 return false;
63 }
64
65 if valid_parts != total_non_empty_parts {
66 return false;
68 }
69
70 if trimmed.starts_with('|') && trimmed.ends_with('|') {
73 valid_parts >= 1
75 } else {
76 valid_parts >= 2
78 }
79 }
80
81 pub fn is_delimiter_row(line: &str) -> bool {
83 let trimmed = line.trim();
84 if !trimmed.contains('|') || !trimmed.contains('-') {
85 return false;
86 }
87
88 let parts: Vec<&str> = trimmed.split('|').collect();
90 let mut valid_delimiter_parts = 0;
91
92 for part in &parts {
93 let part_trimmed = part.trim();
94 if part_trimmed.is_empty() {
95 continue; }
97
98 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
100 valid_delimiter_parts += 1;
101 }
102 }
103
104 valid_delimiter_parts >= 2
105 }
106
107 pub fn find_table_blocks_with_code_info(
110 content: &str,
111 code_blocks: &[(usize, usize)],
112 code_spans: &[crate::lint_context::CodeSpan],
113 ) -> Vec<TableBlock> {
114 let lines: Vec<&str> = content.lines().collect();
115 let mut tables = Vec::new();
116 let mut i = 0;
117
118 let mut line_positions = Vec::with_capacity(lines.len());
120 let mut pos = 0;
121 for line in &lines {
122 line_positions.push(pos);
123 pos += line.len() + 1; }
125
126 while i < lines.len() {
127 let line_start = line_positions[i];
129 let in_code =
130 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start)
131 || code_spans
132 .iter()
133 .any(|span| line_start >= span.byte_offset && line_start < span.byte_end);
134 if in_code {
135 i += 1;
136 continue;
137 }
138
139 if Self::is_potential_table_row(lines[i]) {
141 if i + 1 < lines.len() && Self::is_delimiter_row(lines[i + 1]) {
143 let table_start = i;
145 let header_line = i;
146 let delimiter_line = i + 1;
147 let mut table_end = i + 1; let mut content_lines = Vec::new();
149
150 let mut j = i + 2;
152 while j < lines.len() {
153 let line = lines[j];
154 if line.trim().is_empty() {
155 break;
157 }
158 if Self::is_potential_table_row(line) {
159 content_lines.push(j);
160 table_end = j;
161 j += 1;
162 } else {
163 break;
165 }
166 }
167
168 tables.push(TableBlock {
169 start_line: table_start,
170 end_line: table_end,
171 header_line,
172 delimiter_line,
173 content_lines,
174 });
175 i = table_end + 1;
176 } else {
177 i += 1;
178 }
179 } else {
180 i += 1;
181 }
182 }
183
184 tables
185 }
186
187 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
190 Self::find_table_blocks_with_code_info(content, &ctx.code_blocks, &ctx.code_spans())
191 }
192
193 pub fn count_cells(row: &str) -> usize {
195 let trimmed = row.trim();
196
197 if !trimmed.contains('|') {
199 return 0;
200 }
201
202 let masked_row = Self::mask_pipes_in_inline_code(trimmed);
204
205 let mut cell_count = 0;
207 let parts: Vec<&str> = masked_row.split('|').collect();
208
209 for (i, part) in parts.iter().enumerate() {
210 if i == 0 && part.trim().is_empty() && parts.len() > 1 {
212 continue;
213 }
214
215 if i == parts.len() - 1 && part.trim().is_empty() && parts.len() > 1 {
217 continue;
218 }
219
220 cell_count += 1;
221 }
222
223 cell_count
224 }
225
226 fn mask_pipes_in_inline_code(text: &str) -> String {
228 let mut result = String::new();
229 let chars: Vec<char> = text.chars().collect();
230 let mut i = 0;
231
232 while i < chars.len() {
233 if chars[i] == '`' {
234 let start = i;
236 let mut backtick_count = 0;
237 while i < chars.len() && chars[i] == '`' {
238 backtick_count += 1;
239 i += 1;
240 }
241
242 let mut found_closing = false;
244 let mut j = i;
245
246 while j < chars.len() {
247 if chars[j] == '`' {
248 let close_start = j;
250 let mut close_count = 0;
251 while j < chars.len() && chars[j] == '`' {
252 close_count += 1;
253 j += 1;
254 }
255
256 if close_count == backtick_count {
257 found_closing = true;
259
260 result.extend(chars[start..i].iter());
262
263 for &ch in chars.iter().take(close_start).skip(i) {
264 if ch == '|' {
265 result.push('_'); } else {
267 result.push(ch);
268 }
269 }
270
271 result.extend(chars[close_start..j].iter());
272 i = j;
273 break;
274 }
275 } else {
277 j += 1;
278 }
279 }
280
281 if !found_closing {
282 result.extend(chars[start..i].iter());
284 }
285 } else {
286 result.push(chars[i]);
287 i += 1;
288 }
289 }
290
291 result
292 }
293
294 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
296 let trimmed = line.trim();
297 if !trimmed.contains('|') {
298 return None;
299 }
300
301 let has_leading = trimmed.starts_with('|');
302 let has_trailing = trimmed.ends_with('|');
303
304 match (has_leading, has_trailing) {
305 (true, true) => Some("leading_and_trailing"),
306 (true, false) => Some("leading_only"),
307 (false, true) => Some("trailing_only"),
308 (false, false) => Some("no_leading_or_trailing"),
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::lint_context::LintContext;
317
318 #[test]
319 fn test_is_potential_table_row() {
320 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
322 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
323 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
324 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
328
329 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
331 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
332
333 assert!(!TableUtils::is_potential_table_row("- List item"));
335 assert!(!TableUtils::is_potential_table_row("* Another list"));
336 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
337 assert!(!TableUtils::is_potential_table_row("Regular text"));
338 assert!(!TableUtils::is_potential_table_row(""));
339 assert!(!TableUtils::is_potential_table_row(" "));
340
341 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
343 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
344
345 assert!(!TableUtils::is_potential_table_row("Just one |"));
347 assert!(!TableUtils::is_potential_table_row("| Just one"));
348
349 let long_cell = "a".repeat(150);
351 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
352
353 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
355 }
356
357 #[test]
358 fn test_is_delimiter_row() {
359 assert!(TableUtils::is_delimiter_row("|---|---|"));
361 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
362 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
363 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
364
365 assert!(TableUtils::is_delimiter_row("|-|--|"));
367 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
368
369 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
371 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
372
373 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
375
376 assert!(TableUtils::is_delimiter_row("--- | ---"));
378 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
379
380 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
382 assert!(!TableUtils::is_delimiter_row("Regular text"));
383 assert!(!TableUtils::is_delimiter_row(""));
384 assert!(!TableUtils::is_delimiter_row("|||"));
385 assert!(!TableUtils::is_delimiter_row("| | |"));
386
387 assert!(!TableUtils::is_delimiter_row("| : | : |"));
389 assert!(!TableUtils::is_delimiter_row("| | |"));
390
391 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
393 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
394 }
395
396 #[test]
397 fn test_count_cells() {
398 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
400 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
401 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
402 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
403
404 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
406 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
410 assert_eq!(TableUtils::count_cells("| | | |"), 3);
411
412 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
414
415 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
421 assert_eq!(TableUtils::count_cells(""), 0);
422 assert_eq!(TableUtils::count_cells(" "), 0);
423
424 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
426 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
427 }
428
429 #[test]
430 fn test_count_cells_with_inline_code() {
431 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
433 assert_eq!(
434 TableUtils::count_cells("| Hour:minute:second formats | `^([0-1]?\\d|2[0-3]):[0-5]\\d:[0-5]\\d$` |"),
435 2
436 );
437
438 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
440 assert_eq!(TableUtils::count_cells("| A | `code | with | pipes` | B |"), 3);
441
442 assert_eq!(TableUtils::count_cells("| Command | `echo \\| grep` |"), 2);
444
445 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
447
448 assert_eq!(TableUtils::count_cells("| Empty inline | `` | cell |"), 3);
450 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
451
452 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
454 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
455 }
456
457 #[test]
458 fn test_determine_pipe_style() {
459 assert_eq!(
461 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
462 Some("leading_and_trailing")
463 );
464 assert_eq!(
465 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
466 Some("leading_only")
467 );
468 assert_eq!(
469 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
470 Some("trailing_only")
471 );
472 assert_eq!(
473 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
474 Some("no_leading_or_trailing")
475 );
476
477 assert_eq!(
479 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
480 Some("leading_and_trailing")
481 );
482 assert_eq!(
483 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
484 Some("leading_only")
485 );
486
487 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
489 assert_eq!(TableUtils::determine_pipe_style(""), None);
490 assert_eq!(TableUtils::determine_pipe_style(" "), None);
491
492 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
494 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
495 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
496 }
497
498 #[test]
499 fn test_find_table_blocks_simple() {
500 let content = "| Header 1 | Header 2 |
501|-----------|-----------|
502| Cell 1 | Cell 2 |
503| Cell 3 | Cell 4 |";
504
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
506
507 let tables = TableUtils::find_table_blocks(content, &ctx);
508 assert_eq!(tables.len(), 1);
509
510 let table = &tables[0];
511 assert_eq!(table.start_line, 0);
512 assert_eq!(table.end_line, 3);
513 assert_eq!(table.header_line, 0);
514 assert_eq!(table.delimiter_line, 1);
515 assert_eq!(table.content_lines, vec![2, 3]);
516 }
517
518 #[test]
519 fn test_find_table_blocks_multiple() {
520 let content = "Some text
521
522| Table 1 | Col A |
523|----------|-------|
524| Data 1 | Val 1 |
525
526More text
527
528| Table 2 | Col 2 |
529|----------|-------|
530| Data 2 | Data |";
531
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
533
534 let tables = TableUtils::find_table_blocks(content, &ctx);
535 assert_eq!(tables.len(), 2);
536
537 assert_eq!(tables[0].start_line, 2);
539 assert_eq!(tables[0].end_line, 4);
540 assert_eq!(tables[0].header_line, 2);
541 assert_eq!(tables[0].delimiter_line, 3);
542 assert_eq!(tables[0].content_lines, vec![4]);
543
544 assert_eq!(tables[1].start_line, 8);
546 assert_eq!(tables[1].end_line, 10);
547 assert_eq!(tables[1].header_line, 8);
548 assert_eq!(tables[1].delimiter_line, 9);
549 assert_eq!(tables[1].content_lines, vec![10]);
550 }
551
552 #[test]
553 fn test_find_table_blocks_no_content_rows() {
554 let content = "| Header 1 | Header 2 |
555|-----------|-----------|
556
557Next paragraph";
558
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560
561 let tables = TableUtils::find_table_blocks(content, &ctx);
562 assert_eq!(tables.len(), 1);
563
564 let table = &tables[0];
565 assert_eq!(table.start_line, 0);
566 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
568 }
569
570 #[test]
571 fn test_find_table_blocks_in_code_block() {
572 let content = "```
573| Not | A | Table |
574|-----|---|-------|
575| In | Code | Block |
576```
577
578| Real | Table |
579|------|-------|
580| Data | Here |";
581
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583
584 let tables = TableUtils::find_table_blocks(content, &ctx);
585 assert_eq!(tables.len(), 1); let table = &tables[0];
588 assert_eq!(table.header_line, 6);
589 assert_eq!(table.delimiter_line, 7);
590 }
591
592 #[test]
593 fn test_find_table_blocks_no_tables() {
594 let content = "Just regular text
595No tables here
596- List item with | pipe
597* Another list item";
598
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600
601 let tables = TableUtils::find_table_blocks(content, &ctx);
602 assert_eq!(tables.len(), 0);
603 }
604
605 #[test]
606 fn test_find_table_blocks_malformed() {
607 let content = "| Header without delimiter |
608| This looks like table |
609But no delimiter row
610
611| Proper | Table |
612|---------|-------|
613| Data | Here |";
614
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
616
617 let tables = TableUtils::find_table_blocks(content, &ctx);
618 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
620 }
621
622 #[test]
623 fn test_edge_cases() {
624 assert!(!TableUtils::is_potential_table_row(""));
626 assert!(!TableUtils::is_delimiter_row(""));
627 assert_eq!(TableUtils::count_cells(""), 0);
628 assert_eq!(TableUtils::determine_pipe_style(""), None);
629
630 assert!(!TableUtils::is_potential_table_row(" "));
632 assert!(!TableUtils::is_delimiter_row(" "));
633 assert_eq!(TableUtils::count_cells(" "), 0);
634 assert_eq!(TableUtils::determine_pipe_style(" "), None);
635
636 assert!(!TableUtils::is_potential_table_row("|"));
638 assert!(!TableUtils::is_delimiter_row("|"));
639 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
644 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
647 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
651 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
652 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
653 }
654
655 #[test]
656 fn test_table_block_struct() {
657 let block = TableBlock {
658 start_line: 0,
659 end_line: 5,
660 header_line: 0,
661 delimiter_line: 1,
662 content_lines: vec![2, 3, 4, 5],
663 };
664
665 let debug_str = format!("{block:?}");
667 assert!(debug_str.contains("TableBlock"));
668 assert!(debug_str.contains("start_line: 0"));
669
670 let cloned = block.clone();
672 assert_eq!(cloned.start_line, block.start_line);
673 assert_eq!(cloned.end_line, block.end_line);
674 assert_eq!(cloned.header_line, block.header_line);
675 assert_eq!(cloned.delimiter_line, block.delimiter_line);
676 assert_eq!(cloned.content_lines, block.content_lines);
677 }
678}