filt_rs/lib.rs
1//! A human-friendly filter expression language for matching your objects against
2//! user-provided queries.
3//!
4//! This crate provides a small, dependency-light filtering DSL designed for
5//! situations where your users need to describe *which* items a tool should
6//! operate on — for example which repositories to back up, which emails to
7//! restore, or which releases to download. It was originally developed for
8//! (and extracted from) the Sierra Softworks
9//! [`github-backup`](https://github.com/SierraSoftworks/github-backup) and
10//! [`mail-backup`](https://github.com/SierraSoftworks/mail-backup) projects.
11//!
12//! # Quick start
13//!
14//! Implement the [`Filterable`] trait on your type to expose the properties
15//! which may be referenced in a filter expression, then parse a [`Filter`]
16//! and evaluate it against your objects.
17//!
18//! ```
19//! use filt_rs::{Filter, FilterValue, Filterable};
20//!
21//! struct Repo {
22//! name: &'static str,
23//! public: bool,
24//! stars: u32,
25//! }
26//!
27//! impl Filterable for Repo {
28//! fn get(&self, key: &str) -> FilterValue<'_> {
29//! match key {
30//! "repo.name" => self.name.into(),
31//! "repo.public" => self.public.into(),
32//! "repo.stars" => self.stars.into(),
33//! _ => FilterValue::Null,
34//! }
35//! }
36//! }
37//!
38//! # fn main() -> Result<(), filt_rs::Error> {
39//! let filter = Filter::new("repo.public && repo.stars >= 50")?;
40//!
41//! let repo = Repo { name: "git-tool", public: true, stars: 87 };
42//! assert!(filter.matches(&repo)?);
43//!
44//! let repo = Repo { name: "top-secret", public: false, stars: 3 };
45//! assert!(!filter.matches(&repo)?);
46//! # Ok(())
47//! # }
48//! ```
49//!
50//! # Filter syntax
51//!
52//! A filter is a single logical expression which is evaluated against each
53//! object, matching the object whenever the expression is
54//! [truthy](FilterValue::is_truthy).
55//!
56//! ```text
57//! repo.public && !repo.fork && repo.name in ["git-tool", "grey"]
58//! ```
59//!
60//! ## Literals
61//!
62//! | Literal | Example | Notes |
63//! |------------|------------------------|--------------------------------------------------|
64//! | Null | `null` | Also returned for properties which aren't found. |
65//! | Boolean | `true`, `false` | |
66//! | Number | `123`, `123.45` | All numbers are 64-bit floats internally. |
67//! | String | `"hello"` | Escape embedded quotes with `\"`. |
68//! | Raw string | `r"^v\d+$"` | No escape processing; cannot contain `"` (the `r#"..."#` form is not supported). |
69//! | Tuple | `["a", "b"]` | A list of literal values. |
70//! | Duration | `5m`, `1h30m`, `500ms` | Requires the **`chrono`** crate feature. |
71//!
72//! ## Properties
73//!
74//! Any other identifier (including `.` and `-` separated names like
75//! `release.prerelease` or `asset.source-code`) is treated as a property
76//! reference, and is resolved by calling [`Filterable::get`] on the target
77//! object. Note that the operator keywords below (`in`, `contains`, `like`,
78//! `matches`, etc.) are reserved and cannot be used as property names.
79//!
80//! ## Operators
81//!
82//! In order of increasing precedence:
83//!
84//! | Operator | Meaning |
85//! |--------------------------|--------------------------------------------------------------------|
86//! | `\|\|` | Logical OR (short-circuiting). |
87//! | `&&` | Logical AND (short-circuiting). |
88//! | `==`, `!=` | Equality (strings are compared case-insensitively). |
89//! | `>`, `>=`, `<`, `<=` | Ordering comparisons. |
90//! | `contains` | String contains a substring, or tuple contains a value. |
91//! | `in` | Inverse of `contains` (i.e. `a in b` ≡ `b contains a`). |
92//! | `startswith`, `endswith` | String prefix/suffix tests (case-insensitive). |
93//! | `like` | Case-insensitive glob match (`*` and `?` wildcards). |
94//! | `matches` | Regular expression match (requires the **`regex`** crate feature). |
95//! | `+`, `-` | Addition and subtraction (numbers, datetimes, and durations). |
96//! | `!` | Logical NOT (unary). |
97//! | `(...)` | Grouping. |
98//!
99//! ## Case sensitivity
100//!
101//! The string operators above compare case-insensitively, folding both
102//! operands with the language's Unicode case-folding rules. Each of them
103//! (except `matches`, where the pattern author controls casing with `(?i)`)
104//! has a case-*sensitive* variant with a `_cs` suffix which compares strings
105//! exactly as written: `contains_cs`, `in_cs`, `startswith_cs`,
106//! `endswith_cs`, and `like_cs`. They sit at the same precedence as their
107//! case-insensitive counterparts, and tuple membership through `contains_cs`
108//! and `in_cs` compares the tuple's elements case-sensitively too.
109//!
110//! ```text
111//! branch.name startswith_cs "Feat/" && "Alice" in_cs branch.reviewers
112//! ```
113//!
114//! ## Pattern matching
115//!
116//! The `like` operator matches a string against a glob pattern. `*` matches
117//! any sequence of characters (including none), `?` matches exactly one
118//! character, and a backslash makes the following character literal (`\*`,
119//! `\?`, `\\`); character classes like `[a-z]` are **not** supported. Like
120//! the rest of the language, matching is case-insensitive: both the pattern
121//! and the input are folded using the language's Unicode case-folding rules,
122//! including multi-character folds (`"groß" like "*ss"` holds, and `?`
123//! counts folded characters, so `ß` counts as two). The `like_cs` variant
124//! matches case-sensitively instead, with no folding at all.
125//!
126//! ```
127//! use filt_rs::{Filter, FilterValue, Filterable};
128//!
129//! struct Branch(&'static str);
130//!
131//! impl Filterable for Branch {
132//! fn get(&self, key: &str) -> FilterValue<'_> {
133//! match key {
134//! "branch.name" => self.0.into(),
135//! _ => FilterValue::Null,
136//! }
137//! }
138//! }
139//!
140//! # fn main() -> Result<(), filt_rs::Error> {
141//! let filter = Filter::new(r#"branch.name like "feat/*""#)?;
142//! assert!(filter.matches(&Branch("feat/login"))?);
143//! assert!(filter.matches(&Branch("FEAT/LOGIN"))?);
144//! assert!(!filter.matches(&Branch("fix/typo"))?);
145//! # Ok(())
146//! # }
147//! ```
148//!
149//! With the **`regex`** crate feature enabled, the `matches` operator tests a
150//! string against a regular expression (as implemented by the
151//! [regex](https://docs.rs/regex) crate). Raw strings (`r"..."`) are the most
152//! convenient way to write these, since they perform no escape processing.
153//! Unlike the rest of the language, regular expressions are case-sensitive as
154//! written (use `(?i)` to ignore case) and unanchored (use `^` and `$` to
155//! anchor the match).
156//!
157//! ```
158//! # use filt_rs::{Filter, FilterValue, Filterable};
159//! # struct Branch(&'static str);
160//! # impl Filterable for Branch {
161//! # fn get(&self, key: &str) -> FilterValue<'_> {
162//! # match key {
163//! # "branch.name" => self.0.into(),
164//! # _ => FilterValue::Null,
165//! # }
166//! # }
167//! # }
168//! # fn main() -> Result<(), filt_rs::Error> {
169//! # #[cfg(feature = "regex")]
170//! # {
171//! let filter = Filter::new(r#"branch.name matches r"^release/v\d+(\.\d+){2}$""#)?;
172//! assert!(filter.matches(&Branch("release/v1.2.3"))?);
173//! assert!(!filter.matches(&Branch("release/v1.2"))?);
174//! # }
175//! # Ok(())
176//! # }
177//! ```
178//!
179//! Both operators require their pattern to be a string literal: the pattern
180//! is compiled once when the filter is parsed (with invalid regular
181//! expressions reported as friendly [`Filter::new`] errors), and evaluation
182//! performs no pattern-related heap allocation. Glob evaluation is fully
183//! allocation-free, while regex evaluation is *amortized* allocation-free
184//! (the regex engine lazily allocates per-thread scratch space on first use
185//! and reuses it thereafter). Only string values can match a pattern: tuples
186//! match when any of their string elements match, while `null`, booleans, and
187//! numbers never match — even against `like "*"`.
188//!
189//! ## Arithmetic
190//!
191//! The `+` and `-` operators bind tighter than comparisons, so
192//! `a + b > c` is read as `(a + b) > c`. Numbers may be added to and
193//! subtracted from one another, while any unsupported combination of operand
194//! types evaluates to `null` (consistent with the language's lenient
195//! comparison semantics). There is no unary minus: write `0 - 5` to produce a
196//! negative value.
197//!
198//! ```
199//! # use filt_rs::Filter;
200//! # struct Nothing;
201//! # impl filt_rs::Filterable for Nothing {
202//! # fn get(&self, _key: &str) -> filt_rs::FilterValue<'_> { filt_rs::FilterValue::Null }
203//! # }
204//! # fn main() -> Result<(), filt_rs::Error> {
205//! let filter = Filter::new("1 + 2 - 4 < 0")?;
206//! assert!(filter.matches(&Nothing)?);
207//! # Ok(())
208//! # }
209//! ```
210//!
211//! Note that a `-` *inside* a property name remains part of that name (so
212//! `asset.source-code` is a single property), while a `-` which starts a new
213//! token is the subtraction operator: `asset.size - 5` subtracts, but
214//! `asset.size-5` references a property named `asset.size-5`.
215//!
216//! ## Functions
217//!
218//! Filters may call built-in functions using the familiar `name(args...)`
219//! syntax. Function names and argument counts are validated when the filter
220//! is parsed, so typos fail fast with a friendly error rather than at
221//! evaluation time.
222//!
223//! | Function | Result |
224//! |----------|-----------------------------------------------------------------------------------|
225//! | `now()` | The current UTC time, evaluated at each [`Filter::matches`] call. Requires **`chrono`**. |
226//!
227//! ## Datetimes and durations
228//!
229//! With the **`chrono`** crate feature enabled, filters can work with points
230//! in time and spans of time:
231//!
232//! - Duration literals are written as a number immediately followed by a
233//! unit — `ms` (milliseconds), `s` (seconds), `m` (minutes), `h` (hours),
234//! `d` (days), or `w` (weeks) — and may chain several segments together:
235//! `90s`, `5m`, `1h30m`, `500ms`.
236//! - [`Filterable::get`] implementations can return
237//! [`FilterValue::DateTime`](FilterValue) values (e.g. from
238//! [`chrono::DateTime<Utc>`](https://docs.rs/chrono/latest/chrono/struct.DateTime.html)
239//! or [`std::time::SystemTime`]).
240//! - Datetimes and durations support ordering comparisons against values of
241//! the same type, and arithmetic via `+` and `-`:
242//! `DateTime ± Duration → DateTime`, `DateTime - DateTime → Duration`, and
243//! `Duration ± Duration → Duration`.
244//! - Datetimes are always truthy, while durations are truthy if (and only
245//! if) they are non-zero.
246//!
247//! This makes relative-time filters pleasantly concise:
248//!
249//! ```text
250//! event.timestamp > now() - 5m
251//! ```
252//!
253//! Without the `chrono` feature, duration literals and `now()` are still
254//! recognised by the parser but produce a friendly error explaining that the
255//! feature must be enabled.
256//!
257//! # Crate features
258//!
259//! - **`regex`** — enables the `matches` regular expression operator (adds a
260//! dependency on the [regex](https://docs.rs/regex) crate). Without this
261//! feature, filters using `matches` fail to parse with an error explaining
262//! how to enable it.
263//! - **`chrono`** — adds datetime and duration support: the
264//! [`FilterValue::DateTime`](FilterValue) and
265//! [`FilterValue::Duration`](FilterValue) variants, duration literals such
266//! as `5m` and `1h30m`, the `now()` function, and temporal arithmetic and
267//! comparisons (see [Datetimes and durations](#datetimes-and-durations)).
268//!
269//! - **`secrecy`** — adds a `FilterValue::Secret` variant backed by the
270//! [`secrecy`](https://docs.rs/secrecy) crate's `SecretString`. Secret values
271//! behave exactly like strings in every comparison operation, but are always
272//! formatted as `[REDACTED]`, making it impossible to leak them through
273//! logging. See `FilterValue::secret` for details.
274//!
275//! ```
276//! # #[cfg(feature = "secrecy")] {
277//! use filt_rs::{Filter, FilterValue, Filterable};
278//!
279//! struct Credentials {
280//! password: secrecy::SecretString,
281//! }
282//!
283//! impl Filterable for Credentials {
284//! fn get(&self, key: &str) -> FilterValue<'_> {
285//! match key {
286//! "password" => self.password.clone().into(),
287//! _ => FilterValue::Null,
288//! }
289//! }
290//! }
291//!
292//! let creds = Credentials { password: "hunter2".into() };
293//!
294//! // Secrets compare exactly like strings within filter expressions...
295//! let filter = Filter::new(r#"password == "Hunter2""#).unwrap();
296//! assert!(filter.matches(&creds).unwrap());
297//!
298//! // ...but they are always redacted when formatted.
299//! assert_eq!(creds.get("password").to_string(), "[REDACTED]");
300//! # }
301//! ```
302//!
303//! - **`serde`** — implements [`serde::Deserialize`] for [`Filter`], allowing
304//! filters to be parsed directly out of configuration files (a missing or
305//! `null` value deserializes to the match-everything `true` filter).
306//!
307//! - **`visitor`** — exposes the parsed expression tree and a visitor
308//! interface: the `Expr` AST, the `ExprVisitor` trait, the `BinaryOperator`,
309//! `LogicalOperator`, and `UnaryOperator` enums, and the `Filter::visit`
310//! method. This lets downstream crates walk and transform a filter — for
311//! example to collect the properties it references, estimate its cost, or
312//! translate it into another query language. See the `property_collector`
313//! example (`cargo run --example property_collector --features visitor`) for
314//! a worked illustration.
315//!
316//! [`serde::Deserialize`]: https://docs.rs/serde/latest/serde/trait.Deserialize.html
317
318#![warn(missing_docs)]
319#![deny(rustdoc::broken_intra_doc_links)]
320#![doc(
321 html_logo_url = "https://raw.githubusercontent.com/SierraSoftworks/filters/main/assets/icon.svg",
322 html_favicon_url = "https://raw.githubusercontent.com/SierraSoftworks/filters/main/assets/icon.svg"
323)]
324
325mod case_sensitivity;
326mod expr;
327mod interpreter;
328mod lexer;
329mod location;
330mod operator;
331mod parser;
332mod pattern;
333mod token;
334mod value;
335
336use std::{fmt::Display, pin::Pin, ptr::NonNull};
337
338use interpreter::FilterContext;
339
340pub use human_errors::Error;
341pub use value::{FilterValue, Filterable};
342
343// The expression-visitor API is gated behind the `visitor` feature. The `Expr`
344// AST and `ExprVisitor` trait are always needed internally (the interpreter is
345// itself a visitor), so they are imported privately when the feature is off and
346// re-exported publicly when it is on.
347#[cfg(feature = "visitor")]
348pub use expr::{Expr, ExprVisitor};
349#[cfg(not(feature = "visitor"))]
350use expr::{Expr, ExprVisitor};
351
352#[cfg(feature = "visitor")]
353pub use operator::{BinaryOperator, LogicalOperator, UnaryOperator};
354#[cfg(all(feature = "visitor", feature = "regex"))]
355pub use pattern::CompiledRegex;
356#[cfg(feature = "visitor")]
357pub use pattern::Glob;
358
359/// A parsed filter expression which can be evaluated against [`Filterable`] objects.
360///
361/// A `Filter` is constructed from a textual filter expression using
362/// [`Filter::new`], which tokenizes and parses the expression up-front so that
363/// it can be cheaply evaluated against any number of objects using
364/// [`Filter::matches`].
365///
366/// ```
367/// use filt_rs::{Filter, FilterValue, Filterable};
368///
369/// struct Server {
370/// hostname: &'static str,
371/// port: u16,
372/// }
373///
374/// impl Filterable for Server {
375/// fn get(&self, key: &str) -> FilterValue<'_> {
376/// match key {
377/// "hostname" => self.hostname.into(),
378/// "port" => self.port.into(),
379/// _ => FilterValue::Null,
380/// }
381/// }
382/// }
383///
384/// # fn main() -> Result<(), filt_rs::Error> {
385/// let filter = Filter::new(r#"hostname startswith "web" && port == 443"#)?;
386///
387/// assert!(filter.matches(&Server { hostname: "web-01", port: 443 })?);
388/// assert!(!filter.matches(&Server { hostname: "db-01", port: 5432 })?);
389/// # Ok(())
390/// # }
391/// ```
392///
393/// The default filter is the expression `true`, which matches every object:
394///
395/// ```
396/// # use filt_rs::{Filter, FilterValue, Filterable};
397/// # struct Anything;
398/// # impl Filterable for Anything {
399/// # fn get(&self, _key: &str) -> FilterValue<'_> { FilterValue::Null }
400/// # }
401/// let filter = Filter::default();
402/// assert_eq!(filter.raw(), "true");
403/// assert!(filter.matches(&Anything).unwrap());
404/// ```
405pub struct Filter {
406 #[allow(clippy::box_collection)]
407 filter: Pin<Box<String>>,
408 ast: Expr<'static>,
409}
410
411impl Filter {
412 /// Parses the provided filter expression, returning a reusable `Filter`.
413 ///
414 /// The expression is tokenized and parsed eagerly, so any syntax errors
415 /// are reported here rather than at evaluation time. Errors include the
416 /// location of the problem and guidance on how to correct it.
417 ///
418 /// ```
419 /// use filt_rs::Filter;
420 ///
421 /// let filter = Filter::new("size > 100 && !archived").unwrap();
422 /// assert_eq!(filter.raw(), "size > 100 && !archived");
423 ///
424 /// let error = Filter::new("size >").unwrap_err();
425 /// assert!(error.to_string().contains("end of your filter expression"));
426 /// ```
427 pub fn new<S: Into<String>>(filter: S) -> Result<Self, Error> {
428 // The AST borrows string slices from the filter expression itself. Pinning
429 // the boxed string keeps those borrows valid for the lifetime of this
430 // struct without re-allocating the lexemes.
431 let filter = Box::new(filter.into());
432 let filter_ptr = NonNull::from(&filter);
433 let pinned = Box::into_pin(filter);
434
435 let tokens = lexer::Scanner::new(unsafe { filter_ptr.as_ref() });
436 let ast = parser::Parser::parse(tokens.into_iter())?;
437 Ok(Self {
438 filter: pinned,
439 ast,
440 })
441 }
442
443 /// Evaluates this filter against the provided object, returning whether it matched.
444 ///
445 /// The object's properties are resolved through its [`Filterable::get`]
446 /// implementation, and the filter matches when the expression evaluates to
447 /// a [truthy](FilterValue::is_truthy) value.
448 ///
449 /// ```
450 /// use filt_rs::{Filter, FilterValue, Filterable};
451 ///
452 /// struct Message(&'static str);
453 ///
454 /// impl Filterable for Message {
455 /// fn get(&self, key: &str) -> FilterValue<'_> {
456 /// match key {
457 /// "subject" => self.0.into(),
458 /// _ => FilterValue::Null,
459 /// }
460 /// }
461 /// }
462 ///
463 /// # fn main() -> Result<(), filt_rs::Error> {
464 /// let filter = Filter::new(r#"subject contains "invoice""#)?;
465 /// assert!(filter.matches(&Message("Invoice #123"))?);
466 /// assert!(!filter.matches(&Message("Weekly newsletter"))?);
467 /// # Ok(())
468 /// # }
469 /// ```
470 pub fn matches<T: Filterable>(&self, target: &T) -> Result<bool, Error> {
471 Ok(self.visit(&mut FilterContext::new(target)).is_truthy())
472 }
473
474 /// Walks this filter's parsed expression tree with a custom
475 /// [`ExprVisitor`], returning whatever the visitor produces.
476 ///
477 /// This is the public entry point for inspecting or transforming the
478 /// structure of a filter — for instance to collect the properties it
479 /// references, estimate its cost, or translate it into another query
480 /// language. The visitor is handed the root of the tree and is responsible
481 /// for recursing into child nodes (typically by calling
482 /// [`ExprVisitor::visit_expr`] on them).
483 ///
484 /// This method (along with the [`Expr`] and [`ExprVisitor`] types it
485 /// operates on, and the [`BinaryOperator`], [`LogicalOperator`], and
486 /// [`UnaryOperator`] enums) is only available when the **`visitor`** crate
487 /// feature is enabled.
488 ///
489 /// ```
490 /// use filt_rs::{
491 /// BinaryOperator, Expr, ExprVisitor, Filter, FilterValue, Glob,
492 /// LogicalOperator, UnaryOperator,
493 /// };
494 ///
495 /// /// Counts how many nodes a filter's expression tree contains.
496 /// struct NodeCounter;
497 ///
498 /// impl<'a> ExprVisitor<'a, usize> for NodeCounter {
499 /// fn visit_literal(&mut self, _value: &FilterValue) -> usize { 1 }
500 /// fn visit_property(&mut self, _name: &str) -> usize { 1 }
501 /// fn visit_function_call(&mut self, _name: &str, args: &[Expr]) -> usize {
502 /// 1 + args.iter().map(|arg| self.visit_expr(arg)).sum::<usize>()
503 /// }
504 /// fn visit_binary(&mut self, l: &'a Expr<'a>, _op: BinaryOperator, r: &'a Expr<'a>) -> usize {
505 /// 1 + self.visit_expr(l) + self.visit_expr(r)
506 /// }
507 /// fn visit_logical(&mut self, l: &'a Expr<'a>, _op: LogicalOperator, r: &'a Expr<'a>) -> usize {
508 /// 1 + self.visit_expr(l) + self.visit_expr(r)
509 /// }
510 /// fn visit_unary(&mut self, _op: UnaryOperator, r: &'a Expr<'a>) -> usize {
511 /// 1 + self.visit_expr(r)
512 /// }
513 /// fn visit_like(&mut self, l: &'a Expr<'a>, _glob: &Glob) -> usize {
514 /// 1 + self.visit_expr(l)
515 /// }
516 /// # #[cfg(feature = "regex")]
517 /// fn visit_matches(&mut self, l: &'a Expr<'a>, _re: &filt_rs::CompiledRegex) -> usize {
518 /// 1 + self.visit_expr(l)
519 /// }
520 /// }
521 ///
522 /// # fn main() -> Result<(), filt_rs::Error> {
523 /// let filter = Filter::new("repo.public && repo.stars >= 50")?;
524 /// // (&&) + property + (>=) + property + literal → 5 nodes.
525 /// assert_eq!(filter.visit(&mut NodeCounter), 5);
526 /// # Ok(())
527 /// # }
528 /// ```
529 #[cfg(feature = "visitor")]
530 pub fn visit<'this, V, T>(&'this self, visitor: &mut V) -> T
531 where
532 V: ExprVisitor<'this, T>,
533 {
534 // The AST borrows from the pinned filter string, so its `'static`
535 // lifetime is really tied to `self`. Handing the visitor `&'this`
536 // borrows narrows that back down to the lifetime of this call.
537 visitor.visit_expr(&self.ast)
538 }
539
540 /// Internal expression walker used by [`Filter::matches`]. This is the same
541 /// as the public [`Filter::visit`], but remains available (crate-private)
542 /// when the `visitor` feature is disabled so that evaluation still works.
543 #[cfg(not(feature = "visitor"))]
544 pub(crate) fn visit<'this, V, T>(&'this self, visitor: &mut V) -> T
545 where
546 V: ExprVisitor<'this, T>,
547 {
548 visitor.visit_expr(&self.ast)
549 }
550
551 /// Gets the raw filter expression which was used to construct this filter.
552 ///
553 /// ```
554 /// use filt_rs::Filter;
555 ///
556 /// let filter = Filter::new("name == \"demo\"").unwrap();
557 /// assert_eq!(filter.raw(), "name == \"demo\"");
558 /// ```
559 pub fn raw(&self) -> &str {
560 &self.filter
561 }
562}
563
564impl Default for Filter {
565 /// Returns the match-everything filter `true`.
566 fn default() -> Self {
567 Self {
568 filter: Box::pin("true".to_string()),
569 ast: Expr::Literal(FilterValue::Bool(true)),
570 }
571 }
572}
573
574impl Clone for Filter {
575 fn clone(&self) -> Self {
576 Self::new(self.raw()).expect("clone filter")
577 }
578}
579
580impl PartialEq for Filter {
581 fn eq(&self, other: &Self) -> bool {
582 self.ast == other.ast
583 }
584}
585
586impl std::fmt::Debug for Filter {
587 /// Formats the filter as its parsed expression tree, which can be useful
588 /// when debugging operator precedence issues.
589 ///
590 /// ```
591 /// use filt_rs::Filter;
592 ///
593 /// let filter = Filter::new("a || b && c").unwrap();
594 /// assert_eq!(format!("{filter:?}"), "(|| (property a) (&& (property b) (property c)))");
595 /// ```
596 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
597 write!(f, "{:?}", self.ast)
598 }
599}
600
601impl Display for Filter {
602 /// Formats the filter as its original raw expression.
603 ///
604 /// ```
605 /// use filt_rs::Filter;
606 ///
607 /// let filter = Filter::new("a || b").unwrap();
608 /// assert_eq!(filter.to_string(), "a || b");
609 /// ```
610 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
611 write!(f, "{}", self.raw())
612 }
613}
614
615#[cfg(feature = "serde")]
616impl serde::Serialize for Filter {
617 /// Serializes a `Filter` as its raw expression string.
618 ///
619 /// ```
620 /// use filt_rs::Filter;
621 ///
622 /// let filter = Filter::new("a || b").unwrap();
623 /// let json = serde_json::to_string(&filter).unwrap();
624 /// assert_eq!(json, r#""a || b""#);
625 /// ```
626 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
627 where
628 S: serde::Serializer,
629 {
630 serializer.serialize_str(self.raw())
631 }
632}
633
634#[cfg(feature = "serde")]
635impl<'de> serde::Deserialize<'de> for Filter {
636 /// Deserializes a `Filter` from a string containing a filter expression.
637 ///
638 /// Missing or `null` values are deserialized as the match-everything
639 /// filter `true`, making it easy to use optional filter fields within
640 /// your configuration structures.
641 ///
642 /// ```
643 /// use filt_rs::Filter;
644 ///
645 /// #[derive(serde::Deserialize)]
646 /// struct Config {
647 /// #[serde(default)]
648 /// filter: Filter,
649 /// }
650 ///
651 /// let config: Config = serde_json::from_str(r#"{"filter": "!repo.fork"}"#).unwrap();
652 /// assert_eq!(config.filter.raw(), "!repo.fork");
653 ///
654 /// let config: Config = serde_json::from_str("{}").unwrap();
655 /// assert_eq!(config.filter.raw(), "true");
656 /// ```
657 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
658 where
659 D: serde::Deserializer<'de>,
660 {
661 struct FilterVisitor;
662
663 impl<'de> serde::de::Visitor<'de> for FilterVisitor {
664 type Value = Filter;
665
666 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
667 formatter.write_str("a valid filter expression")
668 }
669
670 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
671 where
672 E: serde::de::Error,
673 {
674 Filter::new(v).map_err(serde::de::Error::custom)
675 }
676
677 fn visit_none<E>(self) -> Result<Self::Value, E>
678 where
679 E: serde::de::Error,
680 {
681 Filter::new("true").map_err(serde::de::Error::custom)
682 }
683
684 fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
685 where
686 D: serde::Deserializer<'de>,
687 {
688 deserializer.deserialize_str(self)
689 }
690 }
691
692 deserializer.deserialize_option(FilterVisitor)
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use rstest::rstest;
699
700 use super::*;
701
702 struct TestObject {
703 name: String,
704 age: i32,
705 alive: bool,
706 tags: Vec<&'static str>,
707 }
708
709 impl Default for TestObject {
710 fn default() -> Self {
711 Self {
712 name: "John Doe".to_string(),
713 age: 30,
714 alive: true,
715 tags: vec!["red", "black"],
716 }
717 }
718 }
719
720 impl Filterable for TestObject {
721 fn get(&self, property: &str) -> FilterValue<'_> {
722 match property {
723 "name" => self.name.clone().into(),
724 "age" => self.age.into(),
725 "alive" => self.alive.into(),
726 "tags" => self
727 .tags
728 .iter()
729 .cloned()
730 .map(|v| v.into())
731 .collect::<Vec<FilterValue<'_>>>()
732 .into(),
733 _ => FilterValue::Null,
734 }
735 }
736 }
737
738 #[rstest]
739 #[case("name == \"John Doe\"", true)]
740 #[case("name != \"John Doe\"", false)]
741 #[case("name == \"Jane Doe\"", false)]
742 #[case("name != \"Jane Doe\"", true)]
743 #[case("name startswith \"John\"", true)]
744 #[case("name startswith \"Jane\"", false)]
745 #[case("name endswith \"Doe\"", true)]
746 #[case("name endswith \"Smith\"", false)]
747 #[case("age == 30", true)]
748 #[case("age != 30", false)]
749 #[case("age == 31", false)]
750 #[case("age != 31", true)]
751 #[case("age > 31", false)]
752 #[case("age < 31", true)]
753 #[case("age >= 30", true)]
754 #[case("age <= 30", true)]
755 #[case("tags == [\"red\",\"black\"]", true)]
756 #[case("tags != [\"red\",\"black\"]", false)]
757 #[case("tags == [\"blue\"]", false)]
758 #[case("tags contains \"red\"", true)]
759 #[case("tags contains \"blue\"", false)]
760 #[case("\"red\" in tags", true)]
761 #[case("\"blue\" in tags", false)]
762 fn case_sensitive_filtering(#[case] filter: &str, #[case] matches: bool) {
763 let obj = TestObject::default();
764
765 assert_eq!(
766 Filter::new(filter)
767 .expect("parse filter")
768 .matches(&obj)
769 .expect("run filter"),
770 matches
771 );
772 }
773
774 #[rstest]
775 #[case("name == \"john doe\"", true)]
776 #[case("name != \"john doe\"", false)]
777 #[case("name == \"jane doe\"", false)]
778 #[case("name != \"jane doe\"", true)]
779 #[case("name startswith \"john\"", true)]
780 #[case("name startswith \"jane\"", false)]
781 #[case("name endswith \"doe\"", true)]
782 #[case("name endswith \"smith\"", false)]
783 #[case("\"RED\" in tags", true)]
784 #[case("\"BLUE\" in tags", false)]
785 fn case_insensitive_filtering(#[case] filter: &str, #[case] matches: bool) {
786 let obj = TestObject::default();
787
788 assert_eq!(
789 Filter::new(filter)
790 .expect("parse filter")
791 .matches(&obj)
792 .expect("run filter"),
793 matches
794 );
795 }
796
797 #[rstest]
798 #[case("name == \"John Doe\" && age == 30", true)]
799 #[case("name == \"John Doe\" && age == 31", false)]
800 #[case("name == \"Jane Doe\" && age == 30", false)]
801 #[case("name == \"John Doe\" || age == 30", true)]
802 #[case("name == \"John Doe\" || age == 31", true)]
803 #[case("name == \"Jane Doe\" || age == 30", true)]
804 #[case("name == \"Jane Doe\" || age == 31", false)]
805 fn binary_operator_filtering(#[case] filter: &str, #[case] matches: bool) {
806 let obj = TestObject::default();
807
808 assert_eq!(
809 Filter::new(filter)
810 .expect("parse filter")
811 .matches(&obj)
812 .expect("run filter"),
813 matches
814 );
815 }
816
817 #[rstest]
818 #[case("alive", true)]
819 #[case("!alive", false)]
820 #[case("name && age", true)]
821 #[case("name && !age", false)]
822 fn logical_operator_filtering(#[case] filter: &str, #[case] matches: bool) {
823 let obj = TestObject::default();
824
825 assert_eq!(
826 Filter::new(filter)
827 .expect("parse filter")
828 .matches(&obj)
829 .expect("run filter"),
830 matches
831 );
832 }
833
834 #[test]
835 fn default_filter_matches_everything() {
836 let filter = Filter::default();
837 assert_eq!(filter.raw(), "true");
838 assert!(filter.matches(&TestObject::default()).expect("run filter"));
839 }
840
841 #[test]
842 fn display_round_trips_the_raw_expression() {
843 let filter = Filter::new("age >= 30 && alive").expect("parse filter");
844 assert_eq!(filter.to_string(), "age >= 30 && alive");
845 assert_eq!(filter.raw(), "age >= 30 && alive");
846 }
847
848 #[test]
849 fn clone_preserves_the_raw_expression_and_behaviour() {
850 let filter = Filter::new("age >= 30 && alive").expect("parse filter");
851 let clone = filter.clone();
852
853 assert_eq!(clone.raw(), filter.raw());
854 assert_eq!(clone, filter);
855 assert_eq!(
856 clone.matches(&TestObject::default()).expect("run filter"),
857 filter.matches(&TestObject::default()).expect("run filter"),
858 );
859 }
860
861 #[test]
862 fn equal_filters_compare_equal() {
863 let lhs = Filter::new("age >= 30 && alive").expect("parse filter");
864 let rhs = Filter::new("age >= 30 && alive").expect("parse filter");
865 assert_eq!(lhs, rhs);
866 }
867
868 #[test]
869 fn different_filters_compare_unequal() {
870 let lhs = Filter::new("age >= 30").expect("parse filter");
871 let rhs = Filter::new("age >= 31").expect("parse filter");
872 assert_ne!(lhs, rhs);
873 }
874
875 #[rstest]
876 #[case("age >")]
877 #[case("(alive")]
878 #[case("name = \"John\"")]
879 #[case("\"unterminated")]
880 fn invalid_filters_report_errors(#[case] filter: &str) {
881 assert!(Filter::new(filter).is_err());
882 }
883
884 #[cfg(feature = "serde")]
885 mod serde_tests {
886 use super::*;
887
888 #[derive(serde::Serialize, serde::Deserialize)]
889 struct Config {
890 #[serde(default)]
891 filter: Filter,
892 }
893
894 #[test]
895 fn deserializes_a_filter_expression() {
896 let config: Config =
897 serde_json::from_str(r#"{"filter": "age > 21 && alive"}"#).expect("deserialize");
898 assert_eq!(config.filter.raw(), "age > 21 && alive");
899 assert!(
900 config
901 .filter
902 .matches(&TestObject::default())
903 .expect("run filter")
904 );
905 }
906
907 #[test]
908 fn missing_filters_match_everything() {
909 let config: Config = serde_json::from_str("{}").expect("deserialize");
910 assert_eq!(config.filter.raw(), "true");
911 }
912
913 #[test]
914 fn null_filters_match_everything() {
915 let config: Config = serde_json::from_str(r#"{"filter": null}"#).expect("deserialize");
916 assert_eq!(config.filter.raw(), "true");
917 }
918
919 #[test]
920 fn invalid_filters_fail_to_deserialize() {
921 let result: Result<Config, _> = serde_json::from_str(r#"{"filter": "age >"}"#);
922 assert!(result.is_err());
923 }
924
925 #[test]
926 fn serializes_a_filter_as_its_raw_expression() {
927 let filter = Filter::new("age > 21 && alive").expect("parse filter");
928 let json = serde_json::to_string(&filter).expect("serialize");
929 assert_eq!(json, r#""age > 21 && alive""#);
930 }
931
932 #[test]
933 fn serializes_a_filter_field() {
934 let config = Config {
935 filter: Filter::new("!repo.fork").expect("parse filter"),
936 };
937 let json = serde_json::to_string(&config).expect("serialize");
938 assert_eq!(json, r#"{"filter":"!repo.fork"}"#);
939 }
940
941 #[test]
942 fn round_trips_through_serde() {
943 let original: Config =
944 serde_json::from_str(r#"{"filter": "age > 21 && alive"}"#).expect("deserialize");
945 let json = serde_json::to_string(&original).expect("serialize");
946 let restored: Config = serde_json::from_str(&json).expect("deserialize");
947 assert_eq!(restored.filter.raw(), original.filter.raw());
948 }
949 }
950}