use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use thiserror::Error;
use crate::location::FilePath;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Gates {
pub path_matches: Vec<String>,
pub path_not_matches: Vec<String>,
pub file_contains: Vec<String>,
pub file_not_contains: Vec<String>,
}
impl Gates {
#[must_use]
pub fn is_empty(&self) -> bool {
self.path_matches.is_empty()
&& self.path_not_matches.is_empty()
&& self.file_contains.is_empty()
&& self.file_not_contains.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("invalid `{field}` gate pattern `{pattern}`: {detail}")]
pub struct GateError {
pub field: &'static str,
pattern: String,
detail: String,
}
#[derive(Debug)]
pub struct CompiledGates {
path_matches: Option<GlobSet>,
path_not_matches: Option<GlobSet>,
file_contains: Vec<String>,
file_not_contains: Vec<String>,
}
impl CompiledGates {
pub fn compile(gates: &Gates) -> Result<Self, GateError> {
Ok(Self {
path_matches: compile_set(&gates.path_matches, "pathMatches")?,
path_not_matches: compile_set(&gates.path_not_matches, "pathNotMatches")?,
file_contains: gates.file_contains.clone(),
file_not_contains: gates.file_not_contains.clone(),
})
}
#[must_use]
pub fn admits_path(&self, path: &FilePath) -> bool {
let path = path.as_str();
if self
.path_not_matches
.as_ref()
.is_some_and(|set| set.is_match(path))
{
return false;
}
self.path_matches
.as_ref()
.is_none_or(|set| set.is_match(path))
}
#[must_use]
pub fn admits_content(&self, bytes: &[u8]) -> bool {
for needle in &self.file_not_contains {
if contains(bytes, needle.as_bytes()) {
return false;
}
}
self.file_contains
.iter()
.all(|needle| contains(bytes, needle.as_bytes()))
}
#[must_use]
pub fn has_content_gates(&self) -> bool {
!self.file_contains.is_empty() || !self.file_not_contains.is_empty()
}
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
memchr::memmem::find(haystack, needle).is_some()
}
fn compile_set(patterns: &[String], field: &'static str) -> Result<Option<GlobSet>, GateError> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|e| GateError {
field,
pattern: pattern.clone(),
detail: e.to_string(),
})?;
builder.add(glob);
}
builder.build().map(Some).map_err(|e| GateError {
field,
pattern: patterns.join(", "),
detail: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn gates(
path_matches: &[&str],
path_not_matches: &[&str],
file_contains: &[&str],
file_not_contains: &[&str],
) -> CompiledGates {
let own = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect();
CompiledGates::compile(&Gates {
path_matches: own(path_matches),
path_not_matches: own(path_not_matches),
file_contains: own(file_contains),
file_not_contains: own(file_not_contains),
})
.expect("compiles")
}
#[test]
fn no_gates_admits_everything() {
let g = gates(&[], &[], &[], &[]);
assert!(g.admits_path(&FilePath::new("anything.ts")));
assert!(g.admits_content(b""));
assert!(g.admits_content(b"whatever"));
assert!(!g.has_content_gates());
}
#[test]
fn path_matches_narrows() {
let g = gates(&["src/**/*.tsx"], &[], &[], &[]);
assert!(g.admits_path(&FilePath::new("src/a/B.tsx")));
assert!(!g.admits_path(&FilePath::new("src/a.ts")));
assert!(!g.admits_path(&FilePath::new("lib/a.tsx")));
}
#[test]
fn path_not_matches_wins_over_path_matches() {
let g = gates(&["src/**"], &["**/generated/**"], &[], &[]);
assert!(g.admits_path(&FilePath::new("src/a.ts")));
assert!(!g.admits_path(&FilePath::new("src/generated/a.ts")));
}
#[test]
fn file_contains_requires_all_of_them() {
let g = gates(&[], &[], &["makeStyles", "theme"], &[]);
assert!(g.admits_content(b"import { makeStyles } from 'x'; theme.spacing"));
assert!(!g.admits_content(b"import { makeStyles } from 'x'"));
assert!(!g.admits_content(b"theme.spacing"));
}
#[test]
fn file_not_contains_rejects_on_any_of_them() {
let g = gates(&[], &[], &[], &["@generated", "DO NOT EDIT"]);
assert!(g.admits_content(b"ordinary source"));
assert!(!g.admits_content(b"// @generated by something"));
assert!(!g.admits_content(b"// DO NOT EDIT"));
}
#[test]
fn content_gates_are_reported_so_a_caller_can_skip_the_read() {
assert!(!gates(&["**/*.ts"], &[], &[], &[]).has_content_gates());
assert!(gates(&[], &[], &["x"], &[]).has_content_gates());
assert!(gates(&[], &[], &[], &["x"]).has_content_gates());
}
#[test]
fn gates_only_ever_narrow() {
let ungated = gates(&[], &[], &[], &[]);
let gated = gates(&["src/**"], &["**/gen/**"], &["needle"], &["skip"]);
for path in ["src/a.ts", "src/gen/a.ts", "lib/a.ts"] {
let path = FilePath::new(path);
if gated.admits_path(&path) {
assert!(
ungated.admits_path(&path),
"gating admitted more for {path}"
);
}
}
for bytes in [b"needle".as_slice(), b"skip needle", b"nothing"] {
if gated.admits_content(bytes) {
assert!(
ungated.admits_content(bytes),
"gating admitted more content"
);
}
}
}
#[test]
fn an_empty_needle_matches_anything() {
let g = gates(&[], &[], &[""], &[]);
assert!(g.admits_content(b""));
assert!(g.admits_content(b"anything"));
}
#[test]
fn content_matching_is_byte_exact() {
let g = gates(&[], &[], &["makeStyles"], &[]);
assert!(
!g.admits_content(b"makestyles"),
"matching must be case-sensitive"
);
assert!(g.admits_content("prefix makeStyles suffix".as_bytes()));
}
#[test]
fn a_malformed_pattern_names_its_field() {
let err = CompiledGates::compile(&Gates {
path_matches: vec!["src/[".to_owned()],
..Gates::default()
})
.expect_err("malformed");
assert_eq!(err.field, "pathMatches");
let err = CompiledGates::compile(&Gates {
path_not_matches: vec!["src/[".to_owned()],
..Gates::default()
})
.expect_err("malformed");
assert_eq!(err.field, "pathNotMatches");
}
#[test]
fn gates_report_whether_they_are_empty() {
assert!(Gates::default().is_empty());
assert!(
!Gates {
file_contains: vec!["x".to_owned()],
..Gates::default()
}
.is_empty()
);
}
}