diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala index cea6c09e..cf9b491c 100644 --- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala @@ -9,17 +9,18 @@ import docspell.query.internal.ExprUtil object ItemQueryParser { @JSExport - def parse(input: String): Either[String, ItemQuery] = + def parse(input: String): Either[ParseFailure, ItemQuery] = if (input.isEmpty) Right(ItemQuery.all) else { val in = if (input.charAt(0) == '(') input else s"(& $input )" ExprParser .parseQuery(in) .left - .map(pe => s"Error parsing: '$input': $pe") //TODO + .map(ParseFailure.fromError(in)) .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) } def parseUnsafe(input: String): ItemQuery = - parse(input).fold(sys.error, identity) + parse(input).fold(m => sys.error(m.render), identity) + } diff --git a/modules/query/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/src/main/scala/docspell/query/ParseFailure.scala new file mode 100644 index 00000000..05235c03 --- /dev/null +++ b/modules/query/src/main/scala/docspell/query/ParseFailure.scala @@ -0,0 +1,65 @@ +package docspell.query + +import cats.data.{NonEmptyList => Nel} +import cats.parse.Parser +import cats.parse.Parser.Expectation.EndOfString +import cats.parse.Parser.Expectation.ExpectedFailureAt +import cats.parse.Parser.Expectation.Fail +import cats.parse.Parser.Expectation.FailWith +import cats.parse.Parser.Expectation.InRange +import cats.parse.Parser.Expectation.Length +import cats.parse.Parser.Expectation.OneOfStr +import cats.parse.Parser.Expectation.StartOfString + +final case class ParseFailure( + input: String, + failedAt: Int, + messages: Nel[ParseFailure.Message] +) { + + def render: String = { + val items = messages.map(_.msg).toList.mkString(", ") + s"Failed to read input near $failedAt: $input\nDetails: $items" + } +} + +object ParseFailure { + + final case class Message(offset: Int, msg: String) + + private[query] def fromError(input: String)(pe: Parser.Error): ParseFailure = + ParseFailure( + input, + pe.failedAtOffset, + Parser.Expectation.unify(pe.expected).map(expectationToMsg) + ) + + private[query] def expectationToMsg(e: Parser.Expectation): Message = + e match { + case StartOfString(offset) => + Message(offset, "Expected start of string") + + case FailWith(offset, message) => + Message(offset, message) + + case InRange(offset, lower, upper) => + if (lower == upper) Message(offset, s"Expected character: $lower") + else Message(offset, s"Expected character from range: [$lower .. $upper]") + + case Length(offset, expected, actual) => + Message(offset, s"Expected input of length $expected, but got $actual") + + case ExpectedFailureAt(offset, matched) => + Message(offset, s"Expected failing, but matched '$matched'") + + case EndOfString(offset, length) => + Message(offset, s"Expected end of string at length: $length") + + case Fail(offset) => + Message(offset, s"Failed to parse near $offset") + + case OneOfStr(offset, strs) => + val options = strs.mkString(", ") + Message(offset, s"Expected one of the following strings: $options") + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 510273a0..e93e4e95 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -61,7 +61,7 @@ object ItemRoutes { val of = offset.getOrElse(0) query match { case Left(err) => - BadRequest(BasicResult(false, err)) + BadRequest(BasicResult(false, err.render)) case Right(sq) => for { items <- backend.itemSearch.findItems(cfg.maxNoteLength)(