diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index 8bb63dbe7..eb266b5ad 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -1,4 +1,34 @@ +## Errors shown when invalid search input is encountered. +## Text wrapped in code tags is literal search input and should generally not to be altered. + search-invalid = Invalid search - please check for typing mistakes. +search-misplaced-and = Invalid search:
An and was found but it is not connecting two search terms.
If you want to search for the word itself, wrap it in double quotes: "and". +search-misplaced-or = Invalid search:
An or was found but it is not connecting two search terms.
If you want to search for the word itself, wrap it in double quotes: "or". +# Here, the ellipsis "..." may be localised. +search-empty-group = Invalid search:
A group (...) was found but there was nothing between the brackets to search for.
If you want to search for literal brackets, wrap them in double quotes: "( )". +search-unopened-group = Invalid search:
A closing bracket ) was found, but there was no opening bracket ( preceding it.
If you want to search for the literal ), wrap it in double quotes or prepend a backslash: ")" or \). +search-unclosed-group = Invalid search:
An opening bracket ( was found, but there was no closing bracket ) following it.
If you want to search for the literal (, wrap it in double quotes or prepend a backslash: "(" or \( . +search-empty-quote = Invalid search:
A pair of double quotes "" was found but there was nothing between them to search for.
If you want to search for literal double quotes, prepend backslashes: \"\". +search-unclosed-quote = Invalid search:
An opening double quote " was found but there was no second one to close it.
If you want to search for the literal ", prepend a backslash: \". +search-missing-key = Invalid search:
A colon : was found but there was no key word preceding it.
If you want to search for the literal :, prepend a backslash: \:. +search-unknown-escape = Invalid search:
The escape sequence { $val } is not defined.
If you want to search for the literal backslash \, prepend another one: \\. +search-invalid-id-list = Invalid search:
Note or card id lists must be comma-separated number series. +search-invalid-state = Invalid search:
is: must be followed by one of the predefined card states: new, review, learn, due, buried, buried-manually, buried-sibling or suspended. +search-invalid-flag = Invalid search:
flag: must be followed by a valid flag number: 1 (red), 2 (orange), 3 (green), 4 (blue) or 0 (no flag). +search-invalid-added = Invalid search:
added: must be followed by a positive number of days. +search-invalid-edited = Invalid search:
edited: must be followed by a positive number of days. +search-invalid-rated-days = Invalid search:
rated: must be followed by a positive number of days. +search-invalid-rated-ease = Invalid search:
rated:{ $val }: must be followed by 1 (again), 2 (hard), 3 (good) or 4 (easy). +search-invalid-resched = Invalid search:
resched: must be followed by a positive number of days. +search-invalid-dupe-mid = Invalid search:
dupe: must be followed by a note type id, a comma and then arbitrary text. +search-invalid-dupe-text = Invalid search:
dupe: must be followed by a note type id, a comma and then arbitrary text. +search-invalid-prop-property = Invalid search:
prop: must be followed by one of the predefined card properties: ivl (interval), due, reps (repetitions), lapses, ease or pos (position). +search-invalid-prop-operator = Invalid search:
prop:{ $val } must be followed by one of the comparison operators: =, !=, <, >, <= or >=. +search-invalid-prop-float = Invalid search:
prop:{ $val } must be followed by a decimal number. +search-invalid-prop-integer = Invalid search:
prop:{ $val } must be followed by a whole number. +search-invalid-prop-unsigned = Invalid search:
prop:{ $val } must be followed by a non-negative whole number. +search-invalid-did = Invalid search:
did: must be followed by a valid deck id. +search-invalid-mid = Invalid search:
mid: must be followed by a note type deck id. ## Column labels in browse screen diff --git a/rslib/src/err.rs b/rslib/src/err.rs index c417a635f..a38052bd9 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -3,6 +3,7 @@ use crate::i18n::{tr_args, tr_strs, I18n, TR}; pub use failure::{Error, Fail}; +use nom::error::{ErrorKind as NomErrorKind, ParseError as NomParseError}; use reqwest::StatusCode; use std::{io, str::Utf8Error}; use tempfile::PathPersistError; @@ -60,7 +61,7 @@ pub enum AnkiError { DeckIsFiltered, #[fail(display = "Invalid search.")] - SearchError(Option), + SearchError(SearchErrorKind), } // error helpers @@ -122,13 +123,62 @@ impl AnkiError { DBErrorKind::Locked => "Anki already open, or media currently syncing.".into(), _ => format!("{:?}", self), }, - AnkiError::SearchError(details) => { - if let Some(details) = details { - details.to_owned() - } else { - i18n.tr(TR::SearchInvalid).to_string() - } + AnkiError::SearchError(kind) => match kind { + SearchErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd), + SearchErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr), + SearchErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup), + SearchErrorKind::UnopenedGroup => i18n.tr(TR::SearchUnopenedGroup), + SearchErrorKind::UnclosedGroup => i18n.tr(TR::SearchUnclosedGroup), + SearchErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), + SearchErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), + SearchErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), + SearchErrorKind::UnknownEscape(ctx) => i18n + .trn( + TR::SearchUnknownEscape, + tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], + ) + .into(), + SearchErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), + SearchErrorKind::InvalidState => i18n.tr(TR::SearchInvalidState), + SearchErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), + SearchErrorKind::InvalidAdded => i18n.tr(TR::SearchInvalidAdded), + SearchErrorKind::InvalidEdited => i18n.tr(TR::SearchInvalidEdited), + SearchErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), + SearchErrorKind::InvalidRatedEase(ctx) => i18n + .trn(TR::SearchInvalidRatedEase, tr_strs!["val"=>(ctx)]) + .into(), + SearchErrorKind::InvalidResched => i18n.tr(TR::SearchInvalidResched), + SearchErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), + SearchErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), + SearchErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), + SearchErrorKind::InvalidPropOperator(ctx) => i18n + .trn(TR::SearchInvalidPropOperator, tr_strs!["val"=>(ctx)]) + .into(), + SearchErrorKind::InvalidPropFloat(ctx) => i18n + .trn( + TR::SearchInvalidPropFloat, + tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], + ) + .into(), + SearchErrorKind::InvalidPropInteger(ctx) => i18n + .trn( + TR::SearchInvalidPropInteger, + tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], + ) + .into(), + SearchErrorKind::InvalidPropUnsigned(ctx) => i18n + .trn( + TR::SearchInvalidPropUnsigned, + tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], + ) + .into(), + SearchErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), + SearchErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), + SearchErrorKind::Regex(text) => text.into(), + SearchErrorKind::Other(Some(info)) => info.into(), + SearchErrorKind::Other(None) => i18n.tr(TR::SearchInvalid), } + .into(), _ => format!("{:?}", self), } } @@ -166,7 +216,7 @@ impl From for AnkiError { }; } if reason.contains("regex parse error") { - return AnkiError::SearchError(Some(reason.to_owned())); + return AnkiError::SearchError(SearchErrorKind::Regex(reason.to_owned())); } } AnkiError::DBError { @@ -339,3 +389,70 @@ impl From for AnkiError { } } } + +#[derive(Debug, PartialEq)] +pub enum ParseError<'a> { + Anki(&'a str, SearchErrorKind), + Nom(&'a str, NomErrorKind), +} + +#[derive(Debug, PartialEq)] +pub enum SearchErrorKind { + MisplacedAnd, + MisplacedOr, + EmptyGroup, + UnopenedGroup, + UnclosedGroup, + EmptyQuote, + UnclosedQuote, + MissingKey, + UnknownEscape(String), + InvalidIdList, + InvalidState, + InvalidFlag, + InvalidAdded, + InvalidEdited, + InvalidRatedDays, + InvalidRatedEase(String), + InvalidDupeMid, + InvalidDupeText, + InvalidResched, + InvalidPropProperty, + InvalidPropOperator(String), + InvalidPropFloat(String), + InvalidPropInteger(String), + InvalidPropUnsigned(String), + InvalidDid, + InvalidMid, + Regex(String), + Other(Option), +} + +impl From> for AnkiError { + fn from(err: ParseError) -> Self { + match err { + ParseError::Anki(_, kind) => AnkiError::SearchError(kind), + ParseError::Nom(_, _) => AnkiError::SearchError(SearchErrorKind::Other(None)), + } + } +} + +impl From>> for AnkiError { + fn from(err: nom::Err>) -> Self { + match err { + nom::Err::Error(e) => e.into(), + nom::Err::Failure(e) => e.into(), + nom::Err::Incomplete(_) => AnkiError::SearchError(SearchErrorKind::Other(None)), + } + } +} + +impl<'a> NomParseError<&'a str> for ParseError<'a> { + fn from_error_kind(input: &'a str, kind: NomErrorKind) -> Self { + ParseError::Nom(input, kind) + } + + fn append(_: &str, _: NomErrorKind, other: Self) -> Self { + other + } +} diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index a42d5cd84..27f7cad34 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -3,7 +3,7 @@ use crate::{ decks::DeckID, - err::{AnkiError, Result}, + err::{ParseError, Result, SearchErrorKind as FailKind}, notetype::NoteTypeID, }; use lazy_static::lazy_static; @@ -11,36 +11,25 @@ use nom::{ branch::alt, bytes::complete::{escaped, is_not, tag}, character::complete::{anychar, char, none_of, one_of}, - combinator::{all_consuming, map, map_res, verify}, - error::{Error, ErrorKind}, - sequence::{delimited, preceded, separated_pair}, - {multi::many0, IResult}, + combinator::{map, verify}, + error::ErrorKind as NomErrorKind, + multi::many0, + sequence::{preceded, separated_pair}, }; use regex::{Captures, Regex}; -use std::{borrow::Cow, num}; +use std::borrow::Cow; -struct ParseError {} +type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; +type ParseResult<'a, O> = std::result::Result>>; -impl From for ParseError { - fn from(_: num::ParseIntError) -> Self { - ParseError {} - } +fn parse_failure(input: &str, kind: FailKind) -> nom::Err> { + nom::Err::Failure(ParseError::Anki(input, kind)) } -impl From for ParseError { - fn from(_: num::ParseFloatError) -> Self { - ParseError {} - } +fn parse_error(input: &str) -> nom::Err> { + nom::Err::Error(ParseError::Anki(input, FailKind::Other(None))) } -impl From> for ParseError { - fn from(_: nom::Err<(I, ErrorKind)>) -> Self { - ParseError {} - } -} - -type ParseResult = std::result::Result; - #[derive(Debug, PartialEq)] pub enum Node<'a> { And, @@ -132,20 +121,17 @@ pub(super) fn parse(input: &str) -> Result> { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); } - let (_, nodes) = - all_consuming(group_inner)(input).map_err(|_e| AnkiError::SearchError(None))?; - Ok(nodes) + match group_inner(input) { + Ok(("", nodes)) => Ok(nodes), + // unmatched ) is only char not consumed by any node parser + Ok((remaining, _)) => Err(parse_failure(remaining, FailKind::UnopenedGroup).into()), + Err(err) => Err(err.into()), + } } -/// One or more nodes surrounded by brackets, eg (one OR two) -fn group(s: &str) -> IResult<&str, Node> { - map(delimited(char('('), group_inner, char(')')), |nodes| { - Node::Group(nodes) - })(s) -} - -/// One or more nodes inside brackets, er 'one OR two -three' -fn group_inner(input: &str) -> IResult<&str, Vec> { +/// Zero or more nodes inside brackets, eg 'one OR two -three'. +/// Empty vec must be handled by caller. +fn group_inner(input: &str) -> IResult> { let mut remaining = input; let mut nodes = vec![]; @@ -157,8 +143,10 @@ fn group_inner(input: &str) -> IResult<&str, Vec> { if nodes.len() % 2 == 0 { // before adding the node, if the length is even then the node // must not be a boolean - if matches!(node, Node::And | Node::Or) { - return Err(nom::Err::Failure(Error::new("", ErrorKind::NoneOf))); + if node == Node::And { + return Err(parse_failure(input, FailKind::MisplacedAnd)); + } else if node == Node::Or { + return Err(parse_failure(input, FailKind::MisplacedOr)); } } else { // if the length is odd, the next item must be a boolean. if it's @@ -176,42 +164,133 @@ fn group_inner(input: &str) -> IResult<&str, Vec> { }; } - if nodes.is_empty() { - Err(nom::Err::Error(Error::new(remaining, ErrorKind::Many1))) - } else if matches!(nodes.last().unwrap(), Node::And | Node::Or) { - // no trailing and/or - Err(nom::Err::Failure(Error::new("", ErrorKind::NoneOf))) - } else { - // chomp any trailing whitespace - let (remaining, _) = whitespace0(remaining)?; - - Ok((remaining, nodes)) + if let Some(last) = nodes.last() { + match last { + Node::And => return Err(parse_failure(input, FailKind::MisplacedAnd)), + Node::Or => return Err(parse_failure(input, FailKind::MisplacedOr)), + _ => (), + } } + let (remaining, _) = whitespace0(remaining)?; + + Ok((remaining, nodes)) } -fn whitespace0(s: &str) -> IResult<&str, Vec> { +fn whitespace0(s: &str) -> IResult> { many0(one_of(" \u{3000}"))(s) } /// Optional leading space, then a (negated) group or text -fn node(s: &str) -> IResult<&str, Node> { +fn node(s: &str) -> IResult { preceded(whitespace0, alt((negated_node, group, text)))(s) } -fn negated_node(s: &str) -> IResult<&str, Node> { +fn negated_node(s: &str) -> IResult { map(preceded(char('-'), alt((group, text))), |node| { Node::Not(Box::new(node)) })(s) } +/// One or more nodes surrounded by brackets, eg (one OR two) +fn group(s: &str) -> IResult { + let (opened, _) = char('(')(s)?; + let (tail, inner) = group_inner(opened)?; + if let Some(remaining) = tail.strip_prefix(')') { + if inner.is_empty() { + Err(parse_failure(s, FailKind::EmptyGroup)) + } else { + Ok((remaining, Node::Group(inner))) + } + } else { + Err(parse_failure(s, FailKind::UnclosedGroup)) + } +} + /// Either quoted or unquoted text -fn text(s: &str) -> IResult<&str, Node> { +fn text(s: &str) -> IResult { alt((quoted_term, partially_quoted_term, unquoted_term))(s) } +/// Quoted text, including the outer double quotes. +fn quoted_term(s: &str) -> IResult { + let (remaining, term) = quoted_term_str(s)?; + Ok((remaining, Node::Search(search_node_for_text(term)?))) +} + +/// eg deck:"foo bar" - quotes must come after the : +fn partially_quoted_term(s: &str) -> IResult { + let (remaining, (key, val)) = separated_pair( + escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(" \u{3000}")), + char(':'), + quoted_term_str, + )(s)?; + Ok(( + remaining, + Node::Search(search_node_for_text_with_argument(key, val)?), + )) +} + +/// Unquoted text, terminated by whitespace or unescaped ", ( or ) +fn unquoted_term(s: &str) -> IResult { + match escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}"))(s) { + Ok((tail, term)) => { + if term.is_empty() { + Err(parse_error(s)) + } else if term.eq_ignore_ascii_case("and") { + Ok((tail, Node::And)) + } else if term.eq_ignore_ascii_case("or") { + Ok((tail, Node::Or)) + } else { + Ok((tail, Node::Search(search_node_for_text(term)?))) + } + } + Err(err) => { + if let nom::Err::Error((c, NomErrorKind::NoneOf)) = err { + Err(parse_failure( + s, + FailKind::UnknownEscape(format!("\\{}", c)), + )) + } else if "\"() \u{3000}".contains(s.chars().next().unwrap()) { + Err(parse_error(s)) + } else { + // input ends in an odd number of backslashes + Err(parse_failure(s, FailKind::UnknownEscape('\\'.to_string()))) + } + } + } +} + +/// Non-empty string delimited by unescaped double quotes. +fn quoted_term_str(s: &str) -> IResult<&str> { + let (opened, _) = char('"')(s)?; + if let Ok((tail, inner)) = + escaped::<_, ParseError, _, _, _, _>(is_not(r#""\"#), '\\', anychar)(opened) + { + if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { + Ok((remaining, inner)) + } else { + Err(parse_failure(s, FailKind::UnclosedQuote)) + } + } else { + Err(parse_failure( + s, + match opened.chars().next().unwrap() { + '"' => FailKind::EmptyQuote, + // no unescaped " and a trailing \ + _ => FailKind::UnclosedQuote, + }, + )) + } +} + /// Determine if text is a qualified search, and handle escaped chars. +/// Expect well-formed input: unempty and no trailing \. fn search_node_for_text(s: &str) -> ParseResult { - let (tail, head) = escaped(is_not(r":\"), '\\', anychar)(s)?; + // leading : is only possible error for well-formed input + let (tail, head) = verify(escaped(is_not(r":\"), '\\', anychar), |t: &str| { + !t.is_empty() + })(s) + .map_err(|_: nom::Err| parse_failure(s, FailKind::MissingKey))?; if tail.is_empty() { Ok(SearchNode::UnqualifiedText(unescape(head)?)) } else { @@ -219,91 +298,210 @@ fn search_node_for_text(s: &str) -> ParseResult { } } -/// Unquoted text, terminated by whitespace or unescaped ", ( or ) -fn unquoted_term(s: &str) -> IResult<&str, Node> { - map_res( - verify( - escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}")), - |s: &str| !s.is_empty(), - ), - |text: &str| -> ParseResult { - Ok(if text.eq_ignore_ascii_case("or") { - Node::Or - } else if text.eq_ignore_ascii_case("and") { - Node::And - } else { - Node::Search(search_node_for_text(text)?) - }) - }, - )(s) -} - -/// Quoted text, including the outer double quotes. -fn quoted_term(s: &str) -> IResult<&str, Node> { - map_res(quoted_term_str, |o| -> ParseResult { - Ok(Node::Search(search_node_for_text(o)?)) - })(s) -} - -fn quoted_term_str(s: &str) -> IResult<&str, &str> { - delimited(char('"'), quoted_term_inner, char('"'))(s) -} - -/// Quoted text, terminated by a non-escaped double quote -fn quoted_term_inner(s: &str) -> IResult<&str, &str> { - verify(escaped(is_not(r#""\"#), '\\', anychar), |s: &str| { - !s.is_empty() - })(s) -} - -/// eg deck:"foo bar" - quotes must come after the : -fn partially_quoted_term(s: &str) -> IResult<&str, Node> { - map_res( - separated_pair( - verify( - escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(": \u{3000}")), - |s: &str| !s.is_empty(), - ), - char(':'), - quoted_term_str, - ), - |p| match search_node_for_text_with_argument(p.0, p.1) { - Ok(search) => Ok(Node::Search(search)), - Err(e) => Err(e), - }, - )(s) -} - /// Convert a colon-separated key/val pair into the relevant search type. fn search_node_for_text_with_argument<'a>( key: &'a str, val: &'a str, -) -> ParseResult> { +) -> ParseResult<'a, SearchNode<'a>> { Ok(match key.to_ascii_lowercase().as_str() { - "added" => parse_added(val)?, - "edited" => parse_edited(val)?, "deck" => SearchNode::Deck(unescape(val)?), "note" => SearchNode::NoteType(unescape(val)?), "tag" => SearchNode::Tag(unescape(val)?), - "mid" => SearchNode::NoteTypeID(val.parse()?), - "nid" => SearchNode::NoteIDs(check_id_list(val)?), - "cid" => SearchNode::CardIDs(check_id_list(val)?), - "did" => SearchNode::DeckID(val.parse()?), "card" => parse_template(val)?, - "is" => parse_state(val)?, "flag" => parse_flag(val)?, - "rated" => parse_rated(val)?, - "dupe" => parse_dupe(val)?, "resched" => parse_resched(val)?, "prop" => parse_prop(val)?, + "added" => parse_added(val)?, + "edited" => parse_edited(val)?, + "rated" => parse_rated(val)?, + "is" => parse_state(val)?, + "did" => parse_did(val)?, + "mid" => parse_mid(val)?, + "nid" => SearchNode::NoteIDs(check_id_list(val)?), + "cid" => SearchNode::CardIDs(check_id_list(val)?), "re" => SearchNode::Regex(unescape_quotes(val)), "nc" => SearchNode::NoCombining(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?), + "dupe" => parse_dupe(val)?, // anything else is a field search _ => parse_single_field(key, val)?, }) } +fn parse_template(s: &str) -> ParseResult { + Ok(SearchNode::CardTemplate(match s.parse::() { + Ok(n) => TemplateKind::Ordinal(n.max(1) - 1), + Err(_) => TemplateKind::Name(unescape(s)?), + })) +} + +/// flag:0-4 +fn parse_flag(s: &str) -> ParseResult { + if let Ok(flag) = s.parse::() { + if flag > 4 { + Err(parse_failure(s, FailKind::InvalidFlag)) + } else { + Ok(SearchNode::Flag(flag)) + } + } else { + Err(parse_failure(s, FailKind::InvalidFlag)) + } +} + +/// eg resched:3 +fn parse_resched(s: &str) -> ParseResult { + if let Ok(d) = s.parse::() { + Ok(SearchNode::Rated { + days: d.max(1).min(365), + ease: EaseKind::ManualReschedule, + }) + } else { + Err(parse_failure(s, FailKind::InvalidResched)) + } +} + +/// eg prop:ivl>3, prop:ease!=2.5 +fn parse_prop(s: &str) -> ParseResult { + let (tail, prop) = alt::<_, _, ParseError, _>(( + tag("ivl"), + tag("due"), + tag("reps"), + tag("lapses"), + tag("ease"), + tag("pos"), + ))(s) + .map_err(|_| parse_failure(s, FailKind::InvalidPropProperty))?; + + let (num, operator) = alt::<_, _, ParseError, _>(( + tag("<="), + tag(">="), + tag("!="), + tag("="), + tag("<"), + tag(">"), + ))(tail) + .map_err(|_| parse_failure(s, FailKind::InvalidPropOperator(prop.to_string())))?; + + let kind = if prop == "ease" { + if let Ok(f) = num.parse::() { + PropertyKind::Ease(f) + } else { + return Err(parse_failure( + s, + FailKind::InvalidPropFloat(format!("{}{}", prop, operator)), + )); + } + } else if prop == "due" { + if let Ok(i) = num.parse::() { + PropertyKind::Due(i) + } else { + return Err(parse_failure( + s, + FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), + )); + } + } else if let Ok(u) = num.parse::() { + match prop { + "ivl" => PropertyKind::Interval(u), + "reps" => PropertyKind::Reps(u), + "lapses" => PropertyKind::Lapses(u), + "pos" => PropertyKind::Position(u), + _ => unreachable!(), + } + } else { + return Err(parse_failure( + s, + FailKind::InvalidPropUnsigned(format!("{}{}", prop, operator)), + )); + }; + + Ok(SearchNode::Property { + operator: operator.to_string(), + kind, + }) +} + +/// eg added:1 +fn parse_added(s: &str) -> ParseResult { + if let Ok(days) = s.parse::() { + Ok(SearchNode::AddedInDays(days.max(1))) + } else { + Err(parse_failure(s, FailKind::InvalidAdded)) + } +} + +/// eg edited:1 +fn parse_edited(s: &str) -> ParseResult { + if let Ok(days) = s.parse::() { + Ok(SearchNode::EditedInDays(days.max(1))) + } else { + Err(parse_failure(s, FailKind::InvalidEdited)) + } +} + +/// eg rated:3 or rated:10:2 +/// second arg must be between 1-4 +fn parse_rated(s: &str) -> ParseResult { + let mut it = s.splitn(2, ':'); + if let Ok(d) = it.next().unwrap().parse::() { + let days = d.max(1).min(365); + let ease = if let Some(tail) = it.next() { + if let Ok(u) = tail.parse::() { + if u > 0 && u < 5 { + EaseKind::AnswerButton(u) + } else { + return Err(parse_failure( + s, + FailKind::InvalidRatedEase(days.to_string()), + )); + } + } else { + return Err(parse_failure( + s, + FailKind::InvalidRatedEase(days.to_string()), + )); + } + } else { + EaseKind::AnyAnswerButton + }; + Ok(SearchNode::Rated { days, ease }) + } else { + Err(parse_failure(s, FailKind::InvalidRatedDays)) + } +} + +/// eg is:due +fn parse_state(s: &str) -> ParseResult { + use StateKind::*; + Ok(SearchNode::State(match s { + "new" => New, + "review" => Review, + "learn" => Learning, + "due" => Due, + "buried" => Buried, + "buried-manually" => UserBuried, + "buried-sibling" => SchedBuried, + "suspended" => Suspended, + _ => return Err(parse_failure(s, FailKind::InvalidState)), + })) +} + +fn parse_did(s: &str) -> ParseResult { + if let Ok(did) = s.parse() { + Ok(SearchNode::DeckID(did)) + } else { + Err(parse_failure(s, FailKind::InvalidDid)) + } +} + +fn parse_mid(s: &str) -> ParseResult { + if let Ok(mid) = s.parse() { + Ok(SearchNode::NoteTypeID(mid)) + } else { + Err(parse_failure(s, FailKind::InvalidMid)) + } +} + /// ensure a list of ids contains only numbers and commas, returning unchanged if true /// used by nid: and cid: fn check_id_list(s: &str) -> ParseResult<&str> { @@ -313,147 +511,28 @@ fn check_id_list(s: &str) -> ParseResult<&str> { if RE.is_match(s) { Ok(s) } else { - Err(ParseError {}) + Err(parse_failure(s, FailKind::InvalidIdList)) } } -/// eg added:1 -fn parse_added(s: &str) -> ParseResult> { - let n: u32 = s.parse()?; - let days = n.max(1); - Ok(SearchNode::AddedInDays(days)) -} - -/// eg edited:1 -fn parse_edited(s: &str) -> ParseResult> { - let n: u32 = s.parse()?; - let days = n.max(1); - Ok(SearchNode::EditedInDays(days)) -} - -/// eg is:due -fn parse_state(s: &str) -> ParseResult> { - use StateKind::*; - Ok(SearchNode::State(match s { - "new" => New, - "review" => Review, - "learn" => Learning, - "due" => Due, - "buried" => Buried, - "buried-manually" => UserBuried, - "buried-sibling" => SchedBuried, - "suspended" => Suspended, - _ => return Err(ParseError {}), - })) -} - -/// flag:0-4 -fn parse_flag(s: &str) -> ParseResult> { - let n: u8 = s.parse()?; - if n > 4 { - Err(ParseError {}) - } else { - Ok(SearchNode::Flag(n)) - } -} - -/// eg rated:3 or rated:10:2 -/// second arg must be between 1-4 -fn parse_rated(val: &str) -> ParseResult> { - let mut it = val.splitn(2, ':'); - - let n: u32 = it.next().unwrap().parse()?; - let days = n.max(1).min(365); - - let ease = match it.next() { - Some(v) => { - let u: u8 = v.parse()?; - if (1..5).contains(&u) { - EaseKind::AnswerButton(u) - } else { - return Err(ParseError {}); - } - } - None => EaseKind::AnyAnswerButton, - }; - - Ok(SearchNode::Rated { days, ease }) -} - -/// eg resched:3 -fn parse_resched(val: &str) -> ParseResult> { - let mut it = val.splitn(1, ':'); - - let n: u32 = it.next().unwrap().parse()?; - let days = n.max(1).min(365); - - let ease = EaseKind::ManualReschedule; - - Ok(SearchNode::Rated { days, ease }) -} - /// eg dupe:1231,hello -fn parse_dupe(val: &str) -> ParseResult { - let mut it = val.splitn(2, ','); - let mid: NoteTypeID = it.next().unwrap().parse()?; - let text = it.next().ok_or(ParseError {})?; - Ok(SearchNode::Duplicates { - note_type_id: mid, - text: unescape_quotes_and_backslashes(text), - }) -} - -/// eg prop:ivl>3, prop:ease!=2.5 -fn parse_prop(val: &str) -> ParseResult> { - let (val, key) = alt(( - tag("ivl"), - tag("due"), - tag("reps"), - tag("lapses"), - tag("ease"), - tag("pos"), - ))(val)?; - - let (val, operator) = alt(( - tag("<="), - tag(">="), - tag("!="), - tag("="), - tag("<"), - tag(">"), - ))(val)?; - - let kind = if key == "ease" { - let num: f32 = val.parse()?; - PropertyKind::Ease(num) - } else if key == "due" { - let num: i32 = val.parse()?; - PropertyKind::Due(num) - } else { - let num: u32 = val.parse()?; - match key { - "ivl" => PropertyKind::Interval(num), - "reps" => PropertyKind::Reps(num), - "lapses" => PropertyKind::Lapses(num), - "pos" => PropertyKind::Position(num), - _ => unreachable!(), +fn parse_dupe(s: &str) -> ParseResult { + let mut it = s.splitn(2, ','); + if let Ok(mid) = it.next().unwrap().parse::() { + if let Some(text) = it.next() { + Ok(SearchNode::Duplicates { + note_type_id: mid, + text: unescape_quotes_and_backslashes(text), + }) + } else { + Err(parse_failure(s, FailKind::InvalidDupeText)) } - }; - - Ok(SearchNode::Property { - operator: operator.to_string(), - kind, - }) + } else { + Err(parse_failure(s, FailKind::InvalidDupeMid)) + } } -fn parse_template(val: &str) -> ParseResult { - Ok(SearchNode::CardTemplate(match val.parse::() { - Ok(n) => TemplateKind::Ordinal(n.max(1) - 1), - Err(_) => TemplateKind::Name(unescape(val)?), - })) -} - -fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult> { +fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode<'a>> { Ok(if let Some(stripped) = val.strip_prefix("re:") { SearchNode::SingleField { field: unescape(key)?, @@ -489,42 +568,45 @@ fn unescape_quotes_and_backslashes(s: &str) -> Cow { /// Unescape chars with special meaning to the parser. fn unescape(txt: &str) -> ParseResult> { - if is_invalid_escape(txt) { - Err(ParseError {}) - } else if is_parser_escape(txt) { - lazy_static! { - static ref RE: Regex = Regex::new(r#"\\[\\":()-]"#).unwrap(); - } - Ok(RE.replace_all(&txt, |caps: &Captures| match &caps[0] { - r"\\" => r"\\", - "\\\"" => "\"", - r"\:" => ":", - r"\(" => "(", - r"\)" => ")", - r"\-" => "-", - _ => unreachable!(), - })) + if let Some(seq) = invalid_escape_sequence(txt) { + Err(parse_failure(txt, FailKind::UnknownEscape(seq))) } else { - Ok(txt.into()) + Ok(if is_parser_escape(txt) { + lazy_static! { + static ref RE: Regex = Regex::new(r#"\\[\\":()-]"#).unwrap(); + } + RE.replace_all(&txt, |caps: &Captures| match &caps[0] { + r"\\" => r"\\", + "\\\"" => "\"", + r"\:" => ":", + r"\(" => "(", + r"\)" => ")", + r"\-" => "-", + _ => unreachable!(), + }) + } else { + txt.into() + }) } } -/// Check string for invalid escape sequences. -fn is_invalid_escape(txt: &str) -> bool { +/// Return invalid escape sequence if any. +fn invalid_escape_sequence(txt: &str) -> Option { // odd number of \s not followed by an escapable character lazy_static! { static ref RE: Regex = Regex::new( r#"(?x) (?:^|[^\\]) # not a backslash (?:\\\\)* # even number of backslashes - \\ # single backslash - (?:[^\\":*_()-]|$) # anything but an escapable char + (\\ # single backslash + (?:[^\\":*_()-]|$)) # anything but an escapable char "# ) .unwrap(); } + let caps = RE.captures(txt)?; - RE.is_match(txt) + Some(caps[1].to_string()) } /// Check string for escape sequences handled by the parser: ":()- @@ -557,11 +639,6 @@ mod test { assert_eq!(parse("")?, vec![Search(SearchNode::WholeCollection)]); assert_eq!(parse(" ")?, vec![Search(SearchNode::WholeCollection)]); - // leading/trailing boolean operators - assert!(parse("foo and").is_err()); - assert!(parse("and foo").is_err()); - assert!(parse("and").is_err()); - // leading/trailing/interspersed whitespace assert_eq!( parse(" t t2 ")?, @@ -622,11 +699,6 @@ mod test { assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:va\"lue"#)?,); - // only \":()-*_ are escapable - assert!(parse(r"\").is_err()); - assert!(parse(r"\a").is_err()); - assert!(parse(r"\%").is_err()); - // parser unescapes ":()- assert_eq!( parse(r#"\"\:\(\)\-"#)?, @@ -641,11 +713,8 @@ mod test { // escaping parentheses is optional (only) inside quotes assert_eq!(parse(r#""\)\(""#), parse(r#"")(""#)); - assert!(parse(")(").is_err()); // escaping : is optional if it is preceded by another : - assert!(parse(":test").is_err()); - assert!(parse(":").is_err()); assert_eq!(parse("field:val:ue"), parse(r"field:val\:ue")); assert_eq!(parse(r#""field:val:ue""#), parse(r"field:val\:ue")); assert_eq!(parse(r#"field:"val:ue""#), parse(r"field:val\:ue")); @@ -667,7 +736,6 @@ mod test { parse(r#"re:te\"st"#)?, vec![Search(Regex(r#"te"st"#.into()))] ); - assert!(parse(r#"re:te"st"#).is_err()); // spaces are optional if node separation is clear assert_eq!(parse(r#"a"b"(c)"#)?, parse("a b (c)")?); @@ -698,11 +766,8 @@ mod test { parse("nid:1237123712,2,3")?, vec![Search(NoteIDs("1237123712,2,3"))] ); - assert!(parse("nid:1237123712_2,3").is_err()); assert_eq!(parse("is:due")?, vec![Search(State(StateKind::Due))]); assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]); - assert!(parse("flag:-1").is_err()); - assert!(parse("flag:5").is_err()); assert_eq!( parse("prop:ivl>3")?, @@ -711,7 +776,6 @@ mod test { kind: PropertyKind::Interval(3) })] ); - assert!(parse("prop:ivl>3.3").is_err()); assert_eq!( parse("prop:ease<=3.3")?, vec![Search(Property { @@ -722,4 +786,131 @@ mod test { Ok(()) } + + #[test] + fn errors() -> Result<()> { + use crate::err::AnkiError; + use FailKind::*; + + fn assert_err_kind(input: &str, kind: FailKind) { + assert_eq!(parse(input), Err(AnkiError::SearchError(kind))); + } + + assert_err_kind("foo and", MisplacedAnd); + assert_err_kind("and foo", MisplacedAnd); + assert_err_kind("and", MisplacedAnd); + + assert_err_kind("foo or", MisplacedOr); + assert_err_kind("or foo", MisplacedOr); + assert_err_kind("or", MisplacedOr); + + assert_err_kind("()", EmptyGroup); + assert_err_kind("( )", EmptyGroup); + assert_err_kind("(foo () bar)", EmptyGroup); + + assert_err_kind(")", UnopenedGroup); + assert_err_kind("foo ) bar", UnopenedGroup); + assert_err_kind("(foo) bar)", UnopenedGroup); + + assert_err_kind("(", UnclosedGroup); + assert_err_kind("foo ( bar", UnclosedGroup); + assert_err_kind("(foo (bar)", UnclosedGroup); + + assert_err_kind(r#""""#, EmptyQuote); + assert_err_kind(r#"foo:"""#, EmptyQuote); + + assert_err_kind(r#" " "#, UnclosedQuote); + assert_err_kind(r#"" foo"#, UnclosedQuote); + assert_err_kind(r#""\"#, UnclosedQuote); + assert_err_kind(r#"foo:"bar"#, UnclosedQuote); + assert_err_kind(r#"foo:"bar\"#, UnclosedQuote); + + assert_err_kind(":", MissingKey); + assert_err_kind(":foo", MissingKey); + assert_err_kind(r#":"foo""#, MissingKey); + + assert_err_kind(r"\", UnknownEscape(r"\".to_string())); + assert_err_kind(r"\%", UnknownEscape(r"\%".to_string())); + assert_err_kind(r"foo\", UnknownEscape(r"\".to_string())); + assert_err_kind(r"\foo", UnknownEscape(r"\f".to_string())); + assert_err_kind(r"\ ", UnknownEscape(r"\".to_string())); + assert_err_kind(r#""\ ""#, UnknownEscape(r"\ ".to_string())); + + assert_err_kind("nid:1_2,3", InvalidIdList); + assert_err_kind("nid:1,2,x", InvalidIdList); + assert_err_kind("nid:,2,3", InvalidIdList); + assert_err_kind("nid:1,2,", InvalidIdList); + assert_err_kind("cid:1_2,3", InvalidIdList); + assert_err_kind("cid:1,2,x", InvalidIdList); + assert_err_kind("cid:,2,3", InvalidIdList); + assert_err_kind("cid:1,2,", InvalidIdList); + + assert_err_kind("is:foo", InvalidState); + assert_err_kind("is:DUE", InvalidState); + assert_err_kind("is:New", InvalidState); + assert_err_kind("is:", InvalidState); + assert_err_kind(r#""is:learn ""#, InvalidState); + + assert_err_kind(r#""flag: ""#, InvalidFlag); + assert_err_kind("flag:-0", InvalidFlag); + assert_err_kind("flag:", InvalidFlag); + assert_err_kind("flag:5", InvalidFlag); + assert_err_kind("flag:1.1", InvalidFlag); + + assert_err_kind("added:1.1", InvalidAdded); + assert_err_kind("added:-1", InvalidAdded); + assert_err_kind("added:", InvalidAdded); + assert_err_kind("added:foo", InvalidAdded); + + assert_err_kind("edited:1.1", InvalidEdited); + assert_err_kind("edited:-1", InvalidEdited); + assert_err_kind("edited:", InvalidEdited); + assert_err_kind("edited:foo", InvalidEdited); + + assert_err_kind("rated:1.1", InvalidRatedDays); + assert_err_kind("rated:-1", InvalidRatedDays); + assert_err_kind("rated:", InvalidRatedDays); + assert_err_kind("rated:foo", InvalidRatedDays); + + assert_err_kind("rated:1:", InvalidRatedEase("1".to_string())); + assert_err_kind("rated:2:-1", InvalidRatedEase("2".to_string())); + assert_err_kind("rated:3:1.1", InvalidRatedEase("3".to_string())); + assert_err_kind("rated:0:foo", InvalidRatedEase("1".to_string())); + + assert_err_kind("resched:", FailKind::InvalidResched); + assert_err_kind("resched:-1", FailKind::InvalidResched); + assert_err_kind("resched:1:1", FailKind::InvalidResched); + assert_err_kind("resched:foo", FailKind::InvalidResched); + + assert_err_kind("dupe:", InvalidDupeMid); + assert_err_kind("dupe:1.1", InvalidDupeMid); + assert_err_kind("dupe:foo", InvalidDupeMid); + + assert_err_kind("dupe:123", InvalidDupeText); + + assert_err_kind("prop:", InvalidPropProperty); + assert_err_kind("prop:=1", InvalidPropProperty); + assert_err_kind("prop:DUE<5", InvalidPropProperty); + + assert_err_kind("prop:lapses", InvalidPropOperator("lapses".to_string())); + assert_err_kind("prop:pos~1", InvalidPropOperator("pos".to_string())); + assert_err_kind("prop:reps10", InvalidPropOperator("reps".to_string())); + + assert_err_kind("prop:ease>", InvalidPropFloat("ease>".to_string())); + assert_err_kind("prop:ease!=one", InvalidPropFloat("ease!=".to_string())); + assert_err_kind("prop:ease<1,3", InvalidPropFloat("ease<".to_string())); + + assert_err_kind("prop:due>", InvalidPropInteger("due>".to_string())); + assert_err_kind("prop:due=0.5", InvalidPropInteger("due=".to_string())); + assert_err_kind("prop:due", InvalidPropUnsigned("ivl>".to_string())); + assert_err_kind("prop:reps=1.1", InvalidPropUnsigned("reps=".to_string())); + assert_err_kind( + "prop:lapses!=-1", + InvalidPropUnsigned("lapses!=".to_string()), + ); + + Ok(()) + } }