From d00c54aacf783bce3bda35f54803b680e6cca510 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 12 Jan 2021 10:43:12 +0100 Subject: [PATCH 01/15] Switch parser to custom erros and results --- rslib/src/search/parser.rs | 585 +++++++++++++++++++++---------------- 1 file changed, 332 insertions(+), 253 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 6274914eb..d0e6d70f7 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -9,38 +9,60 @@ use crate::{ use lazy_static::lazy_static; use nom::{ branch::alt, - bytes::complete::{escaped, is_not, tag}, + bytes::complete::{escaped, is_not, tag, tag_no_case}, character::complete::{anychar, char, none_of, one_of}, - combinator::{all_consuming, map, map_res, verify}, - error::{Error, ErrorKind}, + combinator::{all_consuming, map, map_parser, verify}, + error::{ErrorKind as NomErrorKind, ParseError as NomParseError}, + multi::many0, sequence::{delimited, preceded, separated_pair}, - {multi::many0, IResult}, + Err::Failure, }; use regex::{Captures, Regex}; -use std::{borrow::Cow, num}; +use std::borrow::Cow; -struct ParseError {} - -impl From for ParseError { - fn from(_: num::ParseIntError) -> Self { - ParseError {} - } +#[derive(Debug)] +enum ParseError<'a> { + Anki(&'a str, ErrorKind), + Nom(&'a str, NomErrorKind), } -impl From for ParseError { - fn from(_: num::ParseFloatError) -> Self { - ParseError {} - } +#[derive(Debug)] +enum ErrorKind { + MisplacedAnd, + MisplacedOr, + EmptyGroup, + UnknownEscape(String), + InvalidIdList, + InvalidState, + InvalidFlag, + InvalidAdded, + InvalidEdited, + InvalidRatedDays, + InvalidRatedEase, + InvalidDupesMid, + InvalidDupesText, + InvalidPropProperty, + InvalidPropOperator, + InvalidPropFloat, + InvalidPropInteger, + InvalidPropUnsigned, + InvalidDid, + InvalidMid, } -impl From> for ParseError { - fn from(_: nom::Err<(I, ErrorKind)>) -> Self { - ParseError {} +type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; +type ParseResult<'a, O> = std::result::Result>>; + +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 } } -type ParseResult = std::result::Result; - #[derive(Debug, PartialEq)] pub enum Node<'a> { And, @@ -126,19 +148,12 @@ pub(super) fn parse(input: &str) -> Result> { } let (_, nodes) = - all_consuming(group_inner)(input).map_err(|_e| AnkiError::SearchError(None))?; + all_consuming(group_inner)(input).map_err(|e| { dbg!(e); AnkiError::SearchError(None) })?; Ok(nodes) } -/// 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> { +fn group_inner(input: &str) -> IResult> { let mut remaining = input; let mut nodes = vec![]; @@ -150,8 +165,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(Failure(ParseError::Anki(input, ErrorKind::MisplacedAnd))); + } else if node == Node::Or { + return Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedOr))); } } else { // if the length is odd, the next item must be a boolean. if it's @@ -164,16 +181,18 @@ fn group_inner(input: &str) -> IResult<&str, Vec> { } Err(e) => match e { nom::Err::Error(_) => break, + // fixme: add context to failure _ => return Err(e), }, }; } 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))) + Err(Failure(ParseError::Anki(input, ErrorKind::EmptyGroup))) + } else if nodes.last().unwrap() == &Node::And { + Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedAnd))) + } else if nodes.last().unwrap() == &Node::Or { + Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedOr))) } else { // chomp any trailing whitespace let (remaining, _) = whitespace0(remaining)?; @@ -182,120 +201,281 @@ fn group_inner(input: &str) -> IResult<&str, Vec> { } } -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 { + map(delimited(char('('), group_inner, char(')')), |nodes| { + Node::Group(nodes) + })(s) +} + /// 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) } -/// Determine if text is a qualified search, and handle escaped chars. -fn search_node_for_text(s: &str) -> ParseResult { - let (tail, head) = escaped(is_not(r":\"), '\\', anychar)(s)?; - if tail.is_empty() { - Ok(SearchNode::UnqualifiedText(unescape(head)?)) - } else { - search_node_for_text_with_argument(head, &tail[1..]) - } +/// Quoted text, including the outer double quotes. +fn quoted_term(s: &str) -> IResult { + map_parser(quoted_term_str, map(search_node_for_text, Node::Search))(s) +} + +/// eg deck:"foo bar" - quotes must come after the : +fn partially_quoted_term(s: &str) -> IResult { + let (remaining, (key, val)) = separated_pair( + verify( + escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(": \u{3000}")), + |s: &str| !s.is_empty(), + ), + char(':'), + quoted_term_str, + )(s)?; + let (_, node) = search_node_for_text_with_argument(key, val)?; + + Ok((remaining, Node::Search(node))) } /// Unquoted text, terminated by whitespace or unescaped ", ( or ) -fn unquoted_term(s: &str) -> IResult<&str, Node> { - map_res( +fn unquoted_term(s: &str) -> IResult { + map_parser( 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)?) - }) - }, + alt(( + map(all_consuming(tag_no_case("and")), |_| Node::And), + map(all_consuming(tag_no_case("or")), |_| Node::Or), + map(search_node_for_text, Node::Search), + )), )(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) +/// Non-empty string delimited by unescaped double quotes. +fn quoted_term_str(s: &str) -> IResult<&str> { + unempty(delimited( + char('"'), + escaped(is_not(r#""\"#), '\\', anychar), + char('"'), + )(s)) } -fn quoted_term_str(s: &str) -> IResult<&str, &str> { - delimited(char('"'), quoted_term_inner, char('"'))(s) +fn unempty<'a>(res: IResult<'a, &'a str>) -> IResult<'a, &'a str> { + if let Ok((_, parsed)) = res { + if parsed.is_empty() { + Err(nom::Err::Failure(ParseError::Anki( + "", + ErrorKind::EmptyGroup, + ))) + } else { + res + } + } else { + res + } } -/// 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) +/// Determine if text is a qualified search, and handle escaped chars. +fn search_node_for_text(s: &str) -> IResult { + let (tail, head) = escaped(is_not(r":\"), '\\', anychar)(s)?; + if tail.is_empty() { + Ok(("", SearchNode::UnqualifiedText(unescape(head)?))) + } else { + search_node_for_text_with_argument(head, &tail[1..]) + } } /// 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> { - 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_dupes(val)?, - "prop" => parse_prop(val)?, - "re" => SearchNode::Regex(unescape_quotes(val)), - "nc" => SearchNode::NoCombining(unescape(val)?), - "w" => SearchNode::WordBoundary(unescape(val)?), - // anything else is a field search - _ => parse_single_field(key, val)?, +) -> IResult<'a, SearchNode<'a>> { + Ok(( + "", + match key.to_ascii_lowercase().as_str() { + "deck" => SearchNode::Deck(unescape(val)?), + "note" => SearchNode::NoteType(unescape(val)?), + "tag" => SearchNode::Tag(unescape(val)?), + "card" => parse_template(val)?, + "flag" => parse_flag(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_dupes(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(Failure(ParseError::Anki(s, ErrorKind::InvalidFlag))) + } else { + Ok(SearchNode::Flag(flag)) + } + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::InvalidEdited))) + } +} + +/// eg prop:ivl>3, prop:ease!=2.5 +fn parse_prop(s: &str) -> ParseResult> { + let (tail, prop) = alt::<&str, &str, ParseError, _>(( + tag("ivl"), + tag("due"), + tag("reps"), + tag("lapses"), + tag("ease"), + tag("pos"), + ))(s) + .map_err(|_| Failure(ParseError::Anki(s, ErrorKind::InvalidPropProperty)))?; + + let (num, operator) = alt::<&str, &str, ParseError, _>(( + tag("<="), + tag(">="), + tag("!="), + tag("="), + tag("<"), + tag(">"), + ))(tail) + .map_err(|_| Failure(ParseError::Anki(s, ErrorKind::InvalidPropOperator)))?; + + let kind = if prop == "ease" { + if let Ok(f) = num.parse::() { + PropertyKind::Ease(f) + } else { + return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidPropFloat))); + } + } else if prop == "due" { + if let Ok(i) = num.parse::() { + PropertyKind::Due(i) + } else { + return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidPropInteger))); + } + } 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(Failure(ParseError::Anki(s, ErrorKind::InvalidPropUnsigned))); + }; + + 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(Failure(ParseError::Anki(s, ErrorKind::InvalidAdded))) + } +} + +/// eg edited:1 +fn parse_edited(s: &str) -> ParseResult { + if let Ok(days) = s.parse::() { + Ok(SearchNode::EditedInDays(days.max(1))) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::InvalidEdited))) + } +} + +/// eg rated:3 or rated:10:2 +/// second arg must be between 0-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 < 5 { + Some(u) + } else { + return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidRatedEase))); + } + } else { + return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidRatedEase))); + } + } else { + None + }; + Ok(SearchNode::Rated { days, ease }) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::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(Failure(ParseError::Anki(s, ErrorKind::InvalidState))), + })) +} + +fn parse_did(s: &str) -> ParseResult { + if let Ok(did) = s.parse() { + Ok(SearchNode::DeckID(did)) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDid))) + } +} + +fn parse_mid(s: &str) -> ParseResult { + if let Ok(mid) = s.parse() { + Ok(SearchNode::NoteTypeID(mid)) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::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> { @@ -305,135 +485,28 @@ fn check_id_list(s: &str) -> ParseResult<&str> { if RE.is_match(s) { Ok(s) } else { - Err(ParseError {}) + Err(Failure(ParseError::Anki(s, ErrorKind::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 0-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 n: u8 = v.parse()?; - if n < 5 { - Some(n) - } else { - return Err(ParseError {}); - } - } - None => None, - }; - - Ok(SearchNode::Rated { days, ease }) -} - /// eg dupes:1231,hello -fn parse_dupes(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(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_dupes(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(text), + }) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDupesText))) } - }; - - Ok(SearchNode::Property { - operator: operator.to_string(), - kind, - }) + } else { + Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDupesMid))) + } } -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)?, @@ -460,42 +533,48 @@ fn unescape_quotes(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(Failure(ParseError::Anki( + txt, + ErrorKind::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: ":()- From b89381ac95215e4548310bc02025a50a1ddb0307 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 12 Jan 2021 17:32:26 +0100 Subject: [PATCH 02/15] Fix top-level search errorkinds --- rslib/src/search/parser.rs | 174 +++++++++++++++++++++---------------- 1 file changed, 101 insertions(+), 73 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index d0e6d70f7..d540e078d 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -9,13 +9,13 @@ use crate::{ use lazy_static::lazy_static; use nom::{ branch::alt, - bytes::complete::{escaped, is_not, tag, tag_no_case}, + bytes::complete::{escaped, is_not, tag}, character::complete::{anychar, char, none_of, one_of}, - combinator::{all_consuming, map, map_parser, verify}, + combinator::{all_consuming, map, verify}, error::{ErrorKind as NomErrorKind, ParseError as NomParseError}, multi::many0, sequence::{delimited, preceded, separated_pair}, - Err::Failure, + Err::{Error, Failure}, }; use regex::{Captures, Regex}; use std::borrow::Cow; @@ -31,6 +31,9 @@ enum ErrorKind { MisplacedAnd, MisplacedOr, EmptyGroup, + EmptyQuote, + UnclosedQuote, + MissingKey, UnknownEscape(String), InvalidIdList, InvalidState, @@ -147,8 +150,10 @@ pub(super) fn parse(input: &str) -> Result> { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); } - let (_, nodes) = - all_consuming(group_inner)(input).map_err(|e| { dbg!(e); AnkiError::SearchError(None) })?; + let (_, nodes) = all_consuming(group_inner)(input).map_err(|e| { + dbg!(e); + AnkiError::SearchError(None) + })?; Ok(nodes) } @@ -218,9 +223,7 @@ fn negated_node(s: &str) -> IResult { /// One or more nodes surrounded by brackets, eg (one OR two) fn group(s: &str) -> IResult { - map(delimited(char('('), group_inner, char(')')), |nodes| { - Node::Group(nodes) - })(s) + map(delimited(char('('), group_inner, char(')')), Node::Group)(s) } /// Either quoted or unquoted text @@ -230,70 +233,98 @@ fn text(s: &str) -> IResult { /// Quoted text, including the outer double quotes. fn quoted_term(s: &str) -> IResult { - map_parser(quoted_term_str, map(search_node_for_text, Node::Search))(s) + 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( - verify( - escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(": \u{3000}")), - |s: &str| !s.is_empty(), - ), + escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(": \u{3000}")), char(':'), quoted_term_str, )(s)?; - let (_, node) = search_node_for_text_with_argument(key, val)?; - - Ok((remaining, Node::Search(node))) + if key.is_empty() { + Err(nom::Err::Failure(ParseError::Anki( + s, + ErrorKind::MissingKey, + ))) + } else { + 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 { - map_parser( - verify( - escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}")), - |s: &str| !s.is_empty(), - ), - alt(( - map(all_consuming(tag_no_case("and")), |_| Node::And), - map(all_consuming(tag_no_case("or")), |_| Node::Or), - map(search_node_for_text, Node::Search), - )), + if let Ok((tail, term)) = verify::<_, _, _, ParseError, _, _>( + escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}")), + |s: &str| !s.is_empty(), )(s) + { + if tail.starts_with('\\') { + let escaped = (if tail.len() > 1 { &tail[0..2] } else { "" }).to_string(); + Err(Failure(ParseError::Anki( + s, + ErrorKind::UnknownEscape(format!("\\{}", escaped)), + ))) + } 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)?))) + } + } else if s.starts_with('\\') { + let escaped = (if s.len() > 1 { &s[0..2] } else { "" }).to_string(); + Err(Failure(ParseError::Anki( + s, + ErrorKind::UnknownEscape(format!("\\{}", escaped)), + ))) + } else { + Err(Error(ParseError::Nom(s, NomErrorKind::Verify))) + } } /// Non-empty string delimited by unescaped double quotes. fn quoted_term_str(s: &str) -> IResult<&str> { - unempty(delimited( - char('"'), - escaped(is_not(r#""\"#), '\\', anychar), - char('"'), - )(s)) -} - -fn unempty<'a>(res: IResult<'a, &'a str>) -> IResult<'a, &'a str> { - if let Ok((_, parsed)) = res { - if parsed.is_empty() { - Err(nom::Err::Failure(ParseError::Anki( - "", - ErrorKind::EmptyGroup, - ))) + let (opened, _) = char('"')(s)?; + if let Ok((tail, inner)) = + escaped::<_, ParseError, _, _, _, _>(is_not(r#""\"#), '\\', anychar)(opened) + { + if tail.is_empty() { + Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))) + } else if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { + Ok((remaining, inner)) } else { - res + Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))) } } else { - res + match opened.chars().next().unwrap() { + '"' => Err(Failure(ParseError::Anki(s, ErrorKind::EmptyQuote))), + // '\' followed by nothing + '\\' => Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))), + // everything else is accepted by escaped + _ => unreachable!(), + } } } /// Determine if text is a qualified search, and handle escaped chars. -fn search_node_for_text(s: &str) -> IResult { - let (tail, head) = escaped(is_not(r":\"), '\\', anychar)(s)?; - if tail.is_empty() { - Ok(("", SearchNode::UnqualifiedText(unescape(head)?))) +fn search_node_for_text(s: &str) -> ParseResult { + if let Ok((tail, head)) = escaped::<_, ParseError, _, _, _, _>(is_not(r":\"), '\\', anychar)(s) + { + if tail.is_empty() { + Ok(SearchNode::UnqualifiedText(unescape(head)?)) + } else { + search_node_for_text_with_argument(head, &tail[1..]) + } } else { - search_node_for_text_with_argument(head, &tail[1..]) + // escaped only fails on "\" and leading ':' + // "\" cannot be passed as an argument by a calling parser + Err(Failure(ParseError::Anki(s, ErrorKind::MissingKey))) } } @@ -301,32 +332,29 @@ fn search_node_for_text(s: &str) -> IResult { fn search_node_for_text_with_argument<'a>( key: &'a str, val: &'a str, -) -> IResult<'a, SearchNode<'a>> { - Ok(( - "", - match key.to_ascii_lowercase().as_str() { - "deck" => SearchNode::Deck(unescape(val)?), - "note" => SearchNode::NoteType(unescape(val)?), - "tag" => SearchNode::Tag(unescape(val)?), - "card" => parse_template(val)?, - "flag" => parse_flag(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_dupes(val)?, - // anything else is a field search - _ => parse_single_field(key, val)?, - }, - )) +) -> ParseResult<'a, SearchNode<'a>> { + Ok(match key.to_ascii_lowercase().as_str() { + "deck" => SearchNode::Deck(unescape(val)?), + "note" => SearchNode::NoteType(unescape(val)?), + "tag" => SearchNode::Tag(unescape(val)?), + "card" => parse_template(val)?, + "flag" => parse_flag(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_dupes(val)?, + // anything else is a field search + _ => parse_single_field(key, val)?, + }) } fn parse_template(s: &str) -> ParseResult { From 447ff6931c547e0759ec1c0c978055e1f9b2a084 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 13 Jan 2021 13:23:25 +0100 Subject: [PATCH 03/15] Move parse errors, add helper func for parse fail --- rslib/src/err.rs | 44 +++++++++++++ rslib/src/search/parser.rs | 127 +++++++++++-------------------------- 2 files changed, 82 insertions(+), 89 deletions(-) diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 87a1f7838..e5b309c71 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}; @@ -327,3 +328,46 @@ pub enum DBErrorKind { Utf8, Other, } + +#[derive(Debug, PartialEq)] +pub enum ParseError<'a> { + Anki(&'a str, ParseErrorKind), + Nom(&'a str, NomErrorKind), +} + +#[derive(Debug, PartialEq)] +pub enum ParseErrorKind { + MisplacedAnd, + MisplacedOr, + EmptyGroup, + EmptyQuote, + UnclosedQuote, + MissingKey, + UnknownEscape(String), + InvalidIdList, + InvalidState, + InvalidFlag, + InvalidAdded, + InvalidEdited, + InvalidRatedDays, + InvalidRatedEase, + InvalidDupesMid, + InvalidDupesText, + InvalidPropProperty, + InvalidPropOperator, + InvalidPropFloat, + InvalidPropInteger, + InvalidPropUnsigned, + InvalidDid, + InvalidMid, +} + +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 d540e078d..286ccf8e0 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::{AnkiError, ParseError, ParseErrorKind, Result}, notetype::NoteTypeID, }; use lazy_static::lazy_static; @@ -12,58 +12,19 @@ use nom::{ bytes::complete::{escaped, is_not, tag}, character::complete::{anychar, char, none_of, one_of}, combinator::{all_consuming, map, verify}, - error::{ErrorKind as NomErrorKind, ParseError as NomParseError}, + error::ErrorKind as NomErrorKind, multi::many0, sequence::{delimited, preceded, separated_pair}, - Err::{Error, Failure}, }; use regex::{Captures, Regex}; use std::borrow::Cow; - -#[derive(Debug)] -enum ParseError<'a> { - Anki(&'a str, ErrorKind), - Nom(&'a str, NomErrorKind), -} - -#[derive(Debug)] -enum ErrorKind { - MisplacedAnd, - MisplacedOr, - EmptyGroup, - EmptyQuote, - UnclosedQuote, - MissingKey, - UnknownEscape(String), - InvalidIdList, - InvalidState, - InvalidFlag, - InvalidAdded, - InvalidEdited, - InvalidRatedDays, - InvalidRatedEase, - InvalidDupesMid, - InvalidDupesText, - InvalidPropProperty, - InvalidPropOperator, - InvalidPropFloat, - InvalidPropInteger, - InvalidPropUnsigned, - InvalidDid, - InvalidMid, -} +use ParseErrorKind::*; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; type ParseResult<'a, O> = std::result::Result>>; -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 - } +fn parse_failure(input: &str, kind: ParseErrorKind) -> nom::Err> { + nom::Err::Failure(ParseError::Anki(input, kind)) } #[derive(Debug, PartialEq)] @@ -171,9 +132,9 @@ fn group_inner(input: &str) -> IResult> { // before adding the node, if the length is even then the node // must not be a boolean if node == Node::And { - return Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedAnd))); + return Err(parse_failure(input, MisplacedAnd)); } else if node == Node::Or { - return Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedOr))); + return Err(parse_failure(input, MisplacedOr)); } } else { // if the length is odd, the next item must be a boolean. if it's @@ -193,11 +154,11 @@ fn group_inner(input: &str) -> IResult> { } if nodes.is_empty() { - Err(Failure(ParseError::Anki(input, ErrorKind::EmptyGroup))) + Err(parse_failure(input, EmptyGroup)) } else if nodes.last().unwrap() == &Node::And { - Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedAnd))) + Err(parse_failure(input, MisplacedAnd)) } else if nodes.last().unwrap() == &Node::Or { - Err(Failure(ParseError::Anki(input, ErrorKind::MisplacedOr))) + Err(parse_failure(input, MisplacedOr)) } else { // chomp any trailing whitespace let (remaining, _) = whitespace0(remaining)?; @@ -245,10 +206,7 @@ fn partially_quoted_term(s: &str) -> IResult { quoted_term_str, )(s)?; if key.is_empty() { - Err(nom::Err::Failure(ParseError::Anki( - s, - ErrorKind::MissingKey, - ))) + Err(parse_failure(s, MissingKey)) } else { Ok(( remaining, @@ -266,10 +224,7 @@ fn unquoted_term(s: &str) -> IResult { { if tail.starts_with('\\') { let escaped = (if tail.len() > 1 { &tail[0..2] } else { "" }).to_string(); - Err(Failure(ParseError::Anki( - s, - ErrorKind::UnknownEscape(format!("\\{}", escaped)), - ))) + Err(parse_failure(s, UnknownEscape(format!("\\{}", escaped)))) } else if term.eq_ignore_ascii_case("and") { Ok((tail, Node::And)) } else if term.eq_ignore_ascii_case("or") { @@ -279,12 +234,9 @@ fn unquoted_term(s: &str) -> IResult { } } else if s.starts_with('\\') { let escaped = (if s.len() > 1 { &s[0..2] } else { "" }).to_string(); - Err(Failure(ParseError::Anki( - s, - ErrorKind::UnknownEscape(format!("\\{}", escaped)), - ))) + Err(parse_failure(s, UnknownEscape(format!("\\{}", escaped)))) } else { - Err(Error(ParseError::Nom(s, NomErrorKind::Verify))) + Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::Verify))) } } @@ -295,17 +247,17 @@ fn quoted_term_str(s: &str) -> IResult<&str> { escaped::<_, ParseError, _, _, _, _>(is_not(r#""\"#), '\\', anychar)(opened) { if tail.is_empty() { - Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))) + Err(parse_failure(s, UnclosedQuote)) } else if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { Ok((remaining, inner)) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))) + Err(parse_failure(s, UnclosedQuote)) } } else { match opened.chars().next().unwrap() { - '"' => Err(Failure(ParseError::Anki(s, ErrorKind::EmptyQuote))), + '"' => Err(parse_failure(s, EmptyQuote)), // '\' followed by nothing - '\\' => Err(Failure(ParseError::Anki(s, ErrorKind::UnclosedQuote))), + '\\' => Err(parse_failure(s, UnclosedQuote)), // everything else is accepted by escaped _ => unreachable!(), } @@ -324,7 +276,7 @@ fn search_node_for_text(s: &str) -> ParseResult { } else { // escaped only fails on "\" and leading ':' // "\" cannot be passed as an argument by a calling parser - Err(Failure(ParseError::Anki(s, ErrorKind::MissingKey))) + Err(parse_failure(s, MissingKey)) } } @@ -368,12 +320,12 @@ fn parse_template(s: &str) -> ParseResult { fn parse_flag(s: &str) -> ParseResult { if let Ok(flag) = s.parse::() { if flag > 4 { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidFlag))) + Err(parse_failure(s, InvalidFlag)) } else { Ok(SearchNode::Flag(flag)) } } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidEdited))) + Err(parse_failure(s, InvalidEdited)) } } @@ -387,7 +339,7 @@ fn parse_prop(s: &str) -> ParseResult> { tag("ease"), tag("pos"), ))(s) - .map_err(|_| Failure(ParseError::Anki(s, ErrorKind::InvalidPropProperty)))?; + .map_err(|_| parse_failure(s, InvalidPropProperty))?; let (num, operator) = alt::<&str, &str, ParseError, _>(( tag("<="), @@ -397,19 +349,19 @@ fn parse_prop(s: &str) -> ParseResult> { tag("<"), tag(">"), ))(tail) - .map_err(|_| Failure(ParseError::Anki(s, ErrorKind::InvalidPropOperator)))?; + .map_err(|_| parse_failure(s, InvalidPropOperator))?; let kind = if prop == "ease" { if let Ok(f) = num.parse::() { PropertyKind::Ease(f) } else { - return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidPropFloat))); + return Err(parse_failure(s, InvalidPropFloat)); } } else if prop == "due" { if let Ok(i) = num.parse::() { PropertyKind::Due(i) } else { - return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidPropInteger))); + return Err(parse_failure(s, InvalidPropInteger)); } } else if let Ok(u) = num.parse::() { match prop { @@ -420,7 +372,7 @@ fn parse_prop(s: &str) -> ParseResult> { _ => unreachable!(), } } else { - return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidPropUnsigned))); + return Err(parse_failure(s, InvalidPropUnsigned)); }; Ok(SearchNode::Property { @@ -434,7 +386,7 @@ fn parse_added(s: &str) -> ParseResult { if let Ok(days) = s.parse::() { Ok(SearchNode::AddedInDays(days.max(1))) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidAdded))) + Err(parse_failure(s, InvalidAdded)) } } @@ -443,7 +395,7 @@ fn parse_edited(s: &str) -> ParseResult { if let Ok(days) = s.parse::() { Ok(SearchNode::EditedInDays(days.max(1))) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidEdited))) + Err(parse_failure(s, InvalidEdited)) } } @@ -458,17 +410,17 @@ fn parse_rated(s: &str) -> ParseResult { if u < 5 { Some(u) } else { - return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidRatedEase))); + return Err(parse_failure(s, InvalidRatedEase)); } } else { - return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidRatedEase))); + return Err(parse_failure(s, InvalidRatedEase)); } } else { None }; Ok(SearchNode::Rated { days, ease }) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidRatedDays))) + Err(parse_failure(s, InvalidRatedDays)) } } @@ -484,7 +436,7 @@ fn parse_state(s: &str) -> ParseResult { "buried-manually" => UserBuried, "buried-sibling" => SchedBuried, "suspended" => Suspended, - _ => return Err(Failure(ParseError::Anki(s, ErrorKind::InvalidState))), + _ => return Err(parse_failure(s, InvalidState)), })) } @@ -492,7 +444,7 @@ fn parse_did(s: &str) -> ParseResult { if let Ok(did) = s.parse() { Ok(SearchNode::DeckID(did)) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDid))) + Err(parse_failure(s, InvalidDid)) } } @@ -500,7 +452,7 @@ fn parse_mid(s: &str) -> ParseResult { if let Ok(mid) = s.parse() { Ok(SearchNode::NoteTypeID(mid)) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidMid))) + Err(parse_failure(s, InvalidMid)) } } @@ -513,7 +465,7 @@ fn check_id_list(s: &str) -> ParseResult<&str> { if RE.is_match(s) { Ok(s) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidIdList))) + Err(parse_failure(s, InvalidIdList)) } } @@ -527,10 +479,10 @@ fn parse_dupes(s: &str) -> ParseResult { text: unescape_quotes(text), }) } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDupesText))) + Err(parse_failure(s, InvalidDupesText)) } } else { - Err(Failure(ParseError::Anki(s, ErrorKind::InvalidDupesMid))) + Err(parse_failure(s, InvalidDupesMid)) } } @@ -562,10 +514,7 @@ fn unescape_quotes(s: &str) -> Cow { /// Unescape chars with special meaning to the parser. fn unescape(txt: &str) -> ParseResult> { if let Some(seq) = invalid_escape_sequence(txt) { - Err(Failure(ParseError::Anki( - txt, - ErrorKind::UnknownEscape(seq), - ))) + Err(parse_failure(txt, UnknownEscape(seq))) } else { Ok(if is_parser_escape(txt) { lazy_static! { From 3aa15a7139e6c6f5b1a4944a83e5f4ae7488b20a Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 13 Jan 2021 14:15:57 +0100 Subject: [PATCH 04/15] Optimise term parsers --- rslib/src/search/parser.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 286ccf8e0..feab52532 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -223,8 +223,9 @@ fn unquoted_term(s: &str) -> IResult { )(s) { if tail.starts_with('\\') { - let escaped = (if tail.len() > 1 { &tail[0..2] } else { "" }).to_string(); - Err(parse_failure(s, UnknownEscape(format!("\\{}", escaped)))) + // trailing backslash followed by whitespace or nothing + let seq = (if tail.len() > 1 { &tail[0..2] } else { r"\" }).to_string(); + Err(parse_failure(s, UnknownEscape(seq))) } else if term.eq_ignore_ascii_case("and") { Ok((tail, Node::And)) } else if term.eq_ignore_ascii_case("or") { @@ -233,8 +234,9 @@ fn unquoted_term(s: &str) -> IResult { Ok((tail, Node::Search(search_node_for_text(term)?))) } } else if s.starts_with('\\') { - let escaped = (if s.len() > 1 { &s[0..2] } else { "" }).to_string(); - Err(parse_failure(s, UnknownEscape(format!("\\{}", escaped)))) + // leading backslash followed by whitespace or nothing + let seq = (if s.len() > 1 { &s[0..2] } else { r"\" }).to_string(); + Err(parse_failure(s, UnknownEscape(seq))) } else { Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::Verify))) } @@ -246,21 +248,21 @@ fn quoted_term_str(s: &str) -> IResult<&str> { if let Ok((tail, inner)) = escaped::<_, ParseError, _, _, _, _>(is_not(r#""\"#), '\\', anychar)(opened) { - if tail.is_empty() { - Err(parse_failure(s, UnclosedQuote)) - } else if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { + if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { Ok((remaining, inner)) } else { Err(parse_failure(s, UnclosedQuote)) } } else { - match opened.chars().next().unwrap() { - '"' => Err(parse_failure(s, EmptyQuote)), - // '\' followed by nothing - '\\' => Err(parse_failure(s, UnclosedQuote)), - // everything else is accepted by escaped - _ => unreachable!(), - } + Err(parse_failure( + s, + match opened.chars().next().unwrap() { + '"' => EmptyQuote, + // '\' followed by nothing + '\\' => UnclosedQuote, + _ => unreachable!(), + }, + )) } } From 4afb476f04582e0c4afeb66b05e47e1c9dad8189 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 11:10:33 +0100 Subject: [PATCH 05/15] Fix text node parsers, add FailKind alias --- rslib/src/search/parser.rs | 127 ++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 66 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index feab52532..01f8fe1f9 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -3,7 +3,7 @@ use crate::{ decks::DeckID, - err::{AnkiError, ParseError, ParseErrorKind, Result}, + err::{ParseError, ParseErrorKind as FailKind, Result}, notetype::NoteTypeID, }; use lazy_static::lazy_static; @@ -11,19 +11,18 @@ use nom::{ branch::alt, bytes::complete::{escaped, is_not, tag}, character::complete::{anychar, char, none_of, one_of}, - combinator::{all_consuming, map, verify}, + combinator::{all_consuming, map}, error::ErrorKind as NomErrorKind, multi::many0, sequence::{delimited, preceded, separated_pair}, }; use regex::{Captures, Regex}; use std::borrow::Cow; -use ParseErrorKind::*; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; type ParseResult<'a, O> = std::result::Result>>; -fn parse_failure(input: &str, kind: ParseErrorKind) -> nom::Err> { +fn parse_failure(input: &str, kind: FailKind) -> nom::Err> { nom::Err::Failure(ParseError::Anki(input, kind)) } @@ -132,9 +131,9 @@ fn group_inner(input: &str) -> IResult> { // before adding the node, if the length is even then the node // must not be a boolean if node == Node::And { - return Err(parse_failure(input, MisplacedAnd)); + return Err(parse_failure(input, FailKind::MisplacedAnd)); } else if node == Node::Or { - return Err(parse_failure(input, MisplacedOr)); + return Err(parse_failure(input, FailKind::MisplacedOr)); } } else { // if the length is odd, the next item must be a boolean. if it's @@ -154,11 +153,11 @@ fn group_inner(input: &str) -> IResult> { } if nodes.is_empty() { - Err(parse_failure(input, EmptyGroup)) + Err(parse_failure(input, FailKind::EmptyGroup)) } else if nodes.last().unwrap() == &Node::And { - Err(parse_failure(input, MisplacedAnd)) + Err(parse_failure(input, FailKind::MisplacedAnd)) } else if nodes.last().unwrap() == &Node::Or { - Err(parse_failure(input, MisplacedOr)) + Err(parse_failure(input, FailKind::MisplacedOr)) } else { // chomp any trailing whitespace let (remaining, _) = whitespace0(remaining)?; @@ -201,44 +200,38 @@ fn quoted_term(s: &str) -> IResult { /// 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}")), + escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(" \u{3000}")), char(':'), quoted_term_str, )(s)?; - if key.is_empty() { - Err(parse_failure(s, MissingKey)) - } else { - Ok(( - remaining, - Node::Search(search_node_for_text_with_argument(key, val)?), - )) - } + 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 { - if let Ok((tail, term)) = verify::<_, _, _, ParseError, _, _>( - escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}")), - |s: &str| !s.is_empty(), - )(s) + match escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}"))(s) { - if tail.starts_with('\\') { - // trailing backslash followed by whitespace or nothing - let seq = (if tail.len() > 1 { &tail[0..2] } else { r"\" }).to_string(); - Err(parse_failure(s, UnknownEscape(seq))) - } 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)?))) - } - } else if s.starts_with('\\') { - // leading backslash followed by whitespace or nothing - let seq = (if s.len() > 1 { &s[0..2] } else { r"\" }).to_string(); - Err(parse_failure(s, UnknownEscape(seq))) - } else { - Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::Verify))) + Ok((tail, term)) => { + if term.is_empty() { + Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::Verify))) + } 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(nom::Err::Error(ParseError::Nom(s, NomErrorKind::IsNot))) + } else { + // input ends in an odd number of backslashes + Err(parse_failure(s, FailKind::UnknownEscape('\\'.to_string()))) + } + }, } } @@ -251,23 +244,26 @@ fn quoted_term_str(s: &str) -> IResult<&str> { if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { Ok((remaining, inner)) } else { - Err(parse_failure(s, UnclosedQuote)) + Err(parse_failure(s, FailKind::UnclosedQuote)) } } else { Err(parse_failure( s, match opened.chars().next().unwrap() { - '"' => EmptyQuote, - // '\' followed by nothing - '\\' => UnclosedQuote, - _ => unreachable!(), + '"' => 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 { + if s.is_empty() { + return Err(parse_failure(s, FailKind::Other(Some("Unexpected search error.".to_string())))); + } if let Ok((tail, head)) = escaped::<_, ParseError, _, _, _, _>(is_not(r":\"), '\\', anychar)(s) { if tail.is_empty() { @@ -276,9 +272,8 @@ fn search_node_for_text(s: &str) -> ParseResult { search_node_for_text_with_argument(head, &tail[1..]) } } else { - // escaped only fails on "\" and leading ':' - // "\" cannot be passed as an argument by a calling parser - Err(parse_failure(s, MissingKey)) + // trailing \ should not be passed, so error must be leading ':' + Err(parse_failure(s, FailKind::MissingKey)) } } @@ -322,12 +317,12 @@ fn parse_template(s: &str) -> ParseResult { fn parse_flag(s: &str) -> ParseResult { if let Ok(flag) = s.parse::() { if flag > 4 { - Err(parse_failure(s, InvalidFlag)) + Err(parse_failure(s, FailKind::InvalidFlag)) } else { Ok(SearchNode::Flag(flag)) } } else { - Err(parse_failure(s, InvalidEdited)) + Err(parse_failure(s, FailKind::InvalidEdited)) } } @@ -341,7 +336,7 @@ fn parse_prop(s: &str) -> ParseResult> { tag("ease"), tag("pos"), ))(s) - .map_err(|_| parse_failure(s, InvalidPropProperty))?; + .map_err(|_| parse_failure(s, FailKind::InvalidPropProperty))?; let (num, operator) = alt::<&str, &str, ParseError, _>(( tag("<="), @@ -351,19 +346,19 @@ fn parse_prop(s: &str) -> ParseResult> { tag("<"), tag(">"), ))(tail) - .map_err(|_| parse_failure(s, InvalidPropOperator))?; + .map_err(|_| parse_failure(s, FailKind::InvalidPropOperator))?; let kind = if prop == "ease" { if let Ok(f) = num.parse::() { PropertyKind::Ease(f) } else { - return Err(parse_failure(s, InvalidPropFloat)); + return Err(parse_failure(s, FailKind::InvalidPropFloat)); } } else if prop == "due" { if let Ok(i) = num.parse::() { PropertyKind::Due(i) } else { - return Err(parse_failure(s, InvalidPropInteger)); + return Err(parse_failure(s, FailKind::InvalidPropInteger)); } } else if let Ok(u) = num.parse::() { match prop { @@ -374,7 +369,7 @@ fn parse_prop(s: &str) -> ParseResult> { _ => unreachable!(), } } else { - return Err(parse_failure(s, InvalidPropUnsigned)); + return Err(parse_failure(s, FailKind::InvalidPropUnsigned)); }; Ok(SearchNode::Property { @@ -388,7 +383,7 @@ fn parse_added(s: &str) -> ParseResult { if let Ok(days) = s.parse::() { Ok(SearchNode::AddedInDays(days.max(1))) } else { - Err(parse_failure(s, InvalidAdded)) + Err(parse_failure(s, FailKind::InvalidAdded)) } } @@ -397,7 +392,7 @@ fn parse_edited(s: &str) -> ParseResult { if let Ok(days) = s.parse::() { Ok(SearchNode::EditedInDays(days.max(1))) } else { - Err(parse_failure(s, InvalidEdited)) + Err(parse_failure(s, FailKind::InvalidEdited)) } } @@ -412,17 +407,17 @@ fn parse_rated(s: &str) -> ParseResult { if u < 5 { Some(u) } else { - return Err(parse_failure(s, InvalidRatedEase)); + return Err(parse_failure(s, FailKind::InvalidRatedEase)); } } else { - return Err(parse_failure(s, InvalidRatedEase)); + return Err(parse_failure(s, FailKind::InvalidRatedEase)); } } else { None }; Ok(SearchNode::Rated { days, ease }) } else { - Err(parse_failure(s, InvalidRatedDays)) + Err(parse_failure(s, FailKind::InvalidRatedDays)) } } @@ -438,7 +433,7 @@ fn parse_state(s: &str) -> ParseResult { "buried-manually" => UserBuried, "buried-sibling" => SchedBuried, "suspended" => Suspended, - _ => return Err(parse_failure(s, InvalidState)), + _ => return Err(parse_failure(s, FailKind::InvalidState)), })) } @@ -446,7 +441,7 @@ fn parse_did(s: &str) -> ParseResult { if let Ok(did) = s.parse() { Ok(SearchNode::DeckID(did)) } else { - Err(parse_failure(s, InvalidDid)) + Err(parse_failure(s, FailKind::InvalidDid)) } } @@ -454,7 +449,7 @@ fn parse_mid(s: &str) -> ParseResult { if let Ok(mid) = s.parse() { Ok(SearchNode::NoteTypeID(mid)) } else { - Err(parse_failure(s, InvalidMid)) + Err(parse_failure(s, FailKind::InvalidMid)) } } @@ -467,7 +462,7 @@ fn check_id_list(s: &str) -> ParseResult<&str> { if RE.is_match(s) { Ok(s) } else { - Err(parse_failure(s, InvalidIdList)) + Err(parse_failure(s, FailKind::InvalidIdList)) } } @@ -481,10 +476,10 @@ fn parse_dupes(s: &str) -> ParseResult { text: unescape_quotes(text), }) } else { - Err(parse_failure(s, InvalidDupesText)) + Err(parse_failure(s, FailKind::InvalidDupeText)) } } else { - Err(parse_failure(s, InvalidDupesMid)) + Err(parse_failure(s, FailKind::InvalidDupeMid)) } } @@ -516,7 +511,7 @@ fn unescape_quotes(s: &str) -> Cow { /// Unescape chars with special meaning to the parser. fn unescape(txt: &str) -> ParseResult> { if let Some(seq) = invalid_escape_sequence(txt) { - Err(parse_failure(txt, UnknownEscape(seq))) + Err(parse_failure(txt, FailKind::UnknownEscape(seq))) } else { Ok(if is_parser_escape(txt) { lazy_static! { From 0b73110f820c30e501e82e34d6447c8533ed2da7 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 11:14:55 +0100 Subject: [PATCH 06/15] Make AnkiError::SearchError work with ParseError Use mock ftl strings for now. --- ftl/core/search.ftl | 34 +++++++++++++++++++ rslib/src/err.rs | 68 ++++++++++++++++++++++++++++++++------ rslib/src/search/parser.rs | 5 +-- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index 8bb63dbe7..0a90c18ed 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -1,4 +1,38 @@ +## Errors shown when invalid search input is encountered. + search-invalid = Invalid search - please check for typing mistakes. +# The literal string `AND` is part of the search syntax. +search-misplaced-and = 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". +# The literal string `OR` is part of the search syntax. +search-misplaced-or = 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". +search-empty-group = A group was found but there was nothing between the + parentheses to search for. +search-empty-quote = A quote was found but there was nothing between the + double quotes to search for. +search-unclosed-quote = An opening double quote `"` was found but there + is no second one to close it. +search-missing-key = A colon `:` must be preceded by a key. +search-unknown-escape = The escape sequence `` is unknown. +search-invalid-id-list = error +search-invalid-state = error +search-invalid-flag = error +search-invalid-added = error +search-invalid-edited = error +search-invalid-rated-days = error +search-invalid-rated-ease = error +search-invalid-dupe-mid = error +search-invalid-dupe-text = error +search-invalid-prop-property = error +search-invalid-prop-operator = error +search-invalid-prop-float = error +search-invalid-prop-integer = error +search-invalid-prop-unsigned = error +search-invalid-did = error +search-invalid-mid = error ## Column labels in browse screen diff --git a/rslib/src/err.rs b/rslib/src/err.rs index e5b309c71..5df552a77 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -60,7 +60,7 @@ pub enum AnkiError { DeckIsFiltered, #[fail(display = "Invalid search.")] - SearchError(Option), + SearchError(ParseErrorKind), } // error helpers @@ -120,13 +120,38 @@ 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 { + ParseErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd), + ParseErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr), + ParseErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup), + ParseErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), + ParseErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), + ParseErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), + ParseErrorKind::UnknownEscape(_seq) => i18n.tr(TR::SearchUnknownEscape), + ParseErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), + ParseErrorKind::InvalidState => i18n.tr(TR::SearchInvalidState), + ParseErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), + ParseErrorKind::InvalidAdded => i18n.tr(TR::SearchInvalidAdded), + ParseErrorKind::InvalidEdited => i18n.tr(TR::SearchInvalidEdited), + ParseErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), + ParseErrorKind::InvalidRatedEase => i18n.tr(TR::SearchInvalidRatedEase), + ParseErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), + ParseErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), + ParseErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), + ParseErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), + ParseErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), + ParseErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), + ParseErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), + ParseErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), + ParseErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), + ParseErrorKind::Regex(text) => text.into(), + ParseErrorKind::Other(opt) => if let Some(info) = opt { + info.into() + } else { + i18n.tr(TR::SearchInvalid) + } + }.into() + }, _ => format!("{:?}", self), } } @@ -164,7 +189,7 @@ impl From for AnkiError { }; } if reason.contains("regex parse error") { - return AnkiError::SearchError(Some(reason.to_owned())); + return AnkiError::SearchError(ParseErrorKind::Regex(reason.to_owned())); } } AnkiError::DBError { @@ -351,8 +376,8 @@ pub enum ParseErrorKind { InvalidEdited, InvalidRatedDays, InvalidRatedEase, - InvalidDupesMid, - InvalidDupesText, + InvalidDupeMid, + InvalidDupeText, InvalidPropProperty, InvalidPropOperator, InvalidPropFloat, @@ -360,6 +385,27 @@ pub enum ParseErrorKind { InvalidPropUnsigned, 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(ParseErrorKind::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(ParseErrorKind::Other(None)), + } + } } impl<'a> NomParseError<&'a str> for ParseError<'a> { diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 01f8fe1f9..f3b6ca2b3 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -110,10 +110,7 @@ pub(super) fn parse(input: &str) -> Result> { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); } - let (_, nodes) = all_consuming(group_inner)(input).map_err(|e| { - dbg!(e); - AnkiError::SearchError(None) - })?; + let (_, nodes) = all_consuming(group_inner)(input)?; Ok(nodes) } From 8f754e15258f7457d7c364f14e5797539cae6b4f Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 19:09:31 +0100 Subject: [PATCH 07/15] Add FailKinds for unopened/unclosed groups --- ftl/core/search.ftl | 17 +++++-- rslib/src/err.rs | 65 +++++++++++++------------- rslib/src/search/parser.rs | 95 ++++++++++++++++++++++---------------- 3 files changed, 100 insertions(+), 77 deletions(-) diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index 0a90c18ed..18e8b9293 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -2,18 +2,25 @@ search-invalid = Invalid search - please check for typing mistakes. # The literal string `AND` is part of the search syntax. -search-misplaced-and = An `AND` was found but it is not connecting two +search-misplaced-and = + 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". # The literal string `OR` is part of the search syntax. -search-misplaced-or = An `OR` was found but it is not connecting two +search-misplaced-or = + 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". -search-empty-group = A group was found but there was nothing between the +search-empty-group = + A group was found but there was nothing between the parentheses to search for. -search-empty-quote = A quote was found but there was nothing between the +search-unopened-group = search-unopened-group +search-unclosed-group = search-unclosed-group +search-empty-quote = + A quote was found but there was nothing between the double quotes to search for. -search-unclosed-quote = An opening double quote `"` was found but there +search-unclosed-quote = + An opening double quote `"` was found but there is no second one to close it. search-missing-key = A colon `:` must be preceded by a key. search-unknown-escape = The escape sequence `` is unknown. diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 5df552a77..7fc9a94e0 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -120,38 +120,37 @@ impl AnkiError { DBErrorKind::Locked => "Anki already open, or media currently syncing.".into(), _ => format!("{:?}", self), }, - AnkiError::SearchError(kind) => { match kind { - ParseErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd), - ParseErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr), - ParseErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup), - ParseErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), - ParseErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), - ParseErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), - ParseErrorKind::UnknownEscape(_seq) => i18n.tr(TR::SearchUnknownEscape), - ParseErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), - ParseErrorKind::InvalidState => i18n.tr(TR::SearchInvalidState), - ParseErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), - ParseErrorKind::InvalidAdded => i18n.tr(TR::SearchInvalidAdded), - ParseErrorKind::InvalidEdited => i18n.tr(TR::SearchInvalidEdited), - ParseErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), - ParseErrorKind::InvalidRatedEase => i18n.tr(TR::SearchInvalidRatedEase), - ParseErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), - ParseErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), - ParseErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), - ParseErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), - ParseErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), - ParseErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), - ParseErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), - ParseErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), - ParseErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), - ParseErrorKind::Regex(text) => text.into(), - ParseErrorKind::Other(opt) => if let Some(info) = opt { - info.into() - } else { - i18n.tr(TR::SearchInvalid) - } - }.into() - }, + AnkiError::SearchError(kind) => match kind { + ParseErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd), + ParseErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr), + ParseErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup), + ParseErrorKind::UnopenedGroup => i18n.tr(TR::SearchUnopenedGroup), + ParseErrorKind::UnclosedGroup => i18n.tr(TR::SearchUnclosedGroup), + ParseErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), + ParseErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), + ParseErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), + ParseErrorKind::UnknownEscape(_seq) => i18n.tr(TR::SearchUnknownEscape), + ParseErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), + ParseErrorKind::InvalidState => i18n.tr(TR::SearchInvalidState), + ParseErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), + ParseErrorKind::InvalidAdded => i18n.tr(TR::SearchInvalidAdded), + ParseErrorKind::InvalidEdited => i18n.tr(TR::SearchInvalidEdited), + ParseErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), + ParseErrorKind::InvalidRatedEase => i18n.tr(TR::SearchInvalidRatedEase), + ParseErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), + ParseErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), + ParseErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), + ParseErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), + ParseErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), + ParseErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), + ParseErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), + ParseErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), + ParseErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), + ParseErrorKind::Regex(text) => text.into(), + ParseErrorKind::Other(Some(info)) => info.into(), + ParseErrorKind::Other(None) => i18n.tr(TR::SearchInvalid), + } + .into(), _ => format!("{:?}", self), } } @@ -365,6 +364,8 @@ pub enum ParseErrorKind { MisplacedAnd, MisplacedOr, EmptyGroup, + UnopenedGroup, + UnclosedGroup, EmptyQuote, UnclosedQuote, MissingKey, diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index f3b6ca2b3..d526912a5 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -11,10 +11,10 @@ use nom::{ branch::alt, bytes::complete::{escaped, is_not, tag}, character::complete::{anychar, char, none_of, one_of}, - combinator::{all_consuming, map}, + combinator::{map, verify}, error::ErrorKind as NomErrorKind, multi::many0, - sequence::{delimited, preceded, separated_pair}, + sequence::{preceded, separated_pair}, }; use regex::{Captures, Regex}; use std::borrow::Cow; @@ -26,6 +26,10 @@ fn parse_failure(input: &str, kind: FailKind) -> nom::Err> { nom::Err::Failure(ParseError::Anki(input, kind)) } +fn parse_error(input: &str) -> nom::Err> { + nom::Err::Error(ParseError::Anki(input, FailKind::Other(None))) +} + #[derive(Debug, PartialEq)] pub enum Node<'a> { And, @@ -110,8 +114,11 @@ pub(super) fn parse(input: &str) -> Result> { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); } - let (_, nodes) = all_consuming(group_inner)(input)?; - Ok(nodes) + match group_inner(input) { + Ok(("", nodes)) => Ok(nodes), + Ok((remaining, _)) => Err(parse_failure(remaining, FailKind::UnopenedGroup).into()), + Err(err) => Err(err.into()), + } } /// One or more nodes inside brackets, er 'one OR two -three' @@ -143,24 +150,21 @@ fn group_inner(input: &str) -> IResult> { } Err(e) => match e { nom::Err::Error(_) => break, - // fixme: add context to failure _ => return Err(e), }, }; } - if nodes.is_empty() { - Err(parse_failure(input, FailKind::EmptyGroup)) - } else if nodes.last().unwrap() == &Node::And { - Err(parse_failure(input, FailKind::MisplacedAnd)) - } else if nodes.last().unwrap() == &Node::Or { - Err(parse_failure(input, FailKind::MisplacedOr)) - } 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> { @@ -180,7 +184,17 @@ fn negated_node(s: &str) -> IResult { /// One or more nodes surrounded by brackets, eg (one OR two) fn group(s: &str) -> IResult { - map(delimited(char('('), group_inner, char(')')), Node::Group)(s) + 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 @@ -201,16 +215,18 @@ fn partially_quoted_term(s: &str) -> IResult { char(':'), quoted_term_str, )(s)?; - Ok((remaining, Node::Search(search_node_for_text_with_argument(key, val)?))) + 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) - { + match escaped(is_not("\"() \u{3000}\\"), '\\', none_of(" \u{3000}"))(s) { Ok((tail, term)) => { if term.is_empty() { - Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::Verify))) + Err(parse_error(s)) } else if term.eq_ignore_ascii_case("and") { Ok((tail, Node::And)) } else if term.eq_ignore_ascii_case("or") { @@ -218,17 +234,20 @@ fn unquoted_term(s: &str) -> IResult { } 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)))) + Err(parse_failure( + s, + FailKind::UnknownEscape(format!("\\{}", c)), + )) } else if "\"() \u{3000}".contains(s.chars().next().unwrap()) { - Err(nom::Err::Error(ParseError::Nom(s, NomErrorKind::IsNot))) + Err(parse_error(s)) } else { // input ends in an odd number of backslashes Err(parse_failure(s, FailKind::UnknownEscape('\\'.to_string()))) } - }, + } } } @@ -258,19 +277,15 @@ fn quoted_term_str(s: &str) -> IResult<&str> { /// 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 { - if s.is_empty() { - return Err(parse_failure(s, FailKind::Other(Some("Unexpected search error.".to_string())))); - } - if let Ok((tail, head)) = escaped::<_, ParseError, _, _, _, _>(is_not(r":\"), '\\', anychar)(s) - { - if tail.is_empty() { - Ok(SearchNode::UnqualifiedText(unescape(head)?)) - } else { - search_node_for_text_with_argument(head, &tail[1..]) - } + // 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 { - // trailing \ should not be passed, so error must be leading ':' - Err(parse_failure(s, FailKind::MissingKey)) + search_node_for_text_with_argument(head, &tail[1..]) } } @@ -319,13 +334,13 @@ fn parse_flag(s: &str) -> ParseResult { Ok(SearchNode::Flag(flag)) } } else { - Err(parse_failure(s, FailKind::InvalidEdited)) + Err(parse_failure(s, FailKind::InvalidFlag)) } } /// eg prop:ivl>3, prop:ease!=2.5 fn parse_prop(s: &str) -> ParseResult> { - let (tail, prop) = alt::<&str, &str, ParseError, _>(( + let (tail, prop) = alt::<_, _, ParseError, _>(( tag("ivl"), tag("due"), tag("reps"), @@ -335,7 +350,7 @@ fn parse_prop(s: &str) -> ParseResult> { ))(s) .map_err(|_| parse_failure(s, FailKind::InvalidPropProperty))?; - let (num, operator) = alt::<&str, &str, ParseError, _>(( + let (num, operator) = alt::<_, _, ParseError, _>(( tag("<="), tag(">="), tag("!="), From 6af18526113a9f61a735fdcc42bc3d521835f51d Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 19:10:12 +0100 Subject: [PATCH 08/15] Add errorkind test --- rslib/src/search/parser.rs | 137 ++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 18 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index d526912a5..df6fd219a 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -593,11 +593,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 ")?, @@ -658,11 +653,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#"\"\:\(\)\-"#)?, @@ -677,11 +667,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")); @@ -703,7 +690,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)")?); @@ -734,11 +720,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")?, @@ -747,7 +730,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 { @@ -758,4 +740,123 @@ 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); + assert_err_kind("rated:2:-1", InvalidRatedEase); + assert_err_kind("rated:3:1.1", InvalidRatedEase); + assert_err_kind("rated:0:foo", InvalidRatedEase); + + 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); + assert_err_kind("prop:pos~1", InvalidPropOperator); + assert_err_kind("prop:reps10", InvalidPropOperator); + + assert_err_kind("prop:ease>", InvalidPropFloat); + assert_err_kind("prop:ease!=one", InvalidPropFloat); + assert_err_kind("prop:ease<1,3", InvalidPropFloat); + + assert_err_kind("prop:due>", InvalidPropInteger); + assert_err_kind("prop:due=0.5", InvalidPropInteger); + assert_err_kind("prop:due", InvalidPropUnsigned); + assert_err_kind("prop:reps=1.1", InvalidPropUnsigned); + assert_err_kind("prop:lapses!=-1", InvalidPropUnsigned); + + Ok(()) + } } From 5f05ca25485568ce3edb85c9c3e289faf590dd23 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 22:00:52 +0100 Subject: [PATCH 09/15] Rename ParseErrorKind to SearchErrorKind --- rslib/src/err.rs | 68 +++++++++++++++++++------------------- rslib/src/search/parser.rs | 6 ++-- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 7fc9a94e0..94a7b280b 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -60,7 +60,7 @@ pub enum AnkiError { DeckIsFiltered, #[fail(display = "Invalid search.")] - SearchError(ParseErrorKind), + SearchError(SearchErrorKind), } // error helpers @@ -121,34 +121,34 @@ impl AnkiError { _ => format!("{:?}", self), }, AnkiError::SearchError(kind) => match kind { - ParseErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd), - ParseErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr), - ParseErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup), - ParseErrorKind::UnopenedGroup => i18n.tr(TR::SearchUnopenedGroup), - ParseErrorKind::UnclosedGroup => i18n.tr(TR::SearchUnclosedGroup), - ParseErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), - ParseErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), - ParseErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), - ParseErrorKind::UnknownEscape(_seq) => i18n.tr(TR::SearchUnknownEscape), - ParseErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), - ParseErrorKind::InvalidState => i18n.tr(TR::SearchInvalidState), - ParseErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), - ParseErrorKind::InvalidAdded => i18n.tr(TR::SearchInvalidAdded), - ParseErrorKind::InvalidEdited => i18n.tr(TR::SearchInvalidEdited), - ParseErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), - ParseErrorKind::InvalidRatedEase => i18n.tr(TR::SearchInvalidRatedEase), - ParseErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), - ParseErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), - ParseErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), - ParseErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), - ParseErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), - ParseErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), - ParseErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), - ParseErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), - ParseErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), - ParseErrorKind::Regex(text) => text.into(), - ParseErrorKind::Other(Some(info)) => info.into(), - ParseErrorKind::Other(None) => i18n.tr(TR::SearchInvalid), + 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(_seq) => i18n.tr(TR::SearchUnknownEscape), + 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 => i18n.tr(TR::SearchInvalidRatedEase), + SearchErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), + SearchErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), + SearchErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), + SearchErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), + SearchErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), + SearchErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), + SearchErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), + 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), @@ -188,7 +188,7 @@ impl From for AnkiError { }; } if reason.contains("regex parse error") { - return AnkiError::SearchError(ParseErrorKind::Regex(reason.to_owned())); + return AnkiError::SearchError(SearchErrorKind::Regex(reason.to_owned())); } } AnkiError::DBError { @@ -355,12 +355,12 @@ pub enum DBErrorKind { #[derive(Debug, PartialEq)] pub enum ParseError<'a> { - Anki(&'a str, ParseErrorKind), + Anki(&'a str, SearchErrorKind), Nom(&'a str, NomErrorKind), } #[derive(Debug, PartialEq)] -pub enum ParseErrorKind { +pub enum SearchErrorKind { MisplacedAnd, MisplacedOr, EmptyGroup, @@ -394,7 +394,7 @@ impl From> for AnkiError { fn from(err: ParseError) -> Self { match err { ParseError::Anki(_, kind) => AnkiError::SearchError(kind), - ParseError::Nom(_, _) => AnkiError::SearchError(ParseErrorKind::Other(None)), + ParseError::Nom(_, _) => AnkiError::SearchError(SearchErrorKind::Other(None)), } } } @@ -404,7 +404,7 @@ impl From>> for AnkiError { match err { nom::Err::Error(e) => e.into(), nom::Err::Failure(e) => e.into(), - nom::Err::Incomplete(_) => AnkiError::SearchError(ParseErrorKind::Other(None)), + nom::Err::Incomplete(_) => AnkiError::SearchError(SearchErrorKind::Other(None)), } } } diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index df6fd219a..b4693e9fc 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -3,7 +3,7 @@ use crate::{ decks::DeckID, - err::{ParseError, ParseErrorKind as FailKind, Result}, + err::{ParseError, SearchErrorKind as FailKind, Result}, notetype::NoteTypeID, }; use lazy_static::lazy_static; @@ -116,12 +116,14 @@ pub(super) fn parse(input: &str) -> Result> { 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 inside brackets, er 'one OR two -three' +/// 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![]; From 2a2ac38a1b3962fdf52bfb356c913dbae0f4b2b6 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 22:40:07 +0100 Subject: [PATCH 10/15] Add search context for certain error kinds --- rslib/src/err.rs | 22 +++++++++++----------- rslib/src/search/parser.rs | 12 ++++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 94a7b280b..4c6f48827 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -129,21 +129,21 @@ impl AnkiError { SearchErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote), SearchErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote), SearchErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey), - SearchErrorKind::UnknownEscape(_seq) => i18n.tr(TR::SearchUnknownEscape), + SearchErrorKind::UnknownEscape(ctx) => i18n.trn(TR::SearchUnknownEscape, tr_strs!["val"=>(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 => i18n.tr(TR::SearchInvalidRatedEase), + SearchErrorKind::InvalidRatedEase(ctx) => i18n.trn(TR::SearchInvalidRatedEase, tr_strs!["val"=>(ctx)]).into(), SearchErrorKind::InvalidDupeMid => i18n.tr(TR::SearchInvalidDupeMid), SearchErrorKind::InvalidDupeText => i18n.tr(TR::SearchInvalidDupeText), SearchErrorKind::InvalidPropProperty => i18n.tr(TR::SearchInvalidPropProperty), - SearchErrorKind::InvalidPropOperator => i18n.tr(TR::SearchInvalidPropOperator), - SearchErrorKind::InvalidPropFloat => i18n.tr(TR::SearchInvalidPropFloat), - SearchErrorKind::InvalidPropInteger => i18n.tr(TR::SearchInvalidPropInteger), - SearchErrorKind::InvalidPropUnsigned => i18n.tr(TR::SearchInvalidPropUnsigned), + SearchErrorKind::InvalidPropOperator(ctx) => i18n.trn(TR::SearchInvalidPropOperator, tr_strs!["val"=>(ctx)]).into(), + SearchErrorKind::InvalidPropFloat(ctx) => i18n.trn(TR::SearchInvalidPropFloat, tr_strs!["val"=>(ctx)]).into(), + SearchErrorKind::InvalidPropInteger(ctx) => i18n.trn(TR::SearchInvalidPropInteger, tr_strs!["val"=>(ctx)]).into(), + SearchErrorKind::InvalidPropUnsigned(ctx) => i18n.trn(TR::SearchInvalidPropUnsigned, tr_strs!["val"=>(ctx)]).into(), SearchErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), SearchErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), SearchErrorKind::Regex(text) => text.into(), @@ -376,14 +376,14 @@ pub enum SearchErrorKind { InvalidAdded, InvalidEdited, InvalidRatedDays, - InvalidRatedEase, + InvalidRatedEase(String), InvalidDupeMid, InvalidDupeText, InvalidPropProperty, - InvalidPropOperator, - InvalidPropFloat, - InvalidPropInteger, - InvalidPropUnsigned, + InvalidPropOperator(String), + InvalidPropFloat(String), + InvalidPropInteger(String), + InvalidPropUnsigned(String), InvalidDid, InvalidMid, Regex(String), diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index b4693e9fc..4d0e15903 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -360,19 +360,19 @@ fn parse_prop(s: &str) -> ParseResult> { tag("<"), tag(">"), ))(tail) - .map_err(|_| parse_failure(s, FailKind::InvalidPropOperator))?; + .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)); + 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)); + return Err(parse_failure(s, FailKind::InvalidPropInteger(format!("{}{}", prop, operator)))); } } else if let Ok(u) = num.parse::() { match prop { @@ -383,7 +383,7 @@ fn parse_prop(s: &str) -> ParseResult> { _ => unreachable!(), } } else { - return Err(parse_failure(s, FailKind::InvalidPropUnsigned)); + return Err(parse_failure(s, FailKind::InvalidPropUnsigned(format!("{}{}", prop, operator)))); }; Ok(SearchNode::Property { @@ -421,10 +421,10 @@ fn parse_rated(s: &str) -> ParseResult { if u < 5 { Some(u) } else { - return Err(parse_failure(s, FailKind::InvalidRatedEase)); + return Err(parse_failure(s, FailKind::InvalidRatedEase(days.to_string()))); } } else { - return Err(parse_failure(s, FailKind::InvalidRatedEase)); + return Err(parse_failure(s, FailKind::InvalidRatedEase(days.to_string()))); } } else { None From 182abfe66d8e2419b1fd592c0aaba52d83d7b11e Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 23:02:39 +0100 Subject: [PATCH 11/15] Encode HTML entities in search errors --- rslib/src/err.rs | 36 ++++++++++++++++++++++++++++++------ rslib/src/search/parser.rs | 27 +++++++++++++++++++++------ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 4c6f48827..e38dbc959 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -129,21 +129,45 @@ impl AnkiError { 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"=>(ctx)]).into(), + 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::InvalidRatedEase(ctx) => i18n + .trn(TR::SearchInvalidRatedEase, tr_strs!["val"=>(ctx)]) + .into(), 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"=>(ctx)]).into(), - SearchErrorKind::InvalidPropInteger(ctx) => i18n.trn(TR::SearchInvalidPropInteger, tr_strs!["val"=>(ctx)]).into(), - SearchErrorKind::InvalidPropUnsigned(ctx) => i18n.trn(TR::SearchInvalidPropUnsigned, tr_strs!["val"=>(ctx)]).into(), + 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(), diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 4d0e15903..67127ccb2 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -3,7 +3,7 @@ use crate::{ decks::DeckID, - err::{ParseError, SearchErrorKind as FailKind, Result}, + err::{ParseError, Result, SearchErrorKind as FailKind}, notetype::NoteTypeID, }; use lazy_static::lazy_static; @@ -366,13 +366,19 @@ fn parse_prop(s: &str) -> ParseResult> { if let Ok(f) = num.parse::() { PropertyKind::Ease(f) } else { - return Err(parse_failure(s, FailKind::InvalidPropFloat(format!("{}{}", prop, operator)))); + 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)))); + return Err(parse_failure( + s, + FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), + )); } } else if let Ok(u) = num.parse::() { match prop { @@ -383,7 +389,10 @@ fn parse_prop(s: &str) -> ParseResult> { _ => unreachable!(), } } else { - return Err(parse_failure(s, FailKind::InvalidPropUnsigned(format!("{}{}", prop, operator)))); + return Err(parse_failure( + s, + FailKind::InvalidPropUnsigned(format!("{}{}", prop, operator)), + )); }; Ok(SearchNode::Property { @@ -421,10 +430,16 @@ fn parse_rated(s: &str) -> ParseResult { if u < 5 { Some(u) } else { - return Err(parse_failure(s, FailKind::InvalidRatedEase(days.to_string()))); + return Err(parse_failure( + s, + FailKind::InvalidRatedEase(days.to_string()), + )); } } else { - return Err(parse_failure(s, FailKind::InvalidRatedEase(days.to_string()))); + return Err(parse_failure( + s, + FailKind::InvalidRatedEase(days.to_string()), + )); } } else { None From c8f0961c6c9eb7fe00714ae0a21924b20d33200a Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 15 Jan 2021 09:09:54 +0100 Subject: [PATCH 12/15] Include context in search error test --- rslib/src/search/parser.rs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 67127ccb2..08f98c924 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -843,10 +843,10 @@ mod test { assert_err_kind("rated:", InvalidRatedDays); assert_err_kind("rated:foo", InvalidRatedDays); - assert_err_kind("rated:1:", InvalidRatedEase); - assert_err_kind("rated:2:-1", InvalidRatedEase); - assert_err_kind("rated:3:1.1", InvalidRatedEase); - assert_err_kind("rated:0:foo", InvalidRatedEase); + 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("dupe:", InvalidDupeMid); assert_err_kind("dupe:1.1", InvalidDupeMid); @@ -858,21 +858,24 @@ mod test { assert_err_kind("prop:=1", InvalidPropProperty); assert_err_kind("prop:DUE<5", InvalidPropProperty); - assert_err_kind("prop:lapses", InvalidPropOperator); - assert_err_kind("prop:pos~1", InvalidPropOperator); - assert_err_kind("prop:reps10", InvalidPropOperator); + 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); - assert_err_kind("prop:ease!=one", InvalidPropFloat); - assert_err_kind("prop:ease<1,3", InvalidPropFloat); + 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); - assert_err_kind("prop:due=0.5", InvalidPropInteger); - 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); - assert_err_kind("prop:reps=1.1", InvalidPropUnsigned); - assert_err_kind("prop:lapses!=-1", InvalidPropUnsigned); + 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(()) } From 91d56a9b918d0b352ec1d42b03d28dcfb0bccfcd Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 15 Jan 2021 09:10:19 +0100 Subject: [PATCH 13/15] Add translatable search error strings --- ftl/core/search.ftl | 114 +++++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index 18e8b9293..b539682f5 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -1,45 +1,91 @@ ## Errors shown when invalid search input is encountered. +## Strong text is literal search input and should not to be translated. search-invalid = Invalid search - please check for typing mistakes. -# The literal string `AND` is part of the search syntax. search-misplaced-and = - 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". -# The literal string `OR` is part of the search syntax. + 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 = - 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". + 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". search-empty-group = - A group was found but there was nothing between the - parentheses to search for. -search-unopened-group = search-unopened-group -search-unclosed-group = search-unclosed-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 = - A quote was found but there was nothing between the - double quotes to search for. + 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 = - An opening double quote `"` was found but there - is no second one to close it. -search-missing-key = A colon `:` must be preceded by a key. -search-unknown-escape = The escape sequence `` is unknown. -search-invalid-id-list = error -search-invalid-state = error -search-invalid-flag = error -search-invalid-added = error -search-invalid-edited = error -search-invalid-rated-days = error -search-invalid-rated-ease = error -search-invalid-dupe-mid = error -search-invalid-dupe-text = error -search-invalid-prop-property = error -search-invalid-prop-operator = error -search-invalid-prop-float = error -search-invalid-prop-integer = error -search-invalid-prop-unsigned = error -search-invalid-did = error -search-invalid-mid = error + 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 0 (rescheduled), 1 (again), 2 (hard), 3 (good) or 4 (easy). +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 From 105fa555f25cec47a72c95395521e3998d7ae79a Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 15 Jan 2021 09:43:36 +0100 Subject: [PATCH 14/15] Fix HTML formatting for search errors --- ftl/core/search.ftl | 112 +++++++++++--------------------------------- 1 file changed, 27 insertions(+), 85 deletions(-) diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index b539682f5..98574ebb4 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -1,91 +1,33 @@ ## Errors shown when invalid search input is encountered. -## Strong text is literal search input and should not to be translated. +## 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". -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 0 (rescheduled), 1 (again), 2 (hard), 3 (good) or 4 (easy). -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. +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 0 (rescheduled), 1 (again), 2 (hard), 3 (good) or 4 (easy). +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 From 5b03d1e2c8ba097b7585ec4a14e63b141855ac19 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 15 Jan 2021 10:57:55 +0100 Subject: [PATCH 15/15] Add resched error tests --- rslib/src/search/parser.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 4c26432e1..27f7cad34 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -877,6 +877,11 @@ mod test { 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);