Skip to content

Commit

Permalink
Exposed DefaultErrorBuilder helpers (#205)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
j-mie6 authored Jun 24, 2023
1 parent 0d5ac65 commit 63c9561
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 69 deletions.
8 changes: 0 additions & 8 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import org.scalajs.linker.interface.ESVersion
import com.typesafe.tools.mima.core._

val projectName = "parsley"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions parsley/shared/src/main/scala/parsley/Parsley.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions parsley/shared/src/main/scala/parsley/character.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
})
}
Expand Down Expand Up @@ -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"
})
}
Expand Down
228 changes: 177 additions & 51 deletions parsley/shared/src/main/scala/parsley/errors/DefaultErrorBuilder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 */
Expand All @@ -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$
12 changes: 8 additions & 4 deletions parsley/shared/src/main/scala/parsley/errors/helpers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
1 change: 0 additions & 1 deletion project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 63c9561

Please sign in to comment.