diff --git a/internal/util-complete/src/main/scala/sbt/internal/util/complete/Parsers.scala b/internal/util-complete/src/main/scala/sbt/internal/util/complete/Parsers.scala index eb68fa679..037abccc8 100644 --- a/internal/util-complete/src/main/scala/sbt/internal/util/complete/Parsers.scala +++ b/internal/util-complete/src/main/scala/sbt/internal/util/complete/Parsers.scala @@ -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. diff --git a/main-command/src/main/scala/sbt/BasicCommands.scala b/main-command/src/main/scala/sbt/BasicCommands.scala index 841244227..3a01ac7b2 100644 --- a/main-command/src/main/scala/sbt/BasicCommands.scala +++ b/main-command/src/main/scala/sbt/BasicCommands.scala @@ -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) diff --git a/main-command/src/main/scala/sbt/State.scala b/main-command/src/main/scala/sbt/State.scala index af81a96de..d83d76b1c 100644 --- a/main-command/src/main/scala/sbt/State.scala +++ b/main-command/src/main/scala/sbt/State.scala @@ -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 { diff --git a/main-command/src/test/scala/sbt/MultiParserSpec.scala b/main-command/src/test/scala/sbt/MultiParserSpec.scala index cb2962399..4ade2ddc0 100644 --- a/main-command/src/test/scala/sbt/MultiParserSpec.scala +++ b/main-command/src/test/scala/sbt/MultiParserSpec.scala @@ -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) } } diff --git a/sbt/src/sbt-test/actions/multi-command/build.sbt b/sbt/src/sbt-test/actions/multi-command/build.sbt index bff6d9d45..5db2dc44f 100644 --- a/sbt/src/sbt-test/actions/multi-command/build.sbt +++ b/sbt/src/sbt-test/actions/multi-command/build.sbt @@ -13,4 +13,8 @@ taskThatFails := { () } -checkInput := checkInputImpl.evaluated \ No newline at end of file +checkInput := checkInputImpl.evaluated + +val dynamicTask = taskKey[Unit]("dynamic input task") + +dynamicTask := { println("not yet et") } diff --git a/sbt/src/sbt-test/actions/multi-command/test b/sbt/src/sbt-test/actions/multi-command/test index f2626b4d6..0e0e84d40 100644 --- a/sbt/src/sbt-test/actions/multi-command/test +++ b/sbt/src/sbt-test/actions/multi-command/test @@ -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 diff --git a/scripted-sbt-redux/src/main/scala/sbt/scriptedtest/ScriptedTests.scala b/scripted-sbt-redux/src/main/scala/sbt/scriptedtest/ScriptedTests.scala index 03d871e56..cf077a326 100644 --- a/scripted-sbt-redux/src/main/scala/sbt/scriptedtest/ScriptedTests.scala +++ b/scripted-sbt-redux/src/main/scala/sbt/scriptedtest/ScriptedTests.scala @@ -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)