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:
repo.public && !repo.fork && repo.name in ["git-tool", "grey"]
This crate was extracted from the Sierra Softworks github-backup and mail-backup projects, where it powers their backup policy filtering.
Features
- Friendly syntax — reads like plain English, with
&&/||/!, comparisons, and string operators likecontains,startswith, andin. - Pattern matching — glob-style matching with
like(built in, zero allocations at evaluation time) and full regular expressions withmatches(behind the optionalregexfeature). - 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).
- 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
Filterabletrait; 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() - 5musing thechronofeature. - Optional serde support — deserialize filters directly out of your
configuration files with the
serdefeature. - Optional secret values — compare passwords and tokens in filters without
ever being able to print them, with the
secrecyfeature. - Optional visitor API — walk and transform the parsed expression tree with
your own
ExprVisitorviaFilter::visit, behind thevisitorfeature.
Usage
cargo add filt-rs
Implement Filterable for your type, then parse and evaluate filters:
use ;
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
| Literal | Example | Notes |
|---|---|---|
| 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:
| Operator | Meaning |
|---|---|
|| |
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.
| Function | Result |
|---|---|
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:
use Cow;
use Arc;
use ;
;
let custom: = ;
let filter = with_functions?;
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.
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):
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 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):
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.
cargo add filt-rs --features regex
Examples
!repo.fork && repo.name contains "awesome"
!release.prerelease && !asset.source-code
size > 1024 && (archived || disabled)
"backup" in tags
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:
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:
use ;
let filter = new?;
assert!;
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:
cargo add filt-rs --features serde
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:
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:
use BTreeSet;
use ;
let filter = new?;
let mut collector = default;
filter.visit;
assert!;
See examples/property_collector.rs for the
complete, runnable version:
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 FilterValues
your Filterable::get implementation returns.
Secret values
Enable the secrecy feature to expose sensitive properties (passwords, API
tokens, and the like) as 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:
cargo add filt-rs --features secrecy
use ;
let user = User ;
// Secrets compare exactly like strings within filter expressions...
let filter = new?;
assert!;
// ...but they are always redacted when formatted.
println!; // 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:
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.
Copyright © Sierra Softworks.