//! [`Filter`]s for querying the database and playlists. //! //! The filter syntax uses a context-free grammar, and has its own //! set of parsers and parsing errors. use std::fmt; use chrono::{DateTime, Utc}; use lalrpop_util::lalrpop_mod; use serde::{Deserialize, Serialize}; use crate::{ commands::RequestParserError, types::{Priority, Tag}, }; /// Represents the case sensitivity of a string comparison, /// used in multiple filter variants. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CaseSensitivity { CaseSensitive, CaseInsensitive, CommandDependent, } /// Represents a comparison operator for priority comparisons. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ComparisonOperator { Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, } /// Represents a filter expression for querying the music database. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Filter { /// Logical NOT of a filter. e.g. /// /// `(!(Artist == "The Beatles"))` Not(Box), /// Logical AND of multiple filters. e.g. /// /// `((Artist == "The Beatles") AND (Album == "Abbey Road") AND (Title == "Come Together"))` And(Vec), /// Equality comparison on a tag. e.g. /// /// `(Artist == "The Beatles")` or `(Album != "Greatest Hits")` /// /// The bool indicates whether the comparison is negated (true for !=, false for ==) EqTag(Tag, CaseSensitivity, bool), /// Substring containment on a tag. e.g. /// /// `(Title contains "Symphony")` or `(Album !contains "Live")` /// /// The bool indicates whether the comparison is negated (true for !contains, false for contains) Contains(Tag, CaseSensitivity, bool), /// Prefix matching on a tag. e.g. /// /// `(Title starts_with "Symphony")` or `(Album !starts_with "Live")` //// /// The bool indicates whether the comparison is negated (true for !starts_with, false for starts_with) StartsWith(Tag, CaseSensitivity, bool), /// Perl-compatible regular expression matching on a tag. e.g. /// /// `(Composer =~ "Beethoven.*")` or `(Genre !~ "Pop.*")` /// /// The bool indicates whether the comparison is negated (true for !~, false for =~) PerlRegex(Tag, bool), /// Equality comparison on the URI. e.g. /// /// `(uri == "Rock/Classics/track01.mp3")` EqUri(String), /// Base path filter. e.g. /// /// `(base "Rock/Classics")` Base(String), /// Filter for files added since the given timestamp. e.g. /// /// `(added-since '1622505600')` or `(added-since '2021-06-01T00:00:00Z')` AddedSince(DateTime), /// Filter for files modified since the given timestamp. e.g. /// /// `(modified-since '1622505600')` or `(modified-since '2021-06-01T00:00:00Z')` ModifiedSince(DateTime), /// Equality comparison on audio format. e.g. /// /// `(AudioFormat == '44100:16:2')` AudioFormatEq { sample_rate: u32, bits: u8, channels: u8, }, /// Masked equality comparison on audio format. e.g. /// /// `(AudioFormat =~ '44100:*:2')` AudioFormatEqMask { sample_rate: Option, bits: Option, channels: Option, }, /// Priority comparison. e.g. /// /// `(prio >= 42)` or `(prio != 10)` 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, "/src/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. // TODO: thiserror #[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, )), ); } }