diff --git a/Cargo.lock b/Cargo.lock index e119bd5..758b57f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,7 @@ name = "aro" version = "0.1.0" dependencies = [ "actix-web", + "itertools", "nom", "serde", "serde_json", @@ -306,6 +307,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + [[package]] name = "encoding_rs" version = "0.8.31" @@ -458,6 +465,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7244300..4100165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } nom = "7.1.3" actix-web = { version = "4.3.0", default_features = false, features = ["macros"] } +itertools = "0.10.5" [dev-dependencies] test-case = "2.2.2" diff --git a/src/filter.rs b/src/filter.rs index 1c97a96..f274884 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -1,6 +1,6 @@ use crate::{ data::Card, - parser::{Field, Operator, Value, OPERATOR_CHARS}, + parser::{Field, Operator, RawCardFilter, Value, OPERATOR_CHARS}, }; /// A struct derived from `Card` that has all fields lowercased for easier search @@ -39,35 +39,36 @@ impl From<&Card> for SearchCard { } pub type CardFilter = Box bool>; -pub type RawCardFilter = (Field, Operator, Value); pub fn fallback_filter(query: &str) -> Result { if query.contains(OPERATOR_CHARS) { return Err(format!("Invalid query: {query}")); } let q = query.to_lowercase(); - Ok((Field::Name, Operator::Equal, Value::String(q))) + Ok(RawCardFilter(Field::Name, Operator::Equal, Value::String(q))) } pub fn build_filter(query: RawCardFilter) -> Result { Ok(match query { - (Field::Atk, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.atk, n)), - (Field::Def, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.def, n)), + RawCardFilter(Field::Atk, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.atk, n)), + RawCardFilter(Field::Def, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.def, n)), // ? ATK/DEF is modeled as None in the source json. At least for some monsters. // Let’s at least find those. - (Field::Atk, _, Value::String(s)) if s == "?" => Box::new(move |card| card.atk.is_none() && card.card_type.contains("monster")), - (Field::Def, _, Value::String(s)) if s == "?" => { + RawCardFilter(Field::Atk, _, Value::String(s)) if s == "?" => { + Box::new(move |card| card.atk.is_none() && card.card_type.contains("monster")) + } + RawCardFilter(Field::Def, _, Value::String(s)) if s == "?" => { Box::new(move |card| card.def.is_none() && card.link_rating.is_none() && card.card_type.contains("monster")) } - (Field::Level, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.level, n)), - (Field::Type, Operator::Equal, Value::String(s)) => Box::new(move |card| card.r#type == s), - (Field::Type, Operator::NotEqual, Value::String(s)) => Box::new(move |card| card.r#type != s), - (Field::Attribute, Operator::Equal, Value::String(s)) => Box::new(move |card| card.attribute.contains(&s)), - (Field::Attribute, Operator::NotEqual, Value::String(s)) => Box::new(move |card| !card.attribute.contains(&s)), - (Field::Class, Operator::Equal, Value::String(s)) => Box::new(move |card| card.card_type.contains(&s)), - (Field::Class, Operator::NotEqual, Value::String(s)) => Box::new(move |card| !card.card_type.contains(&s)), - (Field::Text, Operator::Equal, Value::String(s)) => Box::new(move |card| card.text.contains(&s)), - (Field::Name, Operator::Equal, Value::String(s)) => Box::new(move |card| card.name.contains(&s)), + RawCardFilter(Field::Level, op, Value::Numerical(n)) => Box::new(move |card| op.filter_number(card.level, n)), + RawCardFilter(Field::Type, Operator::Equal, Value::String(s)) => Box::new(move |card| card.r#type == s), + RawCardFilter(Field::Type, Operator::NotEqual, Value::String(s)) => Box::new(move |card| card.r#type != s), + RawCardFilter(Field::Attribute, Operator::Equal, Value::String(s)) => Box::new(move |card| card.attribute.contains(&s)), + RawCardFilter(Field::Attribute, Operator::NotEqual, Value::String(s)) => Box::new(move |card| !card.attribute.contains(&s)), + RawCardFilter(Field::Class, Operator::Equal, Value::String(s)) => Box::new(move |card| card.card_type.contains(&s)), + RawCardFilter(Field::Class, Operator::NotEqual, Value::String(s)) => Box::new(move |card| !card.card_type.contains(&s)), + RawCardFilter(Field::Text, Operator::Equal, Value::String(s)) => Box::new(move |card| card.text.contains(&s)), + RawCardFilter(Field::Name, Operator::Equal, Value::String(s)) => Box::new(move |card| card.name.contains(&s)), q => Err(format!("unknown query: {q:?}"))?, }) } @@ -81,8 +82,8 @@ mod tests { fn level_filter_test() { let lacooda = SearchCard::from(&serde_json::from_str::(RAW_MONSTER).unwrap()); let filter_level_3 = parse_filters("l=3").unwrap(); - assert!(filter_level_3[0](&lacooda)); + assert!(filter_level_3[0].1(&lacooda)); let filter_level_5 = parse_filters("l=5").unwrap(); - assert!(!filter_level_5[0](&lacooda)); + assert!(!filter_level_5[0].1(&lacooda)); } } diff --git a/src/main.rs b/src/main.rs index c5b129b..ede04ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use actix_web::{get, http::header, web, App, Either, HttpResponse, HttpServer}; use data::{Card, CardInfo}; use filter::SearchCard; +use itertools::Itertools; use serde::Deserialize; use std::{collections::HashMap, fmt::Write, fs::File, io::BufReader, net::Ipv4Addr, sync::LazyLock, time::Instant}; @@ -46,11 +47,33 @@ async fn search(q: Option, web::Form>>) -> Resul write!( res, r#" - + + + + +
-
-
- + +
"#, match &q { Some(q) => q, @@ -62,14 +85,20 @@ async fn search(q: Option, web::Form>>) -> Resul let now = Instant::now(); let matches: Vec<&Card> = SEARCH_CARDS .iter() - .filter(|card| query.iter().all(|q| q(card))) + .filter(|card| query.iter().all(|(_, q)| q(card))) .map(|c| CARDS_BY_ID.get(&c.id).unwrap()) .take(RESULT_LIMIT) .collect(); - write!(res, "Showing {} results (took {:?})

