From b91331452ea3acf7384ab022337e679418a9801e Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 24 Nov 2025 17:38:37 +0900 Subject: [PATCH] filter: implement basic parser --- Cargo.toml | 5 + build.rs | 8 + src/filter.rs | 485 +---------------------- src/filter/filter.rs | 625 ++++++++++++++++++++++++++++++ src/filter/filter_grammar.lalrpop | 149 +++++++ 5 files changed, 789 insertions(+), 483 deletions(-) create mode 100644 build.rs create mode 100644 src/filter/filter.rs create mode 100644 src/filter/filter_grammar.lalrpop diff --git a/Cargo.toml b/Cargo.toml index 3e30c96..7b74626 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,13 @@ edition = "2024" rust-version = "1.85.0" [dependencies] +chrono = { version = "0.4.42", features = ["serde"] } +lalrpop-util = { version = "0.22.2", features = ["lexer"] } serde = { version = "1.0.228", features = ["derive"] } [dev-dependencies] indoc = "2.0.7" pretty_assertions = "1.4.1" + +[build-dependencies] +lalrpop = "0.22.2" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ae9320d --- /dev/null +++ b/build.rs @@ -0,0 +1,8 @@ +fn main() { + lalrpop::process_root().unwrap(); + // let debug_mode = std::env::var("PROFILE").unwrap() == "debug"; + // lalrpop::Configuration::new() + // .emit_comments(debug_mode) + // .process() + // .unwrap(); +} diff --git a/src/filter.rs b/src/filter.rs index 16aba8a..04d909d 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,484 +1,3 @@ -use std::fmt; +mod filter; -use serde::{Deserialize, Serialize}; - -use crate::{ - commands::RequestParserError, - common::{Priority, Tag}, -}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum CaseSensitivity { - CaseSensitive, - CaseInsensitive, - CommandDependent, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ComparisonOperator { - Equal, - NotEqual, - GreaterThan, - GreaterThanOrEqual, - LessThan, - LessThanOrEqual, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Filter { - Not(Box), - And(Box, Box), - - // The bool indicates whether the comparison is negated (true for !=, false for ==) - EqTag(Tag, CaseSensitivity, bool), - Contains(Tag, CaseSensitivity, bool), - StartsWith(Tag, CaseSensitivity, bool), - PerlRegex(Tag), - NegPerlRegex(Tag), - EqUri(String), - Base(String), - ModifiedSince(u64), - AudioFormatEq { - sample_rate: u32, - bits: u8, - channels: u8, - }, - AudioFormatEqMask { - sample_rate: Option, - bits: Option, - channels: Option, - }, - PrioCmp(ComparisonOperator, Priority), -} - -impl fmt::Display for Filter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Filter::Not(inner) => write!(f, "(NOT ({}))", inner), - Filter::And(left, right) => write!(f, "({} AND {})", left, right), - Filter::EqTag(tag, case_sensitivity, negated) => { - let op = if *negated { "!=" } else { "==" }; - let cs = match case_sensitivity { - CaseSensitivity::CaseSensitive => "_cs", - CaseSensitivity::CaseInsensitive => "_ci", - CaseSensitivity::CommandDependent => "", - }; - write!(f, "({} {}{} '{}')", tag.key(), op, cs, tag.value()) - } - Filter::Contains(tag, case_sensitivity, negated) => { - let op = if *negated { "!contains" } else { "contains" }; - let cs = match case_sensitivity { - CaseSensitivity::CaseSensitive => "_cs", - CaseSensitivity::CaseInsensitive => "_ci", - CaseSensitivity::CommandDependent => "", - }; - write!(f, "({} {}{} '{}')", tag.key(), op, cs, tag.value()) - } - Filter::StartsWith(tag, case_sensitivity, negated) => { - let op = if *negated { - "!starts_with" - } else { - "starts_with" - }; - let cs = match case_sensitivity { - CaseSensitivity::CaseSensitive => "_cs", - CaseSensitivity::CaseInsensitive => "_ci", - CaseSensitivity::CommandDependent => "", - }; - write!(f, "({} {}{} '{}')", tag.key(), op, cs, tag.value()) - } - Filter::PerlRegex(tag) => { - write!(f, "({} =~ '{}')", tag.key(), tag.value()) - } - Filter::NegPerlRegex(tag) => { - write!(f, "({} !~ '{}')", tag.key(), tag.value()) - } - Filter::EqUri(uri) => { - write!(f, "(uri == '{}')", uri) - } - Filter::Base(path) => { - write!(f, "(base '{}')", path) - } - Filter::ModifiedSince(timestamp) => { - write!(f, "(modified-since '{}')", timestamp) - } - Filter::AudioFormatEq { - sample_rate, - bits, - channels, - } => { - write!( - f, - "(AudioFormat == '{}:{}:{}')", - sample_rate, bits, channels - ) - } - Filter::AudioFormatEqMask { - sample_rate, - bits, - channels, - } => { - let sr_str = match sample_rate { - Some(sr) => sr.to_string(), - None => "*".to_string(), - }; - let bits_str = match bits { - Some(b) => b.to_string(), - None => "*".to_string(), - }; - let ch_str = match channels { - Some(c) => c.to_string(), - None => "*".to_string(), - }; - write!(f, "(AudioFormat =~ '{}:{}:{}')", sr_str, bits_str, ch_str) - } - Filter::PrioCmp(op, prio) => { - let op_str = match op { - ComparisonOperator::Equal => "==", - ComparisonOperator::NotEqual => "!=", - ComparisonOperator::GreaterThan => ">", - ComparisonOperator::GreaterThanOrEqual => ">=", - ComparisonOperator::LessThan => "<", - ComparisonOperator::LessThanOrEqual => "<=", - }; - write!(f, "(prio {} {})", op_str, prio) - } - } - } -} - -impl Filter { - pub fn parse(input: &str) -> Result { - unimplemented!() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_filter_eq_tag() { - let filter = Filter::parse("(artist == 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - ); - - let filter = Filter::parse("(artist != 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CommandDependent, - true, - )), - ); - } - - #[test] - fn test_parse_filter_contains() { - let filter = Filter::parse("(album contains 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CommandDependent, - false, - )) - ); - - let filter = Filter::parse("(album !contains 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CommandDependent, - true, - )), - ); - } - - #[test] - fn test_parse_filter_starts_with() { - let filter = Filter::parse("(title starts_with 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - ); - - let filter = Filter::parse("(title !starts_with 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CommandDependent, - true, - )), - ); - } - - #[test] - fn test_parse_filter_perl_regex_positive() { - let filter = Filter::parse("(composer =~ 'Beethoven.*')"); - assert_eq!( - filter, - Ok(Filter::PerlRegex(Tag::Composer("Beethoven.*".to_string()))), - ); - } - - #[test] - fn test_parse_filter_perl_regex_negative() { - let filter = Filter::parse("(genre !~ 'Pop.*')"); - assert_eq!( - filter, - Ok(Filter::NegPerlRegex(Tag::Genre("Pop.*".to_string()))), - ); - } - - #[test] - fn test_parse_filter_base() { - let filter = Filter::parse("(base 'Rock/Classics')"); - assert_eq!(filter, Ok(Filter::Base("Rock/Classics".to_string()))); - } - - #[test] - fn test_parse_filter_modified_since() { - let filter = Filter::parse("(modified-since '1622505600')"); - assert_eq!(filter, Ok(Filter::ModifiedSince(1622505600))); - } - - #[test] - fn test_parse_filter_audio_added_since() { - let filter = Filter::parse("(added-since '1622505600')"); - assert_eq!(filter, Ok(Filter::ModifiedSince(1622505600))); - } - - #[test] - fn test_parse_filter_audio_format_eq() { - let filter = Filter::parse("(AudioFormat == '44100:16:2')"); - assert_eq!( - filter, - Ok(Filter::AudioFormatEq { - sample_rate: 44100, - bits: 16, - channels: 2, - }), - ); - } - - #[test] - fn test_parse_filter_audio_format_eq_mask() { - let filter = Filter::parse("(AudioFormat =~ '44100:*:2')"); - assert_eq!( - filter, - Ok(Filter::AudioFormatEqMask { - sample_rate: Some(44100), - bits: None, - channels: Some(2), - }), - ); - } - - #[test] - fn test_parse_filter_prio_cmp() { - let filter = Filter::parse("(prio >= 42)"); - assert_eq!( - filter, - Ok(Filter::PrioCmp(ComparisonOperator::GreaterThanOrEqual, 42)), - ); - } - - #[test] - fn test_parse_filter_not() { - let filter = Filter::parse("(!(artist == 'The Beatles'))"); - assert_eq!( - filter, - Ok(Filter::Not(Box::new(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CommandDependent, - false, - )))), - ); - } - - #[test] - fn test_parse_filter_and() { - let filter = Filter::parse("((artist == 'The Beatles') AND (album == 'Abbey Road'))"); - assert_eq!( - filter, - Ok(Filter::And( - Box::new(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - Box::new(Filter::EqTag( - Tag::Album("Abbey Road".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - )), - ); - } - - #[test] - fn test_parse_filter_explicitly_case_sensitive() { - let filter = Filter::parse("(artist eq_cs 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CaseSensitive, - false, - )), - ); - - let filter = Filter::parse("(artist !eq_cs 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CaseSensitive, - true, - )), - ); - - let filter = Filter::parse("(album contains_cs 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CaseSensitive, - false, - )), - ); - - let filter = Filter::parse("(album !contains_cs 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CaseSensitive, - true, - )), - ); - - let filter = Filter::parse("(title starts_with_cs 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CaseSensitive, - false, - )), - ); - - let filter = Filter::parse("(title !starts_with_cs 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CaseSensitive, - true, - )), - ); - } - - #[test] - fn test_parse_filter_explicitly_case_insensitive() { - let filter = Filter::parse("(artist eq_ci 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CaseInsensitive, - false, - )), - ); - - let filter = Filter::parse("(artist !eq_ci 'The Beatles')"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CaseInsensitive, - true, - )), - ); - - let filter = Filter::parse("(album contains_ci 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CaseInsensitive, - false, - )), - ); - - let filter = Filter::parse("(album !contains_ci 'Greatest Hits')"); - assert_eq!( - filter, - Ok(Filter::Contains( - Tag::Album("Greatest Hits".to_string()), - CaseSensitivity::CaseInsensitive, - true, - )), - ); - - let filter = Filter::parse("(title starts_with_ci 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CaseInsensitive, - false, - )), - ); - - let filter = Filter::parse("(title !starts_with_ci 'Symphony No. ')"); - assert_eq!( - filter, - Ok(Filter::StartsWith( - Tag::Title("Symphony No. ".to_string()), - CaseSensitivity::CaseInsensitive, - true, - )), - ); - } - - #[test] - fn test_parse_filter_string_escapes() { - let filter = Filter::parse("(Artist == \"foo\\'bar\\\"\")"); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("foo'bar\"".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - ); - } - - #[test] - fn test_parse_filter_excessive_whitespace() { - let filter = Filter::parse("(\tartist\n == 'The Beatles' ) "); - assert_eq!( - filter, - Ok(Filter::EqTag( - Tag::Artist("The Beatles".to_string()), - CaseSensitivity::CommandDependent, - false, - )), - ); - } -} +pub use filter::Filter; diff --git a/src/filter/filter.rs b/src/filter/filter.rs new file mode 100644 index 0000000..9598829 --- /dev/null +++ b/src/filter/filter.rs @@ -0,0 +1,625 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use lalrpop_util::lalrpop_mod; +use serde::{Deserialize, Serialize}; + +use crate::{ + commands::RequestParserError, + common::{Priority, Tag}, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CaseSensitivity { + CaseSensitive, + CaseInsensitive, + CommandDependent, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ComparisonOperator { + Equal, + NotEqual, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Filter { + Not(Box), + And(Vec), + + // The bool indicates whether the comparison is negated (true for !=, false for ==) + EqTag(Tag, CaseSensitivity, bool), + Contains(Tag, CaseSensitivity, bool), + StartsWith(Tag, CaseSensitivity, bool), + PerlRegex(Tag, bool), + EqUri(String), + Base(String), + AddedSince(DateTime), + ModifiedSince(DateTime), + AudioFormatEq { + sample_rate: u32, + bits: u8, + channels: u8, + }, + AudioFormatEqMask { + sample_rate: Option, + bits: Option, + channels: Option, + }, + PrioCmp(ComparisonOperator, Priority), +} + +fn quote_string(s: &str) -> String { + let mut result = String::new(); + result.push('"'); + for c in s.chars() { + match c { + '\n' => result.push_str(r"\n"), + '\t' => result.push_str(r"\t"), + '\r' => result.push_str(r"\r"), + '\\' => result.push_str(r"\\"), + '"' => result.push_str(r#"\""#), + other => result.push(other), + } + } + result.push('"'); + result +} + +impl fmt::Display for Filter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Filter::Not(inner) => write!(f, "(!{})", inner), + Filter::And(inner) => write!( + f, + "({})", + inner + .iter() + .map(|f| format!("{}", f)) + .collect::>() + .join(" AND "), + ), + Filter::EqTag(tag, case_sensitivity, negated) => { + let op = match (negated, case_sensitivity) { + (false, CaseSensitivity::CommandDependent) => "==", + (true, CaseSensitivity::CommandDependent) => "!=", + (false, CaseSensitivity::CaseSensitive) => "eq_cs", + (true, CaseSensitivity::CaseSensitive) => "!eq_cs", + (false, CaseSensitivity::CaseInsensitive) => "eq_ci", + (true, CaseSensitivity::CaseInsensitive) => "!eq_ci", + }; + write!(f, "({} {} {})", tag.key(), op, quote_string(tag.value())) + } + Filter::Contains(tag, case_sensitivity, negated) => { + let op = if *negated { "!contains" } else { "contains" }; + let cs = match case_sensitivity { + CaseSensitivity::CaseSensitive => "_cs", + CaseSensitivity::CaseInsensitive => "_ci", + CaseSensitivity::CommandDependent => "", + }; + write!( + f, + "({} {}{} {})", + tag.key(), + op, + cs, + quote_string(tag.value()) + ) + } + Filter::StartsWith(tag, case_sensitivity, negated) => { + let op = if *negated { + "!starts_with" + } else { + "starts_with" + }; + let cs = match case_sensitivity { + CaseSensitivity::CaseSensitive => "_cs", + CaseSensitivity::CaseInsensitive => "_ci", + CaseSensitivity::CommandDependent => "", + }; + write!( + f, + "({} {}{} {})", + tag.key(), + op, + cs, + quote_string(tag.value()) + ) + } + Filter::PerlRegex(tag, negated) => { + if *negated { + write!(f, "({} !~ {})", tag.key(), quote_string(tag.value())) + } else { + write!(f, "({} =~ {})", tag.key(), quote_string(tag.value())) + } + } + Filter::EqUri(uri) => { + write!(f, "(uri == {})", quote_string(uri)) + } + Filter::Base(path) => { + write!(f, "(base {})", quote_string(path)) + } + Filter::ModifiedSince(timestamp) => { + write!( + f, + "(modified-since {})", + quote_string(×tamp.timestamp().to_string()) + ) + } + Filter::AddedSince(timestamp) => { + write!( + f, + "(added-since {})", + quote_string(×tamp.timestamp().to_string()) + ) + } + Filter::AudioFormatEq { + sample_rate, + bits, + channels, + } => { + write!( + f, + "(AudioFormat == '{}:{}:{}')", + sample_rate, bits, channels + ) + } + Filter::AudioFormatEqMask { + sample_rate, + bits, + channels, + } => { + let sr_str = match sample_rate { + Some(sr) => sr.to_string(), + None => "*".to_string(), + }; + let bits_str = match bits { + Some(b) => b.to_string(), + None => "*".to_string(), + }; + let ch_str = match channels { + Some(c) => c.to_string(), + None => "*".to_string(), + }; + write!(f, "(AudioFormat =~ '{}:{}:{}')", sr_str, bits_str, ch_str) + } + Filter::PrioCmp(op, prio) => { + let op_str = match op { + ComparisonOperator::Equal => "==", + ComparisonOperator::NotEqual => "!=", + ComparisonOperator::GreaterThan => ">", + ComparisonOperator::GreaterThanOrEqual => ">=", + ComparisonOperator::LessThan => "<", + ComparisonOperator::LessThanOrEqual => "<=", + }; + write!(f, "(prio {} {})", op_str, prio) + } + } + } +} + +pub(super) fn unescape_string(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(escaped) = chars.next() { + match escaped { + 'n' => result.push('\n'), + 't' => result.push('\t'), + 'r' => result.push('\r'), + '\\' => result.push('\\'), + '"' => result.push('"'), + other => { + result.push('\\'); + result.push(other); + } + } + } else { + result.push('\\'); + } + } else { + result.push(c); + } + } + result +} + +lalrpop_mod!(filter_grammar, "/filter/filter_grammar.rs"); + +impl Filter { + pub fn parse(input: &str) -> Result { + let parser = filter_grammar::ExpressionParser::new(); + // println!("Parsing filter: {:#?}", parser.parse(input)); + parser + .parse(input) + .map_err(|e| RequestParserError::SyntaxError(0, format!("{}", e))) + } +} + +// TODO: There is a significant amount of error handling to be improved and tested here. + +#[derive(Debug, Clone, PartialEq)] +pub enum FilterParserError<'a> { + /// Could not parse the response due to a syntax error. + SyntaxError(usize, &'a str), + + /// Audio format string is invalid. + InvalidAudioFormat, + + /// Parentheses are unbalanced. + UnbalancedParentheses, + + /// String literal is not closed. + UnclosedStringLiteral, +} + +#[cfg(test)] +mod tests { + use chrono::DateTime; + + use super::*; + + #[test] + fn test_parse_filter_eq_tag() { + let filter = Filter::parse(r#"(artist == "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CommandDependent, + false, + )), + ); + + let filter = Filter::parse(r#"(artist != "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CommandDependent, + true, + )), + ); + } + + #[test] + fn test_parse_filter_contains() { + let filter = Filter::parse(r#"(album contains "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CommandDependent, + false, + )) + ); + + let filter = Filter::parse(r#"(album !contains "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CommandDependent, + true, + )), + ); + } + + #[test] + fn test_parse_filter_starts_with() { + let filter = Filter::parse(r#"(title starts_with "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CommandDependent, + false, + )), + ); + + let filter = Filter::parse(r#"(title !starts_with "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CommandDependent, + true, + )), + ); + } + + #[test] + fn test_parse_filter_perl_regex_positive() { + let filter = Filter::parse(r#"(composer =~ "Beethoven.*")"#); + assert_eq!( + filter, + Ok(Filter::PerlRegex( + Tag::Composer("Beethoven.*".to_string()), + false, + )), + ); + + let filter = Filter::parse(r#"(genre !~ "Pop.*")"#); + assert_eq!( + filter, + Ok(Filter::PerlRegex(Tag::Genre("Pop.*".to_string()), true)), + ); + } + + #[test] + fn test_parse_filter_base() { + let filter = Filter::parse(r#"(base "Rock/Classics")"#); + assert_eq!(filter, Ok(Filter::Base("Rock/Classics".to_string()))); + } + + #[test] + fn test_parse_filter_modified_since() { + let filter = Filter::parse(r#"(modified-since '1622505600')"#); + assert_eq!( + filter, + Ok(Filter::ModifiedSince( + DateTime::from_timestamp(1622505600, 0).unwrap() + )) + ); + + let date_time = DateTime::from_timestamp(1622505600, 0).unwrap(); + let iso8601_str = date_time.to_rfc3339(); + let filter = Filter::parse(format!(r#"(modified-since "{}")"#, iso8601_str).as_str()); + assert_eq!(filter, Ok(Filter::ModifiedSince(date_time))); + } + + #[test] + fn test_parse_filter_audio_added_since() { + let filter = Filter::parse(r#"(added-since '1622505600')"#); + assert_eq!( + filter, + Ok(Filter::AddedSince( + DateTime::from_timestamp(1622505600, 0).unwrap() + )) + ); + + let date_time = DateTime::from_timestamp(1622505600, 0).unwrap(); + let iso8601_str = date_time.to_rfc3339(); + let filter = Filter::parse(format!(r#"(added-since "{}")"#, iso8601_str).as_str()); + assert_eq!(filter, Ok(Filter::AddedSince(date_time))); + } + + #[test] + fn test_parse_filter_audio_format_eq() { + let filter = Filter::parse(r#"(AudioFormat == 44100:16:2)"#); + assert_eq!( + filter, + Ok(Filter::AudioFormatEq { + sample_rate: 44100, + bits: 16, + channels: 2, + }), + ); + } + + #[test] + fn test_parse_filter_audio_format_eq_mask() { + let filter = Filter::parse(r#"(AudioFormat =~ 44100:*:2)"#); + assert_eq!( + filter, + Ok(Filter::AudioFormatEqMask { + sample_rate: Some(44100), + bits: None, + channels: Some(2), + }), + ); + } + + #[test] + fn test_parse_filter_prio_cmp() { + for (op_str, op_enum) in &[ + (">", ComparisonOperator::GreaterThan), + (">=", ComparisonOperator::GreaterThanOrEqual), + ("<", ComparisonOperator::LessThan), + ("<=", ComparisonOperator::LessThanOrEqual), + ("==", ComparisonOperator::Equal), + ("!=", ComparisonOperator::NotEqual), + ] { + let filter_str = format!(r#"(prio {} 42)"#, op_str); + let filter = Filter::parse(&filter_str); + assert_eq!(filter, Ok(Filter::PrioCmp(op_enum.clone(), 42)),); + } + } + + #[test] + fn test_parse_filter_not() { + let filter = Filter::parse(r#"(!(artist == "The Beatles"))"#); + assert_eq!( + filter, + Ok(Filter::Not(Box::new(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CommandDependent, + false, + )))), + ); + } + + #[test] + fn test_parse_filter_and() { + let filter = Filter::parse( + r#"((artist == "The Beatles") AND (album == "Abbey Road") AND (title == "Come Together"))"#, + ); + assert_eq!( + filter, + Ok(Filter::And(vec![ + Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CommandDependent, + false, + ), + Filter::EqTag( + Tag::Album("Abbey Road".to_string()), + CaseSensitivity::CommandDependent, + false, + ), + Filter::EqTag( + Tag::Title("Come Together".to_string()), + CaseSensitivity::CommandDependent, + false, + ), + ])), + ); + } + + #[test] + fn test_parse_filter_explicitly_case_sensitive() { + let filter = Filter::parse(r#"(artist eq_cs "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CaseSensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(artist !eq_cs "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CaseSensitive, + true, + )), + ); + + let filter = Filter::parse(r#"(album contains_cs "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CaseSensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(album !contains_cs "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CaseSensitive, + true, + )), + ); + + let filter = Filter::parse(r#"(title starts_with_cs "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CaseSensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(title !starts_with_cs "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CaseSensitive, + true, + )), + ); + } + + #[test] + fn test_parse_filter_explicitly_case_insensitive() { + let filter = Filter::parse(r#"(artist eq_ci "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CaseInsensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(artist !eq_ci "The Beatles")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CaseInsensitive, + true, + )), + ); + + let filter = Filter::parse(r#"(album contains_ci "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CaseInsensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(album !contains_ci "Greatest Hits")"#); + assert_eq!( + filter, + Ok(Filter::Contains( + Tag::Album("Greatest Hits".to_string()), + CaseSensitivity::CaseInsensitive, + true, + )), + ); + + let filter = Filter::parse(r#"(title starts_with_ci "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CaseInsensitive, + false, + )), + ); + + let filter = Filter::parse(r#"(title !starts_with_ci "Symphony No. ")"#); + assert_eq!( + filter, + Ok(Filter::StartsWith( + Tag::Title("Symphony No. ".to_string()), + CaseSensitivity::CaseInsensitive, + true, + )), + ); + } + + #[test] + fn test_parse_filter_string_escapes() { + let filter = Filter::parse(r#"(Artist == "\"foo\\'bar\\\"")"#); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist(r#""foo\'bar\""#.to_string()), + CaseSensitivity::CommandDependent, + false, + )), + ); + } + + #[test] + fn test_parse_filter_excessive_whitespace() { + let filter = Filter::parse("(\tartist\n == \"The Beatles\" ) "); + assert_eq!( + filter, + Ok(Filter::EqTag( + Tag::Artist("The Beatles".to_string()), + CaseSensitivity::CommandDependent, + false, + )), + ); + } +} diff --git a/src/filter/filter_grammar.lalrpop b/src/filter/filter_grammar.lalrpop new file mode 100644 index 0000000..48616da --- /dev/null +++ b/src/filter/filter_grammar.lalrpop @@ -0,0 +1,149 @@ +use std::str::FromStr; +use chrono::{DateTime, Utc}; +use crate::filter::filter::{CaseSensitivity, ComparisonOperator, Filter, unescape_string}; +use crate::common::Tag; + +grammar; + +// match { +// r#"[ \t\n\r]+"# => {}, +// r"[0-9]+" +// } else { +// r#"\w+"#, +// _ +// } + +i64: i64 = => i64::from_str(s).unwrap(); +u32: u32 = => u32::from_str(s).unwrap(); +u8: u8 = => u8::from_str(s).unwrap(); + +pub Expression: Filter = { + "(" "base" ")" => Filter::Base(p), + "(" "file" "==" ")" => Filter::EqUri(p), + "(" "prio" ")" => Filter::PrioCmp(op, n), + + "(" "modified-since" ")" => Filter::ModifiedSince(d), + "(" "added-since" ")" => Filter::AddedSince(d), + "(" "AudioFormat" "==" ")" => { + let (sr, b, c) = af; + match (sr, b, c) { + (Some(sr), Some(b), Some(c)) => Filter::AudioFormatEq { + sample_rate: sr, + bits: b, + channels: c, + }, + _ => panic!("AudioFormat equality requires all fields to be specified"), + } + }, + "(" "AudioFormat" "=~" ")" => { + let (sr, b, c) = af; + Filter::AudioFormatEqMask { + sample_rate: sr, + bits: b, + channels: c, + } + }, + + "(" "==" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CommandDependent, false), + "(" "eq_cs" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CaseSensitive, false), + "(" "eq_ci" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CaseInsensitive, false), + "(" "!=" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CommandDependent, true), + "(" "!eq_cs" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CaseSensitive, true), + "(" "!eq_ci" ")" => Filter::EqTag(Tag::new(t, v), CaseSensitivity::CaseInsensitive, true), + + "(" "contains" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CommandDependent, false), + "(" "contains_cs" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CaseSensitive, false), + "(" "contains_ci" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CaseInsensitive, false), + "(" "!contains" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CommandDependent, true), + "(" "!contains_cs" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CaseSensitive, true), + "(" "!contains_ci" ")" => Filter::Contains(Tag::new(t, v), CaseSensitivity::CaseInsensitive, true), + + "(" "starts_with" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CommandDependent, false), + "(" "starts_with_cs" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CaseSensitive, false), + "(" "starts_with_ci" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CaseInsensitive, false), + "(" "!starts_with" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CommandDependent, true), + "(" "!starts_with_cs" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CaseSensitive, true), + "(" "!starts_with_ci" ")" => Filter::StartsWith(Tag::new(t, v), CaseSensitivity::CaseInsensitive, true), + + "(" "=~" ")" => Filter::PerlRegex(Tag::new(t, p), false), + "(" "!~" ")" => Filter::PerlRegex(Tag::new(t, p), true), + "(" "!" ")" => Filter::Not(Box::new(e)), + "(" ")" => e, +}; + +AndExpression: Filter = { + => e, + "AND" => { + let mut filters = match e1 { + Filter::And(fs) => fs, + _ => vec![e1], + }; + filters.push(e2); + Filter::And(filters) + } +} + +DateTime_: DateTime = { + => { + DateTime::::from_timestamp(n, 0).unwrap() + }, + => { + // Try ISO 8601 without timezone first + if let Some(dt) = DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc)) { + return dt; + } else if let Some(n) = i64::from_str(&s).ok() && let Some(d) = DateTime::::from_timestamp(n, 0) { + return d; + } + panic!("Invalid date time format: {}", s); + } +}; + +PrioCmp : ComparisonOperator = { + ">" => ComparisonOperator::GreaterThan, + ">=" => ComparisonOperator::GreaterThanOrEqual, + "<" => ComparisonOperator::LessThan, + "<=" => ComparisonOperator::LessThanOrEqual, + "==" => ComparisonOperator::Equal, + "!=" => ComparisonOperator::NotEqual, +}; + +Uri: String = { + => e, +}; + +TagName: String = => { + unescape_string(s) +}; + +TagValue: String = { + => e, + // => { + // s.to_string() + // }, +}; + +Pattern: String = { + => e, +}; + +pub EscapedString: String = { + => { + let unquoted = &l[1..l.len()-1]; + unescape_string(unquoted) + }, + => { + let unquoted = &l[1..l.len()-1]; + unescape_string(unquoted) + }, +}; + +AudioFormat: (Option, Option, Option) = { + ":" ":" => (Some(s), Some(b), Some(c)), + ":" ":" "*" => (Some(s), Some(b), None), + ":" "*" ":" => (Some(s), None, Some(c)), + ":" "*" ":" "*" => (Some(s), None, None), + "*" ":" ":" => (None, Some(b), Some(c)), + "*" ":" ":" "*" => (None, Some(b), None), + "*" ":" "*" ":" => (None, None, Some(c)), + "*" ":" "*" ":" "*" => (None, None, None), +};