1use super::{ByteSet, Finder, RunClass, RunRule};
22use crate::word::{boundary_after, boundary_before, char_at};
23use std::collections::HashSet;
24use std::convert::Infallible;
25use std::ops::Range;
26use std::sync::OnceLock;
27
28fn default_mnemonics() -> &'static HashSet<String> {
29 static DEFAULT_MNEMONICS: OnceLock<HashSet<String>> = OnceLock::new();
30 DEFAULT_MNEMONICS.get_or_init(|| {
31 [
32 "TODO",
34 "MILESTONE",
35 "MLSTN",
36 "DONE",
37 "YAGNI",
38 "TBD",
39 "TOBEDONE",
40 "FIXME",
42 "XXX",
43 "DEBUG",
44 "BROKEN",
45 "REFACTOR",
46 "REFACT",
47 "RFCTR",
48 "OOPS",
49 "SMELL",
50 "NEEDSWORK",
51 "INSPECT",
52 "BUG",
54 "BUGFIX",
55 "NOBUG",
57 "NOFIX",
58 "WONTFIX",
59 "DONTFIX",
60 "NEVERFIX",
61 "UNFIXABLE",
62 "CANTFIX",
63 "REQ",
65 "REQUIREMENT",
66 "STORY",
67 "RFE",
69 "FEETCH",
70 "NYI",
71 "FR",
72 "FTRQ",
73 "FTR",
74 "IDEA",
76 "???",
78 "QUESTION",
79 "QUEST",
80 "QSTN",
81 "WTF",
82 "!!!",
84 "ALERT",
85 "HACK",
87 "CLEVER",
88 "MAGIC",
89 "PORT",
91 "PORTABILITY",
92 "WKRD",
93 "CAVEAT",
95 "CAV",
96 "CAVT",
97 "WARNING",
98 "CAUTION",
99 "NOTE",
101 "HELP",
102 "FAQ",
104 "GLOSS",
106 "GLOSSARY",
107 "SEE",
109 "REF",
110 "REFERENCE",
111 "TODOC",
113 "DOCDO",
114 "DODOC",
115 "NEEDSDOC",
116 "EXPLAIN",
117 "DOCUMENT",
118 "CRED",
120 "CREDIT",
121 "THANKS",
122 "STAT",
124 "STATUS",
125 "RVD",
127 "REVIEWED",
128 "REVIEW",
129 ]
130 .iter()
131 .map(|s| s.to_string())
132 .collect()
133 })
134}
135
136struct Mnemonic {
138 folded: Vec<char>,
141 word: bool,
144 ascii_len: Option<usize>,
147}
148
149fn fold(c: char) -> char {
153 match c {
154 '\u{212A}' => 'k',
155 '\u{017F}' => 's',
156 _ => {
157 let mut lower = c.to_lowercase();
158 match (lower.next(), lower.next()) {
159 (Some(l), None) => l,
160 _ => c,
161 }
162 }
163 }
164}
165
166impl Mnemonic {
167 fn match_at(&self, input: &[u8], pos: usize) -> Option<usize> {
169 let mut p = pos;
170 for &want in &self.folded {
171 let (c, width) = char_at(input, p)?;
172 let same = if c.is_ascii() && want.is_ascii() {
173 c.eq_ignore_ascii_case(&want)
174 } else {
175 fold(c) == want
176 };
177 if !same {
178 return None;
179 }
180 p += width;
181 }
182 Some(p)
183 }
184}
185
186struct Index {
188 mnemonics: Vec<Mnemonic>,
189 by_first: Vec<Vec<u16>>,
191 next_after: Vec<ByteSet>,
193 anywhere: ByteSet,
195}
196
197impl Index {
198 fn build(mnemonics: impl Iterator<Item = String>) -> Index {
199 let mut prepared: Vec<Mnemonic> = mnemonics
200 .map(|m| Mnemonic {
201 word: m.chars().all(|c| c.is_alphanumeric()),
202 ascii_len: m.is_ascii().then_some(m.len()),
203 folded: m.chars().map(fold).collect(),
204 })
205 .collect();
206 prepared.sort_by(|a, b| b.word.cmp(&a.word).then_with(|| a.folded.cmp(&b.folded)));
208
209 let mut by_first = vec![Vec::new(); 256];
210 let mut next_after = vec![ByteSet::EMPTY; 256];
211 let mut anywhere = ByteSet::EMPTY;
212 for (id, m) in prepared.iter().enumerate() {
213 let Some(&first) = m.folded.first() else {
214 continue;
215 };
216 let second = m.folded.get(1).copied();
217 for variant in case_variants(first) {
218 let mut buf = [0u8; 4];
219 let bytes = variant.encode_utf8(&mut buf).as_bytes();
220 let lead = bytes[0];
221 if !by_first[lead as usize].contains(&(id as u16)) {
222 by_first[lead as usize].push(id as u16);
223 }
224 if !m.word {
225 anywhere = anywhere.with(lead);
226 }
227 let mut next = next_after[lead as usize];
228 if bytes.len() > 1 {
229 next = next.with(bytes[1]);
230 } else {
231 match second {
232 Some(c) => {
233 for v in case_variants(c) {
234 let mut buf = [0u8; 4];
235 next = next.with(v.encode_utf8(&mut buf).as_bytes()[0]);
236 }
237 }
238 None => next = next.with(b'(').with(b':'),
239 }
240 }
241 next_after[lead as usize] = next;
242 }
243 }
244 Index {
245 mnemonics: prepared,
246 by_first,
247 next_after,
248 anywhere,
249 }
250 }
251}
252
253fn case_variants(c: char) -> Vec<char> {
256 let mut variants = vec![c];
257 let extra = match fold(c) {
258 'k' => Some('\u{212A}'),
259 's' => Some('\u{017F}'),
260 _ => None,
261 };
262 for v in c.to_lowercase().chain(c.to_uppercase()).chain(extra) {
263 if !variants.contains(&v) {
264 variants.push(v);
265 }
266 }
267 variants
268}
269
270pub struct Codetag {
296 pub hide_mnemonic: bool,
298 mnemonics: HashSet<String>,
299 index: OnceLock<Index>,
300}
301
302impl Default for Codetag {
303 fn default() -> Self {
304 Codetag {
305 hide_mnemonic: false,
306 mnemonics: HashSet::new(),
307 index: OnceLock::new(),
308 }
309 }
310}
311
312impl Codetag {
313 fn index(&self) -> &Index {
314 self.index.get_or_init(|| self.build_index())
315 }
316
317 fn build_index(&self) -> Index {
318 let mnemonics = if self.mnemonics.is_empty() {
319 default_mnemonics().iter()
320 } else {
321 self.mnemonics.iter()
322 };
323 Index::build(mnemonics.cloned())
324 }
325
326 fn match_at(&self, input: &[u8], pos: usize) -> Option<(usize, usize)> {
329 let index = self.index();
330 let ids = &index.by_first[input[pos] as usize];
331 if ids.is_empty() {
332 return None;
333 }
334 let mut word_end = pos;
338 let mut ascii = true;
339 while let Some(&b) = input.get(word_end) {
340 if b >= 0x80 {
341 ascii = false;
342 break;
343 }
344 if !(b.is_ascii_alphanumeric() || b == b'_') {
345 break;
346 }
347 word_end += 1;
348 }
349 let word_len = word_end - pos;
350 for &id in ids {
351 let m = &index.mnemonics[id as usize];
352 if m.word && ascii && m.ascii_len.is_some_and(|len| len != word_len) {
353 continue;
354 }
355 let Some(end) = m.match_at(input, pos) else {
356 continue;
357 };
358 if m.word && !(boundary_before(input, pos) && boundary_after(input, end)) {
359 continue;
360 }
361 let mut p = end;
362 if input.get(p) == Some(&b'(') {
363 match input[p + 1..].iter().position(|&b| b == b')') {
364 Some(close) => p = p + 1 + close + 1,
365 None => continue,
366 }
367 }
368 if input.get(p) == Some(&b':') {
369 return Some((end, p + 1));
370 }
371 }
372 None
373 }
374
375 fn range_from(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
376 let (_, head_end) = self.match_at(input, pos)?;
377 let from = if self.hide_mnemonic { head_end } else { pos };
378 let to = input.len();
379 if from >= to { None } else { Some(from..to) }
380 }
381}
382
383impl Finder for Codetag {
384 fn id(&self) -> &'static str {
385 "codetag"
386 }
387
388 fn dispatchable(&self) -> bool {
389 true
390 }
391
392 fn could_start_at(&self, byte: u8) -> bool {
393 !self.index().by_first[byte as usize].is_empty()
394 }
395
396 fn could_start_after(&self, prev: u8, cur: u8) -> bool {
397 self.index().anywhere.contains(cur)
400 || prev >= 0x80
401 || !(prev.is_ascii_alphanumeric() || prev == b'_')
402 }
403
404 fn could_continue_with(&self, cur: u8, next: u8) -> bool {
405 self.index().next_after[cur as usize].contains(next)
406 }
407
408 fn run_rules(&self) -> Vec<RunRule> {
409 let mut lengths = 0u64;
416 let mut after = ByteSet::from_bytes(b":(");
417 let mut max = 0u8;
418 for m in &self.index().mnemonics {
419 let Some(first) = m.folded.first() else {
420 continue;
421 };
422 if !first.is_ascii() || !RunClass::Word.contains(*first as u8) {
423 continue;
424 }
425 let prefix = m
426 .folded
427 .iter()
428 .take_while(|c| c.is_ascii() && RunClass::Word.contains(**c as u8))
429 .count();
430 if let Some(next) = m.folded.get(prefix) {
431 if next.is_ascii() {
432 after = after.with(*next as u8);
433 } else {
434 continue;
436 }
437 }
438 if prefix < 64 {
439 lengths |= 1u64 << prefix;
440 }
441 max = max.max(prefix as u8);
442 }
443 vec![
444 RunRule::new(RunClass::Word, 1, max.max(1))
445 .lengths(lengths)
446 .followed_by_set(after),
447 RunRule::new(RunClass::Word, 0, max.saturating_sub(1))
449 .followed_by_set(ByteSet::from_fn(|b| b >= 0x80)),
450 ]
451 }
452
453 fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
454 self.range_from(input, pos)
455 }
456
457 fn prefixes(&self) -> Vec<Vec<u8>> {
458 let mut prefixes = Vec::new();
459 for m in &self.index().mnemonics {
460 let prefix: Vec<u8> = m
464 .folded
465 .iter()
466 .take_while(|c| c.is_ascii() && !matches!(c, 'k' | 's'))
467 .map(|&c| c as u8)
468 .collect();
469 if prefix.is_empty() {
470 return Vec::new();
471 }
472 prefixes.push(prefix);
473 }
474 prefixes
475 }
476
477 fn find(&self, s: &str) -> Option<Range<usize>> {
481 let input = s.as_bytes();
482 for pos in 0..input.len() {
483 if self.could_start_at(input[pos]) && self.match_at(input, pos).is_some() {
484 return self.range_from(input, pos);
485 }
486 }
487 None
488 }
489}
490
491impl Codetag {
492 pub fn add_mnemonic(&mut self, mnemonic: &str) {
501 let mnemonic = mnemonic.trim();
502 if mnemonic.is_empty() {
503 return;
504 }
505 self.mnemonics.insert(mnemonic.to_uppercase());
506 self.index = OnceLock::new();
507 }
508
509 pub fn build_mnemonics_regex(&mut self) -> Result<(), Infallible> {
515 let index = self.build_index();
516 self.index = OnceLock::new();
517 let _ = self.index.set(index);
518 Ok(())
519 }
520}
521
522#[cfg(test)]
523#[allow(clippy::useless_vec)]
524#[allow(clippy::field_reassign_with_default)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn default_finder_is_ready_to_use() {
530 let finder = Codetag::default();
531 let input = "TODO: check if cmd is installed";
532 assert_eq!(
533 Some("TODO: check if cmd is installed"),
534 finder.find(input).map(|r| &input[r])
535 );
536 }
537
538 #[test]
539 fn it_should_find_at_start_of_line() {
540 let mut finder = Codetag::default();
541 finder.build_mnemonics_regex().unwrap();
542 let input = "TODO: check if cmd is installed";
543 assert_eq!(
544 Some("TODO: check if cmd is installed"),
545 finder.find(input).map(|r| &input[r])
546 );
547 }
548
549 #[test]
550 fn it_should_find_at_middle_of_line() {
551 let mut finder = Codetag::default();
552 finder.build_mnemonics_regex().unwrap();
553 let input = "foobar // TODO: check if cmd is installed";
554 assert_eq!(
555 Some("TODO: check if cmd is installed"),
556 finder.find(input).map(|r| &input[r])
557 );
558 }
559
560 #[test]
561 fn it_should_find_uppercase() {
562 let mut finder = Codetag::default();
563 finder.build_mnemonics_regex().unwrap();
564 let input = "TODO: check if cmd is installed";
565 assert_eq!(
566 Some("TODO: check if cmd is installed"),
567 finder.find(input).map(|r| &input[r])
568 );
569 }
570
571 #[test]
572 fn it_should_find_lowercase() {
573 let mut finder = Codetag::default();
574 finder.build_mnemonics_regex().unwrap();
575 let input = "todo: check if cmd is installed";
576 assert_eq!(
577 Some("todo: check if cmd is installed"),
578 finder.find(input).map(|r| &input[r])
579 );
580 }
581
582 #[test]
583 fn it_should_find_mnemonics_with_empty_description() {
584 let mut finder = Codetag::default();
585 finder.build_mnemonics_regex().unwrap();
586 let input = "todo:";
587 assert_eq!(Some("todo:"), finder.find(input).map(|r| &input[r]));
588 }
589
590 #[test]
591 fn it_should_hide_mnemonics_if_asked_to() {
592 let mut finder = Codetag::default();
593 finder.hide_mnemonic = true;
594 finder.build_mnemonics_regex().unwrap();
595 let input = "todo: foobar";
596 assert_eq!(Some(" foobar"), finder.find(input).map(|r| &input[r]));
597 }
598
599 #[test]
600 fn it_should_limit_results_to_the_given_mnemonics() {
601 let mut finder = Codetag::default();
602 finder.add_mnemonic("test");
603 finder.build_mnemonics_regex().unwrap();
604 let input = "test: check if cmd is installed";
605 assert_eq!(
606 Some("test: check if cmd is installed"),
607 finder.find(input).map(|r| &input[r])
608 );
609 let input = "test2: check if cmd is installed";
610 assert_eq!(None, finder.find(input).map(|r| &input[r]));
611 }
612
613 #[test]
614 fn it_should_ignore_invalid_inputs() {
615 let mut finder = Codetag::default();
616 finder.build_mnemonics_regex().unwrap();
617 for input in vec!["", " "] {
618 assert_eq!(None, finder.find(input));
619 }
620 }
621
622 #[test]
623 fn it_should_find_codetags_with_fields() {
624 let mut finder = Codetag::default();
626 finder.build_mnemonics_regex().unwrap();
627
628 let input = "TODO(john): implement feature";
629 assert_eq!(
630 Some("TODO(john): implement feature"),
631 finder.find(input).map(|r| &input[r])
632 );
633
634 let input = "FIXME(#123): fix this bug";
635 assert_eq!(
636 Some("FIXME(#123): fix this bug"),
637 finder.find(input).map(|r| &input[r])
638 );
639 }
640
641 #[test]
642 fn it_should_find_mixed_case_mnemonics() {
643 let mut finder = Codetag::default();
644 finder.build_mnemonics_regex().unwrap();
645
646 for input in vec!["Todo: task", "ToDo: task", "tOdO: task"] {
647 assert!(finder.find(input).is_some(), "{}", input);
648 }
649 }
650
651 #[test]
652 fn it_should_not_match_partial_mnemonics() {
653 let mut finder = Codetag::default();
654 finder.add_mnemonic("TODO");
655 finder.build_mnemonics_regex().unwrap();
656
657 assert_eq!(None, finder.find("TODOS: not a match"));
659 assert_eq!(None, finder.find("MYTODO: not a match"));
660 }
661
662 #[test]
663 fn it_should_handle_codetags_at_end_of_line() {
664 let mut finder = Codetag::default();
665 finder.build_mnemonics_regex().unwrap();
666
667 let input = "code here // TODO:";
668 assert_eq!(Some("TODO:"), finder.find(input).map(|r| &input[r]));
669 }
670
671 #[test]
672 fn it_should_find_special_mnemonics() {
673 let mut finder = Codetag::default();
674 finder.build_mnemonics_regex().unwrap();
675
676 let input = "// ???: what does this do?";
678 assert_eq!(
679 Some("???: what does this do?"),
680 finder.find(input).map(|r| &input[r])
681 );
682
683 let input = "// !!!: urgent issue here";
684 assert_eq!(
685 Some("!!!: urgent issue here"),
686 finder.find(input).map(|r| &input[r])
687 );
688 }
689
690 #[test]
691 fn it_should_find_codetags_after_various_delimiters() {
692 let mut finder = Codetag::default();
693 finder.build_mnemonics_regex().unwrap();
694
695 for input in vec![
697 "// TODO: c-style",
698 "# TODO: shell-style",
699 "/* TODO: block comment",
700 "-- TODO: sql-style",
701 "; TODO: lisp-style",
702 "' TODO: vb-style",
703 ] {
704 assert!(finder.find(input).is_some(), "{}", input);
705 }
706 }
707
708 #[test]
713 fn it_should_handle_empty_string() {
714 let mut finder = Codetag::default();
715 finder.build_mnemonics_regex().unwrap();
716 assert_eq!(None, finder.find(""));
717 }
718
719 #[test]
720 fn it_should_handle_mnemonic_only_with_colon() {
721 let mut finder = Codetag::default();
722 finder.build_mnemonics_regex().unwrap();
723
724 let input = "TODO:";
726 assert_eq!(Some("TODO:"), finder.find(input).map(|r| &input[r]));
727 }
728
729 #[test]
730 fn it_should_handle_whitespace_only_after_mnemonic() {
731 let mut finder = Codetag::default();
732 finder.build_mnemonics_regex().unwrap();
733
734 let input = "TODO: ";
736 let result = finder.find(input);
737 assert!(result.is_some());
738 }
739
740 #[test]
741 fn it_should_handle_nested_parentheses_in_field() {
742 let mut finder = Codetag::default();
743 finder.build_mnemonics_regex().unwrap();
744
745 let input = "TODO(author): description";
748 assert!(finder.find(input).is_some());
749
750 let input2 = "TODO(a(b)): description";
753 assert!(finder.find(input2).is_none());
756 }
757
758 #[test]
759 fn it_should_handle_multiple_mnemonics_per_line() {
760 let mut finder = Codetag::default();
761 finder.build_mnemonics_regex().unwrap();
762
763 let input = "TODO: first FIXME: second";
765 let result = finder.find(input).map(|r| &input[r]);
766 assert!(result.is_some());
767 assert!(result.unwrap().starts_with("TODO:"));
769 }
770
771 #[test]
772 fn it_should_handle_mnemonic_at_exact_start() {
773 let mut finder = Codetag::default();
774 finder.build_mnemonics_regex().unwrap();
775
776 let input = "TODO: at start";
777 let result = finder.find(input);
778 assert!(result.is_some());
779 assert_eq!(0, result.unwrap().start);
780 }
781
782 #[test]
783 fn it_should_handle_mnemonic_at_exact_end() {
784 let mut finder = Codetag::default();
785 finder.build_mnemonics_regex().unwrap();
786
787 let input = "comment TODO:";
788 let result = finder.find(input);
789 assert!(result.is_some());
790 assert_eq!(input.len(), result.unwrap().end);
792 }
793
794 #[test]
795 fn it_should_handle_very_long_description() {
796 let mut finder = Codetag::default();
797 finder.build_mnemonics_regex().unwrap();
798
799 let long_desc = "a".repeat(10000);
800 let input = format!("TODO: {}", long_desc);
801 let result = finder.find(&input);
802 assert!(result.is_some());
803 assert_eq!(input.len(), result.unwrap().end);
804 }
805
806 #[test]
807 fn it_should_handle_unicode_in_description() {
808 let mut finder = Codetag::default();
809 finder.build_mnemonics_regex().unwrap();
810
811 let input = "TODO: 修复这个问题 🐛";
812 let result = finder.find(input);
813 assert!(result.is_some());
814 assert_eq!(Some("TODO: 修复这个问题 🐛"), result.map(|r| &input[r]));
815 }
816
817 #[test]
818 fn it_should_handle_all_default_mnemonics() {
819 let mut finder = Codetag::default();
820 finder.build_mnemonics_regex().unwrap();
821
822 let mnemonics = vec![
823 "TODO", "FIXME", "XXX", "HACK", "BUG", "NOTE", "WARNING", "REVIEW",
824 ];
825
826 for mnemonic in mnemonics {
827 let input = format!("{}: description", mnemonic);
828 assert!(
829 finder.find(&input).is_some(),
830 "Should find mnemonic: {}",
831 mnemonic
832 );
833 }
834 }
835
836 #[test]
837 fn it_should_handle_hide_mnemonic_with_no_content() {
838 let mut finder = Codetag::default();
839 finder.hide_mnemonic = true;
840 finder.build_mnemonics_regex().unwrap();
841
842 let input = "TODO:";
844 assert_eq!(None, finder.find(input));
845 }
846
847 #[test]
848 fn it_should_handle_custom_mnemonic_case_insensitivity() {
849 let mut finder = Codetag::default();
850 finder.add_mnemonic("CUSTOM");
851 finder.build_mnemonics_regex().unwrap();
852
853 assert!(finder.find("custom: test").is_some());
855 assert!(finder.find("CUSTOM: test").is_some());
856 assert!(finder.find("Custom: test").is_some());
857 assert!(finder.find("cUsToM: test").is_some());
858 }
859
860 #[test]
861 fn it_should_handle_multiple_custom_mnemonics() {
862 let mut finder = Codetag::default();
863 finder.add_mnemonic("AAA");
864 finder.add_mnemonic("BBB");
865 finder.add_mnemonic("CCC");
866 finder.build_mnemonics_regex().unwrap();
867
868 assert!(finder.find("AAA: test").is_some());
869 assert!(finder.find("BBB: test").is_some());
870 assert!(finder.find("CCC: test").is_some());
871 assert!(finder.find("TODO: test").is_none()); }
873
874 #[test]
875 fn it_should_handle_field_with_special_characters() {
876 let mut finder = Codetag::default();
877 finder.build_mnemonics_regex().unwrap();
878
879 let inputs = vec![
880 "TODO(@user): mention",
881 "TODO(#123): issue number",
882 "TODO(v1.2.3): version",
883 "TODO(2024-01-01): date",
884 ];
885
886 for input in inputs {
887 assert!(finder.find(input).is_some(), "{}", input);
888 }
889 }
890
891 #[test]
892 fn it_should_not_match_without_colon() {
893 let mut finder = Codetag::default();
894 finder.build_mnemonics_regex().unwrap();
895
896 assert!(finder.find("TODO is not a codetag").is_none());
898 assert!(finder.find("FIXME this").is_none());
899 }
900
901 #[test]
902 fn it_should_handle_colon_inside_description() {
903 let mut finder = Codetag::default();
904 finder.build_mnemonics_regex().unwrap();
905
906 let input = "TODO: time is 12:30:45";
907 let result = finder.find(input);
908 assert!(result.is_some());
909 assert_eq!(Some("TODO: time is 12:30:45"), result.map(|r| &input[r]));
911 }
912
913 #[test]
914 fn it_should_ignore_empty_and_whitespace_only_mnemonics() {
915 let mut finder = Codetag::default();
919 finder.add_mnemonic("");
920 finder.add_mnemonic(" ");
921 finder.add_mnemonic("\t");
922 finder.build_mnemonics_regex().unwrap();
923 assert_eq!(None, finder.find("hello: world"));
924 assert!(finder.find("TODO: x").is_some());
926
927 let mut finder = Codetag::default();
928 finder.add_mnemonic("todo");
929 finder.add_mnemonic("");
930 finder.build_mnemonics_regex().unwrap();
931 assert_eq!(None, finder.find("hello: world"));
932 assert!(finder.find("todo: x").is_some());
933 assert_eq!(None, finder.find("FIXME: z"));
934 }
935}