todo_tree/
parser.rs

1use colored::Color;
2use regex::{Regex, RegexBuilder};
3use std::path::Path;
4use todo_tree_core::{Priority, TodoItem};
5
6/// Get the color associated with a priority level
7pub 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))]
17/// Default regex pattern for matching TODO-style tags in comments.
18///
19/// This pattern is inspired by the VSCode Todo Tree extension and matches tags
20/// that appear after common comment markers.
21///
22/// Pattern breakdown:
23/// - `(//|#|<!--|;|/\*|\*|--)`  - Comment markers for most languages
24/// - `\s*`                       - Optional whitespace after comment marker
25/// - `($TAGS)`                   - The tag to match (placeholder, replaced at runtime)
26/// - `(?:\(([^)]+)\))?`          - Optional author in parentheses
27/// - `[:\s]`                     - Colon or whitespace after tag
28/// - `(.*)`                      - The message
29///
30/// Supported comment syntaxes:
31///   //    - C, C++, Java, JavaScript, TypeScript, Rust, Go, Swift, Kotlin
32///   #     - Python, Ruby, Shell, YAML, TOML
33///   /*    - C-style block comments
34///   *     - Block comment continuation lines
35///   <!--  - HTML, XML, Markdown comments
36///   --    - SQL, Lua, Haskell, Ada
37///   ;     - Lisp, Clojure, Assembly, INI files
38///   %     - LaTeX, Erlang, MATLAB, Prolog
39///   """   - Python docstrings
40///   '''   - Python docstrings
41///   REM   - Batch files
42///   ::    - Batch files
43pub const DEFAULT_REGEX: &str =
44    r#"(//|#|<!--|;|/\*|\*|--|%|"""|'''|REM\s|::)\s*($TAGS)(?:\(([^)]+)\))?[:\s]+(.*)"#;
45
46/// Parser for detecting TODO-style tags in source code
47#[derive(Debug, Clone)]
48pub struct TodoParser {
49    /// Compiled regex pattern for matching tags (None if no tags to search for)
50    pattern: Option<Regex>,
51
52    /// Tags being searched for
53    tags: Vec<String>,
54
55    /// Whether matching is case-sensitive
56    case_sensitive: bool,
57
58    /// The regex pattern string (for ripgrep integration)
59    pattern_string: Option<String>,
60}
61
62impl TodoParser {
63    /// Create a new parser with the given tags
64    pub fn new(tags: &[String], case_sensitive: bool) -> Self {
65        Self::with_regex(tags, case_sensitive, None)
66    }
67
68    /// Create a new parser with a custom regex pattern
69    ///
70    /// The pattern should contain `$TAGS` as a placeholder which will be replaced
71    /// with the alternation of escaped tags (e.g., `TODO|FIXME|BUG`).
72    ///
73    /// If no custom pattern is provided, the default pattern is used.
74    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    /// Build the regex pattern for matching tags
85    ///
86    /// Returns both the compiled regex and the pattern string (for ripgrep integration).
87    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        // Escape special regex characters in tags
97        let escaped_tags: Vec<String> = tags.iter().map(|t| regex::escape(t)).collect();
98        let tags_alternation = escaped_tags.join("|");
99
100        // Use custom regex or default
101        let base_pattern = custom_regex.unwrap_or(DEFAULT_REGEX);
102
103        // Replace $TAGS placeholder with the actual tags alternation
104        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    /// Get the regex pattern string for ripgrep integration
116    pub fn pattern_string(&self) -> Option<&str> {
117        self.pattern_string.as_deref()
118    }
119
120    /// Parse a single line for TODO items
121    pub fn parse_line(&self, line: &str, line_number: usize) -> Option<TodoItem> {
122        let pattern = self.pattern.as_ref()?;
123
124        // Try to match the pattern
125        if let Some(captures) = pattern.captures(line) {
126            // Default pattern capture groups:
127            // Group 1: Comment marker (e.g., //, #, /*, etc.)
128            // Group 2: Tag (e.g., TODO, FIXME, BUG)
129            // Group 3: Author (optional, in parentheses)
130            // Group 4: Message
131            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            // Calculate column (1-indexed)
141            let column = tag_match.start() + 1;
142
143            // Normalize the tag case for consistency
144            let normalized_tag = if self.case_sensitive {
145                tag
146            } else {
147                // Find the matching tag from our list (preserving original case)
148                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    /// Parse content (multiple lines) for TODO items
172    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    /// Parse a file for TODO items
181    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    /// Get the tags being searched for
187    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        // Test all priority levels have a color
323        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        // Unknown tags should default to Medium priority
332        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        // Test case variations
340        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        // "método" (Spanish/Portuguese for "method") contains "todo" but should not match
438        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        // "todos" means "all" in Spanish/Portuguese and should not match
446        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        // Words ending in -todo with accented prefix should not match
464        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        // A real TODO after Unicode text should still match
476        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        // TODO with Unicode characters in the message should work
489        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        // Cyrillic characters should also be treated as word characters
502        let parser = TodoParser::new(&default_tags(), false);
503
504        // "методология" contains "todo" pattern but with Cyrillic prefix
505        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        // CJK characters adjacent to TODO should prevent matching
515        // (though CJK doesn't typically have this issue, good to test)
516        let parser = TodoParser::new(&default_tags(), false);
517
518        let result = parser.parse_line("完成TODO任务", 1);
519        // This actually should NOT match because 成 and 任 are letters
520        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        // TODO in a comment after CJK text should match
529        let parser = TodoParser::new(&default_tags(), false);
530
531        // With comment-only detection, bare "TODO:" doesn't match - needs comment marker
532        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        // Without comment marker, should NOT match
537        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        // Real-world case from the issue: typst document with "método"
547        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        // Mix of real TODOs and false positives
565        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        // With ripgrep-style matching (like VSCode Todo Tree extension),
597        // # is treated as a comment marker. This means markdown headings
598        // like "# Error Handling" will match if ERROR is a tag.
599        //
600        // This is intentional - users should exclude *.md files if they
601        // don't want to scan markdown headings. The VSCode extension
602        // works the same way.
603        let parser = TodoParser::new(&tags_with_error(), false);
604
605        // "# Error Handling" matches because # is a comment marker
606        // and "Error" is followed by whitespace
607        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        // Tags require a colon or whitespace separator after them
612        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        // Prose containing "error" should NOT match
622        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        // Code examples showing error usage should NOT match
637        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        // Real ERROR comments should still match
652        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        // With ripgrep-style matching, # is a comment marker, so markdown
670        // headings with tags followed by separators will match.
671        //
672        // This matches VSCode Todo Tree extension behavior. Users should
673        // exclude markdown files or use custom regex if this is undesired.
674        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        // With ripgrep-style, "# Error Handling" and "## Error Classes" match
705        // because # is a comment marker and ERROR tag is followed by space
706        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        // JSON script keys like "test: ci" should NOT match
724        let parser = TodoParser::new(&tags_with_test(), false);
725
726        // These are from package.json scripts section
727        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        // npm script naming conventions
757        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        // Real TODO comments in JS files with JSON-like content should match
774        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        // Full package.json content test
784        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}