From 8f754e15258f7457d7c364f14e5797539cae6b4f Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 14 Jan 2021 19:09:31 +0100 Subject: [PATCH] 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("!="),