From b6dccc1147debc06ec0a125d976828d505094406 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:21:44 +0000 Subject: [PATCH 1/3] Migrate filter engine to the filt-rs crate --- Cargo.lock | 15 +- Cargo.toml | 1 + docs/advanced/filters.md | 55 ++++- docs/reference/gist.md | 2 + docs/reference/release.md | 16 +- docs/reference/repo.md | 11 +- src/entities/macros.rs | 6 +- src/entities/mod.rs | 24 +- src/filter/expr.rs | 113 ---------- src/filter/interpreter.rs | 273 ----------------------- src/filter/lexer.rs | 396 --------------------------------- src/filter/location.rs | 40 ---- src/filter/mod.rs | 237 -------------------- src/filter/parser.rs | 293 ------------------------ src/filter/token.rs | 112 ---------- src/filter/value.rs | 302 ------------------------- src/helpers/github/entities.rs | 13 +- src/main.rs | 3 +- 18 files changed, 129 insertions(+), 1783 deletions(-) delete mode 100644 src/filter/expr.rs delete mode 100644 src/filter/interpreter.rs delete mode 100644 src/filter/lexer.rs delete mode 100644 src/filter/location.rs delete mode 100644 src/filter/mod.rs delete mode 100644 src/filter/parser.rs delete mode 100644 src/filter/token.rs delete mode 100644 src/filter/value.rs diff --git a/Cargo.lock b/Cargo.lock index f6df82c..d7a59f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1026,6 +1026,18 @@ dependencies = [ "libc", ] +[[package]] +name = "filt-rs" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b2137f98f1cdb6e92f000d78ff8a094f12d66d0acda26c239d59a2c9675155" +dependencies = [ + "chrono", + "human-errors", + "regex", + "serde", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1220,6 +1232,7 @@ dependencies = [ "clap", "croner", "ctrlc", + "filt-rs", "gix", "http 1.4.2", "human-errors", @@ -1650,7 +1663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e414717..f26717e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ chrono = { version = "0.4.45", features = ["serde"] } clap = { version = "4.6.1", features = ["derive", "string"] } croner = "3.0.1" ctrlc = "3.5.2" +filt-rs = { version = "1.1.0", features = ["chrono", "regex", "serde"] } gix = { version = "0.84.0", features = [ "blocking-http-transport-reqwest-rust-tls", ] } diff --git a/docs/advanced/filters.md b/docs/advanced/filters.md index 2819b45..e80baf8 100644 --- a/docs/advanced/filters.md +++ b/docs/advanced/filters.md @@ -19,6 +19,9 @@ Here are a few common filter examples which you might use in your configuration. - `!release.prerelease && !asset.source-code` - Only include release artifacts which are not marked as pre-releases and are not source code archives. - `repo.name in ["git-tool", "grey"]` - Only include repositories with the names "git-tool" or "grey". - `repo.stargazers >= 5` - Only include repositories with at least 5 stars. +- `repo.name like "*-backup"` - Only include repositories whose name ends with "-backup" using glob pattern matching. +- `repo.name matches r"^awesome-\d+$"` - Only include repositories whose name matches the given regular expression. +- `repo.pushed_at > now() - 30d` - Only include repositories which have been pushed to within the last 30 days. ## Language Features ### Properties - `repo.` @@ -46,6 +49,12 @@ If you wish to treat an empty string as a valid value, you can use `repo. evaluation of an empty string. ::: +::: tip +You can also write *raw strings* using an `r` prefix (for example `r"^v\d+$"`), within which backslashes are treated literally +rather than as escape sequences. This is particularly convenient when writing [regular expression](#pattern-matching-like-matches) +patterns. Use the hashed form `r#"..."#` if your pattern needs to contain a double quote. +::: + #### Numbers Numbers are represented internally as a 64-bit floating-point value, which means that they can represent most reasonably sized integers as well as most reasonably precise decimal numbers. For example, `5` and `5.0` are equivalent in the filter language. @@ -61,6 +70,18 @@ example, `repo.fork` will evaluate to `true` if the repository is a fork, and `f The `null` value is used to represent the absence of a value, and is considered falsey when evaluated. Accessing a property which does not exist will return `null`. +#### Datetimes and Durations +Some fields, such as `repo.pushed_at` or `release.published_at`, expose native timestamps rather than strings. These can be compared +against one another, and against the current time using the [`now()`](#functions) function, allowing you to backup only those entities +which have changed recently. + +Durations are written as a number immediately followed by a unit (`ms`, `s`, `m` for minutes, `h`, `d`, or `w`), and several segments +can be chained together to form a more precise duration, for example `1h30m`. Datetimes and durations support `+` and `-` arithmetic, +so `now() - 7d` evaluates to the point in time seven days ago. + + - `repo.pushed_at > now() - 30d` - Only include repositories which have been pushed to within the last 30 days. + - `release.published_at < now() - 1w` - Only include releases which were published more than a week ago. + ## Operators ### Unary Negation - `!` The unary negation operator converts the following expression into the boolean opposite of its value. @@ -102,8 +123,9 @@ comparison. These operators **DO NOT** perform type coercion, which means that y type - for example, comparing `5 <= "5" || 5 >= "5"` will always return `false`. ::: warning -String comparisons are performed using a case-insensitive comparison of ASCII characters, which means that `"Hello" == "hello"` will return `true`, -as will `"hello👋" == "hello"`. +String comparisons are performed case-insensitively using the filter language's Unicode case-folding rules, which means that +`"Hello" == "hello"` will return `true`, as will `"STRASSE" == "straße"`. If you need an exact, case-sensitive comparison, use the +[`_cs` variants](#case-sensitivity-cs) of the string operators. ::: - `==` - Returns `true` if the left and right hand expressions are equal. @@ -136,6 +158,35 @@ The prefix and suffix matching operators are used to determine whether a string - `"hello" startswith "he"` - Determines whether the string `hello` starts with the sequence `he`, returning `true` in this case. - `"goodbye" endswith "bye"` - Determines whether the string `goodbye` ends with the sequence `bye`, returning `true` in this case. +### Pattern Matching - `like`, `matches` +The pattern matching operators allow you to match a string against a pattern, which can be useful when you want to match +repositories whose names follow a particular convention without listing each of them explicitly. + + - `like` performs a case-insensitive [glob](https://en.wikipedia.org/wiki/Glob_(programming)) match, where `*` matches any + sequence of characters (including none), `?` matches exactly one character, and a backslash makes the following character + literal (`\*`, `\?`, `\\`). For example, `repo.name like "feat/*"` matches any repository whose name begins with `feat/`. + - `matches` performs a [regular expression](https://docs.rs/regex/latest/regex/#syntax) match. Regular expressions are + case-sensitive (use `(?i)` to ignore case) and unanchored (use `^` and `$` to anchor the match). For example, + `repo.name matches r"^release/v\d+(\.\d+){2}$"` matches names like `release/v1.2.3`. + +::: tip +Regular expression patterns are easiest to write using [raw strings](#strings) (`r"..."`), which do not process backslash +escape sequences and so avoid the need to double-escape characters like `\d`. +::: + +### Case Sensitivity - `_cs` +The string operators (`contains`, `in`, `startswith`, `endswith`, and `like`) compare values case-insensitively by default. Each of +them has a case-sensitive variant with a `_cs` suffix (`contains_cs`, `in_cs`, `startswith_cs`, `endswith_cs`, and `like_cs`) which +compares strings exactly as written. The `matches` operator is always case-sensitive unless you opt in with the `(?i)` flag. + +## 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. + + - `now()` - Returns the current UTC time, evaluated afresh on every evaluation. This is most useful in combination with + [durations](#datetimes-and-durations), for example `repo.pushed_at > now() - 30d`. + - `trim(string)` - Returns the string argument with leading and trailing whitespace removed (`null` for non-string values). + ## Nerdy Details The filtering language itself is implemented as a simple recursive descent parser which compiles an expression tree from the input string. This expression tree is then evaluated using an interpreter to determine whether diff --git a/docs/reference/gist.md b/docs/reference/gist.md index 51ac9e6..d40da95 100644 --- a/docs/reference/gist.md +++ b/docs/reference/gist.md @@ -65,3 +65,5 @@ These fields are also available when using [`github/repo`](./repo.md) or [`githu | `gist.file_names` | `array` | List of file names in the gist | | `gist.languages` | `array` | List of programming languages used in the gist | | `gist.type` | `string` | MIME-Type of content in the gist | +| `gist.created_at` | `datetime`| When the gist was created | +| `gist.updated_at` | `datetime`| When the gist was last updated | diff --git a/docs/reference/release.md b/docs/reference/release.md index bb8d3b1..3650d85 100644 --- a/docs/reference/release.md +++ b/docs/reference/release.md @@ -70,9 +70,13 @@ For `kind: github/release` | `release.draft` | `boolean` | Whether the release is a draft (unpublished) release | | `release.prerelease` | `boolean` | Whether to identify the release as a prerelease or a full release | | `release.published` | `boolean` | Whether the release is a published (not a draft) release | +| `release.created_at` | `datetime` | When the release was created (_2013-02-27T19:35:32Z_) | +| `release.published_at`| `datetime` | When the release was published, or `null` for drafts (_2013-02-27T19:35:32Z_) | | `asset.name` | `string` | The file name of the asset (_github-backup-darwin-arm64_) | | `asset.size` | `integer` | The size of the asset, in kilobytes. (_1024_) | | `asset.downloaded` | `boolean` | If the asset was downloaded at least once from the GitHub Release | +| `asset.created_at` | `datetime` | When the asset was created (_2013-02-27T19:35:32Z_) | +| `asset.updated_at` | `datetime` | When the asset was last updated (_2013-02-27T19:35:32Z_) | ```json { @@ -117,7 +121,11 @@ For `kind: github/release` // Whether the release is a draft (inverse of published) "draft": false, /// Whether the release has been published yet or not (inverse of draft) - "published": true + "published": true, + // When the release was created + "created_at": "2013-02-27T19:35:32Z", + // When the release was published (null for draft releases) + "published_at": "2013-02-27T19:35:32Z" }, // Describes a specific artifact which is part of a release @@ -127,7 +135,11 @@ For `kind: github/release` // The size of the release asset in kilobytes "size": 1024, // Whether the asset has been downloaded at least once - "downloaded": true + "downloaded": true, + // When the asset was created + "created_at": "2013-02-27T19:35:32Z", + // When the asset was last updated + "updated_at": "2013-02-27T19:35:32Z" } } ``` \ No newline at end of file diff --git a/docs/reference/repo.md b/docs/reference/repo.md index 60e8a05..ec36876 100644 --- a/docs/reference/repo.md +++ b/docs/reference/repo.md @@ -73,6 +73,9 @@ These fields are also available when using [`github/release`](./release.md) or [ | `repo.template` | `boolean` | Whether this repository acts as a template that can be used to generate new repositories | | `repo.forks` | `integer` | The number of times this repository is forked | | `repo.stargazers` | `integer` | The number of people starred this repository | +| `repo.pushed_at` | `datetime` | When the repository was last pushed to (_2011-01-26T19:06:43Z_) | +| `repo.created_at` | `datetime` | When the repository was created (_2011-01-26T19:01:12Z_) | +| `repo.updated_at` | `datetime` | When the repository was last updated (_2011-01-26T19:14:43Z_) | ```json { @@ -102,7 +105,13 @@ These fields are also available when using [`github/release`](./release.md) or [ // The number of times this repository has been forked. "forks": 0, // The number of people who have starred this repository. - "stargazers": 501 + "stargazers": 501, + // When the repository was last pushed to. + "pushed_at": "2011-01-26T19:06:43Z", + // When the repository was created. + "created_at": "2011-01-26T19:01:12Z", + // When the repository was last updated. + "updated_at": "2011-01-26T19:14:43Z" } } ``` \ No newline at end of file diff --git a/src/entities/macros.rs b/src/entities/macros.rs index af60999..2af9534 100644 --- a/src/entities/macros.rs +++ b/src/entities/macros.rs @@ -29,7 +29,7 @@ macro_rules! entity { } )* - pub fn with_metadata>(mut self, key: &'static str, value: V) -> Self { + pub fn with_metadata<'a, V: Into>>(mut self, key: &'static str, value: V) -> Self { self.metadata.insert(key, value.into()); self } @@ -47,7 +47,7 @@ macro_rules! entity { } impl crate::Filterable for $name { - fn get(&self, key: &str) -> crate::FilterValue { + fn get(&self, key: &str) -> crate::FilterValue<'_> { self.metadata.get(key) } } @@ -79,7 +79,7 @@ mod tests { assert_eq!(entity.url, "http://example.com"); assert_eq!(entity.credentials, Credentials::Token("test".to_string())); - assert_eq!(entity.get("test"), FilterValue::String("test".to_string())); + assert_eq!(entity.get("test"), FilterValue::String("test".into())); assert_eq!(entity.get("test2"), FilterValue::Number(1_f64)); } } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 806ba7e..98f168e 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -7,6 +7,7 @@ use crate::{FilterValue, Filterable}; pub use credentials::Credentials; pub use release::Release; +use std::borrow::Cow; use std::collections::HashMap; use unicase::UniCase; @@ -18,14 +19,14 @@ pub trait BackupEntity: std::fmt::Display + Filterable { } #[derive(Default, Clone, Debug)] -pub struct Metadata(HashMap, FilterValue>); +pub struct Metadata(HashMap, FilterValue<'static>>); impl Metadata { - pub fn insert>(&mut self, key: &'static str, value: V) { - self.0.insert(UniCase::new(key), value.into()); + pub fn insert<'a, V: Into>>(&mut self, key: &'static str, value: V) { + self.0.insert(UniCase::new(key), into_owned(value.into())); } - pub fn get(&self, key: &str) -> FilterValue { + pub fn get(&self, key: &str) -> FilterValue<'_> { self.0 .get(&UniCase::new(key)) .cloned() @@ -33,6 +34,21 @@ impl Metadata { } } +/// Converts a [`FilterValue`] into one which owns all of its data so that it +/// can be cached within a [`Metadata`] collection (whose entries must outlive +/// the entity they were derived from). +fn into_owned(value: FilterValue<'_>) -> FilterValue<'static> { + match value { + FilterValue::Null => FilterValue::Null, + FilterValue::Bool(b) => FilterValue::Bool(b), + FilterValue::Number(n) => FilterValue::Number(n), + FilterValue::String(s) => FilterValue::String(Cow::Owned(s.into_owned())), + FilterValue::Tuple(v) => FilterValue::Tuple(v.into_iter().map(into_owned).collect()), + FilterValue::DateTime(dt) => FilterValue::DateTime(dt), + FilterValue::Duration(d) => FilterValue::Duration(d), + } +} + pub trait MetadataSource { fn inject_metadata(&self, metadata: &mut Metadata); } diff --git a/src/filter/expr.rs b/src/filter/expr.rs deleted file mode 100644 index e70e968..0000000 --- a/src/filter/expr.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::fmt::{Debug, Display}; - -use super::{FilterValue, token::Token}; - -// WARNING: We cannot have clone/copy semantics here because the [`Filter`] relies on -// pinning pointers to ensure that this struct can be safely used without additional -// allocations. -#[derive(PartialEq)] -pub enum Expr<'a> { - Literal(FilterValue), - Property(&'a str), - Binary(Box>, Token<'a>, Box>), - Logical(Box>, Token<'a>, Box>), - Unary(Token<'a>, Box>), -} - -pub trait ExprVisitor { - fn visit_expr(&mut self, expr: &Expr) -> T { - match expr { - Expr::Literal(value) => self.visit_literal(value), - Expr::Property(name) => self.visit_property(name), - Expr::Binary(left, operator, right) => self.visit_binary(left, operator, right), - Expr::Logical(left, operator, right) => self.visit_logical(left, operator, right), - Expr::Unary(operator, right) => self.visit_unary(operator, right), - } - } - - fn visit_literal(&mut self, value: &FilterValue) -> T; - fn visit_property(&mut self, name: &str) -> T; - fn visit_binary(&mut self, left: &Expr, operator: &Token, right: &Expr) -> T; - fn visit_logical(&mut self, left: &Expr, operator: &Token, right: &Expr) -> T; - fn visit_unary(&mut self, operator: &Token, right: &Expr) -> T; -} - -impl Display for Expr<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut printer = ExprPrinter(f); - printer.visit_expr(self)?; - Ok(()) - } -} - -impl Debug for Expr<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut printer = ExprPrinter(f); - printer.visit_expr(self)?; - Ok(()) - } -} - -struct ExprPrinter<'a, 'b>(&'a mut std::fmt::Formatter<'b>); -impl ExprVisitor for ExprPrinter<'_, '_> { - fn visit_literal(&mut self, value: &FilterValue) -> std::fmt::Result { - write!(self.0, "{}", value) - } - - fn visit_property(&mut self, name: &str) -> std::fmt::Result { - write!(self.0, "(property {})", name) - } - - fn visit_binary(&mut self, left: &Expr, operator: &Token, right: &Expr) -> std::fmt::Result { - write!(self.0, "({operator} ")?; - self.visit_expr(left)?; - write!(self.0, " ")?; - self.visit_expr(right)?; - write!(self.0, ")") - } - - fn visit_logical(&mut self, left: &Expr, operator: &Token, right: &Expr) -> std::fmt::Result { - write!(self.0, "({operator} ")?; - self.visit_expr(left)?; - write!(self.0, " ")?; - self.visit_expr(right)?; - write!(self.0, ")") - } - - fn visit_unary(&mut self, operator: &Token, right: &Expr) -> std::fmt::Result { - write!(self.0, "{}", operator.lexeme())?; - self.visit_expr(right) - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use crate::filter::location::Loc; - - use super::*; - - #[rstest] - #[case(Expr::Literal("value".into()), "\"value\"")] - #[case(Expr::Property("test"), "(property test)")] - #[case( - Expr::Binary( - Box::new(Expr::Literal("value".into())), - Token::In(Loc::new(1, 8)), - Box::new(Expr::Property("test")), - ), - "(in \"value\" (property test))" - )] - #[case( - Expr::Logical( - Box::new(Expr::Literal("value".into())), - Token::And(Loc::new(1, 8)), - Box::new(Expr::Property("test")), - ), - "(&& \"value\" (property test))" - )] - fn expression_visualization(#[case] expr: Expr<'_>, #[case] view: &str) { - assert_eq!(view, format!("{expr}")); - } -} diff --git a/src/filter/interpreter.rs b/src/filter/interpreter.rs deleted file mode 100644 index 0a47e67..0000000 --- a/src/filter/interpreter.rs +++ /dev/null @@ -1,273 +0,0 @@ -use super::{ - FilterValue, Filterable, - expr::{Expr, ExprVisitor}, - token::Token, -}; - -pub struct FilterContext<'a, T: Filterable> { - target: &'a T, -} - -impl<'a, T: Filterable> FilterContext<'a, T> { - pub fn new(target: &'a T) -> Self { - Self { target } - } -} - -impl ExprVisitor for FilterContext<'_, T> { - fn visit_literal(&mut self, value: &FilterValue) -> FilterValue { - value.clone() - } - - fn visit_property(&mut self, name: &str) -> FilterValue { - self.target.get(name).clone() - } - - fn visit_binary(&mut self, left: &Expr, operator: &Token, right: &Expr) -> FilterValue { - let left = self.visit_expr(left); - let right = self.visit_expr(right); - match operator { - Token::Equals(..) => (left == right).into(), - Token::NotEquals(..) => (left != right).into(), - Token::Contains(..) => left.contains(&right).into(), - Token::In(..) => right.contains(&left).into(), - Token::StartsWith(..) => left.startswith(&right).into(), - Token::EndsWith(..) => left.endswith(&right).into(), - Token::GreaterThan(..) => (left > right).into(), - Token::SmallerThan(..) => (left < right).into(), - Token::GreaterEqual(..) => (left >= right).into(), - Token::SmallerEqual(..) => (left <= right).into(), - token => unreachable!("Encountered an unexpected binary operator '{token}'"), - } - } - - fn visit_logical(&mut self, left: &Expr, operator: &Token, right: &Expr) -> FilterValue { - let left = self.visit_expr(left); - - match operator { - Token::And(..) if left.is_truthy() => self.visit_expr(right), - Token::And(..) => left, - Token::Or(..) if !left.is_truthy() => self.visit_expr(right), - Token::Or(..) => left, - token => unreachable!("Encountered an unexpected logical operator '{token}'"), - } - } - - fn visit_unary(&mut self, operator: &Token, right: &Expr) -> FilterValue { - let right = self.visit_expr(right); - - match operator { - Token::Not(..) => { - if right.is_truthy() { - false.into() - } else { - true.into() - } - } - token => unreachable!("Encountered an unexpected unary operator '{token}'"), - } - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use crate::filter::lexer::Scanner; - - use super::*; - - #[derive(Debug, PartialEq)] - struct TestFilterable; - - impl TestFilterable { - pub fn matches(filter: &str) -> bool { - use crate::filter::parser::Parser; - - let tokens = Scanner::new(filter); - let expr = Parser::parse(tokens).expect("parse the filter"); - let mut context = FilterContext::new(&Self); - let result = context.visit_expr(&expr); - result.is_truthy() - } - } - - impl Filterable for TestFilterable { - fn get(&self, property: &str) -> FilterValue { - match property { - "boolean" => true.into(), - "string" => "Alice".into(), - "number" => 1.into(), - "null" => FilterValue::Null, - "tuple" => vec![true.into(), false.into()].into(), - _ => FilterValue::Null, - } - } - } - - #[rstest] - #[case("true", true)] - #[case("false", false)] - #[case("null", false)] - #[case("1", true)] - #[case("0", false)] - #[case("\"\"", false)] - #[case("\"Alice\"", true)] - fn literals(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("boolean", true)] - #[case("string", true)] - #[case("number", true)] - #[case("tuple", true)] - #[case("null", false)] - #[case("unknown", false)] - fn properties(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("boolean == true", true)] - #[case("boolean == false", false)] - #[case("string == \"Alice\"", true)] - #[case("string == \"Bob\"", false)] - #[case("number == 1", true)] - #[case("number == 2", false)] - #[case("tuple == [true, false]", true)] - #[case("tuple == [false, true]", false)] - #[case("tuple == []", false)] - #[case("null == null", true)] - fn equals(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("2 > 1", true)] - #[case("1 > 2", false)] - #[case("2 >= 1", true)] - #[case("2 >= 2", true)] - fn greater_than(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("1 < 2", true)] - #[case("2 < 1", false)] - #[case("1 <= 2", true)] - #[case("1 <= 1", true)] - #[case("2 <= 1", false)] - fn smaller(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("boolean != true", false)] - #[case("boolean != false", true)] - #[case("string != \"Alice\"", false)] - #[case("string != \"Bob\"", true)] - #[case("number != 1", false)] - #[case("number != 2", true)] - #[case("tuple != [true, false]", false)] - #[case("tuple != [false, true]", true)] - #[case("tuple != []", true)] - #[case("null != null", false)] - fn not_equals(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("string contains \"Ali\"", true)] - #[case("string contains \"Bob\"", false)] - #[case("tuple contains true", true)] - #[case("tuple contains false", true)] - #[case("tuple contains null", false)] - #[case("null contains null", false)] - fn contains(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("string in \"Alice\"", true)] - #[case("\"Ali\" in string", true)] - #[case("string in \"Bob\"", false)] - #[case("\"Bob\" in string", false)] - #[case("true in tuple", true)] - #[case("false in tuple", true)] - #[case("null in tuple", false)] - #[case("number in 1", false)] - #[case("null in null", false)] - fn in_(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("string startswith \"Ali\"", true)] - #[case("string startswith \"Bob\"", false)] - #[case("string startswith null", false)] - #[case("null startswith null", false)] - fn startswith(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("string endswith \"ce\"", true)] - #[case("string endswith \"ob\"", false)] - #[case("string endswith null", false)] - #[case("null endswith null", false)] - fn endswith(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("!boolean", false)] - #[case("!string", false)] - #[case("!number", false)] - #[case("!tuple", false)] - #[case("!null", true)] - fn not(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("true && true", true)] - #[case("true && false", false)] - #[case("false && true", false)] - #[case("false && false", false)] - #[case("string && number", true)] - #[case("string && null", false)] - fn and(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("true || true", true)] - #[case("true || false", true)] - #[case("false || true", true)] - #[case("false || false", false)] - #[case("string || number", true)] - #[case("string || null", true)] - #[case("null || null", false)] - fn or(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("true && (false || true)", true)] - #[case("true && (false || false)", false)] - #[case("true && (string || null)", true)] - #[case("false && (string || null)", false)] - fn grouping(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } - - #[rstest] - #[case("true && false || true", true)] - #[case("true && false || false", false)] - #[case("false && true || false", false)] - #[case("false && false || true", true)] - fn precedence(#[case] filter: &str, #[case] expected: bool) { - assert_eq!(TestFilterable::matches(filter), expected); - } -} diff --git a/src/filter/lexer.rs b/src/filter/lexer.rs deleted file mode 100644 index fdb2097..0000000 --- a/src/filter/lexer.rs +++ /dev/null @@ -1,396 +0,0 @@ -use super::{location::Loc, token::Token}; - -pub struct Scanner<'a> { - source: &'a str, - chars: std::iter::Peekable>, - line: usize, - line_start: usize, -} - -impl<'a> Scanner<'a> { - pub fn new(source: &'a str) -> Self { - Self { - source, - chars: source.char_indices().peekable(), - line: 1, - line_start: 0, - } - } - - fn match_char(&mut self, next: char) -> bool { - if let Some((idx, c)) = self.chars.peek() { - if *c == '\n' { - self.line += 1; - self.line_start = *idx + 1; - } - - if *c == next { - self.chars.next(); - return true; - } - } - - false - } - - fn advance_while_fn bool>(&mut self, f: F) -> usize { - let mut length = 0; - while let Some((idx, c)) = self.chars.peek() { - if *c == '\n' { - self.line += 1; - self.line_start = *idx + 1; - } - - if !f(*idx, *c) { - break; - } - - self.chars.next(); - length += 1; - } - - length - } - - fn read_string(&mut self, start: usize) -> Result, human_errors::Error> { - let start_loc = Loc::new(self.line, 1 + start - self.line_start); - while let Some((idx, c)) = self.chars.next() { - match c { - '\n' => { - self.line += 1; - self.line_start = idx + 1; - } - '"' => { - return Ok(Token::String(start_loc, &self.source[start + 1..idx])); - } - '\\' if self.match_char('"') => {} - _ => {} - } - } - - Err(human_errors::user( - format!( - "Reached the end of the filter without finding the closing quote for a string starting at {}.", - start_loc - ), - &["Make sure that you have terminated your string with a '\"' character."], - )) - } - - fn read_number(&mut self, start: usize) -> Result, human_errors::Error> { - let mut end = start + self.advance_while_fn(|_, c| c.is_numeric()); - if let Some((loc, c)) = self.chars.peek() - && *c == '.' - && self - .source - .chars() - .nth(loc + 1) - .map(|c2| c2.is_numeric()) - .unwrap_or_default() - { - self.chars.next(); - end += 1 + self.advance_while_fn(|_, c| c.is_numeric()); - } - - Ok(Token::Number( - Loc::new(self.line, 1 + start - self.line_start), - &self.source[start..end + 1], - )) - } - - fn read_identifier(&mut self, start: usize) -> Result, human_errors::Error> { - let end = start - + self.advance_while_fn(|_, c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-'); - let lexeme = &self.source[start..end + 1]; - let location = Loc::new(self.line, 1 + start - self.line_start); - - match lexeme { - "false" => Ok(Token::False(location)), - "null" => Ok(Token::Null(location)), - "true" => Ok(Token::True(location)), - "contains" => Ok(Token::Contains(location)), - "in" => Ok(Token::In(location)), - "startswith" => Ok(Token::StartsWith(location)), - "endswith" => Ok(Token::EndsWith(location)), - lexeme => Ok(Token::Property(location, lexeme)), - } - } -} - -impl<'a> Iterator for Scanner<'a> { - type Item = Result, human_errors::Error>; - - fn next(&mut self) -> Option { - while let Some((idx, c)) = self.chars.next() { - match c { - ' ' | '\t' => {} - '\n' => { - self.line += 1; - self.line_start = idx + 1; - } - '(' => { - return Some(Ok(Token::LeftParen(Loc::new( - self.line, - 1 + idx - self.line_start, - )))); - } - ')' => { - return Some(Ok(Token::RightParen(Loc::new( - self.line, - 1 + idx - self.line_start, - )))); - } - '[' => { - return Some(Ok(Token::LeftBracket(Loc::new( - self.line, - 1 + idx - self.line_start, - )))); - } - ']' => { - return Some(Ok(Token::RightBracket(Loc::new( - self.line, - 1 + idx - self.line_start, - )))); - } - ',' => { - return Some(Ok(Token::Comma(Loc::new( - self.line, - 1 + idx - self.line_start, - )))); - } - '&' => { - return if self.match_char('&') { - Some(Ok(Token::And(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Err(human_errors::user( - format!( - "Filter included an orphaned '&' at {} which is not a valid operator.", - Loc::new(self.line, 1 + idx - self.line_start) - ), - &[ - "Ensure that you are using the '&&' operator to implement a logical AND within your filter.", - ], - ))) - }; - } - '|' => { - return if self.match_char('|') { - Some(Ok(Token::Or(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Err(human_errors::user( - format!( - "Filter included an orphaned '|' at {} which is not a valid operator.", - Loc::new(self.line, 1 + idx - self.line_start) - ), - &[ - "Ensure that you are using the '||' operator to implement a logical OR within your filter.", - ], - ))) - }; - } - '=' => { - return if self.match_char('=') { - Some(Ok(Token::Equals(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Err(human_errors::user( - format!( - "Filter included an orphaned '=' at {} which is not a valid operator.", - Loc::new(self.line, 1 + idx - self.line_start) - ), - &[ - "Ensure that you are using the '==' operator to implement a logical equality within your filter.", - ], - ))) - }; - } - '!' => { - return if self.match_char('=') { - Some(Ok(Token::NotEquals(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Ok(Token::Not(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - }; - } - '>' => { - return if self.match_char('=') { - Some(Ok(Token::GreaterEqual(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Ok(Token::GreaterThan(Loc::new( - self.line, - idx - self.line_start, - )))) - }; - } - '<' => { - return if self.match_char('=') { - Some(Ok(Token::SmallerEqual(Loc::new( - self.line, - 1 + idx - self.line_start, - )))) - } else { - Some(Ok(Token::SmallerThan(Loc::new( - self.line, - idx - self.line_start, - )))) - }; - } - '"' => { - return Some(self.read_string(idx)); - } - c if c.is_numeric() => { - return Some(self.read_number(idx)); - } - _ => { - return Some(self.read_identifier(idx)); - } - } - } - - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - macro_rules! assert_sequence { - ($filter:expr $(, $item:pat)* $(,)?) => { - let mut scanner = Scanner::new($filter); - $( - match scanner.next() { - Some(Ok($item)) => {}, - Some(Ok(item)) => panic!("Expected '{}' but got '{:?}'", stringify!($item), item), - Some(Err(e)) => panic!("Error: {}", e), - None => panic!("Expected '{}' but got the end of the parse sequence instead", stringify!($item)), - } - )* - - assert!(scanner.next().is_none(), "expected end of sequence, but got an item"); - }; - } - - #[test] - fn test_empty() { - assert_sequence!(""); - } - - #[test] - fn test_whitespace() { - assert_sequence!(" \t\n"); - } - - #[test] - fn test_parens() { - assert_sequence!( - "() []", - Token::LeftParen(..), - Token::RightParen(..), - Token::LeftBracket(..), - Token::RightBracket(..), - ); - } - - #[test] - fn test_logical_operators() { - assert_sequence!("&& ||", Token::And(..), Token::Or(..)); - } - - #[test] - fn test_comparison_operators() { - assert_sequence!( - "== != contains in startswith endswith > >= < <=", - Token::Equals(..), - Token::NotEquals(..), - Token::Contains(..), - Token::In(..), - Token::StartsWith(..), - Token::EndsWith(..), - Token::GreaterThan(..), - Token::GreaterEqual(..), - Token::SmallerThan(..), - Token::SmallerEqual(..), - ); - } - - #[test] - fn test_string() { - assert_sequence!("\"hello world\"", Token::String(.., "hello world")); - - assert_sequence!( - "\"hello \\\"world\\\"\"", - Token::String(.., "hello \\\"world\\\""), - ); - } - - #[test] - fn test_number() { - assert_sequence!("123.456", Token::Number(.., "123.456")); - } - - #[test] - fn test_identifiers() { - assert_sequence!( - "true false null foo.bar-baz", - Token::True(..), - Token::False(..), - Token::Null(..), - Token::Property(.., "foo.bar-baz"), - ); - } - - #[test] - fn test_mixed() { - assert_sequence!( - "foo == \"bar\" && baz != 123", - Token::Property(.., "foo"), - Token::Equals(..), - Token::String(.., "bar"), - Token::And(..), - Token::Property(.., "baz"), - Token::NotEquals(..), - Token::Number(.., "123"), - ); - } - - #[test] - fn test_negation() { - assert_sequence!( - "repo.public && !release.prerelease && !artifact.source-code", - Token::Property(.., "repo.public"), - Token::And(..), - Token::Not(..), - Token::Property(.., "release.prerelease"), - Token::And(..), - Token::Not(..), - Token::Property(.., "artifact.source-code"), - ); - } - - #[test] - fn test_location() { - assert_sequence!( - "true !=\nfalse", - Token::True(Loc { line: 1, column: 1 }), - Token::NotEquals(Loc { line: 1, column: 6 }), - Token::False(Loc { line: 2, column: 1 }) - ); - } -} diff --git a/src/filter/location.rs b/src/filter/location.rs deleted file mode 100644 index 22145a7..0000000 --- a/src/filter/location.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::fmt::Display; - -#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Copy, Clone, Hash, Default)] -pub struct Loc { - pub line: usize, - pub column: usize, -} - -impl Loc { - pub fn new(line: usize, column: usize) -> Self { - Self { line, column } - } -} - -impl Display for Loc { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Loc { line: 0, column: 0 } => { - write!(f, "unknown location") - } - Loc { line, column } => { - write!(f, "line {}, column {}", line, column) - } - } - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - #[rstest] - #[case(0, 0, "unknown location")] - #[case(1, 2, "line 1, column 2")] - fn test_display(#[case] line: usize, #[case] column: usize, #[case] expected: &str) { - assert_eq!(format!("{}", Loc::new(line, column)), expected); - } -} diff --git a/src/filter/mod.rs b/src/filter/mod.rs deleted file mode 100644 index a35bada..0000000 --- a/src/filter/mod.rs +++ /dev/null @@ -1,237 +0,0 @@ -mod expr; -mod interpreter; -mod lexer; -mod location; -mod parser; -mod token; -mod value; - -use std::{fmt::Display, pin::Pin, ptr::NonNull}; - -use expr::{Expr, ExprVisitor}; -use interpreter::FilterContext; -pub use value::*; - -pub struct Filter { - #[allow(clippy::box_collection)] - filter: Pin>, - ast: Expr<'static>, -} - -impl Filter { - pub fn new>(filter: S) -> Result { - let filter = Box::new(filter.into()); - let filter_ptr = NonNull::from(&filter); - let pinned = Box::into_pin(filter); - - let tokens = lexer::Scanner::new(unsafe { filter_ptr.as_ref() }); - let ast = parser::Parser::parse(tokens.into_iter())?; - Ok(Self { - filter: pinned, - ast, - }) - } - - pub fn matches(&self, target: &T) -> Result { - Ok(FilterContext::new(target).visit_expr(&self.ast).is_truthy()) - } - - /// Gets the raw filter expression which was used to construct this filter. - pub fn raw(&self) -> &str { - &self.filter - } -} - -impl Default for Filter { - fn default() -> Self { - Self { - filter: Box::pin("true".to_string()), - ast: Expr::Literal(FilterValue::Bool(true)), - } - } -} - -impl Display for Filter { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.raw()) - } -} - -impl<'de> serde::Deserialize<'de> for Filter { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct FilterVisitor; - - impl<'de> serde::de::Visitor<'de> for FilterVisitor { - type Value = Filter; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a valid filter expression") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - Filter::new(v).map_err(serde::de::Error::custom) - } - - fn visit_none(self) -> Result - where - E: serde::de::Error, - { - Filter::new("true").map_err(serde::de::Error::custom) - } - - fn visit_some(self, deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_str(self) - } - } - - deserializer.deserialize_option(FilterVisitor) - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - struct TestObject { - name: String, - age: i32, - alive: bool, - tags: Vec<&'static str>, - } - - impl Default for TestObject { - fn default() -> Self { - Self { - name: "John Doe".to_string(), - age: 30, - alive: true, - tags: vec!["red", "black"], - } - } - } - - impl Filterable for TestObject { - fn get(&self, property: &str) -> FilterValue { - match property { - "name" => self.name.clone().into(), - "age" => self.age.into(), - "alive" => self.alive.into(), - "tags" => self - .tags - .iter() - .cloned() - .map(|v| v.into()) - .collect::>() - .into(), - _ => FilterValue::Null, - } - } - } - - #[rstest] - #[case("name == \"John Doe\"", true)] - #[case("name != \"John Doe\"", false)] - #[case("name == \"Jane Doe\"", false)] - #[case("name != \"Jane Doe\"", true)] - #[case("name startswith \"John\"", true)] - #[case("name startswith \"Jane\"", false)] - #[case("name endswith \"Doe\"", true)] - #[case("name endswith \"Smith\"", false)] - #[case("age == 30", true)] - #[case("age != 30", false)] - #[case("age == 31", false)] - #[case("age != 31", true)] - #[case("age > 31", false)] - #[case("age < 31", true)] - #[case("age >= 30", true)] - #[case("age <= 30", true)] - #[case("tags == [\"red\",\"black\"]", true)] - #[case("tags != [\"red\",\"black\"]", false)] - #[case("tags == [\"blue\"]", false)] - #[case("tags contains \"red\"", true)] - #[case("tags contains \"blue\"", false)] - #[case("\"red\" in tags", true)] - #[case("\"blue\" in tags", false)] - fn case_sensitive_filtering(#[case] filter: &str, #[case] matches: bool) { - let obj = TestObject::default(); - - assert_eq!( - Filter::new(filter) - .expect("parse filter") - .matches(&obj) - .expect("run filter"), - matches - ); - } - - #[rstest] - #[case("name == \"john doe\"", true)] - #[case("name != \"john doe\"", false)] - #[case("name == \"jane doe\"", false)] - #[case("name != \"jane doe\"", true)] - #[case("name startswith \"john\"", true)] - #[case("name startswith \"jane\"", false)] - #[case("name endswith \"doe\"", true)] - #[case("name endswith \"smith\"", false)] - #[case("\"RED\" in tags", true)] - #[case("\"BLUE\" in tags", false)] - fn case_insensitive_filtering(#[case] filter: &str, #[case] matches: bool) { - let obj = TestObject::default(); - - assert_eq!( - Filter::new(filter) - .expect("parse filter") - .matches(&obj) - .expect("run filter"), - matches - ); - } - - #[rstest] - #[case("name == \"John Doe\" && age == 30", true)] - #[case("name == \"John Doe\" && age == 31", false)] - #[case("name == \"Jane Doe\" && age == 30", false)] - #[case("name == \"John Doe\" || age == 30", true)] - #[case("name == \"John Doe\" || age == 31", true)] - #[case("name == \"Jane Doe\" || age == 30", true)] - #[case("name == \"Jane Doe\" || age == 31", false)] - fn binary_operator_filtering(#[case] filter: &str, #[case] matches: bool) { - let obj = TestObject::default(); - - assert_eq!( - Filter::new(filter) - .expect("parse filter") - .matches(&obj) - .expect("run filter"), - matches - ); - } - - #[rstest] - #[case("alive", true)] - #[case("!alive", false)] - #[case("name && age", true)] - #[case("name && !age", false)] - fn logical_operator_filtering(#[case] filter: &str, #[case] matches: bool) { - let obj = TestObject::default(); - - assert_eq!( - Filter::new(filter) - .expect("parse filter") - .matches(&obj) - .expect("run filter"), - matches - ); - } -} diff --git a/src/filter/parser.rs b/src/filter/parser.rs deleted file mode 100644 index d54251b..0000000 --- a/src/filter/parser.rs +++ /dev/null @@ -1,293 +0,0 @@ -use std::iter::Peekable; - -use human_errors::{Error, ResultExt}; - -use super::{FilterValue, expr::Expr, token::Token}; - -pub struct Parser<'a, I: Iterator, Error>>> { - tokens: Peekable, -} - -impl<'a, I: Iterator, Error>>> Parser<'a, I> { - pub fn parse(tokens: I) -> Result, Error> { - let mut parser = Parser { - tokens: tokens.peekable(), - }; - - let expr = parser.or()?; - parser.ensure_end()?; - - Ok(expr) - } - - fn ensure_end(&mut self) -> Result<(), Error> { - if let Some(result) = self.tokens.next() { - let token = result?; - Err(human_errors::user( - format!( - "Your filter expression contained an unexpected '{}' at {}.", - token, - token.location(), - ), - &["Make sure that you have written a valid filter query."], - )) - } else { - Ok(()) - } - } - - fn or(&mut self) -> Result, Error> { - let mut expr = self.and()?; - - while matches!(self.tokens.peek(), Some(Ok(Token::Or(..)))) { - let token = self.tokens.next().unwrap()?; - let right = self.and()?; - expr = Expr::Logical(Box::new(expr), token, Box::new(right)); - } - - Ok(expr) - } - - fn and(&mut self) -> Result, Error> { - let mut expr = self.equality()?; - - while matches!(self.tokens.peek(), Some(Ok(Token::And(..)))) { - let token = self.tokens.next().unwrap()?; - let right = self.equality()?; - expr = Expr::Logical(Box::new(expr), token, Box::new(right)); - } - - Ok(expr) - } - - fn equality(&mut self) -> Result, Error> { - let mut expr = self.comparison()?; - - if matches!( - self.tokens.peek(), - Some(Ok(Token::Equals(..)) | Ok(Token::NotEquals(..))) - ) { - let token = self.tokens.next().unwrap()?; - let right = self.comparison()?; - expr = Expr::Binary(Box::new(expr), token, Box::new(right)); - } - - Ok(expr) - } - - fn comparison(&mut self) -> Result, Error> { - let mut expr = self.unary()?; - - if matches!( - self.tokens.peek(), - Some(Ok(Token::In(..))) - | Some(Ok(Token::Contains(..))) - | Some(Ok(Token::StartsWith(..))) - | Some(Ok(Token::EndsWith(..))) - | Some(Ok(Token::GreaterThan(..))) - | Some(Ok(Token::GreaterEqual(..))) - | Some(Ok(Token::SmallerThan(..))) - | Some(Ok(Token::SmallerEqual(..))) - ) { - let token = self.tokens.next().unwrap()?; - let right = self.unary()?; - expr = Expr::Binary(Box::new(expr), token, Box::new(right)); - } - - Ok(expr) - } - - fn unary(&mut self) -> Result, Error> { - if matches!(self.tokens.peek(), Some(Ok(Token::Not(..)))) { - let token = self.tokens.next().unwrap()?; - let right = self.unary()?; - Ok(Expr::Unary(token, Box::new(right))) - } else { - self.primary() - } - } - - fn primary(&mut self) -> Result, Error> { - match self.tokens.peek() { - Some(Ok(Token::LeftParen(..))) => { - let start = self.tokens.next().unwrap()?; - let expr = self.or()?; - if let Some(Ok(Token::RightParen(..))) = self.tokens.next() { - Ok(expr) - } else { - Err(human_errors::user( - format!( - "When attempting to parse a grouped filter expression starting at {}, we didn't find the closing ')' where we expected to.", - start.location() - ), - &["Make sure that you have balanced your parentheses correctly."], - )) - } - } - Some(Ok(Token::LeftBracket(..))) => { - let start = self.tokens.next().unwrap()?; - let mut items = Vec::new(); - while !matches!(self.tokens.peek(), Some(Ok(Token::RightBracket(..)))) { - items.push(self.literal()?); - if matches!(self.tokens.peek(), Some(Ok(Token::Comma(..)))) { - self.tokens.next(); - } else { - break; - } - } - - if let Some(Ok(Token::RightBracket(..))) = self.tokens.next() { - Ok(Expr::Literal(items.into())) - } else { - Err(human_errors::user( - format!( - "When attempting to parse a list filter expression starting at {}, we didn't find the closing ']' where we expected to.", - start.location() - ), - &["Make sure that you have closed your tuple brackets correctly."], - )) - } - } - Some(Ok(Token::Property(..))) => { - if let Some(Ok(Token::Property(.., p))) = self.tokens.next() { - Ok(Expr::Property(p)) - } else { - unreachable!() - } - } - Some(Ok(..)) => self.literal().map(Expr::Literal), - Some(Err(..)) => Err(self.tokens.next().unwrap().unwrap_err()), - None => Err(human_errors::user( - "We reached the end of your filter expression while waiting for a [true, false, \"string\", number, (group), or property.name].", - &[ - "Make sure that you have written a valid filter query and that you haven't forgotten part of it.", - ], - )), - } - } - - fn literal(&mut self) -> Result { - match self.tokens.next() { - Some(Ok(Token::True(..))) => Ok(true.into()), - Some(Ok(Token::False(..))) => Ok(false.into()), - Some(Ok(Token::Number(loc, n))) => Ok(FilterValue::Number(n.parse().wrap_user_err( - format!("Failed to parse the number '{n}' which you provided at {}.", loc), - &["Please make sure that the number is well formatted. It should be in the form 123, or 123.45."], - )?)), - Some(Ok(Token::String(.., s))) => Ok(s.replace("\\\"", "\"").replace("\\\\", "\\").into()), - Some(Ok(Token::Null(..))) => Ok(FilterValue::Null), - Some(Ok(token)) => Err(human_errors::user( - format!("While parsing your filter, we found an unexpected '{}' at {}.", token, token.location()), - &["Make sure that you have written a valid filter query."], - )), - Some(Err(err)) => Err(err), - None => Err(human_errors::user( - "We reached the end of your filter expression while waiting for a [true, false, \"string\", number, (group), or property.name].", - &["Make sure that you have written a valid filter query and that you haven't forgotten part of it."], - )), - } - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use crate::filter::{FilterValue, location::Loc}; - - use super::*; - - #[rstest] - #[case("true", true.into())] - #[case("false", false.into())] - #[case("\"hello\"", "hello".into())] - #[case("123", 123.0.into())] - #[case("null", FilterValue::Null)] - #[case("[]", FilterValue::Tuple(vec![]))] - #[case("[true]", FilterValue::Tuple(vec![true.into()]))] - #[case("[\ntrue,\n]", FilterValue::Tuple(vec![true.into()]))] - #[case("[true, false, \"test\", 123, null]", FilterValue::Tuple(vec![true.into(), false.into(), "test".into(), 123.into(), FilterValue::Null]))] - fn parsing_literals(#[case] input: &str, #[case] value: FilterValue) { - let tokens = crate::filter::lexer::Scanner::new(input); - match Parser::parse(tokens.into_iter()) { - Ok(Expr::Literal(ast)) => assert_eq!(value, ast, "Expected {ast} to be {value}"), - Ok(expr) => panic!("Expected a literal, got {:?}", expr), - Err(e) => panic!("Error: {}", e), - } - } - - #[rstest] - #[case("!true", Expr::Unary(Token::Not(Loc::new(1, 1)), Box::new(Expr::Literal(true.into()))))] - #[case("!false", Expr::Unary(Token::Not(Loc::new(1, 1)), Box::new(Expr::Literal(false.into()))))] - #[case("!\"hello\"", Expr::Unary(Token::Not(Loc::new(1, 1)), Box::new(Expr::Literal("hello".into()))))] - fn parsing_unary_expressions(#[case] input: &str, #[case] ast: Expr) { - let tokens = crate::filter::lexer::Scanner::new(input); - match Parser::parse(tokens.into_iter()) { - Ok(expr) => assert_eq!(ast, expr, "Expected {ast} to be {expr}"), - Err(e) => panic!("Error: {}", e), - } - } - - #[rstest] - #[case("true == false", Expr::Binary(Box::new(Expr::Literal(true.into())), Token::Equals(Loc::new(1, 6)), Box::new(Expr::Literal(false.into()))))] - #[case("true != false", Expr::Binary(Box::new(Expr::Literal(true.into())), Token::NotEquals(Loc::new(1, 6)), Box::new(Expr::Literal(false.into()))))] - #[case("\"xyz\" startswith \"x\"", Expr::Binary(Box::new(Expr::Literal("xyz".into())), Token::StartsWith(Loc::new(1, 7)), Box::new(Expr::Literal("x".into()))))] - #[case("\"xyz\" endswith \"z\"", Expr::Binary(Box::new(Expr::Literal("xyz".into())), Token::EndsWith(Loc::new(1, 7)), Box::new(Expr::Literal("z".into()))))] - #[case("1 < 2", Expr::Binary(Box::new(Expr::Literal(1.0.into())), Token::SmallerThan(Loc::new(1, 2)), Box::new(Expr::Literal(2.0.into()))))] - #[case("1 > 2", Expr::Binary(Box::new(Expr::Literal(1.0.into())), Token::GreaterThan(Loc::new(1, 2)), Box::new(Expr::Literal(2.0.into()))))] - #[case("1 <= 2", Expr::Binary(Box::new(Expr::Literal(1.0.into())), Token::SmallerEqual(Loc::new(1, 3)), Box::new(Expr::Literal(2.0.into()))))] - #[case("1 >= 2", Expr::Binary(Box::new(Expr::Literal(1.0.into())), Token::GreaterEqual(Loc::new(1, 3)), Box::new(Expr::Literal(2.0.into()))))] - fn parse_comparison_expressions(#[case] input: &str, #[case] ast: Expr) { - let tokens = crate::filter::lexer::Scanner::new(input); - match Parser::parse(tokens.into_iter()) { - Ok(expr) => assert_eq!(ast, expr, "Expected {ast} to be {expr}"), - Err(e) => panic!("Error: {}", e), - } - } - - #[rstest] - #[case("true && false", Expr::Logical(Box::new(Expr::Literal(true.into())), Token::And(Loc::new(1, 6)), Box::new(Expr::Literal(false.into()))))] - #[case("true || false", Expr::Logical(Box::new(Expr::Literal(true.into())), Token::Or(Loc::new(1, 6)), Box::new(Expr::Literal(false.into()))))] - #[case("true && (true || false)", Expr::Logical(Box::new(Expr::Literal(true.into())), Token::And(Loc::new(1, 6)), Box::new(Expr::Logical(Box::new(Expr::Literal(true.into())), Token::Or(Loc::new(1, 15)), Box::new(Expr::Literal(false.into()))))))] - fn parsing_logical_expressions(#[case] input: &str, #[case] ast: Expr) { - let tokens = crate::filter::lexer::Scanner::new(input); - match Parser::parse(tokens.into_iter()) { - Ok(expr) => assert_eq!(ast, expr, "Expected {ast} to be {expr}"), - Err(e) => panic!("Error: {}", e), - } - } - - #[rstest] - #[case( - "true false", - "Your filter expression contained an unexpected 'false' at line 1, column 6." - )] - #[case( - "true ==", - "We reached the end of your filter expression while waiting for a [true, false, \"string\", number, (group), or property.name]." - )] - #[case( - "(true", - "When attempting to parse a grouped filter expression starting at line 1, column 1, we didn't find the closing ')' where we expected to." - )] - #[case( - "[true, false", - "When attempting to parse a list filter expression starting at line 1, column 1, we didn't find the closing ']' where we expected to." - )] - #[case( - ")", - "While parsing your filter, we found an unexpected ')' at line 1, column 1." - )] - fn invalid_filters(#[case] input: &str, #[case] message: &str) { - let tokens = crate::filter::lexer::Scanner::new(input); - match Parser::parse(tokens.into_iter()) { - Ok(expr) => panic!("Expected an error, got {:?}", expr), - Err(e) => assert!( - e.to_string().contains(message), - "Expected error message to contain '{}', got '{}'", - message, - e - ), - } - } -} diff --git a/src/filter/token.rs b/src/filter/token.rs deleted file mode 100644 index 07ae2f2..0000000 --- a/src/filter/token.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::fmt::Display; - -use super::location::Loc; - -#[derive(Debug, PartialEq)] -pub enum Token<'a> { - LeftParen(Loc), - RightParen(Loc), - LeftBracket(Loc), - RightBracket(Loc), - Comma(Loc), - - Property(Loc, &'a str), - - Null(Loc), - True(Loc), - False(Loc), - String(Loc, &'a str), - Number(Loc, &'a str), - - Equals(Loc), - NotEquals(Loc), - Contains(Loc), - In(Loc), - StartsWith(Loc), - EndsWith(Loc), - GreaterThan(Loc), - SmallerThan(Loc), - GreaterEqual(Loc), - SmallerEqual(Loc), - - Not(Loc), - And(Loc), - Or(Loc), -} - -impl Token<'_> { - pub fn lexeme(&self) -> &str { - match self { - Token::LeftParen(..) => "(", - Token::RightParen(..) => ")", - Token::LeftBracket(..) => "[", - Token::RightBracket(..) => "]", - Token::Comma(..) => ",", - - Token::Property(.., s) => s, - - Token::Null(..) => "null", - Token::True(..) => "true", - Token::False(..) => "false", - Token::String(.., s) => s, - Token::Number(.., s) => s, - - Token::Equals(..) => "==", - Token::NotEquals(..) => "!=", - Token::Contains(..) => "contains", - Token::In(..) => "in", - Token::StartsWith(..) => "startswith", - Token::EndsWith(..) => "endswith", - Token::GreaterThan(..) => ">", - Token::GreaterEqual(..) => ">=", - Token::SmallerThan(..) => "<", - Token::SmallerEqual(..) => "<=", - - Token::Not(..) => "!", - Token::And(..) => "&&", - Token::Or(..) => "||", - } - } - - pub fn location(&self) -> Loc { - match self { - Token::LeftParen(loc) => *loc, - Token::RightParen(loc) => *loc, - Token::LeftBracket(loc) => *loc, - Token::RightBracket(loc) => *loc, - Token::Comma(loc) => *loc, - - Token::Property(loc, ..) => *loc, - - Token::Null(loc) => *loc, - Token::True(loc) => *loc, - Token::False(loc) => *loc, - Token::String(loc, ..) => *loc, - Token::Number(loc, ..) => *loc, - - Token::Equals(loc) => *loc, - Token::NotEquals(loc) => *loc, - Token::Contains(loc) => *loc, - Token::In(loc) => *loc, - Token::StartsWith(loc) => *loc, - Token::EndsWith(loc) => *loc, - Token::GreaterThan(loc) => *loc, - Token::SmallerThan(loc) => *loc, - Token::GreaterEqual(loc) => *loc, - Token::SmallerEqual(loc) => *loc, - - Token::Not(loc) => *loc, - Token::And(loc) => *loc, - Token::Or(loc) => *loc, - } - } -} - -impl Display for Token<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Token::String(s, ..) => write!(f, "\"{s}\""), - t => write!(f, "{}", t.lexeme()), - } - } -} diff --git a/src/filter/value.rs b/src/filter/value.rs deleted file mode 100644 index 00514ef..0000000 --- a/src/filter/value.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::cmp::Ordering; -use std::fmt::{Debug, Display}; - -/// A trait for types which can be filtered by the filter system. -/// -/// Types which implement this trait can be filtered through the use -/// of filter DSL expressions. A filter expression might look something -/// like the following: -/// -/// ``` -/// repo.public && !repo.fork && repo.name in ["git-tool", "grey"] -/// ``` -/// -/// In this case, the [`Filter`] would call [`Filterable::get`] with the -/// property keys it intends to retrieve, in thise case: `repo.public`, -/// `repo.fork`, and `repo.name`. The [`Filterable`] implementation would -/// then return the appropriate [`FilterValue`] for each key. -pub trait Filterable { - /// Retrieve the value of a property key. - /// - /// This method should return the value of the property key as it - /// pertains to the filterable object. If the key is not present, - /// the method should return a [`FilterValue::Null`] value. The - /// [`crate::filter::NULL`] constant is provided for this purpose. - fn get(&self, key: &str) -> FilterValue; -} - -/// A value describing the -#[derive(Clone, Default)] -pub enum FilterValue { - #[default] - Null, - Bool(bool), - Number(f64), - String(String), - Tuple(Vec), -} - -impl FilterValue { - pub fn is_truthy(&self) -> bool { - match self { - FilterValue::Null => false, - FilterValue::Bool(b) => *b, - FilterValue::Number(n) => *n != 0.0, - FilterValue::String(s) => !s.is_empty(), - FilterValue::Tuple(v) => !v.is_empty(), - } - } - - pub fn contains(&self, other: &FilterValue) -> bool { - match (self, other) { - (FilterValue::Tuple(a), b) => a.iter().any(|ai| ai == b), - (FilterValue::String(a), FilterValue::String(b)) => { - a.to_lowercase().contains(&b.to_lowercase()) - } - _ => false, - } - } - - pub fn startswith(&self, other: &FilterValue) -> bool { - match (self, other) { - (FilterValue::Tuple(a), b) => a.iter().any(|ai| ai == b), - (FilterValue::String(a), FilterValue::String(b)) => { - a.to_lowercase().starts_with(&b.to_lowercase()) - } - _ => false, - } - } - - pub fn endswith(&self, other: &FilterValue) -> bool { - match (self, other) { - (FilterValue::Tuple(a), b) => a.iter().any(|ai| ai == b), - (FilterValue::String(a), FilterValue::String(b)) => { - a.to_lowercase().ends_with(&b.to_lowercase()) - } - _ => false, - } - } -} - -impl PartialEq for FilterValue { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => true, - (FilterValue::Bool(a), FilterValue::Bool(b)) => a == b, - (FilterValue::Number(a), FilterValue::Number(b)) => a == b, - (FilterValue::String(a), FilterValue::String(b)) => a.eq_ignore_ascii_case(b), - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - a.len() == b.len() && a.iter().zip(b.iter()).all(|(a, b)| a == b) - } - _ => false, - } - } -} - -impl PartialOrd for FilterValue { - fn partial_cmp(&self, other: &Self) -> Option { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => Some(Ordering::Equal), - (FilterValue::Bool(a), FilterValue::Bool(b)) => a.partial_cmp(b), - (FilterValue::Number(a), FilterValue::Number(b)) => a.partial_cmp(b), - (FilterValue::String(a), FilterValue::String(b)) => a.partial_cmp(b), - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - if a.len() != b.len() { - a.len().partial_cmp(&b.len()) - } else { - a.iter() - .zip(b.iter()) - .map(|(x, y)| x.partial_cmp(y)) - .find(|&cmp| cmp != Some(Ordering::Equal)) - .unwrap_or(Some(Ordering::Equal)) - } - } - _ => None, // Return None for non-comparable types - } - } - - fn lt(&self, other: &Self) -> bool { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => true, - (FilterValue::Bool(a), FilterValue::Bool(b)) => a < b, - (FilterValue::Number(a), FilterValue::Number(b)) => a < b, - (FilterValue::String(a), FilterValue::String(b)) => a < b, - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - a.len() <= b.len() && a.iter().zip(b.iter()).all(|(a, b)| a < b) - } - _ => false, - } - } - - fn le(&self, other: &Self) -> bool { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => true, - (FilterValue::Bool(a), FilterValue::Bool(b)) => a <= b, - (FilterValue::Number(a), FilterValue::Number(b)) => a <= b, - (FilterValue::String(a), FilterValue::String(b)) => a <= b, - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - a.len() <= b.len() && a.iter().zip(b.iter()).all(|(a, b)| a <= b) - } - _ => false, - } - } - - fn gt(&self, other: &Self) -> bool { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => true, - (FilterValue::Bool(a), FilterValue::Bool(b)) => a > b, - (FilterValue::Number(a), FilterValue::Number(b)) => a > b, - (FilterValue::String(a), FilterValue::String(b)) => a > b, - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - a.len() >= b.len() && a.iter().zip(b.iter()).all(|(a, b)| a > b) - } - _ => false, - } - } - - fn ge(&self, other: &Self) -> bool { - match (self, other) { - (FilterValue::Null, FilterValue::Null) => true, - (FilterValue::Bool(a), FilterValue::Bool(b)) => a >= b, - (FilterValue::Number(a), FilterValue::Number(b)) => a >= b, - (FilterValue::String(a), FilterValue::String(b)) => a >= b, - (FilterValue::Tuple(a), FilterValue::Tuple(b)) => { - a.len() >= b.len() && a.iter().zip(b.iter()).all(|(a, b)| a >= b) - } - _ => false, - } - } -} - -impl Display for FilterValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FilterValue::Null => write!(f, "null"), - FilterValue::Bool(b) => write!(f, "{}", b), - FilterValue::Number(n) => write!(f, "{}", n), - FilterValue::String(s) => { - write!(f, "\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) - } - FilterValue::Tuple(v) => { - write!(f, "[")?; - for (i, value) in v.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}", value)?; - } - write!(f, "]") - } - } - } -} - -impl Debug for FilterValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self) - } -} - -impl From for FilterValue { - fn from(b: bool) -> Self { - FilterValue::Bool(b) - } -} - -macro_rules! number { - ($t:ty) => { - impl From<$t> for FilterValue { - fn from(n: $t) -> Self { - FilterValue::Number(n as f64) - } - } - }; -} - -number!(i8); -number!(u8); -number!(i16); -number!(u16); -number!(f32); -number!(i32); -number!(u32); -number!(f64); -number!(i64); -number!(u64); - -impl From<&str> for FilterValue { - fn from(s: &str) -> Self { - FilterValue::String(s.to_string()) - } -} - -impl From for FilterValue { - fn from(s: String) -> Self { - FilterValue::String(s) - } -} - -impl From> for FilterValue -where - T: Into, -{ - fn from(o: Option) -> Self { - o.map_or(FilterValue::Null, Into::into) - } -} - -impl From> for FilterValue { - fn from(v: Vec) -> Self { - FilterValue::Tuple(v) - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - #[rstest] - #[case(FilterValue::Null, false)] - #[case(FilterValue::Bool(false), false)] - #[case(FilterValue::Bool(true), true)] - #[case(FilterValue::Number(0.0), false)] - #[case(FilterValue::Number(1.0), true)] - #[case(FilterValue::String("".to_string()), false)] - #[case(FilterValue::String("hello".to_string()), true)] - #[case(FilterValue::Tuple(vec![]), false)] - #[case(FilterValue::Tuple(vec![FilterValue::Bool(true)]), true)] - fn test_truthy>(#[case] value: V, #[case] truthy: bool) { - assert_eq!(value.into().is_truthy(), truthy); - } - - #[test] - fn test_bool_comparison() { - assert!(FilterValue::Bool(false) < FilterValue::Bool(true)); - assert!(FilterValue::Bool(true) > FilterValue::Bool(false)); - assert_eq!(FilterValue::Bool(true), FilterValue::Bool(true)); - assert_eq!(FilterValue::Bool(false), FilterValue::Bool(false)); - } - - #[test] - fn test_number_comparison() { - assert!(FilterValue::Number(1.0) < FilterValue::Number(2.0)); - assert!(FilterValue::Number(2.0) > FilterValue::Number(1.0)); - assert_eq!(FilterValue::Number(2.0), FilterValue::Number(2.0)); - } - - #[test] - fn test_string_comparison() { - assert!( - FilterValue::String(String::from("abc")) < FilterValue::String(String::from("xyz")) - ); - assert!( - FilterValue::String(String::from("xyz")) > FilterValue::String(String::from("abc")) - ); - assert_eq!( - FilterValue::String(String::from("abc")), - FilterValue::String(String::from("abc")) - ); - } -} diff --git a/src/helpers/github/entities.rs b/src/helpers/github/entities.rs index 263277d..084f4db 100644 --- a/src/helpers/github/entities.rs +++ b/src/helpers/github/entities.rs @@ -110,6 +110,9 @@ impl MetadataSource for GitHubRepo { metadata.insert("repo.template", self.is_template); metadata.insert("repo.forks", self.forks_count as u32); metadata.insert("repo.stargazers", self.stargazers_count as u32); + metadata.insert("repo.pushed_at", self.pushed_at); + metadata.insert("repo.created_at", self.created_at); + metadata.insert("repo.updated_at", self.updated_at); } } @@ -203,6 +206,8 @@ impl MetadataSource for GitHubRelease { metadata.insert("release.draft", self.draft); metadata.insert("release.prerelease", self.prerelease); metadata.insert("release.published", self.published_at.is_some()); + metadata.insert("release.created_at", self.created_at); + metadata.insert("release.published_at", self.published_at); } } @@ -247,6 +252,8 @@ impl MetadataSource for GitHubReleaseAsset { metadata.insert("asset.name", self.name.as_str()); metadata.insert("asset.size", self.size); metadata.insert("asset.downloaded", self.download_count > 0); + metadata.insert("asset.created_at", self.created_at); + metadata.insert("asset.updated_at", self.updated_at); } } @@ -309,8 +316,8 @@ pub struct GitHubGist { pub forks: Option>, pub history: Option>, - pub created_at: String, - pub updated_at: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, } impl MetadataSource for GitHubGist { @@ -321,6 +328,8 @@ impl MetadataSource for GitHubGist { metadata.insert("gist.comments", self.comments); metadata.insert("gist.files", self.files.len() as u32); metadata.insert("gist.forks", self.forks.iter().count() as u32); + metadata.insert("gist.created_at", self.created_at); + metadata.insert("gist.updated_at", self.updated_at); metadata.insert( "gist.file_names", self.files diff --git a/src/main.rs b/src/main.rs index d6431b7..3505675 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,6 @@ mod config; mod engines; mod entities; mod errors; -mod filter; pub(crate) mod helpers; mod pairing; mod policy; @@ -25,7 +24,7 @@ mod telemetry; use crate::helpers::github::GitHubArtifactKind; use crate::pairing::SummaryStatistics; pub use entities::BackupEntity; -pub use filter::{Filter, FilterValue, Filterable}; +pub use filt_rs::{Filter, FilterValue, Filterable}; pub use policy::BackupPolicy; pub use sources::BackupSource; pub use target::BackupTarget; From 4b10b94eccf42e55532152766b7932878f425afa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:13:47 +0000 Subject: [PATCH 2/3] docs: use repo.name like "*-rs" as glob example --- docs/advanced/filters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/filters.md b/docs/advanced/filters.md index e80baf8..093b649 100644 --- a/docs/advanced/filters.md +++ b/docs/advanced/filters.md @@ -164,7 +164,7 @@ repositories whose names follow a particular convention without listing each of - `like` performs a case-insensitive [glob](https://en.wikipedia.org/wiki/Glob_(programming)) match, where `*` matches any sequence of characters (including none), `?` matches exactly one character, and a backslash makes the following character - literal (`\*`, `\?`, `\\`). For example, `repo.name like "feat/*"` matches any repository whose name begins with `feat/`. + literal (`\*`, `\?`, `\\`). For example, `repo.name like "*-rs"` matches any repository whose name ends with `-rs`. - `matches` performs a [regular expression](https://docs.rs/regex/latest/regex/#syntax) match. Regular expressions are case-sensitive (use `(?i)` to ignore case) and unanchored (use `^` and `$` to anchor the match). For example, `repo.name matches r"^release/v\d+(\.\d+){2}$"` matches names like `release/v1.2.3`. From 3e76e6f2a0006832459bd05ac55f6e703c1f406e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:32:07 +0000 Subject: [PATCH 3/3] docs: use release.tag semver as matches example --- docs/advanced/filters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/filters.md b/docs/advanced/filters.md index 093b649..6814876 100644 --- a/docs/advanced/filters.md +++ b/docs/advanced/filters.md @@ -167,7 +167,7 @@ repositories whose names follow a particular convention without listing each of literal (`\*`, `\?`, `\\`). For example, `repo.name like "*-rs"` matches any repository whose name ends with `-rs`. - `matches` performs a [regular expression](https://docs.rs/regex/latest/regex/#syntax) match. Regular expressions are case-sensitive (use `(?i)` to ignore case) and unanchored (use `^` and `$` to anchor the match). For example, - `repo.name matches r"^release/v\d+(\.\d+){2}$"` matches names like `release/v1.2.3`. + `release.tag matches r"^v\d+(\.\d+){2}$"` matches tags like `v1.2.3`. ::: tip Regular expression patterns are easiest to write using [raw strings](#strings) (`r"..."`), which do not process backslash