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(())
+ }
}