use super::{ByteSet, Finder, RunClass, RunRule};
use crate::word::{boundary_after, boundary_before, char_at};
use std::collections::HashSet;
use std::convert::Infallible;
use std::ops::Range;
use std::sync::OnceLock;
fn default_mnemonics() -> &'static HashSet<String> {
static DEFAULT_MNEMONICS: OnceLock<HashSet<String>> = OnceLock::new();
DEFAULT_MNEMONICS.get_or_init(|| {
[
"TODO",
"MILESTONE",
"MLSTN",
"DONE",
"YAGNI",
"TBD",
"TOBEDONE",
"FIXME",
"XXX",
"DEBUG",
"BROKEN",
"REFACTOR",
"REFACT",
"RFCTR",
"OOPS",
"SMELL",
"NEEDSWORK",
"INSPECT",
"BUG",
"BUGFIX",
"NOBUG",
"NOFIX",
"WONTFIX",
"DONTFIX",
"NEVERFIX",
"UNFIXABLE",
"CANTFIX",
"REQ",
"REQUIREMENT",
"STORY",
"RFE",
"FEETCH",
"NYI",
"FR",
"FTRQ",
"FTR",
"IDEA",
"???",
"QUESTION",
"QUEST",
"QSTN",
"WTF",
"!!!",
"ALERT",
"HACK",
"CLEVER",
"MAGIC",
"PORT",
"PORTABILITY",
"WKRD",
"CAVEAT",
"CAV",
"CAVT",
"WARNING",
"CAUTION",
"NOTE",
"HELP",
"FAQ",
"GLOSS",
"GLOSSARY",
"SEE",
"REF",
"REFERENCE",
"TODOC",
"DOCDO",
"DODOC",
"NEEDSDOC",
"EXPLAIN",
"DOCUMENT",
"CRED",
"CREDIT",
"THANKS",
"STAT",
"STATUS",
"RVD",
"REVIEWED",
"REVIEW",
]
.iter()
.map(|s| s.to_string())
.collect()
})
}
struct Mnemonic {
folded: Vec<char>,
word: bool,
ascii_len: Option<usize>,
}
fn fold(c: char) -> char {
match c {
'\u{212A}' => 'k',
'\u{017F}' => 's',
_ => {
let mut lower = c.to_lowercase();
match (lower.next(), lower.next()) {
(Some(l), None) => l,
_ => c,
}
}
}
}
impl Mnemonic {
fn match_at(&self, input: &[u8], pos: usize) -> Option<usize> {
let mut p = pos;
for &want in &self.folded {
let (c, width) = char_at(input, p)?;
let same = if c.is_ascii() && want.is_ascii() {
c.eq_ignore_ascii_case(&want)
} else {
fold(c) == want
};
if !same {
return None;
}
p += width;
}
Some(p)
}
}
struct Index {
mnemonics: Vec<Mnemonic>,
by_first: Vec<Vec<u16>>,
next_after: Vec<ByteSet>,
anywhere: ByteSet,
}
impl Index {
fn build(mnemonics: impl Iterator<Item = String>) -> Index {
let mut prepared: Vec<Mnemonic> = mnemonics
.map(|m| Mnemonic {
word: m.chars().all(|c| c.is_alphanumeric()),
ascii_len: m.is_ascii().then_some(m.len()),
folded: m.chars().map(fold).collect(),
})
.collect();
prepared.sort_by(|a, b| b.word.cmp(&a.word).then_with(|| a.folded.cmp(&b.folded)));
let mut by_first = vec![Vec::new(); 256];
let mut next_after = vec![ByteSet::EMPTY; 256];
let mut anywhere = ByteSet::EMPTY;
for (id, m) in prepared.iter().enumerate() {
let Some(&first) = m.folded.first() else {
continue;
};
let second = m.folded.get(1).copied();
for variant in case_variants(first) {
let mut buf = [0u8; 4];
let bytes = variant.encode_utf8(&mut buf).as_bytes();
let lead = bytes[0];
if !by_first[lead as usize].contains(&(id as u16)) {
by_first[lead as usize].push(id as u16);
}
if !m.word {
anywhere = anywhere.with(lead);
}
let mut next = next_after[lead as usize];
if bytes.len() > 1 {
next = next.with(bytes[1]);
} else {
match second {
Some(c) => {
for v in case_variants(c) {
let mut buf = [0u8; 4];
next = next.with(v.encode_utf8(&mut buf).as_bytes()[0]);
}
}
None => next = next.with(b'(').with(b':'),
}
}
next_after[lead as usize] = next;
}
}
Index {
mnemonics: prepared,
by_first,
next_after,
anywhere,
}
}
}
fn case_variants(c: char) -> Vec<char> {
let mut variants = vec![c];
let extra = match fold(c) {
'k' => Some('\u{212A}'),
's' => Some('\u{017F}'),
_ => None,
};
for v in c.to_lowercase().chain(c.to_uppercase()).chain(extra) {
if !variants.contains(&v) {
variants.push(v);
}
}
variants
}
pub struct Codetag {
pub hide_mnemonic: bool,
mnemonics: HashSet<String>,
index: OnceLock<Index>,
}
impl Default for Codetag {
fn default() -> Self {
Codetag {
hide_mnemonic: false,
mnemonics: HashSet::new(),
index: OnceLock::new(),
}
}
}
impl Codetag {
fn index(&self) -> &Index {
self.index.get_or_init(|| self.build_index())
}
fn build_index(&self) -> Index {
let mnemonics = if self.mnemonics.is_empty() {
default_mnemonics().iter()
} else {
self.mnemonics.iter()
};
Index::build(mnemonics.cloned())
}
fn match_at(&self, input: &[u8], pos: usize) -> Option<(usize, usize)> {
let index = self.index();
let ids = &index.by_first[input[pos] as usize];
if ids.is_empty() {
return None;
}
let mut word_end = pos;
let mut ascii = true;
while let Some(&b) = input.get(word_end) {
if b >= 0x80 {
ascii = false;
break;
}
if !(b.is_ascii_alphanumeric() || b == b'_') {
break;
}
word_end += 1;
}
let word_len = word_end - pos;
for &id in ids {
let m = &index.mnemonics[id as usize];
if m.word && ascii && m.ascii_len.is_some_and(|len| len != word_len) {
continue;
}
let Some(end) = m.match_at(input, pos) else {
continue;
};
if m.word && !(boundary_before(input, pos) && boundary_after(input, end)) {
continue;
}
let mut p = end;
if input.get(p) == Some(&b'(') {
match input[p + 1..].iter().position(|&b| b == b')') {
Some(close) => p = p + 1 + close + 1,
None => continue,
}
}
if input.get(p) == Some(&b':') {
return Some((end, p + 1));
}
}
None
}
fn range_from(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
let (_, head_end) = self.match_at(input, pos)?;
let from = if self.hide_mnemonic { head_end } else { pos };
let to = input.len();
if from >= to { None } else { Some(from..to) }
}
}
impl Finder for Codetag {
fn id(&self) -> &'static str {
"codetag"
}
fn dispatchable(&self) -> bool {
true
}
fn could_start_at(&self, byte: u8) -> bool {
!self.index().by_first[byte as usize].is_empty()
}
fn could_start_after(&self, prev: u8, cur: u8) -> bool {
self.index().anywhere.contains(cur)
|| prev >= 0x80
|| !(prev.is_ascii_alphanumeric() || prev == b'_')
}
fn could_continue_with(&self, cur: u8, next: u8) -> bool {
self.index().next_after[cur as usize].contains(next)
}
fn run_rules(&self) -> Vec<RunRule> {
let mut lengths = 0u64;
let mut after = ByteSet::from_bytes(b":(");
let mut max = 0u8;
for m in &self.index().mnemonics {
let Some(first) = m.folded.first() else {
continue;
};
if !first.is_ascii() || !RunClass::Word.contains(*first as u8) {
continue;
}
let prefix = m
.folded
.iter()
.take_while(|c| c.is_ascii() && RunClass::Word.contains(**c as u8))
.count();
if let Some(next) = m.folded.get(prefix) {
if next.is_ascii() {
after = after.with(*next as u8);
} else {
continue;
}
}
if prefix < 64 {
lengths |= 1u64 << prefix;
}
max = max.max(prefix as u8);
}
vec![
RunRule::new(RunClass::Word, 1, max.max(1))
.lengths(lengths)
.followed_by_set(after),
RunRule::new(RunClass::Word, 0, max.saturating_sub(1))
.followed_by_set(ByteSet::from_fn(|b| b >= 0x80)),
]
}
fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
self.range_from(input, pos)
}
fn prefixes(&self) -> Vec<Vec<u8>> {
let mut prefixes = Vec::new();
for m in &self.index().mnemonics {
let prefix: Vec<u8> = m
.folded
.iter()
.take_while(|c| c.is_ascii() && !matches!(c, 'k' | 's'))
.map(|&c| c as u8)
.collect();
if prefix.is_empty() {
return Vec::new();
}
prefixes.push(prefix);
}
prefixes
}
fn find(&self, s: &str) -> Option<Range<usize>> {
let input = s.as_bytes();
for pos in 0..input.len() {
if self.could_start_at(input[pos]) && self.match_at(input, pos).is_some() {
return self.range_from(input, pos);
}
}
None
}
}
impl Codetag {
pub fn add_mnemonic(&mut self, mnemonic: &str) {
let mnemonic = mnemonic.trim();
if mnemonic.is_empty() {
return;
}
self.mnemonics.insert(mnemonic.to_uppercase());
self.index = OnceLock::new();
}
pub fn build_mnemonics_regex(&mut self) -> Result<(), Infallible> {
let index = self.build_index();
self.index = OnceLock::new();
let _ = self.index.set(index);
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::useless_vec)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
#[test]
fn default_finder_is_ready_to_use() {
let finder = Codetag::default();
let input = "TODO: check if cmd is installed";
assert_eq!(
Some("TODO: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_at_start_of_line() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: check if cmd is installed";
assert_eq!(
Some("TODO: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_at_middle_of_line() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "foobar // TODO: check if cmd is installed";
assert_eq!(
Some("TODO: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_uppercase() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: check if cmd is installed";
assert_eq!(
Some("TODO: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_lowercase() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "todo: check if cmd is installed";
assert_eq!(
Some("todo: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_mnemonics_with_empty_description() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "todo:";
assert_eq!(Some("todo:"), finder.find(input).map(|r| &input[r]));
}
#[test]
fn it_should_hide_mnemonics_if_asked_to() {
let mut finder = Codetag::default();
finder.hide_mnemonic = true;
finder.build_mnemonics_regex().unwrap();
let input = "todo: foobar";
assert_eq!(Some(" foobar"), finder.find(input).map(|r| &input[r]));
}
#[test]
fn it_should_limit_results_to_the_given_mnemonics() {
let mut finder = Codetag::default();
finder.add_mnemonic("test");
finder.build_mnemonics_regex().unwrap();
let input = "test: check if cmd is installed";
assert_eq!(
Some("test: check if cmd is installed"),
finder.find(input).map(|r| &input[r])
);
let input = "test2: check if cmd is installed";
assert_eq!(None, finder.find(input).map(|r| &input[r]));
}
#[test]
fn it_should_ignore_invalid_inputs() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
for input in vec!["", " "] {
assert_eq!(None, finder.find(input));
}
}
#[test]
fn it_should_find_codetags_with_fields() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO(john): implement feature";
assert_eq!(
Some("TODO(john): implement feature"),
finder.find(input).map(|r| &input[r])
);
let input = "FIXME(#123): fix this bug";
assert_eq!(
Some("FIXME(#123): fix this bug"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_mixed_case_mnemonics() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
for input in vec!["Todo: task", "ToDo: task", "tOdO: task"] {
assert!(finder.find(input).is_some(), "{}", input);
}
}
#[test]
fn it_should_not_match_partial_mnemonics() {
let mut finder = Codetag::default();
finder.add_mnemonic("TODO");
finder.build_mnemonics_regex().unwrap();
assert_eq!(None, finder.find("TODOS: not a match"));
assert_eq!(None, finder.find("MYTODO: not a match"));
}
#[test]
fn it_should_handle_codetags_at_end_of_line() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "code here // TODO:";
assert_eq!(Some("TODO:"), finder.find(input).map(|r| &input[r]));
}
#[test]
fn it_should_find_special_mnemonics() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "// ???: what does this do?";
assert_eq!(
Some("???: what does this do?"),
finder.find(input).map(|r| &input[r])
);
let input = "// !!!: urgent issue here";
assert_eq!(
Some("!!!: urgent issue here"),
finder.find(input).map(|r| &input[r])
);
}
#[test]
fn it_should_find_codetags_after_various_delimiters() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
for input in vec![
"// TODO: c-style",
"# TODO: shell-style",
"/* TODO: block comment",
"-- TODO: sql-style",
"; TODO: lisp-style",
"' TODO: vb-style",
] {
assert!(finder.find(input).is_some(), "{}", input);
}
}
#[test]
fn it_should_handle_empty_string() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
assert_eq!(None, finder.find(""));
}
#[test]
fn it_should_handle_mnemonic_only_with_colon() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO:";
assert_eq!(Some("TODO:"), finder.find(input).map(|r| &input[r]));
}
#[test]
fn it_should_handle_whitespace_only_after_mnemonic() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: ";
let result = finder.find(input);
assert!(result.is_some());
}
#[test]
fn it_should_handle_nested_parentheses_in_field() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO(author): description";
assert!(finder.find(input).is_some());
let input2 = "TODO(a(b)): description";
assert!(finder.find(input2).is_none());
}
#[test]
fn it_should_handle_multiple_mnemonics_per_line() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: first FIXME: second";
let result = finder.find(input).map(|r| &input[r]);
assert!(result.is_some());
assert!(result.unwrap().starts_with("TODO:"));
}
#[test]
fn it_should_handle_mnemonic_at_exact_start() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: at start";
let result = finder.find(input);
assert!(result.is_some());
assert_eq!(0, result.unwrap().start);
}
#[test]
fn it_should_handle_mnemonic_at_exact_end() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "comment TODO:";
let result = finder.find(input);
assert!(result.is_some());
assert_eq!(input.len(), result.unwrap().end);
}
#[test]
fn it_should_handle_very_long_description() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let long_desc = "a".repeat(10000);
let input = format!("TODO: {}", long_desc);
let result = finder.find(&input);
assert!(result.is_some());
assert_eq!(input.len(), result.unwrap().end);
}
#[test]
fn it_should_handle_unicode_in_description() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: 修复这个问题 🐛";
let result = finder.find(input);
assert!(result.is_some());
assert_eq!(Some("TODO: 修复这个问题 🐛"), result.map(|r| &input[r]));
}
#[test]
fn it_should_handle_all_default_mnemonics() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let mnemonics = vec![
"TODO", "FIXME", "XXX", "HACK", "BUG", "NOTE", "WARNING", "REVIEW",
];
for mnemonic in mnemonics {
let input = format!("{}: description", mnemonic);
assert!(
finder.find(&input).is_some(),
"Should find mnemonic: {}",
mnemonic
);
}
}
#[test]
fn it_should_handle_hide_mnemonic_with_no_content() {
let mut finder = Codetag::default();
finder.hide_mnemonic = true;
finder.build_mnemonics_regex().unwrap();
let input = "TODO:";
assert_eq!(None, finder.find(input));
}
#[test]
fn it_should_handle_custom_mnemonic_case_insensitivity() {
let mut finder = Codetag::default();
finder.add_mnemonic("CUSTOM");
finder.build_mnemonics_regex().unwrap();
assert!(finder.find("custom: test").is_some());
assert!(finder.find("CUSTOM: test").is_some());
assert!(finder.find("Custom: test").is_some());
assert!(finder.find("cUsToM: test").is_some());
}
#[test]
fn it_should_handle_multiple_custom_mnemonics() {
let mut finder = Codetag::default();
finder.add_mnemonic("AAA");
finder.add_mnemonic("BBB");
finder.add_mnemonic("CCC");
finder.build_mnemonics_regex().unwrap();
assert!(finder.find("AAA: test").is_some());
assert!(finder.find("BBB: test").is_some());
assert!(finder.find("CCC: test").is_some());
assert!(finder.find("TODO: test").is_none()); }
#[test]
fn it_should_handle_field_with_special_characters() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let inputs = vec![
"TODO(@user): mention",
"TODO(#123): issue number",
"TODO(v1.2.3): version",
"TODO(2024-01-01): date",
];
for input in inputs {
assert!(finder.find(input).is_some(), "{}", input);
}
}
#[test]
fn it_should_not_match_without_colon() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
assert!(finder.find("TODO is not a codetag").is_none());
assert!(finder.find("FIXME this").is_none());
}
#[test]
fn it_should_handle_colon_inside_description() {
let mut finder = Codetag::default();
finder.build_mnemonics_regex().unwrap();
let input = "TODO: time is 12:30:45";
let result = finder.find(input);
assert!(result.is_some());
assert_eq!(Some("TODO: time is 12:30:45"), result.map(|r| &input[r]));
}
#[test]
fn it_should_ignore_empty_and_whitespace_only_mnemonics() {
let mut finder = Codetag::default();
finder.add_mnemonic("");
finder.add_mnemonic(" ");
finder.add_mnemonic("\t");
finder.build_mnemonics_regex().unwrap();
assert_eq!(None, finder.find("hello: world"));
assert!(finder.find("TODO: x").is_some());
let mut finder = Codetag::default();
finder.add_mnemonic("todo");
finder.add_mnemonic("");
finder.build_mnemonics_regex().unwrap();
assert_eq!(None, finder.find("hello: world"));
assert!(finder.find("todo: x").is_some());
assert_eq!(None, finder.find("FIXME: z"));
}
}