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 && valid_parts != total_non_empty_parts {
62 return false;
64 }
65
66 if total_non_empty_parts == 0 {
69 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
71 }
72
73 if trimmed.starts_with('|') && trimmed.ends_with('|') {
76 valid_parts >= 1
78 } else {
79 valid_parts >= 2
81 }
82 }
83
84 pub fn is_delimiter_row(line: &str) -> bool {
86 let trimmed = line.trim();
87 if !trimmed.contains('|') || !trimmed.contains('-') {
88 return false;
89 }
90
91 let parts: Vec<&str> = trimmed.split('|').collect();
93 let mut valid_delimiter_parts = 0;
94 let mut total_non_empty_parts = 0;
95
96 for part in &parts {
97 let part_trimmed = part.trim();
98 if part_trimmed.is_empty() {
99 continue; }
101
102 total_non_empty_parts += 1;
103
104 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
106 valid_delimiter_parts += 1;
107 }
108 }
109
110 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
112 }
113
114 pub fn find_table_blocks_with_code_info(
117 content: &str,
118 code_blocks: &[(usize, usize)],
119 code_spans: &[crate::lint_context::CodeSpan],
120 ) -> Vec<TableBlock> {
121 let lines: Vec<&str> = content.lines().collect();
122 let mut tables = Vec::new();
123 let mut i = 0;
124
125 let mut line_positions = Vec::with_capacity(lines.len());
127 let mut pos = 0;
128 for line in &lines {
129 line_positions.push(pos);
130 pos += line.len() + 1; }
132
133 while i < lines.len() {
134 let line_start = line_positions[i];
136 let in_code =
137 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start)
138 || code_spans
139 .iter()
140 .any(|span| line_start >= span.byte_offset && line_start < span.byte_end);
141 if in_code {
142 i += 1;
143 continue;
144 }
145
146 if Self::is_potential_table_row(lines[i]) {
148 if i + 1 < lines.len() && Self::is_delimiter_row(lines[i + 1]) {
150 let table_start = i;
152 let header_line = i;
153 let delimiter_line = i + 1;
154 let mut table_end = i + 1; let mut content_lines = Vec::new();
156
157 let mut j = i + 2;
159 while j < lines.len() {
160 let line = lines[j];
161 if line.trim().is_empty() {
162 break;
164 }
165 if Self::is_potential_table_row(line) {
166 content_lines.push(j);
167 table_end = j;
168 j += 1;
169 } else {
170 break;
172 }
173 }
174
175 tables.push(TableBlock {
176 start_line: table_start,
177 end_line: table_end,
178 header_line,
179 delimiter_line,
180 content_lines,
181 });
182 i = table_end + 1;
183 } else {
184 i += 1;
185 }
186 } else {
187 i += 1;
188 }
189 }
190
191 tables
192 }
193
194 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
197 Self::find_table_blocks_with_code_info(content, &ctx.code_blocks, &ctx.code_spans())
198 }
199
200 pub fn count_cells(row: &str) -> usize {
202 let trimmed = row.trim();
203
204 if !trimmed.contains('|') {
206 return 0;
207 }
208
209 let masked_row = Self::mask_pipes_in_inline_code(trimmed);
211
212 let mut cell_count = 0;
214 let parts: Vec<&str> = masked_row.split('|').collect();
215
216 for (i, part) in parts.iter().enumerate() {
217 if i == 0 && part.trim().is_empty() && parts.len() > 1 {
219 continue;
220 }
221
222 if i == parts.len() - 1 && part.trim().is_empty() && parts.len() > 1 {
224 continue;
225 }
226
227 cell_count += 1;
228 }
229
230 cell_count
231 }
232
233 pub fn mask_pipes_in_inline_code(text: &str) -> String {
235 let mut result = String::new();
236 let chars: Vec<char> = text.chars().collect();
237 let mut i = 0;
238
239 while i < chars.len() {
240 if chars[i] == '`' {
241 let start = i;
243 let mut backtick_count = 0;
244 while i < chars.len() && chars[i] == '`' {
245 backtick_count += 1;
246 i += 1;
247 }
248
249 let mut found_closing = false;
251 let mut j = i;
252
253 while j < chars.len() {
254 if chars[j] == '`' {
255 let close_start = j;
257 let mut close_count = 0;
258 while j < chars.len() && chars[j] == '`' {
259 close_count += 1;
260 j += 1;
261 }
262
263 if close_count == backtick_count {
264 found_closing = true;
266
267 result.extend(chars[start..i].iter());
269
270 for &ch in chars.iter().take(close_start).skip(i) {
271 if ch == '|' {
272 result.push('_'); } else {
274 result.push(ch);
275 }
276 }
277
278 result.extend(chars[close_start..j].iter());
279 i = j;
280 break;
281 }
282 } else {
284 j += 1;
285 }
286 }
287
288 if !found_closing {
289 result.extend(chars[start..i].iter());
291 }
292 } else {
293 result.push(chars[i]);
294 i += 1;
295 }
296 }
297
298 result
299 }
300
301 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
303 let trimmed = line.trim();
304 if !trimmed.contains('|') {
305 return None;
306 }
307
308 let has_leading = trimmed.starts_with('|');
309 let has_trailing = trimmed.ends_with('|');
310
311 match (has_leading, has_trailing) {
312 (true, true) => Some("leading_and_trailing"),
313 (true, false) => Some("leading_only"),
314 (false, true) => Some("trailing_only"),
315 (false, false) => Some("no_leading_or_trailing"),
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::lint_context::LintContext;
324
325 #[test]
326 fn test_is_potential_table_row() {
327 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
329 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
330 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
331 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
335
336 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
338 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
339
340 assert!(!TableUtils::is_potential_table_row("- List item"));
342 assert!(!TableUtils::is_potential_table_row("* Another list"));
343 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
344 assert!(!TableUtils::is_potential_table_row("Regular text"));
345 assert!(!TableUtils::is_potential_table_row(""));
346 assert!(!TableUtils::is_potential_table_row(" "));
347
348 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
350 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
351
352 assert!(!TableUtils::is_potential_table_row("Just one |"));
354 assert!(!TableUtils::is_potential_table_row("| Just one"));
355
356 let long_cell = "a".repeat(150);
358 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
359
360 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
362
363 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
368
369 #[test]
370 fn test_is_delimiter_row() {
371 assert!(TableUtils::is_delimiter_row("|---|---|"));
373 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
374 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
375 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
376
377 assert!(TableUtils::is_delimiter_row("|-|--|"));
379 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
380
381 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
383 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
384
385 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
387
388 assert!(TableUtils::is_delimiter_row("--- | ---"));
390 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
391
392 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
394 assert!(!TableUtils::is_delimiter_row("Regular text"));
395 assert!(!TableUtils::is_delimiter_row(""));
396 assert!(!TableUtils::is_delimiter_row("|||"));
397 assert!(!TableUtils::is_delimiter_row("| | |"));
398
399 assert!(!TableUtils::is_delimiter_row("| : | : |"));
401 assert!(!TableUtils::is_delimiter_row("| | |"));
402
403 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
405 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
406 }
407
408 #[test]
409 fn test_count_cells() {
410 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
412 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
413 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
414 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
415
416 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
418 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
422 assert_eq!(TableUtils::count_cells("| | | |"), 3);
423
424 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
426
427 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
433 assert_eq!(TableUtils::count_cells(""), 0);
434 assert_eq!(TableUtils::count_cells(" "), 0);
435
436 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
438 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
439 }
440
441 #[test]
442 fn test_count_cells_with_inline_code() {
443 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
445 assert_eq!(
446 TableUtils::count_cells("| Hour:minute:second formats | `^([0-1]?\\d|2[0-3]):[0-5]\\d:[0-5]\\d$` |"),
447 2
448 );
449
450 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
452 assert_eq!(TableUtils::count_cells("| A | `code | with | pipes` | B |"), 3);
453
454 assert_eq!(TableUtils::count_cells("| Command | `echo \\| grep` |"), 2);
456
457 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
459
460 assert_eq!(TableUtils::count_cells("| Empty inline | `` | cell |"), 3);
462 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
463
464 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
466 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
467 }
468
469 #[test]
470 fn test_determine_pipe_style() {
471 assert_eq!(
473 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
474 Some("leading_and_trailing")
475 );
476 assert_eq!(
477 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
478 Some("leading_only")
479 );
480 assert_eq!(
481 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
482 Some("trailing_only")
483 );
484 assert_eq!(
485 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
486 Some("no_leading_or_trailing")
487 );
488
489 assert_eq!(
491 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
492 Some("leading_and_trailing")
493 );
494 assert_eq!(
495 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
496 Some("leading_only")
497 );
498
499 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
501 assert_eq!(TableUtils::determine_pipe_style(""), None);
502 assert_eq!(TableUtils::determine_pipe_style(" "), None);
503
504 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
506 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
507 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
508 }
509
510 #[test]
511 fn test_find_table_blocks_simple() {
512 let content = "| Header 1 | Header 2 |
513|-----------|-----------|
514| Cell 1 | Cell 2 |
515| Cell 3 | Cell 4 |";
516
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
518
519 let tables = TableUtils::find_table_blocks(content, &ctx);
520 assert_eq!(tables.len(), 1);
521
522 let table = &tables[0];
523 assert_eq!(table.start_line, 0);
524 assert_eq!(table.end_line, 3);
525 assert_eq!(table.header_line, 0);
526 assert_eq!(table.delimiter_line, 1);
527 assert_eq!(table.content_lines, vec![2, 3]);
528 }
529
530 #[test]
531 fn test_find_table_blocks_multiple() {
532 let content = "Some text
533
534| Table 1 | Col A |
535|----------|-------|
536| Data 1 | Val 1 |
537
538More text
539
540| Table 2 | Col 2 |
541|----------|-------|
542| Data 2 | Data |";
543
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
545
546 let tables = TableUtils::find_table_blocks(content, &ctx);
547 assert_eq!(tables.len(), 2);
548
549 assert_eq!(tables[0].start_line, 2);
551 assert_eq!(tables[0].end_line, 4);
552 assert_eq!(tables[0].header_line, 2);
553 assert_eq!(tables[0].delimiter_line, 3);
554 assert_eq!(tables[0].content_lines, vec![4]);
555
556 assert_eq!(tables[1].start_line, 8);
558 assert_eq!(tables[1].end_line, 10);
559 assert_eq!(tables[1].header_line, 8);
560 assert_eq!(tables[1].delimiter_line, 9);
561 assert_eq!(tables[1].content_lines, vec![10]);
562 }
563
564 #[test]
565 fn test_find_table_blocks_no_content_rows() {
566 let content = "| Header 1 | Header 2 |
567|-----------|-----------|
568
569Next paragraph";
570
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
572
573 let tables = TableUtils::find_table_blocks(content, &ctx);
574 assert_eq!(tables.len(), 1);
575
576 let table = &tables[0];
577 assert_eq!(table.start_line, 0);
578 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
580 }
581
582 #[test]
583 fn test_find_table_blocks_in_code_block() {
584 let content = "```
585| Not | A | Table |
586|-----|---|-------|
587| In | Code | Block |
588```
589
590| Real | Table |
591|------|-------|
592| Data | Here |";
593
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
595
596 let tables = TableUtils::find_table_blocks(content, &ctx);
597 assert_eq!(tables.len(), 1); let table = &tables[0];
600 assert_eq!(table.header_line, 6);
601 assert_eq!(table.delimiter_line, 7);
602 }
603
604 #[test]
605 fn test_find_table_blocks_no_tables() {
606 let content = "Just regular text
607No tables here
608- List item with | pipe
609* Another list item";
610
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
612
613 let tables = TableUtils::find_table_blocks(content, &ctx);
614 assert_eq!(tables.len(), 0);
615 }
616
617 #[test]
618 fn test_find_table_blocks_malformed() {
619 let content = "| Header without delimiter |
620| This looks like table |
621But no delimiter row
622
623| Proper | Table |
624|---------|-------|
625| Data | Here |";
626
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
628
629 let tables = TableUtils::find_table_blocks(content, &ctx);
630 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
632 }
633
634 #[test]
635 fn test_edge_cases() {
636 assert!(!TableUtils::is_potential_table_row(""));
638 assert!(!TableUtils::is_delimiter_row(""));
639 assert_eq!(TableUtils::count_cells(""), 0);
640 assert_eq!(TableUtils::determine_pipe_style(""), None);
641
642 assert!(!TableUtils::is_potential_table_row(" "));
644 assert!(!TableUtils::is_delimiter_row(" "));
645 assert_eq!(TableUtils::count_cells(" "), 0);
646 assert_eq!(TableUtils::determine_pipe_style(" "), None);
647
648 assert!(!TableUtils::is_potential_table_row("|"));
650 assert!(!TableUtils::is_delimiter_row("|"));
651 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
656 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
659 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
663 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
664 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
665 }
666
667 #[test]
668 fn test_table_block_struct() {
669 let block = TableBlock {
670 start_line: 0,
671 end_line: 5,
672 header_line: 0,
673 delimiter_line: 1,
674 content_lines: vec![2, 3, 4, 5],
675 };
676
677 let debug_str = format!("{block:?}");
679 assert!(debug_str.contains("TableBlock"));
680 assert!(debug_str.contains("start_line: 0"));
681
682 let cloned = block.clone();
684 assert_eq!(cloned.start_line, block.start_line);
685 assert_eq!(cloned.end_line, block.end_line);
686 assert_eq!(cloned.header_line, block.header_line);
687 assert_eq!(cloned.delimiter_line, block.delimiter_line);
688 assert_eq!(cloned.content_lines, block.content_lines);
689 }
690}