use std::net::Ipv6Addr;
use std::sync::LazyLock;
use regex::Regex;
use super::jev_error::JevError;
pub(super) struct Patterns {
pub(super) url: Regex,
pub(super) email: Regex,
pub(super) slash: Regex,
pub(super) dotted: Regex,
pub(super) ipv6: Regex,
pub(super) ticket: Regex,
pub(super) id: Regex,
pub(super) hex: Regex,
pub(super) mention: Regex,
pub(super) merge_branch: Regex,
pub(super) quoted: Regex,
pub(super) merge_of: Regex,
pub(super) merge_pr: Regex,
pub(super) merged_in: Regex,
pub(super) merge_request: Regex,
pub(super) placeholder: Regex,
}
impl Patterns {
fn compile() -> Result<Self, &'static str> {
let c = |name: &'static str, src: &str| Regex::new(src).map_err(|_| name);
Ok(Self {
url: c(
"url",
r#"(?i)\b(?:[a-z][a-z0-9+.-]*://[^\s<>"'`]+|git@[^\s:/]+:[^\s<>"'`]+|www\.[^\s<>"'`]+)"#,
)?,
email: c(
"email",
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+",
)?,
slash: c(
"slash",
r"(?:[A-Za-z]:\\|~/|\.\.?/|/)?[A-Za-z0-9_.@+-]+(?:[/\\][A-Za-z0-9_.@+-]+)+[/\\]?",
)?,
dotted: c(
"dotted",
r"\b[A-Za-z0-9_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+",
)?,
ipv6: c(
"ipv6",
r"(?i)(^|[^0-9a-z_:.])((?:[0-9a-f]{0,4}:){2,7}(?:[0-9a-f]{1,4}|\d{1,3}(?:\.\d{1,3}){3})?)",
)?,
ticket: c("ticket", r"\b([A-Za-z][A-Za-z0-9_]{1,19})-([0-9]+)")?,
id: c("id", r"\b([A-Za-z]{1,4})[0-9]{4,}\b")?,
hex: c(
"hex",
r"(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{7,}",
)?,
mention: c(
"mention",
r"(^|[^A-Za-z0-9_.@/])@([A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?(?:/[A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?)?(?:\[bot\])?)",
)?,
merge_branch: c(
"merge_branch",
r"(?i)\bmerge (?:remote-tracking )?branch(?:es)? ",
)?,
quoted: c("quoted", r#"(['"])([^'"\n]+)(['"])"#)?,
merge_of: c(
"merge_of",
r#"(?i)(\bmerge (?:remote-tracking )?branch(?:es)? .*?['"] of )(\S+)"#,
)?,
merge_pr: c("merge_pr", r"(?i)(\bmerge pull request #\d+ from )(\S+)")?,
merged_in: c("merged_in", r"(?i)(\bmerged in )(\S+)")?,
merge_request: c("merge_request", r"(?i)(\bmerge request )([^\s!]+)(!\d+)")?,
placeholder: c(
"placeholder",
r"\b(?:EMAIL|PERSON|TICKET|URL|HOST|PATH|BRANCH|REPO|TERM|ID)_[0-9]+\b",
)?,
})
}
}
static PATTERNS: LazyLock<Result<Patterns, &'static str>> = LazyLock::new(Patterns::compile);
pub(super) fn patterns() -> Result<&'static Patterns, JevError> {
PATTERNS.as_ref().map_err(|name| JevError::Pattern(name))
}
const NOT_FILES: &[&str] = &[
"node.js",
"vue.js",
"next.js",
"nuxt.js",
"react.js",
"three.js",
"d3.js",
"chart.js",
"express.js",
"ember.js",
"backbone.js",
];
pub(super) const FILE_EXTENSIONS: &[&str] = &[
"bash",
"bat",
"c",
"cc",
"cfg",
"cjs",
"conf",
"cpp",
"cs",
"css",
"csv",
"cxx",
"dart",
"dockerfile",
"env",
"ex",
"exs",
"gif",
"go",
"gradle",
"gz",
"h",
"hpp",
"htm",
"html",
"ini",
"ipynb",
"java",
"jpeg",
"jpg",
"js",
"json",
"jsx",
"kt",
"kts",
"lock",
"log",
"lua",
"md",
"mjs",
"pdf",
"php",
"pl",
"png",
"proto",
"ps1",
"py",
"r",
"rb",
"rs",
"rst",
"sass",
"scala",
"scss",
"sh",
"sql",
"svelte",
"svg",
"swift",
"tar",
"tf",
"tfvars",
"toml",
"ts",
"tsx",
"txt",
"vue",
"wasm",
"xml",
"yaml",
"yml",
"zip",
"zsh",
"adoc",
"avro",
"bzl",
"cfm",
"cmake",
"csproj",
"cts",
"editorconfig",
"ejs",
"erb",
"gemspec",
"gql",
"graphql",
"haml",
"hbs",
"hcl",
"j2",
"jinja",
"jsonc",
"jsonl",
"less",
"mdx",
"mk",
"mts",
"ndjson",
"nix",
"njk",
"parquet",
"plist",
"properties",
"rake",
"rego",
"sbt",
"sln",
"sol",
"styl",
"tmpl",
"tpl",
"tsv",
"vbs",
"vcxproj",
"xaml",
"xsd",
"xsl",
"zig",
];
const PUBLIC_BRANCHES: &[&str] = &[
"main",
"master",
"develop",
"development",
"dev",
"trunk",
"staging",
"production",
"release",
"head",
];
const NAME_STOP_WORDS: &[&str] = &[
"account", "actions", "admin", "and", "april", "archer", "art", "august", "baker", "bell",
"best", "bill", "bin", "bishop", "black", "bot", "brown", "build", "butler", "carter", "case",
"chase", "cliff", "cook", "cooper", "dawn", "day", "dean", "del", "den", "der", "dev", "drew",
"early", "faith", "field", "fine", "fisher", "for", "frank", "gene", "github", "gitlab",
"glass", "grace", "grant", "gray", "green", "grey", "guy", "hall", "hand", "hill", "hope",
"hunter", "jack", "joy", "june", "key", "king", "knight", "lane", "law", "little", "long",
"los", "love", "major", "mark", "mason", "max", "may", "miles", "miller", "name", "nice",
"noble", "page", "park", "parker", "pat", "penny", "porter", "power", "price", "rain", "ray",
"rich", "rob", "rod", "root", "rose", "sandy", "service", "sharp", "short", "sky", "small",
"snow", "spring", "steel", "stone", "storm", "summer", "swift", "system", "team", "temple",
"test", "the", "turner", "user", "van", "von", "walker", "ward", "white", "will", "winter",
"wolf", "wood", "young", "your",
];
pub(super) fn split_trailing_punct(s: &str) -> (&str, &str) {
let core = s.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}', '\'', '"']);
s.split_at(core.len())
}
const PROSE_PAIRS: &[(&str, &str)] = &[
("and", "or"),
("read", "write"),
("client", "server"),
("input", "output"),
("true", "false"),
("yes", "no"),
("on", "off"),
("pass", "fail"),
];
pub(super) fn is_path(s: &str) -> bool {
let segs: Vec<&str> = s.split(['/', '\\']).filter(|x| !x.is_empty()).collect();
if segs.is_empty() || segs.iter().all(|x| x.bytes().all(|b| b.is_ascii_digit())) {
return false;
}
if let [a, b] = segs.as_slice() {
let one = |x: &str| x.chars().count() == 1 && !x.chars().any(char::is_uppercase);
let single = one(a) || one(b);
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
let pair = PROSE_PAIRS
.iter()
.any(|(x, y)| (a == *x && b == *y) || (a == *y && b == *x));
if single || pair {
return false;
}
}
true
}
pub(super) fn is_placeholder(s: &str) -> bool {
const PREFIXES: [&str; 11] = [
"EMAIL_", "PERSON_", "TICKET_", "URL_", "HOST_", "PATH_", "REPO_", "TERM_", "BRANCH_",
"CAT_", "ID_",
];
PREFIXES.iter().any(|p| {
s.strip_prefix(p).is_some_and(|rest| {
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && matches!(rest.as_bytes().get(digits), None | Some(b'.'))
})
})
}
pub(super) fn known_ext(name: &str) -> Option<&str> {
let (stem, ext) = name.rsplit_once('.')?;
let known = FILE_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext));
(!stem.is_empty() && known).then_some(ext)
}
pub(super) fn is_public_branch(name: &str) -> bool {
let bare = name
.strip_prefix("origin/")
.or_else(|| name.strip_prefix("upstream/"))
.unwrap_or(name);
PUBLIC_BRANCHES.iter().any(|b| b.eq_ignore_ascii_case(bare))
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Dotted<'a> {
Keep,
Host,
File(&'a str),
FileName,
}
pub(super) const FILE_STEMS: &[&str] = &[
"brewfile",
"caddyfile",
"containerfile",
"dockerfile",
"gemfile",
"jenkinsfile",
"justfile",
"makefile",
"procfile",
"rakefile",
"vagrantfile",
];
const HOST_SUFFIXES: &[&str] = &[
"ad",
"ai",
"au",
"aws",
"biz",
"br",
"ca",
"ch",
"cluster",
"cn",
"co",
"com",
"corp",
"cz",
"de",
"dk",
"edu",
"es",
"eu",
"example",
"fi",
"fm",
"fr",
"gg",
"gov",
"hk",
"ie",
"il",
"infra",
"internal",
"intra",
"intranet",
"invalid",
"io",
"jp",
"kr",
"lan",
"localdomain",
"localhost",
"ly",
"mil",
"mx",
"net",
"nl",
"nz",
"org",
"priv",
"pt",
"ru",
"se",
"sg",
"svc",
"tv",
"tw",
"uk",
"vpn",
"xyz",
"za",
];
const WORD_SUFFIXES: &[&str] = &[
"app", "at", "be", "cloud", "dev", "global", "home", "in", "info", "int", "it", "local", "me",
"no", "office", "online", "private", "prod", "qa", "site", "so", "stage", "staging", "tech",
"test", "to", "uat", "us",
];
const CODE_LABELS: &[&str] = &[
"app", "args", "config", "console", "context", "ctx", "data", "dev", "document", "e2e", "env",
"exports", "item", "local", "module", "obj", "options", "opts", "package", "params", "process",
"prod", "props", "req", "request", "res", "response", "result", "self", "settings", "spec",
"state", "test", "this", "unit", "user", "value", "window",
];
pub(super) fn classify_dotted(s: &str, next: Option<char>) -> Dotted<'_> {
if is_placeholder(s) {
return Dotted::Keep;
}
let labels: Vec<&str> = s.split('.').collect();
let octet = |l: &&str| l.len() <= 3 && l.parse::<u16>().is_ok_and(|n| n <= 255);
if labels.len() == 4 && labels.iter().all(octet) {
return Dotted::Host;
}
let last = labels.last().copied().unwrap_or("");
if last.is_empty() || !last.chars().all(|c| c.is_ascii_alphabetic()) {
return Dotted::Keep;
}
if labels.len() >= 2 && FILE_STEMS.contains(&labels[0].to_ascii_lowercase().as_str()) {
return Dotted::FileName;
}
if FILE_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(last)) {
let framework = NOT_FILES.iter().any(|f| f.eq_ignore_ascii_case(s));
if framework {
return Dotted::Keep;
}
return Dotted::File(last);
}
let camel = |l: &&str| {
l.as_bytes()
.windows(2)
.any(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase())
};
if next == Some('(') || labels.iter().any(camel) {
return Dotted::Keep;
}
let lower = last.to_ascii_lowercase();
if HOST_SUFFIXES.contains(&lower.as_str()) {
return Dotted::Host;
}
if last.len() < 2 {
return Dotted::Keep;
}
let head = &labels[..labels.len() - 1];
let marked = head
.iter()
.any(|l| l.contains('-') || l.bytes().any(|b| b.is_ascii_digit()));
let code = head
.iter()
.any(|l| CODE_LABELS.contains(&l.to_ascii_lowercase().as_str()));
let host = if WORD_SUFFIXES.contains(&lower.as_str()) {
!code
} else {
let lowercase = labels
.iter()
.all(|l| !l.bytes().any(|b| b.is_ascii_uppercase()));
lowercase && marked
};
if host {
Dotted::Host
} else {
Dotted::Keep
}
}
pub(super) fn is_ipv6(s: &str, next: Option<char>) -> bool {
let runs_on = next.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == ':');
!runs_on && s.parse::<Ipv6Addr>().is_ok() && s.bytes().any(|b| b.is_ascii_digit())
}
pub(super) fn is_name_stop_word(token: &str) -> bool {
let lower = token.to_lowercase();
NAME_STOP_WORDS.contains(&lower.as_str())
}