aede_core/query/
parsing.rs

1//! Parsing and strict validation of query expressions.
2
3use super::*;
4
5// --------------------------------------------------------------------------
6// Reading a query
7// --------------------------------------------------------------------------
8
9/// Splits a query into its words, keeping quoted runs whole.
10///
11/// Parentheses are words of their own so that `(a OR b)` needs no spaces around
12/// them, which nobody would remember to type.
13fn tokenize(input: &str) -> Result<Vec<String>, QueryError> {
14    let mut out: Vec<String> = Vec::new();
15    let mut current = String::new();
16    let mut quote: Option<char> = None;
17    for ch in input.chars() {
18        match quote {
19            Some(q) if ch == q => quote = None,
20            Some(_) => current.push(ch),
21            None if ch == '"' || ch == '\'' => quote = Some(ch),
22            None if ch.is_whitespace() => {
23                if !current.is_empty() {
24                    out.push(std::mem::take(&mut current));
25                }
26            }
27            None if ch == '(' || ch == ')' => {
28                if !current.is_empty() {
29                    out.push(std::mem::take(&mut current));
30                }
31                out.push(ch.to_string());
32            }
33            None => current.push(ch),
34        }
35    }
36    if quote.is_some() {
37        return Err(error("a quotation mark is left open"));
38    }
39    if !current.is_empty() {
40        out.push(current);
41    }
42    Ok(out)
43}
44
45/// Reads a query, rejecting incomplete operators and invalid numeric bounds.
46///
47/// Juxtaposition means AND. Durations accept seconds or `minutes:seconds`;
48/// all numeric bounds must be finite and nonnegative.
49///
50/// ```
51/// use aede_core::query::parse;
52/// assert!(parse("(artist:ozzy OR artist:dio) duration:..5:00").is_ok());
53/// assert!(parse("rating:NaN").is_err());
54/// assert!(parse("title:Track AND").is_err());
55/// ```
56pub fn parse(input: &str) -> Result<Query, QueryError> {
57    if let Some(value) = input.strip_prefix("browse-v1:") {
58        return browse::parse(value).map(|filter| Query::Browse(Box::new(filter)));
59    }
60    let words = tokenize(input)?;
61    if words.is_empty() {
62        return Ok(Query::All);
63    }
64    let mut at = 0usize;
65    let query = parse_or(&words, &mut at, 0)?;
66    if at < words.len() {
67        return Err(error(format!("\"{}\" is one bracket too many", words[at])));
68    }
69    Ok(query)
70}
71
72fn parse_or(words: &[String], at: &mut usize, depth: usize) -> Result<Query, QueryError> {
73    let mut parts = vec![parse_and(words, at, depth)?];
74    while *at < words.len() && is_or(&words[*at]) {
75        *at += 1;
76        parts.push(parse_and(words, at, depth)?);
77    }
78    Ok(if parts.len() == 1 {
79        parts.remove(0)
80    } else {
81        Query::Or(parts)
82    })
83}
84
85fn is_or(word: &str) -> bool {
86    word.eq_ignore_ascii_case("or") || word == "|" || word == "||"
87}
88
89fn parse_and(words: &[String], at: &mut usize, depth: usize) -> Result<Query, QueryError> {
90    let mut parts: Vec<Query> = Vec::new();
91    while *at < words.len() && words[*at] != ")" && !is_or(&words[*at]) {
92        // `AND` may be written, and is what juxtaposition already means.
93        if words[*at].eq_ignore_ascii_case("and") {
94            if parts.is_empty() {
95                return Err(error("AND needs an expression before it"));
96            }
97            *at += 1;
98            if *at >= words.len()
99                || words[*at] == ")"
100                || is_or(&words[*at])
101                || words[*at].eq_ignore_ascii_case("and")
102            {
103                return Err(error("AND needs an expression after it"));
104            }
105            parts.push(parse_unary(words, at, depth)?);
106            continue;
107        }
108        parts.push(parse_unary(words, at, depth)?);
109    }
110    if parts.is_empty() {
111        return Err(error("something is missing between the brackets"));
112    }
113    Ok(if parts.len() == 1 {
114        parts.remove(0)
115    } else {
116        Query::And(parts)
117    })
118}
119
120fn parse_unary(words: &[String], at: &mut usize, depth: usize) -> Result<Query, QueryError> {
121    let Some(word) = words.get(*at) else {
122        return Err(error("an expression is missing"));
123    };
124    parse_unary_word(words, at, depth, word)
125}
126
127fn parse_unary_word(
128    words: &[String],
129    at: &mut usize,
130    depth: usize,
131    word: &str,
132) -> Result<Query, QueryError> {
133    if depth > 128 {
134        return Err(error("a query exceeds 128 nested brackets or negations"));
135    }
136    if word.eq_ignore_ascii_case("and") || is_or(word) {
137        return Err(error(format!("{word} needs an expression on both sides")));
138    }
139    if word == "-" || word.eq_ignore_ascii_case("not") {
140        *at += 1;
141        if *at >= words.len() {
142            return Err(error("nothing follows the minus sign"));
143        }
144        return Ok(Query::Not(Box::new(parse_unary(words, at, depth + 1)?)));
145    }
146    if let Some(rest) = word.strip_prefix('-')
147        && !rest.is_empty()
148    {
149        // `-genre:metal`, the common spelling, with no space after the sign.
150        // Borrow the token suffix. Cloning all remaining words for every
151        // negative term makes a flat, shallow query quadratic in its length.
152        let negated = parse_unary_word(words, at, depth + 1, rest)?;
153        return Ok(Query::Not(Box::new(negated)));
154    }
155    if word == "(" {
156        *at += 1;
157        let inside = parse_or(words, at, depth + 1)?;
158        if *at >= words.len() || words[*at] != ")" {
159            return Err(error("a bracket is left open"));
160        }
161        *at += 1;
162        return Ok(inside);
163    }
164    if word == ")" {
165        return Err(error("a closing bracket has nothing to close"));
166    }
167    let term = parse_term(word)?;
168    *at += 1;
169    Ok(Query::Term(term))
170}
171
172fn parse_term(word: &str) -> Result<Term, QueryError> {
173    let Some((name, value)) = word.split_once(':') else {
174        // A bare word is a question when it names a field that can be asked
175        // one, and a search otherwise.
176        if let Some(field) = field_named(word)
177            && asks_whether_it_holds_anything(&field)
178        {
179            return Ok(Term {
180                field,
181                test: Test::Set,
182            });
183        }
184        return Ok(Term {
185            field: Field::Anything,
186            test: Test::Contains(word.to_string()),
187        });
188    };
189    let Some(field) = field_named(name) else {
190        return Err(error(format!(
191            "\"{name}\" is not a field.\nFields: {}",
192            FIELD_NAMES
193                .iter()
194                .map(|(n, _)| *n)
195                .collect::<Vec<_>>()
196                .join(", ")
197        )));
198    };
199    let test = parse_test(&field, value)?;
200    Ok(Term { field, test })
201}
202
203fn parse_test(field: &Field, value: &str) -> Result<Test, QueryError> {
204    if value.is_empty() {
205        return Err(error("a field needs something after the colon"));
206    }
207    // A flag asked with a word: `lossless:false` reads better than `-lossless`
208    // and means the same, so both are accepted rather than one being a trap.
209    if is_flag(field) {
210        match value.to_lowercase().as_str() {
211            "true" | "yes" | "1" => return Ok(Test::Set),
212            "false" | "no" | "0" => return Ok(Test::Unset),
213            other => {
214                return Err(error(format!(
215                    "\"{other}\" is not a yes or a no: try true or false"
216                )));
217            }
218        }
219    }
220    if is_numeric(field) {
221        // A range, either end of which may be left open: `1990..`, `..1999`.
222        if let Some((low, high)) = value.split_once("..") {
223            let low = parse_number(field, low)?;
224            let high = parse_number(field, high)?;
225            if low.is_none() && high.is_none() {
226                return Err(error("a range needs at least one bound"));
227            }
228            if let (Some(low), Some(high)) = (low, high)
229                && low > high
230            {
231                return Err(error("the lower range bound exceeds the upper bound"));
232            }
233            return Ok(Test::Between(low, high));
234        }
235        for (prefix, compare) in [
236            (">=", Compare::AtLeast),
237            ("<=", Compare::AtMost),
238            (">", Compare::Greater),
239            ("<", Compare::Less),
240            ("=", Compare::Equal),
241        ] {
242            if let Some(rest) = value.strip_prefix(prefix) {
243                let Some(number) = parse_number(field, rest)? else {
244                    return Err(error(format!("\"{rest}\" is not a number")));
245                };
246                return Ok(Test::Compare(compare, number));
247            }
248        }
249        let Some(number) = parse_number(field, value)? else {
250            return Err(error(format!("\"{value}\" is not a number")));
251        };
252        return Ok(Test::Compare(Compare::Equal, number));
253    }
254    if let Some(exact) = value.strip_prefix('=') {
255        return Ok(Test::Is(exact.to_string()));
256    }
257    Ok(Test::Contains(value.to_string()))
258}
259
260/// Reads a number, accepting `3:45` wherever a duration is expected.
261fn parse_number(field: &Field, raw: &str) -> Result<Option<f64>, QueryError> {
262    let raw = raw.trim();
263    if raw.is_empty() {
264        return Ok(None);
265    }
266    if *field == Field::Duration
267        && let Some((minutes, seconds)) = raw.split_once(':')
268    {
269        let minutes: f64 = minutes
270            .parse()
271            .map_err(|_| error(format!("\"{raw}\" is not a length")))?;
272        let seconds: f64 = seconds
273            .parse()
274            .map_err(|_| error(format!("\"{raw}\" is not a length")))?;
275        if !minutes.is_finite()
276            || minutes < 0.0
277            || minutes.fract() != 0.0
278            || !seconds.is_finite()
279            || !(0.0..60.0).contains(&seconds)
280        {
281            return Err(error(format!(
282                "\"{raw}\" needs whole minutes and seconds below 60"
283            )));
284        }
285        return finite_number((minutes * 60.0 + seconds) * 1000.0, raw).map(Some);
286    }
287    let number: f64 = raw
288        .parse()
289        .map_err(|_| error(format!("\"{raw}\" is not a number")))?;
290    // Durations are stored in milliseconds and typed in seconds.
291    let number = if *field == Field::Duration {
292        number * 1000.0
293    } else {
294        number
295    };
296    finite_number(number, raw).map(Some)
297}
298
299fn finite_number(number: f64, raw: &str) -> Result<f64, QueryError> {
300    if !number.is_finite() || number < 0.0 {
301        return Err(error(format!("\"{raw}\" must be finite and nonnegative")));
302    }
303    Ok(number)
304}