", matches.len(), now.elapsed())?; + write!( + res, + "Showing {} results where {} (took {:?})


", + matches.len(), + query.iter().map(|(f, _)| f.to_string()).join(" and "), + now.elapsed() + )?; for card in matches { res.push_str(&card.to_string()); - res.push_str("

"); + res.push_str("


"); } write!(res, "")?; } else { diff --git a/src/parser.rs b/src/parser.rs index bb7135c..2558b0c 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,21 +1,24 @@ -use std::str::FromStr; +use std::{ + fmt::{self, Display}, + str::FromStr, +}; -use crate::filter::{build_filter, fallback_filter, CardFilter, RawCardFilter}; +use crate::filter::{build_filter, fallback_filter, CardFilter}; use nom::{ branch::alt, bytes::complete::{take_until1, take_while, take_while_m_n}, character::complete::{char, multispace0}, - combinator::{complete, map_res, rest, verify}, + combinator::{complete, map, map_res, rest, verify}, multi::many_m_n, sequence::{delimited, preceded, tuple}, IResult, }; -pub fn parse_filters(input: &str) -> Result, String> { +pub fn parse_filters(input: &str) -> Result, String> { parse_raw_filters(input).map_err(|e| format!("Error while parsing filters “{input}”: {e:?}")).and_then(|(rest, mut v)| { if rest.is_empty() { - v.sort_unstable_by_key(|(f, _, _)| *f as u8); - v.into_iter().map(build_filter).collect() + v.sort_unstable_by_key(|RawCardFilter(f, _, _)| *f as u8); + v.into_iter().map(|r| build_filter(r.clone()).map(|f| (r, f))).collect() } else { Err(format!("Input was not fully parsed. Left over: “{rest}”")) } @@ -31,7 +34,10 @@ fn word_non_empty(input: &str) -> IResult<&str, &str> { } fn parse_raw_filter(input: &str) -> IResult<&str, RawCardFilter> { - preceded(multispace0, alt((complete(tuple((field, operator, value))), map_res(word_non_empty, fallback_filter))))(input) + preceded( + multispace0, + alt((map(complete(tuple((field, operator, value))), |(f, o, v)| RawCardFilter(f, o, v)), map_res(word_non_empty, fallback_filter))), + )(input) } fn field(input: &str) -> IResult<&str, Field> { @@ -56,14 +62,29 @@ fn value(input: &str) -> IResult<&str, Value> { /// This is used to sort filters before applying them. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Field { - Text = 0, - Name = 1, - Class = 2, - Attribute = 3, + Atk = 1, + Def = 2, + Level = 3, Type = 4, - Level = 5, - Atk = 6, - Def = 7, + Attribute = 5, + Class = 6, + Name = 7, + Text = 8, +} + +impl Display for Field { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Text => "text", + Self::Name => "name", + Self::Class => "card type", + Self::Attribute => "attribute", + Self::Type => "type", + Self::Level => "level/rank", + Self::Atk => "ATK", + Self::Def => "DEF", + }) + } } impl FromStr for Field { @@ -124,25 +145,56 @@ impl FromStr for Operator { } } +impl Display for Operator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Equal => "is", + Self::NotEqual => "is not", + Self::Less => "<", + Self::LessEqual => "<=", + Self::Greater => ">", + Self::GreaterEqual => ">=", + }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct RawCardFilter(pub Field, pub Operator, pub Value); + +impl Display for RawCardFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {} {}", self.0, self.1, self.2) + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub enum Value { String(String), Numerical(i32), } +impl Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::String(s) => f.write_str(s), + Self::Numerical(n) => write!(f, "{}", n), + } + } +} + #[cfg(test)] mod tests { use super::*; use test_case::test_case; - #[test_case("t=pyro" => Ok(("", (Field::Type, Operator::Equal, Value::String("pyro".into())))))] - #[test_case("t:PYro" => Ok(("", (Field::Type, Operator::Equal, Value::String("pyro".into())))); "input is lowercased")] - #[test_case("t==warrior" => Ok(("", (Field::Type, Operator::Equal, Value::String("warrior".into())))))] - #[test_case("atk>=100" => Ok(("", (Field::Atk, Operator::GreaterEqual, Value::Numerical(100)))))] - #[test_case("Necrovalley" => Ok(("", (Field::Name, Operator::Equal, Value::String("necrovalley".into())))))] - #[test_case("l=10" => Ok(("", (Field::Level, Operator::Equal, Value::Numerical(10)))))] - #[test_case("Ib" => Ok(("", (Field::Name, Operator::Equal, Value::String("ib".to_owned())))))] - #[test_case("c!=synchro" => Ok(("", (Field::Class, Operator::NotEqual, Value::String("synchro".to_owned())))))] + #[test_case("t=pyro" => Ok(("", RawCardFilter(Field::Type, Operator::Equal, Value::String("pyro".into())))))] + #[test_case("t:PYro" => Ok(("", RawCardFilter(Field::Type, Operator::Equal, Value::String("pyro".into())))); "input is lowercased")] + #[test_case("t==warrior" => Ok(("", RawCardFilter(Field::Type, Operator::Equal, Value::String("warrior".into())))))] + #[test_case("atk>=100" => Ok(("", RawCardFilter(Field::Atk, Operator::GreaterEqual, Value::Numerical(100)))))] + #[test_case("Necrovalley" => Ok(("", RawCardFilter(Field::Name, Operator::Equal, Value::String("necrovalley".into())))))] + #[test_case("l=10" => Ok(("", RawCardFilter(Field::Level, Operator::Equal, Value::Numerical(10)))))] + #[test_case("Ib" => Ok(("", RawCardFilter(Field::Name, Operator::Equal, Value::String("ib".to_owned())))))] + #[test_case("c!=synchro" => Ok(("", RawCardFilter(Field::Class, Operator::NotEqual, Value::String("synchro".to_owned())))))] fn successful_parsing_test(input: &str) -> IResult<&str, RawCardFilter> { parse_raw_filter(input) } @@ -159,14 +211,17 @@ mod tests { #[test] fn sequential_parsing_test() { let (rest, filter) = parse_raw_filter("atk>=100 l:4").unwrap(); - assert_eq!(filter, (Field::Atk, Operator::GreaterEqual, Value::Numerical(100))); - assert_eq!(parse_raw_filter(rest), Ok(("", (Field::Level, Operator::Equal, Value::Numerical(4))))); + assert_eq!(filter, RawCardFilter(Field::Atk, Operator::GreaterEqual, Value::Numerical(100))); + assert_eq!(parse_raw_filter(rest), Ok(("", RawCardFilter(Field::Level, Operator::Equal, Value::Numerical(4))))); assert_eq!( parse_raw_filters("atk>=100 l=4"), Ok(( "", - vec![(Field::Atk, Operator::GreaterEqual, Value::Numerical(100)), (Field::Level, Operator::Equal, Value::Numerical(4))] + vec![ + RawCardFilter(Field::Atk, Operator::GreaterEqual, Value::Numerical(100)), + RawCardFilter(Field::Level, Operator::Equal, Value::Numerical(4)) + ] )) ); @@ -175,9 +230,9 @@ mod tests { Ok(( "", vec![ - (Field::Type, Operator::Equal, Value::String("counter".into())), - (Field::Class, Operator::Equal, Value::String("trap".into())), - (Field::Text, Operator::Equal, Value::String("negate the summon".into())), + RawCardFilter(Field::Type, Operator::Equal, Value::String("counter".into())), + RawCardFilter(Field::Class, Operator::Equal, Value::String("trap".into())), + RawCardFilter(Field::Text, Operator::Equal, Value::String("negate the summon".into())), ] )) ); @@ -187,6 +242,6 @@ mod tests { fn quoted_value_test() { let (rest, filter) = parse_raw_filter(r#"o:"destroy that target""#).unwrap(); assert_eq!(rest, ""); - assert_eq!(filter, (Field::Text, Operator::Equal, Value::String("destroy that target".into()))); + assert_eq!(filter, RawCardFilter(Field::Text, Operator::Equal, Value::String("destroy that target".into()))); } }