1use crate::core::{TodoItem, TodoPriority};
4use memchr::{memchr2, memmem};
5use regex::{Regex, RegexBuilder};
6use std::path::Path;
7
8pub const DEFAULT_REGEX: &str =
43 r#"(//|#|<!--|;|/\*|\*|--|%|"""|'''|REM\s|(?:^|\s)::)\s*($TAGS)(?:\(([^)]+)\))?:(.*)"#;
44
45#[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 pub fn new(tags: &[String], case_sensitive: bool) -> Self {
58 Self::with_options(tags, case_sensitive, true, None)
59 }
60
61 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 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 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 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 pub fn tags(&self) -> &[String] {
179 &self.tags
180 }
181}
182
183fn 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
189fn 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 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 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 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 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 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}