mirror of https://github.com/sbt/sbt.git
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:
parent
ccfc3d7bc7
commit
4c814752fb
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,8 @@ taskThatFails := {
|
|||
()
|
||||
}
|
||||
|
||||
checkInput := checkInputImpl.evaluated
|
||||
checkInput := checkInputImpl.evaluated
|
||||
|
||||
val dynamicTask = taskKey[Unit]("dynamic input task")
|
||||
|
||||
dynamicTask := { println("not yet et") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue