use regex::{Captures, Regex};
pub(super) fn replace_ids(
text: &str,
re: &Regex,
is_id: impl Fn(&Captures) -> bool,
mut token: impl FnMut(&str) -> String,
) -> String {
re.replace_all(text, |c: &Captures| {
if is_id(c) {
token(&c[0])
} else {
c[0].to_string()
}
})
.into_owned()
}
const NOT_TICKETS: &[&str] = &[
"UTF", "UCS", "SHA", "MD", "ISO", "RFC", "CVE", "CWE", "GHSA", "HTTP", "TLS", "SSL", "AES",
"RSA", "ECDSA", "IPV", "X", "PEP", "ES", "ECMA", "IEEE", "WCAG", "PKCS", "ANSI", "BASE",
];
const TICKET_STOP_WORDS: &[&str] = &[
"alpine",
"android",
"angular",
"arm",
"attempt",
"batch",
"case",
"centos",
"chapter",
"chrome",
"claude",
"clang",
"covid",
"day",
"debian",
"django",
"dotnet",
"ecma",
"fedora",
"firefox",
"gcc",
"gemini",
"gpt",
"grade",
"ios",
"item",
"iteration",
"java",
"jdk",
"jre",
"k8s",
"level",
"line",
"llama",
"llvm",
"log4j",
"macos",
"month",
"mysql",
"next",
"node",
"npm",
"option",
"opus",
"page",
"part",
"pg",
"phase",
"php",
"postgres",
"python",
"rails",
"rc",
"react",
"redis",
"rev",
"rhel",
"round",
"ruby",
"run",
"rust",
"safari",
"section",
"sonnet",
"spring",
"sprint",
"stage",
"step",
"table",
"tier",
"top",
"try",
"ubuntu",
"version",
"vue",
"wave",
"week",
"win",
"windows",
"year",
];
pub(super) fn is_record_id(prefix: &str) -> bool {
!NOT_TICKETS.iter().any(|n| n.eq_ignore_ascii_case(prefix))
}
pub(super) fn is_hex_id(text: &str, start: usize, end: usize) -> bool {
let word = |c: Option<char>| c.is_some_and(char::is_alphanumeric);
let mut before = text[..start].chars().rev();
let free_start = match before.next() {
None => true,
Some('g' | 'G') => !word(before.next()),
Some(c) => !c.is_alphanumeric(),
};
if !free_start || word(text[end..].chars().next()) {
return false;
}
let run = &text[start..end];
let digit = run.bytes().any(|b| b.is_ascii_digit());
let letter = run.bytes().any(|b| b.is_ascii_alphabetic());
run.contains('-') || run.len() >= 32 || (digit && letter)
}
fn key(prefix: &str, before: Option<char>, after: &str, common: &dyn Fn(&str) -> bool) -> bool {
let mut rest = after.chars();
let first = rest.next();
if NOT_TICKETS.iter().any(|n| n.eq_ignore_ascii_case(prefix))
|| first.is_some_and(char::is_alphanumeric)
{
return false;
}
if !prefix.bytes().any(|b| b.is_ascii_lowercase()) {
return true;
}
let letters = prefix.bytes().all(|b| b.is_ascii_alphabetic());
if before == Some('[') && first == Some(']') {
return letters;
}
let version = first == Some('.') && rest.next().is_some_and(|c| c.is_ascii_digit());
let lower = prefix.to_ascii_lowercase();
(2..=10).contains(&prefix.len())
&& letters
&& !TICKET_STOP_WORDS.contains(&lower.as_str())
&& !common(prefix)
&& !matches!(before, Some('-' | '.' | '_'))
&& !version
}
pub(super) fn ticket_start(
text: &str,
start: usize,
end: usize,
prefix: &str,
common: &dyn Fn(&str) -> bool,
) -> Option<usize> {
let before = text[..start].chars().next_back();
let after = &text[end..];
if key(prefix, before, after, common) {
return Some(0);
}
let (head, tail) = prefix.rsplit_once('_')?;
let off = head.len() + 1;
(tail.len() >= 2 && key(tail, None, after, common)).then_some(off)
}