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;