From e868c43fcc8233d33b87405acd1801ec578ecd27 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 31 Jan 2019 11:18:27 -0800 Subject: [PATCH] Refactor Watched This is a huge refactor of Watched. I produced this through multiple rewrite iterations and it was too difficult to separate all of the changes into small individual commits so I, unfortunately, had to make a massive commit. In general, I have tried to document the source code extensively both to facilitate reading this commit and to help with future maintenance. These changes are quite complicated because they provided a built-in like api to a feature that is implemented like a plugin. In particular, we have to manually do a lot of parsing as well as roll our own task/setting evaluation because we cannot infer the watch settings at project build time because we do not know a priori what commands the user may watch in a given session. The dynamic setting and task evaluation is mostly confined to the WatchSettings class in Continuous. It feels dirty to do all of this extraction by hand, but it does seem to work correctly with scopes. At a high level this commit does four things: 1) migrate the watch implementation to using the InputGraph to collect the globs that it needs to monitor during the watch 2) simplify WatchConfig to make it easier for plugin authors to write their own custom watch implementations 3) allow configuration of the watch settings based on the task(s) that is/are being run 4) adds an InputTask implemenation of watch. Point #1 is mostly handled by Point #3 since I had to overhaul how _all_ of the watch settings are generated. InputGraph already handles both transitive inputs and triggers as well as legacy watchSources so not much additional logic is needed beyond passing the correct scoped keys into InputGraph. Point #3 require some structural changes. The watch settings cannot in general be defined statically because we don't know a priori what tasks the user will try and watch. To address this, I added code that will extract the task keys for all of the commands that we are running. I then manually extract the relevant settings for each command. Finally, I aggregate those settings into a single WatchConfig that can be used to actually implement the watch. The aggregation is generally straightforward: we run all of the callbacks for each task and choose the next watch state based on the highest priority Action that is returned by any of the callbacks. Because I needed Extracted to pull out the necessary settings, I was forced to move a lot of logic out of Watched and into a new singleton, Continuous, that exists in the main project (Watched is in the command project). The public footprint of Continuous is tiny. Even though I want to make the watch feature flexible for plugin authors, the implementation and api remain a moving target so I do not want to be limited by future binary compatibility requirements. Anyone who wants to live dangerously can access the private[sbt] apis via reflection or by adding custom code to the sbt package in their plugin (a technique I've used in CloseWatch). Point #2 is addressed by removing the count and lastStatus from the WatchConfig callbacks. While these parameters can be useful, they are not necessary to implement the semantics of a watch. Moreover, a status boolean isn't really that useful and the sbt task engine makes it very difficult to actually extract the previous result of the tasks that were run. After this refactor, WatchConfig has a simpler api. There are fewer callbacks to implement and the signatures are simpler. To preserve the _functionality_ of making the count accessible to the user specifiable callbacks, I still provided settings like watchOnInputEvent that accept a count parameter, but the count is actually tracked externally to Watched.watch and incremented every time the task is run. Moreover, there are a few parameters of the watch: the logger and transitive globs, that cannot be provided via settings. I provide callback settings like watchOnStart that mirror the WatchConfig callbacks except that they return a function from Continuous.Arguments to the needed callback. The Continuous.aggregate function will check if the watchOnStart setting is set and if it is, will pass in the needed arguments. Otherwise it will use the default watchOnStart implementation which simulates the existing behavior by tracking the iteration count in an AtomicInteger and passing the current count into the user provided callback. In this way, we are able to provide a number of apis to the watch process while preserving the default behavior. To implement #4, I had to change the label of the `watch` attribute key from "watch" to "watched". This allows `watch compile` to work at the sbt command line even thought it maps to the watchTasks key. The actual implementation is almost trivial. The difference between an InputTask[Unit] and a command is very small. The tricky part is that the actual implementation requires applying mapTask to a delegate task that overrides the Task's info.postTransform value (which is used to transform the state after task evaluation). The actual postTransform function can be shared by the continuous task and continuous command. There is just a slightly different mechanism for getting to the state transformation function. --- .../src/main/scala/sbt/BasicKeys.scala | 3 +- main-command/src/main/scala/sbt/Watched.scala | 801 ++++++++-------- .../src/test/scala/sbt/MultiParserSpec.scala | 2 +- .../src/test/scala/sbt/WatchedSpec.scala | 159 ++-- main-settings/src/main/scala/sbt/Append.scala | 11 +- main/src/main/scala/sbt/Defaults.scala | 112 +-- main/src/main/scala/sbt/Keys.scala | 46 +- main/src/main/scala/sbt/Main.scala | 10 +- main/src/main/scala/sbt/ScriptedPlugin.scala | 3 +- .../main/scala/sbt/internal/Continuous.scala | 873 ++++++++++++++++++ .../sbt/internal/DeprecatedContinuous.scala | 19 + .../scala/sbt/internal/DupedInputStream.scala | 73 ++ .../scala/sbt/internal/FileManagement.scala | 51 +- project/SbtLauncherPlugin.scala | 3 +- .../tests/interproject-inputs/build.sbt | 58 ++ .../src/main/scala/bar/Bar.scala | 0 .../src/main/scala/foo/Foo.scala | 0 .../sbt-test/tests/interproject-inputs/test | 5 + .../tests/transitive-inputs/build.sbt | 46 - sbt/src/sbt-test/tests/transitive-inputs/test | 5 - .../build.sbt | 4 +- .../project/Build.scala | 12 +- sbt/src/sbt-test/watch/command-parser/test | 21 + .../sbt-test/watch/custom-config/build.sbt | 5 + .../watch/custom-config/project/Build.scala | 40 + sbt/src/sbt-test/watch/custom-config/test | 7 + .../watch/input-aggregation/build.sbt | 7 + .../input-aggregation/project/Build.scala | 94 ++ sbt/src/sbt-test/watch/input-aggregation/test | 11 + sbt/src/sbt-test/watch/input-parser/build.sbt | 9 + .../watch/input-parser/project/Build.scala | 27 + sbt/src/sbt-test/watch/input-parser/test | 17 + .../sbt-test/watch/legacy-sources/build.sbt | 13 + .../watch/legacy-sources/project/Build.scala | 17 + sbt/src/sbt-test/watch/legacy-sources/test | 3 + .../sbt-test/watch/on-start-watch/build.sbt | 29 - .../watch/on-start-watch/changes/extra.sbt | 4 + .../sbt-test/watch/on-start-watch/extra.sbt | 1 + .../watch/on-start-watch/project/Count.scala | 8 +- sbt/src/sbt-test/watch/on-start-watch/test | 18 +- sbt/src/sbt-test/watch/task/build.sbt | 3 + .../sbt-test/watch/task/changes/Build.scala | 27 + .../sbt-test/watch/task/project/Build.scala | 34 + sbt/src/sbt-test/watch/task/test | 12 + sbt/src/sbt-test/watch/watch-parser/test | 21 - 45 files changed, 2011 insertions(+), 713 deletions(-) create mode 100644 main/src/main/scala/sbt/internal/Continuous.scala create mode 100644 main/src/main/scala/sbt/internal/DeprecatedContinuous.scala create mode 100644 main/src/main/scala/sbt/internal/DupedInputStream.scala create mode 100644 sbt/src/sbt-test/tests/interproject-inputs/build.sbt rename sbt/src/sbt-test/tests/{transitive-inputs => interproject-inputs}/src/main/scala/bar/Bar.scala (100%) rename sbt/src/sbt-test/tests/{transitive-inputs => interproject-inputs}/src/main/scala/foo/Foo.scala (100%) create mode 100644 sbt/src/sbt-test/tests/interproject-inputs/test delete mode 100644 sbt/src/sbt-test/tests/transitive-inputs/build.sbt delete mode 100644 sbt/src/sbt-test/tests/transitive-inputs/test rename sbt/src/sbt-test/watch/{watch-parser => command-parser}/build.sbt (57%) rename sbt/src/sbt-test/watch/{watch-parser => command-parser}/project/Build.scala (52%) create mode 100644 sbt/src/sbt-test/watch/command-parser/test create mode 100644 sbt/src/sbt-test/watch/custom-config/build.sbt create mode 100644 sbt/src/sbt-test/watch/custom-config/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/custom-config/test create mode 100644 sbt/src/sbt-test/watch/input-aggregation/build.sbt create mode 100644 sbt/src/sbt-test/watch/input-aggregation/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/input-aggregation/test create mode 100644 sbt/src/sbt-test/watch/input-parser/build.sbt create mode 100644 sbt/src/sbt-test/watch/input-parser/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/input-parser/test create mode 100644 sbt/src/sbt-test/watch/legacy-sources/build.sbt create mode 100644 sbt/src/sbt-test/watch/legacy-sources/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/legacy-sources/test create mode 100644 sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt create mode 100644 sbt/src/sbt-test/watch/on-start-watch/extra.sbt create mode 100644 sbt/src/sbt-test/watch/task/build.sbt create mode 100644 sbt/src/sbt-test/watch/task/changes/Build.scala create mode 100644 sbt/src/sbt-test/watch/task/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/task/test delete mode 100644 sbt/src/sbt-test/watch/watch-parser/test diff --git a/main-command/src/main/scala/sbt/BasicKeys.scala b/main-command/src/main/scala/sbt/BasicKeys.scala index b4ab63054..251423049 100644 --- a/main-command/src/main/scala/sbt/BasicKeys.scala +++ b/main-command/src/main/scala/sbt/BasicKeys.scala @@ -33,7 +33,8 @@ object BasicKeys { "The function that constructs the command prompt from the current build state.", 10000 ) - val watch = AttributeKey[Watched]("watch", "Continuous execution configuration.", 1000) + val watch = + AttributeKey[Watched]("watched", "Continuous execution configuration.", 1000) val serverPort = AttributeKey[Int]("server-port", "The port number used by server command.", 10000) diff --git a/main-command/src/main/scala/sbt/Watched.scala b/main-command/src/main/scala/sbt/Watched.scala index 91b7dad18..abc1f9412 100644 --- a/main-command/src/main/scala/sbt/Watched.scala +++ b/main-command/src/main/scala/sbt/Watched.scala @@ -8,27 +8,24 @@ package sbt import java.io.{ File, InputStream } -import java.nio.file.{ FileSystems, Path } +import java.nio.file.FileSystems -import sbt.BasicCommandStrings.{ - ContinuousExecutePrefix, - FailureWall, - continuousBriefHelp, - continuousDetail -} -import sbt.BasicCommands.otherCommandParser +import sbt.BasicCommandStrings.ContinuousExecutePrefix import sbt.internal.LabeledFunctions._ +import sbt.internal.{ FileAttributes, LegacyWatched } import sbt.internal.io.{ EventMonitor, Source, WatchState } import sbt.internal.util.Types.const -import sbt.internal.util.complete.{ DefaultParsers, Parser } -import sbt.internal.util.{ AttributeKey, JLine } -import sbt.internal.{ FileAttributes, LegacyWatched } +import sbt.internal.util.complete.DefaultParsers._ +import sbt.internal.util.complete.Parser +import sbt.internal.util.{ AttributeKey, JLine, Util } +import sbt.io.FileEventMonitor.{ Creation, Deletion, Event, Update } import sbt.io._ import sbt.util.{ Level, Logger } import scala.annotation.tailrec import scala.concurrent.duration._ import scala.util.Properties +import scala.util.control.NonFatal @deprecated("Watched is no longer used to implement continuous execution", "1.3.0") trait Watched { @@ -38,8 +35,8 @@ trait Watched { def terminateWatch(key: Int): Boolean = Watched.isEnter(key) /** - * The time in milliseconds between checking for changes. The actual time between the last change made to a file and the - * execution time is between `pollInterval` and `pollInterval*2`. + * The time in milliseconds between checking for changes. The actual time between the last change + * made to a file and the execution time is between `pollInterval` and `pollInterval*2`. */ def pollInterval: FiniteDuration = Watched.PollDelay @@ -68,121 +65,279 @@ object Watched { */ sealed trait Action + /** + * Provides a default Ordering for actions. Lower values correspond to higher priority actions. + * [[CancelWatch]] is higher priority than [[ContinueWatch]]. + */ + object Action { + implicit object ordering extends Ordering[Action] { + override def compare(left: Action, right: Action): Int = (left, right) match { + case (a: ContinueWatch, b: ContinueWatch) => ContinueWatch.ordering.compare(a, b) + case (_: ContinueWatch, _: CancelWatch) => 1 + case (a: CancelWatch, b: CancelWatch) => CancelWatch.ordering.compare(a, b) + case (_: CancelWatch, _: ContinueWatch) => -1 + } + } + } + /** * Action that indicates that the watch should stop. */ - case object CancelWatch extends Action + sealed trait CancelWatch extends Action + + /** + * Action that does not terminate the watch but might trigger a build. + */ + sealed trait ContinueWatch extends Action + + /** + * Provides a default Ordering for classes extending [[ContinueWatch]]. [[Trigger]] is higher + * priority than [[Ignore]]. + */ + object ContinueWatch { + + /** + * A default [[Ordering]] for [[ContinueWatch]]. [[Trigger]] is higher priority than [[Ignore]]. + */ + 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 + } + } + } + + /** + * Action that indicates that the watch should stop. + */ + case object CancelWatch extends CancelWatch { + + /** + * A default [[Ordering]] for [[ContinueWatch]]. The priority of each type of [[CancelWatch]] + * is reflected by the ordering of the case statements in the [[ordering.compare]] method, + * e.g. [[Custom]] is higher priority than [[HandleError]]. + */ + implicit object ordering extends Ordering[CancelWatch] { + override def compare(left: CancelWatch, right: CancelWatch): Int = left match { + // Note that a negative return value means the left CancelWatch is preferred to the right + // CancelWatch while the inverse is true for a positive return value. This logic could + // likely be simplified, but the pattern matching approach makes it very clear what happens + // for each type of Action. + case _: Custom => + right match { + case _: Custom => 0 + case _ => -1 + } + case _: HandleError => + right match { + case _: Custom => 1 + case _: HandleError => 0 + case _ => -1 + } + case _: Run => + right match { + case _: Run => 0 + case CancelWatch | Reload => -1 + case _ => 1 + } + case CancelWatch => + right match { + case CancelWatch => 0 + case Reload => -1 + case _ => 1 + } + case Reload => if (right == Reload) 0 else 1 + } + } + } /** * Action that indicates that an error has occurred. The watch will be terminated when this action * is produced. */ - case object HandleError extends Action + final class HandleError(val throwable: Throwable) extends CancelWatch { + override def equals(o: Any): Boolean = o match { + case that: HandleError => this.throwable == that.throwable + case _ => false + } + override def hashCode: Int = throwable.hashCode + override def toString: String = s"HandleError($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 in [[WatchConfig.handleInput]]. + * because, for example, no user input was yet available. */ - case object Ignore extends Action + case object Ignore extends ContinueWatch /** * Action that indicates that the watch should pause while the build is reloaded. This is used to * automatically reload the project when the build files (e.g. build.sbt) are changed. */ - case object Reload extends Action + case object Reload extends CancelWatch + + /** + * Action that indicates that we should exit and run the provided command. + * @param commands the commands to run after we exit the watch + */ + final class Run(val commands: String*) extends CancelWatch { + override def toString: String = s"Run(${commands.mkString(", ")})" + } + // 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 { + def unapply(r: Run): Option[List[Exec]] = Some(r.commands.toList.map(Exec(_, None))) + } /** * Action that indicates that the watch process should re-run the command. */ - case object Trigger extends Action + case object Trigger extends ContinueWatch /** * A user defined Action. It is not sealed so that the user can create custom instances. If any - * of the [[WatchConfig]] callbacks, e.g. [[WatchConfig.onWatchEvent]], return an instance of - * [[Custom]], the watch will terminate. + * of the [[Watched.watch]] callbacks return [[Custom]], then watch will terminate. */ - trait Custom extends Action + trait Custom extends CancelWatch + @deprecated("WatchSource is replaced by sbt.io.Glob", "1.3.0") type WatchSource = Source + private[sbt] type OnTermination = (Action, String, State) => State + private[sbt] type OnEnter = () => Unit def terminateWatch(key: Int): Boolean = Watched.isEnter(key) - private[this] val isWin = Properties.isWin - private def drain(is: InputStream): Unit = while (is.available > 0) is.read() - private def withCharBufferedStdIn[R](f: InputStream => R): R = - if (!isWin) JLine.usingTerminal { terminal => - terminal.init() - val in = terminal.wrapInIfNeeded(System.in) - try { - drain(in) - f(in) - } finally { - drain(in) - terminal.reset() - } - } else - try { - drain(System.in) - f(System.in) - } finally drain(System.in) + /** + * A constant function that returns [[Trigger]]. + */ + final val trigger: (Int, Event[FileAttributes]) => Watched.Action = { + (_: Int, _: Event[FileAttributes]) => + Trigger + }.label("Watched.trigger") - private[sbt] final val handleInput: InputStream => Action = in => { - @tailrec - def scanInput(): Action = { - if (in.available > 0) { - in.read() match { - case key if isEnter(key) => CancelWatch - case key if isR(key) && !isWin => Trigger - case key if key >= 0 => scanInput() - case _ => Ignore - } - } else { - Ignore - } + def ifChanged(action: Action): (Int, Event[FileAttributes]) => Watched.Action = + (_: Int, event: Event[FileAttributes]) => + event match { + case Update(prev, cur, _) if prev.value != cur.value => action + case _: Creation[_] | _: Deletion[_] => action + case _ => Ignore } - scanInput() - } - private[sbt] def onEvent( - sources: Seq[WatchSource], - projectSources: Seq[WatchSource] - ): FileAttributes.Event => Watched.Action = - event => - if (sources.exists(_.accept(event.path))) Watched.Trigger - else if (projectSources.exists(_.accept(event.path))) { - (event.previous, event.current) match { - case (Some(p), Some(c)) => if (c == p) Watched.Ignore else Watched.Reload - case _ => Watched.Trigger - } - } else Ignore - private[this] val reRun = if (isWin) "" else " or 'r' to re-run the command" + private[this] val options = + if (Util.isWindows) + "press 'enter' to return to the shell or the following keys followed by 'enter': 'r' to" + + " re-run the command, 'x' to exit sbt" + else "press 'r' to re-run the command, 'x' to exit sbt or 'enter' to return to the shell" private def waitMessage(project: String): String = - s"Waiting for source changes$project... (press enter to interrupt$reRun)" + s"Waiting for source changes$project... (press enter to interrupt$options)" + + /** + * The minimum delay between build triggers for the same file. If the file is detected + * to have changed within this period from the last build trigger, the event will be discarded. + */ + final val defaultAntiEntropy: FiniteDuration = 500.milliseconds + + /** + * The duration in wall clock time for which a FileEventMonitor will retain anti-entropy + * events for files. This is an implementation detail of the FileEventMonitor. It should + * hopefully not need to be set by the users. It is needed because when a task takes a long time + * to run, it is possible that events will be detected for the file that triggered the build that + * occur within the anti-entropy period. We still allow it to be configured to limit the memory + * usage of the FileEventMonitor (but this is somewhat unlikely to be a problem). + */ + final val defaultAntiEntropyRetentionPeriod: FiniteDuration = 10.minutes + + /** + * The duration for which we delay triggering when a file is deleted. This is needed because + * many programs implement save as a file move of a temporary file onto the target file. + * Depending on how the move is implemented, this may be detected as a deletion immediately + * followed by a creation. If we trigger immediately on delete, we may, for example, try to + * compile before all of the source files are actually available. The longer this value is set, + * the less likely we are to spuriously trigger a build before all files are available, but + * the longer it will take to trigger a build when the file is actually deleted and not renamed. + */ + final val defaultDeletionQuarantinePeriod: FiniteDuration = 50.milliseconds + + /** + * Converts user input to an Action with the following rules: + * 1) on all platforms, new lines exit the watch + * 2) on posix platforms, 'r' or 'R' will trigger a build + * 3) on posix platforms, 's' or 'S' will exit the watch and run the shell command. This is to + * support the case where the user starts sbt in a continuous mode but wants to return to + * the shell without having to restart sbt. + */ + final val defaultInputParser: Parser[Action] = { + def posixOnly(legal: String, action: Action): Parser[Action] = + if (!Util.isWindows) chars(legal) ^^^ action + else Parser.invalid(Seq("Can't use jline for individual character entry on windows.")) + val rebuildParser: Parser[Action] = posixOnly(legal = "rR", Trigger) + val shellParser: Parser[Action] = posixOnly(legal = "sS", new Run("shell")) + val cancelParser: Parser[Action] = chars(legal = "\n\r") ^^^ CancelWatch + shellParser | rebuildParser | cancelParser + } + + /** + * A function that prints out the current iteration count and gives instructions for exiting + * or triggering the build. + */ val defaultStartWatch: Int => Option[String] = ((count: Int) => Some(s"$count. ${waitMessage("")}")).label("Watched.defaultStartWatch") - @deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0") - val defaultWatchingMessage: WatchState => String = - ((ws: WatchState) => defaultStartWatch(ws.count).get).label("Watched.defaultWatchingMessage") - def projectWatchingMessage(projectId: String): WatchState => String = - ((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count).get) - .label("Watched.projectWatchingMessage") - def projectOnWatchMessage(project: String): Int => Option[String] = - ((count: Int) => Some(s"$count. ${waitMessage(s" in project $project")}")) - .label("Watched.projectOnWatchMessage") - val defaultOnTriggerMessage: Int => Option[String] = - ((_: Int) => None).label("Watched.defaultOnTriggerMessage") - @deprecated( - "Use defaultOnTriggerMessage in conjunction with the watchTriggeredMessage key", - "1.3.0" - ) - val defaultTriggeredMessage: WatchState => String = - const("").label("Watched.defaultTriggeredMessage") - val clearOnTrigger: Int => Option[String] = _ => Some(clearScreen) - @deprecated("Use clearOnTrigger in conjunction with the watchTriggeredMessage key", "1.3.0") - val clearWhenTriggered: WatchState => String = - const(clearScreen).label("Watched.clearWhenTriggered") + /** + * Default no-op callback. + */ + val defaultOnEnter: () => Unit = () => {} + + private[sbt] val defaultCommandOnTermination: (Action, String, Int, State) => State = + onTerminationImpl(ContinuousExecutePrefix).label("Watched.defaultCommandOnTermination") + private[sbt] val defaultTaskOnTermination: (Action, String, Int, State) => State = + onTerminationImpl("watch", ContinuousExecutePrefix).label("Watched.defaultTaskOnTermination") + + /** + * Default handler to transform the state when the watch terminates. When the [[Watched.Action]] + * is [[Reload]], the handler will prepend the original command (prefixed by ~) to the + * [[State.remainingCommands]] and then invoke the [[StateOps.reload]] method. When the + * [[Watched.Action]] is [[HandleError]], the handler returns the result of [[StateOps.fail]]. + * When the [[Watched.Action]] is [[Watched.Run]], we add the commands specified by + * [[Watched.Run.commands]] to the stat's remaining commands. Otherwise the original state is + * returned. + */ + private def onTerminationImpl( + watchPrefixes: String* + ): (Action, String, Int, State) => State = { (action, command, count, state) => + val prefix = watchPrefixes.head + val rc = state.remainingCommands + .filterNot(c => watchPrefixes.exists(c.commandLine.trim.startsWith)) + action match { + case Run(commands) => state.copy(remainingCommands = commands ++ rc) + case Reload => + state.copy(remainingCommands = "reload".toExec :: s"$prefix $count $command".toExec :: rc) + case _: HandleError => state.copy(remainingCommands = rc).fail + case _ => state.copy(remainingCommands = rc) + } + } + + /** + * A constant function that always returns [[None]]. When + * `Keys.watchTriggeredMessage := Watched.defaultOnTriggerMessage`, then nothing is logged when + * a build is triggered. + */ + final val defaultOnTriggerMessage: (Int, Event[FileAttributes]) => Option[String] = + ((_: Int, _: Event[FileAttributes]) => None).label("Watched.defaultOnTriggerMessage") + + /** + * The minimum delay between file system polling when a [[PollingWatchService]] is used. + */ + final val defaultPollInterval: FiniteDuration = 500.milliseconds + + /** + * A constant function that returns an Option wrapped string that clears the screen when + * written to stdout. + */ + final val clearOnTrigger: Int => Option[String] = + ((_: Int) => Some(clearScreen)).label("Watched.clearOnTrigger") def clearScreen: String = "\u001b[2J\u001b[0;0H" + @deprecated("WatchSource has been replaced by sbt.io.Glob", "1.3.0") object WatchSource { /** @@ -206,6 +361,128 @@ object Watched { } + private type RunCommand = () => State + private type NextAction = () => Watched.Action + private[sbt] type Monitor = FileEventMonitor[FileAttributes] + + /** + * Runs a task and then blocks until the task is ready to run again or we no longer wish to + * block execution. + * + * @param task the aggregated task to run with each iteration + * @param onStart function to be invoked before we start polling for events + * @param nextAction function that returns the next state transition [[Watched.Action]]. + * @return the exit [[Watched.Action]] that can be used to potentially modify the build state and + * the count of the number of iterations that were run. If + */ + def watch(task: () => Unit, onStart: NextAction, nextAction: NextAction): Watched.Action = { + def safeNextAction(delegate: NextAction): Watched.Action = + try delegate() + catch { case NonFatal(t) => new HandleError(t) } + @tailrec def next(): Watched.Action = safeNextAction(nextAction) match { + // This should never return Ignore due to this condition. + case Ignore => next() + case action => action + } + @tailrec def impl(): Watched.Action = { + task() + safeNextAction(onStart) match { + case Ignore => + next() match { + case Trigger => impl() + case action => action + } + case Trigger => impl() + case a => a + } + } + try impl() + catch { case NonFatal(t) => new HandleError(t) } + } + + private[sbt] object NullLogger extends Logger { + override def trace(t: => Throwable): Unit = {} + override def success(message: => String): Unit = {} + override def log(level: Level.Value, message: => String): Unit = {} + } + + /** + * Traverse all of the events and find the one for which we give the highest + * weight. Within the [[Action]] hierarchy: + * [[Custom]] > [[HandleError]] > [[Run]] > [[CancelWatch]] > [[Reload]] > [[Trigger]] > [[Ignore]] + * the first event of each kind is returned so long as there are no higher priority events + * in the collection. For example, if there are multiple events that all return [[Trigger]], then + * the first one is returned. If, on the other hand, one of the events returns [[Reload]], + * then that event "wins" and the [[Reload]] action is returned with the [[Event[FileAttributes]]] + * that triggered it. + * + * @param events the ([[Action]], [[Event[FileAttributes]]]) pairs + * @return the ([[Action]], [[Event[FileAttributes]]]) pair with highest weight if the input events + * are non empty. + */ + @inline + private[sbt] def aggregate( + events: Seq[(Action, Event[FileAttributes])] + ): Option[(Action, Event[FileAttributes])] = + if (events.isEmpty) None else Some(events.minBy(_._1)) + + private implicit class StringToExec(val s: String) extends AnyVal { + def toExec: Exec = Exec(s, None) + } + + private[sbt] def withCharBufferedStdIn[R](f: InputStream => R): R = + if (!Util.isWindows) JLine.usingTerminal { terminal => + terminal.init() + val in = terminal.wrapInIfNeeded(System.in) + try { + f(in) + } finally { + terminal.reset() + } + } else + f(System.in) + + private[sbt] val newWatchService: () => WatchService = + (() => createWatchService()).label("Watched.newWatchService") + def createWatchService(pollDelay: FiniteDuration): WatchService = { + def closeWatch = new MacOSXWatchService() + sys.props.get("sbt.watch.mode") match { + case Some("polling") => + new PollingWatchService(pollDelay) + case Some("nio") => + FileSystems.getDefault.newWatchService() + case Some("closewatch") => closeWatch + case _ if Properties.isMac => closeWatch + case _ => + FileSystems.getDefault.newWatchService() + } + } + + @deprecated("This is no longer used by continuous builds.", "1.3.0") + def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg) + @deprecated("This is no longer used by continuous builds.", "1.3.0") + def isEnter(key: Int): Boolean = key == 10 || key == 13 + @deprecated("Replaced by defaultPollInterval", "1.3.0") + val PollDelay: FiniteDuration = 500.milliseconds + @deprecated("Replaced by defaultAntiEntropy", "1.3.0") + val AntiEntropy: FiniteDuration = 40.milliseconds + @deprecated("Use the version that explicitly takes the poll delay", "1.3.0") + def createWatchService(): WatchService = createWatchService(PollDelay) + + @deprecated("Replaced by Watched.command", "1.3.0") + def executeContinuously(watched: Watched, s: State, next: String, repeat: String): State = + LegacyWatched.executeContinuously(watched, s, next, repeat) + + // Deprecated apis below + @deprecated("unused", "1.3.0") + def projectWatchingMessage(projectId: String): WatchState => String = + ((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count).get) + .label("Watched.projectWatchingMessage") + @deprecated("unused", "1.3.0") + def projectOnWatchMessage(project: String): Int => Option[String] = + ((count: Int) => Some(s"$count. ${waitMessage(s" in project $project")}")) + .label("Watched.projectOnWatchMessage") + @deprecated("This method is not used and may be removed in a future version of sbt", "1.3.0") private[this] class AWatched extends Watched @@ -223,350 +500,36 @@ object Watched { @deprecated("This method is not used and may be removed in a future version of sbt", "1.3.0") def empty: Watched = new AWatched - val PollDelay: FiniteDuration = 500.milliseconds - val AntiEntropy: FiniteDuration = 40.milliseconds - def isEnter(key: Int): Boolean = key == 10 || key == 13 - def isR(key: Int): Boolean = key == 82 || key == 114 - def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg) - - private type RunCommand = () => State - private type WatchSetup = (State, String) => (State, WatchConfig, RunCommand => State) - - /** - * Provides the '~' continuous execution command. - * @param setup a function that provides a logger and a function from (() => State) => State. - * @return the '~' command. - */ - def continuous(setup: WatchSetup): Command = - Command(ContinuousExecutePrefix, continuousBriefHelp, continuousDetail)(otherCommandParser) { - (state, command) => - Watched.executeContinuously(state, command, setup) - } - - /** - * Default handler to transform the state when the watch terminates. When the [[Watched.Action]] is - * [[Reload]], the handler will prepend the original command (prefixed by ~) to the - * [[State.remainingCommands]] and then invoke the [[StateOps.reload]] method. When the - * [[Watched.Action]] is [[HandleError]], the handler returns the result of [[StateOps.fail]]. Otherwise - * the original state is returned. - */ - private[sbt] val onTermination: (Action, String, State) => State = (action, command, state) => - action match { - case Reload => - val continuousCommand = Exec(ContinuousExecutePrefix + command, None) - state.copy(remainingCommands = continuousCommand +: state.remainingCommands).reload - case HandleError => state.fail - case _ => state - } - - /** - * Implements continuous execution. It works by first parsing the command and generating a task to - * run with each build. It can run multiple commands that are separated by ";" in the command - * input. If any of these commands are invalid, the watch will immediately exit. - * @param state the initial state - * @param command the command(s) to repeatedly apply - * @param setup function to generate a logger and a transformation of the resultant state. The - * purpose of the transformation is to preserve the logging semantics that existed - * in the legacy version of this function in which the task would be run through - * MainLoop.processCommand, which is unavailable in the main-command project - * @return the initial state if all of the input commands are valid. Otherwise, returns the - * initial state with the failure transformation. - */ - private[sbt] def executeContinuously( - state: State, - command: String, - setup: WatchSetup, - ): State = withCharBufferedStdIn { in => - val (s0, config, newState) = setup(state, command) - val failureCommandName = "SbtContinuousWatchOnFail" - val onFail = Command.command(failureCommandName)(identity) - val s = (FailureWall :: s0).copy( - onFailure = Some(Exec(failureCommandName, None)), - definedCommands = s0.definedCommands :+ onFail - ) - val commands = Parser.parse(command, BasicCommands.multiParserImpl(Some(s))) match { - case Left(_) => command :: Nil - case Right(c) => c - } - val parser = Command.combine(s.definedCommands)(s) - val tasks = commands.foldLeft(Nil: Seq[Either[String, () => Either[Exception, Boolean]]]) { - (t, cmd) => - t :+ (DefaultParsers.parse(cmd, parser) match { - case Right(task) => - Right { () => - try { - Right(newState(task).remainingCommands.forall(_.commandLine != failureCommandName)) - } catch { case e: Exception => Left(e) } - } - case Left(_) => Left(cmd) - }) - } - val (valid, invalid) = tasks.partition(_.isRight) - if (invalid.isEmpty) { - val task = () => - valid.foldLeft(Right(true): Either[Exception, Boolean]) { - case (status, Right(t)) => if (status.getOrElse(true)) t() else status - case _ => throw new IllegalStateException("Should be unreachable") - } - val terminationAction = watch(in, task, config) - config.onWatchTerminated(terminationAction, command, state) - } else { - val commands = invalid.flatMap(_.left.toOption).mkString("'", "', '", "'") - config.logger.error(s"Terminating watch due to invalid command(s): $commands") - state.fail - } - } - - private[sbt] def watch( - in: InputStream, - task: () => Either[Exception, Boolean], - config: WatchConfig - ): Action = { - val logger = config.logger - def info(msg: String): Unit = if (msg.nonEmpty) logger.info(msg) - - @tailrec - def impl(count: Int): Action = { - @tailrec - def nextAction(): Action = { - config.handleInput(in) match { - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - case Trigger => Trigger - case _ => - val events = config.fileEventMonitor - .poll(10.millis) - .map(new FileAttributes.EventImpl(_)) - val next = events match { - case Seq() => (Ignore, None) - case Seq(head, tail @ _*) => - /* - * We traverse all of the events and find the one for which we give the highest - * weight. - * Custom > HandleError > CancelWatch > Reload > Trigger > Ignore - */ - tail.foldLeft((config.onWatchEvent(head), Some(head))) { - case (current @ (_: Custom, _), _) => current - case (current @ (action, _), event) => - config.onWatchEvent(event) match { - case HandleError => (HandleError, Some(event)) - case CancelWatch if action != HandleError => (CancelWatch, Some(event)) - case Reload if action != HandleError && action != CancelWatch => - (Reload, Some(event)) - case Trigger if action == Ignore => (Trigger, Some(event)) - case _ => current - } - } - } - // Note that nextAction should never return Ignore. - next match { - case (action @ (HandleError | CancelWatch | _: Custom), Some(event)) => - val cause = - if (action == HandleError) "error" - else if (action.isInstanceOf[Custom]) action.toString - else "cancellation" - logger.debug(s"Stopping watch due to $cause from ${event.path}") - action - case (Trigger, Some(event)) => - logger.debug(s"Triggered by ${event.path}") - config.triggeredMessage(event.path, count).foreach(info) - Trigger - case (Reload, Some(event)) => - logger.info(s"Reload triggered by ${event.path}") - Reload - case _ => - nextAction() - } - } - } - task() match { - case Right(status) => - config.preWatch(count, status) match { - case Ignore => - config.watchingMessage(count).foreach(info) - nextAction() match { - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - case _ => impl(count + 1) - } - case Trigger => impl(count + 1) - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - } - case Left(e) => - logger.error(s"Terminating watch due to Unexpected error: $e") - HandleError - } - } - try impl(count = 1) - finally config.fileEventMonitor.close() - } - - @deprecated("Replaced by Watched.command", "1.3.0") - def executeContinuously(watched: Watched, s: State, next: String, repeat: String): State = - LegacyWatched.executeContinuously(watched, s, next, repeat) - - private[sbt] object NullLogger extends Logger { - override def trace(t: => Throwable): Unit = {} - override def success(message: => String): Unit = {} - override def log(level: Level.Value, message: => String): Unit = {} - } - @deprecated("ContinuousEventMonitor attribute is not used by Watched.command", "1.3.0") val ContinuousEventMonitor = AttributeKey[EventMonitor]( "watch event monitor", "Internal: maintains watch state and monitor threads." ) - @deprecated("Superseded by ContinuousEventMonitor", "1.1.5") + @deprecated("Superseded by ContinuousEventMonitor", "1.3.0") val ContinuousState = AttributeKey[WatchState]("watch state", "Internal: tracks state for continuous execution.") - @deprecated("Superseded by ContinuousEventMonitor", "1.1.5") + @deprecated("Superseded by ContinuousEventMonitor", "1.3.0") val ContinuousWatchService = AttributeKey[WatchService]( "watch service", "Internal: tracks watch service for continuous execution." ) + @deprecated("No longer used for continuous execution", "1.3.0") val Configuration = AttributeKey[Watched]("watched-configuration", "Configures continuous execution.") - def createWatchService(pollDelay: FiniteDuration): WatchService = { - def closeWatch = new MacOSXWatchService() - sys.props.get("sbt.watch.mode") match { - case Some("polling") => - new PollingWatchService(pollDelay) - case Some("nio") => - FileSystems.getDefault.newWatchService() - case Some("closewatch") => closeWatch - case _ if Properties.isMac => closeWatch - case _ => - FileSystems.getDefault.newWatchService() - } - } - def createWatchService(): WatchService = createWatchService(PollDelay) -} - -/** - * Provides a number of configuration options for continuous execution. - */ -trait WatchConfig { - - /** - * A logger. - * @return a logger - */ - def logger: Logger - - /** - * The sbt.io.FileEventMonitor that is used to monitor the file system. - * - * @return an sbt.io.FileEventMonitor instance. - */ - def fileEventMonitor: FileEventMonitor[FileAttributes] - - /** - * A function that is periodically invoked to determine whether the watch should stop or - * trigger. Usually this will read from System.in to react to user input. - * @return an [[Watched.Action Action]] that will determine the next step in the watch. - */ - def handleInput(inputStream: InputStream): Watched.Action - - /** - * This is run before each watch iteration and if it returns true, the watch is terminated. - * @param count The current number of watch iterations. - * @param lastStatus true if the previous task execution completed successfully - * @return the Action to apply - */ - def preWatch(count: Int, lastStatus: Boolean): Watched.Action - - /** - * Callback that is invoked whenever a file system vent is detected. The next step of the watch - * is determined by the [[Watched.Action Action]] returned by the callback. - * @param event the detected sbt.io.FileEventMonitor.Event. - * @return the next [[Watched.Action Action]] to run. - */ - def onWatchEvent(event: FileAttributes.Event): Watched.Action - - /** - * Transforms the state after the watch terminates. - * @param action the [[Watched.Action Action]] that caused the build to terminate - * @param command the command that the watch was repeating - * @param state the initial state prior to the start of continuous execution - * @return the updated state. - */ - def onWatchTerminated(action: Watched.Action, command: String, state: State): State - - /** - * The optional message to log when a build is triggered. - * @param path the path that triggered the vuild - * @param count the current iteration - * @return an optional log message. - */ - def triggeredMessage(path: Path, count: Int): Option[String] - - /** - * The optional message to log before each watch iteration. - * @param count the current iteration - * @return an optional log message. - */ - def watchingMessage(count: Int): Option[String] -} - -/** - * Provides a default implementation of [[WatchConfig]]. - */ -object WatchConfig { - - /** - * Create an instance of [[WatchConfig]]. - * @param logger logger for watch events - * @param fileEventMonitor the monitor for file system events. - * @param handleInput callback that is periodically invoked to check whether to continue or - * terminate the watch based on user input. It is also possible to, for - * example time out the watch using this callback. - * @param preWatch callback to invoke before waiting for updates from the sbt.io.FileEventMonitor. - * The input parameters are the current iteration count and whether or not - * the last invocation of the command was successful. Typical uses would be to - * terminate the watch after a fixed number of iterations or to terminate the - * watch if the command was unsuccessful. - * @param onWatchEvent callback that is invoked when - * @param onWatchTerminated callback that is invoked to update the state after the watch - * terminates. - * @param triggeredMessage optional message that will be logged when a new build is triggered. - * The input parameters are the sbt.io.TypedPath that triggered the new - * build and the current iteration count. - * @param watchingMessage optional message that is printed before each watch iteration begins. - * The input parameter is the current iteration count. - * @return a [[WatchConfig]] instance. - */ - def default( - logger: Logger, - fileEventMonitor: FileEventMonitor[FileAttributes], - handleInput: InputStream => Watched.Action, - preWatch: (Int, Boolean) => Watched.Action, - onWatchEvent: FileAttributes.Event => Watched.Action, - onWatchTerminated: (Watched.Action, String, State) => State, - triggeredMessage: (Path, Int) => Option[String], - watchingMessage: Int => Option[String] - ): WatchConfig = { - val l = logger - val fem = fileEventMonitor - val hi = handleInput - val pw = preWatch - val owe = onWatchEvent - val owt = onWatchTerminated - val tm = triggeredMessage - val wm = watchingMessage - new WatchConfig { - override def logger: Logger = l - override def fileEventMonitor: FileEventMonitor[FileAttributes] = fem - override def handleInput(inputStream: InputStream): Watched.Action = hi(inputStream) - override def preWatch(count: Int, lastResult: Boolean): Watched.Action = - pw(count, lastResult) - override def onWatchEvent(event: FileAttributes.Event): Watched.Action = owe(event) - override def onWatchTerminated(action: Watched.Action, command: String, state: State): State = - owt(action, command, state) - override def triggeredMessage(path: Path, count: Int): Option[String] = - tm(path, count) - override def watchingMessage(count: Int): Option[String] = wm(count) - } - } + @deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0") + val defaultWatchingMessage: WatchState => String = + ((ws: WatchState) => defaultStartWatch(ws.count).get).label("Watched.defaultWatchingMessage") + @deprecated( + "Use defaultOnTriggerMessage in conjunction with the watchTriggeredMessage key", + "1.3.0" + ) + val defaultTriggeredMessage: WatchState => String = + const("").label("Watched.defaultTriggeredMessage") + @deprecated("Use clearOnTrigger in conjunction with the watchTriggeredMessage key", "1.3.0") + val clearWhenTriggered: WatchState => String = + const(clearScreen).label("Watched.clearWhenTriggered") } diff --git a/main-command/src/test/scala/sbt/MultiParserSpec.scala b/main-command/src/test/scala/sbt/MultiParserSpec.scala index 106490cbd..cb2962399 100644 --- a/main-command/src/test/scala/sbt/MultiParserSpec.scala +++ b/main-command/src/test/scala/sbt/MultiParserSpec.scala @@ -17,7 +17,7 @@ object MultiParserSpec { def parseEither: Either[String, Seq[String]] = Parser.parse(s, parser) } } -import MultiParserSpec._ +import sbt.MultiParserSpec._ class MultiParserSpec extends FlatSpec with Matchers { "parsing" should "parse single commands" in { ";foo".parse shouldBe Seq("foo") diff --git a/main-command/src/test/scala/sbt/WatchedSpec.scala b/main-command/src/test/scala/sbt/WatchedSpec.scala index ed1170fa5..538321c9a 100644 --- a/main-command/src/test/scala/sbt/WatchedSpec.scala +++ b/main-command/src/test/scala/sbt/WatchedSpec.scala @@ -9,12 +9,13 @@ package sbt import java.io.{ File, InputStream } import java.nio.file.{ Files, Path } -import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger } import org.scalatest.{ FlatSpec, Matchers } import sbt.Watched._ import sbt.WatchedSpec._ import sbt.internal.FileAttributes +import sbt.io.FileEventMonitor.Event import sbt.io._ import sbt.io.syntax._ import sbt.util.Logger @@ -23,59 +24,84 @@ import scala.collection.mutable import scala.concurrent.duration._ class WatchedSpec extends FlatSpec with Matchers { - object Defaults { - def config( - globs: Seq[Glob], + private type NextAction = () => Watched.Action + private def watch(task: Task, callbacks: (NextAction, NextAction)): Watched.Action = + Watched.watch(task, callbacks._1, callbacks._2) + object TestDefaults { + def callbacks( + inputs: Seq[Glob], fileEventMonitor: Option[FileEventMonitor[FileAttributes]] = None, logger: Logger = NullLogger, - handleInput: InputStream => Action = _ => Ignore, - preWatch: (Int, Boolean) => Action = (_, _) => CancelWatch, - onWatchEvent: FileAttributes.Event => Action = _ => Ignore, - triggeredMessage: (Path, Int) => Option[String] = (_, _) => None, - watchingMessage: Int => Option[String] = _ => None - ): WatchConfig = { + parseEvent: () => Action = () => Ignore, + onStartWatch: () => Action = () => CancelWatch: Action, + onWatchEvent: Event[FileAttributes] => Action = _ => Ignore, + triggeredMessage: Event[FileAttributes] => Option[String] = _ => None, + watchingMessage: () => Option[String] = () => None + ): (NextAction, NextAction) = { val monitor = fileEventMonitor.getOrElse { val fileTreeRepository = FileTreeRepository.default(FileAttributes.default) - globs.foreach(fileTreeRepository.register) - FileEventMonitor.antiEntropy( - fileTreeRepository, - 50.millis, - m => logger.debug(m.toString), - 50.milliseconds, - 100.milliseconds - ) + inputs.foreach(fileTreeRepository.register) + val m = + FileEventMonitor.antiEntropy( + fileTreeRepository, + 50.millis, + m => logger.debug(m.toString), + 50.millis, + 10.minutes + ) + new FileEventMonitor[FileAttributes] { + override def poll(duration: Duration): Seq[Event[FileAttributes]] = m.poll(duration) + override def close(): Unit = m.close() + } } - WatchConfig.default( - logger = logger, - monitor, - handleInput, - preWatch, - onWatchEvent, - (_, _, state) => state, - triggeredMessage, - watchingMessage - ) + val onTrigger: Event[FileAttributes] => Unit = event => { + triggeredMessage(event).foreach(logger.info(_)) + } + val onStart: () => Watched.Action = () => { + watchingMessage().foreach(logger.info(_)) + onStartWatch() + } + val nextAction: NextAction = () => { + val inputAction = parseEvent() + val fileActions = monitor.poll(10.millis).map { e: Event[FileAttributes] => + onWatchEvent(e) match { + case Trigger => onTrigger(e); Trigger + case act => act + } + } + (inputAction +: fileActions).min + } + (onStart, nextAction) } } object NullInputStream extends InputStream { override def available(): Int = 0 override def read(): Int = -1 } + private class Task extends (() => Unit) { + private val count = new AtomicInteger(0) + override def apply(): Unit = { + count.incrementAndGet() + () + } + def getCount: Int = count.get() + } "Watched.watch" should "stop" in IO.withTemporaryDirectory { dir => - val config = Defaults.config(globs = Seq(dir.toRealPath.toGlob)) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + val task = new Task + watch(task, TestDefaults.callbacks(inputs = Seq(dir.toRealPath ** AllPassFilter))) shouldBe CancelWatch } it should "trigger" in IO.withTemporaryDirectory { dir => val triggered = new AtomicBoolean(false) - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (count, _) => if (count == 2) CancelWatch else Ignore, + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(dir.toRealPath ** AllPassFilter), + onStartWatch = () => if (task.getCount == 2) CancelWatch else Ignore, onWatchEvent = _ => { triggered.set(true); Trigger }, - watchingMessage = _ => { + watchingMessage = () => { new File(dir, "file").createNewFile; None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch assert(triggered.get()) } it should "filter events" in IO.withTemporaryDirectory { dir => @@ -83,28 +109,33 @@ class WatchedSpec extends FlatSpec with Matchers { val queue = new mutable.Queue[Path] val foo = realDir.toPath.resolve("foo") val bar = realDir.toPath.resolve("bar") - val config = Defaults.config( - globs = Seq(realDir ** AllPassFilter), - preWatch = (count, _) => if (count == 2) CancelWatch else Ignore, - onWatchEvent = e => if (e.path == foo) Trigger else Ignore, - triggeredMessage = (tp, _) => { queue += tp; None }, - watchingMessage = _ => { Files.createFile(bar); Thread.sleep(5); Files.createFile(foo); None } + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(realDir ** AllPassFilter), + onStartWatch = () => if (task.getCount == 2) CancelWatch else Ignore, + onWatchEvent = e => if (e.entry.typedPath.toPath == foo) Trigger else Ignore, + triggeredMessage = e => { queue += e.entry.typedPath.toPath; None }, + watchingMessage = () => { + IO.touch(bar.toFile); Thread.sleep(5); IO.touch(foo.toFile) + None + } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch queue.toIndexedSeq shouldBe Seq(foo) } it should "enforce anti-entropy" in IO.withTemporaryDirectory { dir => - val realDir = dir.toRealPath.toPath + val realDir = dir.toRealPath val queue = new mutable.Queue[Path] - val foo = realDir.resolve("foo") - val bar = realDir.resolve("bar") - val config = Defaults.config( - globs = Seq(realDir ** AllPassFilter), - preWatch = (count, _) => if (count == 3) CancelWatch else Ignore, - onWatchEvent = e => if (e.path != realDir) Trigger else Ignore, - triggeredMessage = (tp, _) => { queue += tp; None }, - watchingMessage = count => { - count match { + val foo = realDir.toPath.resolve("foo") + val bar = realDir.toPath.resolve("bar") + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(realDir ** AllPassFilter), + onStartWatch = () => if (task.getCount == 3) CancelWatch else Ignore, + onWatchEvent = _ => Trigger, + triggeredMessage = e => { queue += e.entry.typedPath.toPath; None }, + watchingMessage = () => { + task.getCount match { case 1 => Files.createFile(bar) case 2 => bar.toFile.setLastModified(5000) @@ -114,26 +145,26 @@ class WatchedSpec extends FlatSpec with Matchers { None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch queue.toIndexedSeq shouldBe Seq(bar, foo) } it should "halt on error" in IO.withTemporaryDirectory { dir => - val halted = new AtomicBoolean(false) - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (_, lastStatus) => if (lastStatus) Ignore else { halted.set(true); HandleError } + val exception = new IllegalStateException("halt") + val task = new Task { override def apply(): Unit = throw exception } + val callbacks = TestDefaults.callbacks( + Seq(dir.toRealPath ** AllPassFilter), ) - Watched.watch(NullInputStream, () => Right(false), config) shouldBe HandleError - assert(halted.get()) + watch(task, callbacks) shouldBe new HandleError(exception) } it should "reload" in IO.withTemporaryDirectory { dir => - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (_, _) => Ignore, + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(dir.toRealPath ** AllPassFilter), + onStartWatch = () => Ignore, onWatchEvent = _ => Reload, - watchingMessage = _ => { new File(dir, "file").createNewFile(); None } + watchingMessage = () => { new File(dir, "file").createNewFile(); None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe Reload + watch(task, callbacks) shouldBe Reload } } diff --git a/main-settings/src/main/scala/sbt/Append.scala b/main-settings/src/main/scala/sbt/Append.scala index ce77805dc..6176fa9b3 100644 --- a/main-settings/src/main/scala/sbt/Append.scala +++ b/main-settings/src/main/scala/sbt/Append.scala @@ -100,7 +100,16 @@ object Append { new Sequence[Seq[Source], Seq[File], File] { def appendValue(a: Seq[Source], b: File): Seq[Source] = appendValues(a, Seq(b)) def appendValues(a: Seq[Source], b: Seq[File]): Seq[Source] = - a ++ b.map(new Source(_, AllPassFilter, NothingFilter)) + a ++ b.map { f => + // Globs only accept their own base if the depth parameter is set to -1. The conversion + // from Source to Glob never sets the depth to -1, which causes individual files + // added via `watchSource += ...` to not trigger a build when they are modified. Since + // watchSources will be deprecated in 1.3.0, I'm hoping that most people will migrate + // their builds to the new system, but this will work for most builds in the interim. + if (f.isFile && f.getParentFile != null) + new Source(f.getParentFile, f.getName, NothingFilter, recursive = false) + else new Source(f, AllPassFilter, NothingFilter) + } } // Implemented with SAM conversion short-hand diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 4b0a9e030..516f4d919 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -9,7 +9,6 @@ package sbt import java.io.{ File, PrintWriter } import java.net.{ URI, URL } -import java.nio.file.{ Path => NioPath } import java.util.Optional import java.util.concurrent.{ Callable, TimeUnit } @@ -232,6 +231,14 @@ object Defaults extends BuildCommon { outputStrategy :== None, // TODO - This might belong elsewhere. buildStructure := Project.structure(state.value), settingsData := buildStructure.value.data, + settingsData / fileInputs := { + val baseDir = file(".").getCanonicalFile + val sourceFilter = ("*.sbt" || "*.scala" || "*.java") -- HiddenFileFilter + Seq( + Glob(baseDir, "*.sbt" -- HiddenFileFilter, 0), + Glob(baseDir / "project", sourceFilter, Int.MaxValue) + ) + }, trapExit :== true, connectInput :== false, cancelable :== false, @@ -246,8 +253,6 @@ object Defaults extends BuildCommon { // The idea here is to be able to define a `sbtVersion in pluginCrossBuild`, which // directs the dependencies of the plugin to build to the specified sbt plugin version. sbtVersion in pluginCrossBuild := sbtVersion.value, - watchingMessage := Watched.defaultWatchingMessage, - triggeredMessage := Watched.defaultTriggeredMessage, onLoad := idFun[State], onUnload := idFun[State], onUnload := { s => @@ -258,8 +263,7 @@ object Defaults extends BuildCommon { Nil }, pollingGlobs :== Nil, - watchSources :== Nil, - watchProjectSources :== Nil, + watchSources :== Nil, // Although this is deprecated, it can't be removed or it breaks += for legacy builds. skip :== false, taskTemporaryDirectory := { val dir = IO.createTemporaryDirectory; dir.deleteOnExit(); dir }, onComplete := { @@ -284,22 +288,16 @@ object Defaults extends BuildCommon { Previous.references :== new Previous.References, concurrentRestrictions := defaultRestrictions.value, parallelExecution :== true, - pollInterval :== new FiniteDuration(500, TimeUnit.MILLISECONDS), - watchTriggeredMessage := { (_, _) => - None - }, - watchStartMessage := Watched.defaultStartWatch, - fileTreeRepository := FileTree.Repository.polling, + fileTreeRepository := + FileTree.repository(state.value.get(Keys.globalFileTreeRepository) match { + case Some(r) => r + case None => FileTreeView.DEFAULT.asDataView(FileAttributes.default) + }), externalHooks := { val repository = fileTreeRepository.value compileOptions => Some(ExternalHooks(compileOptions, repository)) }, - watchAntiEntropy :== new FiniteDuration(500, TimeUnit.MILLISECONDS), - watchLogger := streams.value.log, - watchService :== { () => - Watched.createWatchService() - }, logBuffered :== false, commands :== Nil, showSuccess :== true, @@ -334,6 +332,22 @@ object Defaults extends BuildCommon { }, insideCI :== sys.env.contains("BUILD_NUMBER") || sys.env.contains("CI") || System.getProperty("sbt.ci", "false") == "true", + // watch related settings + pollInterval :== Watched.defaultPollInterval, + watchAntiEntropy :== Watched.defaultAntiEntropy, + watchAntiEntropyRetentionPeriod :== Watched.defaultAntiEntropyRetentionPeriod, + watchLogLevel :== Level.Info, + watchOnEnter :== Watched.defaultOnEnter, + watchOnMetaBuildEvent :== Watched.ifChanged(Watched.Reload), + watchOnInputEvent :== Watched.trigger, + watchOnTriggerEvent :== Watched.trigger, + watchDeletionQuarantinePeriod :== Watched.defaultDeletionQuarantinePeriod, + watchService :== Watched.newWatchService, + watchStartMessage :== Watched.defaultStartWatch, + watchTasks := Continuous.continuousTask.evaluated, + aggregate in watchTasks :== false, + watchTrackMetaBuild :== true, + watchTriggeredMessage :== Watched.defaultOnTriggerMessage, ) ) @@ -390,25 +404,7 @@ object Defaults extends BuildCommon { val baseSources = if (sourcesInBase.value) baseDirectory.value * filter :: Nil else Nil unmanagedSourceDirectories.value.map(_ ** filter) ++ baseSources }, - unmanagedSources / fileInputs += baseDirectory.value * "foo.txt", unmanagedSources := (unmanagedSources / fileInputs).value.all.map(Stamped.file), - watchSources in ConfigGlobal := (watchSources in ConfigGlobal).value ++ { - val baseDir = baseDirectory.value - val bases = unmanagedSourceDirectories.value - val include = (includeFilter in unmanagedSources).value - val exclude = (excludeFilter in unmanagedSources).value - val baseSources = - if (sourcesInBase.value) Seq(new Source(baseDir, include, exclude, recursive = false)) - else Nil - bases.map(b => new Source(b, include, exclude)) ++ baseSources - }, - watchProjectSources in ConfigGlobal := (watchProjectSources in ConfigGlobal).value ++ { - val baseDir = baseDirectory.value - Seq( - new Source(baseDir, "*.sbt", HiddenFileFilter, recursive = false), - new Source(baseDir / "project", "*.sbt" || "*.scala", HiddenFileFilter, recursive = true) - ) - }, managedSourceDirectories := Seq(sourceManaged.value), managedSources := generate(sourceGenerators).value, sourceGenerators :== Nil, @@ -432,12 +428,6 @@ object Defaults extends BuildCommon { unmanagedResourceDirectories.value.map(_ ** filter) }, unmanagedResources := (unmanagedResources / fileInputs).value.all.map(Stamped.file), - watchSources in ConfigGlobal := (watchSources in ConfigGlobal).value ++ { - val bases = unmanagedResourceDirectories.value - val include = (includeFilter in unmanagedResources).value - val exclude = (excludeFilter in unmanagedResources).value - bases.map(b => new Source(b, include, exclude)) - }, resourceGenerators :== Nil, resourceGenerators += Def.task { PluginDiscovery.writeDescriptors(discoveredSbtPlugins.value, resourceManaged.value) @@ -634,40 +624,6 @@ object Defaults extends BuildCommon { clean := Clean.taskIn(ThisScope).value, consoleProject := consoleProjectTask.value, watchTransitiveSources := watchTransitiveSourcesTask.value, - watchProjectTransitiveSources := watchTransitiveSourcesTaskImpl(watchProjectSources).value, - watchOnEvent := Watched - .onEvent(watchTransitiveSources.value, watchProjectTransitiveSources.value), - watchHandleInput := Watched.handleInput, - watchPreWatch := { (_, _) => - Watched.Ignore - }, - watchOnTermination := Watched.onTermination, - watchConfig := { - val sources = watchTransitiveSources.value ++ watchProjectTransitiveSources.value - val globs = sources.map( - s => Glob(s.base, s.includeFilter -- s.excludeFilter, if (s.recursive) Int.MaxValue else 0) - ) - val wm = watchingMessage.?.value - .map(w => (count: Int) => Some(w(WatchState.empty(globs).withCount(count)))) - .getOrElse(watchStartMessage.value) - val tm = triggeredMessage.?.value - .map(tm => (_: NioPath, count: Int) => Some(tm(WatchState.empty(globs).withCount(count)))) - .getOrElse(watchTriggeredMessage.value) - val logger = watchLogger.value - val repo = FileManagement.toMonitoringRepository(FileManagement.repo.value) - globs.foreach(repo.register) - val monitor = FileManagement.monitor(repo, watchAntiEntropy.value, logger) - WatchConfig.default( - logger, - monitor, - watchHandleInput.value, - watchPreWatch.value, - watchOnEvent.value, - watchOnTermination.value, - tm, - wm - ) - }, watchStartMessage := Watched.projectOnWatchMessage(thisProjectRef.value.project), watch := watchSetting.value, fileOutputs += target.value ** AllPassFilter, @@ -679,6 +635,10 @@ object Defaults extends BuildCommon { def generate(generators: SettingKey[Seq[Task[Seq[File]]]]): Initialize[Task[Seq[File]]] = generators { _.join.map(_.flatten) } + @deprecated( + "The watchTransitiveSourcesTask is used only for legacy builds and will be removed in a future version of sbt.", + "1.3.0" + ) def watchTransitiveSourcesTask: Initialize[Task[Seq[Source]]] = watchTransitiveSourcesTaskImpl(watchSources) @@ -706,8 +666,8 @@ object Defaults extends BuildCommon { val interval = pollInterval.value val _antiEntropy = watchAntiEntropy.value val base = thisProjectRef.value - val msg = watchingMessage.value - val trigMsg = triggeredMessage.value + val msg = watchingMessage.?.value.getOrElse(Watched.defaultWatchingMessage) + val trigMsg = triggeredMessage.?.value.getOrElse(Watched.defaultTriggeredMessage) new Watched { val scoped = watchTransitiveSources in base val key = scoped.scopedKey diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 05e1d1d8a..0d78d65fc 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -9,7 +9,6 @@ package sbt import java.io.{ File, InputStream } import java.net.URL -import java.nio.file.Path import org.apache.ivy.core.module.descriptor.ModuleDescriptor import org.apache.ivy.core.module.id.ModuleRevisionId @@ -22,7 +21,9 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.io.WatchState import sbt.internal.librarymanagement.{ CompatibilityWarningOptions, IvySbt } import sbt.internal.server.ServerHandler +import sbt.internal.util.complete.Parser import sbt.internal.util.{ AttributeKey, SourcePosition } +import sbt.io.FileEventMonitor.Event import sbt.io._ import sbt.librarymanagement.Configurations.CompilerPlugin import sbt.librarymanagement.LibraryManagementCodec._ @@ -90,27 +91,42 @@ object Keys { val serverHandlers = settingKey[Seq[ServerHandler]]("User-defined server handlers.") val analysis = AttributeKey[CompileAnalysis]("analysis", "Analysis of compilation, including dependencies and generated outputs.", DSetting) - @deprecated("This is no longer used for continuous execution", "1.3.0") - val watch = SettingKey(BasicKeys.watch) val suppressSbtShellNotification = settingKey[Boolean]("""True to suppress the "Executing in batch mode.." message.""").withRank(CSetting) val enableGlobalCachingFileTreeRepository = settingKey[Boolean]("Toggles whether or not to create a global cache of the file system that can be used by tasks to quickly list a path").withRank(DSetting) - val fileTreeRepository = taskKey[FileTree.Repository]("A repository of the file system.") + val fileTreeRepository = taskKey[FileTree.Repository]("A repository of the file system.").withRank(DSetting) val pollInterval = settingKey[FiniteDuration]("Interval between checks for modified sources by the continuous execution command.").withRank(BMinusSetting) val pollingGlobs = settingKey[Seq[Glob]]("Directories that cannot be cached and must always be rescanned. Typically these will be NFS mounted or something similar.").withRank(DSetting) val watchAntiEntropy = settingKey[FiniteDuration]("Duration for which the watch EventMonitor will ignore events for a file after that file has triggered a build.").withRank(BMinusSetting) - val watchConfig = taskKey[WatchConfig]("The configuration for continuous execution.").withRank(BMinusSetting) - val watchLogger = taskKey[Logger]("A logger that reports watch events.").withRank(DSetting) - val watchHandleInput = settingKey[InputStream => Watched.Action]("Function that is periodically invoked to determine if the continous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands.").withRank(BMinusSetting) - val watchOnEvent = taskKey[FileAttributes.Event => Watched.Action]("Determines how to handle a file event").withRank(BMinusSetting) - val watchOnTermination = taskKey[(Watched.Action, String, State) => State]("Transforms the input state after the continuous build completes.").withRank(BMinusSetting) - val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting) - val watchProjectSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources for the sbt meta project to watch to trigger a reload.").withRank(CSetting) - val watchProjectTransitiveSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in all projects for the sbt meta project to watch to trigger a reload.").withRank(CSetting) - val watchPreWatch = settingKey[(Int, Boolean) => Watched.Action]("Function that may terminate a continuous build based on the number of iterations and the last result").withRank(BMinusSetting) - val watchSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in this project for continuous execution to watch for changes.").withRank(BMinusSetting) + val watchAntiEntropyRetentionPeriod = settingKey[FiniteDuration]("Wall clock Duration for which a FileEventMonitor will store anti-entropy events. This prevents spurious triggers when a task takes a long time to run. Higher values will consume more memory but make spurious triggers less likely.").withRank(BMinusSetting) + val watchDeletionQuarantinePeriod = settingKey[FiniteDuration]("Period for which deletion events will be quarantined. This is to prevent spurious builds when a file is updated with a rename which manifests as a file deletion followed by a file creation. The higher this value is set, the longer the delay will be between a file deletion and a build trigger but the less likely it is for a spurious trigger.").withRank(DSetting) + val watchLogLevel = settingKey[sbt.util.Level.Value]("Transform the default logger in continuous builds.").withRank(DSetting) + val watchInputHandler = settingKey[InputStream => Watched.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 watchInputStream = taskKey[InputStream]("The input stream to read for user input events. This will usually be System.in").withRank(DSetting) + val watchInputParser = settingKey[Parser[Watched.Action]]("A parser of user input that can be used to trigger or exit a continuous build").withRank(DSetting) + val watchOnEnter = settingKey[() => Unit]("Function to run prior to beginning a continuous build. This will run before the continuous task(s) is(are) first evaluated.").withRank(DSetting) + val watchOnExit = settingKey[() => Unit]("Function to run upon exit of a continuous build. It can be used to cleanup resources used during the watch.").withRank(DSetting) + val watchOnInputEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the transitive inputs. This is only invoked if watchOnEvent is not explicitly set.").withRank(DSetting) + val watchOnEvent = settingKey[Continuous.Arguments => Event[FileAttributes] => Watched.Action]("Determines how to handle a file event. The Seq[Glob] contains all of the transitive inputs for the task(s) being run by the continuous build.").withRank(DSetting) + val watchOnMetaBuildEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the meta build triggers.").withRank(DSetting) + val watchOnTermination = settingKey[(Watched.Action, String, Int, State) => State]("Transforms the state upon completion of a watch. The String argument is the command that was run during the watch. The Int parameter specifies how many times the command was run during the watch.").withRank(DSetting) + val watchOnTrigger = settingKey[Continuous.Arguments => Event[FileAttributes] => Unit]("Callback to invoke when a continuous build triggers. The first parameter is the number of previous watch task invocations. The second parameter is the Event that triggered this build").withRank(DSetting) + val watchOnTriggerEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the transitive triggers. This is only invoked if watchOnEvent is not explicitly set.").withRank(DSetting) + val watchOnIteration = settingKey[Int => Watched.Action]("Function that is invoked before waiting for file system events or user input events. This is only invoked if watchOnStart is not explicitly set.").withRank(DSetting) + val watchOnStart = settingKey[Continuous.Arguments => () => Watched.Action]("Function is invoked before waiting for file system or input events. The returned Action is used to either trigger the build, terminate the watch or wait for events.").withRank(DSetting) + val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting).withRank(DSetting) val watchStartMessage = settingKey[Int => Option[String]]("The message to show when triggered execution waits for sources to change. The parameter is the current watch iteration count.").withRank(DSetting) + // The watchTasks key should really be named watch, but that is already taken by the deprecated watch key. I'd be surprised if there are any plugins that use it so I think we should consider breaking binary compatibility to rename this task. + val watchTasks = InputKey[State]("watch", "Watch a task (or multiple tasks) and rebuild when its file inputs change or user input is received. The semantics are more or less the same as the `~` command except that it cannot transform the state on exit. This means that it cannot be used to reload the build.").withRank(DSetting) + val watchTrackMetaBuild = settingKey[Boolean]("Toggles whether or not changing the build files (e.g. **/*.sbt, project/**/(*.scala | *.java)) should automatically trigger a project reload").withRank(DSetting) + val watchTriggeredMessage = settingKey[(Int, Event[FileAttributes]) => Option[String]]("The message to show before triggered execution executes an action after sources change. The parameters are the path that triggered the build and the current watch iteration count.").withRank(DSetting) + + // Deprecated watch apis + @deprecated("This is no longer used for continuous execution", "1.3.0") + val watch = SettingKey(BasicKeys.watch) + @deprecated("WatchSource has been replaced by Glob. To add file triggers to a task with key: Key, set `Key / watchTriggers := Seq[Glob](...)`.", "1.3.0") + val watchSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in this project for continuous execution to watch for changes.").withRank(BMinusSetting) + @deprecated("This is for legacy builds only and will be removed in a future version of sbt", "1.3.0") val watchTransitiveSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in all projects for continuous execution to watch.").withRank(CSetting) - val watchTriggeredMessage = settingKey[(Path, Int) => Option[String]]("The message to show before triggered execution executes an action after sources change. The parameters are the path that triggered the build and the current watch iteration count.").withRank(DSetting) @deprecated("Use watchStartMessage instead", "1.3.0") val watchingMessage = settingKey[WatchState => String]("The message to show when triggered execution waits for sources to change.").withRank(DSetting) @deprecated("Use watchTriggeredMessage instead", "1.3.0") diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index edb3e180f..1a2856a73 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -22,7 +22,7 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.util.Types.{ const, idFun } import sbt.internal.util._ import sbt.internal.util.complete.Parser -import sbt.io.IO +import sbt.io._ import sbt.io.syntax._ import sbt.util.{ Level, Logger, Show } import xsbti.compile.CompilerCache @@ -423,13 +423,7 @@ object BuiltinCommands { s } - def continuous: Command = Watched.continuous { (state: State, command: String) => - val extracted = Project.extract(state) - val (s, watchConfig) = extracted.runTask(Keys.watchConfig, state) - val updateState = - (runCommand: () => State) => MainLoop.processCommand(Exec(command, None), s, runCommand) - (s, watchConfig, updateState) - } + def continuous: Command = Continuous.continuous private[this] def loadedEval(s: State, arg: String): Unit = { val extracted = Project extract s diff --git a/main/src/main/scala/sbt/ScriptedPlugin.scala b/main/src/main/scala/sbt/ScriptedPlugin.scala index 229f7e1bf..f3c4163dc 100644 --- a/main/src/main/scala/sbt/ScriptedPlugin.scala +++ b/main/src/main/scala/sbt/ScriptedPlugin.scala @@ -88,7 +88,8 @@ object ScriptedPlugin extends AutoPlugin { val pub = (publishLocal).value use(analysis, pub) }, - scripted := scriptedTask.evaluated + scripted := scriptedTask.evaluated, + watchTriggers in scripted += sbtTestDirectory.value ** AllPassFilter ) private[sbt] def scriptedTestsTask: Initialize[Task[AnyRef]] = diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala new file mode 100644 index 000000000..277fa968b --- /dev/null +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -0,0 +1,873 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import java.io.{ ByteArrayInputStream, InputStream } +import java.util.concurrent.atomic.AtomicInteger + +import sbt.BasicCommandStrings.{ + ContinuousExecutePrefix, + FailureWall, + continuousBriefHelp, + continuousDetail +} +import sbt.BasicCommands.otherCommandParser +import sbt.Def._ +import sbt.Scope.Global +import sbt.Watched.Monitor +import sbt.internal.FileManagement.FileTreeRepositoryOps +import sbt.internal.io.WatchState +import sbt.internal.util.Types.const +import sbt.internal.util.complete.Parser._ +import sbt.internal.util.complete.{ Parser, Parsers } +import sbt.internal.util.{ AttributeKey, AttributeMap } +import sbt.io._ +import sbt.util.{ Level, _ } + +import scala.annotation.tailrec +import scala.concurrent.duration.FiniteDuration.FiniteDurationIsOrdered +import scala.concurrent.duration._ +import scala.util.Try + +/** + * Provides the implementation of the `~` command and `watch` task. The implementation is quite + * complex because we have to parse the command string to figure out which tasks we want to run. + * Using the tasks, we then have to extract all of the settings for the continuous build. Finally + * we have to aggregate the settings for each task into an aggregated watch config that will + * sanely watch multiple tasks and respond to file updates and user input in a way that makes + * sense for each of the tasks that are being monitored. + * + * The behavior, on the other hand, should be fairly straightforward. For example, if a user + * wants to continuously run the compile task for projects a and b, then we create FileEventMonitor + * instances for each product and watch all of the directories that contain compile sources + * (as well as the source directories of transitive inter-project classpath dependencies). If + * a change is detected in project a, then we should trigger a build for both projects a and b. + * + * The semantics are flexible and may be adapted. For example, a user may want to watch two + * unrelated tasks and only rebuild the task with sources that have been changed. This could be + * handled at the `~` level, but it probably makes more sense to build a better task caching + * system so that we don't rerun tasks if their inputs have not changed. As of 1.3.0, the + * semantics match previous sbt versions as closely as possible while allowing the user more + * freedom to adjust the behavior to best suit their use cases. + * + * For now Continuous extends DeprecatedContinuous to minimize the number of deprecation warnings + * produced by this file. In sbt 2.0, the DeprecatedContinuous mixin should be eliminated and + * the deprecated apis should no longer be supported. + * + */ +object Continuous extends DeprecatedContinuous { + + /** + * Provides the dynamic inputs to the continuous build callbacks that cannot be stored as + * settings. This wouldn't need to exist if there was a notion of a lazy setting in sbt. + * @param logger the Logger + * @param inputs the transitive task inputs + * @param triggers the transitive task triggers + */ + final class Arguments private[Continuous] ( + val logger: Logger, + val inputs: Seq[Glob], + val triggers: Seq[Glob] + ) + + /** + * Provides a copy of System.in that can be scanned independently from System.in itself. This task + * will only be valid during a continuous build started via `~` or the `watch` task. The + * motivation is that a plugin may want to completely override the parsing of System.in which + * is not straightforward since the default implementation is hard-wired to read from and + * parse System.in. If an invalid parser is provided by [[Keys.watchInputParser]] and + * [[Keys.watchInputStream]] is set to this task, then a custom parser can be provided via + * [[Keys.watchInputHandler]] and the default System.in processing will not occur. + * + * @return the duplicated System.in + */ + def dupedSystemIn: Def.Initialize[Task[InputStream]] = Def.task { + Keys.state.value.get(DupedSystemIn).map(_.duped).getOrElse(System.in) + } + + /** + * Create a function from InputStream => [[Watched.Action]] from a [[Parser]]. This is intended + * to be used to set the watchInputHandler setting for a task. + * @param parser the parser + * @return the function + */ + def defaultInputHandler(parser: Parser[Watched.Action]): InputStream => Watched.Action = { + val builder = new StringBuilder + val any = matched(Parsers.any.*) + val fullParser = any ~> parser ~ any + inputStream => + parse(inputStream, builder, fullParser) + } + + /** + * Implements continuous execution. It works by first parsing the command and generating a task to + * run with each build. It can run multiple commands that are separated by ";" in the command + * input. If any of these commands are invalid, the watch will immediately exit. + * @return a Command that can be used by sbt to implement continuous builds. + */ + private[sbt] def continuous: Command = + Command(ContinuousExecutePrefix, continuousBriefHelp, continuousDetail)(continuousParser) { + case (state, (initialCount, command)) => + runToTermination(state, command, initialCount, isCommand = true) + } + + /** + * The task implementation is quite similar to the command implementation. The tricky part is that + * we have to modify the Task.info to apply the state transformation after the task completes. + * @return the [[InputTask]] + */ + private[sbt] def continuousTask: Def.Initialize[InputTask[State]] = + Def.inputTask { + val (initialCount, command) = continuousParser.parsed + runToTermination(Keys.state.value, command, initialCount, isCommand = false) + }(_.mapTask { t => + val postTransform = t.info.postTransform { + case (state: State, am: AttributeMap) => am.put(Keys.transformState, const(state)) + } + Task(postTransform, t.work) + }) + + private[this] val DupedSystemIn = + AttributeKey[DupedInputStream]( + "duped-system-in", + "Receives a copy of all of the bytes from System.in.", + 10000 + ) + + private[this] val continuousParser: State => Parser[(Int, String)] = { + def toInt(s: String): Int = Try(s.toInt).getOrElse(0) + // This allows us to re-enter the watch with the previous count. + val digitParser: Parser[Int] = + (Parsers.Space.* ~> matched(Parsers.Digit.+) <~ Parsers.Space.*).map(toInt) + state => + val ocp = otherCommandParser(state) + (digitParser.? ~ ocp).map { case (i, s) => (i.getOrElse(0), s) } + } + + /** + * Gets the [[Config]] necessary to watch a task. It will extract the internal dependency + * configurations for the task (these are the classpath dependencies specified by + * [[Project.dependsOn]]). Using these configurations and the settings map, it walks the + * dependency graph for the key and extracts all of the transitive globs specified by the + * inputs and triggers keys. It also extracts the legacy globs specified by the watchSources key. + * + * @param state the current [[State]] instance. + * @param scopedKey the [[ScopedKey]] instance corresponding to the task we're monitoring + * @param compiledMap the map of all of the build settings + * @param extracted the [[Extracted]] instance for the build + * @param logger a logger that can be used while generating the [[Config]] + * @return the [[Config]] instance + */ + private def getConfig( + state: State, + scopedKey: ScopedKey[_], + compiledMap: CompiledMap, + )(implicit extracted: Extracted, logger: Logger): Config = { + + // Extract all of the globs that we will monitor during the continuous build. + val (inputs, triggers) = { + val configs = scopedKey.get(Keys.internalDependencyConfigurations).getOrElse(Nil) + val args = new InputGraph.Arguments(scopedKey, extracted, compiledMap, logger, configs, state) + InputGraph.transitiveGlobs(args) + } match { + case (i: Seq[Glob], t: Seq[Glob]) => (i.distinct.sorted, t.distinct.sorted) + } + + val repository = getRepository(state) + (inputs ++ triggers).foreach(repository.register) + val watchSettings = new WatchSettings(scopedKey) + new Config( + scopedKey, + repository, + inputs, + triggers, + watchSettings + ) + } + private def getRepository(state: State): FileTreeRepository[FileAttributes] = { + lazy val exception = + new IllegalStateException("Tried to access FileTreeRepository for uninitialized state") + state + .get(Keys.globalFileTreeRepository) + .map(FileManagement.toMonitoringRepository(_).copy()) + .getOrElse(throw exception) + } + + private[sbt] def setup[R](state: State, command: String)( + f: (State, Seq[String], Seq[() => Boolean], Seq[String]) => R + ): R = { + // First set up the state so that we can capture whether or not a task completed successfully + // or if it threw an Exception (we lose the actual exception, but that should still be printed + // to the console anyway). + val failureCommandName = "SbtContinuousWatchOnFail" + val onFail = Command.command(failureCommandName)(identity) + /* + * Takes a task string and converts it to an EitherTask. We cannot preserve either + * the value returned by the task or any exception thrown by the task, but we can determine + * whether or not the task ran successfully using the onFail command defined above. + */ + def makeTask(cmd: String)(task: () => State): () => Boolean = { () => + MainLoop + .processCommand(Exec(cmd, None), state, task) + .remainingCommands + .forall(_.commandLine != failureCommandName) + } + + // This adds the "SbtContinuousWatchOnFail" onFailure handler which allows us to determine + // whether or not the last task successfully ran. It is used in the makeTask method below. + val s = (FailureWall :: state).copy( + onFailure = Some(Exec(failureCommandName, None)), + definedCommands = state.definedCommands :+ onFail + ) + + // We support multiple commands in watch, so it's necessary to run the command string through + // the multi parser. + val trimmed = command.trim + val commands = Parser.parse(trimmed, BasicCommands.multiParserImpl(Some(s))) match { + case Left(_) => trimmed :: Nil + case Right(c) => c + } + + // Convert the command strings to runnable tasks, which are represented by + // () => Try[Boolean]. + val taskParser = Command.combine(s.definedCommands)(s) + // This specified either the task corresponding to a command or the command itself if the + // the command cannot be converted to a task. + val (invalid, valid) = commands.foldLeft((Nil: Seq[String], Nil: Seq[() => Boolean])) { + case ((i, v), cmd) => + Parser.parse(cmd, taskParser) match { + case Right(task) => (i, v :+ makeTask(cmd)(task)) + case Left(c) => (i :+ c, v) + } + } + f(s, commands, valid, invalid) + } + + private[sbt] def runToTermination( + state: State, + command: String, + count: Int, + isCommand: Boolean + ): State = Watched.withCharBufferedStdIn { in => + val duped = new DupedInputStream(in) + setup(state.put(DupedSystemIn, duped), command) { (s, commands, valid, invalid) => + implicit val extracted: Extracted = Project.extract(s) + EvaluateTask.withStreams(extracted.structure, s)(_.use(Keys.streams in Global) { streams => + implicit val logger: Logger = streams.log + if (invalid.isEmpty) { + val currentCount = new AtomicInteger(count) + val callbacks = + aggregate(getAllConfigs(s, commands), logger, in, state, currentCount, isCommand) + val task = () => { + currentCount.getAndIncrement() + // abort as soon as one of the tasks fails + valid.takeWhile(_.apply()) + () + } + callbacks.onEnter() + // Here we enter the Watched.watch state machine. We will not return until one of the + // state machine callbacks returns Watched.CancelWatch, Watched.Custom, Watched.HandleError + // or Watched.Reload. The task defined above will be run at least once. It will be run + // additional times whenever the state transition callbacks return Watched.Trigger. + try { + val terminationAction = Watched.watch(task, callbacks.onStart, callbacks.nextEvent) + callbacks.onTermination(terminationAction, command, currentCount.get(), state) + } finally callbacks.onExit() + } else { + // At least one of the commands in the multi command string could not be parsed, so we + // log an error and exit. + val commands = invalid.mkString("'", "', '", "'") + logger.error(s"Terminating watch due to invalid command(s): $commands") + state.fail + } + }) + } + } + + private def parseCommands(state: State, commands: Seq[String]): Seq[ScopedKey[_]] = { + // Collect all of the scoped keys that are used to delegate the multi commands. These are + // necessary to extract all of the transitive globs that we need to monitor during watch. + // We have to add the <~ Parsers.any.* to ensure that we're able to extract the input key + // from input tasks. + val scopedKeyParser: Parser[Seq[ScopedKey[_]]] = Act.aggregatedKeyParser(state) <~ Parsers.any.* + commands.flatMap { cmd: String => + Parser.parse(cmd, scopedKeyParser) match { + case Right(scopedKeys: Seq[ScopedKey[_]]) => scopedKeys + case Left(e) => + throw new IllegalStateException(s"Error attempting to extract scope from $cmd: $e.") + case _ => Nil: Seq[ScopedKey[_]] + } + } + } + private def getAllConfigs( + state: State, + commands: Seq[String] + )(implicit extracted: Extracted, logger: Logger): Seq[Config] = { + val commandKeys = parseCommands(state, commands) + val compiledMap = InputGraph.compile(extracted.structure) + commandKeys.map((scopedKey: ScopedKey[_]) => getConfig(state, scopedKey, compiledMap)) + } + + private class Callbacks( + val nextEvent: () => Watched.Action, + val onEnter: () => Unit, + val onExit: () => Unit, + val onStart: () => Watched.Action, + val onTermination: (Watched.Action, String, Int, State) => State + ) + + /** + * Aggregates a collection of [[Config]] instances into a single instance of [[Callbacks]]. + * This allows us to monitor and respond to changes for all of + * the inputs and triggers for each of the tasks that we are monitoring in the continuous build. + * To monitor all of the inputs and triggers, it creates a [[FileEventMonitor]] for each task + * and then aggregates each of the individual [[FileEventMonitor]] instances into an aggregated + * instance. It aggregates all of the event callbacks into a single callback that delegates + * to each of the individual callbacks. For the callbacks that return a [[Watched.Action]], + * the aggregated callback will select the minimum [[Watched.Action]] returned where the ordering + * is such that the highest priority [[Watched.Action]] have the lowest values. Finally, to + * handle user input, we read from the provided input stream and buffer the result. Each + * task's input parser is then applied to the buffered result and, again, we return the mimimum + * [[Watched.Action]] returned by the parsers (when the parsers fail, they just return + * [[Watched.Ignore]], which is the lowest priority [[Watched.Action]]. + * + * @param configs the [[Config]] instances + * @param rawLogger the default sbt logger instance + * @param state the current state + * @param extracted the [[Extracted]] instance for the current build + * @return the [[Callbacks]] to pass into [[Watched.watch]] + */ + private def aggregate( + configs: Seq[Config], + rawLogger: Logger, + inputStream: InputStream, + state: State, + count: AtomicInteger, + isCommand: Boolean + )( + implicit extracted: Extracted + ): Callbacks = { + val logger = setLevel(rawLogger, configs.map(_.watchSettings.logLevel).min, state) + val onEnter = () => configs.foreach(_.watchSettings.onEnter()) + val onStart: () => Watched.Action = getOnStart(configs, logger, count) + val nextInputEvent: () => Watched.Action = parseInputEvents(configs, state, inputStream, logger) + val (nextFileEvent, cleanupFileMonitor): (() => Watched.Action, () => Unit) = + getFileEvents(configs, logger, state, count) + val nextEvent: () => Watched.Action = + combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger) + val onExit = () => { + cleanupFileMonitor() + configs.foreach(_.watchSettings.onExit()) + } + val onTermination = getOnTermination(configs, isCommand) + new Callbacks(nextEvent, onEnter, onExit, onStart, onTermination) + } + + private def getOnTermination( + configs: Seq[Config], + isCommand: Boolean + ): (Watched.Action, String, Int, State) => State = { + configs.flatMap(_.watchSettings.onTermination).distinct match { + case Seq(head, tail @ _*) => + tail.foldLeft(head) { + case (onTermination, configOnTermination) => + (action, cmd, count, state) => + configOnTermination(action, cmd, count, onTermination(action, cmd, count, state)) + } + case _ => + if (isCommand) Watched.defaultCommandOnTermination else Watched.defaultTaskOnTermination + } + } + + private def getOnStart( + configs: Seq[Config], + logger: Logger, + count: AtomicInteger + ): () => Watched.Action = { + val f = configs.map { params => + val ws = params.watchSettings + ws.onStart.map(_.apply(params.arguments(logger))).getOrElse { () => + ws.onIteration.map(_(count.get)).getOrElse { + if (configs.size == 1) { // Only allow custom start messages for single tasks + ws.startMessage match { + case Some(Left(sm)) => logger.info(sm(params.watchState(count.get()))) + case Some(Right(sm)) => sm(count.get()).foreach(logger.info(_)) + case None => Watched.defaultStartWatch(count.get()).foreach(logger.info(_)) + } + } + Watched.Ignore + } + } + } + () => + { + val res = f.view.map(_()).min + // Print the default watch message if there are multiple tasks + if (configs.size > 1) Watched.defaultStartWatch(count.get()).foreach(logger.info(_)) + res + } + } + private def getFileEvents( + configs: Seq[Config], + logger: Logger, + state: State, + count: AtomicInteger, + )(implicit extracted: Extracted): (() => Watched.Action, () => Unit) = { + val trackMetaBuild = configs.forall(_.watchSettings.trackMetaBuild) + val buildGlobs = + if (trackMetaBuild) extracted.getOpt(Keys.fileInputs in Keys.settingsData).getOrElse(Nil) + else Nil + val buildFilter = buildGlobs.toEntryFilter + + /* + * This is a callback that will be invoked whenever onEvent returns a Trigger action. The + * motivation is to allow the user to specify this callback via setting so that, for example, + * they can clear the screen when the build triggers. + */ + val onTrigger: Event => Watched.Action = { + val f: Seq[Event => Unit] = configs.map { params => + val ws = params.watchSettings + ws.onTrigger + .map(_.apply(params.arguments(logger))) + .getOrElse { + val globFilter = (params.inputs ++ params.triggers).toEntryFilter + event: Event => + if (globFilter(event.entry)) { + ws.triggerMessage match { + case Some(Left(tm)) => logger.info(tm(params.watchState(count.get()))) + case Some(Right(tm)) => tm(count.get(), event).foreach(logger.info(_)) + case None => // By default don't print anything + } + } + } + } + event: Event => + f.view.foreach(_.apply(event)) + Watched.Trigger + } + + val onEvent: Event => (Event, Watched.Action) = { + val f = configs.map { params => + val ws = params.watchSettings + val oe = ws.onEvent + .map(_.apply(params.arguments(logger))) + .getOrElse { + val onInputEvent = ws.onInputEvent.getOrElse(Watched.trigger) + val onTriggerEvent = ws.onTriggerEvent.getOrElse(Watched.trigger) + val onMetaBuildEvent = ws.onMetaBuildEvent.getOrElse(Watched.ifChanged(Watched.Reload)) + val inputFilter = params.inputs.toEntryFilter + val triggerFilter = params.triggers.toEntryFilter + event: Event => + val c = count.get() + Seq[Watched.Action]( + if (inputFilter(event.entry)) onInputEvent(c, event) else Watched.Ignore, + if (triggerFilter(event.entry)) onTriggerEvent(c, event) else Watched.Ignore, + if (buildFilter(event.entry)) onMetaBuildEvent(c, event) else Watched.Ignore + ).min + } + event: Event => + event -> (oe(event) match { + case Watched.Trigger => onTrigger(event) + case a => a + }) + } + event: Event => + f.view.map(_.apply(event)).minBy(_._2) + } + val monitor: Monitor = new FileEventMonitor[FileAttributes] { + private def setup( + monitor: FileEventMonitor[FileAttributes], + globs: Seq[Glob] + ): FileEventMonitor[FileAttributes] = { + val globFilters = globs.toEntryFilter + val filter: Event => Boolean = (event: Event) => globFilters(event.entry) + new FileEventMonitor[FileAttributes] { + override def poll(duration: Duration): Seq[FileEventMonitor.Event[FileAttributes]] = + monitor.poll(duration).filter(filter) + override def close(): Unit = monitor.close() + } + } + private[this] val monitors: Seq[FileEventMonitor[FileAttributes]] = + configs.map { config => + // Create a logger with a scoped key prefix so that we can tell from which + // monitor events occurred. + val l = logger.withPrefix(config.key.show) + val monitor: FileEventMonitor[FileAttributes] = + FileManagement.monitor(config.repository, config.watchSettings.antiEntropy, l) + val allGlobs = (config.inputs ++ config.triggers).distinct.sorted + setup(monitor, allGlobs) + } ++ (if (trackMetaBuild) { + val l = logger.withPrefix("meta-build") + val antiEntropy = configs.map(_.watchSettings.antiEntropy).min + setup(FileManagement.monitor(getRepository(state), antiEntropy, l), buildGlobs) :: Nil + } else Nil) + override def poll(duration: Duration): Seq[FileEventMonitor.Event[FileAttributes]] = { + // The call to .par allows us to poll all of the monitors in parallel. + // This should be cheap because poll just blocks on a queue until an event is added. + monitors.par.flatMap(_.poll(duration)).toSet.toVector + } + override def close(): Unit = monitors.foreach(_.close()) + } + val watchLogger: WatchLogger = msg => logger.debug(msg.toString) + val retentionPeriod = configs.map(_.watchSettings.antiEntropyRetentionPeriod).max + val antiEntropy = configs.map(_.watchSettings.antiEntropy).max + val quarantinePeriod = configs.map(_.watchSettings.deletionQuarantinePeriod).max + val antiEntropyMonitor = FileEventMonitor.antiEntropy( + monitor, + antiEntropy, + watchLogger, + quarantinePeriod, + retentionPeriod + ) + (() => { + val actions = antiEntropyMonitor.poll(2.milliseconds).map(onEvent) + if (actions.exists(_._2 != Watched.Ignore)) { + val min = actions.minBy(_._2) + logger.debug(s"Received file event actions: ${actions.mkString(", ")}. Returning: $min") + min._2 + } else Watched.Ignore + }, () => monitor.close()) + } + + /** + * Each task has its own input parser that can be used to modify the watch based on the input + * read from System.in as well as a custom task-specific input stream that can be used as + * an alternative source of control. In this method, we create two functions for each task, + * one from `String => Seq[Watched.Action]` and another from `() => Seq[Watched.Action]`. + * Each of these functions is invoked to determine the next state transformation for the watch. + * The first function is a task specific copy of System.in. For each task we keep a mutable + * buffer of the characters previously seen from System.in. Every time we receive new characters + * we update the buffer and then try to parse a Watched.Action for each task. Any trailing + * characters are captured and can be used for the next trigger. Because each task has a local + * copy of the buffer, we do not have to worry about one task breaking parsing of another. We + * also provide an alternative per task InputStream that is read in a similar way except that + * we don't need to copy the custom InputStream which allows the function to be + * `() => Seq[Watched.Action]` which avoids actually exposing the InputStream anywhere. + */ + private def parseInputEvents( + configs: Seq[Config], + state: State, + inputStream: InputStream, + logger: Logger + )( + implicit extracted: Extracted + ): () => Watched.Action = { + /* + * This parses the buffer until all possible actions are extracted. By draining the input + * to a state where it does not parse an action, we can wait until we receive new input + * to attempt to parse again. + */ + type ActionParser = String => Watched.Action + // Transform the Config.watchSettings.inputParser instances to functions of type + // String => Watched.Action. The String that is provided will contain any characters that + // have been read from stdin. If there are any characters available, then it calls the + // parse method with the InputStream set to a ByteArrayInputStream that wraps the input + // string. The parse method then appends those bytes to a mutable buffer and attempts to + // parse the buffer. To make this work with streaming input, we prefix the parser with any.*. + // 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 any = Parsers.any.* + val inputParser = c.watchSettings.inputParser + val parser = any ~> inputParser ~ matched(any) + // Each parser gets its own copy of System.in that it can modify while parsing. + val systemInBuilder = new StringBuilder + def inputStream(string: String): InputStream = new ByteArrayInputStream(string.getBytes) + // This string is provided in the closure below by reading from System.in + val default: String => Watched.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) + } + .getOrElse(() => Watched.Ignore) + (string: String) => + (default(string) :: alternative() :: Nil).min + } + () => + { + val stringBuilder = new StringBuilder + while (inputStream.available > 0) stringBuilder += inputStream.read().toChar + val newBytes = stringBuilder.toString + val parse: ActionParser => Watched.Action = parser => parser(newBytes) + val allEvents = inputHandlers.map(parse).filterNot(_ == Watched.Ignore) + if (allEvents.exists(_ != Watched.Ignore)) { + val res = allEvents.min + logger.debug(s"Received input events: ${allEvents mkString ","}. Taking $res") + res + } else Watched.Ignore + } + } + + private def combineInputAndFileEvents( + nextInputEvent: () => Watched.Action, + nextFileEvent: () => Watched.Action, + logger: Logger + ): () => Watched.Action = () => { + val Seq(inputEvent: Watched.Action, fileEvent: Watched.Action) = + Seq(nextInputEvent, nextFileEvent).par.map(_.apply()).toIndexedSeq + val min: Watched.Action = Seq[Watched.Action](inputEvent, fileEvent).min + lazy val inputMessage = + s"Received input event: $inputEvent." + + (if (inputEvent != min) s" Dropping in favor of file event: $min" else "") + lazy val fileMessage = + s"Received file event: $fileEvent." + + (if (fileEvent != min) s" Dropping in favor of input event: $min" else "") + if (inputEvent != Watched.Ignore) logger.debug(inputMessage) + if (fileEvent != Watched.Ignore) logger.debug(fileMessage) + min + } + + @tailrec + private final def parse( + is: InputStream, + builder: StringBuilder, + parser: Parser[(Watched.Action, String)] + ): Watched.Action = { + if (is.available > 0) builder += is.read().toChar + Parser.parse(builder.toString, parser) match { + case Right((action, rest)) => + builder.clear() + builder ++= rest + action + case _ if is.available > 0 => parse(is, builder, parser) + case _ => Watched.Ignore + } + } + + /** + * Generates a custom logger for the watch process that is able to log at a different level + * from the provided logger. + * @param logger the delegate logger. + * @param logLevel the log level for watch events + * @return the wrapped logger. + */ + private def setLevel(logger: Logger, logLevel: Level.Value, state: State): Logger = { + import Level._ + val delegateLevel = state.get(Keys.logLevel.key).getOrElse(Info) + /* + * The delegate logger may be set to, say, info level, but we want it to print out debug + * messages if the logLevel variable above is Debug. To do this, we promote Debug messages + * to the Info level (or Warn or Error if that's what the input logger is set to). + */ + new Logger { + override def trace(t: => Throwable): Unit = logger.trace(t) + override def success(message: => String): Unit = logger.success(message) + override def log(level: Level.Value, message: => String): Unit = { + val levelString = if (level < delegateLevel) s"[$level] " else "" + val newMessage = s"[watch] $levelString$message" + val watchLevel = if (level < delegateLevel && level >= logLevel) delegateLevel else level + logger.log(watchLevel, newMessage) + } + } + } + + private type WatchOnEvent = (Int, Event) => Watched.Action + + /** + * Contains all of the user defined settings that will be used to build a [[Callbacks]] + * instance that is used to produce the arguments to [[Watched.watch]]. The + * callback settings (e.g. onEvent or onInputEvent) come in two forms: those that return a + * function from [[Arguments]] => F for some function type `F` and those that directly return a function, e.g. + * `(Int, Boolean) => Watched.Action`. The former are a low level interface that will usually + * be unspecified and automatically filled in by [[Continuous.aggregate]]. The latter are + * intended to be user configurable and will be scoped to the input [[ScopedKey]]. To ensure + * that the scoping makes sense, we first try and extract the setting from the [[ScopedKey]] + * instance's task scope, which is the scope with the task axis set to the task key. If that + * fails, we fall back on the task axis. To make this concrete, to get the logLevel for + * `foo / Compile / compile` (which is a TaskKey with scope `foo / Compile`), we first try and + * get the setting in the `foo / Compile / compile` scope. If logLevel is not set at the task + * level, then we fall back to the `foo / Compile` scope. + * + * This has to be done by manually extracting the settings via [[Extracted]] because there is + * no good way to automatically add a [[WatchSettings]] setting to every task in the build. + * Thankfully these map retrievals are reasonably fast so there is not a significant runtime + * performance penalty for creating the [[WatchSettings]] this way. The drawback is that we + * have to manually resolve the settings in multiple scopes which may lead to inconsistencies + * with scope resolution elsewhere in sbt. + * + * @param key the [[ScopedKey]] instance that sets the [[Scope]] for the settings we're extracting + * @param extracted the [[Extracted]] instance for the build + */ + private final class WatchSettings private[Continuous] (val key: ScopedKey[_])( + implicit extracted: Extracted + ) { + val antiEntropy: FiniteDuration = + key.get(Keys.watchAntiEntropy).getOrElse(Watched.defaultAntiEntropy) + val antiEntropyRetentionPeriod: FiniteDuration = + key + .get(Keys.watchAntiEntropyRetentionPeriod) + .getOrElse(Watched.defaultAntiEntropyRetentionPeriod) + val deletionQuarantinePeriod: FiniteDuration = + key.get(Keys.watchDeletionQuarantinePeriod).getOrElse(Watched.defaultDeletionQuarantinePeriod) + val inputHandler: Option[InputStream => Watched.Action] = key.get(Keys.watchInputHandler) + val inputParser: Parser[Watched.Action] = + key.get(Keys.watchInputParser).getOrElse(Watched.defaultInputParser) + val logLevel: Level.Value = key.get(Keys.watchLogLevel).getOrElse(Level.Info) + val onEnter: () => Unit = key.get(Keys.watchOnEnter).getOrElse(() => {}) + val onEvent: Option[Arguments => Event => Watched.Action] = key.get(Keys.watchOnEvent) + val onExit: () => Unit = key.get(Keys.watchOnExit).getOrElse(() => {}) + val onInputEvent: Option[WatchOnEvent] = key.get(Keys.watchOnInputEvent) + val onIteration: Option[Int => Watched.Action] = key.get(Keys.watchOnIteration) + val onMetaBuildEvent: Option[WatchOnEvent] = key.get(Keys.watchOnMetaBuildEvent) + val onStart: Option[Arguments => () => Watched.Action] = key.get(Keys.watchOnStart) + val onTermination: Option[(Watched.Action, String, Int, State) => State] = + key.get(Keys.watchOnTermination) + val onTrigger: Option[Arguments => Event => Unit] = key.get(Keys.watchOnTrigger) + val onTriggerEvent: Option[WatchOnEvent] = key.get(Keys.watchOnTriggerEvent) + val startMessage: StartMessage = getStartMessage(key) + val trackMetaBuild: Boolean = key.get(Keys.watchTrackMetaBuild).getOrElse(true) + val triggerMessage: TriggerMessage = getTriggerMessage(key) + + // Unlike the rest of the settings, InputStream is a TaskKey which means that if it is set, + // we have to use Extracted.runTask to get the value. The reason for this is because it is + // logical that users may want to use a different InputStream on each task invocation. The + // alternative would be SettingKey[() => InputStream], but that doesn't feel right because + // one might want the InputStream to depend on other tasks. + val inputStream: Option[TaskKey[InputStream]] = key.get(Keys.watchInputStream) + } + + /** + * Container class for all of the components we need to setup a watch for a particular task or + * input task. + * @param key the [[ScopedKey]] instance for the task we will watch + * @param repository the task [[FileTreeRepository]] instance + * @param inputs the transitive task inputs (see [[InputGraph]]) + * @param triggers the transitive triggers (see [[InputGraph]]) + * @param watchSettings the [[WatchSettings]] instance for the task + */ + private final class Config private[internal] ( + val key: ScopedKey[_], + val repository: FileTreeRepository[FileAttributes], + val inputs: Seq[Glob], + val triggers: Seq[Glob], + val watchSettings: WatchSettings + ) { + private[sbt] def watchState(count: Int): DeprecatedWatchState = + WatchState.empty(inputs ++ triggers).withCount(count) + def arguments(logger: Logger): Arguments = new Arguments(logger, inputs, triggers) + } + private def getStartMessage(key: ScopedKey[_])(implicit e: Extracted): StartMessage = Some { + lazy val default = key.get(Keys.watchStartMessage).getOrElse(Watched.defaultStartWatch) + key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default)) + } + private def getTriggerMessage(key: ScopedKey[_])(implicit e: Extracted): TriggerMessage = Some { + lazy val default = + key.get(Keys.watchTriggeredMessage).getOrElse(Watched.defaultOnTriggerMessage) + key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default)) + } + + private implicit class ScopeOps(val scope: Scope) { + + /** + * This shows the [[Scope]] in the format that a user would likely type it in a build + * or in the sbt console. For example, the key corresponding to the command + * foo/Compile/compile will pretty print as "foo / Compile / compile", not + * "ProjectRef($URI, foo) / compile / compile", where the ProjectRef part is just noise that + * is rarely relevant for debugging. + * @return the pretty printed output. + */ + def show: String = { + val mask = ScopeMask( + config = scope.config.toOption.isDefined, + task = scope.task.toOption.isDefined, + extra = scope.extra.toOption.isDefined + ) + Scope + .displayMasked(scope, " ", (_: Reference) match { + case p: ProjectRef => s"${p.project.trim} /" + case _ => "Global /" + }, mask) + .dropRight(3) // delete trailing "/" + .trim + } + } + + private implicit class ScopedKeyOps(val scopedKey: ScopedKey[_]) extends AnyVal { + + /** + * Gets the value for a setting key scoped to the wrapped [[ScopedKey]]. If the task axis is not + * set in the [[ScopedKey]], then we first set the task axis and try to extract the setting + * from that scope otherwise we fallback on the [[ScopedKey]] instance's scope. We use the + * reverse order if the task is set. + * + * @param settingKey the [[SettingKey]] to extract + * @param extracted the provided [[Extracted]] instance + * @tparam T the type of the [[SettingKey]] + * @return the optional value of the [[SettingKey]] if it is defined at the input + * [[ScopedKey]] instance's scope or task scope. + */ + def get[T](settingKey: SettingKey[T])(implicit extracted: Extracted): Option[T] = { + lazy val taskScope = Project.fillTaskAxis(scopedKey).scope + scopedKey.scope match { + case scope if scope.task.toOption.isDefined => + extracted.getOpt(settingKey in scope) orElse extracted.getOpt(settingKey in taskScope) + case scope => + extracted.getOpt(settingKey in taskScope) orElse extracted.getOpt(settingKey in scope) + } + } + + /** + * Gets the [[ScopedKey]] for a task scoped to the wrapped [[ScopedKey]]. If the task axis is + * not set in the [[ScopedKey]], then we first set the task axis and try to extract the tak + * from that scope otherwise we fallback on the [[ScopedKey]] instance's scope. We use the + * reverse order if the task is set. + * + * @param taskKey the [[TaskKey]] to extract + * @param extracted the provided [[Extracted]] instance + * @tparam T the type of the [[SettingKey]] + * @return the optional value of the [[SettingKey]] if it is defined at the input + * [[ScopedKey]] instance's scope or task scope. + */ + def get[T](taskKey: TaskKey[T])(implicit extracted: Extracted): Option[TaskKey[T]] = { + lazy val taskScope = Project.fillTaskAxis(scopedKey).scope + scopedKey.scope match { + case scope if scope.task.toOption.isDefined => + if (extracted.getOpt(taskKey in scope).isDefined) Some(taskKey in scope) + else if (extracted.getOpt(taskKey in taskScope).isDefined) Some(taskKey in taskScope) + else None + case scope => + if (extracted.getOpt(taskKey in taskScope).isDefined) Some(taskKey in taskScope) + else if (extracted.getOpt(taskKey in scope).isDefined) Some(taskKey in scope) + else None + } + } + + /** + * This shows the [[ScopedKey[_]] in the format that a user would likely type it in a build + * or in the sbt console. For example, the key corresponding to the command + * foo/Compile/compile will pretty print as "foo / Compile / compile", not + * "ProjectRef($URI, foo) / compile / compile", where the ProjectRef part is just noise that + * is rarely relevant for debugging. + * @return the pretty printed output. + */ + def show: String = s"${scopedKey.scope.show} / ${scopedKey.key}" + } + + private implicit class LoggerOps(val logger: Logger) extends AnyVal { + + /** + * Creates a logger that adds a prefix to the messages that it logs. The motivation is so that + * we can tell from which FileEventMonitor an event originated. + * @param prefix the string to prefix the message with + * @return the wrapped Logger. + */ + def withPrefix(prefix: String): Logger = new Logger { + override def trace(t: => Throwable): Unit = logger.trace(t) + override def success(message: => String): Unit = logger.success(message) + override def log(level: Level.Value, message: => String): Unit = + logger.log(level, s"$prefix - $message") + } + } + +} diff --git a/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala new file mode 100644 index 000000000..742c1aa46 --- /dev/null +++ b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala @@ -0,0 +1,19 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import sbt.internal.io.{ WatchState => WS } + +private[internal] trait DeprecatedContinuous { + protected type Event = sbt.io.FileEventMonitor.Event[FileAttributes] + protected type StartMessage = Option[Either[WS => String, Int => Option[String]]] + protected type TriggerMessage = Option[Either[WS => String, (Int, Event) => Option[String]]] + protected type DeprecatedWatchState = WS + protected val deprecatedWatchingMessage = sbt.Keys.watchingMessage + protected val deprecatedTriggeredMessage = sbt.Keys.triggeredMessage +} diff --git a/main/src/main/scala/sbt/internal/DupedInputStream.scala b/main/src/main/scala/sbt/internal/DupedInputStream.scala new file mode 100644 index 000000000..6334d5cbd --- /dev/null +++ b/main/src/main/scala/sbt/internal/DupedInputStream.scala @@ -0,0 +1,73 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.{ InputStream, PipedInputStream, PipedOutputStream } +import java.util.concurrent.LinkedBlockingQueue + +import scala.annotation.tailrec +import scala.collection.JavaConverters._ + +/** + * Creates a copy of the provided [[InputStream]] that forwards its contents to an arbitrary + * number of connected [[InputStream]] instances via pipe. + * @param in the [[InputStream]] to wrap. + */ +private[internal] class DupedInputStream(val in: InputStream) + extends InputStream + with AutoCloseable { + + /** + * Returns a copied [[InputStream]] that will receive the same bytes as System.in. + * @return + */ + def duped: InputStream = { + val pipedOutputStream = new PipedOutputStream() + pipes += pipedOutputStream + val res = new PollingInputStream(new PipedInputStream(pipedOutputStream)) + buffer.forEach(pipedOutputStream.write(_)) + res + } + + private[this] val pipes = new java.util.Vector[PipedOutputStream].asScala + private[this] val buffer = new LinkedBlockingQueue[Int] + private class PollingInputStream(val pipedInputStream: PipedInputStream) extends InputStream { + override def available(): Int = { + fillBuffer() + pipedInputStream.available() + } + override def read(): Int = { + fillBuffer() + pipedInputStream.read + } + } + override def available(): Int = { + fillBuffer() + buffer.size + } + override def read(): Int = { + fillBuffer() + buffer.take() + } + + private[this] def fillBuffer(): Unit = synchronized { + @tailrec + def impl(): Unit = in.available match { + case i if i > 0 => + val res = in.read() + buffer.add(res) + pipes.foreach { p => + p.write(res) + p.flush() + } + impl() + case _ => + } + impl() + } +} diff --git a/main/src/main/scala/sbt/internal/FileManagement.scala b/main/src/main/scala/sbt/internal/FileManagement.scala index 87f067e21..77c2c0624 100644 --- a/main/src/main/scala/sbt/internal/FileManagement.scala +++ b/main/src/main/scala/sbt/internal/FileManagement.scala @@ -12,10 +12,10 @@ import java.io.IOException import java.util.concurrent.ConcurrentHashMap import sbt.BasicCommandStrings.ContinuousExecutePrefix -import sbt.Keys._ import sbt.internal.io.HybridPollingFileTreeRepository import sbt.internal.util.Util import sbt.io.FileTreeDataView.{ Entry, Observable, Observer, Observers } +import sbt.io.Glob.TraversableGlobOps import sbt.io.{ FileTreeRepository, _ } import sbt.util.{ Level, Logger } @@ -100,10 +100,53 @@ private[sbt] object FileManagement { override def close(): Unit = monitor.close() } } + private[sbt] implicit class FileTreeRepositoryOps[T](val repo: FileTreeRepository[T]) + extends AnyVal { + def copy(): FileTreeRepository[T] = + copy(ConcurrentHashMap.newKeySet[Glob].asScala, closeUnderlying = false) - private[sbt] def repo: Def.Initialize[Task[FileTreeRepository[FileAttributes]]] = Def.task { - lazy val msg = s"Tried to get FileTreeRepository for uninitialized state." - state.value.get(Keys.globalFileTreeRepository).getOrElse(throw new IllegalStateException(msg)) + /** + * Creates a copied FileTreeRepository that keeps track of all of the globs that are explicitly + * registered with it. + * + * @param registered the registered globs + * @param closeUnderlying toggles whether or not close should actually close the delegate + * repository + * + * @return the copied FileTreeRepository + */ + def copy(registered: mutable.Set[Glob], closeUnderlying: Boolean): FileTreeRepository[T] = + new FileTreeRepository[T] { + private val entryFilter: FileTreeDataView.Entry[T] => Boolean = + (entry: FileTreeDataView.Entry[T]) => registered.toEntryFilter(entry) + private[this] val observers = new Observers[T] { + override def onCreate(newEntry: FileTreeDataView.Entry[T]): Unit = + if (entryFilter(newEntry)) super.onCreate(newEntry) + override def onDelete(oldEntry: FileTreeDataView.Entry[T]): Unit = + if (entryFilter(oldEntry)) super.onDelete(oldEntry) + override def onUpdate( + oldEntry: FileTreeDataView.Entry[T], + newEntry: FileTreeDataView.Entry[T] + ): Unit = if (entryFilter(newEntry)) super.onUpdate(oldEntry, newEntry) + } + private[this] val handle = repo.addObserver(observers) + override def register(glob: Glob): Either[IOException, Boolean] = { + registered.add(glob) + repo.register(glob) + } + override def unregister(glob: Glob): Unit = repo.unregister(glob) + override def addObserver(observer: FileTreeDataView.Observer[T]): Int = + observers.addObserver(observer) + override def removeObserver(handle: Int): Unit = observers.removeObserver(handle) + override def close(): Unit = { + repo.removeObserver(handle) + if (closeUnderlying) repo.close() + } + override def toString: String = s"CopiedFileTreeRepository(base = $repo)" + override def list(glob: Glob): Seq[TypedPath] = repo.list(glob) + override def listEntries(glob: Glob): Seq[FileTreeDataView.Entry[T]] = + repo.listEntries(glob) + } } private[sbt] class HybridMonitoringRepository[T]( diff --git a/project/SbtLauncherPlugin.scala b/project/SbtLauncherPlugin.scala index 7386b79cc..7d95ed9ee 100644 --- a/project/SbtLauncherPlugin.scala +++ b/project/SbtLauncherPlugin.scala @@ -21,7 +21,8 @@ object SbtLauncherPlugin extends AutoPlugin { case Some(jar) => jar.data case None => sys.error( - s"Could not resolve sbt launcher!, dependencies := ${libraryDependencies.value}") + s"Could not resolve sbt launcher!, dependencies := ${libraryDependencies.value}" + ) } }, sbtLaunchJar := { diff --git a/sbt/src/sbt-test/tests/interproject-inputs/build.sbt b/sbt/src/sbt-test/tests/interproject-inputs/build.sbt new file mode 100644 index 000000000..61996f5be --- /dev/null +++ b/sbt/src/sbt-test/tests/interproject-inputs/build.sbt @@ -0,0 +1,58 @@ +import sbt.internal.TransitiveGlobs._ +val cached = settingKey[Unit]("") +val newInputs = settingKey[Unit]("") +Compile / cached / fileInputs := (Compile / unmanagedSources / fileInputs).value ++ + (Compile / unmanagedResources / fileInputs).value +Test / cached / fileInputs := (Test / unmanagedSources / fileInputs).value ++ + (Test / unmanagedResources / fileInputs).value +Compile / newInputs / fileInputs += baseDirectory.value * "*.sc" + +Compile / unmanagedSources / fileInputs ++= (Compile / newInputs / fileInputs).value + +val checkCompile = taskKey[Unit]("check compile inputs") +checkCompile := { + val actual = (Compile / compile / transitiveInputs).value.toSet + val expected = ((Compile / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"$actual did not equal $expected\n" + + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } + +} + +val checkRun = taskKey[Unit]("check runtime inputs") +checkRun := { + val actual = (Runtime / run / transitiveInputs).value.toSet + // Runtime doesn't add any new inputs, but it should correctly find the Compile inputs via + // delegation. + val expected = ((Compile / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields: $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } +} + +val checkTest = taskKey[Unit]("check test inputs") +checkTest := { + val actual = (Test / compile / transitiveInputs).value.toSet + val expected = ((Test / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value ++ + (Compile / cached / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"$actual did not equal $expected\n" + + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } +} diff --git a/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala b/sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/bar/Bar.scala similarity index 100% rename from sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala rename to sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/bar/Bar.scala diff --git a/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala b/sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/foo/Foo.scala similarity index 100% rename from sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala rename to sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/foo/Foo.scala diff --git a/sbt/src/sbt-test/tests/interproject-inputs/test b/sbt/src/sbt-test/tests/interproject-inputs/test new file mode 100644 index 000000000..7aca28678 --- /dev/null +++ b/sbt/src/sbt-test/tests/interproject-inputs/test @@ -0,0 +1,5 @@ +> checkCompile + +> checkRun + +> checkTest diff --git a/sbt/src/sbt-test/tests/transitive-inputs/build.sbt b/sbt/src/sbt-test/tests/transitive-inputs/build.sbt deleted file mode 100644 index f3151f5c5..000000000 --- a/sbt/src/sbt-test/tests/transitive-inputs/build.sbt +++ /dev/null @@ -1,46 +0,0 @@ -val foo = taskKey[Int]("foo") -foo := { - val _ = (foo / fileInputs).value - 1 -} -foo / fileInputs += baseDirectory.value * "foo.txt" -val checkFoo = taskKey[Unit]("check foo inputs") -checkFoo := { - val actual = (foo / transitiveDependencies).value.toSet - val expected = (foo / fileInputs).value.toSet - assert(actual == expected) -} - -val bar = taskKey[Int]("bar") -bar := { - val _ = (bar / fileInputs).value - foo.value + 1 -} -bar / fileInputs += baseDirectory.value * "bar.txt" - -val checkBar = taskKey[Unit]("check bar inputs") -checkBar := { - val actual = (bar / transitiveDependencies).value.toSet - val expected = ((bar / fileInputs).value ++ (foo / fileInputs).value).toSet - assert(actual == expected) -} - -val baz = taskKey[Int]("baz") -baz / fileInputs += baseDirectory.value * "baz.txt" -baz := { - println(resolvedScoped.value) - val _ = (baz / fileInputs).value - bar.value + 1 -} -baz := Def.taskDyn { - val _ = (bar / transitiveDependencies).value - val len = (baz / fileInputs).value.length - Def.task(bar.value + len) -}.value - -val checkBaz = taskKey[Unit]("check bar inputs") -checkBaz := { - val actual = (baz / transitiveDependencies).value.toSet - val expected = ((bar / fileInputs).value ++ (foo / fileInputs).value ++ (baz / fileInputs).value).toSet - assert(actual == expected) -} diff --git a/sbt/src/sbt-test/tests/transitive-inputs/test b/sbt/src/sbt-test/tests/transitive-inputs/test deleted file mode 100644 index 24a3714e8..000000000 --- a/sbt/src/sbt-test/tests/transitive-inputs/test +++ /dev/null @@ -1,5 +0,0 @@ -#> checkFoo - -#> checkBar - -> checkBaz diff --git a/sbt/src/sbt-test/watch/watch-parser/build.sbt b/sbt/src/sbt-test/watch/command-parser/build.sbt similarity index 57% rename from sbt/src/sbt-test/watch/watch-parser/build.sbt rename to sbt/src/sbt-test/watch/command-parser/build.sbt index c29f61af0..1e26f0e48 100644 --- a/sbt/src/sbt-test/watch/watch-parser/build.sbt +++ b/sbt/src/sbt-test/watch/command-parser/build.sbt @@ -8,6 +8,6 @@ setStringValue := setStringValueImpl.evaluated checkStringValue := checkStringValueImpl.evaluated -watchSources += file("string.txt") +setStringValue / watchTriggers := baseDirectory.value * "string.txt" :: Nil -watchOnEvent := { _ => Watched.CancelWatch } +watchOnEvent := { _ => _ => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/watch-parser/project/Build.scala b/sbt/src/sbt-test/watch/command-parser/project/Build.scala similarity index 52% rename from sbt/src/sbt-test/watch/watch-parser/project/Build.scala rename to sbt/src/sbt-test/watch/command-parser/project/Build.scala index 19380e885..0650b3ad8 100644 --- a/sbt/src/sbt-test/watch/watch-parser/project/Build.scala +++ b/sbt/src/sbt-test/watch/command-parser/project/Build.scala @@ -1,16 +1,16 @@ import sbt._ +import Keys.baseDirectory + object Build { - private[this] var string: String = "" - private[this] val stringFile = file("string.txt") val setStringValue = inputKey[Unit]("set a global string to a value") val checkStringValue = inputKey[Unit]("check the value of a global") def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { - string = Def.spaceDelimited().parsed.mkString(" ").trim - IO.write(stringFile, string) + val Seq(stringFile, string) = Def.spaceDelimited().parsed + IO.write(baseDirectory.value / stringFile, string) } def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { - assert(string == Def.spaceDelimited().parsed.mkString(" ").trim) - assert(IO.read(stringFile) == string) + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(baseDirectory.value / stringFile) == string) } } \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/command-parser/test b/sbt/src/sbt-test/watch/command-parser/test new file mode 100644 index 000000000..e8733214f --- /dev/null +++ b/sbt/src/sbt-test/watch/command-parser/test @@ -0,0 +1,21 @@ +> ~; setStringValue string.txt foo; setStringValue string.txt bar + +> checkStringValue string.txt bar + +> ~;setStringValue string.txt foo;setStringValue string.txt bar; checkStringValue string.txt bar + +> ~; setStringValue string.txt foo;setStringValue string.txt bar; checkStringValue string.txt bar + +> ~; setStringValue string.txt foo; setStringValue string.txt bar; checkStringValue string.txt bar + +# no leading semicolon +> ~ setStringValue string.txt foo; setStringValue string.txt bar; checkStringValue string.txt bar + +> ~ setStringValue string.txt foo + +> checkStringValue string.txt foo + +# All of the other tests have involved input tasks, so include commands with regular tasks as well. +> ~; compile; setStringValue string.txt baz; checkStringValue string.txt baz +# Ensure that trailing semi colons work +> ~ compile; setStringValue string.txt baz; checkStringValue string.txt baz; diff --git a/sbt/src/sbt-test/watch/custom-config/build.sbt b/sbt/src/sbt-test/watch/custom-config/build.sbt new file mode 100644 index 000000000..1c8d34f46 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/build.sbt @@ -0,0 +1,5 @@ +import sbt.input.aggregation.Build + +val root = Build.root +val foo = Build.foo +val bar = Build.bar diff --git a/sbt/src/sbt-test/watch/custom-config/project/Build.scala b/sbt/src/sbt-test/watch/custom-config/project/Build.scala new file mode 100644 index 000000000..2696d5c75 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/project/Build.scala @@ -0,0 +1,40 @@ +package sbt.input.aggregation + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val foo = project.settings( + watchStartMessage := { (count: Int) => Some(s"FOO $count") }, + Compile / compile / watchTriggers += baseDirectory.value * "foo.txt", + Compile / compile / watchStartMessage := { (count: Int) => + // this checks that Compile / compile / watchStartMessage + // is preferred to Compile / watchStartMessage + val outputFile = baseDirectory.value / "foo.txt" + IO.write(outputFile, "compile") + Some(s"compile $count") + }, + Compile / watchStartMessage := { (count: Int) => Some(s"Compile $count") }, + Runtime / watchStartMessage := { (count: Int) => Some(s"Runtime $count") }, + setStringValue := { + val _ = (fileInputs in (bar, setStringValue)).value + setStringValueImpl.evaluated + }, + checkStringValue := checkStringValueImpl.evaluated, + watchOnEvent := { _ => _ => Watched.CancelWatch } + ) + lazy val bar = project.settings(fileInputs in setStringValue += baseDirectory.value * "foo.txt") + lazy val root = (project in file(".")).aggregate(foo, bar).settings( + watchOnEvent := { _ => _ => Watched.CancelWatch } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/custom-config/test b/sbt/src/sbt-test/watch/custom-config/test new file mode 100644 index 000000000..1b878cf44 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/test @@ -0,0 +1,7 @@ +> ~ foo/Runtime/setStringValue bar/foo.txt foo + +> checkStringValue bar/foo.txt foo + +> ~ foo/compile + +> checkStringValue foo/foo.txt compile \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-aggregation/build.sbt b/sbt/src/sbt-test/watch/input-aggregation/build.sbt new file mode 100644 index 000000000..aa95faf82 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/build.sbt @@ -0,0 +1,7 @@ +import sbt.input.aggregation.Build + +val root = Build.root +val foo = Build.foo +val bar = Build.bar + +Global / watchTriggers += baseDirectory.value * "baz.txt" diff --git a/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala new file mode 100644 index 000000000..eca66f9b5 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala @@ -0,0 +1,94 @@ +package sbt.input.aggregation + +import sbt._ +import Keys._ +import sbt.internal.TransitiveGlobs._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + val checkTriggers = taskKey[Unit]("Check that the triggers are correctly aggregated.") + val checkGlobs = taskKey[Unit]("Check that the globs are correctly aggregated and that the globs are the union of the inputs and the triggers") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + def checkGlobsImpl: Def.Initialize[Task[Unit]] = Def.task { + val (globInputs, globTriggers) = (Compile / compile / transitiveGlobs).value + val inputs = (Compile / compile / transitiveInputs).value.toSet + val triggers = (Compile / compile / transitiveTriggers).value.toSet + assert(globInputs.toSet == inputs) + assert(globTriggers.toSet == triggers) + } + lazy val foo = project.settings( + setStringValue := { + val _ = (fileInputs in (bar, setStringValue)).value + setStringValueImpl.evaluated + }, + checkStringValue := checkStringValueImpl.evaluated, + watchOnTriggerEvent := { (_, _) => Watched.CancelWatch }, + watchOnInputEvent := { (_, _) => Watched.CancelWatch }, + Compile / compile / watchOnStart := { _ => () => Watched.CancelWatch }, + checkTriggers := { + val actual = (Compile / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + // This checks that since foo depends on bar there is a transitive trigger generated + // for the "bar.txt" trigger added to bar / Compile / unmanagedResources (which is a + // transitive dependency of + val expected: Set[Glob] = Set(base * "baz.txt", (base / "bar") * "bar.txt") + assert(actual == expected) + }, + Test / test / watchTriggers += baseDirectory.value * "test.txt", + Test / checkTriggers := { + val testTriggers = (Test / test / transitiveTriggers).value.toSet + // This validates that since the "test.txt" trigger is only added to the Test / test task, + // that the Test / compile does not pick it up. Both of them pick up the the triggers that + // are found in the test above for the compile configuration because of the transitive + // classpath dependency that is added in Defaults.internalDependencies. + val compileTriggers = (Test / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + val expected: Set[Glob] = Set( + base * "baz.txt", (base / "bar") * "bar.txt", (base / "foo") * "test.txt") + assert(testTriggers == expected) + assert((testTriggers - ((base / "foo") * "test.txt")) == compileTriggers) + }, + checkGlobs := checkGlobsImpl.value + ).dependsOn(bar) + lazy val bar = project.settings( + fileInputs in setStringValue += baseDirectory.value * "foo.txt", + setStringValue / watchTriggers += baseDirectory.value * "bar.txt", + // This trigger should transitively propagate to foo / compile and foo / Test / compile + Compile / unmanagedResources / watchTriggers += baseDirectory.value * "bar.txt", + checkTriggers := { + val base = baseDirectory.value.getParentFile + val actual = (Compile / compile / transitiveTriggers).value + val expected: Set[Glob] = Set((base / "bar") * "bar.txt", base * "baz.txt") + assert(actual.toSet == expected) + }, + // This trigger should not transitively propagate to any foo task + Test / unmanagedResources / watchTriggers += baseDirectory.value * "bar-test.txt", + Test / checkTriggers := { + val testTriggers = (Test / test / transitiveTriggers).value.toSet + val compileTriggers = (Test / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + val expected: Set[Glob] = Set( + base * "baz.txt", (base / "bar") * "bar.txt", (base / "bar") * "bar-test.txt") + assert(testTriggers == expected) + assert(testTriggers == compileTriggers) + }, + checkGlobs := checkGlobsImpl.value + ) + lazy val root = (project in file(".")).aggregate(foo, bar).settings( + watchOnEvent := { _ => _ => Watched.CancelWatch }, + checkTriggers := { + val actual = (Compile / compile / transitiveTriggers).value + val expected: Seq[Glob] = baseDirectory.value * "baz.txt" :: Nil + assert(actual == expected) + }, + checkGlobs := checkGlobsImpl.value + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-aggregation/test b/sbt/src/sbt-test/watch/input-aggregation/test new file mode 100644 index 000000000..052d414d6 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/test @@ -0,0 +1,11 @@ +> checkTriggers + +> Test / checkTriggers + +> checkGlobs + +# do not set the project here to ensure the bar/bar.txt trigger is captured by aggregation +# also add random spaces and multiple commands to ensure the parser is sane. +> ~ setStringValue bar/bar.txt bar; root / setStringValue bar/bar.txt baz + +> checkStringValue bar/bar.txt baz diff --git a/sbt/src/sbt-test/watch/input-parser/build.sbt b/sbt/src/sbt-test/watch/input-parser/build.sbt new file mode 100644 index 000000000..5de1267dc --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/build.sbt @@ -0,0 +1,9 @@ +import sbt.input.parser.Build + +watchInputStream := Build.inputStream + +watchStartMessage := { count => + Build.outputStream.write('\n'.toByte) + Build.outputStream.flush() + Some("default start message") +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-parser/project/Build.scala b/sbt/src/sbt-test/watch/input-parser/project/Build.scala new file mode 100644 index 000000000..d430bdb76 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/project/Build.scala @@ -0,0 +1,27 @@ +package sbt +package input.parser + +import complete.Parser +import complete.Parser._ + +import java.io.{ PipedInputStream, PipedOutputStream } + +object Build { + val outputStream = new PipedOutputStream() + val inputStream = new PipedInputStream(outputStream) + val byeParser: Parser[Watched.Action] = "bye" ^^^ Watched.CancelWatch + val helloParser: Parser[Watched.Action] = "hello" ^^^ Watched.Ignore + // Note that the order is byeParser | helloParser. In general, we want the higher priority + // action to come first because otherwise we would potentially scan past it. + val helloOrByeParser: Parser[Watched.Action] = byeParser | helloParser + val alternativeStartMessage: Int => Option[String] = { _ => + outputStream.write("xybyexyblahxyhelloxy".getBytes) + outputStream.flush() + Some("alternative start message") + } + val otherAlternativeStartMessage: Int => Option[String] = { _ => + outputStream.write("xyhellobyexyblahx".getBytes) + outputStream.flush() + Some("other alternative start message") + } +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-parser/test b/sbt/src/sbt-test/watch/input-parser/test new file mode 100644 index 000000000..981496f0b --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/test @@ -0,0 +1,17 @@ +# this should exit because watchStartMessage writes "\n" to Build.outputStream, which in turn +# triggers a CancelWatch +> ~ compile + +> set watchStartMessage := sbt.input.parser.Build.alternativeStartMessage + +> set watchInputParser := sbt.input.parser.Build.helloOrByeParser + +# this should exit because we write "xybyexyblahxyhelloxy" to Build.outputStream. The +# helloOrByeParser will produce Watched.Ignore and Watched.CancelWatch but the +# Watched.CancelWatch event should win. +> ~ compile + +> set watchStartMessage := sbt.input.parser.Build.otherAlternativeStartMessage + +# this is the same as above except that hello appears before bye in the string +> ~ compile diff --git a/sbt/src/sbt-test/watch/legacy-sources/build.sbt b/sbt/src/sbt-test/watch/legacy-sources/build.sbt new file mode 100644 index 000000000..5ee39a863 --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/build.sbt @@ -0,0 +1,13 @@ +import sbt.legacy.sources.Build._ + +Global / watchSources += new sbt.internal.io.Source(baseDirectory.value, "global.txt", NothingFilter, false) + +watchSources in setStringValue += new sbt.internal.io.Source(baseDirectory.value, "foo.txt", NothingFilter, false) + +setStringValue := setStringValueImpl.evaluated + +checkStringValue := checkStringValueImpl.evaluated + +watchOnTriggerEvent := { (_, _) => Watched.CancelWatch } +watchOnInputEvent := { (_, _) => Watched.CancelWatch } +watchOnMetaBuildEvent := { (_, _) => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala b/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala new file mode 100644 index 000000000..17643092a --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala @@ -0,0 +1,17 @@ +package sbt.legacy.sources + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/legacy-sources/test b/sbt/src/sbt-test/watch/legacy-sources/test new file mode 100644 index 000000000..834087ccb --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/test @@ -0,0 +1,3 @@ +> ~ setStringValue foo.txt foo + +> checkStringValue foo.txt foo \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/on-start-watch/build.sbt b/sbt/src/sbt-test/watch/on-start-watch/build.sbt index 1c6dab6c1..b66bb6199 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/build.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/build.sbt @@ -1,11 +1,6 @@ -import scala.util.Try - val checkCount = inputKey[Unit]("check that compile has run a specified number of times") -val checkReloadCount = inputKey[Unit]("check whether the project was reloaded") val failingTask = taskKey[Unit]("should always fail") -val maybeReload = settingKey[(Int, Boolean) => Watched.Action]("possibly reload") val resetCount = taskKey[Unit]("reset compile count") -val reloadFile = settingKey[File]("get the current reload file") checkCount := { val expected = Def.spaceDelimited().parsed.head.toInt @@ -13,12 +8,6 @@ checkCount := { throw new IllegalStateException(s"Expected ${expected} compilation runs, got ${Count.get}") } -maybeReload := { (_, _) => - if (Count.reloadCount(reloadFile.value) == 0) Watched.Reload else Watched.CancelWatch -} - -reloadFile := baseDirectory.value / "reload-count" - resetCount := { Count.reset() } @@ -27,24 +16,6 @@ failingTask := { throw new IllegalStateException("failed") } -watchPreWatch := maybeReload.value - -checkReloadCount := { - val expected = Def.spaceDelimited().parsed.head.toInt - assert(Count.reloadCount(reloadFile.value) == expected) -} - -val addReloadShutdownHook = Command.command("addReloadShutdownHook") { state => - state.addExitHook { - val base = Project.extract(state).get(baseDirectory) - val file = base / "reload-count" - val currentCount = Try(Count.reloadCount(file)).getOrElse(0) - IO.write(file, s"${currentCount + 1}".getBytes) - } -} - -commands += addReloadShutdownHook - Compile / compile := { Count.increment() // Trigger a new build by updating the last modified time diff --git a/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt b/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt new file mode 100644 index 000000000..e8b658ba1 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt @@ -0,0 +1,4 @@ +val checkReloaded = taskKey[Unit]("Asserts that the build was reloaded") +checkReloaded := { () } + +watchOnIteration := { _ => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/on-start-watch/extra.sbt b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt new file mode 100644 index 000000000..6af4f2331 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt @@ -0,0 +1 @@ +watchOnStart := { _ => () => Watched.Reload } diff --git a/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala b/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala index 67d3bf940..db2258f5c 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala +++ b/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala @@ -4,7 +4,11 @@ import scala.util.Try object Count { private var count = 0 def get: Int = count - def increment(): Unit = count += 1 - def reset(): Unit = count = 0 + def increment(): Unit = { + count += 1 + } + def reset(): Unit = { + count = 0 + } def reloadCount(file: File): Int = Try(IO.read(file).toInt).getOrElse(0) } diff --git a/sbt/src/sbt-test/watch/on-start-watch/test b/sbt/src/sbt-test/watch/on-start-watch/test index 37781fce3..f550e66b6 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/test +++ b/sbt/src/sbt-test/watch/on-start-watch/test @@ -1,28 +1,24 @@ -# verify that reloading occurs if watchPreWatch returns Watched.Reload -> addReloadShutdownHook -> checkReloadCount 0 +# verify that reloading occurs if watchOnStart returns Watched.Reload +$ copy-file changes/extra.sbt extra.sbt + > ~compile -> checkReloadCount 1 +> checkReloaded # verify that the watch terminates when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watched.CancelWatch else Watched.Ignore } > ~compile > checkCount 2 # verify that the watch terminates and returns an error when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.HandleError else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) new Watched.HandleError(new Exception("")) else Watched.Ignore } # Returning Watched.HandleError causes the '~' command to fail -> ~compile > checkCount 2 # verify that a re-build is triggered when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.Trigger else if (count == 3) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watched.Trigger else if (count == 3) Watched.CancelWatch else Watched.Ignore } > ~compile > checkCount 3 - -# verify that the watch exits and returns an error if the task fails -> set watchPreWatch := { (_, lastStatus: Boolean) => if (lastStatus) Watched.Ignore else Watched.HandleError } --> ~failingTask diff --git a/sbt/src/sbt-test/watch/task/build.sbt b/sbt/src/sbt-test/watch/task/build.sbt new file mode 100644 index 000000000..3e1169f6d --- /dev/null +++ b/sbt/src/sbt-test/watch/task/build.sbt @@ -0,0 +1,3 @@ +import sbt.watch.task.Build + +val root = Build.root diff --git a/sbt/src/sbt-test/watch/task/changes/Build.scala b/sbt/src/sbt-test/watch/task/changes/Build.scala new file mode 100644 index 000000000..1c5162d37 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/changes/Build.scala @@ -0,0 +1,27 @@ +package sbt.watch.task + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val root = (project in file(".")).settings( + setStringValue / watchTriggers += baseDirectory.value * "foo.txt", + setStringValue := setStringValueImpl.evaluated, + checkStringValue := checkStringValueImpl.evaluated, + watchStartMessage := { _ => + IO.touch(baseDirectory.value / "foo.txt", true) + Some("watching") + }, + watchOnStart := { _ => () => Watched.CancelWatch } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/task/project/Build.scala b/sbt/src/sbt-test/watch/task/project/Build.scala new file mode 100644 index 000000000..f0beda1c1 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/project/Build.scala @@ -0,0 +1,34 @@ +package sbt.watch.task + +import sbt._ +import Keys._ + +object Build { + val reloadFile = settingKey[File]("file to toggle whether or not to reload") + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val root = (project in file(".")).settings( + reloadFile := baseDirectory.value / "reload", + setStringValue / watchTriggers += baseDirectory.value * "foo.txt", + setStringValue := setStringValueImpl.evaluated, + checkStringValue := checkStringValueImpl.evaluated, + watchStartMessage := { _ => + IO.touch(baseDirectory.value / "foo.txt", true) + Some("watching") + }, + watchOnTriggerEvent := { (f, e) => + if (reloadFile.value.exists) Watched.CancelWatch else { + IO.touch(reloadFile.value, true) + Watched.Reload + } + } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/task/test b/sbt/src/sbt-test/watch/task/test new file mode 100644 index 000000000..b3e02de19 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/test @@ -0,0 +1,12 @@ +# this tests that if the watch _task_ is able to reload the project + +# the original version of the build will only return Watched.Reload for trigger events while the +# updated version will return Watched.CancelWatch. If this test exits, it more or less works. +$ copy-file changes/Build.scala project/Build.scala + +# setStringValue has foo.txt as a watch source so running that command should first trigger a +# reload. After the project has been reloaded, the next write to setStringValue will also +# trigger a CancelWatch event, hence we exit. +> watch root / setStringValue foo.txt bar + +> checkStringValue foo.txt bar diff --git a/sbt/src/sbt-test/watch/watch-parser/test b/sbt/src/sbt-test/watch/watch-parser/test deleted file mode 100644 index 4d5358af7..000000000 --- a/sbt/src/sbt-test/watch/watch-parser/test +++ /dev/null @@ -1,21 +0,0 @@ -> ~; setStringValue foo; setStringValue bar - -> checkStringValue bar - -> ~;setStringValue foo;setStringValue bar; checkStringValue bar - -> ~; setStringValue foo;setStringValue bar; checkStringValue bar - -> ~; setStringValue foo; setStringValue bar; checkStringValue bar - -# no leading semicolon -> ~ setStringValue foo; setStringValue bar; checkStringValue bar - -> ~ setStringValue foo - -> checkStringValue foo - -# All of the other tests have involved input tasks, so include commands with regular tasks as well. -> ~; compile; setStringValue baz; checkStringValue baz -# Ensure that trailing semi colons work -> ~ compile; setStringValue baz; checkStringValue baz;