Boolean operators parsing

This commit is contained in:
Antoine Gersant 2024-09-21 13:03:15 -07:00
parent b96cd2d781
commit 83b5431994
2 changed files with 189 additions and 60 deletions

View file

@ -1,7 +1,7 @@
use chumsky::{ use chumsky::{
error::Simple, error::Simple,
prelude::{choice, filter, just, none_of}, prelude::{choice, end, filter, just, none_of, recursive},
text::{int, keyword, TextParser}, text::{int, keyword, whitespace, TextParser},
Parser, Parser,
}; };
@ -49,81 +49,104 @@ pub enum Literal {
Number(i32), Number(i32),
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BoolOp {
And,
Or,
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub enum Expr { pub enum Expr {
Fuzzy(Literal), Fuzzy(Literal),
TextCmp(TextField, TextOp, String), TextCmp(TextField, TextOp, String),
NumberCmp(NumberField, NumberOp, i32), NumberCmp(NumberField, NumberOp, i32),
And(Box<Expr>, Box<Expr>), Combined(Box<Expr>, BoolOp, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
} }
pub fn make_parser() -> impl Parser<char, Expr, Error = Simple<char>> { pub fn make_parser() -> impl Parser<char, Expr, Error = Simple<char>> {
let quoted_str = just('"') let combined = recursive(|expr| {
.ignore_then(none_of('"').repeated().collect::<String>()) let quoted_str = just('"')
.then_ignore(just('"')); .ignore_then(none_of('"').repeated().collect::<String>())
.then_ignore(just('"'));
let raw_str = filter(|c: &char| !c.is_whitespace() && *c != '"') let raw_str = filter(|c: &char| !c.is_whitespace() && *c != '"')
.repeated() .repeated()
.at_least(1) .at_least(1)
.collect::<String>(); .collect::<String>();
let str_ = choice((quoted_str, raw_str)).padded(); let str_ = choice((quoted_str, raw_str)).padded();
let number = int(10).map(|n: String| n.parse::<i32>().unwrap()).padded(); let number = int(10).from_str().unwrapped().padded();
let text_field = choice(( let text_field = choice((
keyword("album").to(TextField::Album), keyword("album").to(TextField::Album),
keyword("albumartist").to(TextField::AlbumArtist), keyword("albumartist").to(TextField::AlbumArtist),
keyword("artist").to(TextField::Artist), keyword("artist").to(TextField::Artist),
keyword("composer").to(TextField::Composer), keyword("composer").to(TextField::Composer),
keyword("genre").to(TextField::Genre), keyword("genre").to(TextField::Genre),
keyword("label").to(TextField::Label), keyword("label").to(TextField::Label),
keyword("lyricist").to(TextField::Lyricist), keyword("lyricist").to(TextField::Lyricist),
keyword("path").to(TextField::Path), keyword("path").to(TextField::Path),
keyword("title").to(TextField::Title), keyword("title").to(TextField::Title),
)) ))
.padded(); .padded();
let text_op = choice(( let text_op = choice((
just("=").to(TextOp::Eq), just("=").to(TextOp::Eq),
just("!=").to(TextOp::NotEq), just("!=").to(TextOp::NotEq),
just("%").to(TextOp::Like), just("%").to(TextOp::Like),
just("!%").to(TextOp::NotLike), just("!%").to(TextOp::NotLike),
)) ))
.padded(); .padded();
let text_cmp = text_field let text_cmp = text_field
.then(text_op) .then(text_op)
.then(str_.clone()) .then(str_.clone())
.map(|((a, b), c)| Expr::TextCmp(a, b, c)); .map(|((a, b), c)| Expr::TextCmp(a, b, c));
let number_field = choice(( let number_field = choice((
keyword("discnumber").to(NumberField::DiscNumber), keyword("discnumber").to(NumberField::DiscNumber),
keyword("tracknumber").to(NumberField::TrackNumber), keyword("tracknumber").to(NumberField::TrackNumber),
keyword("year").to(NumberField::Year), keyword("year").to(NumberField::Year),
)) ))
.padded(); .padded();
let number_op = choice(( let number_op = choice((
just("=").to(NumberOp::Eq), just("=").to(NumberOp::Eq),
just("!=").to(NumberOp::NotEq), just("!=").to(NumberOp::NotEq),
just(">=").to(NumberOp::GreaterOrEq), just(">=").to(NumberOp::GreaterOrEq),
just(">").to(NumberOp::Greater), just(">").to(NumberOp::Greater),
just("<=").to(NumberOp::LessOrEq), just("<=").to(NumberOp::LessOrEq),
just("<").to(NumberOp::Less), just("<").to(NumberOp::Less),
)) ))
.padded(); .padded();
let number_cmp = number_field let number_cmp = number_field
.then(number_op) .then(number_op)
.then(number) .then(number)
.map(|((a, b), c)| Expr::NumberCmp(a, b, c)); .map(|((a, b), c)| Expr::NumberCmp(a, b, c));
let literal = number.map(Literal::Number).or(str_.map(Literal::Text)); let literal = number.map(Literal::Number).or(str_.map(Literal::Text));
let fuzzy = literal.map(Expr::Fuzzy); let fuzzy = literal.map(Expr::Fuzzy);
text_cmp.or(number_cmp).or(fuzzy) let filter = text_cmp.or(number_cmp).or(fuzzy);
let atom = filter;
let bool_op = choice((just("&&").to(BoolOp::And), just("||").to(BoolOp::Or))).padded();
let combined = atom
.clone()
.then(bool_op.then(atom).repeated())
.foldl(|a, (b, c)| Expr::Combined(Box::new(a), b, Box::new(c)));
combined
});
combined
.clone()
.then(whitespace().ignore_then(combined).repeated())
.foldl(|a: Expr, b: Expr| Expr::Combined(Box::new(a), BoolOp::And, Box::new(b)))
.then_ignore(end())
} }
#[test] #[test]
@ -250,3 +273,101 @@ fn can_parse_number_operators() {
Expr::NumberCmp(NumberField::DiscNumber, NumberOp::LessOrEq, 6), Expr::NumberCmp(NumberField::DiscNumber, NumberOp::LessOrEq, 6),
); );
} }
#[test]
fn can_use_boolean_operators() {
let parser = make_parser();
assert_eq!(
parser.parse(r#"album % lands && title % "sword""#).unwrap(),
Expr::Combined(
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"lands".to_owned()
)),
BoolOp::And,
Box::new(Expr::TextCmp(
TextField::Title,
TextOp::Like,
"sword".to_owned()
))
),
);
assert_eq!(
parser.parse(r#"album % lands || title % "sword""#).unwrap(),
Expr::Combined(
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"lands".to_owned()
)),
BoolOp::Or,
Box::new(Expr::TextCmp(
TextField::Title,
TextOp::Like,
"sword".to_owned()
))
),
);
}
#[test]
fn boolean_operators_share_precedence() {
let parser = make_parser();
assert_eq!(
parser
.parse(r#"album % lands || album % tales && title % "sword""#)
.unwrap(),
Expr::Combined(
Box::new(Expr::Combined(
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"lands".to_owned()
)),
BoolOp::Or,
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"tales".to_owned()
))
)),
BoolOp::And,
Box::new(Expr::TextCmp(
TextField::Title,
TextOp::Like,
"sword".to_owned()
))
),
);
assert_eq!(
parser
.parse(r#"album % lands && album % tales || title % "sword""#)
.unwrap(),
Expr::Combined(
Box::new(Expr::Combined(
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"lands".to_owned()
)),
BoolOp::And,
Box::new(Expr::TextCmp(
TextField::Album,
TextOp::Like,
"tales".to_owned()
))
)),
BoolOp::Or,
Box::new(Expr::TextCmp(
TextField::Title,
TextOp::Like,
"sword".to_owned()
))
),
);
}

