Support braces in multi command parser

We run into issues if we naively split the command input on ';' and
treat each part as a separate command unless the ';' is inside of a
string because it is also valid to have ';'s inside of braced
expressions, e.g. `set foo := { val x = 1; x + 1 }`. There was no parser
for expressions enclosed in braces. I add one that should parse any
expression wrapped in braces so long as each opening brace is matched by a
closing brace. The parser returns the original expression. This allows
the multi parser to ignore ';' inside of '{...}'.

I had to rework the scripted tests to individually run 'reload' and
'setUpScripted' because the new parser rejects setUpScripted because it
isn't a valid command until reload has run.
This commit is contained in:
Ethan Atkins 2019-06-13 23:48:51 -07:00
parent ccfc3d7bc7
commit 4c814752fb
7 changed files with 132 additions and 43 deletions

View File

@ -219,6 +219,30 @@ trait Parsers {
(DQuoteChar ~> (NotDQuoteBackslashClass | EscapeSequence).+.string <~ DQuoteChar |
(DQuoteChar ~ DQuoteChar) ^^^ "")
/**
* Parses a brace enclosed string and, if each opening brace is matched with a closing brace,
* it returns the entire string including the braces.
*
* @param open the opening character, e.g. '{'
* @param close the closing character, e.g. '}'
* @return a parser for the brace encloosed string.
*/
private[sbt] def braces(open: Char, close: Char): Parser[String] = {
val notDelim = charClass(c => c != open && c != close).*.string
def impl(): Parser[String] = {
(open ~ (notDelim ~ close).?).flatMap {
case (l, Some((content, r))) => Parser.success(l + content + r)
case (l, None) =>
((notDelim ~ impl()).map {
case (leftPrefix, nestedBraces) => leftPrefix + nestedBraces
}.+ ~ notDelim ~ close).map {
case ((nested, suffix), r) => l + nested.mkString + suffix + r
}
}
}
impl()
}
/**
* Parses a single escape sequence into the represented Char.
* Escapes start with a backslash and are followed by `u` for a [[UnicodeEscape]] or by `b`, `t`, `n`, `f`, `r`, `"`, `'`, `\` for standard escapes.

View File

@ -155,29 +155,33 @@ object BasicCommands {
private[sbt] def multiParserImpl(state: Option[State]): Parser[List[String]] = {
val nonSemi = charClass(_ != ';', "not ';'")
val semi = token(';' ~> OptSpace)
val nonQuote = charClass(_ != '"', label = "not '\"'")
val cmdPart = token(
((nonSemi & nonQuote).map(_.toString) | StringEscapable.map(c => s""""$c"""")).+,
hide = const(true)
)
lazy val combinedParser =
state.map(s => (s.nonMultiParsers & cmdPart) | cmdPart).getOrElse(cmdPart)
val part = semi.flatMap(_ => matched(combinedParser) <~ token(OptSpace))
(matched(cmdPart).? ~ part.+ <~ semi.?).map {
case (h, t) =>
val commands = (h ++ t).toList.map(_.trim)
commands.collect { case c if Parser.parse(c, combinedParser).isLeft => c } match {
case Nil => commands
case invalid =>
/*
* This is to prevent the user from running something like 'run a;b'. Instead, they
* must do 'run "a;b"' if they wish to have semicolongs in the task input.
*/
val msg = s"Couldn't parse commands: ${invalid.mkString("'", "', '", "'")}"
throw new IllegalArgumentException(msg)
val semi = token(OptSpace ~> ';' ~> OptSpace)
val nonDelim = charClass(c => c != '"' && c != '{' && c != '}', label = "not '\"', '{', '}'")
val cmdPart = OptSpace ~> matched(
token(
(nonSemi & nonDelim).map(_.toString) | StringEscapable | braces('{', '}'),
hide = const(true)
).+
) <~ OptSpace
val strictParser: Option[Parser[String]] =
state.map(s => OptSpace ~> matched(s.nonMultiParsers) <~ OptSpace)
val parser = strictParser.map(sp => sp & cmdPart).getOrElse(cmdPart)
/*
* There are two cases that need to be handled separately:
* 1) There are multiple commands separated by at least one semicolon with an optional
* leading semicolon.
* 2) There is a leading semicolon, but only on one command
* These have to be handled separately because the performance degrades badly if the first
* case is implemented with the following parser:
* (semi.? ~> ((combinedParser <~ semi).* ~ combinedParser <~ semi.?)
*/
(semi.? ~> (parser <~ semi).+ ~ (parser <~ semi.?).?).flatMap {
case (prefix, last) =>
(prefix ++ last).toList.map(_.trim).filter(_.nonEmpty) match {
case Nil => Parser.failure("No commands were parsed")
case cmds => Parser.success(cmds)
}
}
} | semi ~> parser.map(_.trim :: Nil) <~ semi.?
}
def multiParser(s: State): Parser[List[String]] = multiParserImpl(Some(s))
@ -440,8 +444,7 @@ object BasicCommands {
def aliasBody(name: String, value: String)(state: State): Parser[() => State] = {
val aliasRemoved = removeAlias(state, name)
// apply the alias value to the commands of `state` except for the alias to avoid recursion (#933)
val partiallyApplied =
Parser(Command.combine(aliasRemoved.definedCommands)(aliasRemoved))(value)
val partiallyApplied = Parser(aliasRemoved.combinedParser)(value)
val arg = matched(partiallyApplied & (success(()) | (SpaceClass ~ any.*)))
// by scheduling the expanded alias instead of directly executing,
// we get errors on the expanded string (#598)

View File

@ -41,8 +41,25 @@ final case class State(
currentCommand: Option[Exec],
next: State.Next
) extends Identity {
private[sbt] lazy val nonMultiParsers = Command.combine(definedCommands)(this)
lazy val combinedParser = (BasicCommands.multiApplied(this) | nonMultiParsers).failOnException
/*
* The `~` and `alias` commands effectively run other commands so they need to be run before
* the multi parser. For example, if the user runs `~foo;bar` and the multi parser runs before
* the `~` parser, then then it will be parsed as two commands `~foo` and `bar`. By running
* the high priority commands before the multi parser, we ensure that the high priority commands
* parse the full command input. Any other command that runs other commands would likely need
* to be added to this list but at the time of writing this comment {~, alias} are the two
* commands that we know need this special treatment.
*
* TODO: add a structured way of indicating that a command needs to run before the multi parser.
*/
private[this] val highPriorityCommands = Set("~", "alias")
private[this] lazy val (highPriority, regularPriority) =
definedCommands.partition(_.nameOption.exists(highPriorityCommands))
private[this] lazy val highPriorityParser = Command.combine(highPriority)(this)
private[this] lazy val lowPriorityParser = Command.combine(regularPriority)(this)
private[sbt] lazy val nonMultiParsers = highPriorityParser | lowPriorityParser
lazy val combinedParser =
highPriorityParser | BasicCommands.multiApplied(this) | lowPriorityParser
def source: Option[CommandSource] =
currentCommand match {

View File

@ -59,12 +59,41 @@ class MultiParserSpec extends FlatSpec with Matchers {
it should "not parse single commands without leading ';'" in {
"foo".parseEither shouldBe Left("Expected ';'\nfoo\n ^")
"foo bar baz".parseEither shouldBe Left("Expected ';'\nfoo bar baz\n ^")
"foo bar baz;".parseEither shouldBe
Left("Expected not ';'\nExpected '\"'\nfoo bar baz;\n ^")
"foo;".parseEither shouldBe Left("Expected not ';'\nExpected '\"'\nfoo;\n ^")
}
it should "not parse empty commands" in {
assert(";;;".parseEither.isLeft)
assert("; ; ;".parseEither.isLeft)
}
it should "parse commands with trailing semi-colon" in {
"foo;bar;".parse shouldBe Seq("foo", "bar")
"foo; bar ;".parse shouldBe Seq("foo", "bar")
assert("foo;bar;".parse == Seq("foo", "bar"))
assert("foo; bar ;".parse == Seq("foo", "bar"))
}
val consecutive = "{ { val x = 1}; { val x = 2 } }"
val oneBrace = "set foo := { val x = 1; x + 1 }"
val twoBrace = "set foo := { val x = { val y = 2; y + 2 }; x + 1 }"
val threeBrace = "set foo := { val x = { val y = 2; { val z = 3; y + 2 } }; x + 1 }"
val doubleBrace = "set foo := { val x = { val y = 2; y + 2 }; { x + 1 } }"
val tripleBrace = "set foo := { val x = { val y = 2; y + 2 }; val y = { x + 1 }; { z + y } }"
val emptyBraces = "{{{{}}}}"
it should "parse commands with braces" in {
assert(s"$consecutive;".parse == consecutive :: Nil)
assert(s"$oneBrace;".parse == oneBrace :: Nil)
assert(s"$twoBrace;".parse == twoBrace :: Nil)
assert(s"$threeBrace;".parse == threeBrace :: Nil)
assert(s"$doubleBrace;".parse == doubleBrace :: Nil)
assert(s"$tripleBrace;".parse == tripleBrace :: Nil)
assert(s"$emptyBraces;".parse == emptyBraces :: Nil)
}
it should "parse multiple commands with braces" in {
s"compile; $consecutive".parse shouldBe "compile" :: consecutive :: Nil
s"compile; $consecutive ; test".parse shouldBe "compile" :: consecutive :: "test" :: Nil
}
it should "not parse unclosed braces" in {
val extraRight = "{ { val x = 1}}{ val x = 2 } }"
assert(s"compile; $extraRight".parseEither.isLeft)
val extraLeft = "{{{ val x = 1}{ val x = 2 } }"
assert(s"compile; $extraLeft".parseEither.isLeft)
val unmatchedEmptyBraces = "{{{{}}}"
assert(s"compile; $unmatchedEmptyBraces".parseEither.isLeft)
}
}

View File

@ -13,4 +13,8 @@ taskThatFails := {
()
}
checkInput := checkInputImpl.evaluated
checkInput := checkInputImpl.evaluated
val dynamicTask = taskKey[Unit]("dynamic input task")
dynamicTask := { println("not yet et") }

View File

@ -1,3 +1,11 @@
> ;set dynamicTask := { println("1"); println("2") }; dynamicTask
-> ; set dynamicTask := { throw new IllegalStateException("fail") }; dynamicTask
> set dynamicTask := { println("1"); println("2") }; dynamicTask
-> set dynamicTask := { throw new IllegalStateException("fail") }; dynamicTask
> ; setStringValue baz
> ; checkStringValue baz

View File

@ -183,9 +183,11 @@ final class ScriptedTests(
s"$group/$name" match {
case "actions/add-alias" => LauncherBased // sbt/Package$
case "actions/cross-multiproject" => LauncherBased // tbd
case "actions/external-doc" => LauncherBased // sbt/Package$
case "actions/input-task" => LauncherBased // sbt/Package$
case "actions/input-task-dyn" => LauncherBased // sbt/Package$
case "actions/cross-multi-parser" =>
LauncherBased // java.lang.ClassNotFoundException: javax.tools.DiagnosticListener when run with java 11 and an old sbt launcher
case "actions/external-doc" => LauncherBased // sbt/Package$
case "actions/input-task" => LauncherBased // sbt/Package$
case "actions/input-task-dyn" => LauncherBased // sbt/Package$
case gn if gn.startsWith("classloader-cache/") =>
LauncherBased // This should be tested using launcher
case "compiler-project/dotty-compiler-plugin" => LauncherBased // sbt/Package$
@ -324,18 +326,20 @@ final class ScriptedTests(
IO.write(tempTestDir / "project" / "InstrumentScripted.scala", pluginImplementation)
def sbtHandlerError = sys error "Missing sbt handler. Scripted is misconfigured."
val sbtHandler = handlers.getOrElse('>', sbtHandlerError)
val commandsToRun = ";reload;setUpScripted"
val statement = Statement(commandsToRun, Nil, successExpected = true, line = -1)
// Run reload inside the hook to reuse error handling for pending tests
val wrapHook = (file: File) => {
preHook(file)
try runner.processStatement(sbtHandler, statement, states)
catch {
case t: Throwable =>
val newMsg = "Reload for scripted batch execution failed."
throw new TestException(statement, newMsg, t)
}
Seq("reload", "setUpScripted")
.map(Statement(_, Nil, successExpected = true, line = -1))
.foreach { statement =>
try runner.processStatement(sbtHandler, statement, states)
catch {
case t: Throwable =>
val newMsg = "Reload for scripted batch execution failed."
throw new TestException(statement, newMsg, t)
}
}
}
commonRunTest(label, tempTestDir, wrapHook, handlers, runner, states, buffer)