const KNIP_REGEX_LIKE: [char; 8] = ['*', '+', '\\', '(', '|', '{', '^', '$'];
const fn is_plain_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '@' | '~')
}
#[derive(Debug, PartialEq, Eq)]
enum Token {
Literal(char),
AnyRun,
NonEmptyRun,
AnyChar,
}
pub(super) fn knip_regex_source(entry: &str) -> Option<&str> {
if entry.len() >= 2 && entry.starts_with('/') && entry.ends_with('/') {
return Some(&entry[1..entry.len() - 1]);
}
entry.contains(KNIP_REGEX_LIKE).then_some(entry)
}
pub(super) fn regex_to_exact_glob(source: &str) -> Option<String> {
let (anchored_start, body) = source
.strip_prefix('^')
.map_or((false, source), |rest| (true, rest));
let (anchored_end, body) = match body.strip_suffix('$') {
Some(rest) if !rest.ends_with('\\') => (true, rest),
_ => (false, body),
};
let tokens = tokenize(body)?;
if tokens.is_empty() {
return None;
}
let mut glob = String::new();
if !anchored_start && tokens.first() != Some(&Token::Literal('@')) {
glob.push('*');
}
for (index, token) in tokens.iter().enumerate() {
match token {
Token::Literal(c) => glob.push(*c),
Token::AnyRun => glob.push('*'),
Token::AnyChar => glob.push('?'),
Token::NonEmptyRun => {
let is_last = index + 1 == tokens.len();
let after_slash = index > 0 && tokens[index - 1] == Token::Literal('/');
if !(is_last && after_slash) {
return None;
}
glob.push('*');
}
}
}
if !anchored_end {
glob.push('*');
}
Some(collapse_stars(&glob))
}
fn tokenize(body: &str) -> Option<Vec<Token>> {
let mut tokens = Vec::new();
let mut chars = body.chars().peekable();
while let Some(c) = chars.next() {
let token = match c {
'\\' => {
let escaped = chars.next()?;
if !is_plain_name_char(escaped) || escaped.is_ascii_alphanumeric() {
return None;
}
Token::Literal(escaped)
}
'.' => match chars.peek() {
Some('*') => {
chars.next();
Token::AnyRun
}
Some('+') => {
chars.next();
Token::NonEmptyRun
}
Some('?' | '{') => return None,
_ => Token::AnyChar,
},
c if is_plain_name_char(c) => {
if matches!(chars.peek(), Some('*' | '+' | '?' | '{')) {
return None;
}
Token::Literal(c)
}
_ => return None,
};
tokens.push(token);
}
Some(tokens)
}
fn collapse_stars(glob: &str) -> String {
let mut out = String::with_capacity(glob.len());
for c in glob.chars() {
if c == '*' && out.ends_with('*') {
continue;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn convert(entry: &str) -> Option<String> {
regex_to_exact_glob(knip_regex_source(entry)?)
}
#[test]
fn plain_names_are_not_regexes() {
assert_eq!(knip_regex_source("lodash"), None);
assert_eq!(knip_regex_source("@org/lib"), None);
assert_eq!(knip_regex_source("lodash.get"), None);
}
#[test]
fn scope_regexes_convert_to_scope_globs() {
assert_eq!(convert("@org/.+").as_deref(), Some("@org/*"));
assert_eq!(convert("^@org/.+$").as_deref(), Some("@org/*"));
assert_eq!(convert("^@org/.*").as_deref(), Some("@org/*"));
assert_eq!(convert("/^@org\\//").as_deref(), Some("@org/*"));
assert_eq!(convert("/^@org/").as_deref(), Some("@org*"));
assert_eq!(convert("^@org\\/").as_deref(), Some("@org/*"));
}
#[test]
fn prefix_and_suffix_regexes_convert() {
assert_eq!(
convert("^eslint-plugin-.*$").as_deref(),
Some("eslint-plugin-*")
);
assert_eq!(convert("^@types/").as_deref(), Some("@types/*"));
assert_eq!(convert("-loader$").as_deref(), Some("*-loader"));
assert_eq!(convert("^react$").as_deref(), Some("react"));
assert_eq!(convert("^lodash\\.get$").as_deref(), Some("lodash.get"));
assert_eq!(convert("^pkg-.$").as_deref(), Some("pkg-?"));
}
#[test]
fn regexes_without_an_exact_glob_are_rejected() {
for entry in [
"^(react|vue)$",
"^@org/[a-z]+$",
"^lib\\d$",
"^react-.+$",
"^@org/.+-plugin$",
"^colou?r$",
"^a+$",
"/^@org//i",
"^$",
] {
assert_eq!(convert(entry), None, "{entry} must not convert");
}
}
}