Skip to main content

todo_tree/
parser.rs

1//! Regex-based parsing of TODO-style comments out of file content.
2
3use crate::core::{TodoItem, TodoPriority};
4use memchr::{memchr2, memmem};
5use regex::{Regex, RegexBuilder};
6use std::path::Path;
7
8/// Default regex pattern for matching TODO-style tags in comments.
9///
10/// This pattern is inspired by the VSCode Todo Tree extension and matches tags
11/// that appear after common comment markers.
12///
13/// Pattern breakdown:
14/// - `(//|#|<!--|;|/\*|\*|--|(?:^|\s)::)`  - Comment markers for most languages
15/// - `\s*`                       - Optional whitespace after comment marker
16/// - `($TAGS)`                   - The tag to match (placeholder, replaced at runtime)
17/// - `(?:\(([^)]+)\))?`          - Optional author in parentheses
18/// - `:`                         - Required colon after tag
19/// - `(.*)`                      - The message
20///
21/// Supported comment syntaxes:
22/// ```text
23///   //    - C, C++, Java, JavaScript, TypeScript, Rust, Go, Swift, Kotlin
24///   #     - Python, Ruby, Shell, YAML, TOML
25///   /*    - C-style block comments
26///   *     - Block comment continuation lines
27///   <!--  - HTML, XML, Markdown comments
28///   --    - SQL, Lua, Haskell, Ada
29///   ;     - Lisp, Clojure, Assembly, INI files
30///   %     - LaTeX, Erlang, MATLAB, Prolog
31///   """   - Python docstrings
32///   '''   - Python docstrings
33///   REM   - Batch files
34///   ::    - Batch files, Lua block-comment idiom (line-start or after whitespace only)
35/// ```
36///
37/// `::` is only recognized as a marker when it's at the start of the line or
38/// preceded by whitespace (the `regex` crate has no lookbehind, so this is
39/// baked into the alternative itself rather than a separate assertion).
40/// `std::io::Error` never matches: every `::` in it is preceded by an
41/// identifier character, not whitespace or line-start.
42pub const DEFAULT_REGEX: &str =
43    r#"(//|#|<!--|;|/\*|\*|--|%|"""|'''|REM\s|(?:^|\s)::)\s*($TAGS)(?:\(([^)]+)\))?:(.*)"#;
44
45/// Parses TODO-style tags out of file content using a configurable regex.
46#[derive(Debug, Clone)]
47pub struct TodoParser {
48    pattern: Option<Regex>,
49    tags: Vec<String>,
50    tag_bytes: Vec<Vec<u8>>,
51    case_sensitive: bool,
52}
53
54impl TodoParser {
55    /// Creates a parser for `tags`, requiring an exact-case match and a
56    /// trailing colon, using the default comment-marker regex.
57    pub fn new(tags: &[String], case_sensitive: bool) -> Self {
58        Self::with_options(tags, case_sensitive, true, None)
59    }
60
61    /// Creates a parser with full control over case sensitivity, whether a
62    /// trailing colon is required, and an optional custom regex (in place
63    /// of [`DEFAULT_REGEX`]).
64    pub fn with_options(
65        tags: &[String],
66        case_sensitive: bool,
67        require_colon: bool,
68        custom_regex: Option<&str>,
69    ) -> Self {
70        let pattern = Self::build_pattern(tags, case_sensitive, require_colon, custom_regex);
71        let tag_bytes = tags.iter().map(|tag| tag.as_bytes().to_vec()).collect();
72        Self {
73            pattern,
74            tags: tags.to_vec(),
75            tag_bytes,
76            case_sensitive,
77        }
78    }
79
80    fn build_pattern(
81        tags: &[String],
82        case_sensitive: bool,
83        require_colon: bool,
84        custom_regex: Option<&str>,
85    ) -> Option<Regex> {
86        if tags.is_empty() {
87            return None;
88        }
89
90        let escaped_tags: Vec<String> = tags.iter().map(|t| regex::escape(t)).collect();
91        let tags_alternation = escaped_tags.join("|");
92
93        let mut base_pattern = custom_regex.unwrap_or(DEFAULT_REGEX).to_string();
94        if custom_regex.is_none() && !require_colon {
95            base_pattern = base_pattern.replace(":(.*)", r"(?:\s*$|(?:(?::|\s+)(.*)))");
96        }
97
98        let pattern_string = base_pattern.replace("$TAGS", &tags_alternation);
99        let regex = RegexBuilder::new(&pattern_string)
100            .case_insensitive(!case_sensitive)
101            .multi_line(true)
102            .build()
103            .expect("Failed to build regex pattern");
104
105        Some(regex)
106    }
107
108    /// Parses a single line, returning the matched item if the line
109    /// contains one of the parser's tags.
110    pub fn parse_line(&self, line: &str, line_number: usize) -> Option<TodoItem> {
111        let pattern = self.pattern.as_ref()?;
112        if let Some(captures) = pattern.captures(line) {
113            let tag_match = captures.get(2)?;
114            let author = captures.get(3).map(|m| m.as_str().to_string());
115            let message = captures
116                .get(4)
117                .map(|m| m.as_str().trim().to_string())
118                .unwrap_or_default();
119
120            let tag = tag_match.as_str().to_string();
121            let column = tag_match.start() + 1;
122
123            let normalized_tag = if self.case_sensitive {
124                tag
125            } else {
126                self.tags
127                    .iter()
128                    .find(|t| t.eq_ignore_ascii_case(&tag))
129                    .cloned()
130                    .unwrap_or(tag)
131            };
132
133            let priority = TodoPriority::from_tag(&normalized_tag);
134
135            return Some(TodoItem {
136                tag: normalized_tag,
137                message,
138                line: line_number,
139                column,
140                line_content: Some(line.to_string()),
141                author,
142                priority,
143            });
144        }
145
146        None
147    }
148
149    /// Parses every line of `content`, returning all matched items in
150    /// order.
151    pub fn parse_content(&self, content: &str) -> Vec<TodoItem> {
152        content
153            .lines()
154            .enumerate()
155            .filter_map(|(idx, line)| self.parse_line(line, idx + 1))
156            .collect()
157    }
158
159    /// Reads `path` and parses its contents.
160    ///
161    /// Before paying for UTF-8 validation and a regex pass, this does a
162    /// cheap `memchr`-based byte scan for any of the configured tags. Files
163    /// that can't possibly match (lockfiles, bundled JS, binaries) are
164    /// skipped without ever being validated as UTF-8.
165    pub fn parse_file(&self, path: &Path) -> std::io::Result<Vec<TodoItem>> {
166        let bytes = std::fs::read(path)?;
167
168        if !contains_any_tag_bytes(&bytes, &self.tag_bytes, self.case_sensitive) {
169            return Ok(Vec::new());
170        }
171
172        let content = String::from_utf8(bytes)
173            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
174        Ok(self.parse_content(&content))
175    }
176
177    /// The tags this parser was configured with.
178    pub fn tags(&self) -> &[String] {
179        &self.tags
180    }
181}
182
183/// Whether `haystack` contains any of `tags` as a byte substring.
184fn contains_any_tag_bytes(haystack: &[u8], tags: &[Vec<u8>], case_sensitive: bool) -> bool {
185    tags.iter()
186        .any(|tag| contains_tag_bytes(haystack, tag, case_sensitive))
187}
188
189/// Whether `haystack` contains `needle` as a byte substring.
190///
191/// In case-sensitive mode this is a direct [`memmem`] search. In
192/// case-insensitive mode, [`memchr2`] finds candidate positions matching
193/// either case of `needle`'s first byte (which `memmem` can't do), and each
194/// candidate is verified with an ASCII case-insensitive comparison.
195fn contains_tag_bytes(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> bool {
196    if needle.is_empty() || haystack.len() < needle.len() {
197        return false;
198    }
199
200    if case_sensitive {
201        return memmem::find(haystack, needle).is_some();
202    }
203
204    let first = needle[0];
205    let (lower, upper) = (first.to_ascii_lowercase(), first.to_ascii_uppercase());
206
207    let mut offset = 0;
208    while let Some(pos) = memchr2(lower, upper, &haystack[offset..]) {
209        let start = offset + pos;
210        let end = start + needle.len();
211
212        if end <= haystack.len() && haystack[start..end].eq_ignore_ascii_case(needle) {
213            return true;
214        }
215
216        offset = start + 1;
217    }
218
219    false
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::fs;
226    use std::time::{SystemTime, UNIX_EPOCH};
227
228    fn tags() -> Vec<String> {
229        vec!["TODO".to_string(), "FIXME".to_string(), "BUG".to_string()]
230    }
231
232    fn custom_parser(tags: &[String], case_sensitive: bool) -> TodoParser {
233        // Capture layout must match parse_line():
234        // 1 = prefix
235        // 2 = tag
236        // 3 = author
237        // 4 = message
238        TodoParser::with_options(
239            tags,
240            case_sensitive,
241            true,
242            Some(r"(^|\s)($TAGS)(?:\(([^)]+)\))?(?::(.*))?$"),
243        )
244    }
245
246    #[test]
247    fn new_uses_default_options() {
248        let parser = TodoParser::new(&tags(), true);
249        assert_eq!(parser.tags(), &tags());
250        assert!(parser.pattern.is_some());
251    }
252
253    #[test]
254    fn empty_tags_disable_parsing() {
255        let parser = TodoParser::new(&[], true);
256
257        assert!(parser.pattern.is_none());
258        assert!(parser.parse_line("// TODO: message", 1).is_none());
259        assert!(parser.parse_content("// TODO: message").is_empty());
260    }
261
262    #[test]
263    fn parse_line_with_custom_regex_extracts_basic_fields() {
264        let parser = custom_parser(&tags(), true);
265        let item = parser
266            .parse_line(" TODO: write more tests", 7)
267            .expect("expected TODO item");
268
269        assert_eq!(item.tag, "TODO");
270        assert_eq!(item.message, "write more tests");
271        assert_eq!(item.author, None);
272        assert_eq!(item.line, 7);
273        assert_eq!(item.column, 2);
274        assert_eq!(
275            item.line_content.as_deref(),
276            Some(" TODO: write more tests")
277        );
278        assert_eq!(item.priority, TodoPriority::from_tag("TODO"));
279    }
280
281    #[test]
282    fn parse_line_with_custom_regex_extracts_author() {
283        let parser = custom_parser(&tags(), true);
284        let item = parser
285            .parse_line(" FIXME(alice): handle edge case", 3)
286            .expect("expected FIXME item");
287
288        assert_eq!(item.tag, "FIXME");
289        assert_eq!(item.author.as_deref(), Some("alice"));
290        assert_eq!(item.message, "handle edge case");
291        assert_eq!(item.line, 3);
292        assert_eq!(item.column, 2);
293        assert_eq!(item.priority, TodoPriority::from_tag("FIXME"));
294    }
295
296    #[test]
297    fn parse_line_trims_message() {
298        let parser = custom_parser(&tags(), true);
299        let item = parser
300            .parse_line(" TODO:   message with spaces   ", 1)
301            .expect("expected TODO item");
302
303        assert_eq!(item.message, "message with spaces");
304    }
305
306    #[test]
307    fn case_sensitive_parser_rejects_wrong_case() {
308        let parser = custom_parser(&tags(), true);
309
310        assert!(parser.parse_line(" todo: lower-case tag", 1).is_none());
311        assert!(parser.parse_line(" TODO: upper-case tag", 1).is_some());
312    }
313
314    #[test]
315    fn case_insensitive_parser_accepts_and_normalizes_tag() {
316        let parser = custom_parser(&tags(), false);
317        let item = parser
318            .parse_line(" todo: lower-case tag", 1)
319            .expect("expected TODO item");
320
321        // In case-insensitive mode, the tag should be normalized back
322        // to the configured spelling from self.tags.
323        assert_eq!(item.tag, "TODO");
324        assert_eq!(item.message, "lower-case tag");
325        assert_eq!(item.priority, TodoPriority::from_tag("TODO"));
326    }
327
328    #[test]
329    fn case_insensitive_parser_uses_first_matching_configured_tag_spelling() {
330        let tags = vec!["ToDo".to_string(), "FixMe".to_string()];
331        let parser = custom_parser(&tags, false);
332
333        let item = parser
334            .parse_line(" todo: mixed case normalization", 1)
335            .expect("expected ToDo item");
336
337        assert_eq!(item.tag, "ToDo");
338        assert_eq!(item.priority, TodoPriority::from_tag("ToDo"));
339    }
340
341    #[test]
342    fn parse_content_collects_multiple_items_with_correct_line_numbers() {
343        let parser = custom_parser(&tags(), false);
344        let content = "\
345first line
346 TODO: first task
347nothing here
348 fixme(bob): second task
349 BUG: third task";
350
351        let items = parser.parse_content(content);
352
353        assert_eq!(items.len(), 3);
354
355        assert_eq!(items[0].tag, "TODO");
356        assert_eq!(items[0].message, "first task");
357        assert_eq!(items[0].line, 2);
358
359        assert_eq!(items[1].tag, "FIXME");
360        assert_eq!(items[1].author.as_deref(), Some("bob"));
361        assert_eq!(items[1].message, "second task");
362        assert_eq!(items[1].line, 4);
363
364        assert_eq!(items[2].tag, "BUG");
365        assert_eq!(items[2].message, "third task");
366        assert_eq!(items[2].line, 5);
367    }
368
369    #[test]
370    fn parse_file_reads_and_parses_content() {
371        let parser = custom_parser(&tags(), false);
372
373        let unique = SystemTime::now()
374            .duration_since(UNIX_EPOCH)
375            .unwrap()
376            .as_nanos();
377        let path = std::env::temp_dir().join(format!("todo_parser_test_{unique}.txt"));
378
379        fs::write(
380            &path,
381            "\
382ignore
383 TODO: from file
384 FIXME(jane): also from file",
385        )
386        .unwrap();
387
388        let items = parser.parse_file(&path).unwrap();
389        let _ = fs::remove_file(&path);
390
391        assert_eq!(items.len(), 2);
392
393        assert_eq!(items[0].tag, "TODO");
394        assert_eq!(items[0].message, "from file");
395        assert_eq!(items[0].line, 2);
396
397        assert_eq!(items[1].tag, "FIXME");
398        assert_eq!(items[1].author.as_deref(), Some("jane"));
399        assert_eq!(items[1].message, "also from file");
400        assert_eq!(items[1].line, 3);
401    }
402
403    fn temp_file_path(name: &str) -> std::path::PathBuf {
404        let unique = SystemTime::now()
405            .duration_since(UNIX_EPOCH)
406            .unwrap()
407            .as_nanos();
408        std::env::temp_dir().join(format!("todo_parser_test_{name}_{unique}.bin"))
409    }
410
411    #[test]
412    fn parse_file_fast_skip_avoids_utf8_error_when_no_tag_present() {
413        let parser = TodoParser::new(&tags(), true);
414        let path = temp_file_path("no_tag_invalid_utf8");
415
416        // No tag bytes anywhere in this buffer, so the fast-skip should
417        // return an empty result without ever validating it as UTF-8.
418        fs::write(&path, [0xFF, 0xFE, 0xFD, b'x', b'y', b'z']).unwrap();
419
420        let items = parser.parse_file(&path).unwrap();
421        let _ = fs::remove_file(&path);
422
423        assert!(items.is_empty());
424    }
425
426    #[test]
427    fn parse_file_still_errors_on_invalid_utf8_when_tag_bytes_present() {
428        let parser = TodoParser::new(&tags(), true);
429        let path = temp_file_path("tag_present_invalid_utf8");
430
431        // "TODO" is present, so the fast-skip can't rule this file out;
432        // the invalid UTF-8 must still surface as an error.
433        let mut content = b"TODO".to_vec();
434        content.extend_from_slice(&[0xFF, 0xFE, 0xFD]);
435        fs::write(&path, content).unwrap();
436
437        let result = parser.parse_file(&path);
438        let _ = fs::remove_file(&path);
439
440        assert!(result.is_err());
441    }
442
443    #[test]
444    fn parse_file_fast_skip_finds_case_insensitive_mixed_case_tag() {
445        let parser = TodoParser::new(&tags(), false);
446        let path = temp_file_path("mixed_case_tag");
447
448        fs::write(&path, "// tOdO: mixed case still matches\n").unwrap();
449
450        let items = parser.parse_file(&path).unwrap();
451        let _ = fs::remove_file(&path);
452
453        assert_eq!(items.len(), 1);
454        assert_eq!(items[0].tag, "TODO");
455    }
456
457    #[test]
458    fn require_colon_true_does_not_match_default_pattern_without_colon() {
459        let parser = TodoParser::with_options(&tags(), false, true, None);
460
461        assert!(parser.parse_line("// TODO missing colon", 1).is_none());
462        assert!(parser.parse_line("// TODO: has colon", 1).is_some());
463    }
464
465    #[test]
466    fn require_colon_false_matches_default_pattern_with_or_without_colon() {
467        let parser = TodoParser::with_options(&tags(), false, false, None);
468
469        let with_colon = parser.parse_line("// TODO: with colon", 1);
470        let with_space = parser.parse_line("// TODO with space", 2);
471        let bare_tag = parser.parse_line("// TODO", 3);
472
473        assert!(with_colon.is_some(), "should match with colon");
474        assert!(
475            with_space.is_some(),
476            "should match with space when colon is optional"
477        );
478        assert!(
479            bare_tag.is_some(),
480            "should match bare tag when colon is optional"
481        );
482
483        let with_space = with_space.unwrap();
484        assert_eq!(with_space.tag, "TODO");
485        assert_eq!(with_space.message, "with space");
486
487        let bare_tag = bare_tag.unwrap();
488        assert_eq!(bare_tag.tag, "TODO");
489        assert_eq!(bare_tag.message, "");
490    }
491
492    #[test]
493    fn require_colon_false_rejects_false_positives() {
494        let parser = TodoParser::with_options(&tags(), false, false, None);
495
496        assert!(
497            parser.parse_line("// TODO.complete()", 4).is_none(),
498            "tag followed by '.' must not match"
499        );
500        assert!(
501            parser.parse_line("// todoList", 5).is_none(),
502            "tag embedded in a word must not match"
503        );
504    }
505
506    #[test]
507    fn require_colon_false_documents_double_colon_behavior() {
508        let parser = TodoParser::with_options(&tags(), false, false, None);
509
510        let item = parser
511            .parse_line("* TODO::module::fn", 6)
512            .expect("double-colon form should match current default regex behavior");
513
514        assert_eq!(item.tag, "TODO");
515        assert_eq!(item.message, ":module::fn");
516    }
517
518    #[test]
519    fn custom_regex_can_support_non_default_syntax() {
520        let tags = vec!["TODO".to_string(), "FIXME".to_string()];
521        let parser = TodoParser::with_options(
522            &tags,
523            false,
524            true,
525            // Matches e.g. "[TODO]{alice}: message"
526            // 1 = prefix
527            // 2 = tag
528            // 3 = author
529            // 4 = message
530            Some(r"(^|\s)\[($TAGS)\](?:\{([^}]+)\})?:(.*)$"),
531        );
532
533        let item = parser
534            .parse_line("[todo]{alice}: custom format works", 10)
535            .expect("expected custom format to match");
536
537        assert_eq!(item.tag, "TODO");
538        assert_eq!(item.author.as_deref(), Some("alice"));
539        assert_eq!(item.message, "custom format works");
540        assert_eq!(item.line, 10);
541        assert_eq!(item.priority, TodoPriority::from_tag("TODO"));
542    }
543
544    #[test]
545    fn default_regex_smoke_test_common_comment_styles() {
546        let parser = TodoParser::with_options(&tags(), false, true, None);
547
548        let slash = parser.parse_line("// TODO: implement feature", 1);
549        let hash = parser.parse_line("# FIXME: fix the bug", 2);
550
551        assert!(slash.is_some(), "default regex should match // TODO: ...");
552        assert!(hash.is_some(), "default regex should match # FIXME: ...");
553
554        let slash = slash.unwrap();
555        assert_eq!(slash.tag, "TODO");
556        assert_eq!(slash.message, "implement feature");
557
558        let hash = hash.unwrap();
559        assert_eq!(hash.tag, "FIXME");
560        assert_eq!(hash.message, "fix the bug");
561    }
562
563    #[test]
564    fn default_regex_matches_double_colon_at_line_start() {
565        let parser = TodoParser::with_options(&tags(), false, true, None);
566
567        let item = parser
568            .parse_line(":: TODO: refactor this", 1)
569            .expect("line-start :: should match");
570
571        assert_eq!(item.tag, "TODO");
572        assert_eq!(item.message, "refactor this");
573    }
574
575    #[test]
576    fn default_regex_matches_double_colon_after_whitespace() {
577        let parser = TodoParser::with_options(&tags(), false, true, None);
578
579        let item = parser
580            .parse_line("let x = 1; :: TODO: cleanup", 1)
581            .expect(":: preceded by whitespace should match");
582
583        assert_eq!(item.tag, "TODO");
584        assert_eq!(item.message, "cleanup");
585    }
586
587    #[test]
588    fn default_regex_rejects_double_colon_scope_resolution() {
589        let parser = TodoParser::with_options(&tags(), false, true, None);
590
591        assert!(
592            parser.parse_line("use std::io::Error;", 1).is_none(),
593            "scope resolution must not match"
594        );
595        assert!(
596            parser
597                .parse_line("let x: std::io::TODO::Error = y;", 2)
598                .is_none(),
599            "tag directly touching a preceding :: (no space) must not match"
600        );
601        assert!(
602            parser.parse_line("Foo::Bar::Baz::new()", 3).is_none(),
603            "chained scope resolution with no tag must not match"
604        );
605    }
606
607    #[test]
608    fn tags_accessor_returns_configured_tags() {
609        let tags = vec!["TODO".to_string(), "FIXME".to_string()];
610        let parser = TodoParser::new(&tags, true);
611
612        assert_eq!(parser.tags(), &tags);
613    }
614}