<div align="center">
<img src="assets/logo.svg" alt="filt-rs" width="440">
<p><strong>A human-friendly filter expression language for matching your objects against user-provided queries.</strong></p>
<p>
<a href="https://github.com/SierraSoftworks/filters/actions/workflows/ci.yml"><img src="https://github.com/SierraSoftworks/filters/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://crates.io/crates/filt-rs"><img src="https://img.shields.io/crates/v/filt-rs.svg" alt="crates.io"></a>
<a href="https://docs.rs/filt-rs"><img src="https://img.shields.io/docsrs/filt-rs" alt="docs.rs"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/SierraSoftworks/filters" alt="MIT License"></a>
</p>
</div>
---
`filt-rs` gives your users a small, safe, and friendly expression language for
describing *which* of your objects a tool should operate on — which repositories
to back up, which emails to restore, which releases to download. You implement
a single-method trait to expose your object's properties, and your users write
filters like this:
```text
repo.public && !repo.fork && repo.name in ["git-tool", "grey"]
```
This crate was extracted from the Sierra Softworks
[github-backup](https://github.com/SierraSoftworks/github-backup) and
[mail-backup](https://github.com/SierraSoftworks/mail-backup) projects, where
it powers their backup policy filtering.
## Features
- **Friendly syntax** — reads like plain English, with `&&`/`||`/`!`,
comparisons, and string operators like `contains`, `startswith`, and `in`.
- **Pattern matching** — glob-style matching with `like` (built in, zero
allocations at evaluation time) and full regular expressions with `matches`
(behind the optional `regex` feature).
- **Helpful errors** — parse and evaluation errors include the exact line and
column of the problem along with advice on how to fix it (powered by
[human-errors](https://crates.io/crates/human-errors)).
- **Parse once, evaluate cheaply** — filters are compiled to an AST up front
and can then be evaluated against any number of objects.
- **Bring your own objects** — implement the single-method `Filterable` trait;
no derives, reflection, or serialization required.
- **Lightweight** — a single small dependency, no async, no unsafe API surface.
- **Optional datetime support** — filter on timestamps with relative-time
expressions like `event.timestamp > now() - 5m` using the `chrono` feature.
- **Optional serde support** — deserialize filters directly out of your
configuration files with the `serde` feature.
- **Optional secret values** — compare passwords and tokens in filters without
ever being able to print them, with the `secrecy` feature.
- **Optional visitor API** — walk and transform the parsed expression tree with
your own `ExprVisitor` via `Filter::visit`, behind the `visitor` feature.
## Usage
```shell
cargo add filt-rs
```
Implement `Filterable` for your type, then parse and evaluate filters:
```rust
use filt_rs::{Filter, FilterValue, Filterable};
struct Repo {
name: &'static str,
public: bool,
stars: u32,
}
impl Filterable for Repo {
fn get(&self, key: &str) -> FilterValue<'_> {
match key {
"repo.name" => self.name.into(),
"repo.public" => self.public.into(),
"repo.stars" => self.stars.into(),
_ => FilterValue::Null,
}
}
}
fn main() -> Result<(), filt_rs::Error> {
let filter = Filter::new("repo.public && repo.stars >= 50")?;
let repo = Repo { name: "git-tool", public: true, stars: 87 };
assert!(filter.matches(&repo)?);
let repo = Repo { name: "top-secret", public: false, stars: 3 };
assert!(!filter.matches(&repo)?);
Ok(())
}
```
## Filter syntax
A filter is a single logical expression which is evaluated against each object,
matching whenever the expression evaluates to a truthy value (`null`, `false`,
`0`, `""`, and `[]` are falsy; everything else is truthy).
### Literals
| Null | `null` | Also returned for properties which aren't found. |
| Boolean | `true`, `false` | |
| Number | `123`, `123.45` | All numbers are 64-bit floats internally. |
| String | `"hello"` | Escape embedded quotes with `\"`. |
| Raw string | `r"^v\d+$"` | No escape processing. Use `r#"..."#` (e.g. `r#"{"k":1}"#`) to embed `"`. |
| Tuple | `["a", "b"]` | A list of literal values. |
| Duration | `5m`, `1h30m`, `500ms` | Requires the `chrono` feature. |
### Properties
Any other identifier — including `.` and `-` separated names like
`release.prerelease` or `asset.source-code` — is treated as a property
reference and resolved by calling `Filterable::get` on the target object.
Operator keywords (`in`, `contains`, `startswith`, `endswith`, `like`,
`matches`, and their `_cs` variants) are reserved and cannot be used as
property names.
### Operators
In order of increasing precedence:
| `\|\|` | Logical OR (short-circuiting). |
| `&&` | Logical AND (short-circuiting). |
| `==`, `!=` | Equality (strings are compared case-insensitively). |
| `>`, `>=`, `<`, `<=` | Ordering comparisons. |
| `contains` | String contains a substring, or tuple contains a value. |
| `in` | Inverse of `contains` (`a in b` ≡ `b contains a`). |
| `startswith`, `endswith` | String prefix/suffix tests (case-insensitive). |
| `like` | Case-insensitive glob match (`*` and `?` wildcards). |
| `matches` | Regular expression match (requires the `regex` feature). |
| `+`, `-` | Addition and subtraction (numbers, datetimes, and durations). |
| `!` | Logical NOT (unary). |
| `(...)` | Grouping. |
Arithmetic binds tighter than comparisons (so `a + b > c` reads as
`(a + b) > c`), and unsupported operand combinations evaluate to `null` rather
than failing. There is no unary minus — write `0 - 5` for a negative value —
and a `-` *inside* a property name remains part of that name, so
`asset.source-code` keeps working as a single property.
### Functions
Filters may call built-in functions using the familiar `name(args...)` syntax.
Unknown function names and incorrect argument counts are rejected when the
filter is parsed, with an error listing the supported functions.
| `now()` | The current UTC time, evaluated afresh on every `Filter::matches` call. Requires the `chrono` feature. |
| `trim(string)` | The string argument with leading and trailing whitespace removed (`null` for non-string values). |
You can extend the language with your own helpers by implementing the
`Function` trait and constructing filters with `Filter::with_functions`, which
makes them available *in addition to* the built-in set:
```rust,ignore
use std::borrow::Cow;
use std::sync::Arc;
use filt_rs::{Filter, FilterValue, Function};
struct Reverse;
impl Function for Reverse {
fn name(&self) -> &str { "reverse" }
fn arity(&self) -> usize { 1 }
fn call<'a>(&self, args: &[Cow<'a, FilterValue<'a>>]) -> Cow<'a, FilterValue<'a>> {
match args[0].as_ref() {
FilterValue::String(s) => {
Cow::Owned(FilterValue::String(s.chars().rev().collect::<String>().into()))
}
_ => Cow::Owned(FilterValue::Null),
}
}
}
let custom: [Arc<dyn Function>; 1] = [Arc::new(Reverse)];
let filter = Filter::with_functions(r#"reverse(word) == "olleh""#, custom)?;
```
### Case sensitivity
The string operators compare case-insensitively by default, folding both
operands with the language's Unicode case-folding rules. Each of them (except
`matches`, where the pattern author controls casing with `(?i)`) has a
case-sensitive variant with a `_cs` suffix which compares strings exactly as
written: `contains_cs`, `in_cs`, `startswith_cs`, `endswith_cs`, and
`like_cs`. Tuple membership through `contains_cs` and `in_cs` compares the
tuple's elements case-sensitively too.
```text
branch.name startswith_cs "Feat/" && "Alice" in_cs branch.reviewers
```
### Pattern matching
The `like` operator matches a string against a glob pattern, where `*` matches
any sequence of characters (including none), `?` matches exactly one
character, and a backslash makes the following character literal (`\*`, `\?`,
`\\`). Character classes like `[a-z]` are not supported. As with the rest of
the language, matching is case-insensitive, using the same Unicode
case-folding rules as `==`, `contains`, `startswith`, and `endswith` —
including multi-character folds, so `"groß" like "*ss"` holds (note that `?`
counts folded characters, so `ß` counts as two):
```text
branch.name like "feat/*"
repo.name like "*-backup"
version like "v?.?.?"
```
With the optional `regex` feature enabled, the `matches` operator tests a
string against a regular expression (powered by the
[regex](https://docs.rs/regex) crate). Raw strings (`r"..."`) avoid having to
escape backslashes. Unlike the rest of the language, regular expressions are
case-sensitive as written (use `(?i)` to ignore case) and unanchored (use `^`
and `$` to anchor the match):
```text
branch.name matches r"^release/v\d+(\.\d+){2}$"
commit.message matches "(?i)breaking change"
```
Both operators require their pattern to be a string literal: patterns are
compiled once when the filter is parsed (invalid regular expressions are
reported as friendly parse errors) and evaluation performs no
pattern-related heap allocation. Only string values can match a pattern;
tuples match when any of their string elements match, while `null`, booleans,
and numbers never match.
```shell
cargo add filt-rs --features regex
```
### Examples
```text
!repo.fork && repo.name contains "awesome"
!release.prerelease && !asset.source-code
branch.name like "feat/*"
branch.name matches r"^release/v\d+(\.\d+){2}$"
event.timestamp > now() - 5m
trim(issue.title) != ""
```
## Datetime support
Enable the `chrono` feature to filter on timestamps with relative-time
expressions:
```shell
cargo add filt-rs --features chrono
```
Expose a `FilterValue::DateTime` from your `Filterable` implementation (any
`chrono::DateTime<Utc>` or `std::time::SystemTime` converts with `.into()`),
and your users can write filters like `event.timestamp > now() - 5m`:
```rust,ignore
use filt_rs::{Filter, FilterValue, Filterable};
struct Event {
timestamp: chrono::DateTime<chrono::Utc>,
}
impl Filterable for Event {
fn get(&self, key: &str) -> FilterValue<'_> {
match key {
"event.timestamp" => self.timestamp.into(),
_ => FilterValue::Null,
}
}
}
let filter = Filter::new("event.timestamp > now() - 5m")?;
assert!(filter.matches(&Event { timestamp: chrono::Utc::now() })?);
```
Durations are written as a number immediately followed by a unit — `ms`, `s`,
`m` (minutes), `h`, `d`, or `w` — and several segments can be chained together
(`1h30m`). Datetimes and durations compare against values of the same type and
support `+`/`-` arithmetic: `DateTime ± Duration → DateTime`,
`DateTime - DateTime → Duration`, and `Duration ± Duration → Duration`.
## Serde support
Enable the `serde` feature to deserialize filters directly from your
configuration files:
```shell
cargo add filt-rs --features serde
```
```rust,ignore
#[derive(serde::Deserialize)]
struct BackupPolicy {
kind: String,
from: String,
#[serde(default)]
filter: filt_rs::Filter,
}
```
Missing or `null` filter fields deserialize to the match-everything filter
`true`, so optional filters work out of the box.
## Inspecting filters
Enable the `visitor` feature to expose the parsed expression tree and walk it
with your own visitor — useful for validating which properties a filter
references, estimating its cost, or translating it into another query language:
```shell
cargo add filt-rs --features visitor
```
Implement the `ExprVisitor` trait and pass it to `Filter::visit`, which returns
whatever your visitor produces. Each `visit_*` method is handed the relevant
child nodes (and, for the binary/logical/unary cases, a `BinaryOperator`,
`LogicalOperator`, or `UnaryOperator` so there's no ambiguity about which
operators can appear where), so you control how the tree is traversed:
```rust,ignore
use std::collections::BTreeSet;
use filt_rs::{BinaryOperator, Expr, ExprVisitor, Filter, FilterValue, Function, Glob, LogicalOperator, UnaryOperator};
#[derive(Default)]
struct PropertyCollector<'a> {
properties: BTreeSet<&'a str>,
}
impl<'a> ExprVisitor<'a, ()> for PropertyCollector<'a> {
fn visit_property(&mut self, name: &'a str) {
self.properties.insert(name);
}
fn visit_binary(&mut self, l: &'a Expr<'a>, _op: BinaryOperator, r: &'a Expr<'a>) {
self.visit_expr(l);
self.visit_expr(r);
}
// ...and the other node kinds, recursing with `self.visit_expr(child)`.
# fn visit_literal(&mut self, _v: &'a FilterValue<'a>) {}
# fn visit_function_call(&mut self, _f: &'a dyn Function, args: &'a [Expr<'a>]) { for a in args { self.visit_expr(a); } }
# fn visit_logical(&mut self, l: &'a Expr<'a>, _op: LogicalOperator, r: &'a Expr<'a>) { self.visit_expr(l); self.visit_expr(r); }
# fn visit_unary(&mut self, _op: UnaryOperator, r: &'a Expr<'a>) { self.visit_expr(r); }
# fn visit_like(&mut self, l: &'a Expr<'a>, _g: &'a Glob) { self.visit_expr(l); }
}
let filter = Filter::new("repo.public && repo.stars >= 50")?;
let mut collector = PropertyCollector::default();
filter.visit(&mut collector);
assert!(collector.properties.contains("repo.stars"));
```
See [`examples/property_collector.rs`](examples/property_collector.rs) for the
complete, runnable version:
```shell
cargo run --example property_collector --features visitor
```
## Performance
Filters are parsed once and may then be evaluated against any number of
objects. Evaluation is allocation-free except for the owned `FilterValue`s
your `Filterable::get` implementation returns.
## Secret values
Enable the `secrecy` feature to expose sensitive properties (passwords, API
tokens, and the like) as [secrecy](https://crates.io/crates/secrecy)-backed
secrets. Secret values behave exactly like strings in every filter operation,
but are always redacted when formatted — so they can never leak into your logs:
```shell
cargo add filt-rs --features secrecy
```
```rust,ignore
use filt_rs::{Filter, FilterValue, Filterable};
struct User {
password: secrecy::SecretString,
}
impl Filterable for User {
fn get(&self, key: &str) -> FilterValue<'_> {
match key {
"user.password" => self.password.clone().into(),
_ => FilterValue::Null,
}
}
}
let user = User { password: "hunter2".into() };
// Secrets compare exactly like strings within filter expressions...
let filter = Filter::new(r#"user.password == "Hunter2""#)?;
assert!(filter.matches(&user)?);
// ...but they are always redacted when formatted.
println!("{}", user.get("user.password")); // prints: [REDACTED]
```
Note that, as with all of the filter language's comparisons, secret comparisons
are not constant-time — don't rely on them to defend against timing attacks.
## Error messages
Errors are designed to be shown directly to the people writing the filters:
```text
Oops! Filter included an orphaned '&' at line 1, column 13 which is not a valid operator.
To try and fix this, you can:
- Ensure that you are using the '&&' operator to implement a logical AND within your filter.
```
## License
Licensed under the [MIT License](LICENSE).
Copyright © Sierra Softworks.