Improve watch messages

This commit reworks the watch start message so that instead of printing
something like:

[info] [watch] 1. Waiting for source changes... (press 'r' to re-run the command, 'x' to exit sbt or 'enter' to return to the shell)

it instead prints something like:

[info] 1. Monitoring source files for updates...
[info] Project: filesJVM
[info] Command: compile
[info] Options:
[info]   <enter>: return to the shell
[info]   'r': repeat the current command
[info]   'x': exit sbt

It will also print which path triggered the build.
This commit is contained in:
Ethan Atkins 2019-03-29 08:33:36 -07:00
parent c72005fd2b
commit 247d242008
11 changed files with 140 additions and 106 deletions

View File

@ -118,11 +118,12 @@ object Watched {
// Deprecated apis below
@deprecated("unused", "1.3.0")
def projectWatchingMessage(projectId: String): WatchState => String =
((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count).get)
((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count, projectId, Nil).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")}")
def projectOnWatchMessage(project: String): (Int, String, Seq[String]) => Option[String] = {
(count: Int, _: String, _: Seq[String]) =>
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")

View File

@ -625,7 +625,6 @@ object Defaults extends BuildCommon {
clean := Clean.taskIn(ThisScope).value,
consoleProject := consoleProjectTask.value,
watchTransitiveSources := watchTransitiveSourcesTask.value,
watchStartMessage := Watched.projectOnWatchMessage(thisProjectRef.value.project),
watch := watchSetting.value,
fileOutputs += target.value ** AllPassFilter,
transitiveGlobs := InputGraph.task.value,

View File

@ -114,11 +114,11 @@ object Keys {
val watchOnIteration = settingKey[Int => Watch.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 => () => Watch.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)
val watchStartMessage = settingKey[(Int, String, Seq[String]) => Option[String]]("The message to show when triggered execution waits for sources to change. The parameters are the current watch iteration count, the current project name and the tasks that are being run with each build.").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[StateTransform]("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)
val watchTriggeredMessage = settingKey[(Int, Event[FileAttributes], Seq[String]) => 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")

View File

@ -145,9 +145,12 @@ object Watch {
/**
* 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
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 {
@ -289,33 +292,42 @@ object Watch {
/**
* 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.
* 1) 'x' or 'X' will exit sbt
* 2) 'r' or 'R' will trigger a build
* 3) new line characters cancel the watch and return to the shell
*/
final val defaultInputParser: Parser[Action] = {
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
val exitParser: Parser[Action] = chars("xX") ^^^ new Run("exit")
val rebuildParser: Parser[Action] = chars("rR") ^^^ Trigger
val cancelParser: Parser[Action] = chars(legal = "\n\r") ^^^ new Run("iflast shell")
exitParser | rebuildParser | cancelParser
}
private[this] val reRun =
if (Util.isWindows) "" else ", 'r' to re-run the command or 's' to return to the shell"
private[sbt] def waitMessage(project: String): String =
s"Waiting for source changes$project... (press enter to interrupt$reRun)"
private[this] val options = {
val enter = "<enter>"
val newLine = if (Util.isWindows) enter else ""
val opts = Seq(
s"$enter: return to the shell",
s"'r$newLine': repeat the current command",
s"'x$newLine': exit sbt"
)
s"Options:\n${opts.mkString(" ", "\n ", "")}"
}
private def waitMessage(project: String, commands: Seq[String]): String = {
val plural = if (commands.size > 1) "s" else ""
val cmds = commands.mkString("; ")
s"Monitoring source files for updates...\n" +
s"Project: $project\nCommand$plural: $cmds\n$options"
}
/**
* 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")
val defaultStartWatch: (Int, String, Seq[String]) => Option[String] = {
(count: Int, project: String, commands: Seq[String]) =>
Some(s"$count. ${waitMessage(project, commands)}")
}.label("Watched.defaultStartWatch")
/**
* Default no-op callback.
@ -325,7 +337,8 @@ object Watch {
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")
onTerminationImpl("watch", ContinuousExecutePrefix)
.label("Watched.defaultTaskOnTermination")
/**
* Default handler to transform the state when the watch terminates. When the [[Watch.Action]]
@ -356,8 +369,15 @@ object Watch {
* `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")
final val defaultOnTriggerMessage: (Int, Event[FileAttributes], Seq[String]) => Option[String] =
((_: Int, e: Event[FileAttributes], commands: Seq[String]) => {
val msg = s"Build triggered by ${e.entry.typedPath.toPath}. " +
s"Running ${commands.mkString("'", "; ", "'")}."
Some(msg)
}).label("Watched.defaultOnTriggerMessage")
final val noTriggerMessage: (Int, Event[FileAttributes], Seq[String]) => Option[String] =
(_, _, _) => None
/**
* The minimum delay between file system polling when a `PollingWatchService` is used.

View File

@ -209,7 +209,7 @@ object Continuous extends DeprecatedContinuous {
}
private[sbt] def setup[R](state: State, command: String)(
f: (State, Seq[(String, State, () => Boolean)], Seq[String]) => R
f: (Seq[String], State, Seq[(String, State, () => 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
@ -273,7 +273,7 @@ object Continuous extends DeprecatedContinuous {
case Left(c) => (i :+ c, v)
}
}
f(s, valid, invalid)
f(commands, s, valid, invalid)
}
private[sbt] def runToTermination(
@ -283,14 +283,14 @@ object Continuous extends DeprecatedContinuous {
isCommand: Boolean
): State = Watch.withCharBufferedStdIn { in =>
val duped = new DupedInputStream(in)
setup(state.put(DupedSystemIn, duped), command) { (s, valid, invalid) =>
setup(state.put(DupedSystemIn, duped), command) { (commands, s, 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 configs = getAllConfigs(valid.map(v => v._1 -> v._2))
val callbacks = aggregate(configs, logger, in, s, currentCount, isCommand)
val callbacks = aggregate(configs, logger, in, s, currentCount, isCommand, commands)
val task = () => {
currentCount.getAndIncrement()
// abort as soon as one of the tasks fails
@ -312,8 +312,8 @@ object Continuous extends DeprecatedContinuous {
} 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")
val invalidCommands = invalid.mkString("'", "', '", "'")
logger.error(s"Terminating watch due to invalid command(s): $invalidCommands")
state.fail
}
})
@ -378,16 +378,18 @@ object Continuous extends DeprecatedContinuous {
inputStream: InputStream,
state: State,
count: AtomicInteger,
isCommand: Boolean
isCommand: Boolean,
commands: Seq[String]
)(
implicit extracted: Extracted
): Callbacks = {
val project = extracted.currentRef.project
val logger = setLevel(rawLogger, configs.map(_.watchSettings.logLevel).min, state)
val onEnter = () => configs.foreach(_.watchSettings.onEnter())
val onStart: () => Watch.Action = getOnStart(configs, logger, count)
val onStart: () => Watch.Action = getOnStart(project, commands, configs, rawLogger, count)
val nextInputEvent: () => Watch.Action = parseInputEvents(configs, state, inputStream, logger)
val (nextFileEvent, cleanupFileMonitor): (() => Watch.Action, () => Unit) =
getFileEvents(configs, logger, state, count)
val (nextFileEvent, cleanupFileMonitor): (() => Option[(Event, Watch.Action)], () => Unit) =
getFileEvents(configs, rawLogger, state, count, commands)
val nextEvent: () => Watch.Action =
combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger)
val onExit = () => {
@ -415,6 +417,8 @@ object Continuous extends DeprecatedContinuous {
}
private def getOnStart(
project: String,
commands: Seq[String],
configs: Seq[Config],
logger: Logger,
count: AtomicInteger
@ -426,8 +430,9 @@ object Continuous extends DeprecatedContinuous {
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 => Watch.defaultStartWatch(count.get()).foreach(logger.info(_))
case Some(Right(sm)) => sm(count.get(), project, commands).foreach(logger.info(_))
case None =>
Watch.defaultStartWatch(count.get(), project, commands).foreach(logger.info(_))
}
}
Watch.Ignore
@ -438,7 +443,8 @@ object Continuous extends DeprecatedContinuous {
{
val res = f.view.map(_()).min
// Print the default watch message if there are multiple tasks
if (configs.size > 1) Watch.defaultStartWatch(count.get()).foreach(logger.info(_))
if (configs.size > 1)
Watch.defaultStartWatch(count.get(), project, commands).foreach(logger.info(_))
res
}
}
@ -447,40 +453,14 @@ object Continuous extends DeprecatedContinuous {
logger: Logger,
state: State,
count: AtomicInteger,
)(implicit extracted: Extracted): (() => Watch.Action, () => Unit) = {
commands: Seq[String]
)(implicit extracted: Extracted): (() => Option[(Event, Watch.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 => Watch.Action = {
val f: Seq[Event => Unit] = configs.map { params =>
val ws = params.watchSettings
ws.onTrigger
.map(_.apply(params.arguments(logger)))
.getOrElse { event: Event =>
val globFilter =
(params.inputs() ++ params.triggers).toEntryFilter
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))
Watch.Trigger
}
val defaultTrigger = if (Util.isWindows) Watch.ifChanged(Watch.Trigger) else Watch.trigger
val onEvent: Event => (Event, Watch.Action) = {
val f = configs.map { params =>
@ -504,10 +484,7 @@ object Continuous extends DeprecatedContinuous {
).min
}
event: Event =>
event -> (oe(event) match {
case Watch.Trigger => onTrigger(event)
case a => a
})
event -> oe(event)
}
event: Event =>
f.view.map(_.apply(event)).minBy(_._2)
@ -568,13 +545,43 @@ object Continuous extends DeprecatedContinuous {
quarantinePeriod,
retentionPeriod
)
/*
* 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 => Unit = { event: Event =>
configs.foreach { params =>
params.watchSettings.onTrigger.foreach(ot => ot(params.arguments(logger))(event))
}
if (configs.size == 1) {
val config = configs.head
config.watchSettings.triggerMessage match {
case Left(tm) => logger.info(tm(config.watchState(count.get())))
case Right(tm) => tm(count.get(), event, commands).foreach(logger.info(_))
}
} else {
Watch.defaultOnTriggerMessage(count.get(), event, commands).foreach(logger.info(_))
}
}
(() => {
val actions = antiEntropyMonitor.poll(2.milliseconds).map(onEvent)
if (actions.exists(_._2 != Watch.Ignore)) {
val min = actions.minBy(_._2)
logger.debug(s"Received file event actions: ${actions.mkString(", ")}. Returning: $min")
min._2
} else Watch.Ignore
val builder = new StringBuilder
val min = actions.minBy {
case (e, a) =>
if (builder.nonEmpty) builder.append(", ")
val path = e.entry.typedPath.toPath.toString
builder.append(path)
builder.append(" -> ")
builder.append(a.toString)
a
}
logger.debug(s"Received file event actions: $builder. Returning: $min")
if (min._2 == Watch.Trigger) onTrigger(min._1)
Some(min)
} else None
}, () => monitor.close())
}
@ -653,21 +660,27 @@ object Continuous extends DeprecatedContinuous {
}
private def combineInputAndFileEvents(
nextInputEvent: () => Watch.Action,
nextFileEvent: () => Watch.Action,
nextInputAction: () => Watch.Action,
nextFileEvent: () => Option[(Event, Watch.Action)],
logger: Logger
): () => Watch.Action = () => {
val Seq(inputEvent: Watch.Action, fileEvent: Watch.Action) =
Seq(nextInputEvent, nextFileEvent).par.map(_.apply()).toIndexedSeq
val min: Watch.Action = Seq[Watch.Action](inputEvent, fileEvent).min
val (inputAction: Watch.Action, fileEvent: Option[(Event, Watch.Action)] @unchecked) =
Seq(nextInputAction, nextFileEvent).map(_.apply()).toIndexedSeq match {
case Seq(ia: Watch.Action, fe @ Some(_)) => (ia, fe)
case Seq(ia: Watch.Action, None) => (ia, None)
}
val min: Watch.Action = (fileEvent.map(_._2).toSeq :+ inputAction).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 != Watch.Ignore) logger.debug(inputMessage)
if (fileEvent != Watch.Ignore) logger.debug(fileMessage)
s"Received input event: $inputAction." +
(if (inputAction != min) s" Dropping in favor of file event: $min" else "")
if (inputAction != Watch.Ignore) logger.debug(inputMessage)
fileEvent
.collect {
case (event, action) if action != Watch.Ignore =>
s"Received file event $action for ${event.entry.typedPath.toPath}." +
(if (action != min) s" Dropping in favor of input event: $min" else "")
}
.foreach(logger.debug(_))
min
}
@ -696,8 +709,7 @@ object Continuous extends DeprecatedContinuous {
* @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)
val delegateLevel: Level.Value = state.get(Keys.logLevel.key).getOrElse(Level.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
@ -804,7 +816,7 @@ object Continuous extends DeprecatedContinuous {
lazy val default = key.get(Keys.watchStartMessage).getOrElse(Watch.defaultStartWatch)
key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default))
}
private def getTriggerMessage(key: ScopedKey[_])(implicit e: Extracted): TriggerMessage = Some {
private def getTriggerMessage(key: ScopedKey[_])(implicit e: Extracted): TriggerMessage = {
lazy val default =
key.get(Keys.watchTriggeredMessage).getOrElse(Watch.defaultOnTriggerMessage)
key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default))

View File

@ -11,8 +11,9 @@ 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 StartMessage =
Option[Either[WS => String, (Int, String, Seq[String]) => Option[String]]]
protected type TriggerMessage = Either[WS => String, (Int, Event, Seq[String]) => Option[String]]
protected type DeprecatedWatchState = WS
protected val deprecatedWatchingMessage = sbt.Keys.watchingMessage
protected val deprecatedTriggeredMessage = sbt.Keys.triggeredMessage

View File

@ -15,17 +15,17 @@ object Build {
assert(IO.read(file(stringFile)) == string)
}
lazy val foo = project.settings(
watchStartMessage := { (count: Int) => Some(s"FOO $count") },
watchStartMessage := { (count: Int, _, _) => Some(s"FOO $count") },
Compile / compile / watchTriggers += baseDirectory.value * "foo.txt",
Compile / compile / watchStartMessage := { (count: Int) =>
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") },
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

View File

@ -12,7 +12,8 @@ object Build {
def setStringValueImpl: Def.Initialize[Task[Unit]] = Def.task {
val i = (setStringValue / fileInputs).value
val (stringFile, string) = ("foo.txt", "bar")
IO.write(file(stringFile), string)
val absoluteFile = baseDirectory.value.toPath.resolve(stringFile).toFile
IO.write(absoluteFile, string)
}
def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask {
val Seq(stringFile, string) = Def.spaceDelimited().parsed

View File

@ -11,7 +11,7 @@ object Build {
val root = (project in file(".")).settings(
useSuperShell := false,
watchInputStream := inputStream,
watchStartMessage := { count =>
watchStartMessage := { (_, _, _) =>
Build.outputStream.write('\n'.toByte)
Build.outputStream.flush()
Some("default start message")
@ -24,14 +24,14 @@ object Build {
// 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[Watch.Action] = byeParser | helloParser
val alternativeStartMessage: Int => Option[String] = { _ =>
val alternativeStartMessage: (Int, String, Seq[String]) => Option[String] = { (_, _, _) =>
outputStream.write("xybyexyblahxyhelloxy".getBytes)
outputStream.flush()
Some("alternative start message")
}
val otherAlternativeStartMessage: Int => Option[String] = { _ =>
val otherAlternativeStartMessage: (Int, String, Seq[String]) => Option[String] = { (_, _, _) =>
outputStream.write("xyhellobyexyblahx".getBytes)
outputStream.flush()
Some("other alternative start message")
}
}
}

View File

@ -18,7 +18,7 @@ object Build {
setStringValue / watchTriggers += baseDirectory.value * "foo.txt",
setStringValue := setStringValueImpl.evaluated,
checkStringValue := checkStringValueImpl.evaluated,
watchStartMessage := { _ =>
watchStartMessage := { (_, _, _) =>
IO.touch(baseDirectory.value / "foo.txt", true)
Some("watching")
},

View File

@ -20,7 +20,7 @@ object Build {
setStringValue / watchTriggers += baseDirectory.value * "foo.txt",
setStringValue := setStringValueImpl.evaluated,
checkStringValue := checkStringValueImpl.evaluated,
watchStartMessage := { _ =>
watchStartMessage := { (_, _, _) =>
IO.touch(baseDirectory.value / "foo.txt", true)
Some("watching")
},
@ -31,4 +31,4 @@ object Build {
}
}
)
}
}