Skip to main content

squeeze/
codetag.rs

1//! Codetag finder implementation based on [PEP 350](https://www.python.org/dev/peps/pep-0350/).
2//!
3//! This module provides a [`Codetag`] finder that extracts codetags (TODO, FIXME, etc.)
4//! from source code comments.
5//!
6//! # Example
7//!
8//! ```
9//! use squeeze::{codetag::Codetag, Finder};
10//!
11//! let mut finder = Codetag::default();
12//! finder.add_mnemonic("TODO");
13//! finder.build_mnemonics_regex().unwrap();
14//!
15//! let text = "// TODO: implement this feature";
16//! if let Some(range) = finder.find(text) {
17//!     assert_eq!(&text[range], "TODO: implement this feature");
18//! }
19//! ```
20
21use 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
33            "TODO",
34            "MILESTONE",
35            "MLSTN",
36            "DONE",
37            "YAGNI",
38            "TBD",
39            "TOBEDONE",
40            // fixme
41            "FIXME",
42            "XXX",
43            "DEBUG",
44            "BROKEN",
45            "REFACTOR",
46            "REFACT",
47            "RFCTR",
48            "OOPS",
49            "SMELL",
50            "NEEDSWORK",
51            "INSPECT",
52            // bug
53            "BUG",
54            "BUGFIX",
55            // nobug
56            "NOBUG",
57            "NOFIX",
58            "WONTFIX",
59            "DONTFIX",
60            "NEVERFIX",
61            "UNFIXABLE",
62            "CANTFIX",
63            // req
64            "REQ",
65            "REQUIREMENT",
66            "STORY",
67            // rfe
68            "RFE",
69            "FEETCH",
70            "NYI",
71            "FR",
72            "FTRQ",
73            "FTR",
74            // idea
75            "IDEA",
76            // ???
77            "???",
78            "QUESTION",
79            "QUEST",
80            "QSTN",
81            "WTF",
82            // !!!
83            "!!!",
84            "ALERT",
85            // hack
86            "HACK",
87            "CLEVER",
88            "MAGIC",
89            // port
90            "PORT",
91            "PORTABILITY",
92            "WKRD",
93            // caveat
94            "CAVEAT",
95            "CAV",
96            "CAVT",
97            "WARNING",
98            "CAUTION",
99            // note
100            "NOTE",
101            "HELP",
102            // faq
103            "FAQ",
104            // gloss
105            "GLOSS",
106            "GLOSSARY",
107            // see
108            "SEE",
109            "REF",
110            "REFERENCE",
111            // todoc
112            "TODOC",
113            "DOCDO",
114            "DODOC",
115            "NEEDSDOC",
116            "EXPLAIN",
117            "DOCUMENT",
118            // cred
119            "CRED",
120            "CREDIT",
121            "THANKS",
122            // stat
123            "STAT",
124            "STATUS",
125            // rvd
126            "RVD",
127            "REVIEWED",
128            "REVIEW",
129        ]
130        .iter()
131        .map(|s| s.to_string())
132        .collect()
133    })
134}
135
136/// A mnemonic prepared for matching.
137struct Mnemonic {
138    /// Case-folded characters; the input is folded the same way while
139    /// comparing.
140    folded: Vec<char>,
141    /// Alphanumeric mnemonics must sit on word boundaries (`\b`); others
142    /// (`???`, `!!!`) match anywhere.
143    word: bool,
144    /// Byte length when every character is ASCII: a word mnemonic can then
145    /// only match an ASCII word of exactly that length.
146    ascii_len: Option<usize>,
147}
148
149/// Simple case folding as used by regex `(?i)`: one character maps to one
150/// character. Only the Kelvin sign and the long s fold onto ASCII letters;
151/// everything else folds through its single-character lowercase form.
152fn 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    /// End of the mnemonic when it matches at `pos`, case-insensitively.
168    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
186/// Matching tables derived from the mnemonic set.
187struct Index {
188    mnemonics: Vec<Mnemonic>,
189    /// Mnemonic ids by first byte, word mnemonics first.
190    by_first: Vec<Vec<u16>>,
191    /// Bytes that may follow a given start byte inside a match.
192    next_after: Vec<ByteSet>,
193    /// Start bytes of mnemonics matching anywhere (no `\b`).
194    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        // Deterministic priority: word mnemonics first, then by text.
207        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
253/// Characters that fold to the same character as `c`: its case variants
254/// plus the two non-ASCII letters that fold onto ASCII.
255fn 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
270/// A finder that extracts codetags (TODO, FIXME, etc.) from text.
271///
272/// Codetags are special comments in source code that mark areas needing attention.
273/// This finder supports all mnemonics defined in PEP 350, plus common variants.
274///
275/// # Usage
276///
277/// 1. Create a default instance or configure with specific mnemonics
278/// 2. Optionally call [`Codetag::build_mnemonics_regex`] to build the
279///    matching tables eagerly ([`Finder::find`] builds them lazily otherwise)
280/// 3. Use the [`Finder::find`] method to extract codetags
281///
282/// # Example
283///
284/// ```
285/// use squeeze::{codetag::Codetag, Finder};
286///
287/// let mut finder = Codetag::default();
288/// finder.build_mnemonics_regex().unwrap();
289///
290/// let text = "// FIXME(john): this is broken";
291/// if let Some(range) = finder.find(text) {
292///     println!("Found: {}", &text[range]);
293/// }
294/// ```
295pub struct Codetag {
296    /// When `true`, the mnemonic (e.g., "TODO:") is excluded from the result.
297    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    /// End of the whole codetag head (mnemonic, optional parenthesised
327    /// note, colon) when one starts at `pos`, with the end of the mnemonic.
328    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        // Length of the ASCII word at `pos`, when it is delimited by ASCII:
335        // a word mnemonic must then be exactly that long (`\b` on both
336        // sides), which rejects most words without comparing characters.
337        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        // Word mnemonics need `\b`; a non-ASCII previous byte is decoded
398        // by `try_at`.
399        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        // A mnemonic starting with an ASCII word byte begins with an ASCII
410        // word run: as long as the mnemonic when it is an ASCII word (then
411        // `:` or `(` follows), or as long as its leading word segment (then
412        // its first non-word byte follows). A non-ASCII byte may cut the
413        // run short anywhere (a folded Kelvin sign for `k`), so a run
414        // followed by one is always allowed.
415        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                    // The high-byte rule below covers it.
435                    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            // A high byte inside the first `max` positions.
448            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            // Up to the first character that is not ASCII or that another
461            // character folds onto (the Kelvin sign onto `k`, the long s
462            // onto `s`).
463            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    // The first head decides, as the former regex did: when hiding the
478    // mnemonic leaves nothing after it (`TBD(TODO:():`), the line has no
479    // match, even if a later head would.
480    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    /// Adds a custom mnemonic to search for.
493    ///
494    /// When at least one mnemonic is added, only those mnemonics will be matched.
495    /// If no mnemonics are added, all default PEP 350 mnemonics are used.
496    ///
497    /// Mnemonic matching is case-insensitive. Surrounding whitespace is
498    /// trimmed; empty and whitespace-only mnemonics are ignored, as they
499    /// would otherwise match every `word:`.
500    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    /// Builds the matching tables for the configured mnemonics.
510    ///
511    /// Calling this is optional: [`Finder::find`] builds them lazily on
512    /// first use. The name is kept from the regex-based implementation; it
513    /// cannot fail.
514    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        // PEP 350 defines optional fields: TODO(author):
625        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        // Should not match TODOS or MYTODO
658        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        // Test ??? and !!! mnemonics
677        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        // After various comment styles
696        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    // ============================================================================
709    // Edge case tests
710    // ============================================================================
711
712    #[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        // Just the mnemonic with colon, nothing after
725        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        // Mnemonic with only whitespace after
735        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        // The field parser captures content up to first )
746        // So nested parens break the field, but simple parens work
747        let input = "TODO(author): description";
748        assert!(finder.find(input).is_some());
749
750        // Nested parentheses - the field regex [^)]* stops at first )
751        // so TODO(a(b)): won't match as the field doesn't close properly
752        let input2 = "TODO(a(b)): description";
753        // This won't match because the regex expects TODO(...)colon pattern
754        // but the nested ( breaks it - this documents the current behavior
755        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        // Only finds the first one
764        let input = "TODO: first FIXME: second";
765        let result = finder.find(input).map(|r| &input[r]);
766        assert!(result.is_some());
767        // Should find TODO and include the rest of the line
768        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        // Range should extend to end of string
791        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        // When hiding mnemonic and there's nothing after, should return None
843        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        // Should match regardless of case
854        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()); // Not in custom list
872    }
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        // Mnemonic without colon should not match
897        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        // Should capture the entire rest of the line including colons
910        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        // Reachable via CLI `--codetag=todo,` (trailing comma): an empty
916        // mnemonic must not become an empty alternation branch that matches
917        // every `word:`.
918        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        // No effective custom mnemonics were added, so defaults stay active.
925        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}