filt-rs 1.1.0

A human-friendly filter expression language for matching your objects against user-provided queries.
Documentation

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 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).
  • 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

cargo add filt-rs

Implement Filterable for your type, then parse and evaluate filters:

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

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 bb 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 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.

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 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:

cargo add filt-rs --features serde
#[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:

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 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 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 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:

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.