From 263f00f3b262d922b0789ea32aeae547fb5b51fd Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 12 Jul 2019 12:21:11 -0700 Subject: [PATCH] Rework watch options In this commit, I both restore some sbt 1.2.8 behavior and enhance the api for setting keyboard shortcuts in watch. I change the default start message to just show the watch count, the tasks that are being monitored and, on a new line, the instructions to terminate the watch or show more options. Here's what it looks like: [info] 1. Monitoring source files for spark/compile... [info] Press to interrupt or '?' for more options. ? [info] Options: [info] : interrupt (exits sbt in batch mode) [info] : interrupt (exits sbt in batch mode) [info] 'r' : re-run the command [info] 's' : return to shell [info] 'q' : quit sbt [info] '?' : print options I also made it so that the new options can be added (and old options removed) with the watchInputOptions key. For example, to add an option to reload the build with the key 'l', you could add ThisBuild / watchInputOptions += Watch.InputOption('l', "reload", Watch.Reload) to your global build.sbt. After adding that to my global ~/sbt/1.0/global.sbt file, the output of '?' became: [info] Options: [info] : interrupt (exits sbt in batch mode) [info] : interrupt (exits sbt in batch mode) [info] '?' : print options [info] 'l' : reload [info] 'q' : quit sbt [info] 'r' : re-run the command [info] 's' : return to shell --- .../main/scala/sbt/internal/Continuous.scala | 91 +++++++++---- main/src/main/scala/sbt/nio/Keys.scala | 2 + main/src/main/scala/sbt/nio/Watch.scala | 126 ++++++++++++++---- 3 files changed, 170 insertions(+), 49 deletions(-) diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 80e948781..202e94fae 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -28,7 +28,7 @@ import sbt.internal.util.complete.{ Parser, Parsers } import sbt.internal.util.{ AttributeKey, JLine, Util } import sbt.nio.FileStamper.LastModified import sbt.nio.Keys.{ fileInputs, _ } -import sbt.nio.Watch.{ Creation, Deletion, Update } +import sbt.nio.Watch.{ Creation, Deletion, ShowOptions, Update } import sbt.nio.file.FileAttributes import sbt.nio.{ FileStamp, FileStamper, Watch } import sbt.util.{ Level, _ } @@ -468,11 +468,13 @@ private[sbt] object Continuous extends DeprecatedContinuous { val beforeCommand = () => configs.foreach(_.watchSettings.beforeCommand()) val onStart: () => Watch.Action = getOnStart(project, commands, configs, rawLogger, count, extracted) - val nextInputEvent: () => Watch.Action = parseInputEvents(configs, state, inputStream, logger) + val (message, parser, altParser) = getWatchInputOptions(configs, extracted) + val nextInputEvent: () => Watch.Action = + parseInputEvents(parser, altParser, state, inputStream, logger) val (nextFileEvent, cleanupFileMonitor): (() => Option[(Watch.Event, Watch.Action)], () => Unit) = getFileEvents(configs, rawLogger, state, count, commands, fileStampCache) val nextEvent: () => Watch.Action = - combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger) + combineInputAndFileEvents(nextInputEvent, nextFileEvent, message, logger, rawLogger) val onExit = () => cleanupFileMonitor() val onTermination = getOnTermination(configs, isCommand) new Callbacks(nextEvent, beforeCommand, onExit, onStart, onTermination) @@ -494,6 +496,38 @@ private[sbt] object Continuous extends DeprecatedContinuous { } } + private def getWatchInputOptions( + configs: Seq[Config], + extracted: Extracted + ): (String, Parser[Watch.Action], Option[(TaskKey[InputStream], InputStream => Watch.Action)]) = { + configs match { + case Seq(h) => + val settings = h.watchSettings + val parser = settings.inputParser + val alt = settings.inputStream.map { k => + k -> settings.inputHandler.getOrElse(defaultInputHandler(parser)) + } + (settings.inputOptionsMessage, parser, alt) + case _ => + val options = + extracted.getOpt(watchInputOptions in ThisBuild).getOrElse(Watch.defaultInputOptions) + val message = extracted + .getOpt(watchInputOptionsMessage in ThisBuild) + .getOrElse(Watch.defaultInputOptionsMessage(options)) + val parser = extracted + .getOpt(watchInputParser in ThisBuild) + .getOrElse(Watch.defaultInputParser(options)) + val alt = extracted + .getOpt(watchInputStream in ThisBuild) + .map { _ => + (watchInputStream in ThisBuild) -> extracted + .getOpt(watchInputHandler in ThisBuild) + .getOrElse(defaultInputHandler(parser)) + } + (message, parser, alt) + } + } + private def getOnStart( project: ProjectRef, commands: Seq[String], @@ -708,7 +742,7 @@ private[sbt] object Continuous extends DeprecatedContinuous { } logger.debug(s"Received file event actions: $builder. Returning: $min") if (min._2 == Watch.Trigger) onTrigger(min._1) - Some(min) + if (min._2 == Watch.ShowOptions) None else Some(min) } else None }, () => monitor.close()) } @@ -729,7 +763,8 @@ private[sbt] object Continuous extends DeprecatedContinuous { * `() => Seq[Watch.Action]` which avoids actually exposing the InputStream anywhere. */ private def parseInputEvents( - configs: Seq[Config], + parser: Parser[Watch.Action], + alternative: Option[(TaskKey[InputStream], InputStream => Watch.Action)], state: State, inputStream: InputStream, logger: Logger @@ -751,10 +786,9 @@ private[sbt] object Continuous extends DeprecatedContinuous { // If the Config.watchSettings.inputStream is set, the same process is applied except that // instead of passing in the wrapped InputStream for the input string, we directly pass // in the inputStream provided by Config.watchSettings.inputStream. - val inputHandlers: Seq[ActionParser] = configs.map { c => + val inputHandler: String => Watch.Action = { val any = Parsers.any.* - val inputParser = c.watchSettings.inputParser - val parser = any ~> inputParser ~ matched(any) + val fullParser = any ~> parser ~ matched(any) // Each parser gets its own copy of System.in that it can modify while parsing. val systemInBuilder = new StringBuilder @@ -762,34 +796,33 @@ private[sbt] object Continuous extends DeprecatedContinuous { // This string is provided in the closure below by reading from System.in val default: String => Watch.Action = - string => parse(inputStream(string), systemInBuilder, parser) - val alternative = c.watchSettings.inputStream - .map { inputStreamKey => - val is = extracted.runTask(inputStreamKey, state)._2 - val handler = c.watchSettings.inputHandler.getOrElse(defaultInputHandler(inputParser)) - () => handler(is) + string => parse(inputStream(string), systemInBuilder, fullParser) + val alt = alternative + .map { + case (key, handler) => + val is = extracted.runTask(key, state)._2 + () => handler(is) } .getOrElse(() => Watch.Ignore) - (string: String) => (default(string) :: alternative() :: Nil).min + string: String => (default(string) :: alt() :: Nil).min } () => { val stringBuilder = new StringBuilder while (inputStream.available > 0) stringBuilder += inputStream.read().toChar val newBytes = stringBuilder.toString val parse: ActionParser => Watch.Action = parser => parser(newBytes) - val allEvents = inputHandlers.map(parse).filterNot(_ == Watch.Ignore) - if (allEvents.exists(_ != Watch.Ignore)) { - val res = allEvents.min - logger.debug(s"Received input events: ${allEvents mkString ","}. Taking $res") - res - } else Watch.Ignore + val event = parse(inputHandler) + if (event != Watch.Ignore) logger.debug(s"Received input event: $event.") + event } } private def combineInputAndFileEvents( nextInputAction: () => Watch.Action, nextFileEvent: () => Option[(Watch.Event, Watch.Action)], - logger: Logger + options: String, + logger: Logger, + rawLogger: Logger ): () => Watch.Action = () => { val (inputAction: Watch.Action, fileEvent: Option[(Watch.Event, Watch.Action)] @unchecked) = Seq(nextInputAction, nextFileEvent).map(_.apply()).toIndexedSeq match { @@ -808,7 +841,13 @@ private[sbt] object Continuous extends DeprecatedContinuous { (if (action != min) s" Dropping in favor of input event: $min" else "") } .foreach(logger.debug(_)) - min + min match { + case ShowOptions => + println("") // This is so the [info] tag appears at the head of a newline + rawLogger.info(options) + Watch.Ignore + case m => m + } } @tailrec @@ -896,8 +935,12 @@ private[sbt] object Continuous extends DeprecatedContinuous { val deletionQuarantinePeriod: FiniteDuration = key.get(watchDeletionQuarantinePeriod).getOrElse(Watch.defaultDeletionQuarantinePeriod) val inputHandler: Option[InputStream => Watch.Action] = key.get(watchInputHandler) + val inputOptions: Seq[Watch.InputOption] = + key.get(watchInputOptions).getOrElse(Watch.defaultInputOptions) + val inputOptionsMessage: String = + key.get(watchInputOptionsMessage).getOrElse(Watch.defaultInputOptionsMessage(inputOptions)) val inputParser: Parser[Watch.Action] = - key.get(watchInputParser).getOrElse(Watch.defaultInputParser) + key.get(watchInputParser).getOrElse(Watch.defaultInputParser(inputOptions)) val logLevel: Level.Value = key.get(watchLogLevel).getOrElse(Level.Info) val beforeCommand: () => Unit = key.get(watchBeforeCommand).getOrElse(() => {}) val onFileInputEvent: WatchOnEvent = diff --git a/main/src/main/scala/sbt/nio/Keys.scala b/main/src/main/scala/sbt/nio/Keys.scala index a0c452777..4da20e066 100644 --- a/main/src/main/scala/sbt/nio/Keys.scala +++ b/main/src/main/scala/sbt/nio/Keys.scala @@ -76,6 +76,8 @@ object Keys { private[sbt] val watchInputHandler = settingKey[InputStream => Watch.Action]( "Function that is periodically invoked to determine if the continuous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands. This is only invoked if watchInputStream is set." ).withRank(DSetting) + val watchInputOptionsMessage = settingKey[String]("The help message for the watch input options") + val watchInputOptions = settingKey[Seq[Watch.InputOption]]("The available input options") val watchInputStream = taskKey[InputStream]( "The input stream to read for user input events. This will usually be System.in" ).withRank(DSetting) diff --git a/main/src/main/scala/sbt/nio/Watch.scala b/main/src/main/scala/sbt/nio/Watch.scala index 82bae33f8..efd7d552a 100644 --- a/main/src/main/scala/sbt/nio/Watch.scala +++ b/main/src/main/scala/sbt/nio/Watch.scala @@ -25,6 +25,7 @@ import sbt.nio.file.FileAttributes import sbt.util.{ Level, Logger } import scala.annotation.tailrec +import scala.collection.mutable import scala.concurrent.duration._ import scala.util.control.NonFatal @@ -164,8 +165,9 @@ object Watch { */ implicit object ordering extends Ordering[ContinueWatch] { override def compare(left: ContinueWatch, right: ContinueWatch): Int = left match { - case Ignore => if (right == Ignore) 0 else 1 - case Trigger => if (right == Trigger) 0 else -1 + case ShowOptions => if (right == ShowOptions) 0 else -1 + case Ignore => if (right == Ignore) 0 else 1 + case Trigger => if (right == Trigger) 0 else if (right == ShowOptions) 1 else -1 } } } @@ -236,11 +238,24 @@ object Watch { override def toString: String = s"HandleUnexpectedError($throwable)" } + /** + * Action that indicates that the watch should continue as though nothing happened. This may be + * because, for example, no user input was yet available. This trait can be used when we don't + * want to take action but we do want to perform a side effect like printing options to the + * terminal. + */ + sealed trait Ignore extends ContinueWatch + + /** + * Action that indicates that the available options should be printed. + */ + case object ShowOptions extends Ignore + /** * Action that indicates that the watch should continue as though nothing happened. This may be * because, for example, no user input was yet available. */ - case object Ignore extends ContinueWatch + case object Ignore extends Ignore /** * Action that indicates that the watch should pause while the build is reloaded. This is used to @@ -258,7 +273,8 @@ object Watch { } // For now leave this private in case this isn't the best unapply type signature since it can't // be evolved in a binary compatible way. - private object Run { + object Run { + def apply(commands: String*): Run = new Watch.Run(commands: _*) def unapply(r: Run): Option[List[Exec]] = Some(r.commands.toList.map(Exec(_, None))) } @@ -274,6 +290,45 @@ object Watch { */ trait Custom extends CancelWatch + trait InputOption { + private[sbt] def parser: Parser[Watch.Action] + def input: String + def display: String + def description: String + override def toString: String = + s"InputOption(input = $input, display = $display, description = $description)" + } + object InputOption { + private class impl( + override val input: String, + override val display: String, + override val description: String, + action: Action + ) extends InputOption { + override private[sbt] def parser: Parser[Watch.Action] = input ^^^ action + } + def apply(key: Char, description: String, action: Action): InputOption = + new impl(key.toString, s"'$key'", description, action) + def apply(key: Char, display: String, description: String, action: Action): InputOption = + new impl(key.toString, display, description, action) + def apply( + display: String, + description: String, + action: Action, + chars: Char* + ): InputOption = + new impl(chars.mkString("|"), display, description, action) { + override private[sbt] def parser: Parser[Watch.Action] = chars match { + case Seq(c) => c ^^^ action + case Seq(h, rest @ _*) => rest.foldLeft(h: Parser[Char])(_ | _) ^^^ action + } + } + def apply(input: String, description: String, action: Action): InputOption = + new impl(input, s"'$input'", description, action) + def apply(input: String, display: String, description: String, action: Action): InputOption = + new impl(input, display, description, action) + } + private type NextAction = () => Watch.Action /** @@ -385,27 +440,44 @@ object Watch { * 2) 'r' or 'R' will trigger a build * 3) new line characters cancel the watch and return to the shell */ - final val defaultInputParser: Parser[Action] = { - val exitParser: Parser[Action] = chars("xX") ^^^ new Run("exit") - val rebuildParser: Parser[Action] = chars("rR") ^^^ Trigger - val cancelParser: Parser[Action] = chars(legal = "\n\r") ^^^ new Run("iflast shell") - exitParser | rebuildParser | cancelParser - } + final def defaultInputParser(options: Seq[Watch.InputOption]): Parser[Action] = + distinctOptions(options) match { + case Seq() => (('\n': Parser[Char]) | '\r' | 4.toChar) ^^^ Run("") + case Seq(h, rest @ _*) => rest.foldLeft(h.parser)(_ | _.parser) + } + final val defaultInputOptions: Seq[Watch.InputOption] = Seq( + Watch.InputOption("", "interrupt (exits sbt in batch mode)", Run(""), '\n', '\r'), + Watch.InputOption(4.toChar, "", "interrupt (exits sbt in batch mode)", Run("")), + Watch.InputOption('r', "re-run the command", Trigger), + Watch.InputOption('s', "return to shell", Run("iflast shell")), + Watch.InputOption('q', "quit sbt", Run("exit")), + Watch.InputOption('?', "print options", ShowOptions) + ) - private[this] val options = { - val enter = "" - val opts = Seq( - s"$enter: return to the shell", - s"'r': repeat the current command", - s"'x': exit sbt" - ) - s"Options:\n${opts.mkString(" ", "\n ", "")}" + def defaultInputOptionsMessage(options: Seq[InputOption]): String = { + val opts = distinctOptions(options).sortBy(_.input) + val alignmentLength = opts.map(_.display.length).max + 1 + val formatted = + opts.map(o => s"${o.display}${" " * (alignmentLength - o.display.length)}: ${o.description}") + s"Options:\n${formatted.mkString(" ", "\n ", "")}" } - private def waitMessage(project: ProjectRef, commands: Seq[String]): String = { - val plural = if (commands.size > 1) "s" else "" - val cmds = commands.mkString("; ") - s"Monitoring source files for updates...\n" + - s"Project: ${project.project}\nCommand$plural: $cmds\n$options" + private def distinctOptions(options: Seq[InputOption]): Seq[InputOption] = { + val distinctOpts = mutable.Set.empty[String] + val opts = new mutable.ArrayBuffer[InputOption] + (options match { + case Seq() => defaultInputOptions.headOption.toSeq + case s => s + }).reverse.foreach { o => + if (distinctOpts.add(o.input)) opts += o + } + opts.reverse + } + private def waitMessage(project: ProjectRef, commands: Seq[String]): Seq[String] = { + val cmds = commands.map(project.project + "/" + _.trim).mkString("; ") + Seq( + s"Monitoring source files for $cmds...", + s"Press to interrupt or '?' for more options." + ) } /** @@ -414,13 +486,16 @@ object Watch { */ val defaultStartWatch: (Int, ProjectRef, Seq[String]) => Option[String] = { (count: Int, project: ProjectRef, commands: Seq[String]) => - Some(s"$count. ${waitMessage(project, commands)}") + { + val countStr = s"$count. " + Some(s"$countStr${waitMessage(project, commands).mkString(s"\n${" " * countStr.length}")}") + } }.label("Watched.defaultStartWatch") /** * Default no-op callback. */ - val defaultBeforeCommand: () => Unit = () => {} + val defaultBeforeCommand: () => Unit = (() => {}).label("Watch.defaultBeforeCommand") private[sbt] val defaultCommandOnTermination: (Action, String, Int, State) => State = onTerminationImpl(ContinuousExecutePrefix).label("Watched.defaultCommandOnTermination") @@ -495,6 +570,7 @@ object Watch { watchOnFileInputEvent :== Watch.trigger, watchDeletionQuarantinePeriod :== Watch.defaultDeletionQuarantinePeriod, sbt.Keys.watchService :== Watched.newWatchService, + watchInputOptions :== Watch.defaultInputOptions, watchStartMessage :== Watch.defaultStartWatch, watchTasks := Continuous.continuousTask.evaluated, sbt.Keys.aggregate in watchTasks :== false,