use std::{borrow::Cow, sync::OnceLock};
use fast_glob::glob_match;
use rustc_hash::FxHashMap;
use crate::{
js_regex::HybridRegex,
pattern_filter::{
StringOrRegex, StringOrRegexMatchKind, get_matcher_string, glob_matcher_depends_on_cwd,
normalize_path,
},
url::{clean_url, get_query},
};
#[derive(Debug)]
pub enum FilterExpr {
Or(Vec<FilterExpr>),
And(Vec<FilterExpr>),
Not(Box<FilterExpr>),
Code(StringOrRegex),
Id(IdMatcher),
ImporterId(IdMatcher),
CleanUrl(Box<FilterExpr>),
ModuleType(String),
Query(String, QueryValue),
}
#[derive(Debug)]
pub struct IdMatcher {
pattern: StringOrRegex,
glob_cache: OnceLock<ResolvedGlob>,
}
#[derive(Debug)]
enum ResolvedGlob {
CwdIndependent(Box<str>),
ScopedToCwd { cwd: Box<str>, matcher: Box<str> },
}
impl ResolvedGlob {
fn resolve(glob: &str, cwd: &str) -> Self {
let matcher = Box::from(get_matcher_string(glob, cwd).as_ref());
if glob_matcher_depends_on_cwd(glob) {
Self::ScopedToCwd { cwd: Box::from(cwd), matcher }
} else {
Self::CwdIndependent(matcher)
}
}
}
impl IdMatcher {
fn test(&self, value: &str, cwd: &str) -> bool {
match &self.pattern {
StringOrRegex::Regex(re) => re.matches(value),
StringOrRegex::String(glob) => {
match self.glob_cache.get_or_init(|| ResolvedGlob::resolve(glob, cwd)) {
ResolvedGlob::CwdIndependent(matcher) => glob_match(matcher.as_bytes(), value.as_bytes()),
ResolvedGlob::ScopedToCwd { cwd: resolved_cwd, matcher }
if resolved_cwd.as_bytes() == cwd.as_bytes() =>
{
glob_match(matcher.as_bytes(), value.as_bytes())
}
ResolvedGlob::ScopedToCwd { .. } => {
glob_match(get_matcher_string(glob, cwd).as_bytes(), value.as_bytes())
}
}
}
}
}
}
impl From<StringOrRegex> for IdMatcher {
fn from(pattern: StringOrRegex) -> Self {
Self { pattern, glob_cache: OnceLock::new() }
}
}
#[derive(Debug)]
pub enum QueryValue {
String(String),
Regex(HybridRegex),
Boolean(bool),
}
#[derive(Debug)]
pub enum FilterExprKind {
Include(FilterExpr),
Exclude(FilterExpr),
}
pub fn filter_expr_interpreter<'a>(
expr: &FilterExpr,
id: Option<&'a str>,
code: Option<&str>,
module_type: Option<&str>,
importer_id: Option<&'a str>,
cwd: &str,
ctx: &mut InterpreterCtx<'a>,
) -> bool {
match expr {
FilterExpr::Or(args) => args
.iter()
.any(|arg| filter_expr_interpreter(arg, id, code, module_type, importer_id, cwd, ctx)),
FilterExpr::And(args) => args
.iter()
.all(|arg| filter_expr_interpreter(arg, id, code, module_type, importer_id, cwd, ctx)),
FilterExpr::Not(inner) => {
!filter_expr_interpreter(inner, id, code, module_type, importer_id, cwd, ctx)
}
FilterExpr::Code(pattern) => {
code.is_some_and(|code| pattern.test(code, &StringOrRegexMatchKind::Code))
}
FilterExpr::Id(id_pattern) => {
id.is_some_and(|id| id_pattern.test(id, cwd))
}
FilterExpr::ImporterId(id_pattern) => {
match importer_id {
Some(importer) => id_pattern.test(importer, cwd),
None => false,
}
}
FilterExpr::ModuleType(module_type_filter) => {
module_type.as_ref().is_some_and(|module_type| module_type == module_type_filter)
}
FilterExpr::CleanUrl(expr) => filter_expr_interpreter(
expr,
id.map(clean_url),
code,
module_type,
importer_id.map(clean_url),
cwd,
ctx,
),
FilterExpr::Query(key, value) => {
if ctx.parsed_url_cache.is_none() {
let query_string = id.map(get_query).unwrap_or("");
let cache = form_urlencoded::parse(query_string.as_bytes())
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect::<_>();
ctx.parsed_url_cache = Some(cache);
}
match value {
QueryValue::String(v) => ctx
.parsed_url_cache
.as_ref()
.and_then(|cache| cache.get(key).map(|qv| qv == v))
.unwrap_or(false),
QueryValue::Regex(hybrid_regex) => ctx
.parsed_url_cache
.as_ref()
.and_then(|cache| cache.get(key).map(|qv| hybrid_regex.matches(qv)))
.unwrap_or(false),
QueryValue::Boolean(v) => {
let has_key = ctx.parsed_url_cache.as_ref().is_some_and(|cache| cache.contains_key(key));
*v == has_key
}
}
}
}
}
#[derive(Default, Debug)]
pub struct InterpreterCtx<'a> {
parsed_url_cache: Option<FxHashMap<String, Cow<'a, str>>>,
}
pub fn filter_exprs_interpreter(
exprs: &[FilterExprKind],
id: Option<&str>,
code: Option<&str>,
module_type: Option<&str>,
importer_id: Option<&str>,
cwd: &str,
) -> bool {
let mut include_count = 0;
let mut ctx = InterpreterCtx::default();
let id = id.map(|id| normalize_path(id));
let id = id.as_deref();
let importer_id = importer_id.map(|id| normalize_path(id));
let importer_id = importer_id.as_deref();
for kind in exprs {
match kind {
FilterExprKind::Include(filter_expr) => {
include_count += 1;
if filter_expr_interpreter(filter_expr, id, code, module_type, importer_id, cwd, &mut ctx) {
return true;
}
}
FilterExprKind::Exclude(filter_expr) => {
if filter_expr_interpreter(filter_expr, id, code, module_type, importer_id, cwd, &mut ctx) {
return false;
}
}
}
}
include_count == 0
}
#[derive(Debug)]
pub enum Token {
Id,
ImporterId,
Code,
ModuleType,
And(u32),
Or(u32),
Not,
Include,
Exclude,
CleanUrl,
Query,
String(String),
Regex(HybridRegex),
Boolean(bool),
}
impl From<StringOrRegex> for Token {
fn from(value: StringOrRegex) -> Self {
match value {
StringOrRegex::String(v) => Self::String(v),
StringOrRegex::Regex(regex) => Self::Regex(regex),
}
}
}
pub fn parse(mut tokens: Vec<Token>) -> anyhow::Result<FilterExprKind> {
fn pop(tokens: &mut Vec<Token>) -> anyhow::Result<Token> {
tokens.pop().ok_or_else(|| anyhow::anyhow!("unexpected end of filter expression tokens"))
}
fn pop_string_or_regex(tokens: &mut Vec<Token>, context: &str) -> anyhow::Result<StringOrRegex> {
match pop(tokens)? {
Token::String(str) => Ok(StringOrRegex::String(str)),
Token::Regex(regexp) => Ok(StringOrRegex::Regex(regexp)),
other => {
anyhow::bail!("{context} token should be followed by a string or regex, but got {other:?}")
}
}
}
fn rec(tokens: &mut Vec<Token>) -> anyhow::Result<FilterExpr> {
let token = pop(tokens)?;
match token {
Token::Id => Ok(FilterExpr::Id(pop_string_or_regex(tokens, "Id")?.into())),
Token::ImporterId => {
Ok(FilterExpr::ImporterId(pop_string_or_regex(tokens, "ImporterId")?.into()))
}
Token::Code => Ok(FilterExpr::Code(pop_string_or_regex(tokens, "Code")?)),
Token::Query => {
let key = match pop(tokens)? {
Token::String(key) => key,
other => anyhow::bail!("key of `Query` should be a string, but got {other:?}"),
};
let value = match pop(tokens)? {
Token::String(v) => QueryValue::String(v),
Token::Regex(v) => QueryValue::Regex(v),
Token::Boolean(v) => QueryValue::Boolean(v),
other => anyhow::bail!(
"value of `Query` should be a string, regex, or boolean, but got {other:?}"
),
};
Ok(FilterExpr::Query(key, value))
}
Token::ModuleType => {
let string = match pop(tokens)? {
Token::String(s) => s,
other => {
anyhow::bail!("ModuleType token should be followed by a string, but got {other:?}")
}
};
Ok(FilterExpr::ModuleType(string))
}
Token::And(arg_count) => {
let mut args = Vec::with_capacity(arg_count as usize);
for _ in 0..arg_count {
args.push(rec(tokens)?);
}
Ok(FilterExpr::And(args))
}
Token::Or(arg_count) => {
let mut args = Vec::with_capacity(arg_count as usize);
for _ in 0..arg_count {
args.push(rec(tokens)?);
}
Ok(FilterExpr::Or(args))
}
Token::Not => Ok(FilterExpr::Not(Box::new(rec(tokens)?))),
Token::CleanUrl => Ok(FilterExpr::CleanUrl(Box::new(rec(tokens)?))),
Token::Include => anyhow::bail!("Include token should not appear inside an expression"),
Token::Exclude => anyhow::bail!("Exclude token should not appear inside an expression"),
Token::String(_) => anyhow::bail!("String token should not appear standalone"),
Token::Regex(_) => anyhow::bail!("Regex token should not appear standalone"),
Token::Boolean(_) => anyhow::bail!("Boolean token should not appear standalone"),
}
}
match tokens.pop() {
Some(Token::Include) => Ok(FilterExprKind::Include(rec(&mut tokens)?)),
Some(Token::Exclude) => Ok(FilterExprKind::Exclude(rec(&mut tokens)?)),
Some(other) => {
anyhow::bail!("filter expression should start with Include or Exclude, but got {other:?}")
}
None => anyhow::bail!("filter expression is empty"),
}
}
#[cfg(test)]
mod test {
use crate::{
filter_expression::{FilterExpr, InterpreterCtx, QueryValue, Token, filter_expr_interpreter},
pattern_filter::StringOrRegex,
};
use super::{IdMatcher, filter_exprs_interpreter, parse};
#[test]
fn test_filter_expr_interpreter() {
let expr = FilterExpr::And(vec![
FilterExpr::Id(StringOrRegex::Regex("node_modules".into()).into()),
FilterExpr::Not(Box::new(FilterExpr::Code(StringOrRegex::Regex("import\\s*".into())))),
]);
assert!(!filter_expr_interpreter(
&expr,
Some("/foo/bar.js"),
Some("console.log('test')"),
None,
None,
".",
&mut InterpreterCtx::default()
));
assert!(filter_expr_interpreter(
&expr,
Some("/node_modules/bar.js"),
Some("console.log('test')"),
None,
None,
".",
&mut InterpreterCtx::default()
));
assert!(!filter_expr_interpreter(
&expr,
Some("/node_modules/bar.js"),
Some("import('foo')"),
None,
None,
".",
&mut InterpreterCtx::default()
));
#[cfg(windows)]
{
use super::FilterExprKind;
let expr = FilterExpr::Id(StringOrRegex::Regex("src/".into()).into());
assert!(filter_exprs_interpreter(
&[FilterExprKind::Include(expr)],
Some("C:\\path\\to\\src\\entry.js"),
None,
None,
None,
".",
));
}
}
#[test]
fn parse_test() {
let mut tokens = vec![
Token::Exclude,
Token::And(2u32),
Token::Id,
Token::Regex("node_modules".into()),
Token::Not,
Token::Code,
Token::Regex("import\\s*".into()),
];
tokens.reverse();
let expr = parse(tokens).unwrap();
assert!(!filter_exprs_interpreter(
&[expr],
Some("/node_modules/bar.js"),
Some("console.log('test')"),
None,
None,
".",
));
}
#[test]
fn missing_id_is_a_non_match_not_a_panic() {
let id_only = FilterExpr::Id(StringOrRegex::Regex("target".into()).into());
assert!(!filter_expr_interpreter(
&id_only,
None,
Some("console.log('test')"),
None,
None,
".",
&mut InterpreterCtx::default()
));
let query_only = FilterExpr::Query("raw".to_string(), QueryValue::Boolean(true));
assert!(!filter_expr_interpreter(
&query_only,
None,
Some("console.log('test')"),
None,
None,
".",
&mut InterpreterCtx::default()
));
}
#[test]
fn a_missing_id_reads_as_an_empty_query_not_a_dead_leaf() {
let eval = |expr: &FilterExpr| {
filter_expr_interpreter(
expr,
None,
Some("console.log('test')"),
None,
None,
".",
&mut InterpreterCtx::default(),
)
};
let present = FilterExpr::Query("raw".to_string(), QueryValue::Boolean(true));
let absent = FilterExpr::Query("raw".to_string(), QueryValue::Boolean(false));
assert!(!eval(&present), "no id carries `?raw`");
assert!(eval(&absent), "`query(k, false)` holds when there is no query");
assert_eq!(
eval(&absent),
eval(&FilterExpr::Not(Box::new(FilterExpr::Query(
"raw".to_string(),
QueryValue::Boolean(true)
)))),
"`query(k, false)` must agree with `not(query(k, true))`"
);
assert!(!eval(&FilterExpr::Query("raw".to_string(), QueryValue::String("1".to_string()))));
}
#[test]
fn missing_code_is_a_non_match_not_a_panic() {
let code_only = FilterExpr::Code(StringOrRegex::Regex("import".into()));
assert!(!filter_expr_interpreter(
&code_only,
Some("/target.js"),
None,
None,
None,
".",
&mut InterpreterCtx::default()
));
}
#[test]
fn id_matcher_relative_glob_is_cwd_correct() {
let matcher = IdMatcher::from(StringOrRegex::String("src/*.ts".to_string()));
assert!(matcher.test("/a/src/foo.ts", "/a"));
assert!(!matcher.test("/b/src/foo.ts", "/a"));
assert!(matcher.test("/b/src/foo.ts", "/b"));
assert!(!matcher.test("/a/src/foo.ts", "/b"));
}
#[test]
fn id_matcher_cwd_independent_glob_is_reused_across_cwds() {
let matcher = IdMatcher::from(StringOrRegex::String("**/*.ts".to_string()));
assert!(matcher.test("/a/src/foo.ts", "/a"));
assert!(matcher.test("/b/src/foo.ts", "/b"));
assert!(matcher.test("/b/src/foo.ts", "/a"));
assert!(matcher.test("/a/src/foo.ts", "/b"));
assert!(!matcher.test("/a/src/foo.js", "/a"));
assert!(!matcher.test("/a/src/foo.js", "/b"));
}
}