From 63c9561a60e7fe6c5a7911d91a489529360e3da0 Mon Sep 17 00:00:00 2001 From: Jamie Willis Date: Sat, 24 Jun 2023 23:20:11 +0100 Subject: [PATCH] Exposed `DefaultErrorBuilder` helpers (#205) * Some functionality shuffled around * Factored out a couple more private methods * Factored out input line formatting * Factored remaining implementations * Exposed methods, needs docs * Remove bloop manual overrides * Added documentation * Added type signatures --- build.sbt | 8 - .../src/main/scala/parsley/Parsley.scala | 4 +- .../src/main/scala/parsley/character.scala | 4 +- .../parsley/errors/DefaultErrorBuilder.scala | 228 ++++++++++++++---- .../main/scala/parsley/errors/helpers.scala | 12 +- .../parsley/token/errors/ErrorConfig.scala | 3 +- project/plugins.sbt | 1 - 7 files changed, 191 insertions(+), 69 deletions(-) diff --git a/build.sbt b/build.sbt index 3c0d3a031..0b530ddf1 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,3 @@ -import org.scalajs.linker.interface.ESVersion import com.typesafe.tools.mima.core._ val projectName = "parsley" @@ -74,7 +73,6 @@ inThisBuild(List( tlCiReleaseBranches := Seq(mainBranch), tlCiScalafmtCheck := false, tlCiHeaderCheck := false, //FIXME: to be honest, we could turn off the scala-check for this and do it here instead (2020 year) - tlSonatypeUseLegacyHost := false, githubWorkflowJavaVersions := Seq(Java8, JavaLTS, JavaLatest), // We need this because our release uses different flags githubWorkflowArtifactUpload := false, @@ -105,14 +103,8 @@ lazy val parsley = crossProject(JSPlatform, JVMPlatform, NativePlatform) Compile / doc / scalacOptions ++= Seq("-groups", "-doc-root-content", s"${baseDirectory.value.getParentFile.getPath}/rootdoc.md"), ) .jsSettings( - Compile / bloopGenerate := None, // JS lacks the IO module, so has its own rootdoc Compile / doc / scalacOptions ++= Seq("-groups", "-doc-root-content", s"${baseDirectory.value.getPath}/rootdoc.md"), - Test / bloopGenerate := None, - ) - .nativeSettings( - Compile / bloopGenerate := None, - Test / bloopGenerate := None, ) def testCoverageJob(cacheSteps: List[WorkflowStep]) = WorkflowJob( diff --git a/parsley/shared/src/main/scala/parsley/Parsley.scala b/parsley/shared/src/main/scala/parsley/Parsley.scala index 22a7712ce..ec073fcb5 100644 --- a/parsley/shared/src/main/scala/parsley/Parsley.scala +++ b/parsley/shared/src/main/scala/parsley/Parsley.scala @@ -191,12 +191,12 @@ final class Parsley[+A] private [parsley] (private [parsley] val internal: front /** Replaces the result of this parser with `()`. * * This combinator is useful when the result of this parser is not required, and the - * type must be `Parsley[Unit]`. Functionally the same as `this #> ()`. + * type must be `Parsley[Unit]`. Functionally the same as `this.as(())`. * * @return a new parser that behaves the same as this parser, but always returns `()` on success. * @group map */ - def void: Parsley[Unit] = this #> (()) + def void: Parsley[Unit] = this.as(()) // BRANCHING COMBINATORS /** This combinator, pronounced "or", $or diff --git a/parsley/shared/src/main/scala/parsley/character.scala b/parsley/shared/src/main/scala/parsley/character.scala index 59e4916e6..aaaed4081 100644 --- a/parsley/shared/src/main/scala/parsley/character.scala +++ b/parsley/shared/src/main/scala/parsley/character.scala @@ -220,7 +220,7 @@ object character { case 0 => empty case 1 => char(cs.head) case _ => satisfy(cs, { - val Some(label) = parsley.errors.helpers.combineAsList(cs.map(renderChar).toList): @unchecked + val Some(label) = parsley.errors.helpers.disjunct(cs.map(renderChar).toList, oxfordComma = true): @unchecked s"one of $label" }) } @@ -310,7 +310,7 @@ object character { case 0 => item case 1 => satisfy(cs.head != _, s"anything except ${renderChar(cs.head)}") case _ => satisfy(!cs.contains(_), { - val Some(label) = parsley.errors.helpers.combineAsList(cs.map(renderChar).toList): @unchecked + val Some(label) = parsley.errors.helpers.disjunct(cs.map(renderChar).toList, oxfordComma = true): @unchecked s"anything except $label" }) } diff --git a/parsley/shared/src/main/scala/parsley/errors/DefaultErrorBuilder.scala b/parsley/shared/src/main/scala/parsley/errors/DefaultErrorBuilder.scala index 80592879d..a27246744 100644 --- a/parsley/shared/src/main/scala/parsley/errors/DefaultErrorBuilder.scala +++ b/parsley/shared/src/main/scala/parsley/errors/DefaultErrorBuilder.scala @@ -19,67 +19,43 @@ package parsley.errors */ abstract class DefaultErrorBuilder extends ErrorBuilder[String] { /** @inheritdoc */ - override def format(pos: Position, source: Source, lines: ErrorInfoLines): String = { - s"${source.fold("")(name => s"In $name ")}$pos:\n${lines.mkString(" ", "\n ", "")}" - } + override def format(pos: Position, source: Source, lines: ErrorInfoLines): String = DefaultErrorBuilder.format(pos, source, lines) //override def format(pos: Position, source: Source, ctxs: NestedContexts, lines: ErrorInfoLines): String = { - // s"${mergeScopes(source, ctxs)}$pos:\n${lines.mkString(" ", "\n ", "")}" + // DefaultErrorBuilder.blockError(header = s"${DefaultErrorBuilder.mergeScopes(source, ctxs)}$pos", lines, indent = 2)" //} - /*protected def mergeScopes(source: Source, ctxs: NestedContexts): String = (source, ctxs) match { - case (None, None) => "" - case (Some(name), None) => s"In $name " - case (None, Some(ctxs)) => s"In $ctxs " - case (Some(name), Some(ctxs)) => s"In $name, $ctxs " - }*/ - /** @inheritdoc */ type Position = String /** @inheritdoc */ type Source = Option[String] //type Context = Option[String] /** @inheritdoc */ - override def pos(line: Int, col: Int): Position = s"(line ${Integer.toUnsignedString(line)}, column ${Integer.toUnsignedString(col)})" + override def pos(line: Int, col: Int): Position = DefaultErrorBuilder.pos(line, col) /** @inheritdoc */ - override def source(sourceName: Option[String]): Source = sourceName.map(name => s"file '$name'") - //override def contexualScope(context: String): Context = Some(context) + override def source(sourceName: Option[String]): Source = DefaultErrorBuilder.source(sourceName) + //override def contexualScope(context: String): Context = ??? //type NestedContexts = Option[String] - /*override def nestContexts(contexts: List[Context]): NestedContexts = { - val nonEmptyContexts = contexts.flatten - if (nonEmptyContexts.nonEmpty) Some(nonEmptyContexts.mkString(", ")) - else None - }*/ + /*override def nestContexts(contexts: List[Context]): NestedContexts = ???*/ /** @inheritdoc */ type ErrorInfoLines = Seq[String] /** @inheritdoc */ override def vanillaError(unexpected: UnexpectedLine, expected: ExpectedLine, reasons: Messages, lines: LineInfo): ErrorInfoLines = { - val reasons_ = reasons.collect { - case reason if reason.nonEmpty => Some(reason) - } - combineOrUnknown((unexpected +: expected +: reasons_).flatten, lines) + DefaultErrorBuilder.vanillaError(unexpected, expected, reasons, lines) } /** @inheritdoc */ - override def specialisedError(msgs: Messages, lines: LineInfo): ErrorInfoLines = combineOrUnknown(msgs, lines) - - /** @inheritdoc */ - private def combineOrUnknown(info: Seq[String], lines: Seq[String]): ErrorInfoLines = { - if (info.isEmpty) DefaultErrorBuilder.Unknown +: lines - else info ++: lines - } + override def specialisedError(msgs: Messages, lines: LineInfo): ErrorInfoLines = DefaultErrorBuilder.specialisedError(msgs, lines) /** @inheritdoc */ type ExpectedItems = Option[String] /** @inheritdoc */ type Messages = Seq[Message] /** @inheritdoc */ - override def combineExpectedItems(alts: Set[Item]): ExpectedItems = { - helpers.combineAsList(alts.toList.filter(_.nonEmpty)) - } + override def combineExpectedItems(alts: Set[Item]): ExpectedItems = DefaultErrorBuilder.disjunct(alts) /** @inheritdoc */ - override def combineMessages(alts: Seq[Message]): Messages = alts.filter(_.nonEmpty) + override def combineMessages(alts: Seq[Message]): Messages = DefaultErrorBuilder.combineMessages(alts) /** @inheritdoc */ type UnexpectedLine = Option[String] @@ -90,28 +66,23 @@ abstract class DefaultErrorBuilder extends ErrorBuilder[String] { /** @inheritdoc */ type LineInfo = Seq[String] /** @inheritdoc */ - override def unexpected(item: Option[Item]): UnexpectedLine = item.map("unexpected " + _) + override def unexpected(item: Option[Item]): UnexpectedLine = DefaultErrorBuilder.unexpected(item) /** @inheritdoc */ - override def expected(alts: ExpectedItems): ExpectedLine = alts.map("expected " + _) + override def expected(alts: ExpectedItems): ExpectedLine = DefaultErrorBuilder.expected(alts) /** @inheritdoc */ - override def reason(reason: String): Message = reason + override def reason(reason: String): Message = DefaultErrorBuilder.reason(reason) /** @inheritdoc */ - override def message(msg: String): Message = msg + override def message(msg: String): Message = DefaultErrorBuilder.message(msg) /** @inheritdoc */ - override val numLinesBefore = 1 + override val numLinesBefore = DefaultErrorBuilder.NumLinesBefore /** @inheritdoc */ - override val numLinesAfter = 1 + override val numLinesAfter = DefaultErrorBuilder.NumLinesAfter /** @inheritdoc */ override def lineInfo(line: String, linesBefore: Seq[String], linesAfter: Seq[String], errorPointsAt: Int, errorWidth: Int): LineInfo = { - linesBefore.map(line => s"$errorLineStart$line") ++: - Seq(s"$errorLineStart$line", s"${" " * errorLineStart.length}${errorPointer(errorPointsAt, errorWidth)}") ++: - linesAfter.map(line => s"$errorLineStart$line") + DefaultErrorBuilder.lineInfo(line, linesBefore, linesAfter, errorPointsAt, errorWidth) } - private val errorLineStart = ">" - private def errorPointer(caretAt: Int, caretWidth: Int) = s"${" " * caretAt}${"^" * caretWidth}" - /** @inheritdoc */ type Item = String /** @inheritdoc */ @@ -121,13 +92,168 @@ abstract class DefaultErrorBuilder extends ErrorBuilder[String] { /** @inheritdoc */ type EndOfInput = String /** @inheritdoc */ - override def raw(item: String): Raw = helpers.renderRawString(item) + override def raw(item: String): Raw = DefaultErrorBuilder.raw(item) /** @inheritdoc */ - override def named(item: String): Named = item + override def named(item: String): Named = DefaultErrorBuilder.named(item) /** @inheritdoc */ - override val endOfInput: EndOfInput = "end of input" + override val endOfInput: EndOfInput = DefaultErrorBuilder.EndOfInput } -private object DefaultErrorBuilder { - private val Unknown = "unknown parse error" +/** Helper functions used to build the `DefaultErrorBuilder` error messages. + * + * @since 4.3.0 + */ +object DefaultErrorBuilder { + final val Unknown = "unknown parse error" + final val EndOfInput = "end of input" + final val ErrorLineStart = ">" + final val NumLinesBefore = 1 + final val NumLinesAfter = 1 + + /** Forms an error message with `blockError`, with two spaces of indentation and + * incorporating the source file and position into the header. + * + * @since 4.3.0 + */ + def format(pos: String, source: Option[String], lines: Seq[String]): String = { + blockError(header = s"${source.fold("")(name => s"In $name ")}$pos", lines, indent = 2) + } + /** If the `sourceName` exists, wraps it in quotes and adds `file` onto the front. + * + * @since 4.3.0 + */ + def source(sourceName: Option[String]): Option[String] = sourceName.map(name => s"file '$name'") + /** Forms a vanilla error by combining all the components in sequence, if there is no information + * other than the `lines`, [[Unknown `Unknown`]] is used instead. + * + * @since 4.3.0 + */ + def vanillaError(unexpected: Option[String], expected: Option[String], reasons: Iterable[String], lines: Seq[String]): Seq[String] = { + DefaultErrorBuilder.combineInfoWithLines(Seq.concat(unexpected, expected, reasons), lines) + } + /** Forms a specialised error by combining all components in sequence, if there are no `msgs`, then + * [[Unknown `Unknown`]] is used instead. + * + * @since 4.3.0 + */ + def specialisedError(msgs: Seq[String], lines: Seq[String]): Seq[String] = DefaultErrorBuilder.combineInfoWithLines(msgs, lines) + + /** Forms an error with the given `header` followed by a colon, a newline, then the remainder of the lines indented. + * + * @since 4.3.0 + */ + def blockError(header: String, lines: Iterable[String], indent: Int): String = s"$header:\n${indentAndUnlines(lines, indent)}" + /** Indents and concatenates the given lines by the given depth. + * + * @since 4.3.0 + */ + def indentAndUnlines(lines: Iterable[String], indent: Int): String = lines.mkString(" " * indent, "\n" + " " * indent, "") + + /** Pairs the line and column up in the form `(line m, column n)`. + * + * @since 4.3.0 + */ + def pos(line: Int, col: Int): String = s"(line ${Integer.toUnsignedString(line)}, column ${Integer.toUnsignedString(col)})" + + /** Combines the alternatives, separated by commas/semicolons, with the final two separated + * by "or". An '''Oxford comma''' is added if there are more than two elements, as this + * helps prevent ambiguity in the list. If the elements contain a comma, then semicolon + * is used as the list separator. + * + * @since 4.3.0 + */ + def disjunct(alts: Iterable[String]): Option[String] = disjunct(alts, oxfordComma = true) + /** Combines the alternatives, separated by commas/semicolons, with the final two separated + * by "or". If the elements contain a comma, then semicolon + * is used as the list separator. + * + * @param oxfordComma decides whether or not to employ an '''Oxford comma''' when there + * more than two elements to join: this helps prevent ambiguity in the list. + * @since 4.3.0 + */ + def disjunct(alts: Iterable[String], oxfordComma: Boolean): Option[String] = helpers.disjunct(alts.toList.filter(_.nonEmpty), oxfordComma) + + /** Filters out any empty messages and returns the rest. + * + * @since 4.3.0 + */ + def combineMessages(alts: Seq[String]): Seq[String] = alts.filter(_.nonEmpty) + + /** Joins together the given sequences: if the first is empty, then [[Unknown `Unknown`]] + * is prepended onto `lines` instead. + * + * @since 4.3.0 + */ + def combineInfoWithLines(info: Seq[String], lines: Seq[String]): Seq[String] = { + if (info.isEmpty) Unknown +: lines + else info ++: lines + } + + /** Adds "unexpected " before the given item should it exist. + * + * @since 4.3.0 + */ + def unexpected(item: Option[String]): Option[String] = item.map("unexpected " + _) + /** Adds "expected " before the given alternatives should they exist. + * + * @since 4.3.0 + */ + def expected(alts: Option[String]): Option[String] = alts.map("expected " + _) + /** Returns the given reason unchanged. + * + * @since 4.3.0 + */ + def reason(reason: String): String = reason + /** Returns the given message unchanged. + * + * @since 4.3.0 + */ + def message(msg: String): String = msg + + /** If the given item is either a whitespace character or is otherwise "unprintable", + * a special name is given to it, otherwise the item is enclosed in double-quotes. + * + * @since 4.3.0 + */ + def raw(item: String): String = helpers.renderRawString(item) + /** Returns the given item unchanged. + * + * @since 4.3.0 + */ + def named(item: String): String = item + + /** Constructs error context by concatenating them together with a "caret line" underneath the + * focus line, `line`, where the error occurs. + * + * @since 4.3.0 + */ + def lineInfo(line: String, linesBefore: Seq[String], linesAfter: Seq[String], errorPointsAt: Int, errorWidth: Int): Seq[String] = { + Seq.concat(linesBefore.map(inputLine), Seq(inputLine(line), caretLine(errorPointsAt, errorWidth)), linesAfter.map(inputLine)) + } + + /** Adds the [[ErrorLineStart `ErrorLineStart`]] character to the front of the given line. + * + * @since 4.3.0 + */ + def inputLine(line: String): String = s"$ErrorLineStart$line" + /** Generates a line of `^` characters as wide as specified starting as seen in as the given + * position, accounting for the length of the [[ErrorLineStart `ErrorLineStart`]] too. + * + * @since 4.3.0 + */ + def caretLine(caretAt: Int, caretWidth: Int): String = s"${" " * (ErrorLineStart.length + caretAt)}${"^" * caretWidth}" + + /*def mergeScopes(source: Option[String], ctxs: Option[String]): String = (source, ctxs) match { + case (None, None) => "" + case (Some(name), None) => s"In $name " + case (None, Some(ctxs)) => s"In $ctxs " + case (Some(name), Some(ctxs)) => s"In $name, $ctxs " + }*/ + + /*def nestContexts(contexts: List[Option[String]]): Option[String] = { + val nonEmptyContexts = contexts.flatten + if (nonEmptyContexts.nonEmpty) Some(nonEmptyContexts.mkString(", ")) + else None + }*/ + /*def contexualScope(context: String): Option[String] = Some(context)*/ } // $COVERAGE-ON$ diff --git a/parsley/shared/src/main/scala/parsley/errors/helpers.scala b/parsley/shared/src/main/scala/parsley/errors/helpers.scala index 972db5d4d..330f2427d 100644 --- a/parsley/shared/src/main/scala/parsley/errors/helpers.scala +++ b/parsley/shared/src/main/scala/parsley/errors/helpers.scala @@ -15,14 +15,18 @@ private [parsley] object helpers { case cs => "\"" + cs + "\"" } - def combineAsList(elems: List[String]): Option[String] = elems.sorted.reverse match { + private def junct(init: List[String], last: String, delim: String, junction: String, oxfordComma: Boolean): String = { + init.mkString(start = "", sep = delim, end = if (oxfordComma) s"$delim$junction $last" else s" $junction $last") + } + def junct(elems: List[String], junction: String, oxfordComma: Boolean): Option[String] = elems.sorted(Ordering[String].reverse) match { case Nil => None case List(alt) => Some(alt) - case List(alt1, alt2) => Some(s"$alt2 or $alt1") + case List(alt1, alt2) => Some(s"$alt2 $junction $alt1") // If the result would contains "," then it's probably nicer to preserve any potential grouping using ";" - case any@(alt::alts) if any.exists(_.contains(",")) => Some(s"${alts.reverse.mkString("; ")}; or $alt") - case alt::alts => Some(s"${alts.reverse.mkString(", ")}, or $alt") + case any@(alt::alts) if any.exists(_.contains(",")) => Some(junct(alts.reverse, alt, delim = "; ", junction = junction, oxfordComma = oxfordComma)) + case alt::alts => Some(junct(alts.reverse, alt, delim = ", ", junction = junction, oxfordComma = true)) } + def disjunct(elems: List[String], oxfordComma: Boolean): Option[String] = junct(elems, junction = "or", oxfordComma) // $COVERAGE-ON$ object WhitespaceOrUnprintable { diff --git a/parsley/shared/src/main/scala/parsley/token/errors/ErrorConfig.scala b/parsley/shared/src/main/scala/parsley/token/errors/ErrorConfig.scala index 5efb03995..7eec801ad 100644 --- a/parsley/shared/src/main/scala/parsley/token/errors/ErrorConfig.scala +++ b/parsley/shared/src/main/scala/parsley/token/errors/ErrorConfig.scala @@ -682,7 +682,8 @@ class ErrorConfig { new SpecialisedMessage[Int] { def message(got: Int) = { assume(needed.nonEmpty, "cannot be empty!") - Seq(s"numeric escape requires ${parsley.errors.helpers.combineAsList(needed.toList.map(_.toString)).get} digits, but only got $got") + val Some(formatted) = parsley.errors.helpers.disjunct(needed.toList.map(_.toString), oxfordComma = true): @unchecked + Seq(s"numeric escape requires $formatted digits, but only got $got") } } diff --git a/project/plugins.sbt b/project/plugins.sbt index 51e60bb8e..69e121b3d 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -15,7 +15,6 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.2") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.14") // This is here purely to enable the niceness settings -addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.6") addSbtPlugin("com.beautiful-scala" % "sbt-scalastyle" % "1.5.1") addSbtPlugin("org.jmotor.sbt" % "sbt-dependency-updates" % "1.2.7") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")