Merge pull request #4859 from eatkins/watch-defaults

Rework watch options
This commit is contained in:
Ethan Atkins 2019-07-12 15:17:54 -07:00 committed by GitHub
commit 8d20bd4c94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 170 additions and 49 deletions

View File

@ -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 =

View File

@ -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)

View File

@ -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("<enter>", "interrupt (exits sbt in batch mode)", Run(""), '\n', '\r'),
Watch.InputOption(4.toChar, "<ctrl-d>", "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 = "<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 <enter> 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,