View file

@ -5,6 +5,8 @@ use crate::app::index::{
storage::SongKey, storage::SongKey,
}; };
use super::query::BoolOp;
struct SearchIndex {} struct SearchIndex {}
impl SearchIndex { impl SearchIndex {
@ -13,12 +15,18 @@ impl SearchIndex {
Expr::Fuzzy(s) => self.eval_fuzzy(s), Expr::Fuzzy(s) => self.eval_fuzzy(s),
Expr::TextCmp(field, op, s) => self.eval_text_operator(*field, *op, &s), Expr::TextCmp(field, op, s) => self.eval_text_operator(*field, *op, &s),
Expr::NumberCmp(field, op, n) => self.eval_number_operator(*field, *op, *n), Expr::NumberCmp(field, op, n) => self.eval_number_operator(*field, *op, *n),
Expr::And(e, f) => self Expr::Combined(e, op, f) => self.combine(e, *op, f),
}
}
fn combine(&self, e: &Box<Expr>, op: BoolOp, f: &Box<Expr>) -> HashSet<SongKey> {
match op {
BoolOp::And => self
.eval_expr(e) .eval_expr(e)
.intersection(&self.eval_expr(f)) .intersection(&self.eval_expr(f))
.cloned() .cloned()
.collect(), .collect(),
Expr::Or(e, f) => self BoolOp::Or => self
.eval_expr(e) .eval_expr(e)
.union(&self.eval_expr(f)) .union(&self.eval_expr(f))
.cloned() .cloned()