mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
commit
65d3a1393c
3 changed files with 636 additions and 298 deletions
|
@ -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-invalid = Invalid search - please check for typing mistakes.
|
||||||
|
search-misplaced-and = Invalid search:<br>An <code>and</code> was found but it is not connecting two search terms.<br>If you want to search for the word itself, wrap it in double quotes: <code>"and"</code>.
|
||||||
|
search-misplaced-or = Invalid search:<br>An <code>or</code> was found but it is not connecting two search terms.<br>If you want to search for the word itself, wrap it in double quotes: <code>"or"</code>.
|
||||||
|
# Here, the ellipsis "..." may be localised.
|
||||||
|
search-empty-group = Invalid search:<br>A group <code>(...)</code> was found but there was nothing between the brackets to search for.<br>If you want to search for literal brackets, wrap them in double quotes: <code>"( )"</code>.
|
||||||
|
search-unopened-group = Invalid search:<br>A closing bracket <code>)</code> was found, but there was no opening bracket <code>(</code> preceding it.<br>If you want to search for the literal <code>)</code>, wrap it in double quotes or prepend a backslash: <code>")"</code> or <code>\)</code>.
|
||||||
|
search-unclosed-group = Invalid search:<br>An opening bracket <code>(</code> was found, but there was no closing bracket <code>)</code> following it.<br>If you want to search for the literal <code>(</code>, wrap it in double quotes or prepend a backslash: <code>"("</code> or <code>\(</code> .
|
||||||
|
search-empty-quote = Invalid search:<br>A pair of double quotes <code>""</code> was found but there was nothing between them to search for.<br>If you want to search for literal double quotes, prepend backslashes: <code>\"\"</code>.
|
||||||
|
search-unclosed-quote = Invalid search:<br>An opening double quote <code>"</code> was found but there was no second one to close it.<br>If you want to search for the literal <code>"</code>, prepend a backslash: <code>\"</code>.
|
||||||
|
search-missing-key = Invalid search:<br>A colon <code>:</code> was found but there was no key word preceding it.<br>If you want to search for the literal <code>:</code>, prepend a backslash: <code>\:</code>.
|
||||||
|
search-unknown-escape = Invalid search:<br>The escape sequence <code>{ $val }</code> is not defined.<br>If you want to search for the literal backslash <code>\</code>, prepend another one: <code>\\</code>.
|
||||||
|
search-invalid-id-list = Invalid search:<br>Note or card id lists must be comma-separated number series.
|
||||||
|
search-invalid-state = Invalid search:<br><code>is:</code> must be followed by one of the predefined card states: <code>new</code>, <code>review</code>, <code>learn</code>, <code>due</code>, <code>buried</code>, <code>buried-manually</code>, <code>buried-sibling</code> or <code>suspended</code>.
|
||||||
|
search-invalid-flag = Invalid search:<br><code>flag:</code> must be followed by a valid flag number: <code>1</code> (red), <code>2</code> (orange), <code>3</code> (green), <code>4</code> (blue) or <code>0</code> (no flag).
|
||||||
|
search-invalid-added = Invalid search:<br><code>added:</code> must be followed by a positive number of days.
|
||||||
|
search-invalid-edited = Invalid search:<br><code>edited:</code> must be followed by a positive number of days.
|
||||||
|
search-invalid-rated-days = Invalid search:<br><code>rated:</code> must be followed by a positive number of days.
|
||||||
|
search-invalid-rated-ease = Invalid search:<br><code>rated:{ $val }:</code> must be followed by <code>1</code> (again), <code>2</code> (hard), <code>3</code> (good) or <code>4</code> (easy).
|
||||||
|
search-invalid-resched = Invalid search:<br><code>resched:</code> must be followed by a positive number of days.
|
||||||
|
search-invalid-dupe-mid = Invalid search:<br><code>dupe:</code> must be followed by a note type id, a comma and then arbitrary text.
|
||||||
|
search-invalid-dupe-text = Invalid search:<br><code>dupe:</code> must be followed by a note type id, a comma and then arbitrary text.
|
||||||
|
search-invalid-prop-property = Invalid search:<br><code>prop:</code> must be followed by one of the predefined card properties: <code>ivl</code> (interval), <code>due</code>, <code>reps</code> (repetitions), <code>lapses</code>, <code>ease</code> or <code>pos</code> (position).
|
||||||
|
search-invalid-prop-operator = Invalid search:<br><code>prop:{ $val }</code> must be followed by one of the comparison operators: <code>=</code>, <code>!=</code>, <code><</code>, <code>></code>, <code><=</code> or <code>>=</code>.
|
||||||
|
search-invalid-prop-float = Invalid search:<br><code>prop:{ $val }</code> must be followed by a decimal number.
|
||||||
|
search-invalid-prop-integer = Invalid search:<br><code>prop:{ $val }</code> must be followed by a whole number.
|
||||||
|
search-invalid-prop-unsigned = Invalid search:<br><code>prop:{ $val }</code> must be followed by a non-negative whole number.
|
||||||
|
search-invalid-did = Invalid search:<br><code>did:</code> must be followed by a valid deck id.
|
||||||
|
search-invalid-mid = Invalid search:<br><code>mid:</code> must be followed by a note type deck id.
|
||||||
|
|
||||||
## Column labels in browse screen
|
## Column labels in browse screen
|
||||||
|
|
||||||
|
|
133
rslib/src/err.rs
133
rslib/src/err.rs
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
use crate::i18n::{tr_args, tr_strs, I18n, TR};
|
use crate::i18n::{tr_args, tr_strs, I18n, TR};
|
||||||
pub use failure::{Error, Fail};
|
pub use failure::{Error, Fail};
|
||||||
|
use nom::error::{ErrorKind as NomErrorKind, ParseError as NomParseError};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use std::{io, str::Utf8Error};
|
use std::{io, str::Utf8Error};
|
||||||
use tempfile::PathPersistError;
|
use tempfile::PathPersistError;
|
||||||
|
@ -60,7 +61,7 @@ pub enum AnkiError {
|
||||||
DeckIsFiltered,
|
DeckIsFiltered,
|
||||||
|
|
||||||
#[fail(display = "Invalid search.")]
|
#[fail(display = "Invalid search.")]
|
||||||
SearchError(Option<String>),
|
SearchError(SearchErrorKind),
|
||||||
}
|
}
|
||||||
|
|
||||||
// error helpers
|
// error helpers
|
||||||
|
@ -122,13 +123,62 @@ impl AnkiError {
|
||||||
DBErrorKind::Locked => "Anki already open, or media currently syncing.".into(),
|
DBErrorKind::Locked => "Anki already open, or media currently syncing.".into(),
|
||||||
_ => format!("{:?}", self),
|
_ => format!("{:?}", self),
|
||||||
},
|
},
|
||||||
AnkiError::SearchError(details) => {
|
AnkiError::SearchError(kind) => match kind {
|
||||||
if let Some(details) = details {
|
SearchErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd),
|
||||||
details.to_owned()
|
SearchErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr),
|
||||||
} else {
|
SearchErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup),
|
||||||
i18n.tr(TR::SearchInvalid).to_string()
|
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),
|
_ => format!("{:?}", self),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -166,7 +216,7 @@ impl From<rusqlite::Error> for AnkiError {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if reason.contains("regex parse error") {
|
if reason.contains("regex parse error") {
|
||||||
return AnkiError::SearchError(Some(reason.to_owned()));
|
return AnkiError::SearchError(SearchErrorKind::Regex(reason.to_owned()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnkiError::DBError {
|
AnkiError::DBError {
|
||||||
|
@ -339,3 +389,70 @@ impl From<PathPersistError> 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<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseError<'_>> for AnkiError {
|
||||||
|
fn from(err: ParseError) -> Self {
|
||||||
|
match err {
|
||||||
|
ParseError::Anki(_, kind) => AnkiError::SearchError(kind),
|
||||||
|
ParseError::Nom(_, _) => AnkiError::SearchError(SearchErrorKind::Other(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nom::Err<ParseError<'_>>> for AnkiError {
|
||||||
|
fn from(err: nom::Err<ParseError<'_>>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
decks::DeckID,
|
decks::DeckID,
|
||||||
err::{AnkiError, Result},
|
err::{ParseError, Result, SearchErrorKind as FailKind},
|
||||||
notetype::NoteTypeID,
|
notetype::NoteTypeID,
|
||||||
};
|
};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
@ -11,36 +11,25 @@ use nom::{
|
||||||
branch::alt,
|
branch::alt,
|
||||||
bytes::complete::{escaped, is_not, tag},
|
bytes::complete::{escaped, is_not, tag},
|
||||||
character::complete::{anychar, char, none_of, one_of},
|
character::complete::{anychar, char, none_of, one_of},
|
||||||
combinator::{all_consuming, map, map_res, verify},
|
combinator::{map, verify},
|
||||||
error::{Error, ErrorKind},
|
error::ErrorKind as NomErrorKind,
|
||||||
sequence::{delimited, preceded, separated_pair},
|
multi::many0,
|
||||||
{multi::many0, IResult},
|
sequence::{preceded, separated_pair},
|
||||||
};
|
};
|
||||||
use regex::{Captures, Regex};
|
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<ParseError<'a>>>;
|
||||||
|
type ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>;
|
||||||
|
|
||||||
impl From<num::ParseIntError> for ParseError {
|
fn parse_failure(input: &str, kind: FailKind) -> nom::Err<ParseError<'_>> {
|
||||||
fn from(_: num::ParseIntError) -> Self {
|
nom::Err::Failure(ParseError::Anki(input, kind))
|
||||||
ParseError {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<num::ParseFloatError> for ParseError {
|
fn parse_error(input: &str) -> nom::Err<ParseError<'_>> {
|
||||||
fn from(_: num::ParseFloatError) -> Self {
|
nom::Err::Error(ParseError::Anki(input, FailKind::Other(None)))
|
||||||
ParseError {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I> From<nom::Err<(I, ErrorKind)>> for ParseError {
|
|
||||||
fn from(_: nom::Err<(I, ErrorKind)>) -> Self {
|
|
||||||
ParseError {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParseResult<T> = std::result::Result<T, ParseError>;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum Node<'a> {
|
pub enum Node<'a> {
|
||||||
And,
|
And,
|
||||||
|
@ -132,20 +121,17 @@ pub(super) fn parse(input: &str) -> Result<Vec<Node>> {
|
||||||
return Ok(vec![Node::Search(SearchNode::WholeCollection)]);
|
return Ok(vec![Node::Search(SearchNode::WholeCollection)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_, nodes) =
|
match group_inner(input) {
|
||||||
all_consuming(group_inner)(input).map_err(|_e| AnkiError::SearchError(None))?;
|
Ok(("", nodes)) => 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)
|
/// Zero or more nodes inside brackets, eg 'one OR two -three'.
|
||||||
fn group(s: &str) -> IResult<&str, Node> {
|
/// Empty vec must be handled by caller.
|
||||||
map(delimited(char('('), group_inner, char(')')), |nodes| {
|
fn group_inner(input: &str) -> IResult<Vec<Node>> {
|
||||||
Node::Group(nodes)
|
|
||||||
})(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One or more nodes inside brackets, er 'one OR two -three'
|
|
||||||
fn group_inner(input: &str) -> IResult<&str, Vec<Node>> {
|
|
||||||
let mut remaining = input;
|
let mut remaining = input;
|
||||||
let mut nodes = vec![];
|
let mut nodes = vec![];
|
||||||
|
|
||||||
|
@ -157,8 +143,10 @@ fn group_inner(input: &str) -> IResult<&str, Vec<Node>> {
|
||||||
if nodes.len() % 2 == 0 {
|
if nodes.len() % 2 == 0 {
|
||||||
// before adding the node, if the length is even then the node
|
// before adding the node, if the length is even then the node
|
||||||
// must not be a boolean
|
// must not be a boolean
|
||||||
if matches!(node, Node::And | Node::Or) {
|
if node == Node::And {
|
||||||
return Err(nom::Err::Failure(Error::new("", ErrorKind::NoneOf)));
|
return Err(parse_failure(input, FailKind::MisplacedAnd));
|
||||||
|
} else if node == Node::Or {
|
||||||
|
return Err(parse_failure(input, FailKind::MisplacedOr));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the length is odd, the next item must be a boolean. if it's
|
// 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<Node>> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if nodes.is_empty() {
|
if let Some(last) = nodes.last() {
|
||||||
Err(nom::Err::Error(Error::new(remaining, ErrorKind::Many1)))
|
match last {
|
||||||
} else if matches!(nodes.last().unwrap(), Node::And | Node::Or) {
|
Node::And => return Err(parse_failure(input, FailKind::MisplacedAnd)),
|
||||||
// no trailing and/or
|
Node::Or => return Err(parse_failure(input, FailKind::MisplacedOr)),
|
||||||
Err(nom::Err::Failure(Error::new("", ErrorKind::NoneOf)))
|
_ => (),
|
||||||
} else {
|
}
|
||||||
// chomp any trailing whitespace
|
|
||||||
let (remaining, _) = whitespace0(remaining)?;
|
|
||||||
|
|
||||||
Ok((remaining, nodes))
|
|
||||||
}
|
}
|
||||||
|
let (remaining, _) = whitespace0(remaining)?;
|
||||||
|
|
||||||
|
Ok((remaining, nodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn whitespace0(s: &str) -> IResult<&str, Vec<char>> {
|
fn whitespace0(s: &str) -> IResult<Vec<char>> {
|
||||||
many0(one_of(" \u{3000}"))(s)
|
many0(one_of(" \u{3000}"))(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional leading space, then a (negated) group or text
|
/// Optional leading space, then a (negated) group or text
|
||||||
fn node(s: &str) -> IResult<&str, Node> {
|
fn node(s: &str) -> IResult<Node> {
|
||||||
preceded(whitespace0, alt((negated_node, group, text)))(s)
|
preceded(whitespace0, alt((negated_node, group, text)))(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn negated_node(s: &str) -> IResult<&str, Node> {
|
fn negated_node(s: &str) -> IResult<Node> {
|
||||||
map(preceded(char('-'), alt((group, text))), |node| {
|
map(preceded(char('-'), alt((group, text))), |node| {
|
||||||
Node::Not(Box::new(node))
|
Node::Not(Box::new(node))
|
||||||
})(s)
|
})(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One or more nodes surrounded by brackets, eg (one OR two)
|
||||||
|
fn group(s: &str) -> IResult<Node> {
|
||||||
|
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
|
/// Either quoted or unquoted text
|
||||||
fn text(s: &str) -> IResult<&str, Node> {
|
fn text(s: &str) -> IResult<Node> {
|
||||||
alt((quoted_term, partially_quoted_term, unquoted_term))(s)
|
alt((quoted_term, partially_quoted_term, unquoted_term))(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quoted text, including the outer double quotes.
|
||||||
|
fn quoted_term(s: &str) -> IResult<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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.
|
/// 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<SearchNode> {
|
fn search_node_for_text(s: &str) -> ParseResult<SearchNode> {
|
||||||
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<ParseError>| parse_failure(s, FailKind::MissingKey))?;
|
||||||
if tail.is_empty() {
|
if tail.is_empty() {
|
||||||
Ok(SearchNode::UnqualifiedText(unescape(head)?))
|
Ok(SearchNode::UnqualifiedText(unescape(head)?))
|
||||||
} else {
|
} else {
|
||||||
|
@ -219,91 +298,210 @@ fn search_node_for_text(s: &str) -> ParseResult<SearchNode> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<Node> {
|
|
||||||
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<Node> {
|
|
||||||
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.
|
/// Convert a colon-separated key/val pair into the relevant search type.
|
||||||
fn search_node_for_text_with_argument<'a>(
|
fn search_node_for_text_with_argument<'a>(
|
||||||
key: &'a str,
|
key: &'a str,
|
||||||
val: &'a str,
|
val: &'a str,
|
||||||
) -> ParseResult<SearchNode<'a>> {
|
) -> ParseResult<'a, SearchNode<'a>> {
|
||||||
Ok(match key.to_ascii_lowercase().as_str() {
|
Ok(match key.to_ascii_lowercase().as_str() {
|
||||||
"added" => parse_added(val)?,
|
|
||||||
"edited" => parse_edited(val)?,
|
|
||||||
"deck" => SearchNode::Deck(unescape(val)?),
|
"deck" => SearchNode::Deck(unescape(val)?),
|
||||||
"note" => SearchNode::NoteType(unescape(val)?),
|
"note" => SearchNode::NoteType(unescape(val)?),
|
||||||
"tag" => SearchNode::Tag(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)?,
|
"card" => parse_template(val)?,
|
||||||
"is" => parse_state(val)?,
|
|
||||||
"flag" => parse_flag(val)?,
|
"flag" => parse_flag(val)?,
|
||||||
"rated" => parse_rated(val)?,
|
|
||||||
"dupe" => parse_dupe(val)?,
|
|
||||||
"resched" => parse_resched(val)?,
|
"resched" => parse_resched(val)?,
|
||||||
"prop" => parse_prop(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)),
|
"re" => SearchNode::Regex(unescape_quotes(val)),
|
||||||
"nc" => SearchNode::NoCombining(unescape(val)?),
|
"nc" => SearchNode::NoCombining(unescape(val)?),
|
||||||
"w" => SearchNode::WordBoundary(unescape(val)?),
|
"w" => SearchNode::WordBoundary(unescape(val)?),
|
||||||
|
"dupe" => parse_dupe(val)?,
|
||||||
// anything else is a field search
|
// anything else is a field search
|
||||||
_ => parse_single_field(key, val)?,
|
_ => parse_single_field(key, val)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_template(s: &str) -> ParseResult<SearchNode> {
|
||||||
|
Ok(SearchNode::CardTemplate(match s.parse::<u16>() {
|
||||||
|
Ok(n) => TemplateKind::Ordinal(n.max(1) - 1),
|
||||||
|
Err(_) => TemplateKind::Name(unescape(s)?),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flag:0-4
|
||||||
|
fn parse_flag(s: &str) -> ParseResult<SearchNode> {
|
||||||
|
if let Ok(flag) = s.parse::<u8>() {
|
||||||
|
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<SearchNode> {
|
||||||
|
if let Ok(d) = s.parse::<u32>() {
|
||||||
|
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<SearchNode> {
|
||||||
|
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::<f32>() {
|
||||||
|
PropertyKind::Ease(f)
|
||||||
|
} else {
|
||||||
|
return Err(parse_failure(
|
||||||
|
s,
|
||||||
|
FailKind::InvalidPropFloat(format!("{}{}", prop, operator)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if prop == "due" {
|
||||||
|
if let Ok(i) = num.parse::<i32>() {
|
||||||
|
PropertyKind::Due(i)
|
||||||
|
} else {
|
||||||
|
return Err(parse_failure(
|
||||||
|
s,
|
||||||
|
FailKind::InvalidPropInteger(format!("{}{}", prop, operator)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if let Ok(u) = num.parse::<u32>() {
|
||||||
|
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<SearchNode> {
|
||||||
|
if let Ok(days) = s.parse::<u32>() {
|
||||||
|
Ok(SearchNode::AddedInDays(days.max(1)))
|
||||||
|
} else {
|
||||||
|
Err(parse_failure(s, FailKind::InvalidAdded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// eg edited:1
|
||||||
|
fn parse_edited(s: &str) -> ParseResult<SearchNode> {
|
||||||
|
if let Ok(days) = s.parse::<u32>() {
|
||||||
|
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<SearchNode> {
|
||||||
|
let mut it = s.splitn(2, ':');
|
||||||
|
if let Ok(d) = it.next().unwrap().parse::<u32>() {
|
||||||
|
let days = d.max(1).min(365);
|
||||||
|
let ease = if let Some(tail) = it.next() {
|
||||||
|
if let Ok(u) = tail.parse::<u8>() {
|
||||||
|
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<SearchNode> {
|
||||||
|
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<SearchNode> {
|
||||||
|
if let Ok(did) = s.parse() {
|
||||||
|
Ok(SearchNode::DeckID(did))
|
||||||
|
} else {
|
||||||
|
Err(parse_failure(s, FailKind::InvalidDid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mid(s: &str) -> ParseResult<SearchNode> {
|
||||||
|
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
|
/// ensure a list of ids contains only numbers and commas, returning unchanged if true
|
||||||
/// used by nid: and cid:
|
/// used by nid: and cid:
|
||||||
fn check_id_list(s: &str) -> ParseResult<&str> {
|
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) {
|
if RE.is_match(s) {
|
||||||
Ok(s)
|
Ok(s)
|
||||||
} else {
|
} else {
|
||||||
Err(ParseError {})
|
Err(parse_failure(s, FailKind::InvalidIdList))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// eg added:1
|
|
||||||
fn parse_added(s: &str) -> ParseResult<SearchNode<'static>> {
|
|
||||||
let n: u32 = s.parse()?;
|
|
||||||
let days = n.max(1);
|
|
||||||
Ok(SearchNode::AddedInDays(days))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// eg edited:1
|
|
||||||
fn parse_edited(s: &str) -> ParseResult<SearchNode<'static>> {
|
|
||||||
let n: u32 = s.parse()?;
|
|
||||||
let days = n.max(1);
|
|
||||||
Ok(SearchNode::EditedInDays(days))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// eg is:due
|
|
||||||
fn parse_state(s: &str) -> ParseResult<SearchNode<'static>> {
|
|
||||||
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<SearchNode<'static>> {
|
|
||||||
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<SearchNode<'static>> {
|
|
||||||
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<SearchNode<'static>> {
|
|
||||||
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
|
/// eg dupe:1231,hello
|
||||||
fn parse_dupe(val: &str) -> ParseResult<SearchNode> {
|
fn parse_dupe(s: &str) -> ParseResult<SearchNode> {
|
||||||
let mut it = val.splitn(2, ',');
|
let mut it = s.splitn(2, ',');
|
||||||
let mid: NoteTypeID = it.next().unwrap().parse()?;
|
if let Ok(mid) = it.next().unwrap().parse::<NoteTypeID>() {
|
||||||
let text = it.next().ok_or(ParseError {})?;
|
if let Some(text) = it.next() {
|
||||||
Ok(SearchNode::Duplicates {
|
Ok(SearchNode::Duplicates {
|
||||||
note_type_id: mid,
|
note_type_id: mid,
|
||||||
text: unescape_quotes_and_backslashes(text),
|
text: unescape_quotes_and_backslashes(text),
|
||||||
})
|
})
|
||||||
}
|
} else {
|
||||||
|
Err(parse_failure(s, FailKind::InvalidDupeText))
|
||||||
/// eg prop:ivl>3, prop:ease!=2.5
|
|
||||||
fn parse_prop(val: &str) -> ParseResult<SearchNode<'static>> {
|
|
||||||
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!(),
|
|
||||||
}
|
}
|
||||||
};
|
} else {
|
||||||
|
Err(parse_failure(s, FailKind::InvalidDupeMid))
|
||||||
Ok(SearchNode::Property {
|
}
|
||||||
operator: operator.to_string(),
|
|
||||||
kind,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_template(val: &str) -> ParseResult<SearchNode> {
|
fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode<'a>> {
|
||||||
Ok(SearchNode::CardTemplate(match val.parse::<u16>() {
|
|
||||||
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<SearchNode<'a>> {
|
|
||||||
Ok(if let Some(stripped) = val.strip_prefix("re:") {
|
Ok(if let Some(stripped) = val.strip_prefix("re:") {
|
||||||
SearchNode::SingleField {
|
SearchNode::SingleField {
|
||||||
field: unescape(key)?,
|
field: unescape(key)?,
|
||||||
|
@ -489,42 +568,45 @@ fn unescape_quotes_and_backslashes(s: &str) -> Cow<str> {
|
||||||
|
|
||||||
/// Unescape chars with special meaning to the parser.
|
/// Unescape chars with special meaning to the parser.
|
||||||
fn unescape(txt: &str) -> ParseResult<Cow<str>> {
|
fn unescape(txt: &str) -> ParseResult<Cow<str>> {
|
||||||
if is_invalid_escape(txt) {
|
if let Some(seq) = invalid_escape_sequence(txt) {
|
||||||
Err(ParseError {})
|
Err(parse_failure(txt, FailKind::UnknownEscape(seq)))
|
||||||
} 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!(),
|
|
||||||
}))
|
|
||||||
} else {
|
} 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.
|
/// Return invalid escape sequence if any.
|
||||||
fn is_invalid_escape(txt: &str) -> bool {
|
fn invalid_escape_sequence(txt: &str) -> Option<String> {
|
||||||
// odd number of \s not followed by an escapable character
|
// odd number of \s not followed by an escapable character
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref RE: Regex = Regex::new(
|
static ref RE: Regex = Regex::new(
|
||||||
r#"(?x)
|
r#"(?x)
|
||||||
(?:^|[^\\]) # not a backslash
|
(?:^|[^\\]) # not a backslash
|
||||||
(?:\\\\)* # even number of backslashes
|
(?:\\\\)* # even number of backslashes
|
||||||
\\ # single backslash
|
(\\ # single backslash
|
||||||
(?:[^\\":*_()-]|$) # anything but an escapable char
|
(?:[^\\":*_()-]|$)) # anything but an escapable char
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
let caps = RE.captures(txt)?;
|
||||||
|
|
||||||
RE.is_match(txt)
|
Some(caps[1].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check string for escape sequences handled by the parser: ":()-
|
/// 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)]);
|
||||||
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
|
// leading/trailing/interspersed whitespace
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse(" t t2 ")?,
|
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""#)?,);
|
||||||
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 ":()-
|
// parser unescapes ":()-
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse(r#"\"\:\(\)\-"#)?,
|
parse(r#"\"\:\(\)\-"#)?,
|
||||||
|
@ -641,11 +713,8 @@ mod test {
|
||||||
|
|
||||||
// escaping parentheses is optional (only) inside quotes
|
// escaping parentheses is optional (only) inside quotes
|
||||||
assert_eq!(parse(r#""\)\(""#), parse(r#"")(""#));
|
assert_eq!(parse(r#""\)\(""#), parse(r#"")(""#));
|
||||||
assert!(parse(")(").is_err());
|
|
||||||
|
|
||||||
// escaping : is optional if it is preceded by another :
|
// 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("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"));
|
||||||
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"#)?,
|
parse(r#"re:te\"st"#)?,
|
||||||
vec![Search(Regex(r#"te"st"#.into()))]
|
vec![Search(Regex(r#"te"st"#.into()))]
|
||||||
);
|
);
|
||||||
assert!(parse(r#"re:te"st"#).is_err());
|
|
||||||
|
|
||||||
// spaces are optional if node separation is clear
|
// spaces are optional if node separation is clear
|
||||||
assert_eq!(parse(r#"a"b"(c)"#)?, parse("a b (c)")?);
|
assert_eq!(parse(r#"a"b"(c)"#)?, parse("a b (c)")?);
|
||||||
|
@ -698,11 +766,8 @@ mod test {
|
||||||
parse("nid:1237123712,2,3")?,
|
parse("nid:1237123712,2,3")?,
|
||||||
vec![Search(NoteIDs("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("is:due")?, vec![Search(State(StateKind::Due))]);
|
||||||
assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]);
|
assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]);
|
||||||
assert!(parse("flag:-1").is_err());
|
|
||||||
assert!(parse("flag:5").is_err());
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("prop:ivl>3")?,
|
parse("prop:ivl>3")?,
|
||||||
|
@ -711,7 +776,6 @@ mod test {
|
||||||
kind: PropertyKind::Interval(3)
|
kind: PropertyKind::Interval(3)
|
||||||
})]
|
})]
|
||||||
);
|
);
|
||||||
assert!(parse("prop:ivl>3.3").is_err());
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("prop:ease<=3.3")?,
|
parse("prop:ease<=3.3")?,
|
||||||
vec![Search(Property {
|
vec![Search(Property {
|
||||||
|
@ -722,4 +786,131 @@ mod test {
|
||||||
|
|
||||||
Ok(())
|
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<foo", InvalidPropInteger("due<".to_string()));
|
||||||
|
|
||||||
|
assert_err_kind("prop:ivl>", 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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue