From 86edd4c5b346ec3dcf92f67861763bc9c31cc024 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 8 Dec 2025 13:32:18 +0900 Subject: [PATCH] filter: flatten module --- src/filter.rs | 626 +++++++++++++++++++++++- src/filter/filter.rs | 625 ----------------------- src/{filter => }/filter_grammar.lalrpop | 2 +- 3 files changed, 625 insertions(+), 628 deletions(-) delete mode 100644 src/filter/filter.rs rename src/{filter => }/filter_grammar.lalrpop (98%) diff --git a/src/filter.rs b/src/filter.rs index 68cee329..9d47a2fd 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,3 +1,625 @@ -mod filter; +use std::fmt; -pub use filter::*; +use chrono::{DateTime, Utc}; +use lalrpop_util::lalrpop_mod; +use serde::{Deserialize, Serialize}; + +use crate::{ + commands::RequestParserError, + types::{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_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.rs b/src/filter/filter.rs deleted file mode 100644 index e94860b6..00000000 --- a/src/filter/filter.rs +++ /dev/null @@ -1,625 +0,0 @@ -use std::fmt; - -use chrono::{DateTime, Utc}; -use lalrpop_util::lalrpop_mod; -use serde::{Deserialize, Serialize}; - -use crate::{ - commands::RequestParserError, - types::{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_grammar.lalrpop similarity index 98% rename from src/filter/filter_grammar.lalrpop rename to src/filter_grammar.lalrpop index 31e32851..1478afd3 100644 --- a/src/filter/filter_grammar.lalrpop +++ b/src/filter_grammar.lalrpop @@ -1,6 +1,6 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; -use crate::filter::filter::{CaseSensitivity, ComparisonOperator, Filter, unescape_string}; +use crate::filter::{CaseSensitivity, ComparisonOperator, Filter, unescape_string}; use crate::types::Tag; grammar;