1use colored::Color;
2use regex::{Regex, RegexBuilder};
3use std::path::Path;
4use todo_tree_core::{Priority, TodoItem};
5
6pub fn priority_to_color(priority: Priority) -> Color {
8 match priority {
9 Priority::Critical => Color::Red,
10 Priority::High => Color::Yellow,
11 Priority::Medium => Color::Cyan,
12 Priority::Low => Color::Green,
13 }
14}
15
16#[cfg(not(doctest))]
17pub const DEFAULT_REGEX: &str =
44 r#"(//|#|<!--|;|/\*|\*|--|%|"""|'''|REM\s|::)\s*($TAGS)(?:\(([^)]+)\))?[:\s]+(.*)"#;
45
46#[derive(Debug, Clone)]
48pub struct TodoParser {
49 pattern: Option<Regex>,
51
52 tags: Vec<String>,
54
55 case_sensitive: bool,
57
58 pattern_string: Option<String>,
60}
61
62impl TodoParser {
63 pub fn new(tags: &[String], case_sensitive: bool) -> Self {
65 Self::with_regex(tags, case_sensitive, None)
66 }
67
68 pub fn with_regex(tags: &[String], case_sensitive: bool, custom_regex: Option<&str>) -> Self {
75 let (pattern, pattern_string) = Self::build_pattern(tags, case_sensitive, custom_regex);
76 Self {
77 pattern,
78 tags: tags.to_vec(),
79 case_sensitive,
80 pattern_string,
81 }
82 }
83
84 fn build_pattern(
88 tags: &[String],
89 case_sensitive: bool,
90 custom_regex: Option<&str>,
91 ) -> (Option<Regex>, Option<String>) {
92 if tags.is_empty() {
93 return (None, None);
94 }
95
96 let escaped_tags: Vec<String> = tags.iter().map(|t| regex::escape(t)).collect();
98 let tags_alternation = escaped_tags.join("|");
99
100 let base_pattern = custom_regex.unwrap_or(DEFAULT_REGEX);
102
103 let pattern_string = base_pattern.replace("$TAGS", &tags_alternation);
105
106 let regex = RegexBuilder::new(&pattern_string)
107 .case_insensitive(!case_sensitive)
108 .multi_line(true)
109 .build()
110 .expect("Failed to build regex pattern");
111
112 (Some(regex), Some(pattern_string))
113 }
114
115 pub fn pattern_string(&self) -> Option<&str> {
117 self.pattern_string.as_deref()
118 }
119
120 pub fn parse_line(&self, line: &str, line_number: usize) -> Option<TodoItem> {
122 let pattern = self.pattern.as_ref()?;
123
124 if let Some(captures) = pattern.captures(line) {
126 let tag_match = captures.get(2)?;
132 let author = captures.get(3).map(|m| m.as_str().to_string());
133 let message = captures
134 .get(4)
135 .map(|m| m.as_str().trim().to_string())
136 .unwrap_or_default();
137
138 let tag = tag_match.as_str().to_string();
139
140 let column = tag_match.start() + 1;
142
143 let normalized_tag = if self.case_sensitive {
145 tag
146 } else {
147 self.tags
149 .iter()
150 .find(|t| t.eq_ignore_ascii_case(&tag))
151 .cloned()
152 .unwrap_or(tag)
153 };
154
155 let priority = Priority::from_tag(&normalized_tag);
156
157 return Some(TodoItem {
158 tag: normalized_tag,
159 message,
160 line: line_number,
161 column,
162 line_content: Some(line.to_string()),
163 author,
164 priority,
165 });
166 }
167
168 None
169 }
170
171 pub fn parse_content(&self, content: &str) -> Vec<TodoItem> {
173 content
174 .lines()
175 .enumerate()
176 .filter_map(|(idx, line)| self.parse_line(line, idx + 1))
177 .collect()
178 }
179
180 pub fn parse_file(&self, path: &Path) -> std::io::Result<Vec<TodoItem>> {
182 let content = std::fs::read_to_string(path)?;
183 Ok(self.parse_content(&content))
184 }
185
186 pub fn tags(&self) -> &[String] {
188 &self.tags
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn default_tags() -> Vec<String> {
197 vec![
198 "TODO".to_string(),
199 "FIXME".to_string(),
200 "BUG".to_string(),
201 "NOTE".to_string(),
202 "HACK".to_string(),
203 ]
204 }
205
206 #[test]
207 fn test_parse_simple_todo() {
208 let parser = TodoParser::new(&default_tags(), false);
209 let result = parser.parse_line("// TODO: Fix this later", 1);
210
211 assert!(result.is_some());
212 let item = result.unwrap();
213 assert_eq!(item.tag, "TODO");
214 assert_eq!(item.message, "Fix this later");
215 assert_eq!(item.line, 1);
216 }
217
218 #[test]
219 fn test_parse_todo_with_author() {
220 let parser = TodoParser::new(&default_tags(), false);
221 let result = parser.parse_line("// TODO(john): Implement this", 5);
222
223 assert!(result.is_some());
224 let item = result.unwrap();
225 assert_eq!(item.tag, "TODO");
226 assert_eq!(item.author, Some("john".to_string()));
227 assert_eq!(item.message, "Implement this");
228 }
229
230 #[test]
231 fn test_parse_hash_comment() {
232 let parser = TodoParser::new(&default_tags(), false);
233 let result = parser.parse_line("# FIXME: This is broken", 1);
234
235 assert!(result.is_some());
236 let item = result.unwrap();
237 assert_eq!(item.tag, "FIXME");
238 assert_eq!(item.message, "This is broken");
239 }
240
241 #[test]
242 fn test_parse_case_insensitive() {
243 let parser = TodoParser::new(&default_tags(), false);
244
245 let result1 = parser.parse_line("// todo: lowercase", 1);
246 assert!(result1.is_some());
247 assert_eq!(result1.unwrap().tag, "TODO");
248
249 let result2 = parser.parse_line("// Todo: mixed case", 1);
250 assert!(result2.is_some());
251 assert_eq!(result2.unwrap().tag, "TODO");
252 }
253
254 #[test]
255 fn test_parse_case_sensitive() {
256 let parser = TodoParser::new(&default_tags(), true);
257
258 let result1 = parser.parse_line("// TODO: uppercase", 1);
259 assert!(result1.is_some());
260
261 let result2 = parser.parse_line("// todo: lowercase", 1);
262 assert!(result2.is_none());
263 }
264
265 #[test]
266 fn test_parse_multiple_lines() {
267 let parser = TodoParser::new(&default_tags(), false);
268 let content = r#"
269// Regular comment
270// TODO: First item
271fn main() {}
272// FIXME: Second item
273// NOTE: Third item
274"#;
275 let items = parser.parse_content(content);
276
277 assert_eq!(items.len(), 3);
278 assert_eq!(items[0].tag, "TODO");
279 assert_eq!(items[1].tag, "FIXME");
280 assert_eq!(items[2].tag, "NOTE");
281 }
282
283 #[test]
284 fn test_priority_from_tag() {
285 assert_eq!(Priority::from_tag("BUG"), Priority::Critical);
286 assert_eq!(Priority::from_tag("FIXME"), Priority::Critical);
287 assert_eq!(Priority::from_tag("HACK"), Priority::High);
288 assert_eq!(Priority::from_tag("TODO"), Priority::Medium);
289 assert_eq!(Priority::from_tag("NOTE"), Priority::Low);
290 }
291
292 #[test]
293 fn test_todo_without_colon() {
294 let parser = TodoParser::new(&default_tags(), false);
295 let result = parser.parse_line("// TODO fix this", 1);
296
297 assert!(result.is_some());
298 let item = result.unwrap();
299 assert_eq!(item.tag, "TODO");
300 assert_eq!(item.message, "fix this");
301 }
302
303 #[test]
304 fn test_empty_tags() {
305 let parser = TodoParser::new(&[], false);
306 let result = parser.parse_line("// TODO: something", 1);
307 assert!(result.is_none());
308 }
309
310 #[test]
311 fn test_special_characters_in_message() {
312 let parser = TodoParser::new(&default_tags(), false);
313 let result = parser.parse_line("// TODO: Handle special chars: @#$%^&*()", 1);
314
315 assert!(result.is_some());
316 let item = result.unwrap();
317 assert!(item.message.contains("@#$%^&*()"));
318 }
319
320 #[test]
321 fn test_priority_to_color() {
322 assert_eq!(priority_to_color(Priority::Critical), Color::Red);
324 assert_eq!(priority_to_color(Priority::High), Color::Yellow);
325 assert_eq!(priority_to_color(Priority::Medium), Color::Cyan);
326 assert_eq!(priority_to_color(Priority::Low), Color::Green);
327 }
328
329 #[test]
330 fn test_priority_from_unknown_tag() {
331 assert_eq!(Priority::from_tag("UNKNOWN"), Priority::Medium);
333 assert_eq!(Priority::from_tag("CUSTOM"), Priority::Medium);
334 assert_eq!(Priority::from_tag("RANDOM"), Priority::Medium);
335 }
336
337 #[test]
338 fn test_priority_from_tag_case_variations() {
339 assert_eq!(Priority::from_tag("bug"), Priority::Critical);
341 assert_eq!(Priority::from_tag("Bug"), Priority::Critical);
342 assert_eq!(Priority::from_tag("hack"), Priority::High);
343 assert_eq!(Priority::from_tag("Hack"), Priority::High);
344 assert_eq!(Priority::from_tag("warn"), Priority::High);
345 assert_eq!(Priority::from_tag("WARNING"), Priority::High);
346 assert_eq!(Priority::from_tag("perf"), Priority::Low);
347 assert_eq!(Priority::from_tag("info"), Priority::Low);
348 assert_eq!(Priority::from_tag("IDEA"), Priority::Low);
349 }
350
351 #[test]
352 fn test_parse_file() {
353 use tempfile::TempDir;
354
355 let temp_dir = TempDir::new().unwrap();
356 let file_path = temp_dir.path().join("test.rs");
357
358 std::fs::write(
359 &file_path,
360 r#"
361// TODO: First item
362fn main() {
363 // FIXME: Second item
364}
365"#,
366 )
367 .unwrap();
368
369 let parser = TodoParser::new(&default_tags(), false);
370 let items = parser.parse_file(&file_path).unwrap();
371
372 assert_eq!(items.len(), 2);
373 assert_eq!(items[0].tag, "TODO");
374 assert_eq!(items[1].tag, "FIXME");
375 }
376
377 #[test]
378 fn test_parse_file_nonexistent() {
379 let parser = TodoParser::new(&default_tags(), false);
380 let result = parser.parse_file(std::path::Path::new("/nonexistent/file.rs"));
381 assert!(result.is_err());
382 }
383
384 #[test]
385 fn test_parser_tags_method() {
386 let tags = default_tags();
387 let parser = TodoParser::new(&tags, false);
388 assert_eq!(parser.tags(), &tags);
389 }
390
391 #[test]
392 fn test_parse_xxx_tag() {
393 let tags = vec!["XXX".to_string()];
394 let parser = TodoParser::new(&tags, false);
395 let result = parser.parse_line("// XXX: Critical issue", 1);
396
397 assert!(result.is_some());
398 let item = result.unwrap();
399 assert_eq!(item.tag, "XXX");
400 assert_eq!(item.priority, Priority::Low);
401 }
402
403 #[test]
404 fn test_todo_item_equality() {
405 let item1 = TodoItem {
406 tag: "TODO".to_string(),
407 message: "Test".to_string(),
408 line: 1,
409 column: 1,
410 line_content: Some("// TODO: Test".to_string()),
411 author: None,
412 priority: Priority::Medium,
413 };
414
415 let item2 = TodoItem {
416 tag: "TODO".to_string(),
417 message: "Test".to_string(),
418 line: 1,
419 column: 1,
420 line_content: Some("// TODO: Test".to_string()),
421 author: None,
422 priority: Priority::Medium,
423 };
424
425 assert_eq!(item1, item2);
426 }
427
428 #[test]
429 fn test_priority_ordering() {
430 assert!(Priority::Critical > Priority::High);
431 assert!(Priority::High > Priority::Medium);
432 assert!(Priority::Medium > Priority::Low);
433 }
434
435 #[test]
436 fn test_no_match_todo_in_accented_word() {
437 let parser = TodoParser::new(&default_tags(), false);
439 let result = parser.parse_line("El método es importante", 1);
440 assert!(result.is_none(), "Should not match 'todo' inside 'método'");
441 }
442
443 #[test]
444 fn test_no_match_todos_spanish_portuguese() {
445 let parser = TodoParser::new(&default_tags(), false);
447
448 let result1 = parser.parse_line("Para todos los usuarios", 1);
449 assert!(
450 result1.is_none(),
451 "Should not match 'todos' (Spanish for 'all')"
452 );
453
454 let result2 = parser.parse_line("Obrigado a todos vocês", 1);
455 assert!(
456 result2.is_none(),
457 "Should not match 'todos' (Portuguese for 'all')"
458 );
459 }
460
461 #[test]
462 fn test_no_match_todo_suffix_in_unicode() {
463 let parser = TodoParser::new(&default_tags(), false);
465
466 let result = parser.parse_line("O método científico", 1);
467 assert!(
468 result.is_none(),
469 "Should not match '-todo' suffix after accented char"
470 );
471 }
472
473 #[test]
474 fn test_match_real_todo_after_unicode() {
475 let parser = TodoParser::new(&default_tags(), false);
477
478 let result = parser.parse_line("café // TODO: add milk", 1);
479 assert!(
480 result.is_some(),
481 "Should match real TODO after Unicode text"
482 );
483 assert_eq!(result.unwrap().message, "add milk");
484 }
485
486 #[test]
487 fn test_match_todo_with_unicode_in_message() {
488 let parser = TodoParser::new(&default_tags(), false);
490
491 let result = parser.parse_line("// TODO: añadir más café", 1);
492 assert!(
493 result.is_some(),
494 "Should match TODO with Unicode in message"
495 );
496 assert_eq!(result.unwrap().message, "añadir más café");
497 }
498
499 #[test]
500 fn test_no_match_cyrillic_boundary() {
501 let parser = TodoParser::new(&default_tags(), false);
503
504 let result = parser.parse_line("использовать методологию", 1);
506 assert!(
507 result.is_none(),
508 "Should not match TODO inside Cyrillic word"
509 );
510 }
511
512 #[test]
513 fn test_no_match_cjk_adjacent() {
514 let parser = TodoParser::new(&default_tags(), false);
517
518 let result = parser.parse_line("完成TODO任务", 1);
519 assert!(
521 result.is_none(),
522 "Should not match TODO between CJK characters"
523 );
524 }
525
526 #[test]
527 fn test_match_todo_after_cjk_with_comment() {
528 let parser = TodoParser::new(&default_tags(), false);
530
531 let result = parser.parse_line("中文 // TODO: task here", 1);
533 assert!(result.is_some(), "Should match TODO in comment after CJK");
534 assert_eq!(result.unwrap().message, "task here");
535
536 let result2 = parser.parse_line("中文 TODO: task here", 1);
538 assert!(
539 result2.is_none(),
540 "Should NOT match TODO without comment marker"
541 );
542 }
543
544 #[test]
545 fn test_typst_document_false_positive() {
546 let parser = TodoParser::new(&default_tags(), false);
548
549 let content = r#"
550O método científico é fundamental.
551Para todos os estudantes.
552El método de investigación.
553"#;
554 let items = parser.parse_content(content);
555 assert_eq!(
556 items.len(),
557 0,
558 "Should not find any false positive TODOs in typst content"
559 );
560 }
561
562 #[test]
563 fn test_mixed_real_and_false_todos() {
564 let parser = TodoParser::new(&default_tags(), false);
566
567 let content = r#"
568// TODO: This is a real todo
569O método científico
570# FIXME: Another real one
571Para todos vocês
572"#;
573 let items = parser.parse_content(content);
574 assert_eq!(
575 items.len(),
576 2,
577 "Should only find real TODOs, not false positives"
578 );
579 assert_eq!(items[0].tag, "TODO");
580 assert_eq!(items[1].tag, "FIXME");
581 }
582
583 fn tags_with_error() -> Vec<String> {
584 vec![
585 "TODO".to_string(),
586 "FIXME".to_string(),
587 "BUG".to_string(),
588 "NOTE".to_string(),
589 "HACK".to_string(),
590 "ERROR".to_string(),
591 ]
592 }
593
594 #[test]
595 fn test_hash_comment_matches_like_vscode_extension() {
596 let parser = TodoParser::new(&tags_with_error(), false);
604
605 let result = parser.parse_line("# ERROR: something", 1);
608 assert!(result.is_some(), "Should match # ERROR: comment");
609 assert_eq!(result.unwrap().tag, "ERROR");
610
611 let result2 = parser.parse_line("# ErrorHandling", 1);
613 assert!(
614 result2.is_none(),
615 "Should not match without separator after tag"
616 );
617 }
618
619 #[test]
620 fn test_no_match_error_in_markdown_prose() {
621 let parser = TodoParser::new(&tags_with_error(), false);
623
624 let result = parser.parse_line("Use error classes for granular error handling", 1);
625 assert!(result.is_none(), "Should not match 'error' in prose text");
626
627 let result2 = parser.parse_line("The error message with status info", 1);
628 assert!(
629 result2.is_none(),
630 "Should not match 'error' in prose describing error messages"
631 );
632 }
633
634 #[test]
635 fn test_no_match_error_in_code_block_content() {
636 let parser = TodoParser::new(&tags_with_error(), false);
638
639 let result = parser.parse_line("throw new Error('Something went wrong')", 1);
640 assert!(result.is_none(), "Should not match 'Error' in code example");
641
642 let result2 = parser.parse_line("catch (error) {", 1);
643 assert!(
644 result2.is_none(),
645 "Should not match 'error' in catch statement"
646 );
647 }
648
649 #[test]
650 fn test_match_real_error_comment() {
651 let parser = TodoParser::new(&tags_with_error(), false);
653
654 let result = parser.parse_line("// ERROR: This needs to be fixed", 1);
655 assert!(result.is_some(), "Should match real ERROR comment");
656 assert_eq!(result.unwrap().tag, "ERROR");
657
658 let result2 = parser.parse_line("# ERROR: Handle this case", 1);
659 assert!(result2.is_some(), "Should match ERROR with # comment");
660 assert_eq!(result2.unwrap().tag, "ERROR");
661
662 let result3 = parser.parse_line("/* ERROR: Critical issue */", 1);
663 assert!(result3.is_some(), "Should match ERROR in block comment");
664 assert_eq!(result3.unwrap().tag, "ERROR");
665 }
666
667 #[test]
668 fn test_markdown_docs_with_ripgrep_style() {
669 let parser = TodoParser::new(&tags_with_error(), false);
675
676 let content = r#"
677# Error Handling
678
679Use error classes for granular error handling.
680
681## Error Classes
682
683The following error classes are available:
684
685- `FetchError`: Base error class
686- `NetworkError`: Network-related errors
687- `TimeoutError`: Request timeout errors
688
689### Custom Error Types
690
691You can create custom error types by extending the base class.
692
693The error message with status info helps debugging.
694
695```typescript
696class CustomError extends FetchError {
697 constructor(message: string) {
698 super(message);
699 }
700}
701```
702"#;
703 let items = parser.parse_content(content);
704 assert!(
707 items.len() >= 2,
708 "Markdown headings with ERROR followed by space will match with ripgrep-style"
709 );
710 }
711
712 fn tags_with_test() -> Vec<String> {
713 vec![
714 "TODO".to_string(),
715 "FIXME".to_string(),
716 "TEST".to_string(),
717 "NOTE".to_string(),
718 ]
719 }
720
721 #[test]
722 fn test_no_match_json_script_keys() {
723 let parser = TodoParser::new(&tags_with_test(), false);
725
726 let result = parser.parse_line(r#" "test: ci": "turbo run test","#, 1);
728 assert!(
729 result.is_none(),
730 "Should not match 'test' in JSON key '\"test: ci\"'"
731 );
732
733 let result2 = parser.parse_line(r#" "test:ci": "turbo run test","#, 1);
734 assert!(
735 result2.is_none(),
736 "Should not match 'test' in JSON key '\"test:ci\"'"
737 );
738
739 let result3 = parser.parse_line(r#" "test:coverage": "vitest --coverage","#, 1);
740 assert!(
741 result3.is_none(),
742 "Should not match 'test' in JSON key '\"test:coverage\"'"
743 );
744
745 let result4 = parser.parse_line(r#" "test:watch": "vitest --watch","#, 1);
746 assert!(
747 result4.is_none(),
748 "Should not match 'test' in JSON key '\"test:watch\"'"
749 );
750 }
751
752 #[test]
753 fn test_no_match_json_various_patterns() {
754 let parser = TodoParser::new(&tags_with_test(), false);
755
756 let cases = vec![
758 r#""test:unit": "jest""#,
759 r#""test:e2e": "cypress run""#,
760 r#""test:lint": "eslint .""#,
761 r#" "note:important": "value","#,
762 r#"{"test": "vitest"}"#,
763 ];
764
765 for case in cases {
766 let result = parser.parse_line(case, 1);
767 assert!(result.is_none(), "Should not match tag in JSON: {}", case);
768 }
769 }
770
771 #[test]
772 fn test_match_real_todo_in_json_comment() {
773 let parser = TodoParser::new(&tags_with_test(), false);
775
776 let result = parser.parse_line("// TODO: update package.json scripts", 1);
777 assert!(result.is_some(), "Should match real TODO comment");
778 assert_eq!(result.unwrap().tag, "TODO");
779 }
780
781 #[test]
782 fn test_package_json_comprehensive() {
783 let parser = TodoParser::new(&tags_with_test(), false);
785
786 let content = r#"
787{
788 "name": "my-project",
789 "scripts": {
790 "build": "turbo run build",
791 "test": "vitest",
792 "test:ci": "turbo run test",
793 "test:coverage": "vitest --coverage",
794 "test:ui": "vitest --ui",
795 "test:watch": "vitest --watch",
796 "note:deploy": "echo 'deploy script'"
797 }
798}
799"#;
800 let items = parser.parse_content(content);
801 assert_eq!(
802 items.len(),
803 0,
804 "Should not find any false positive TODOs in package.json"
805 );
806 }
807}