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 let mut total_non_empty_parts = 0;
92
93 for part in &parts {
94 let part_trimmed = part.trim();
95 if part_trimmed.is_empty() {
96 continue; }
98
99 total_non_empty_parts += 1;
100
101 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
103 valid_delimiter_parts += 1;
104 }
105 }
106
107 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
109 }
110
111 pub fn find_table_blocks_with_code_info(
114 content: &str,
115 code_blocks: &[(usize, usize)],
116 code_spans: &[crate::lint_context::CodeSpan],
117 ) -> Vec<TableBlock> {
118 let lines: Vec<&str> = content.lines().collect();
119 let mut tables = Vec::new();
120 let mut i = 0;
121
122 let mut line_positions = Vec::with_capacity(lines.len());
124 let mut pos = 0;
125 for line in &lines {
126 line_positions.push(pos);
127 pos += line.len() + 1; }
129
130 while i < lines.len() {
131 let line_start = line_positions[i];
133 let in_code =
134 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start)
135 || code_spans
136 .iter()
137 .any(|span| line_start >= span.byte_offset && line_start < span.byte_end);
138 if in_code {
139 i += 1;
140 continue;
141 }
142
143 if Self::is_potential_table_row(lines[i]) {
145 if i + 1 < lines.len() && Self::is_delimiter_row(lines[i + 1]) {
147 let table_start = i;
149 let header_line = i;
150 let delimiter_line = i + 1;
151 let mut table_end = i + 1; let mut content_lines = Vec::new();
153
154 let mut j = i + 2;
156 while j < lines.len() {
157 let line = lines[j];
158 if line.trim().is_empty() {
159 break;
161 }
162 if Self::is_potential_table_row(line) {
163 content_lines.push(j);
164 table_end = j;
165 j += 1;
166 } else {
167 break;
169 }
170 }
171
172 tables.push(TableBlock {
173 start_line: table_start,
174 end_line: table_end,
175 header_line,
176 delimiter_line,
177 content_lines,
178 });
179 i = table_end + 1;
180 } else {
181 i += 1;
182 }
183 } else {
184 i += 1;
185 }
186 }
187
188 tables
189 }
190
191 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
194 Self::find_table_blocks_with_code_info(content, &ctx.code_blocks, &ctx.code_spans())
195 }
196
197 pub fn count_cells(row: &str) -> usize {
199 let trimmed = row.trim();
200
201 if !trimmed.contains('|') {
203 return 0;
204 }
205
206 let masked_row = Self::mask_pipes_in_inline_code(trimmed);
208
209 let mut cell_count = 0;
211 let parts: Vec<&str> = masked_row.split('|').collect();
212
213 for (i, part) in parts.iter().enumerate() {
214 if i == 0 && part.trim().is_empty() && parts.len() > 1 {
216 continue;
217 }
218
219 if i == parts.len() - 1 && part.trim().is_empty() && parts.len() > 1 {
221 continue;
222 }
223
224 cell_count += 1;
225 }
226
227 cell_count
228 }
229
230 pub fn mask_pipes_in_inline_code(text: &str) -> String {
232 let mut result = String::new();
233 let chars: Vec<char> = text.chars().collect();
234 let mut i = 0;
235
236 while i < chars.len() {
237 if chars[i] == '`' {
238 let start = i;
240 let mut backtick_count = 0;
241 while i < chars.len() && chars[i] == '`' {
242 backtick_count += 1;
243 i += 1;
244 }
245
246 let mut found_closing = false;
248 let mut j = i;
249
250 while j < chars.len() {
251 if chars[j] == '`' {
252 let close_start = j;
254 let mut close_count = 0;
255 while j < chars.len() && chars[j] == '`' {
256 close_count += 1;
257 j += 1;
258 }
259
260 if close_count == backtick_count {
261 found_closing = true;
263
264 result.extend(chars[start..i].iter());
266
267 for &ch in chars.iter().take(close_start).skip(i) {
268 if ch == '|' {
269 result.push('_'); } else {
271 result.push(ch);
272 }
273 }
274
275 result.extend(chars[close_start..j].iter());
276 i = j;
277 break;
278 }
279 } else {
281 j += 1;
282 }
283 }
284
285 if !found_closing {
286 result.extend(chars[start..i].iter());
288 }
289 } else {
290 result.push(chars[i]);
291 i += 1;
292 }
293 }
294
295 result
296 }
297
298 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
300 let trimmed = line.trim();
301 if !trimmed.contains('|') {
302 return None;
303 }
304
305 let has_leading = trimmed.starts_with('|');
306 let has_trailing = trimmed.ends_with('|');
307
308 match (has_leading, has_trailing) {
309 (true, true) => Some("leading_and_trailing"),
310 (true, false) => Some("leading_only"),
311 (false, true) => Some("trailing_only"),
312 (false, false) => Some("no_leading_or_trailing"),
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::lint_context::LintContext;
321
322 #[test]
323 fn test_is_potential_table_row() {
324 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
326 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
327 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
328 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
332
333 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
335 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
336
337 assert!(!TableUtils::is_potential_table_row("- List item"));
339 assert!(!TableUtils::is_potential_table_row("* Another list"));
340 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
341 assert!(!TableUtils::is_potential_table_row("Regular text"));
342 assert!(!TableUtils::is_potential_table_row(""));
343 assert!(!TableUtils::is_potential_table_row(" "));
344
345 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
347 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
348
349 assert!(!TableUtils::is_potential_table_row("Just one |"));
351 assert!(!TableUtils::is_potential_table_row("| Just one"));
352
353 let long_cell = "a".repeat(150);
355 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
356
357 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
359 }
360
361 #[test]
362 fn test_is_delimiter_row() {
363 assert!(TableUtils::is_delimiter_row("|---|---|"));
365 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
366 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 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
376
377 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
379
380 assert!(TableUtils::is_delimiter_row("--- | ---"));
382 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
383
384 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
386 assert!(!TableUtils::is_delimiter_row("Regular text"));
387 assert!(!TableUtils::is_delimiter_row(""));
388 assert!(!TableUtils::is_delimiter_row("|||"));
389 assert!(!TableUtils::is_delimiter_row("| | |"));
390
391 assert!(!TableUtils::is_delimiter_row("| : | : |"));
393 assert!(!TableUtils::is_delimiter_row("| | |"));
394
395 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
397 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
398 }
399
400 #[test]
401 fn test_count_cells() {
402 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
404 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
405 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
406 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
407
408 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
410 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
414 assert_eq!(TableUtils::count_cells("| | | |"), 3);
415
416 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
418
419 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
425 assert_eq!(TableUtils::count_cells(""), 0);
426 assert_eq!(TableUtils::count_cells(" "), 0);
427
428 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
430 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
431 }
432
433 #[test]
434 fn test_count_cells_with_inline_code() {
435 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
437 assert_eq!(
438 TableUtils::count_cells("| Hour:minute:second formats | `^([0-1]?\\d|2[0-3]):[0-5]\\d:[0-5]\\d$` |"),
439 2
440 );
441
442 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
444 assert_eq!(TableUtils::count_cells("| A | `code | with | pipes` | B |"), 3);
445
446 assert_eq!(TableUtils::count_cells("| Command | `echo \\| grep` |"), 2);
448
449 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
451
452 assert_eq!(TableUtils::count_cells("| Empty inline | `` | cell |"), 3);
454 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
455
456 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
458 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
459 }
460
461 #[test]
462 fn test_determine_pipe_style() {
463 assert_eq!(
465 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
466 Some("leading_and_trailing")
467 );
468 assert_eq!(
469 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
470 Some("leading_only")
471 );
472 assert_eq!(
473 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
474 Some("trailing_only")
475 );
476 assert_eq!(
477 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
478 Some("no_leading_or_trailing")
479 );
480
481 assert_eq!(
483 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
484 Some("leading_and_trailing")
485 );
486 assert_eq!(
487 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
488 Some("leading_only")
489 );
490
491 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
493 assert_eq!(TableUtils::determine_pipe_style(""), None);
494 assert_eq!(TableUtils::determine_pipe_style(" "), None);
495
496 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
498 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
499 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
500 }
501
502 #[test]
503 fn test_find_table_blocks_simple() {
504 let content = "| Header 1 | Header 2 |
505|-----------|-----------|
506| Cell 1 | Cell 2 |
507| Cell 3 | Cell 4 |";
508
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510
511 let tables = TableUtils::find_table_blocks(content, &ctx);
512 assert_eq!(tables.len(), 1);
513
514 let table = &tables[0];
515 assert_eq!(table.start_line, 0);
516 assert_eq!(table.end_line, 3);
517 assert_eq!(table.header_line, 0);
518 assert_eq!(table.delimiter_line, 1);
519 assert_eq!(table.content_lines, vec![2, 3]);
520 }
521
522 #[test]
523 fn test_find_table_blocks_multiple() {
524 let content = "Some text
525
526| Table 1 | Col A |
527|----------|-------|
528| Data 1 | Val 1 |
529
530More text
531
532| Table 2 | Col 2 |
533|----------|-------|
534| Data 2 | Data |";
535
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537
538 let tables = TableUtils::find_table_blocks(content, &ctx);
539 assert_eq!(tables.len(), 2);
540
541 assert_eq!(tables[0].start_line, 2);
543 assert_eq!(tables[0].end_line, 4);
544 assert_eq!(tables[0].header_line, 2);
545 assert_eq!(tables[0].delimiter_line, 3);
546 assert_eq!(tables[0].content_lines, vec![4]);
547
548 assert_eq!(tables[1].start_line, 8);
550 assert_eq!(tables[1].end_line, 10);
551 assert_eq!(tables[1].header_line, 8);
552 assert_eq!(tables[1].delimiter_line, 9);
553 assert_eq!(tables[1].content_lines, vec![10]);
554 }
555
556 #[test]
557 fn test_find_table_blocks_no_content_rows() {
558 let content = "| Header 1 | Header 2 |
559|-----------|-----------|
560
561Next paragraph";
562
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564
565 let tables = TableUtils::find_table_blocks(content, &ctx);
566 assert_eq!(tables.len(), 1);
567
568 let table = &tables[0];
569 assert_eq!(table.start_line, 0);
570 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
572 }
573
574 #[test]
575 fn test_find_table_blocks_in_code_block() {
576 let content = "```
577| Not | A | Table |
578|-----|---|-------|
579| In | Code | Block |
580```
581
582| Real | Table |
583|------|-------|
584| Data | Here |";
585
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
587
588 let tables = TableUtils::find_table_blocks(content, &ctx);
589 assert_eq!(tables.len(), 1); let table = &tables[0];
592 assert_eq!(table.header_line, 6);
593 assert_eq!(table.delimiter_line, 7);
594 }
595
596 #[test]
597 fn test_find_table_blocks_no_tables() {
598 let content = "Just regular text
599No tables here
600- List item with | pipe
601* Another list item";
602
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
604
605 let tables = TableUtils::find_table_blocks(content, &ctx);
606 assert_eq!(tables.len(), 0);
607 }
608
609 #[test]
610 fn test_find_table_blocks_malformed() {
611 let content = "| Header without delimiter |
612| This looks like table |
613But no delimiter row
614
615| Proper | Table |
616|---------|-------|
617| Data | Here |";
618
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
620
621 let tables = TableUtils::find_table_blocks(content, &ctx);
622 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
624 }
625
626 #[test]
627 fn test_edge_cases() {
628 assert!(!TableUtils::is_potential_table_row(""));
630 assert!(!TableUtils::is_delimiter_row(""));
631 assert_eq!(TableUtils::count_cells(""), 0);
632 assert_eq!(TableUtils::determine_pipe_style(""), None);
633
634 assert!(!TableUtils::is_potential_table_row(" "));
636 assert!(!TableUtils::is_delimiter_row(" "));
637 assert_eq!(TableUtils::count_cells(" "), 0);
638 assert_eq!(TableUtils::determine_pipe_style(" "), None);
639
640 assert!(!TableUtils::is_potential_table_row("|"));
642 assert!(!TableUtils::is_delimiter_row("|"));
643 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
648 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
651 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
655 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
656 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
657 }
658
659 #[test]
660 fn test_table_block_struct() {
661 let block = TableBlock {
662 start_line: 0,
663 end_line: 5,
664 header_line: 0,
665 delimiter_line: 1,
666 content_lines: vec![2, 3, 4, 5],
667 };
668
669 let debug_str = format!("{block:?}");
671 assert!(debug_str.contains("TableBlock"));
672 assert!(debug_str.contains("start_line: 0"));
673
674 let cloned = block.clone();
676 assert_eq!(cloned.start_line, block.start_line);
677 assert_eq!(cloned.end_line, block.end_line);
678 assert_eq!(cloned.header_line, block.header_line);
679 assert_eq!(cloned.delimiter_line, block.delimiter_line);
680 assert_eq!(cloned.content_lines, block.content_lines);
681 }
682}