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,