From 6da876cbe755a522845df317f24a4ea0582fd71f Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sat, 23 Mar 2019 11:20:53 -0700 Subject: [PATCH 01/11] Remove unneeded string interpolation --- main/src/main/scala/sbt/internal/TaskProgress.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/src/main/scala/sbt/internal/TaskProgress.scala b/main/src/main/scala/sbt/internal/TaskProgress.scala index d3120e2dd..39f9fa7ee 100644 --- a/main/src/main/scala/sbt/internal/TaskProgress.scala +++ b/main/src/main/scala/sbt/internal/TaskProgress.scala @@ -110,7 +110,7 @@ private[sbt] final class TaskProgress private[this] def deleteConsoleLines(n: Int): Unit = { (1 to n) foreach { _ => - console.println(s"$DeleteLine") + console.println(DeleteLine) } } } From 1df62b6933b49fde0f3119c9b2b7e4b49f637655 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 14 Dec 2018 14:19:36 -0800 Subject: [PATCH 02/11] Add fileInputs and watchTriggers task This adds two new tasks: fileInputs and watchTriggers, that will be used by sbt both to fetch files within a task as well as to create watch sources for continuous builds. In a subsequent commit, I will add a task for a command that will traverse the task dependency graph to find all of the input task dependency scopes. The idea is to make it possible to easily and accurately specify the watch sources for a task. For example, we'd be able to write something like: val foo = taskKey[Unit]("print text file contents") foo / fileInputs := baseDirectory.value ** "*.txt" foo := { (foo / fileInputs).value.all.foreach(f => println(s"$f:\n${new String(java.nio.Files.readAllBytes(f.toPath))}")) } If the user then runs `~foo`, then the task should trigger if the user modifies any file with the "txt" extension in the project directory. Today, the user would have to do something like: val fooInputs = settingKey[Seq[Source]]("the input files for foo") fooInputs := baseDirectory.value ** "*.txt" val foo = taskKey[Unit]("print text file contents") foo := { fooInputs.value.all.foreach(f => println(s"$f:\n${new String(java.nio.Files.readAllBytes(f.toPath))}")) } watchSources ++= fooInputs.value or even worse: val foo = taskKey[Unit]("print text file contents") foo := { (baseDirectory.value ** "*.txt").all.foreach(f => println(s"$f:\n${new String(java.nio.Files.readAllBytes(f.toPath))}")) } watchSources ++= baseDirectory.value ** "*.txt" which makes it possible for the watchSources and the task sources to get out of sync. For consistency, I also renamed the `outputs` key `fileOutputs`. --- main/src/main/scala/sbt/Defaults.scala | 22 ++++--- main/src/main/scala/sbt/Keys.scala | 4 +- main/src/main/scala/sbt/internal/Clean.scala | 3 +- sbt/src/main/scala/sbt/Import.scala | 1 + .../glob-dsl/base/subdir/nested-subdir/Bar.md | 0 .../base/subdir/nested-subdir/Foo.txt | 0 sbt/src/sbt-test/tests/glob-dsl/build.sbt | 57 +++++++++++++++++++ sbt/src/sbt-test/tests/glob-dsl/test | 7 +++ sbt/src/sbt-test/tests/inputs/build.sbt | 42 ++++++++++++++ .../tests/inputs/src/main/scala/bar/Bar.scala | 3 + .../tests/inputs/src/main/scala/foo/Foo.scala | 3 + sbt/src/sbt-test/tests/inputs/test | 3 + 12 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Bar.md create mode 100644 sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Foo.txt create mode 100644 sbt/src/sbt-test/tests/glob-dsl/build.sbt create mode 100644 sbt/src/sbt-test/tests/glob-dsl/test create mode 100644 sbt/src/sbt-test/tests/inputs/build.sbt create mode 100644 sbt/src/sbt-test/tests/inputs/src/main/scala/bar/Bar.scala create mode 100644 sbt/src/sbt-test/tests/inputs/src/main/scala/foo/Foo.scala create mode 100644 sbt/src/sbt-test/tests/inputs/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index d9a9d4484..24659d662 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -143,6 +143,7 @@ object Defaults extends BuildCommon { defaultTestTasks(test) ++ defaultTestTasks(testOnly) ++ defaultTestTasks(testQuick) ++ Seq( excludeFilter :== HiddenFileFilter, classLoaderCache := ClassLoaderCache(4), + fileInputs :== Nil, ) ++ TaskRepository .proxy(GlobalScope / classLoaderCache, ClassLoaderCache(4)) ++ globalIvyCore ++ globalJvmCore ) ++ globalSbtCore @@ -381,12 +382,14 @@ object Defaults extends BuildCommon { crossPaths.value ) }, - unmanagedSources := { + unmanagedSources / fileInputs := { val filter = (includeFilter in unmanagedSources).value -- (excludeFilter in unmanagedSources).value val baseSources = if (sourcesInBase.value) baseDirectory.value * filter :: Nil else Nil - (unmanagedSourceDirectories.value.map(_ ** filter) ++ baseSources).all.map(Stamped.file) + 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 @@ -407,7 +410,7 @@ object Defaults extends BuildCommon { managedSourceDirectories := Seq(sourceManaged.value), managedSources := generate(sourceGenerators).value, sourceGenerators :== Nil, - sourceGenerators / outputs := Seq(managedDirectory.value ** AllPassFilter), + sourceGenerators / fileOutputs := Seq(managedDirectory.value ** AllPassFilter), sourceDirectories := Classpaths .concatSettings(unmanagedSourceDirectories, managedSourceDirectories) .value, @@ -421,11 +424,12 @@ object Defaults extends BuildCommon { resourceDirectories := Classpaths .concatSettings(unmanagedResourceDirectories, managedResourceDirectories) .value, - unmanagedResources := { + unmanagedResources / fileInputs := { val filter = (includeFilter in unmanagedResources).value -- (excludeFilter in unmanagedResources).value - unmanagedResourceDirectories.value.map(_ ** filter).all.map(Stamped.file) + 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 @@ -573,10 +577,10 @@ object Defaults extends BuildCommon { lazy val configTasks: Seq[Setting[_]] = docTaskSettings(doc) ++ inTask(compile)( compileInputsSettings :+ (clean := Clean.taskIn(ThisScope).value) ) ++ configGlobal ++ defaultCompileSettings ++ compileAnalysisSettings ++ Seq( - outputs := Seq( + fileOutputs := Seq( compileAnalysisFileTask.value.toGlob, classDirectory.value ** "*.class" - ) ++ (sourceGenerators / outputs).value, + ) ++ (sourceGenerators / fileOutputs).value, compile := compileTask.value, clean := Clean.taskIn(ThisScope).value, manipulateBytecode := compileIncremental.value, @@ -663,7 +667,7 @@ object Defaults extends BuildCommon { }, watchStartMessage := Watched.projectOnWatchMessage(thisProjectRef.value.project), watch := watchSetting.value, - outputs += target.value ** AllPassFilter, + fileOutputs += target.value ** AllPassFilter, ) def generate(generators: SettingKey[Seq[Task[Seq[File]]]]): Initialize[Task[Seq[File]]] = @@ -2039,7 +2043,7 @@ object Classpaths { transitiveClassifiers :== Seq(SourceClassifier, DocClassifier), sourceArtifactTypes :== Artifact.DefaultSourceTypes.toVector, docArtifactTypes :== Artifact.DefaultDocTypes.toVector, - outputs :== Nil, + fileOutputs :== Nil, sbtDependency := { val app = appConfiguration.value val id = app.provider.id diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index ade13247d..f8de41394 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -133,6 +133,8 @@ object Keys { val managedSources = taskKey[Seq[File]]("Sources generated by the build.").withRank(BTask) val sources = taskKey[Seq[File]]("All sources, both managed and unmanaged.").withRank(BTask) val sourcesInBase = settingKey[Boolean]("If true, sources from the project's base directory are included as main sources.") + val fileInputs = settingKey[Seq[Glob]]("The file globs that are used by a task. This setting will generally be scoped per task. It will also be used to determine the sources to watch during continuous execution.") + val watchTriggers = settingKey[Seq[Glob]]("Describes files that should trigger a new continuous build.") // Filters val includeFilter = settingKey[FileFilter]("Filter for including sources and resources files from default directories.").withRank(CSetting) @@ -157,7 +159,7 @@ object Keys { val cleanKeepGlobs = settingKey[Seq[Glob]]("Globs to keep during a clean. Must be direct children of target.").withRank(CSetting) val crossPaths = settingKey[Boolean]("If true, enables cross paths, which distinguish input and output directories for cross-building.").withRank(ASetting) val taskTemporaryDirectory = settingKey[File]("Directory used for temporary files for tasks that is deleted after each task execution.").withRank(DSetting) - val outputs = taskKey[Seq[Glob]]("Describes the output files of a task") + val fileOutputs = taskKey[Seq[Glob]]("Describes the output files of a task") // Generators val sourceGenerators = settingKey[Seq[Task[Seq[File]]]]("List of tasks that generate sources.").withRank(CSetting) diff --git a/main/src/main/scala/sbt/internal/Clean.scala b/main/src/main/scala/sbt/internal/Clean.scala index 83629d505..0173fe9d8 100644 --- a/main/src/main/scala/sbt/internal/Clean.scala +++ b/main/src/main/scala/sbt/internal/Clean.scala @@ -61,6 +61,7 @@ object Clean { case f => f.toGlob } ++ cleanKeepGlobs.value val excludeFilter: TypedPath => Boolean = excludes.toTypedPathFilter + // Don't use a regular logger because the logger actually writes to the target directory. val debug = (logLevel in scope).?.value.orElse(state.value.get(logLevel.key)) match { case Some(Level.Debug) => (string: String) => @@ -71,7 +72,7 @@ object Clean { } val delete = tryDelete(debug) cleanFiles.value.sorted.reverseIterator.foreach(delete) - (outputs in scope).value.foreach { g => + (fileOutputs in scope).value.foreach { g => val filter: TypedPath => Boolean = { val globFilter = g.toTypedPathFilter tp => diff --git a/sbt/src/main/scala/sbt/Import.scala b/sbt/src/main/scala/sbt/Import.scala index e790c863d..03146de4d 100644 --- a/sbt/src/main/scala/sbt/Import.scala +++ b/sbt/src/main/scala/sbt/Import.scala @@ -42,6 +42,7 @@ trait Import { val ExistsFileFilter = sbt.io.ExistsFileFilter val FileFilter = sbt.io.FileFilter type FileFilter = sbt.io.FileFilter + type Glob = sbt.io.Glob val GlobFilter = sbt.io.GlobFilter val Hash = sbt.io.Hash val HiddenFileFilter = sbt.io.HiddenFileFilter diff --git a/sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Bar.md b/sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Bar.md new file mode 100644 index 000000000..e69de29bb diff --git a/sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Foo.txt b/sbt/src/sbt-test/tests/glob-dsl/base/subdir/nested-subdir/Foo.txt new file mode 100644 index 000000000..e69de29bb diff --git a/sbt/src/sbt-test/tests/glob-dsl/build.sbt b/sbt/src/sbt-test/tests/glob-dsl/build.sbt new file mode 100644 index 000000000..e94925bfb --- /dev/null +++ b/sbt/src/sbt-test/tests/glob-dsl/build.sbt @@ -0,0 +1,57 @@ +// The project contains two files: { Foo.txt, Bar.md } in the subdirector base/subdir/nested-subdir + +// Check that we can correctly extract Foo.txt with a recursive source +val foo = taskKey[Seq[File]]("Retrieve Foo.txt") + +foo / inputs += baseDirectory.value ** "*.txt" + +foo := (foo / inputs).value.all + +val checkFoo = taskKey[Unit]("Check that the Foo.txt file is retrieved") + +checkFoo := assert(foo.value == Seq(baseDirectory.value / "base/subdir/nested-subdir/Foo.txt")) + +// Check that we can correctly extract Bar.md with a non-recursive source +val bar = taskKey[Seq[File]]("Retrieve Bar.md") + +bar / inputs += baseDirectory.value / "base/subdir/nested-subdir" * "*.md" + +bar := (bar / inputs).value.all + +val checkBar = taskKey[Unit]("Check that the Bar.md file is retrieved") + +checkBar := assert(bar.value == Seq(baseDirectory.value / "base/subdir/nested-subdir/Bar.md")) + +// Check that we can correctly extract Bar.md and Foo.md with a non-recursive source +val all = taskKey[Seq[File]]("Retrieve all files") + +all / inputs += baseDirectory.value / "base" / "subdir" / "nested-subdir" * AllPassFilter + +val checkAll = taskKey[Unit]("Check that the Bar.md file is retrieved") + +checkAll := { + import sbt.dsl.LinterLevel.Ignore + val expected = Set("Foo.txt", "Bar.md").map(baseDirectory.value / "base/subdir/nested-subdir" / _) + assert((all / inputs).value.all.toSet == expected) +} + +val set = taskKey[Seq[File]]("Specify redundant sources in a set") + +set / inputs ++= Seq( + baseDirectory.value / "base" ** -DirectoryFilter, + baseDirectory.value / "base" / "subdir" / "nested-subdir" * -DirectoryFilter +) + +val checkSet = taskKey[Unit]("Verify that redundant sources are handled") + +checkSet := { + val redundant = (set / inputs).value.all + assert(redundant.size == 4) // It should get Foo.txt and Bar.md twice + + val deduped = (set / inputs).value.toSet[Glob].all + val expected = Seq("Bar.md", "Foo.txt").map(baseDirectory.value / "base/subdir/nested-subdir" / _) + assert(deduped.sorted == expected) + + val altDeduped = (set / inputs).value.unique + assert(altDeduped.sorted == expected) +} diff --git a/sbt/src/sbt-test/tests/glob-dsl/test b/sbt/src/sbt-test/tests/glob-dsl/test new file mode 100644 index 000000000..29af3c03d --- /dev/null +++ b/sbt/src/sbt-test/tests/glob-dsl/test @@ -0,0 +1,7 @@ +> checkFoo + +> checkBar + +> checkAll + +> checkSet \ No newline at end of file diff --git a/sbt/src/sbt-test/tests/inputs/build.sbt b/sbt/src/sbt-test/tests/inputs/build.sbt new file mode 100644 index 000000000..c242467e2 --- /dev/null +++ b/sbt/src/sbt-test/tests/inputs/build.sbt @@ -0,0 +1,42 @@ +import sbt.internal.FileTree +import sbt.io.FileTreeDataView +import xsbti.compile.analysis.Stamp + +val allInputs = taskKey[Seq[File]]("") +val allInputsExplicit = taskKey[Seq[File]]("") + +val checkInputs = inputKey[Unit]("") +val checkInputsExplicit = inputKey[Unit]("") + +allInputs := (Compile / unmanagedSources / inputs).value.all + +checkInputs := { + val res = allInputs.value + val scala = (Compile / scalaSource).value + val expected = Def.spaceDelimited("").parsed.map(scala / _).toSet + assert(res.toSet == expected) +} + +// In this test we override the FileTree.Repository used by the all method. +allInputsExplicit := { + val files = scala.collection.mutable.Set.empty[File] + val underlying = implicitly[FileTree.Repository] + val repo = new FileTree.Repository { + override def get(glob: Glob): Seq[FileTreeDataView.Entry[Stamp]] = { + val res = underlying.get(glob) + files ++= res.map(_.typedPath.toPath.toFile) + res + } + override def close(): Unit = {} + } + val include = (Compile / unmanagedSources / includeFilter).value + val _ = (Compile / unmanagedSources / inputs).value.all(repo).toSet + files.filter(include.accept).toSeq +} + +checkInputsExplicit := { + val res = allInputsExplicit.value + val scala = (Compile / scalaSource).value + val expected = Def.spaceDelimited("").parsed.map(scala / _).toSet + assert(res.toSet == expected) +} diff --git a/sbt/src/sbt-test/tests/inputs/src/main/scala/bar/Bar.scala b/sbt/src/sbt-test/tests/inputs/src/main/scala/bar/Bar.scala new file mode 100644 index 000000000..f51e51890 --- /dev/null +++ b/sbt/src/sbt-test/tests/inputs/src/main/scala/bar/Bar.scala @@ -0,0 +1,3 @@ +package bar + +object Bar \ No newline at end of file diff --git a/sbt/src/sbt-test/tests/inputs/src/main/scala/foo/Foo.scala b/sbt/src/sbt-test/tests/inputs/src/main/scala/foo/Foo.scala new file mode 100644 index 000000000..5c464310a --- /dev/null +++ b/sbt/src/sbt-test/tests/inputs/src/main/scala/foo/Foo.scala @@ -0,0 +1,3 @@ +package foo + +object Foo diff --git a/sbt/src/sbt-test/tests/inputs/test b/sbt/src/sbt-test/tests/inputs/test new file mode 100644 index 000000000..a8082a1f3 --- /dev/null +++ b/sbt/src/sbt-test/tests/inputs/test @@ -0,0 +1,3 @@ +> checkInputs foo/Foo.scala bar/Bar.scala + +> checkInputsExplicit foo/Foo.scala bar/Bar.scala From e910a13d7f80388018957e40a4dba8d4e82c59ea Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Wed, 9 Jan 2019 17:27:50 -0800 Subject: [PATCH 03/11] Add internalDependencyConfigurations setting In order to walk the full dependency graph of a task, we need to know the internal class path dependency configurations. Suppose that we have projects a and b where b depends on *->compile in a. If we want to find all of the inputs for b, then if we find that there is a dependency on b/Compile/internalDependencyClasspath, then we must add a / Compile / internalDependencyClasspath to the list of dependencies for the task. I copied the setup of one of the other scripted tests that was introduced to test the track-internal-dependencies feature to write a basic scripted test for this new key and implementation. --- main/src/main/scala/sbt/Defaults.scala | 1 + main/src/main/scala/sbt/Keys.scala | 1 + .../sbt/internal/InternalDependencies.scala | 32 +++++++++++ .../a/A.scala | 3 ++ .../b/B.scala | 5 ++ .../build.sbt | 53 +++++++++++++++++++ .../c/C.scala | 3 ++ .../d/D.scala | 3 ++ .../internal-dependency-configurations/test | 7 +++ 9 files changed, 108 insertions(+) create mode 100644 main/src/main/scala/sbt/internal/InternalDependencies.scala create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/a/A.scala create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/b/B.scala create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/build.sbt create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/c/C.scala create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/d/D.scala create mode 100644 sbt/src/sbt-test/project/internal-dependency-configurations/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 24659d662..d7044ecfd 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -583,6 +583,7 @@ object Defaults extends BuildCommon { ) ++ (sourceGenerators / fileOutputs).value, compile := compileTask.value, clean := Clean.taskIn(ThisScope).value, + internalDependencyConfigurations := InternalDependencies.configurations.value, manipulateBytecode := compileIncremental.value, compileIncremental := (compileIncrementalTask tag (Tags.Compile, Tags.CPU)).value, printWarnings := printWarningsTask.value, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index f8de41394..1b276dea8 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -334,6 +334,7 @@ object Keys { val internalDependencyAsJars = taskKey[Classpath]("The internal (inter-project) classpath as JARs.") val dependencyClasspathAsJars = taskKey[Classpath]("The classpath consisting of internal and external, managed and unmanaged dependencies, all as JARs.") val fullClasspathAsJars = taskKey[Classpath]("The exported classpath, consisting of build products and unmanaged and managed, internal and external dependencies, all as JARs.") + val internalDependencyConfigurations = settingKey[Seq[(ProjectRef, Set[String])]]("The project configurations that this configuration depends on") val internalConfigurationMap = settingKey[Configuration => Configuration]("Maps configurations to the actual configuration used to define the classpath.").withRank(CSetting) val classpathConfiguration = taskKey[Configuration]("The configuration used to define the classpath.").withRank(CTask) diff --git a/main/src/main/scala/sbt/internal/InternalDependencies.scala b/main/src/main/scala/sbt/internal/InternalDependencies.scala new file mode 100644 index 000000000..9dc447831 --- /dev/null +++ b/main/src/main/scala/sbt/internal/InternalDependencies.scala @@ -0,0 +1,32 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import sbt.Keys._ + +private[sbt] object InternalDependencies { + def configurations: Def.Initialize[Seq[(ProjectRef, Set[String])]] = Def.setting { + val allConfigs = Classpaths.allConfigs(configuration.value).map(_.name).toSet + val ref = thisProjectRef.value + val applicableConfigs = allConfigs + "*" + ((ref -> allConfigs) +: buildDependencies.value.classpath + .get(ref) + .toSeq + .flatMap(_.flatMap { + case ResolvedClasspathDependency(p, rawConfigs) => + val configs = rawConfigs.getOrElse("*->compile").split(";").flatMap { config => + config.split("->") match { + case Array(n, c) if applicableConfigs.contains(n) => Some(c) + case _ => None + } + } + if (configs.isEmpty) None else Some(p -> configs.toSet) + })).distinct + } +} diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/a/A.scala b/sbt/src/sbt-test/project/internal-dependency-configurations/a/A.scala new file mode 100644 index 000000000..3f2aea979 --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/a/A.scala @@ -0,0 +1,3 @@ +package a + +object A {} diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/b/B.scala b/sbt/src/sbt-test/project/internal-dependency-configurations/b/B.scala new file mode 100644 index 000000000..184707bb1 --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/b/B.scala @@ -0,0 +1,5 @@ +package b + +object B { + println(a.A.toString) +} diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/build.sbt b/sbt/src/sbt-test/project/internal-dependency-configurations/build.sbt new file mode 100644 index 000000000..06a8556dd --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/build.sbt @@ -0,0 +1,53 @@ +lazy val root = (project in file(".")). + aggregate(a, b, c, d). + settings( + inThisBuild(Seq( + scalaVersion := "2.11.7", + trackInternalDependencies := TrackLevel.NoTracking + )) + ) + +lazy val a = project in file("a") + +lazy val b = (project in file("b")).dependsOn(a % "*->compile") + +lazy val c = (project in file("c")).settings(exportToInternal := TrackLevel.NoTracking) + +lazy val d = (project in file("d")) + .dependsOn(c % "test->test;compile->compile") + .settings(trackInternalDependencies := TrackLevel.TrackIfMissing) + +def getConfigs(key: SettingKey[Seq[(ProjectRef, Set[String])]]): + Def.Initialize[Map[String, Set[String]]] = + Def.setting(key.value.map { case (p, c) => p.project -> c }.toMap) +val checkA = taskKey[Unit]("Verify that project a's internal dependencies are as expected") +checkA := { + val compileDeps = getConfigs(a / Compile / internalDependencyConfigurations).value + assert(compileDeps == Map("a" -> Set("compile"))) + val testDeps = getConfigs(a / Test / internalDependencyConfigurations).value + assert(testDeps == Map("a" -> Set("compile", "runtime", "test"))) +} + +val checkB = taskKey[Unit]("Verify that project b's internal dependencies are as expected") +checkB := { + val compileDeps = getConfigs(b / Compile / internalDependencyConfigurations).value + assert(compileDeps == Map("b" -> Set("compile"), "a" -> Set("compile"))) + val testDeps = getConfigs(b / Test / internalDependencyConfigurations).value + assert(testDeps == Map("b" -> Set("compile", "runtime", "test"), "a" -> Set("compile"))) +} + +val checkC = taskKey[Unit]("Verify that project c's internal dependencies are as expected") +checkC := { + val compileDeps = getConfigs(c / Compile / internalDependencyConfigurations).value + assert(compileDeps == Map("c" -> Set("compile"))) + val testDeps = getConfigs(c / Test / internalDependencyConfigurations).value + assert(testDeps == Map("c" -> Set("compile", "runtime", "test"))) +} + +val checkD = taskKey[Unit]("Verify that project d's internal dependencies are as expected") +checkD := { + val compileDeps = getConfigs(d / Compile / internalDependencyConfigurations).value + assert(compileDeps == Map("d" -> Set("compile"), "c" -> Set("compile"))) + val testDeps = getConfigs(d / Test / internalDependencyConfigurations).value + assert(testDeps == Map("d" -> Set("compile", "runtime", "test"), "c" -> Set("compile", "test"))) +} diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/c/C.scala b/sbt/src/sbt-test/project/internal-dependency-configurations/c/C.scala new file mode 100644 index 000000000..abadf1e84 --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/c/C.scala @@ -0,0 +1,3 @@ +package c + +object C {} diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/d/D.scala b/sbt/src/sbt-test/project/internal-dependency-configurations/d/D.scala new file mode 100644 index 000000000..9a2cd3377 --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/d/D.scala @@ -0,0 +1,3 @@ +package d + +object D { println(c.C.toString) } diff --git a/sbt/src/sbt-test/project/internal-dependency-configurations/test b/sbt/src/sbt-test/project/internal-dependency-configurations/test new file mode 100644 index 000000000..618b58159 --- /dev/null +++ b/sbt/src/sbt-test/project/internal-dependency-configurations/test @@ -0,0 +1,7 @@ +> checkA + +> checkB + +> checkC + +> checkD From ed06e18fab7890c9c509223a7b1925a7a8f9bc1b Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 20 Dec 2018 10:03:40 -0800 Subject: [PATCH 04/11] Add InputGraph This commit adds functionality to traverse the settings graph to find all of the Inputs settings values for the transitive dependencies of the task. We can use this to build up the list of globs that we must watch when we are in a continuous build. Because the Inputs key is a setting, it is actually quite fast to fetch all the values once the compiled map is generated (O(2ms) in the scripted tests, though I did find that it took O(20ms) to generate the compiled map). One complicating factor is that dynamic tasks do not track any of their dynamic dependencies. To work around this, I added the transitiveDependencies key. If one does something like: foo := { val _ = bar / transitiveDependencies val _ = baz / transitiveDependencies if (System.getProperty("some.prop", "false") == "true") Def.task(bar.value) else Def.task(baz.value) } then (foo / transitiveDependencies).value will return all of the inputs and triggers for bar and baz as well as for foo. To implement transitiveDependencies, I did something fairly similar to streams where if the setting is referenced, I add a default implementation. If the default implementation is not present, I fall back on trying to extract the key from the commandLine. This allows the user to run `show bar / transitiveDependencies` from the command line even if `bar / transitiveDependencies` is not defined in the project. It might be possible to coax transitiveDependencies into a setting, but then it would have to be eagerly evaluated at project definition time which might increase start up time too much. Alternatively, we could just define this task for every task in the build, but I'm not sure how expensive that would be. At any rate, it should be straightforward to make that change without breaking binary compatibility if need be. This is something to possibly explore before the 1.3 release if there is any spare time (unlikely). --- main-settings/src/main/scala/sbt/Def.scala | 21 +- main/src/main/scala/sbt/Defaults.scala | 23 +- main/src/main/scala/sbt/EvaluateTask.scala | 71 +++--- main/src/main/scala/sbt/Keys.scala | 2 + .../main/scala/sbt/internal/FileTree.scala | 2 +- .../main/scala/sbt/internal/InputGraph.scala | 216 ++++++++++++++++++ main/src/main/scala/sbt/internal/Load.scala | 4 +- sbt/src/sbt-test/tests/glob-dsl/build.sbt | 20 +- sbt/src/sbt-test/tests/inputs/build.sbt | 12 +- .../tests/transitive-inputs/build.sbt | 46 ++++ .../src/main/scala/bar/Bar.scala | 3 + .../src/main/scala/foo/Foo.scala | 3 + sbt/src/sbt-test/tests/transitive-inputs/test | 5 + 13 files changed, 366 insertions(+), 62 deletions(-) create mode 100644 main/src/main/scala/sbt/internal/InputGraph.scala create mode 100644 sbt/src/sbt-test/tests/transitive-inputs/build.sbt create mode 100644 sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala create mode 100644 sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala create mode 100644 sbt/src/sbt-test/tests/transitive-inputs/test diff --git a/main-settings/src/main/scala/sbt/Def.scala b/main-settings/src/main/scala/sbt/Def.scala index 2baf38e33..1451b5ffc 100644 --- a/main-settings/src/main/scala/sbt/Def.scala +++ b/main-settings/src/main/scala/sbt/Def.scala @@ -7,15 +7,15 @@ package sbt -import sbt.internal.util.Types.const -import sbt.internal.util.{ AttributeKey, Attributed, ConsoleAppender, Init } -import sbt.util.Show -import sbt.internal.util.complete.Parser import java.io.File import java.net.URI -import Scope.{ GlobalScope, ThisScope } -import KeyRanks.{ DTask, Invisible } +import sbt.KeyRanks.{ DTask, Invisible } +import sbt.Scope.{ GlobalScope, ThisScope } +import sbt.internal.util.Types.const +import sbt.internal.util.complete.Parser +import sbt.internal.util.{ AttributeKey, Attributed, ConsoleAppender, Init } +import sbt.util.Show /** A concrete settings system that uses `sbt.Scope` for the scope type. */ object Def extends Init[Scope] with TaskMacroExtra { @@ -206,15 +206,16 @@ object Def extends Init[Scope] with TaskMacroExtra { def toISParser[T](p: Initialize[Parser[T]]): Initialize[State => Parser[T]] = p(toSParser) def toIParser[T](p: Initialize[InputTask[T]]): Initialize[State => Parser[Task[T]]] = p(_.parser) - import language.experimental.macros + import std.SettingMacro.{ settingDynMacroImpl, settingMacroImpl } import std.TaskMacro.{ - inputTaskMacroImpl, inputTaskDynMacroImpl, + inputTaskMacroImpl, taskDynMacroImpl, taskMacroImpl } - import std.SettingMacro.{ settingDynMacroImpl, settingMacroImpl } - import std.{ InputEvaluated, MacroPrevious, MacroValue, MacroTaskValue, ParserInput } + import std._ + + import language.experimental.macros def task[T](t: T): Def.Initialize[Task[T]] = macro taskMacroImpl[T] def taskDyn[T](t: Def.Initialize[Task[T]]): Def.Initialize[Task[T]] = macro taskDynMacroImpl[T] diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index d7044ecfd..4b0a9e030 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -43,6 +43,7 @@ import sbt.internal.server.{ ServerHandler } import sbt.internal.testing.TestLogger +import sbt.internal.TransitiveGlobs._ import sbt.internal.util.Attributed.data import sbt.internal.util.Types._ import sbt.internal.util._ @@ -144,6 +145,7 @@ object Defaults extends BuildCommon { excludeFilter :== HiddenFileFilter, classLoaderCache := ClassLoaderCache(4), fileInputs :== Nil, + watchTriggers :== Nil, ) ++ TaskRepository .proxy(GlobalScope / classLoaderCache, ClassLoaderCache(4)) ++ globalIvyCore ++ globalJvmCore ) ++ globalSbtCore @@ -669,6 +671,9 @@ object Defaults extends BuildCommon { watchStartMessage := Watched.projectOnWatchMessage(thisProjectRef.value.project), watch := watchSetting.value, fileOutputs += target.value ** AllPassFilter, + transitiveGlobs := InputGraph.task.value, + transitiveInputs := InputGraph.inputsTask.value, + transitiveTriggers := InputGraph.triggersTask.value, ) def generate(generators: SettingKey[Seq[Task[Seq[File]]]]): Initialize[Task[Seq[File]]] = @@ -2058,7 +2063,12 @@ object Classpaths { val base = ModuleID(id.groupID, id.name, sbtVersion.value).withCrossVersion(cross) CrossVersion(scalaVersion, binVersion)(base).withCrossVersion(Disabled()) }, - shellPrompt := shellPromptFromState + shellPrompt := shellPromptFromState, + dynamicDependency := { (): Unit }, + transitiveClasspathDependency := { (): Unit }, + transitiveGlobs := { (Nil: Seq[Glob], Nil: Seq[Glob]) }, + transitiveInputs := Nil, + transitiveTriggers := Nil, ) ) @@ -2897,6 +2907,7 @@ object Classpaths { } private[sbt] def trackedExportedProducts(track: TrackLevel): Initialize[Task[Classpath]] = Def.task { + val _ = (packageBin / dynamicDependency).value val art = (artifact in packageBin).value val module = projectID.value val config = configuration.value @@ -2909,6 +2920,7 @@ object Classpaths { } private[sbt] def trackedExportedJarProducts(track: TrackLevel): Initialize[Task[Classpath]] = Def.task { + val _ = (packageBin / dynamicDependency).value val art = (artifact in packageBin).value val module = projectID.value val config = configuration.value @@ -2923,6 +2935,7 @@ object Classpaths { track: TrackLevel ): Initialize[Task[Seq[(File, CompileAnalysis)]]] = Def.taskDyn { + val _ = (packageBin / dynamicDependency).value val useJars = exportJars.value if (useJars) trackedJarProductsImplTask(track) else trackedNonJarProductsImplTask(track) @@ -2993,6 +3006,14 @@ object Classpaths { def internalDependencies: Initialize[Task[Classpath]] = Def.taskDyn { + val _ = ( + (exportedProductsNoTracking / transitiveClasspathDependency).value, + (exportedProductsIfMissing / transitiveClasspathDependency).value, + (exportedProducts / transitiveClasspathDependency).value, + (exportedProductJarsNoTracking / transitiveClasspathDependency).value, + (exportedProductJarsIfMissing / transitiveClasspathDependency).value, + (exportedProductJars / transitiveClasspathDependency).value + ) internalDependenciesImplTask( thisProjectRef.value, classpathConfiguration.value, diff --git a/main/src/main/scala/sbt/EvaluateTask.scala b/main/src/main/scala/sbt/EvaluateTask.scala index ddbcce356..ada880ef8 100644 --- a/main/src/main/scala/sbt/EvaluateTask.scala +++ b/main/src/main/scala/sbt/EvaluateTask.scala @@ -7,38 +7,23 @@ package sbt -import sbt.internal.{ - Load, - BuildStructure, - TaskTimings, - TaskName, - GCUtil, - TaskProgress, - TaskTraceEvent -} -import sbt.internal.util.{ Attributed, ConsoleAppender, ErrorHandling, HList, RMap, Signals, Types } -import sbt.util.{ Logger, Show } -import sbt.librarymanagement.{ Resolver, UpdateReport } - -import scala.concurrent.duration.Duration import java.io.File import java.util.concurrent.atomic.AtomicReference -import Def.{ dummyState, ScopedKey, Setting } -import Keys.{ - Streams, - TaskStreams, - dummyRoots, - executionRoots, - pluginData, - streams, - streamsManager, - transformState -} -import Project.richInitializeTask -import Scope.Global + +import sbt.Def.{ ScopedKey, Setting, dummyState } +import sbt.Keys.{ TaskProgress => _, name => _, _ } +import sbt.Project.richInitializeTask +import sbt.Scope.Global +import sbt.internal.TaskName._ +import sbt.internal.TransitiveGlobs._ +import sbt.internal.util._ +import sbt.internal.{ BuildStructure, GCUtil, Load, TaskProgress, TaskTimings, TaskTraceEvent, _ } +import sbt.librarymanagement.{ Resolver, UpdateReport } +import sbt.std.Transform.DummyTaskMap +import sbt.util.{ Logger, Show } + import scala.Console.RED -import std.Transform.DummyTaskMap -import TaskName._ +import scala.concurrent.duration.Duration /** * An API that allows you to cancel executing tasks upon some signal. @@ -166,8 +151,8 @@ object PluginData { } object EvaluateTask { - import std.Transform import Keys.state + import std.Transform lazy private val sharedProgress = new TaskTimings(reportOnShutdown = true) def taskTimingProgress: Option[ExecuteProgress[Task]] = @@ -565,7 +550,7 @@ object EvaluateTask { // if the return type Seq[Setting[_]] is not explicitly given, scalac hangs val injectStreams: ScopedKey[_] => Seq[Setting[_]] = scoped => - if (scoped.key == streams.key) + if (scoped.key == streams.key) { Seq(streams in scoped.scope := { (streamsManager map { mgr => val stream = mgr(scoped) @@ -573,6 +558,26 @@ object EvaluateTask { stream }).value }) - else - Nil + } else if (scoped.key == transitiveInputs.key) { + scoped.scope.task.toOption.toSeq.map { key => + val updatedKey = ScopedKey(scoped.scope.copy(task = Zero), key) + transitiveInputs in scoped.scope := InputGraph.inputsTask(updatedKey).value + } + } else if (scoped.key == transitiveTriggers.key) { + scoped.scope.task.toOption.toSeq.map { key => + val updatedKey = ScopedKey(scoped.scope.copy(task = Zero), key) + transitiveTriggers in scoped.scope := InputGraph.triggersTask(updatedKey).value + } + } else if (scoped.key == transitiveGlobs.key) { + scoped.scope.task.toOption.toSeq.map { key => + val updatedKey = ScopedKey(scoped.scope.copy(task = Zero), key) + transitiveGlobs in scoped.scope := InputGraph.task(updatedKey).value + } + } else if (scoped.key == dynamicDependency.key) { + (dynamicDependency in scoped.scope := { () }) :: Nil + } else if (scoped.key == transitiveClasspathDependency.key) { + (transitiveClasspathDependency in scoped.scope := { () }) :: Nil + } else { + Nil + } } diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 1b276dea8..05e1d1d8a 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -481,6 +481,8 @@ object Keys { "Provides a view into the file system that may or may not cache the tree in memory", 1000 ) + private[sbt] val dynamicDependency = settingKey[Unit]("Leaves a breadcrumb that the scoped task is evaluated inside of a dynamic task") + private[sbt] val transitiveClasspathDependency = settingKey[Unit]("Leaves a breadcrumb that the scoped task has transitive classpath dependencies") val stateStreams = AttributeKey[Streams]("stateStreams", "Streams manager, which provides streams for different contexts. Setting this on State will override the default Streams implementation.") val resolvedScoped = Def.resolvedScoped diff --git a/main/src/main/scala/sbt/internal/FileTree.scala b/main/src/main/scala/sbt/internal/FileTree.scala index a26bc0bec..7b0919056 100644 --- a/main/src/main/scala/sbt/internal/FileTree.scala +++ b/main/src/main/scala/sbt/internal/FileTree.scala @@ -16,7 +16,7 @@ import sbt.io._ import scala.language.experimental.macros -private[sbt] object FileTree { +object FileTree { private def toPair(e: Entry[FileAttributes]): Option[(Path, FileAttributes)] = e.value.toOption.map(a => e.typedPath.toPath -> a) trait Repository extends sbt.internal.Repository[Seq, Glob, (Path, FileAttributes)] diff --git a/main/src/main/scala/sbt/internal/InputGraph.scala b/main/src/main/scala/sbt/internal/InputGraph.scala new file mode 100644 index 000000000..0620a5d00 --- /dev/null +++ b/main/src/main/scala/sbt/internal/InputGraph.scala @@ -0,0 +1,216 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import sbt.Def._ +import sbt.Keys._ +import sbt.Project.richInitializeTask +import sbt._ +import sbt.internal.io.Source +import sbt.internal.util.AttributeMap +import sbt.internal.util.complete.Parser +import sbt.io.Glob + +import scala.annotation.tailrec + +object TransitiveGlobs { + val transitiveTriggers = Def.taskKey[Seq[Glob]]("The transitive triggers for a key") + val transitiveInputs = Def.taskKey[Seq[Glob]]("The transitive inputs for a key") + val transitiveGlobs = + Def.taskKey[(Seq[Glob], Seq[Glob])]("The transitive inputs and triggers for a key") +} +private[sbt] object InputGraph { + @deprecated("Source is also deprecated.", "1.3.0") + private implicit class SourceOps(val source: Source) { + def toGlob: Glob = + Glob( + source.base, + source.includeFilter -- source.excludeFilter, + if (source.recursive) Int.MaxValue else 0 + ) + } + private[sbt] def inputsTask: Def.Initialize[Task[Seq[Glob]]] = + Def.task(transitiveGlobs(arguments.value)._1.sorted) + private[sbt] def inputsTask(key: ScopedKey[_]): Def.Initialize[Task[Seq[Glob]]] = + withParams((e, cm) => Def.task(transitiveGlobs(argumentsImpl(key, e, cm).value)._1.sorted)) + private[sbt] def triggersTask: Def.Initialize[Task[Seq[Glob]]] = + Def.task(transitiveGlobs(arguments.value)._2.sorted) + private[sbt] def triggersTask(key: ScopedKey[_]): Def.Initialize[Task[Seq[Glob]]] = + withParams((e, cm) => Def.task(transitiveGlobs(argumentsImpl(key, e, cm).value)._2.sorted)) + private[sbt] def task: Def.Initialize[Task[(Seq[Glob], Seq[Glob])]] = + Def.task(transitiveGlobs(arguments.value)) + private[sbt] def task(key: ScopedKey[_]): Def.Initialize[Task[(Seq[Glob], Seq[Glob])]] = + withParams((e, cm) => Def.task(transitiveGlobs(argumentsImpl(key, e, cm).value))) + private def withParams[R]( + f: (Extracted, CompiledMap) => Def.Initialize[Task[R]] + ): Def.Initialize[Task[R]] = Def.taskDyn { + val extracted = Project.extract(state.value) + f(extracted, compile(extracted.structure)) + } + + private[sbt] def compile(structure: BuildStructure): CompiledMap = + compiled(structure.settings)(structure.delegates, structure.scopeLocal, (_: ScopedKey[_]) => "") + private[sbt] final class Arguments( + val scopedKey: ScopedKey[_], + val extracted: Extracted, + val compiledMap: CompiledMap, + val log: sbt.util.Logger, + val dependencyConfigurations: Seq[(ProjectRef, Set[String])], + val state: State + ) { + def structure: BuildStructure = extracted.structure + def data: Map[Scope, AttributeMap] = extracted.structure.data.data + } + private def argumentsImpl( + scopedKey: ScopedKey[_], + extracted: Extracted, + compiledMap: CompiledMap + ): Def.Initialize[Task[Arguments]] = Def.task { + val log = (streamsManager map { mgr => + val stream = mgr(scopedKey) + stream.open() + stream + }).value.log + val configs = (internalDependencyConfigurations in scopedKey.scope).value + new Arguments( + scopedKey, + extracted, + compiledMap, + log, + configs, + state.value + ) + } + private val ShowTransitive = "(?:show)?(?:[ ]*)(.*)/(?:[ ]*)transitive(?:Inputs|Globs|Triggers)".r + private def arguments: Def.Initialize[Task[Arguments]] = Def.taskDyn { + Def.task { + val extracted = Project.extract(state.value) + val compiledMap = compile(extracted.structure) + state.value.currentCommand.map(_.commandLine) match { + case Some(ShowTransitive(key)) => + Parser.parse(key.trim, Act.scopedKeyParser(state.value)) match { + case Right(scopedKey) => argumentsImpl(scopedKey, extracted, compiledMap) + case _ => argumentsImpl(Keys.resolvedScoped.value, extracted, compiledMap) + } + case Some(_) => argumentsImpl(Keys.resolvedScoped.value, extracted, compiledMap) + } + }.value + } + private[sbt] def transitiveGlobs(args: Arguments): (Seq[Glob], Seq[Glob]) = { + import args._ + val taskScope = Project.fillTaskAxis(scopedKey).scope + def delegates(sk: ScopedKey[_]): Seq[ScopedKey[_]] = + Project.delegates(structure, sk.scope, sk.key) + // We add the triggers to the delegate scopes to make it possible for the user to do something + // like: Compile / compile / watchTriggers += baseDirectory.value ** "*.proto". We do not do the + // same for inputs because inputs are expected to be explicitly used as part of the task. + val allKeys: Seq[ScopedKey[_]] = + (delegates(scopedKey).toSet ++ delegates(ScopedKey(taskScope, watchTriggers.key))).toSeq + val keys = collectKeys(args, allKeys, Set.empty, Set.empty) + def getGlobs(scopedKey: ScopedKey[Seq[Glob]]): Seq[Glob] = + data.get(scopedKey.scope).flatMap(_.get(scopedKey.key)).getOrElse(Nil) + val (inputGlobs, triggerGlobs) = keys.partition(_.key == fileInputs.key) match { + case (i, t) => (i.flatMap(getGlobs), t.flatMap(getGlobs)) + } + (inputGlobs.distinct, (triggerGlobs ++ legacy(keys :+ scopedKey, args)).distinct) + } + + private def legacy(keys: Seq[ScopedKey[_]], args: Arguments): Seq[Glob] = { + import args._ + val projectScopes = + keys.view + .map(_.scope.copy(task = Zero, extra = Zero)) + .distinct + .toIndexedSeq + val projects = projectScopes.flatMap(_.project.toOption).distinct.toSet + val scopes: Seq[Either[Scope, Seq[Glob]]] = + data.flatMap { + case (s, am) => + if (s == Scope.Global || s.project.toOption.exists(projects.contains)) + am.get(Keys.watchSources.key) match { + case Some(k) => + k.work match { + // Avoid extracted.runTask if possible. + case Pure(w, _) => Some(Right(w().map(_.toGlob))) + case _ => Some(Left(s)) + } + case _ => None + } else { + None + } + }.toSeq + scopes.flatMap { + case Left(scope) => + extracted.runTask(Keys.watchSources in scope, state)._2.map(_.toGlob) + case Right(globs) => globs + } + } + @tailrec + private def collectKeys( + arguments: Arguments, + dependencies: Seq[ScopedKey[_]], + accumulator: Set[ScopedKey[Seq[Glob]]], + visited: Set[ScopedKey[_]] + ): Seq[ScopedKey[Seq[Glob]]] = dependencies match { + // Iterates until the dependency list is empty. The visited parameter prevents the graph + // traversal from getting stuck in a cycle. + case Seq(dependency, rest @ _*) => + (if (!visited(dependency)) arguments.compiledMap.get(dependency) else None) match { + case Some(compiled) => + val newVisited = visited + compiled.key + val baseGlobs: Seq[ScopedKey[Seq[Glob]]] = compiled.key match { + case key: ScopedKey[Seq[Glob]] @unchecked if isGlobKey(key) => key :: Nil + case _ => Nil + } + val base: (Seq[ScopedKey[_]], Seq[ScopedKey[Seq[Glob]]]) = (Nil, baseGlobs) + val (newDependencies, newScopes) = + (compiled.dependencies.filterNot(newVisited) ++ compiled.settings.map(_.key)) + .foldLeft(base) { + case ((d, s), key: ScopedKey[Seq[Glob]] @unchecked) + if isGlobKey(key) && !newVisited(key) => + (d, s :+ key) + case ((d, s), key) if key.key == dynamicDependency.key => + key.scope.task.toOption + .map { k => + val newKey = ScopedKey(key.scope.copy(task = Zero), k) + if (newVisited(newKey)) (d, s) else (d :+ newKey, s) + } + .getOrElse((d, s)) + case ((d, s), key) if key.key == transitiveClasspathDependency.key => + key.scope.task.toOption + .map { task => + val zeroedTaskScope = key.scope.copy(task = Zero) + val transitiveKeys = arguments.dependencyConfigurations.flatMap { + case (p, configs) => + configs.map(c => ScopedKey(zeroedTaskScope in (p, ConfigKey(c)), task)) + } + + (d ++ transitiveKeys.filterNot(newVisited), s) + } + .getOrElse((d, s)) + case ((d, s), key) => + (d ++ (if (!newVisited(key)) Some(key) else None), s) + } + // Append the Keys.triggers key in case there are no other references to Keys.triggers. + val transitiveTrigger = compiled.key.scope.task.toOption match { + case _: Some[_] => ScopedKey(compiled.key.scope, watchTriggers.key) + case None => ScopedKey(Project.fillTaskAxis(compiled.key).scope, watchTriggers.key) + } + val newRest = rest ++ newDependencies ++ (if (newVisited(transitiveTrigger)) Nil + else Some(transitiveTrigger)) + collectKeys(arguments, newRest, accumulator ++ newScopes, newVisited) + case _ if rest.nonEmpty => collectKeys(arguments, rest, accumulator, visited) + case _ => accumulator.toIndexedSeq + } + case _ => accumulator.toIndexedSeq + } + private[this] def isGlobKey(key: ScopedKey[_]): Boolean = key.key match { + case fileInputs.key | watchTriggers.key => true + case _ => false + } +} diff --git a/main/src/main/scala/sbt/internal/Load.scala b/main/src/main/scala/sbt/internal/Load.scala index 1ca8513f7..59c381326 100755 --- a/main/src/main/scala/sbt/internal/Load.scala +++ b/main/src/main/scala/sbt/internal/Load.scala @@ -293,9 +293,9 @@ private[sbt] object Load { def finalTransforms(ss: Seq[Setting[_]]): Seq[Setting[_]] = { def mapSpecial(to: ScopedKey[_]) = λ[ScopedKey ~> ScopedKey]( (key: ScopedKey[_]) => - if (key.key == streams.key) + if (key.key == streams.key) { ScopedKey(Scope.fillTaskAxis(Scope.replaceThis(to.scope)(key.scope), to.key), key.key) - else key + } else key ) def setDefining[T] = (key: ScopedKey[T], value: T) => diff --git a/sbt/src/sbt-test/tests/glob-dsl/build.sbt b/sbt/src/sbt-test/tests/glob-dsl/build.sbt index e94925bfb..16b161d89 100644 --- a/sbt/src/sbt-test/tests/glob-dsl/build.sbt +++ b/sbt/src/sbt-test/tests/glob-dsl/build.sbt @@ -3,9 +3,9 @@ // Check that we can correctly extract Foo.txt with a recursive source val foo = taskKey[Seq[File]]("Retrieve Foo.txt") -foo / inputs += baseDirectory.value ** "*.txt" +foo / fileInputs += baseDirectory.value ** "*.txt" -foo := (foo / inputs).value.all +foo := (foo / fileInputs).value.all.map(_._1.toFile) val checkFoo = taskKey[Unit]("Check that the Foo.txt file is retrieved") @@ -14,9 +14,9 @@ checkFoo := assert(foo.value == Seq(baseDirectory.value / "base/subdir/nested-su // Check that we can correctly extract Bar.md with a non-recursive source val bar = taskKey[Seq[File]]("Retrieve Bar.md") -bar / inputs += baseDirectory.value / "base/subdir/nested-subdir" * "*.md" +bar / fileInputs += baseDirectory.value / "base/subdir/nested-subdir" * "*.md" -bar := (bar / inputs).value.all +bar := (bar / fileInputs).value.all.map(_._1.toFile) val checkBar = taskKey[Unit]("Check that the Bar.md file is retrieved") @@ -25,19 +25,19 @@ checkBar := assert(bar.value == Seq(baseDirectory.value / "base/subdir/nested-su // Check that we can correctly extract Bar.md and Foo.md with a non-recursive source val all = taskKey[Seq[File]]("Retrieve all files") -all / inputs += baseDirectory.value / "base" / "subdir" / "nested-subdir" * AllPassFilter +all / fileInputs += baseDirectory.value / "base" / "subdir" / "nested-subdir" * AllPassFilter val checkAll = taskKey[Unit]("Check that the Bar.md file is retrieved") checkAll := { import sbt.dsl.LinterLevel.Ignore val expected = Set("Foo.txt", "Bar.md").map(baseDirectory.value / "base/subdir/nested-subdir" / _) - assert((all / inputs).value.all.toSet == expected) + assert((all / fileInputs).value.all.map(_._1.toFile).toSet == expected) } val set = taskKey[Seq[File]]("Specify redundant sources in a set") -set / inputs ++= Seq( +set / fileInputs ++= Seq( baseDirectory.value / "base" ** -DirectoryFilter, baseDirectory.value / "base" / "subdir" / "nested-subdir" * -DirectoryFilter ) @@ -45,13 +45,13 @@ set / inputs ++= Seq( val checkSet = taskKey[Unit]("Verify that redundant sources are handled") checkSet := { - val redundant = (set / inputs).value.all + val redundant = (set / fileInputs).value.all.map(_._1.toFile) assert(redundant.size == 4) // It should get Foo.txt and Bar.md twice - val deduped = (set / inputs).value.toSet[Glob].all + val deduped = (set / fileInputs).value.toSet[Glob].all.map(_._1.toFile) val expected = Seq("Bar.md", "Foo.txt").map(baseDirectory.value / "base/subdir/nested-subdir" / _) assert(deduped.sorted == expected) - val altDeduped = (set / inputs).value.unique + val altDeduped = (set / fileInputs).value.unique.map(_._1.toFile) assert(altDeduped.sorted == expected) } diff --git a/sbt/src/sbt-test/tests/inputs/build.sbt b/sbt/src/sbt-test/tests/inputs/build.sbt index c242467e2..88cc5a636 100644 --- a/sbt/src/sbt-test/tests/inputs/build.sbt +++ b/sbt/src/sbt-test/tests/inputs/build.sbt @@ -1,4 +1,6 @@ -import sbt.internal.FileTree +import java.nio.file.Path + +import sbt.internal.{FileAttributes, FileTree} import sbt.io.FileTreeDataView import xsbti.compile.analysis.Stamp @@ -8,7 +10,7 @@ val allInputsExplicit = taskKey[Seq[File]]("") val checkInputs = inputKey[Unit]("") val checkInputsExplicit = inputKey[Unit]("") -allInputs := (Compile / unmanagedSources / inputs).value.all +allInputs := (Compile / unmanagedSources / fileInputs).value.all.map(_._1.toFile) checkInputs := { val res = allInputs.value @@ -22,15 +24,15 @@ allInputsExplicit := { val files = scala.collection.mutable.Set.empty[File] val underlying = implicitly[FileTree.Repository] val repo = new FileTree.Repository { - override def get(glob: Glob): Seq[FileTreeDataView.Entry[Stamp]] = { + override def get(glob: Glob): Seq[(Path, FileAttributes)] = { val res = underlying.get(glob) - files ++= res.map(_.typedPath.toPath.toFile) + files ++= res.map(_._1.toFile) res } override def close(): Unit = {} } val include = (Compile / unmanagedSources / includeFilter).value - val _ = (Compile / unmanagedSources / inputs).value.all(repo).toSet + val _ = (Compile / unmanagedSources / fileInputs).value.all(repo).map(_._1.toFile).toSet files.filter(include.accept).toSeq } diff --git a/sbt/src/sbt-test/tests/transitive-inputs/build.sbt b/sbt/src/sbt-test/tests/transitive-inputs/build.sbt new file mode 100644 index 000000000..f3151f5c5 --- /dev/null +++ b/sbt/src/sbt-test/tests/transitive-inputs/build.sbt @@ -0,0 +1,46 @@ +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/src/main/scala/bar/Bar.scala b/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala new file mode 100644 index 000000000..f51e51890 --- /dev/null +++ b/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala @@ -0,0 +1,3 @@ +package bar + +object Bar \ No newline at end of file diff --git a/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala b/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala new file mode 100644 index 000000000..5c464310a --- /dev/null +++ b/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala @@ -0,0 +1,3 @@ +package foo + +object Foo diff --git a/sbt/src/sbt-test/tests/transitive-inputs/test b/sbt/src/sbt-test/tests/transitive-inputs/test new file mode 100644 index 000000000..24a3714e8 --- /dev/null +++ b/sbt/src/sbt-test/tests/transitive-inputs/test @@ -0,0 +1,5 @@ +#> checkFoo + +#> checkBar + +> checkBaz From e868c43fcc8233d33b87405acd1801ec578ecd27 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Thu, 31 Jan 2019 11:18:27 -0800 Subject: [PATCH 05/11] Refactor Watched This is a huge refactor of Watched. I produced this through multiple rewrite iterations and it was too difficult to separate all of the changes into small individual commits so I, unfortunately, had to make a massive commit. In general, I have tried to document the source code extensively both to facilitate reading this commit and to help with future maintenance. These changes are quite complicated because they provided a built-in like api to a feature that is implemented like a plugin. In particular, we have to manually do a lot of parsing as well as roll our own task/setting evaluation because we cannot infer the watch settings at project build time because we do not know a priori what commands the user may watch in a given session. The dynamic setting and task evaluation is mostly confined to the WatchSettings class in Continuous. It feels dirty to do all of this extraction by hand, but it does seem to work correctly with scopes. At a high level this commit does four things: 1) migrate the watch implementation to using the InputGraph to collect the globs that it needs to monitor during the watch 2) simplify WatchConfig to make it easier for plugin authors to write their own custom watch implementations 3) allow configuration of the watch settings based on the task(s) that is/are being run 4) adds an InputTask implemenation of watch. Point #1 is mostly handled by Point #3 since I had to overhaul how _all_ of the watch settings are generated. InputGraph already handles both transitive inputs and triggers as well as legacy watchSources so not much additional logic is needed beyond passing the correct scoped keys into InputGraph. Point #3 require some structural changes. The watch settings cannot in general be defined statically because we don't know a priori what tasks the user will try and watch. To address this, I added code that will extract the task keys for all of the commands that we are running. I then manually extract the relevant settings for each command. Finally, I aggregate those settings into a single WatchConfig that can be used to actually implement the watch. The aggregation is generally straightforward: we run all of the callbacks for each task and choose the next watch state based on the highest priority Action that is returned by any of the callbacks. Because I needed Extracted to pull out the necessary settings, I was forced to move a lot of logic out of Watched and into a new singleton, Continuous, that exists in the main project (Watched is in the command project). The public footprint of Continuous is tiny. Even though I want to make the watch feature flexible for plugin authors, the implementation and api remain a moving target so I do not want to be limited by future binary compatibility requirements. Anyone who wants to live dangerously can access the private[sbt] apis via reflection or by adding custom code to the sbt package in their plugin (a technique I've used in CloseWatch). Point #2 is addressed by removing the count and lastStatus from the WatchConfig callbacks. While these parameters can be useful, they are not necessary to implement the semantics of a watch. Moreover, a status boolean isn't really that useful and the sbt task engine makes it very difficult to actually extract the previous result of the tasks that were run. After this refactor, WatchConfig has a simpler api. There are fewer callbacks to implement and the signatures are simpler. To preserve the _functionality_ of making the count accessible to the user specifiable callbacks, I still provided settings like watchOnInputEvent that accept a count parameter, but the count is actually tracked externally to Watched.watch and incremented every time the task is run. Moreover, there are a few parameters of the watch: the logger and transitive globs, that cannot be provided via settings. I provide callback settings like watchOnStart that mirror the WatchConfig callbacks except that they return a function from Continuous.Arguments to the needed callback. The Continuous.aggregate function will check if the watchOnStart setting is set and if it is, will pass in the needed arguments. Otherwise it will use the default watchOnStart implementation which simulates the existing behavior by tracking the iteration count in an AtomicInteger and passing the current count into the user provided callback. In this way, we are able to provide a number of apis to the watch process while preserving the default behavior. To implement #4, I had to change the label of the `watch` attribute key from "watch" to "watched". This allows `watch compile` to work at the sbt command line even thought it maps to the watchTasks key. The actual implementation is almost trivial. The difference between an InputTask[Unit] and a command is very small. The tricky part is that the actual implementation requires applying mapTask to a delegate task that overrides the Task's info.postTransform value (which is used to transform the state after task evaluation). The actual postTransform function can be shared by the continuous task and continuous command. There is just a slightly different mechanism for getting to the state transformation function. --- .../src/main/scala/sbt/BasicKeys.scala | 3 +- main-command/src/main/scala/sbt/Watched.scala | 801 ++++++++-------- .../src/test/scala/sbt/MultiParserSpec.scala | 2 +- .../src/test/scala/sbt/WatchedSpec.scala | 159 ++-- main-settings/src/main/scala/sbt/Append.scala | 11 +- main/src/main/scala/sbt/Defaults.scala | 112 +-- main/src/main/scala/sbt/Keys.scala | 46 +- main/src/main/scala/sbt/Main.scala | 10 +- main/src/main/scala/sbt/ScriptedPlugin.scala | 3 +- .../main/scala/sbt/internal/Continuous.scala | 873 ++++++++++++++++++ .../sbt/internal/DeprecatedContinuous.scala | 19 + .../scala/sbt/internal/DupedInputStream.scala | 73 ++ .../scala/sbt/internal/FileManagement.scala | 51 +- project/SbtLauncherPlugin.scala | 3 +- .../tests/interproject-inputs/build.sbt | 58 ++ .../src/main/scala/bar/Bar.scala | 0 .../src/main/scala/foo/Foo.scala | 0 .../sbt-test/tests/interproject-inputs/test | 5 + .../tests/transitive-inputs/build.sbt | 46 - sbt/src/sbt-test/tests/transitive-inputs/test | 5 - .../build.sbt | 4 +- .../project/Build.scala | 12 +- sbt/src/sbt-test/watch/command-parser/test | 21 + .../sbt-test/watch/custom-config/build.sbt | 5 + .../watch/custom-config/project/Build.scala | 40 + sbt/src/sbt-test/watch/custom-config/test | 7 + .../watch/input-aggregation/build.sbt | 7 + .../input-aggregation/project/Build.scala | 94 ++ sbt/src/sbt-test/watch/input-aggregation/test | 11 + sbt/src/sbt-test/watch/input-parser/build.sbt | 9 + .../watch/input-parser/project/Build.scala | 27 + sbt/src/sbt-test/watch/input-parser/test | 17 + .../sbt-test/watch/legacy-sources/build.sbt | 13 + .../watch/legacy-sources/project/Build.scala | 17 + sbt/src/sbt-test/watch/legacy-sources/test | 3 + .../sbt-test/watch/on-start-watch/build.sbt | 29 - .../watch/on-start-watch/changes/extra.sbt | 4 + .../sbt-test/watch/on-start-watch/extra.sbt | 1 + .../watch/on-start-watch/project/Count.scala | 8 +- sbt/src/sbt-test/watch/on-start-watch/test | 18 +- sbt/src/sbt-test/watch/task/build.sbt | 3 + .../sbt-test/watch/task/changes/Build.scala | 27 + .../sbt-test/watch/task/project/Build.scala | 34 + sbt/src/sbt-test/watch/task/test | 12 + sbt/src/sbt-test/watch/watch-parser/test | 21 - 45 files changed, 2011 insertions(+), 713 deletions(-) create mode 100644 main/src/main/scala/sbt/internal/Continuous.scala create mode 100644 main/src/main/scala/sbt/internal/DeprecatedContinuous.scala create mode 100644 main/src/main/scala/sbt/internal/DupedInputStream.scala create mode 100644 sbt/src/sbt-test/tests/interproject-inputs/build.sbt rename sbt/src/sbt-test/tests/{transitive-inputs => interproject-inputs}/src/main/scala/bar/Bar.scala (100%) rename sbt/src/sbt-test/tests/{transitive-inputs => interproject-inputs}/src/main/scala/foo/Foo.scala (100%) create mode 100644 sbt/src/sbt-test/tests/interproject-inputs/test delete mode 100644 sbt/src/sbt-test/tests/transitive-inputs/build.sbt delete mode 100644 sbt/src/sbt-test/tests/transitive-inputs/test rename sbt/src/sbt-test/watch/{watch-parser => command-parser}/build.sbt (57%) rename sbt/src/sbt-test/watch/{watch-parser => command-parser}/project/Build.scala (52%) create mode 100644 sbt/src/sbt-test/watch/command-parser/test create mode 100644 sbt/src/sbt-test/watch/custom-config/build.sbt create mode 100644 sbt/src/sbt-test/watch/custom-config/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/custom-config/test create mode 100644 sbt/src/sbt-test/watch/input-aggregation/build.sbt create mode 100644 sbt/src/sbt-test/watch/input-aggregation/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/input-aggregation/test create mode 100644 sbt/src/sbt-test/watch/input-parser/build.sbt create mode 100644 sbt/src/sbt-test/watch/input-parser/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/input-parser/test create mode 100644 sbt/src/sbt-test/watch/legacy-sources/build.sbt create mode 100644 sbt/src/sbt-test/watch/legacy-sources/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/legacy-sources/test create mode 100644 sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt create mode 100644 sbt/src/sbt-test/watch/on-start-watch/extra.sbt create mode 100644 sbt/src/sbt-test/watch/task/build.sbt create mode 100644 sbt/src/sbt-test/watch/task/changes/Build.scala create mode 100644 sbt/src/sbt-test/watch/task/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/task/test delete mode 100644 sbt/src/sbt-test/watch/watch-parser/test diff --git a/main-command/src/main/scala/sbt/BasicKeys.scala b/main-command/src/main/scala/sbt/BasicKeys.scala index b4ab63054..251423049 100644 --- a/main-command/src/main/scala/sbt/BasicKeys.scala +++ b/main-command/src/main/scala/sbt/BasicKeys.scala @@ -33,7 +33,8 @@ object BasicKeys { "The function that constructs the command prompt from the current build state.", 10000 ) - val watch = AttributeKey[Watched]("watch", "Continuous execution configuration.", 1000) + val watch = + AttributeKey[Watched]("watched", "Continuous execution configuration.", 1000) val serverPort = AttributeKey[Int]("server-port", "The port number used by server command.", 10000) diff --git a/main-command/src/main/scala/sbt/Watched.scala b/main-command/src/main/scala/sbt/Watched.scala index 91b7dad18..abc1f9412 100644 --- a/main-command/src/main/scala/sbt/Watched.scala +++ b/main-command/src/main/scala/sbt/Watched.scala @@ -8,27 +8,24 @@ package sbt import java.io.{ File, InputStream } -import java.nio.file.{ FileSystems, Path } +import java.nio.file.FileSystems -import sbt.BasicCommandStrings.{ - ContinuousExecutePrefix, - FailureWall, - continuousBriefHelp, - continuousDetail -} -import sbt.BasicCommands.otherCommandParser +import sbt.BasicCommandStrings.ContinuousExecutePrefix import sbt.internal.LabeledFunctions._ +import sbt.internal.{ FileAttributes, LegacyWatched } import sbt.internal.io.{ EventMonitor, Source, WatchState } import sbt.internal.util.Types.const -import sbt.internal.util.complete.{ DefaultParsers, Parser } -import sbt.internal.util.{ AttributeKey, JLine } -import sbt.internal.{ FileAttributes, LegacyWatched } +import sbt.internal.util.complete.DefaultParsers._ +import sbt.internal.util.complete.Parser +import sbt.internal.util.{ AttributeKey, JLine, Util } +import sbt.io.FileEventMonitor.{ Creation, Deletion, Event, Update } import sbt.io._ import sbt.util.{ Level, Logger } import scala.annotation.tailrec import scala.concurrent.duration._ import scala.util.Properties +import scala.util.control.NonFatal @deprecated("Watched is no longer used to implement continuous execution", "1.3.0") trait Watched { @@ -38,8 +35,8 @@ trait Watched { def terminateWatch(key: Int): Boolean = Watched.isEnter(key) /** - * The time in milliseconds between checking for changes. The actual time between the last change made to a file and the - * execution time is between `pollInterval` and `pollInterval*2`. + * The time in milliseconds between checking for changes. The actual time between the last change + * made to a file and the execution time is between `pollInterval` and `pollInterval*2`. */ def pollInterval: FiniteDuration = Watched.PollDelay @@ -68,121 +65,279 @@ object Watched { */ sealed trait Action + /** + * Provides a default Ordering for actions. Lower values correspond to higher priority actions. + * [[CancelWatch]] is higher priority than [[ContinueWatch]]. + */ + object Action { + implicit object ordering extends Ordering[Action] { + override def compare(left: Action, right: Action): Int = (left, right) match { + case (a: ContinueWatch, b: ContinueWatch) => ContinueWatch.ordering.compare(a, b) + case (_: ContinueWatch, _: CancelWatch) => 1 + case (a: CancelWatch, b: CancelWatch) => CancelWatch.ordering.compare(a, b) + case (_: CancelWatch, _: ContinueWatch) => -1 + } + } + } + /** * Action that indicates that the watch should stop. */ - case object CancelWatch extends Action + sealed trait CancelWatch extends Action + + /** + * Action that does not terminate the watch but might trigger a build. + */ + sealed trait ContinueWatch extends Action + + /** + * Provides a default Ordering for classes extending [[ContinueWatch]]. [[Trigger]] is higher + * priority than [[Ignore]]. + */ + object ContinueWatch { + + /** + * A default [[Ordering]] for [[ContinueWatch]]. [[Trigger]] is higher priority than [[Ignore]]. + */ + implicit object ordering extends Ordering[ContinueWatch] { + override def compare(left: ContinueWatch, right: ContinueWatch): Int = left match { + case Ignore => if (right == Ignore) 0 else 1 + case Trigger => if (right == Trigger) 0 else -1 + } + } + } + + /** + * Action that indicates that the watch should stop. + */ + case object CancelWatch extends CancelWatch { + + /** + * A default [[Ordering]] for [[ContinueWatch]]. The priority of each type of [[CancelWatch]] + * is reflected by the ordering of the case statements in the [[ordering.compare]] method, + * e.g. [[Custom]] is higher priority than [[HandleError]]. + */ + implicit object ordering extends Ordering[CancelWatch] { + override def compare(left: CancelWatch, right: CancelWatch): Int = left match { + // Note that a negative return value means the left CancelWatch is preferred to the right + // CancelWatch while the inverse is true for a positive return value. This logic could + // likely be simplified, but the pattern matching approach makes it very clear what happens + // for each type of Action. + case _: Custom => + right match { + case _: Custom => 0 + case _ => -1 + } + case _: HandleError => + right match { + case _: Custom => 1 + case _: HandleError => 0 + case _ => -1 + } + case _: Run => + right match { + case _: Run => 0 + case CancelWatch | Reload => -1 + case _ => 1 + } + case CancelWatch => + right match { + case CancelWatch => 0 + case Reload => -1 + case _ => 1 + } + case Reload => if (right == Reload) 0 else 1 + } + } + } /** * Action that indicates that an error has occurred. The watch will be terminated when this action * is produced. */ - case object HandleError extends Action + final class HandleError(val throwable: Throwable) extends CancelWatch { + override def equals(o: Any): Boolean = o match { + case that: HandleError => this.throwable == that.throwable + case _ => false + } + override def hashCode: Int = throwable.hashCode + override def toString: String = s"HandleError($throwable)" + } /** * Action that indicates that the watch should continue as though nothing happened. This may be - * because, for example, no user input was yet available in [[WatchConfig.handleInput]]. + * because, for example, no user input was yet available. */ - case object Ignore extends Action + case object Ignore extends ContinueWatch /** * Action that indicates that the watch should pause while the build is reloaded. This is used to * automatically reload the project when the build files (e.g. build.sbt) are changed. */ - case object Reload extends Action + case object Reload extends CancelWatch + + /** + * Action that indicates that we should exit and run the provided command. + * @param commands the commands to run after we exit the watch + */ + final class Run(val commands: String*) extends CancelWatch { + override def toString: String = s"Run(${commands.mkString(", ")})" + } + // For now leave this private in case this isn't the best unapply type signature since it can't + // be evolved in a binary compatible way. + private object Run { + def unapply(r: Run): Option[List[Exec]] = Some(r.commands.toList.map(Exec(_, None))) + } /** * Action that indicates that the watch process should re-run the command. */ - case object Trigger extends Action + case object Trigger extends ContinueWatch /** * A user defined Action. It is not sealed so that the user can create custom instances. If any - * of the [[WatchConfig]] callbacks, e.g. [[WatchConfig.onWatchEvent]], return an instance of - * [[Custom]], the watch will terminate. + * of the [[Watched.watch]] callbacks return [[Custom]], then watch will terminate. */ - trait Custom extends Action + trait Custom extends CancelWatch + @deprecated("WatchSource is replaced by sbt.io.Glob", "1.3.0") type WatchSource = Source + private[sbt] type OnTermination = (Action, String, State) => State + private[sbt] type OnEnter = () => Unit def terminateWatch(key: Int): Boolean = Watched.isEnter(key) - private[this] val isWin = Properties.isWin - private def drain(is: InputStream): Unit = while (is.available > 0) is.read() - private def withCharBufferedStdIn[R](f: InputStream => R): R = - if (!isWin) JLine.usingTerminal { terminal => - terminal.init() - val in = terminal.wrapInIfNeeded(System.in) - try { - drain(in) - f(in) - } finally { - drain(in) - terminal.reset() - } - } else - try { - drain(System.in) - f(System.in) - } finally drain(System.in) + /** + * A constant function that returns [[Trigger]]. + */ + final val trigger: (Int, Event[FileAttributes]) => Watched.Action = { + (_: Int, _: Event[FileAttributes]) => + Trigger + }.label("Watched.trigger") - private[sbt] final val handleInput: InputStream => Action = in => { - @tailrec - def scanInput(): Action = { - if (in.available > 0) { - in.read() match { - case key if isEnter(key) => CancelWatch - case key if isR(key) && !isWin => Trigger - case key if key >= 0 => scanInput() - case _ => Ignore - } - } else { - Ignore - } + def ifChanged(action: Action): (Int, Event[FileAttributes]) => Watched.Action = + (_: Int, event: Event[FileAttributes]) => + event match { + case Update(prev, cur, _) if prev.value != cur.value => action + case _: Creation[_] | _: Deletion[_] => action + case _ => Ignore } - scanInput() - } - private[sbt] def onEvent( - sources: Seq[WatchSource], - projectSources: Seq[WatchSource] - ): FileAttributes.Event => Watched.Action = - event => - if (sources.exists(_.accept(event.path))) Watched.Trigger - else if (projectSources.exists(_.accept(event.path))) { - (event.previous, event.current) match { - case (Some(p), Some(c)) => if (c == p) Watched.Ignore else Watched.Reload - case _ => Watched.Trigger - } - } else Ignore - private[this] val reRun = if (isWin) "" else " or 'r' to re-run the command" + private[this] val options = + if (Util.isWindows) + "press 'enter' to return to the shell or the following keys followed by 'enter': 'r' to" + + " re-run the command, 'x' to exit sbt" + else "press 'r' to re-run the command, 'x' to exit sbt or 'enter' to return to the shell" private def waitMessage(project: String): String = - s"Waiting for source changes$project... (press enter to interrupt$reRun)" + s"Waiting for source changes$project... (press enter to interrupt$options)" + + /** + * The minimum delay between build triggers for the same file. If the file is detected + * to have changed within this period from the last build trigger, the event will be discarded. + */ + final val defaultAntiEntropy: FiniteDuration = 500.milliseconds + + /** + * The duration in wall clock time for which a FileEventMonitor will retain anti-entropy + * events for files. This is an implementation detail of the FileEventMonitor. It should + * hopefully not need to be set by the users. It is needed because when a task takes a long time + * to run, it is possible that events will be detected for the file that triggered the build that + * occur within the anti-entropy period. We still allow it to be configured to limit the memory + * usage of the FileEventMonitor (but this is somewhat unlikely to be a problem). + */ + final val defaultAntiEntropyRetentionPeriod: FiniteDuration = 10.minutes + + /** + * The duration for which we delay triggering when a file is deleted. This is needed because + * many programs implement save as a file move of a temporary file onto the target file. + * Depending on how the move is implemented, this may be detected as a deletion immediately + * followed by a creation. If we trigger immediately on delete, we may, for example, try to + * compile before all of the source files are actually available. The longer this value is set, + * the less likely we are to spuriously trigger a build before all files are available, but + * the longer it will take to trigger a build when the file is actually deleted and not renamed. + */ + final val defaultDeletionQuarantinePeriod: FiniteDuration = 50.milliseconds + + /** + * Converts user input to an Action with the following rules: + * 1) on all platforms, new lines exit the watch + * 2) on posix platforms, 'r' or 'R' will trigger a build + * 3) on posix platforms, 's' or 'S' will exit the watch and run the shell command. This is to + * support the case where the user starts sbt in a continuous mode but wants to return to + * the shell without having to restart sbt. + */ + final val defaultInputParser: Parser[Action] = { + def posixOnly(legal: String, action: Action): Parser[Action] = + if (!Util.isWindows) chars(legal) ^^^ action + else Parser.invalid(Seq("Can't use jline for individual character entry on windows.")) + val rebuildParser: Parser[Action] = posixOnly(legal = "rR", Trigger) + val shellParser: Parser[Action] = posixOnly(legal = "sS", new Run("shell")) + val cancelParser: Parser[Action] = chars(legal = "\n\r") ^^^ CancelWatch + shellParser | rebuildParser | cancelParser + } + + /** + * A function that prints out the current iteration count and gives instructions for exiting + * or triggering the build. + */ val defaultStartWatch: Int => Option[String] = ((count: Int) => Some(s"$count. ${waitMessage("")}")).label("Watched.defaultStartWatch") - @deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0") - val defaultWatchingMessage: WatchState => String = - ((ws: WatchState) => defaultStartWatch(ws.count).get).label("Watched.defaultWatchingMessage") - def projectWatchingMessage(projectId: String): WatchState => String = - ((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count).get) - .label("Watched.projectWatchingMessage") - def projectOnWatchMessage(project: String): Int => Option[String] = - ((count: Int) => Some(s"$count. ${waitMessage(s" in project $project")}")) - .label("Watched.projectOnWatchMessage") - val defaultOnTriggerMessage: Int => Option[String] = - ((_: Int) => None).label("Watched.defaultOnTriggerMessage") - @deprecated( - "Use defaultOnTriggerMessage in conjunction with the watchTriggeredMessage key", - "1.3.0" - ) - val defaultTriggeredMessage: WatchState => String = - const("").label("Watched.defaultTriggeredMessage") - val clearOnTrigger: Int => Option[String] = _ => Some(clearScreen) - @deprecated("Use clearOnTrigger in conjunction with the watchTriggeredMessage key", "1.3.0") - val clearWhenTriggered: WatchState => String = - const(clearScreen).label("Watched.clearWhenTriggered") + /** + * Default no-op callback. + */ + val defaultOnEnter: () => Unit = () => {} + + private[sbt] val defaultCommandOnTermination: (Action, String, Int, State) => State = + onTerminationImpl(ContinuousExecutePrefix).label("Watched.defaultCommandOnTermination") + private[sbt] val defaultTaskOnTermination: (Action, String, Int, State) => State = + onTerminationImpl("watch", ContinuousExecutePrefix).label("Watched.defaultTaskOnTermination") + + /** + * Default handler to transform the state when the watch terminates. When the [[Watched.Action]] + * is [[Reload]], the handler will prepend the original command (prefixed by ~) to the + * [[State.remainingCommands]] and then invoke the [[StateOps.reload]] method. When the + * [[Watched.Action]] is [[HandleError]], the handler returns the result of [[StateOps.fail]]. + * When the [[Watched.Action]] is [[Watched.Run]], we add the commands specified by + * [[Watched.Run.commands]] to the stat's remaining commands. Otherwise the original state is + * returned. + */ + private def onTerminationImpl( + watchPrefixes: String* + ): (Action, String, Int, State) => State = { (action, command, count, state) => + val prefix = watchPrefixes.head + val rc = state.remainingCommands + .filterNot(c => watchPrefixes.exists(c.commandLine.trim.startsWith)) + action match { + case Run(commands) => state.copy(remainingCommands = commands ++ rc) + case Reload => + state.copy(remainingCommands = "reload".toExec :: s"$prefix $count $command".toExec :: rc) + case _: HandleError => state.copy(remainingCommands = rc).fail + case _ => state.copy(remainingCommands = rc) + } + } + + /** + * A constant function that always returns [[None]]. When + * `Keys.watchTriggeredMessage := Watched.defaultOnTriggerMessage`, then nothing is logged when + * a build is triggered. + */ + final val defaultOnTriggerMessage: (Int, Event[FileAttributes]) => Option[String] = + ((_: Int, _: Event[FileAttributes]) => None).label("Watched.defaultOnTriggerMessage") + + /** + * The minimum delay between file system polling when a [[PollingWatchService]] is used. + */ + final val defaultPollInterval: FiniteDuration = 500.milliseconds + + /** + * A constant function that returns an Option wrapped string that clears the screen when + * written to stdout. + */ + final val clearOnTrigger: Int => Option[String] = + ((_: Int) => Some(clearScreen)).label("Watched.clearOnTrigger") def clearScreen: String = "\u001b[2J\u001b[0;0H" + @deprecated("WatchSource has been replaced by sbt.io.Glob", "1.3.0") object WatchSource { /** @@ -206,6 +361,128 @@ object Watched { } + private type RunCommand = () => State + private type NextAction = () => Watched.Action + private[sbt] type Monitor = FileEventMonitor[FileAttributes] + + /** + * Runs a task and then blocks until the task is ready to run again or we no longer wish to + * block execution. + * + * @param task the aggregated task to run with each iteration + * @param onStart function to be invoked before we start polling for events + * @param nextAction function that returns the next state transition [[Watched.Action]]. + * @return the exit [[Watched.Action]] that can be used to potentially modify the build state and + * the count of the number of iterations that were run. If + */ + def watch(task: () => Unit, onStart: NextAction, nextAction: NextAction): Watched.Action = { + def safeNextAction(delegate: NextAction): Watched.Action = + try delegate() + catch { case NonFatal(t) => new HandleError(t) } + @tailrec def next(): Watched.Action = safeNextAction(nextAction) match { + // This should never return Ignore due to this condition. + case Ignore => next() + case action => action + } + @tailrec def impl(): Watched.Action = { + task() + safeNextAction(onStart) match { + case Ignore => + next() match { + case Trigger => impl() + case action => action + } + case Trigger => impl() + case a => a + } + } + try impl() + catch { case NonFatal(t) => new HandleError(t) } + } + + private[sbt] object NullLogger extends Logger { + override def trace(t: => Throwable): Unit = {} + override def success(message: => String): Unit = {} + override def log(level: Level.Value, message: => String): Unit = {} + } + + /** + * Traverse all of the events and find the one for which we give the highest + * weight. Within the [[Action]] hierarchy: + * [[Custom]] > [[HandleError]] > [[Run]] > [[CancelWatch]] > [[Reload]] > [[Trigger]] > [[Ignore]] + * the first event of each kind is returned so long as there are no higher priority events + * in the collection. For example, if there are multiple events that all return [[Trigger]], then + * the first one is returned. If, on the other hand, one of the events returns [[Reload]], + * then that event "wins" and the [[Reload]] action is returned with the [[Event[FileAttributes]]] + * that triggered it. + * + * @param events the ([[Action]], [[Event[FileAttributes]]]) pairs + * @return the ([[Action]], [[Event[FileAttributes]]]) pair with highest weight if the input events + * are non empty. + */ + @inline + private[sbt] def aggregate( + events: Seq[(Action, Event[FileAttributes])] + ): Option[(Action, Event[FileAttributes])] = + if (events.isEmpty) None else Some(events.minBy(_._1)) + + private implicit class StringToExec(val s: String) extends AnyVal { + def toExec: Exec = Exec(s, None) + } + + private[sbt] def withCharBufferedStdIn[R](f: InputStream => R): R = + if (!Util.isWindows) JLine.usingTerminal { terminal => + terminal.init() + val in = terminal.wrapInIfNeeded(System.in) + try { + f(in) + } finally { + terminal.reset() + } + } else + f(System.in) + + private[sbt] val newWatchService: () => WatchService = + (() => createWatchService()).label("Watched.newWatchService") + def createWatchService(pollDelay: FiniteDuration): WatchService = { + def closeWatch = new MacOSXWatchService() + sys.props.get("sbt.watch.mode") match { + case Some("polling") => + new PollingWatchService(pollDelay) + case Some("nio") => + FileSystems.getDefault.newWatchService() + case Some("closewatch") => closeWatch + case _ if Properties.isMac => closeWatch + case _ => + FileSystems.getDefault.newWatchService() + } + } + + @deprecated("This is no longer used by continuous builds.", "1.3.0") + def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg) + @deprecated("This is no longer used by continuous builds.", "1.3.0") + def isEnter(key: Int): Boolean = key == 10 || key == 13 + @deprecated("Replaced by defaultPollInterval", "1.3.0") + val PollDelay: FiniteDuration = 500.milliseconds + @deprecated("Replaced by defaultAntiEntropy", "1.3.0") + val AntiEntropy: FiniteDuration = 40.milliseconds + @deprecated("Use the version that explicitly takes the poll delay", "1.3.0") + def createWatchService(): WatchService = createWatchService(PollDelay) + + @deprecated("Replaced by Watched.command", "1.3.0") + def executeContinuously(watched: Watched, s: State, next: String, repeat: String): State = + LegacyWatched.executeContinuously(watched, s, next, repeat) + + // Deprecated apis below + @deprecated("unused", "1.3.0") + def projectWatchingMessage(projectId: String): WatchState => String = + ((ws: WatchState) => projectOnWatchMessage(projectId)(ws.count).get) + .label("Watched.projectWatchingMessage") + @deprecated("unused", "1.3.0") + def projectOnWatchMessage(project: String): Int => Option[String] = + ((count: Int) => Some(s"$count. ${waitMessage(s" in project $project")}")) + .label("Watched.projectOnWatchMessage") + @deprecated("This method is not used and may be removed in a future version of sbt", "1.3.0") private[this] class AWatched extends Watched @@ -223,350 +500,36 @@ object Watched { @deprecated("This method is not used and may be removed in a future version of sbt", "1.3.0") def empty: Watched = new AWatched - val PollDelay: FiniteDuration = 500.milliseconds - val AntiEntropy: FiniteDuration = 40.milliseconds - def isEnter(key: Int): Boolean = key == 10 || key == 13 - def isR(key: Int): Boolean = key == 82 || key == 114 - def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg) - - private type RunCommand = () => State - private type WatchSetup = (State, String) => (State, WatchConfig, RunCommand => State) - - /** - * Provides the '~' continuous execution command. - * @param setup a function that provides a logger and a function from (() => State) => State. - * @return the '~' command. - */ - def continuous(setup: WatchSetup): Command = - Command(ContinuousExecutePrefix, continuousBriefHelp, continuousDetail)(otherCommandParser) { - (state, command) => - Watched.executeContinuously(state, command, setup) - } - - /** - * Default handler to transform the state when the watch terminates. When the [[Watched.Action]] is - * [[Reload]], the handler will prepend the original command (prefixed by ~) to the - * [[State.remainingCommands]] and then invoke the [[StateOps.reload]] method. When the - * [[Watched.Action]] is [[HandleError]], the handler returns the result of [[StateOps.fail]]. Otherwise - * the original state is returned. - */ - private[sbt] val onTermination: (Action, String, State) => State = (action, command, state) => - action match { - case Reload => - val continuousCommand = Exec(ContinuousExecutePrefix + command, None) - state.copy(remainingCommands = continuousCommand +: state.remainingCommands).reload - case HandleError => state.fail - case _ => state - } - - /** - * Implements continuous execution. It works by first parsing the command and generating a task to - * run with each build. It can run multiple commands that are separated by ";" in the command - * input. If any of these commands are invalid, the watch will immediately exit. - * @param state the initial state - * @param command the command(s) to repeatedly apply - * @param setup function to generate a logger and a transformation of the resultant state. The - * purpose of the transformation is to preserve the logging semantics that existed - * in the legacy version of this function in which the task would be run through - * MainLoop.processCommand, which is unavailable in the main-command project - * @return the initial state if all of the input commands are valid. Otherwise, returns the - * initial state with the failure transformation. - */ - private[sbt] def executeContinuously( - state: State, - command: String, - setup: WatchSetup, - ): State = withCharBufferedStdIn { in => - val (s0, config, newState) = setup(state, command) - val failureCommandName = "SbtContinuousWatchOnFail" - val onFail = Command.command(failureCommandName)(identity) - val s = (FailureWall :: s0).copy( - onFailure = Some(Exec(failureCommandName, None)), - definedCommands = s0.definedCommands :+ onFail - ) - val commands = Parser.parse(command, BasicCommands.multiParserImpl(Some(s))) match { - case Left(_) => command :: Nil - case Right(c) => c - } - val parser = Command.combine(s.definedCommands)(s) - val tasks = commands.foldLeft(Nil: Seq[Either[String, () => Either[Exception, Boolean]]]) { - (t, cmd) => - t :+ (DefaultParsers.parse(cmd, parser) match { - case Right(task) => - Right { () => - try { - Right(newState(task).remainingCommands.forall(_.commandLine != failureCommandName)) - } catch { case e: Exception => Left(e) } - } - case Left(_) => Left(cmd) - }) - } - val (valid, invalid) = tasks.partition(_.isRight) - if (invalid.isEmpty) { - val task = () => - valid.foldLeft(Right(true): Either[Exception, Boolean]) { - case (status, Right(t)) => if (status.getOrElse(true)) t() else status - case _ => throw new IllegalStateException("Should be unreachable") - } - val terminationAction = watch(in, task, config) - config.onWatchTerminated(terminationAction, command, state) - } else { - val commands = invalid.flatMap(_.left.toOption).mkString("'", "', '", "'") - config.logger.error(s"Terminating watch due to invalid command(s): $commands") - state.fail - } - } - - private[sbt] def watch( - in: InputStream, - task: () => Either[Exception, Boolean], - config: WatchConfig - ): Action = { - val logger = config.logger - def info(msg: String): Unit = if (msg.nonEmpty) logger.info(msg) - - @tailrec - def impl(count: Int): Action = { - @tailrec - def nextAction(): Action = { - config.handleInput(in) match { - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - case Trigger => Trigger - case _ => - val events = config.fileEventMonitor - .poll(10.millis) - .map(new FileAttributes.EventImpl(_)) - val next = events match { - case Seq() => (Ignore, None) - case Seq(head, tail @ _*) => - /* - * We traverse all of the events and find the one for which we give the highest - * weight. - * Custom > HandleError > CancelWatch > Reload > Trigger > Ignore - */ - tail.foldLeft((config.onWatchEvent(head), Some(head))) { - case (current @ (_: Custom, _), _) => current - case (current @ (action, _), event) => - config.onWatchEvent(event) match { - case HandleError => (HandleError, Some(event)) - case CancelWatch if action != HandleError => (CancelWatch, Some(event)) - case Reload if action != HandleError && action != CancelWatch => - (Reload, Some(event)) - case Trigger if action == Ignore => (Trigger, Some(event)) - case _ => current - } - } - } - // Note that nextAction should never return Ignore. - next match { - case (action @ (HandleError | CancelWatch | _: Custom), Some(event)) => - val cause = - if (action == HandleError) "error" - else if (action.isInstanceOf[Custom]) action.toString - else "cancellation" - logger.debug(s"Stopping watch due to $cause from ${event.path}") - action - case (Trigger, Some(event)) => - logger.debug(s"Triggered by ${event.path}") - config.triggeredMessage(event.path, count).foreach(info) - Trigger - case (Reload, Some(event)) => - logger.info(s"Reload triggered by ${event.path}") - Reload - case _ => - nextAction() - } - } - } - task() match { - case Right(status) => - config.preWatch(count, status) match { - case Ignore => - config.watchingMessage(count).foreach(info) - nextAction() match { - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - case _ => impl(count + 1) - } - case Trigger => impl(count + 1) - case action @ (CancelWatch | HandleError | Reload | _: Custom) => action - } - case Left(e) => - logger.error(s"Terminating watch due to Unexpected error: $e") - HandleError - } - } - try impl(count = 1) - finally config.fileEventMonitor.close() - } - - @deprecated("Replaced by Watched.command", "1.3.0") - def executeContinuously(watched: Watched, s: State, next: String, repeat: String): State = - LegacyWatched.executeContinuously(watched, s, next, repeat) - - private[sbt] object NullLogger extends Logger { - override def trace(t: => Throwable): Unit = {} - override def success(message: => String): Unit = {} - override def log(level: Level.Value, message: => String): Unit = {} - } - @deprecated("ContinuousEventMonitor attribute is not used by Watched.command", "1.3.0") val ContinuousEventMonitor = AttributeKey[EventMonitor]( "watch event monitor", "Internal: maintains watch state and monitor threads." ) - @deprecated("Superseded by ContinuousEventMonitor", "1.1.5") + @deprecated("Superseded by ContinuousEventMonitor", "1.3.0") val ContinuousState = AttributeKey[WatchState]("watch state", "Internal: tracks state for continuous execution.") - @deprecated("Superseded by ContinuousEventMonitor", "1.1.5") + @deprecated("Superseded by ContinuousEventMonitor", "1.3.0") val ContinuousWatchService = AttributeKey[WatchService]( "watch service", "Internal: tracks watch service for continuous execution." ) + @deprecated("No longer used for continuous execution", "1.3.0") val Configuration = AttributeKey[Watched]("watched-configuration", "Configures continuous execution.") - def createWatchService(pollDelay: FiniteDuration): WatchService = { - def closeWatch = new MacOSXWatchService() - sys.props.get("sbt.watch.mode") match { - case Some("polling") => - new PollingWatchService(pollDelay) - case Some("nio") => - FileSystems.getDefault.newWatchService() - case Some("closewatch") => closeWatch - case _ if Properties.isMac => closeWatch - case _ => - FileSystems.getDefault.newWatchService() - } - } - def createWatchService(): WatchService = createWatchService(PollDelay) -} - -/** - * Provides a number of configuration options for continuous execution. - */ -trait WatchConfig { - - /** - * A logger. - * @return a logger - */ - def logger: Logger - - /** - * The sbt.io.FileEventMonitor that is used to monitor the file system. - * - * @return an sbt.io.FileEventMonitor instance. - */ - def fileEventMonitor: FileEventMonitor[FileAttributes] - - /** - * A function that is periodically invoked to determine whether the watch should stop or - * trigger. Usually this will read from System.in to react to user input. - * @return an [[Watched.Action Action]] that will determine the next step in the watch. - */ - def handleInput(inputStream: InputStream): Watched.Action - - /** - * This is run before each watch iteration and if it returns true, the watch is terminated. - * @param count The current number of watch iterations. - * @param lastStatus true if the previous task execution completed successfully - * @return the Action to apply - */ - def preWatch(count: Int, lastStatus: Boolean): Watched.Action - - /** - * Callback that is invoked whenever a file system vent is detected. The next step of the watch - * is determined by the [[Watched.Action Action]] returned by the callback. - * @param event the detected sbt.io.FileEventMonitor.Event. - * @return the next [[Watched.Action Action]] to run. - */ - def onWatchEvent(event: FileAttributes.Event): Watched.Action - - /** - * Transforms the state after the watch terminates. - * @param action the [[Watched.Action Action]] that caused the build to terminate - * @param command the command that the watch was repeating - * @param state the initial state prior to the start of continuous execution - * @return the updated state. - */ - def onWatchTerminated(action: Watched.Action, command: String, state: State): State - - /** - * The optional message to log when a build is triggered. - * @param path the path that triggered the vuild - * @param count the current iteration - * @return an optional log message. - */ - def triggeredMessage(path: Path, count: Int): Option[String] - - /** - * The optional message to log before each watch iteration. - * @param count the current iteration - * @return an optional log message. - */ - def watchingMessage(count: Int): Option[String] -} - -/** - * Provides a default implementation of [[WatchConfig]]. - */ -object WatchConfig { - - /** - * Create an instance of [[WatchConfig]]. - * @param logger logger for watch events - * @param fileEventMonitor the monitor for file system events. - * @param handleInput callback that is periodically invoked to check whether to continue or - * terminate the watch based on user input. It is also possible to, for - * example time out the watch using this callback. - * @param preWatch callback to invoke before waiting for updates from the sbt.io.FileEventMonitor. - * The input parameters are the current iteration count and whether or not - * the last invocation of the command was successful. Typical uses would be to - * terminate the watch after a fixed number of iterations or to terminate the - * watch if the command was unsuccessful. - * @param onWatchEvent callback that is invoked when - * @param onWatchTerminated callback that is invoked to update the state after the watch - * terminates. - * @param triggeredMessage optional message that will be logged when a new build is triggered. - * The input parameters are the sbt.io.TypedPath that triggered the new - * build and the current iteration count. - * @param watchingMessage optional message that is printed before each watch iteration begins. - * The input parameter is the current iteration count. - * @return a [[WatchConfig]] instance. - */ - def default( - logger: Logger, - fileEventMonitor: FileEventMonitor[FileAttributes], - handleInput: InputStream => Watched.Action, - preWatch: (Int, Boolean) => Watched.Action, - onWatchEvent: FileAttributes.Event => Watched.Action, - onWatchTerminated: (Watched.Action, String, State) => State, - triggeredMessage: (Path, Int) => Option[String], - watchingMessage: Int => Option[String] - ): WatchConfig = { - val l = logger - val fem = fileEventMonitor - val hi = handleInput - val pw = preWatch - val owe = onWatchEvent - val owt = onWatchTerminated - val tm = triggeredMessage - val wm = watchingMessage - new WatchConfig { - override def logger: Logger = l - override def fileEventMonitor: FileEventMonitor[FileAttributes] = fem - override def handleInput(inputStream: InputStream): Watched.Action = hi(inputStream) - override def preWatch(count: Int, lastResult: Boolean): Watched.Action = - pw(count, lastResult) - override def onWatchEvent(event: FileAttributes.Event): Watched.Action = owe(event) - override def onWatchTerminated(action: Watched.Action, command: String, state: State): State = - owt(action, command, state) - override def triggeredMessage(path: Path, count: Int): Option[String] = - tm(path, count) - override def watchingMessage(count: Int): Option[String] = wm(count) - } - } + @deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0") + val defaultWatchingMessage: WatchState => String = + ((ws: WatchState) => defaultStartWatch(ws.count).get).label("Watched.defaultWatchingMessage") + @deprecated( + "Use defaultOnTriggerMessage in conjunction with the watchTriggeredMessage key", + "1.3.0" + ) + val defaultTriggeredMessage: WatchState => String = + const("").label("Watched.defaultTriggeredMessage") + @deprecated("Use clearOnTrigger in conjunction with the watchTriggeredMessage key", "1.3.0") + val clearWhenTriggered: WatchState => String = + const(clearScreen).label("Watched.clearWhenTriggered") } diff --git a/main-command/src/test/scala/sbt/MultiParserSpec.scala b/main-command/src/test/scala/sbt/MultiParserSpec.scala index 106490cbd..cb2962399 100644 --- a/main-command/src/test/scala/sbt/MultiParserSpec.scala +++ b/main-command/src/test/scala/sbt/MultiParserSpec.scala @@ -17,7 +17,7 @@ object MultiParserSpec { def parseEither: Either[String, Seq[String]] = Parser.parse(s, parser) } } -import MultiParserSpec._ +import sbt.MultiParserSpec._ class MultiParserSpec extends FlatSpec with Matchers { "parsing" should "parse single commands" in { ";foo".parse shouldBe Seq("foo") diff --git a/main-command/src/test/scala/sbt/WatchedSpec.scala b/main-command/src/test/scala/sbt/WatchedSpec.scala index ed1170fa5..538321c9a 100644 --- a/main-command/src/test/scala/sbt/WatchedSpec.scala +++ b/main-command/src/test/scala/sbt/WatchedSpec.scala @@ -9,12 +9,13 @@ package sbt import java.io.{ File, InputStream } import java.nio.file.{ Files, Path } -import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger } import org.scalatest.{ FlatSpec, Matchers } import sbt.Watched._ import sbt.WatchedSpec._ import sbt.internal.FileAttributes +import sbt.io.FileEventMonitor.Event import sbt.io._ import sbt.io.syntax._ import sbt.util.Logger @@ -23,59 +24,84 @@ import scala.collection.mutable import scala.concurrent.duration._ class WatchedSpec extends FlatSpec with Matchers { - object Defaults { - def config( - globs: Seq[Glob], + private type NextAction = () => Watched.Action + private def watch(task: Task, callbacks: (NextAction, NextAction)): Watched.Action = + Watched.watch(task, callbacks._1, callbacks._2) + object TestDefaults { + def callbacks( + inputs: Seq[Glob], fileEventMonitor: Option[FileEventMonitor[FileAttributes]] = None, logger: Logger = NullLogger, - handleInput: InputStream => Action = _ => Ignore, - preWatch: (Int, Boolean) => Action = (_, _) => CancelWatch, - onWatchEvent: FileAttributes.Event => Action = _ => Ignore, - triggeredMessage: (Path, Int) => Option[String] = (_, _) => None, - watchingMessage: Int => Option[String] = _ => None - ): WatchConfig = { + parseEvent: () => Action = () => Ignore, + onStartWatch: () => Action = () => CancelWatch: Action, + onWatchEvent: Event[FileAttributes] => Action = _ => Ignore, + triggeredMessage: Event[FileAttributes] => Option[String] = _ => None, + watchingMessage: () => Option[String] = () => None + ): (NextAction, NextAction) = { val monitor = fileEventMonitor.getOrElse { val fileTreeRepository = FileTreeRepository.default(FileAttributes.default) - globs.foreach(fileTreeRepository.register) - FileEventMonitor.antiEntropy( - fileTreeRepository, - 50.millis, - m => logger.debug(m.toString), - 50.milliseconds, - 100.milliseconds - ) + inputs.foreach(fileTreeRepository.register) + val m = + FileEventMonitor.antiEntropy( + fileTreeRepository, + 50.millis, + m => logger.debug(m.toString), + 50.millis, + 10.minutes + ) + new FileEventMonitor[FileAttributes] { + override def poll(duration: Duration): Seq[Event[FileAttributes]] = m.poll(duration) + override def close(): Unit = m.close() + } } - WatchConfig.default( - logger = logger, - monitor, - handleInput, - preWatch, - onWatchEvent, - (_, _, state) => state, - triggeredMessage, - watchingMessage - ) + val onTrigger: Event[FileAttributes] => Unit = event => { + triggeredMessage(event).foreach(logger.info(_)) + } + val onStart: () => Watched.Action = () => { + watchingMessage().foreach(logger.info(_)) + onStartWatch() + } + val nextAction: NextAction = () => { + val inputAction = parseEvent() + val fileActions = monitor.poll(10.millis).map { e: Event[FileAttributes] => + onWatchEvent(e) match { + case Trigger => onTrigger(e); Trigger + case act => act + } + } + (inputAction +: fileActions).min + } + (onStart, nextAction) } } object NullInputStream extends InputStream { override def available(): Int = 0 override def read(): Int = -1 } + private class Task extends (() => Unit) { + private val count = new AtomicInteger(0) + override def apply(): Unit = { + count.incrementAndGet() + () + } + def getCount: Int = count.get() + } "Watched.watch" should "stop" in IO.withTemporaryDirectory { dir => - val config = Defaults.config(globs = Seq(dir.toRealPath.toGlob)) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + val task = new Task + watch(task, TestDefaults.callbacks(inputs = Seq(dir.toRealPath ** AllPassFilter))) shouldBe CancelWatch } it should "trigger" in IO.withTemporaryDirectory { dir => val triggered = new AtomicBoolean(false) - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (count, _) => if (count == 2) CancelWatch else Ignore, + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(dir.toRealPath ** AllPassFilter), + onStartWatch = () => if (task.getCount == 2) CancelWatch else Ignore, onWatchEvent = _ => { triggered.set(true); Trigger }, - watchingMessage = _ => { + watchingMessage = () => { new File(dir, "file").createNewFile; None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch assert(triggered.get()) } it should "filter events" in IO.withTemporaryDirectory { dir => @@ -83,28 +109,33 @@ class WatchedSpec extends FlatSpec with Matchers { val queue = new mutable.Queue[Path] val foo = realDir.toPath.resolve("foo") val bar = realDir.toPath.resolve("bar") - val config = Defaults.config( - globs = Seq(realDir ** AllPassFilter), - preWatch = (count, _) => if (count == 2) CancelWatch else Ignore, - onWatchEvent = e => if (e.path == foo) Trigger else Ignore, - triggeredMessage = (tp, _) => { queue += tp; None }, - watchingMessage = _ => { Files.createFile(bar); Thread.sleep(5); Files.createFile(foo); None } + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(realDir ** AllPassFilter), + onStartWatch = () => if (task.getCount == 2) CancelWatch else Ignore, + onWatchEvent = e => if (e.entry.typedPath.toPath == foo) Trigger else Ignore, + triggeredMessage = e => { queue += e.entry.typedPath.toPath; None }, + watchingMessage = () => { + IO.touch(bar.toFile); Thread.sleep(5); IO.touch(foo.toFile) + None + } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch queue.toIndexedSeq shouldBe Seq(foo) } it should "enforce anti-entropy" in IO.withTemporaryDirectory { dir => - val realDir = dir.toRealPath.toPath + val realDir = dir.toRealPath val queue = new mutable.Queue[Path] - val foo = realDir.resolve("foo") - val bar = realDir.resolve("bar") - val config = Defaults.config( - globs = Seq(realDir ** AllPassFilter), - preWatch = (count, _) => if (count == 3) CancelWatch else Ignore, - onWatchEvent = e => if (e.path != realDir) Trigger else Ignore, - triggeredMessage = (tp, _) => { queue += tp; None }, - watchingMessage = count => { - count match { + val foo = realDir.toPath.resolve("foo") + val bar = realDir.toPath.resolve("bar") + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(realDir ** AllPassFilter), + onStartWatch = () => if (task.getCount == 3) CancelWatch else Ignore, + onWatchEvent = _ => Trigger, + triggeredMessage = e => { queue += e.entry.typedPath.toPath; None }, + watchingMessage = () => { + task.getCount match { case 1 => Files.createFile(bar) case 2 => bar.toFile.setLastModified(5000) @@ -114,26 +145,26 @@ class WatchedSpec extends FlatSpec with Matchers { None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch + watch(task, callbacks) shouldBe CancelWatch queue.toIndexedSeq shouldBe Seq(bar, foo) } it should "halt on error" in IO.withTemporaryDirectory { dir => - val halted = new AtomicBoolean(false) - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (_, lastStatus) => if (lastStatus) Ignore else { halted.set(true); HandleError } + val exception = new IllegalStateException("halt") + val task = new Task { override def apply(): Unit = throw exception } + val callbacks = TestDefaults.callbacks( + Seq(dir.toRealPath ** AllPassFilter), ) - Watched.watch(NullInputStream, () => Right(false), config) shouldBe HandleError - assert(halted.get()) + watch(task, callbacks) shouldBe new HandleError(exception) } it should "reload" in IO.withTemporaryDirectory { dir => - val config = Defaults.config( - globs = Seq(dir.toRealPath ** AllPassFilter), - preWatch = (_, _) => Ignore, + val task = new Task + val callbacks = TestDefaults.callbacks( + inputs = Seq(dir.toRealPath ** AllPassFilter), + onStartWatch = () => Ignore, onWatchEvent = _ => Reload, - watchingMessage = _ => { new File(dir, "file").createNewFile(); None } + watchingMessage = () => { new File(dir, "file").createNewFile(); None } ) - Watched.watch(NullInputStream, () => Right(true), config) shouldBe Reload + watch(task, callbacks) shouldBe Reload } } diff --git a/main-settings/src/main/scala/sbt/Append.scala b/main-settings/src/main/scala/sbt/Append.scala index ce77805dc..6176fa9b3 100644 --- a/main-settings/src/main/scala/sbt/Append.scala +++ b/main-settings/src/main/scala/sbt/Append.scala @@ -100,7 +100,16 @@ object Append { new Sequence[Seq[Source], Seq[File], File] { def appendValue(a: Seq[Source], b: File): Seq[Source] = appendValues(a, Seq(b)) def appendValues(a: Seq[Source], b: Seq[File]): Seq[Source] = - a ++ b.map(new Source(_, AllPassFilter, NothingFilter)) + a ++ b.map { f => + // Globs only accept their own base if the depth parameter is set to -1. The conversion + // from Source to Glob never sets the depth to -1, which causes individual files + // added via `watchSource += ...` to not trigger a build when they are modified. Since + // watchSources will be deprecated in 1.3.0, I'm hoping that most people will migrate + // their builds to the new system, but this will work for most builds in the interim. + if (f.isFile && f.getParentFile != null) + new Source(f.getParentFile, f.getName, NothingFilter, recursive = false) + else new Source(f, AllPassFilter, NothingFilter) + } } // Implemented with SAM conversion short-hand diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 4b0a9e030..516f4d919 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -9,7 +9,6 @@ package sbt import java.io.{ File, PrintWriter } import java.net.{ URI, URL } -import java.nio.file.{ Path => NioPath } import java.util.Optional import java.util.concurrent.{ Callable, TimeUnit } @@ -232,6 +231,14 @@ object Defaults extends BuildCommon { outputStrategy :== None, // TODO - This might belong elsewhere. buildStructure := Project.structure(state.value), settingsData := buildStructure.value.data, + settingsData / fileInputs := { + val baseDir = file(".").getCanonicalFile + val sourceFilter = ("*.sbt" || "*.scala" || "*.java") -- HiddenFileFilter + Seq( + Glob(baseDir, "*.sbt" -- HiddenFileFilter, 0), + Glob(baseDir / "project", sourceFilter, Int.MaxValue) + ) + }, trapExit :== true, connectInput :== false, cancelable :== false, @@ -246,8 +253,6 @@ object Defaults extends BuildCommon { // The idea here is to be able to define a `sbtVersion in pluginCrossBuild`, which // directs the dependencies of the plugin to build to the specified sbt plugin version. sbtVersion in pluginCrossBuild := sbtVersion.value, - watchingMessage := Watched.defaultWatchingMessage, - triggeredMessage := Watched.defaultTriggeredMessage, onLoad := idFun[State], onUnload := idFun[State], onUnload := { s => @@ -258,8 +263,7 @@ object Defaults extends BuildCommon { Nil }, pollingGlobs :== Nil, - watchSources :== Nil, - watchProjectSources :== Nil, + watchSources :== Nil, // Although this is deprecated, it can't be removed or it breaks += for legacy builds. skip :== false, taskTemporaryDirectory := { val dir = IO.createTemporaryDirectory; dir.deleteOnExit(); dir }, onComplete := { @@ -284,22 +288,16 @@ object Defaults extends BuildCommon { Previous.references :== new Previous.References, concurrentRestrictions := defaultRestrictions.value, parallelExecution :== true, - pollInterval :== new FiniteDuration(500, TimeUnit.MILLISECONDS), - watchTriggeredMessage := { (_, _) => - None - }, - watchStartMessage := Watched.defaultStartWatch, - fileTreeRepository := FileTree.Repository.polling, + fileTreeRepository := + FileTree.repository(state.value.get(Keys.globalFileTreeRepository) match { + case Some(r) => r + case None => FileTreeView.DEFAULT.asDataView(FileAttributes.default) + }), externalHooks := { val repository = fileTreeRepository.value compileOptions => Some(ExternalHooks(compileOptions, repository)) }, - watchAntiEntropy :== new FiniteDuration(500, TimeUnit.MILLISECONDS), - watchLogger := streams.value.log, - watchService :== { () => - Watched.createWatchService() - }, logBuffered :== false, commands :== Nil, showSuccess :== true, @@ -334,6 +332,22 @@ object Defaults extends BuildCommon { }, insideCI :== sys.env.contains("BUILD_NUMBER") || sys.env.contains("CI") || System.getProperty("sbt.ci", "false") == "true", + // watch related settings + pollInterval :== Watched.defaultPollInterval, + watchAntiEntropy :== Watched.defaultAntiEntropy, + watchAntiEntropyRetentionPeriod :== Watched.defaultAntiEntropyRetentionPeriod, + watchLogLevel :== Level.Info, + watchOnEnter :== Watched.defaultOnEnter, + watchOnMetaBuildEvent :== Watched.ifChanged(Watched.Reload), + watchOnInputEvent :== Watched.trigger, + watchOnTriggerEvent :== Watched.trigger, + watchDeletionQuarantinePeriod :== Watched.defaultDeletionQuarantinePeriod, + watchService :== Watched.newWatchService, + watchStartMessage :== Watched.defaultStartWatch, + watchTasks := Continuous.continuousTask.evaluated, + aggregate in watchTasks :== false, + watchTrackMetaBuild :== true, + watchTriggeredMessage :== Watched.defaultOnTriggerMessage, ) ) @@ -390,25 +404,7 @@ object Defaults extends BuildCommon { val baseSources = if (sourcesInBase.value) baseDirectory.value * filter :: Nil else Nil unmanagedSourceDirectories.value.map(_ ** filter) ++ baseSources }, - unmanagedSources / fileInputs += baseDirectory.value * "foo.txt", unmanagedSources := (unmanagedSources / fileInputs).value.all.map(Stamped.file), - watchSources in ConfigGlobal := (watchSources in ConfigGlobal).value ++ { - val baseDir = baseDirectory.value - val bases = unmanagedSourceDirectories.value - val include = (includeFilter in unmanagedSources).value - val exclude = (excludeFilter in unmanagedSources).value - val baseSources = - if (sourcesInBase.value) Seq(new Source(baseDir, include, exclude, recursive = false)) - else Nil - bases.map(b => new Source(b, include, exclude)) ++ baseSources - }, - watchProjectSources in ConfigGlobal := (watchProjectSources in ConfigGlobal).value ++ { - val baseDir = baseDirectory.value - Seq( - new Source(baseDir, "*.sbt", HiddenFileFilter, recursive = false), - new Source(baseDir / "project", "*.sbt" || "*.scala", HiddenFileFilter, recursive = true) - ) - }, managedSourceDirectories := Seq(sourceManaged.value), managedSources := generate(sourceGenerators).value, sourceGenerators :== Nil, @@ -432,12 +428,6 @@ object Defaults extends BuildCommon { unmanagedResourceDirectories.value.map(_ ** filter) }, unmanagedResources := (unmanagedResources / fileInputs).value.all.map(Stamped.file), - watchSources in ConfigGlobal := (watchSources in ConfigGlobal).value ++ { - val bases = unmanagedResourceDirectories.value - val include = (includeFilter in unmanagedResources).value - val exclude = (excludeFilter in unmanagedResources).value - bases.map(b => new Source(b, include, exclude)) - }, resourceGenerators :== Nil, resourceGenerators += Def.task { PluginDiscovery.writeDescriptors(discoveredSbtPlugins.value, resourceManaged.value) @@ -634,40 +624,6 @@ object Defaults extends BuildCommon { clean := Clean.taskIn(ThisScope).value, consoleProject := consoleProjectTask.value, watchTransitiveSources := watchTransitiveSourcesTask.value, - watchProjectTransitiveSources := watchTransitiveSourcesTaskImpl(watchProjectSources).value, - watchOnEvent := Watched - .onEvent(watchTransitiveSources.value, watchProjectTransitiveSources.value), - watchHandleInput := Watched.handleInput, - watchPreWatch := { (_, _) => - Watched.Ignore - }, - watchOnTermination := Watched.onTermination, - watchConfig := { - val sources = watchTransitiveSources.value ++ watchProjectTransitiveSources.value - val globs = sources.map( - s => Glob(s.base, s.includeFilter -- s.excludeFilter, if (s.recursive) Int.MaxValue else 0) - ) - val wm = watchingMessage.?.value - .map(w => (count: Int) => Some(w(WatchState.empty(globs).withCount(count)))) - .getOrElse(watchStartMessage.value) - val tm = triggeredMessage.?.value - .map(tm => (_: NioPath, count: Int) => Some(tm(WatchState.empty(globs).withCount(count)))) - .getOrElse(watchTriggeredMessage.value) - val logger = watchLogger.value - val repo = FileManagement.toMonitoringRepository(FileManagement.repo.value) - globs.foreach(repo.register) - val monitor = FileManagement.monitor(repo, watchAntiEntropy.value, logger) - WatchConfig.default( - logger, - monitor, - watchHandleInput.value, - watchPreWatch.value, - watchOnEvent.value, - watchOnTermination.value, - tm, - wm - ) - }, watchStartMessage := Watched.projectOnWatchMessage(thisProjectRef.value.project), watch := watchSetting.value, fileOutputs += target.value ** AllPassFilter, @@ -679,6 +635,10 @@ object Defaults extends BuildCommon { def generate(generators: SettingKey[Seq[Task[Seq[File]]]]): Initialize[Task[Seq[File]]] = generators { _.join.map(_.flatten) } + @deprecated( + "The watchTransitiveSourcesTask is used only for legacy builds and will be removed in a future version of sbt.", + "1.3.0" + ) def watchTransitiveSourcesTask: Initialize[Task[Seq[Source]]] = watchTransitiveSourcesTaskImpl(watchSources) @@ -706,8 +666,8 @@ object Defaults extends BuildCommon { val interval = pollInterval.value val _antiEntropy = watchAntiEntropy.value val base = thisProjectRef.value - val msg = watchingMessage.value - val trigMsg = triggeredMessage.value + val msg = watchingMessage.?.value.getOrElse(Watched.defaultWatchingMessage) + val trigMsg = triggeredMessage.?.value.getOrElse(Watched.defaultTriggeredMessage) new Watched { val scoped = watchTransitiveSources in base val key = scoped.scopedKey diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 05e1d1d8a..0d78d65fc 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -9,7 +9,6 @@ package sbt import java.io.{ File, InputStream } import java.net.URL -import java.nio.file.Path import org.apache.ivy.core.module.descriptor.ModuleDescriptor import org.apache.ivy.core.module.id.ModuleRevisionId @@ -22,7 +21,9 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.io.WatchState import sbt.internal.librarymanagement.{ CompatibilityWarningOptions, IvySbt } import sbt.internal.server.ServerHandler +import sbt.internal.util.complete.Parser import sbt.internal.util.{ AttributeKey, SourcePosition } +import sbt.io.FileEventMonitor.Event import sbt.io._ import sbt.librarymanagement.Configurations.CompilerPlugin import sbt.librarymanagement.LibraryManagementCodec._ @@ -90,27 +91,42 @@ object Keys { val serverHandlers = settingKey[Seq[ServerHandler]]("User-defined server handlers.") val analysis = AttributeKey[CompileAnalysis]("analysis", "Analysis of compilation, including dependencies and generated outputs.", DSetting) - @deprecated("This is no longer used for continuous execution", "1.3.0") - val watch = SettingKey(BasicKeys.watch) val suppressSbtShellNotification = settingKey[Boolean]("""True to suppress the "Executing in batch mode.." message.""").withRank(CSetting) val enableGlobalCachingFileTreeRepository = settingKey[Boolean]("Toggles whether or not to create a global cache of the file system that can be used by tasks to quickly list a path").withRank(DSetting) - val fileTreeRepository = taskKey[FileTree.Repository]("A repository of the file system.") + val fileTreeRepository = taskKey[FileTree.Repository]("A repository of the file system.").withRank(DSetting) val pollInterval = settingKey[FiniteDuration]("Interval between checks for modified sources by the continuous execution command.").withRank(BMinusSetting) val pollingGlobs = settingKey[Seq[Glob]]("Directories that cannot be cached and must always be rescanned. Typically these will be NFS mounted or something similar.").withRank(DSetting) val watchAntiEntropy = settingKey[FiniteDuration]("Duration for which the watch EventMonitor will ignore events for a file after that file has triggered a build.").withRank(BMinusSetting) - val watchConfig = taskKey[WatchConfig]("The configuration for continuous execution.").withRank(BMinusSetting) - val watchLogger = taskKey[Logger]("A logger that reports watch events.").withRank(DSetting) - val watchHandleInput = settingKey[InputStream => Watched.Action]("Function that is periodically invoked to determine if the continous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands.").withRank(BMinusSetting) - val watchOnEvent = taskKey[FileAttributes.Event => Watched.Action]("Determines how to handle a file event").withRank(BMinusSetting) - val watchOnTermination = taskKey[(Watched.Action, String, State) => State]("Transforms the input state after the continuous build completes.").withRank(BMinusSetting) - val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting) - val watchProjectSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources for the sbt meta project to watch to trigger a reload.").withRank(CSetting) - val watchProjectTransitiveSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in all projects for the sbt meta project to watch to trigger a reload.").withRank(CSetting) - val watchPreWatch = settingKey[(Int, Boolean) => Watched.Action]("Function that may terminate a continuous build based on the number of iterations and the last result").withRank(BMinusSetting) - val watchSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in this project for continuous execution to watch for changes.").withRank(BMinusSetting) + val watchAntiEntropyRetentionPeriod = settingKey[FiniteDuration]("Wall clock Duration for which a FileEventMonitor will store anti-entropy events. This prevents spurious triggers when a task takes a long time to run. Higher values will consume more memory but make spurious triggers less likely.").withRank(BMinusSetting) + val watchDeletionQuarantinePeriod = settingKey[FiniteDuration]("Period for which deletion events will be quarantined. This is to prevent spurious builds when a file is updated with a rename which manifests as a file deletion followed by a file creation. The higher this value is set, the longer the delay will be between a file deletion and a build trigger but the less likely it is for a spurious trigger.").withRank(DSetting) + val watchLogLevel = settingKey[sbt.util.Level.Value]("Transform the default logger in continuous builds.").withRank(DSetting) + val watchInputHandler = settingKey[InputStream => Watched.Action]("Function that is periodically invoked to determine if the continuous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands. This is only invoked if watchInputStream is set.").withRank(DSetting) + val watchInputStream = taskKey[InputStream]("The input stream to read for user input events. This will usually be System.in").withRank(DSetting) + val watchInputParser = settingKey[Parser[Watched.Action]]("A parser of user input that can be used to trigger or exit a continuous build").withRank(DSetting) + val watchOnEnter = settingKey[() => Unit]("Function to run prior to beginning a continuous build. This will run before the continuous task(s) is(are) first evaluated.").withRank(DSetting) + val watchOnExit = settingKey[() => Unit]("Function to run upon exit of a continuous build. It can be used to cleanup resources used during the watch.").withRank(DSetting) + val watchOnInputEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the transitive inputs. This is only invoked if watchOnEvent is not explicitly set.").withRank(DSetting) + val watchOnEvent = settingKey[Continuous.Arguments => Event[FileAttributes] => Watched.Action]("Determines how to handle a file event. The Seq[Glob] contains all of the transitive inputs for the task(s) being run by the continuous build.").withRank(DSetting) + val watchOnMetaBuildEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the meta build triggers.").withRank(DSetting) + val watchOnTermination = settingKey[(Watched.Action, String, Int, State) => State]("Transforms the state upon completion of a watch. The String argument is the command that was run during the watch. The Int parameter specifies how many times the command was run during the watch.").withRank(DSetting) + val watchOnTrigger = settingKey[Continuous.Arguments => Event[FileAttributes] => Unit]("Callback to invoke when a continuous build triggers. The first parameter is the number of previous watch task invocations. The second parameter is the Event that triggered this build").withRank(DSetting) + val watchOnTriggerEvent = settingKey[(Int, Event[FileAttributes]) => Watched.Action]("Callback to invoke if an event is triggered in a continuous build by one of the transitive triggers. This is only invoked if watchOnEvent is not explicitly set.").withRank(DSetting) + val watchOnIteration = settingKey[Int => Watched.Action]("Function that is invoked before waiting for file system events or user input events. This is only invoked if watchOnStart is not explicitly set.").withRank(DSetting) + val watchOnStart = settingKey[Continuous.Arguments => () => Watched.Action]("Function is invoked before waiting for file system or input events. The returned Action is used to either trigger the build, terminate the watch or wait for events.").withRank(DSetting) + val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting).withRank(DSetting) val watchStartMessage = settingKey[Int => Option[String]]("The message to show when triggered execution waits for sources to change. The parameter is the current watch iteration count.").withRank(DSetting) + // The watchTasks key should really be named watch, but that is already taken by the deprecated watch key. I'd be surprised if there are any plugins that use it so I think we should consider breaking binary compatibility to rename this task. + val watchTasks = InputKey[State]("watch", "Watch a task (or multiple tasks) and rebuild when its file inputs change or user input is received. The semantics are more or less the same as the `~` command except that it cannot transform the state on exit. This means that it cannot be used to reload the build.").withRank(DSetting) + val watchTrackMetaBuild = settingKey[Boolean]("Toggles whether or not changing the build files (e.g. **/*.sbt, project/**/(*.scala | *.java)) should automatically trigger a project reload").withRank(DSetting) + val watchTriggeredMessage = settingKey[(Int, Event[FileAttributes]) => Option[String]]("The message to show before triggered execution executes an action after sources change. The parameters are the path that triggered the build and the current watch iteration count.").withRank(DSetting) + + // Deprecated watch apis + @deprecated("This is no longer used for continuous execution", "1.3.0") + val watch = SettingKey(BasicKeys.watch) + @deprecated("WatchSource has been replaced by Glob. To add file triggers to a task with key: Key, set `Key / watchTriggers := Seq[Glob](...)`.", "1.3.0") + val watchSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in this project for continuous execution to watch for changes.").withRank(BMinusSetting) + @deprecated("This is for legacy builds only and will be removed in a future version of sbt", "1.3.0") val watchTransitiveSources = taskKey[Seq[Watched.WatchSource]]("Defines the sources in all projects for continuous execution to watch.").withRank(CSetting) - val watchTriggeredMessage = settingKey[(Path, Int) => Option[String]]("The message to show before triggered execution executes an action after sources change. The parameters are the path that triggered the build and the current watch iteration count.").withRank(DSetting) @deprecated("Use watchStartMessage instead", "1.3.0") val watchingMessage = settingKey[WatchState => String]("The message to show when triggered execution waits for sources to change.").withRank(DSetting) @deprecated("Use watchTriggeredMessage instead", "1.3.0") diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index edb3e180f..1a2856a73 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -22,7 +22,7 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.util.Types.{ const, idFun } import sbt.internal.util._ import sbt.internal.util.complete.Parser -import sbt.io.IO +import sbt.io._ import sbt.io.syntax._ import sbt.util.{ Level, Logger, Show } import xsbti.compile.CompilerCache @@ -423,13 +423,7 @@ object BuiltinCommands { s } - def continuous: Command = Watched.continuous { (state: State, command: String) => - val extracted = Project.extract(state) - val (s, watchConfig) = extracted.runTask(Keys.watchConfig, state) - val updateState = - (runCommand: () => State) => MainLoop.processCommand(Exec(command, None), s, runCommand) - (s, watchConfig, updateState) - } + def continuous: Command = Continuous.continuous private[this] def loadedEval(s: State, arg: String): Unit = { val extracted = Project extract s diff --git a/main/src/main/scala/sbt/ScriptedPlugin.scala b/main/src/main/scala/sbt/ScriptedPlugin.scala index 229f7e1bf..f3c4163dc 100644 --- a/main/src/main/scala/sbt/ScriptedPlugin.scala +++ b/main/src/main/scala/sbt/ScriptedPlugin.scala @@ -88,7 +88,8 @@ object ScriptedPlugin extends AutoPlugin { val pub = (publishLocal).value use(analysis, pub) }, - scripted := scriptedTask.evaluated + scripted := scriptedTask.evaluated, + watchTriggers in scripted += sbtTestDirectory.value ** AllPassFilter ) private[sbt] def scriptedTestsTask: Initialize[Task[AnyRef]] = diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala new file mode 100644 index 000000000..277fa968b --- /dev/null +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -0,0 +1,873 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import java.io.{ ByteArrayInputStream, InputStream } +import java.util.concurrent.atomic.AtomicInteger + +import sbt.BasicCommandStrings.{ + ContinuousExecutePrefix, + FailureWall, + continuousBriefHelp, + continuousDetail +} +import sbt.BasicCommands.otherCommandParser +import sbt.Def._ +import sbt.Scope.Global +import sbt.Watched.Monitor +import sbt.internal.FileManagement.FileTreeRepositoryOps +import sbt.internal.io.WatchState +import sbt.internal.util.Types.const +import sbt.internal.util.complete.Parser._ +import sbt.internal.util.complete.{ Parser, Parsers } +import sbt.internal.util.{ AttributeKey, AttributeMap } +import sbt.io._ +import sbt.util.{ Level, _ } + +import scala.annotation.tailrec +import scala.concurrent.duration.FiniteDuration.FiniteDurationIsOrdered +import scala.concurrent.duration._ +import scala.util.Try + +/** + * Provides the implementation of the `~` command and `watch` task. The implementation is quite + * complex because we have to parse the command string to figure out which tasks we want to run. + * Using the tasks, we then have to extract all of the settings for the continuous build. Finally + * we have to aggregate the settings for each task into an aggregated watch config that will + * sanely watch multiple tasks and respond to file updates and user input in a way that makes + * sense for each of the tasks that are being monitored. + * + * The behavior, on the other hand, should be fairly straightforward. For example, if a user + * wants to continuously run the compile task for projects a and b, then we create FileEventMonitor + * instances for each product and watch all of the directories that contain compile sources + * (as well as the source directories of transitive inter-project classpath dependencies). If + * a change is detected in project a, then we should trigger a build for both projects a and b. + * + * The semantics are flexible and may be adapted. For example, a user may want to watch two + * unrelated tasks and only rebuild the task with sources that have been changed. This could be + * handled at the `~` level, but it probably makes more sense to build a better task caching + * system so that we don't rerun tasks if their inputs have not changed. As of 1.3.0, the + * semantics match previous sbt versions as closely as possible while allowing the user more + * freedom to adjust the behavior to best suit their use cases. + * + * For now Continuous extends DeprecatedContinuous to minimize the number of deprecation warnings + * produced by this file. In sbt 2.0, the DeprecatedContinuous mixin should be eliminated and + * the deprecated apis should no longer be supported. + * + */ +object Continuous extends DeprecatedContinuous { + + /** + * Provides the dynamic inputs to the continuous build callbacks that cannot be stored as + * settings. This wouldn't need to exist if there was a notion of a lazy setting in sbt. + * @param logger the Logger + * @param inputs the transitive task inputs + * @param triggers the transitive task triggers + */ + final class Arguments private[Continuous] ( + val logger: Logger, + val inputs: Seq[Glob], + val triggers: Seq[Glob] + ) + + /** + * Provides a copy of System.in that can be scanned independently from System.in itself. This task + * will only be valid during a continuous build started via `~` or the `watch` task. The + * motivation is that a plugin may want to completely override the parsing of System.in which + * is not straightforward since the default implementation is hard-wired to read from and + * parse System.in. If an invalid parser is provided by [[Keys.watchInputParser]] and + * [[Keys.watchInputStream]] is set to this task, then a custom parser can be provided via + * [[Keys.watchInputHandler]] and the default System.in processing will not occur. + * + * @return the duplicated System.in + */ + def dupedSystemIn: Def.Initialize[Task[InputStream]] = Def.task { + Keys.state.value.get(DupedSystemIn).map(_.duped).getOrElse(System.in) + } + + /** + * Create a function from InputStream => [[Watched.Action]] from a [[Parser]]. This is intended + * to be used to set the watchInputHandler setting for a task. + * @param parser the parser + * @return the function + */ + def defaultInputHandler(parser: Parser[Watched.Action]): InputStream => Watched.Action = { + val builder = new StringBuilder + val any = matched(Parsers.any.*) + val fullParser = any ~> parser ~ any + inputStream => + parse(inputStream, builder, fullParser) + } + + /** + * Implements continuous execution. It works by first parsing the command and generating a task to + * run with each build. It can run multiple commands that are separated by ";" in the command + * input. If any of these commands are invalid, the watch will immediately exit. + * @return a Command that can be used by sbt to implement continuous builds. + */ + private[sbt] def continuous: Command = + Command(ContinuousExecutePrefix, continuousBriefHelp, continuousDetail)(continuousParser) { + case (state, (initialCount, command)) => + runToTermination(state, command, initialCount, isCommand = true) + } + + /** + * The task implementation is quite similar to the command implementation. The tricky part is that + * we have to modify the Task.info to apply the state transformation after the task completes. + * @return the [[InputTask]] + */ + private[sbt] def continuousTask: Def.Initialize[InputTask[State]] = + Def.inputTask { + val (initialCount, command) = continuousParser.parsed + runToTermination(Keys.state.value, command, initialCount, isCommand = false) + }(_.mapTask { t => + val postTransform = t.info.postTransform { + case (state: State, am: AttributeMap) => am.put(Keys.transformState, const(state)) + } + Task(postTransform, t.work) + }) + + private[this] val DupedSystemIn = + AttributeKey[DupedInputStream]( + "duped-system-in", + "Receives a copy of all of the bytes from System.in.", + 10000 + ) + + private[this] val continuousParser: State => Parser[(Int, String)] = { + def toInt(s: String): Int = Try(s.toInt).getOrElse(0) + // This allows us to re-enter the watch with the previous count. + val digitParser: Parser[Int] = + (Parsers.Space.* ~> matched(Parsers.Digit.+) <~ Parsers.Space.*).map(toInt) + state => + val ocp = otherCommandParser(state) + (digitParser.? ~ ocp).map { case (i, s) => (i.getOrElse(0), s) } + } + + /** + * Gets the [[Config]] necessary to watch a task. It will extract the internal dependency + * configurations for the task (these are the classpath dependencies specified by + * [[Project.dependsOn]]). Using these configurations and the settings map, it walks the + * dependency graph for the key and extracts all of the transitive globs specified by the + * inputs and triggers keys. It also extracts the legacy globs specified by the watchSources key. + * + * @param state the current [[State]] instance. + * @param scopedKey the [[ScopedKey]] instance corresponding to the task we're monitoring + * @param compiledMap the map of all of the build settings + * @param extracted the [[Extracted]] instance for the build + * @param logger a logger that can be used while generating the [[Config]] + * @return the [[Config]] instance + */ + private def getConfig( + state: State, + scopedKey: ScopedKey[_], + compiledMap: CompiledMap, + )(implicit extracted: Extracted, logger: Logger): Config = { + + // Extract all of the globs that we will monitor during the continuous build. + val (inputs, triggers) = { + val configs = scopedKey.get(Keys.internalDependencyConfigurations).getOrElse(Nil) + val args = new InputGraph.Arguments(scopedKey, extracted, compiledMap, logger, configs, state) + InputGraph.transitiveGlobs(args) + } match { + case (i: Seq[Glob], t: Seq[Glob]) => (i.distinct.sorted, t.distinct.sorted) + } + + val repository = getRepository(state) + (inputs ++ triggers).foreach(repository.register) + val watchSettings = new WatchSettings(scopedKey) + new Config( + scopedKey, + repository, + inputs, + triggers, + watchSettings + ) + } + private def getRepository(state: State): FileTreeRepository[FileAttributes] = { + lazy val exception = + new IllegalStateException("Tried to access FileTreeRepository for uninitialized state") + state + .get(Keys.globalFileTreeRepository) + .map(FileManagement.toMonitoringRepository(_).copy()) + .getOrElse(throw exception) + } + + private[sbt] def setup[R](state: State, command: String)( + f: (State, Seq[String], Seq[() => Boolean], Seq[String]) => R + ): R = { + // First set up the state so that we can capture whether or not a task completed successfully + // or if it threw an Exception (we lose the actual exception, but that should still be printed + // to the console anyway). + val failureCommandName = "SbtContinuousWatchOnFail" + val onFail = Command.command(failureCommandName)(identity) + /* + * Takes a task string and converts it to an EitherTask. We cannot preserve either + * the value returned by the task or any exception thrown by the task, but we can determine + * whether or not the task ran successfully using the onFail command defined above. + */ + def makeTask(cmd: String)(task: () => State): () => Boolean = { () => + MainLoop + .processCommand(Exec(cmd, None), state, task) + .remainingCommands + .forall(_.commandLine != failureCommandName) + } + + // This adds the "SbtContinuousWatchOnFail" onFailure handler which allows us to determine + // whether or not the last task successfully ran. It is used in the makeTask method below. + val s = (FailureWall :: state).copy( + onFailure = Some(Exec(failureCommandName, None)), + definedCommands = state.definedCommands :+ onFail + ) + + // We support multiple commands in watch, so it's necessary to run the command string through + // the multi parser. + val trimmed = command.trim + val commands = Parser.parse(trimmed, BasicCommands.multiParserImpl(Some(s))) match { + case Left(_) => trimmed :: Nil + case Right(c) => c + } + + // Convert the command strings to runnable tasks, which are represented by + // () => Try[Boolean]. + val taskParser = Command.combine(s.definedCommands)(s) + // This specified either the task corresponding to a command or the command itself if the + // the command cannot be converted to a task. + val (invalid, valid) = commands.foldLeft((Nil: Seq[String], Nil: Seq[() => Boolean])) { + case ((i, v), cmd) => + Parser.parse(cmd, taskParser) match { + case Right(task) => (i, v :+ makeTask(cmd)(task)) + case Left(c) => (i :+ c, v) + } + } + f(s, commands, valid, invalid) + } + + private[sbt] def runToTermination( + state: State, + command: String, + count: Int, + isCommand: Boolean + ): State = Watched.withCharBufferedStdIn { in => + val duped = new DupedInputStream(in) + setup(state.put(DupedSystemIn, duped), command) { (s, commands, valid, invalid) => + implicit val extracted: Extracted = Project.extract(s) + EvaluateTask.withStreams(extracted.structure, s)(_.use(Keys.streams in Global) { streams => + implicit val logger: Logger = streams.log + if (invalid.isEmpty) { + val currentCount = new AtomicInteger(count) + val callbacks = + aggregate(getAllConfigs(s, commands), logger, in, state, currentCount, isCommand) + val task = () => { + currentCount.getAndIncrement() + // abort as soon as one of the tasks fails + valid.takeWhile(_.apply()) + () + } + callbacks.onEnter() + // Here we enter the Watched.watch state machine. We will not return until one of the + // state machine callbacks returns Watched.CancelWatch, Watched.Custom, Watched.HandleError + // or Watched.Reload. The task defined above will be run at least once. It will be run + // additional times whenever the state transition callbacks return Watched.Trigger. + try { + val terminationAction = Watched.watch(task, callbacks.onStart, callbacks.nextEvent) + callbacks.onTermination(terminationAction, command, currentCount.get(), state) + } finally callbacks.onExit() + } else { + // At least one of the commands in the multi command string could not be parsed, so we + // log an error and exit. + val commands = invalid.mkString("'", "', '", "'") + logger.error(s"Terminating watch due to invalid command(s): $commands") + state.fail + } + }) + } + } + + private def parseCommands(state: State, commands: Seq[String]): Seq[ScopedKey[_]] = { + // Collect all of the scoped keys that are used to delegate the multi commands. These are + // necessary to extract all of the transitive globs that we need to monitor during watch. + // We have to add the <~ Parsers.any.* to ensure that we're able to extract the input key + // from input tasks. + val scopedKeyParser: Parser[Seq[ScopedKey[_]]] = Act.aggregatedKeyParser(state) <~ Parsers.any.* + commands.flatMap { cmd: String => + Parser.parse(cmd, scopedKeyParser) match { + case Right(scopedKeys: Seq[ScopedKey[_]]) => scopedKeys + case Left(e) => + throw new IllegalStateException(s"Error attempting to extract scope from $cmd: $e.") + case _ => Nil: Seq[ScopedKey[_]] + } + } + } + private def getAllConfigs( + state: State, + commands: Seq[String] + )(implicit extracted: Extracted, logger: Logger): Seq[Config] = { + val commandKeys = parseCommands(state, commands) + val compiledMap = InputGraph.compile(extracted.structure) + commandKeys.map((scopedKey: ScopedKey[_]) => getConfig(state, scopedKey, compiledMap)) + } + + private class Callbacks( + val nextEvent: () => Watched.Action, + val onEnter: () => Unit, + val onExit: () => Unit, + val onStart: () => Watched.Action, + val onTermination: (Watched.Action, String, Int, State) => State + ) + + /** + * Aggregates a collection of [[Config]] instances into a single instance of [[Callbacks]]. + * This allows us to monitor and respond to changes for all of + * the inputs and triggers for each of the tasks that we are monitoring in the continuous build. + * To monitor all of the inputs and triggers, it creates a [[FileEventMonitor]] for each task + * and then aggregates each of the individual [[FileEventMonitor]] instances into an aggregated + * instance. It aggregates all of the event callbacks into a single callback that delegates + * to each of the individual callbacks. For the callbacks that return a [[Watched.Action]], + * the aggregated callback will select the minimum [[Watched.Action]] returned where the ordering + * is such that the highest priority [[Watched.Action]] have the lowest values. Finally, to + * handle user input, we read from the provided input stream and buffer the result. Each + * task's input parser is then applied to the buffered result and, again, we return the mimimum + * [[Watched.Action]] returned by the parsers (when the parsers fail, they just return + * [[Watched.Ignore]], which is the lowest priority [[Watched.Action]]. + * + * @param configs the [[Config]] instances + * @param rawLogger the default sbt logger instance + * @param state the current state + * @param extracted the [[Extracted]] instance for the current build + * @return the [[Callbacks]] to pass into [[Watched.watch]] + */ + private def aggregate( + configs: Seq[Config], + rawLogger: Logger, + inputStream: InputStream, + state: State, + count: AtomicInteger, + isCommand: Boolean + )( + implicit extracted: Extracted + ): Callbacks = { + val logger = setLevel(rawLogger, configs.map(_.watchSettings.logLevel).min, state) + val onEnter = () => configs.foreach(_.watchSettings.onEnter()) + val onStart: () => Watched.Action = getOnStart(configs, logger, count) + val nextInputEvent: () => Watched.Action = parseInputEvents(configs, state, inputStream, logger) + val (nextFileEvent, cleanupFileMonitor): (() => Watched.Action, () => Unit) = + getFileEvents(configs, logger, state, count) + val nextEvent: () => Watched.Action = + combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger) + val onExit = () => { + cleanupFileMonitor() + configs.foreach(_.watchSettings.onExit()) + } + val onTermination = getOnTermination(configs, isCommand) + new Callbacks(nextEvent, onEnter, onExit, onStart, onTermination) + } + + private def getOnTermination( + configs: Seq[Config], + isCommand: Boolean + ): (Watched.Action, String, Int, State) => State = { + configs.flatMap(_.watchSettings.onTermination).distinct match { + case Seq(head, tail @ _*) => + tail.foldLeft(head) { + case (onTermination, configOnTermination) => + (action, cmd, count, state) => + configOnTermination(action, cmd, count, onTermination(action, cmd, count, state)) + } + case _ => + if (isCommand) Watched.defaultCommandOnTermination else Watched.defaultTaskOnTermination + } + } + + private def getOnStart( + configs: Seq[Config], + logger: Logger, + count: AtomicInteger + ): () => Watched.Action = { + val f = configs.map { params => + val ws = params.watchSettings + ws.onStart.map(_.apply(params.arguments(logger))).getOrElse { () => + ws.onIteration.map(_(count.get)).getOrElse { + if (configs.size == 1) { // Only allow custom start messages for single tasks + ws.startMessage match { + case Some(Left(sm)) => logger.info(sm(params.watchState(count.get()))) + case Some(Right(sm)) => sm(count.get()).foreach(logger.info(_)) + case None => Watched.defaultStartWatch(count.get()).foreach(logger.info(_)) + } + } + Watched.Ignore + } + } + } + () => + { + val res = f.view.map(_()).min + // Print the default watch message if there are multiple tasks + if (configs.size > 1) Watched.defaultStartWatch(count.get()).foreach(logger.info(_)) + res + } + } + private def getFileEvents( + configs: Seq[Config], + logger: Logger, + state: State, + count: AtomicInteger, + )(implicit extracted: Extracted): (() => Watched.Action, () => Unit) = { + val trackMetaBuild = configs.forall(_.watchSettings.trackMetaBuild) + val buildGlobs = + if (trackMetaBuild) extracted.getOpt(Keys.fileInputs in Keys.settingsData).getOrElse(Nil) + else Nil + val buildFilter = buildGlobs.toEntryFilter + + /* + * This is a callback that will be invoked whenever onEvent returns a Trigger action. The + * motivation is to allow the user to specify this callback via setting so that, for example, + * they can clear the screen when the build triggers. + */ + val onTrigger: Event => Watched.Action = { + val f: Seq[Event => Unit] = configs.map { params => + val ws = params.watchSettings + ws.onTrigger + .map(_.apply(params.arguments(logger))) + .getOrElse { + val globFilter = (params.inputs ++ params.triggers).toEntryFilter + event: Event => + if (globFilter(event.entry)) { + ws.triggerMessage match { + case Some(Left(tm)) => logger.info(tm(params.watchState(count.get()))) + case Some(Right(tm)) => tm(count.get(), event).foreach(logger.info(_)) + case None => // By default don't print anything + } + } + } + } + event: Event => + f.view.foreach(_.apply(event)) + Watched.Trigger + } + + val onEvent: Event => (Event, Watched.Action) = { + val f = configs.map { params => + val ws = params.watchSettings + val oe = ws.onEvent + .map(_.apply(params.arguments(logger))) + .getOrElse { + val onInputEvent = ws.onInputEvent.getOrElse(Watched.trigger) + val onTriggerEvent = ws.onTriggerEvent.getOrElse(Watched.trigger) + val onMetaBuildEvent = ws.onMetaBuildEvent.getOrElse(Watched.ifChanged(Watched.Reload)) + val inputFilter = params.inputs.toEntryFilter + val triggerFilter = params.triggers.toEntryFilter + event: Event => + val c = count.get() + Seq[Watched.Action]( + if (inputFilter(event.entry)) onInputEvent(c, event) else Watched.Ignore, + if (triggerFilter(event.entry)) onTriggerEvent(c, event) else Watched.Ignore, + if (buildFilter(event.entry)) onMetaBuildEvent(c, event) else Watched.Ignore + ).min + } + event: Event => + event -> (oe(event) match { + case Watched.Trigger => onTrigger(event) + case a => a + }) + } + event: Event => + f.view.map(_.apply(event)).minBy(_._2) + } + val monitor: Monitor = new FileEventMonitor[FileAttributes] { + private def setup( + monitor: FileEventMonitor[FileAttributes], + globs: Seq[Glob] + ): FileEventMonitor[FileAttributes] = { + val globFilters = globs.toEntryFilter + val filter: Event => Boolean = (event: Event) => globFilters(event.entry) + new FileEventMonitor[FileAttributes] { + override def poll(duration: Duration): Seq[FileEventMonitor.Event[FileAttributes]] = + monitor.poll(duration).filter(filter) + override def close(): Unit = monitor.close() + } + } + private[this] val monitors: Seq[FileEventMonitor[FileAttributes]] = + configs.map { config => + // Create a logger with a scoped key prefix so that we can tell from which + // monitor events occurred. + val l = logger.withPrefix(config.key.show) + val monitor: FileEventMonitor[FileAttributes] = + FileManagement.monitor(config.repository, config.watchSettings.antiEntropy, l) + val allGlobs = (config.inputs ++ config.triggers).distinct.sorted + setup(monitor, allGlobs) + } ++ (if (trackMetaBuild) { + val l = logger.withPrefix("meta-build") + val antiEntropy = configs.map(_.watchSettings.antiEntropy).min + setup(FileManagement.monitor(getRepository(state), antiEntropy, l), buildGlobs) :: Nil + } else Nil) + override def poll(duration: Duration): Seq[FileEventMonitor.Event[FileAttributes]] = { + // The call to .par allows us to poll all of the monitors in parallel. + // This should be cheap because poll just blocks on a queue until an event is added. + monitors.par.flatMap(_.poll(duration)).toSet.toVector + } + override def close(): Unit = monitors.foreach(_.close()) + } + val watchLogger: WatchLogger = msg => logger.debug(msg.toString) + val retentionPeriod = configs.map(_.watchSettings.antiEntropyRetentionPeriod).max + val antiEntropy = configs.map(_.watchSettings.antiEntropy).max + val quarantinePeriod = configs.map(_.watchSettings.deletionQuarantinePeriod).max + val antiEntropyMonitor = FileEventMonitor.antiEntropy( + monitor, + antiEntropy, + watchLogger, + quarantinePeriod, + retentionPeriod + ) + (() => { + val actions = antiEntropyMonitor.poll(2.milliseconds).map(onEvent) + if (actions.exists(_._2 != Watched.Ignore)) { + val min = actions.minBy(_._2) + logger.debug(s"Received file event actions: ${actions.mkString(", ")}. Returning: $min") + min._2 + } else Watched.Ignore + }, () => monitor.close()) + } + + /** + * Each task has its own input parser that can be used to modify the watch based on the input + * read from System.in as well as a custom task-specific input stream that can be used as + * an alternative source of control. In this method, we create two functions for each task, + * one from `String => Seq[Watched.Action]` and another from `() => Seq[Watched.Action]`. + * Each of these functions is invoked to determine the next state transformation for the watch. + * The first function is a task specific copy of System.in. For each task we keep a mutable + * buffer of the characters previously seen from System.in. Every time we receive new characters + * we update the buffer and then try to parse a Watched.Action for each task. Any trailing + * characters are captured and can be used for the next trigger. Because each task has a local + * copy of the buffer, we do not have to worry about one task breaking parsing of another. We + * also provide an alternative per task InputStream that is read in a similar way except that + * we don't need to copy the custom InputStream which allows the function to be + * `() => Seq[Watched.Action]` which avoids actually exposing the InputStream anywhere. + */ + private def parseInputEvents( + configs: Seq[Config], + state: State, + inputStream: InputStream, + logger: Logger + )( + implicit extracted: Extracted + ): () => Watched.Action = { + /* + * This parses the buffer until all possible actions are extracted. By draining the input + * to a state where it does not parse an action, we can wait until we receive new input + * to attempt to parse again. + */ + type ActionParser = String => Watched.Action + // Transform the Config.watchSettings.inputParser instances to functions of type + // String => Watched.Action. The String that is provided will contain any characters that + // have been read from stdin. If there are any characters available, then it calls the + // parse method with the InputStream set to a ByteArrayInputStream that wraps the input + // string. The parse method then appends those bytes to a mutable buffer and attempts to + // parse the buffer. To make this work with streaming input, we prefix the parser with any.*. + // If the Config.watchSettings.inputStream is set, the same process is applied except that + // instead of passing in the wrapped InputStream for the input string, we directly pass + // in the inputStream provided by Config.watchSettings.inputStream. + val inputHandlers: Seq[ActionParser] = configs.map { c => + val any = Parsers.any.* + val inputParser = c.watchSettings.inputParser + val parser = any ~> inputParser ~ matched(any) + // Each parser gets its own copy of System.in that it can modify while parsing. + val systemInBuilder = new StringBuilder + def inputStream(string: String): InputStream = new ByteArrayInputStream(string.getBytes) + // This string is provided in the closure below by reading from System.in + val default: String => Watched.Action = + string => parse(inputStream(string), systemInBuilder, parser) + val alternative = c.watchSettings.inputStream + .map { inputStreamKey => + val is = extracted.runTask(inputStreamKey, state)._2 + val handler = c.watchSettings.inputHandler.getOrElse(defaultInputHandler(inputParser)) + () => + handler(is) + } + .getOrElse(() => Watched.Ignore) + (string: String) => + (default(string) :: alternative() :: Nil).min + } + () => + { + val stringBuilder = new StringBuilder + while (inputStream.available > 0) stringBuilder += inputStream.read().toChar + val newBytes = stringBuilder.toString + val parse: ActionParser => Watched.Action = parser => parser(newBytes) + val allEvents = inputHandlers.map(parse).filterNot(_ == Watched.Ignore) + if (allEvents.exists(_ != Watched.Ignore)) { + val res = allEvents.min + logger.debug(s"Received input events: ${allEvents mkString ","}. Taking $res") + res + } else Watched.Ignore + } + } + + private def combineInputAndFileEvents( + nextInputEvent: () => Watched.Action, + nextFileEvent: () => Watched.Action, + logger: Logger + ): () => Watched.Action = () => { + val Seq(inputEvent: Watched.Action, fileEvent: Watched.Action) = + Seq(nextInputEvent, nextFileEvent).par.map(_.apply()).toIndexedSeq + val min: Watched.Action = Seq[Watched.Action](inputEvent, fileEvent).min + lazy val inputMessage = + s"Received input event: $inputEvent." + + (if (inputEvent != min) s" Dropping in favor of file event: $min" else "") + lazy val fileMessage = + s"Received file event: $fileEvent." + + (if (fileEvent != min) s" Dropping in favor of input event: $min" else "") + if (inputEvent != Watched.Ignore) logger.debug(inputMessage) + if (fileEvent != Watched.Ignore) logger.debug(fileMessage) + min + } + + @tailrec + private final def parse( + is: InputStream, + builder: StringBuilder, + parser: Parser[(Watched.Action, String)] + ): Watched.Action = { + if (is.available > 0) builder += is.read().toChar + Parser.parse(builder.toString, parser) match { + case Right((action, rest)) => + builder.clear() + builder ++= rest + action + case _ if is.available > 0 => parse(is, builder, parser) + case _ => Watched.Ignore + } + } + + /** + * Generates a custom logger for the watch process that is able to log at a different level + * from the provided logger. + * @param logger the delegate logger. + * @param logLevel the log level for watch events + * @return the wrapped logger. + */ + private def setLevel(logger: Logger, logLevel: Level.Value, state: State): Logger = { + import Level._ + val delegateLevel = state.get(Keys.logLevel.key).getOrElse(Info) + /* + * The delegate logger may be set to, say, info level, but we want it to print out debug + * messages if the logLevel variable above is Debug. To do this, we promote Debug messages + * to the Info level (or Warn or Error if that's what the input logger is set to). + */ + new Logger { + override def trace(t: => Throwable): Unit = logger.trace(t) + override def success(message: => String): Unit = logger.success(message) + override def log(level: Level.Value, message: => String): Unit = { + val levelString = if (level < delegateLevel) s"[$level] " else "" + val newMessage = s"[watch] $levelString$message" + val watchLevel = if (level < delegateLevel && level >= logLevel) delegateLevel else level + logger.log(watchLevel, newMessage) + } + } + } + + private type WatchOnEvent = (Int, Event) => Watched.Action + + /** + * Contains all of the user defined settings that will be used to build a [[Callbacks]] + * instance that is used to produce the arguments to [[Watched.watch]]. The + * callback settings (e.g. onEvent or onInputEvent) come in two forms: those that return a + * function from [[Arguments]] => F for some function type `F` and those that directly return a function, e.g. + * `(Int, Boolean) => Watched.Action`. The former are a low level interface that will usually + * be unspecified and automatically filled in by [[Continuous.aggregate]]. The latter are + * intended to be user configurable and will be scoped to the input [[ScopedKey]]. To ensure + * that the scoping makes sense, we first try and extract the setting from the [[ScopedKey]] + * instance's task scope, which is the scope with the task axis set to the task key. If that + * fails, we fall back on the task axis. To make this concrete, to get the logLevel for + * `foo / Compile / compile` (which is a TaskKey with scope `foo / Compile`), we first try and + * get the setting in the `foo / Compile / compile` scope. If logLevel is not set at the task + * level, then we fall back to the `foo / Compile` scope. + * + * This has to be done by manually extracting the settings via [[Extracted]] because there is + * no good way to automatically add a [[WatchSettings]] setting to every task in the build. + * Thankfully these map retrievals are reasonably fast so there is not a significant runtime + * performance penalty for creating the [[WatchSettings]] this way. The drawback is that we + * have to manually resolve the settings in multiple scopes which may lead to inconsistencies + * with scope resolution elsewhere in sbt. + * + * @param key the [[ScopedKey]] instance that sets the [[Scope]] for the settings we're extracting + * @param extracted the [[Extracted]] instance for the build + */ + private final class WatchSettings private[Continuous] (val key: ScopedKey[_])( + implicit extracted: Extracted + ) { + val antiEntropy: FiniteDuration = + key.get(Keys.watchAntiEntropy).getOrElse(Watched.defaultAntiEntropy) + val antiEntropyRetentionPeriod: FiniteDuration = + key + .get(Keys.watchAntiEntropyRetentionPeriod) + .getOrElse(Watched.defaultAntiEntropyRetentionPeriod) + val deletionQuarantinePeriod: FiniteDuration = + key.get(Keys.watchDeletionQuarantinePeriod).getOrElse(Watched.defaultDeletionQuarantinePeriod) + val inputHandler: Option[InputStream => Watched.Action] = key.get(Keys.watchInputHandler) + val inputParser: Parser[Watched.Action] = + key.get(Keys.watchInputParser).getOrElse(Watched.defaultInputParser) + val logLevel: Level.Value = key.get(Keys.watchLogLevel).getOrElse(Level.Info) + val onEnter: () => Unit = key.get(Keys.watchOnEnter).getOrElse(() => {}) + val onEvent: Option[Arguments => Event => Watched.Action] = key.get(Keys.watchOnEvent) + val onExit: () => Unit = key.get(Keys.watchOnExit).getOrElse(() => {}) + val onInputEvent: Option[WatchOnEvent] = key.get(Keys.watchOnInputEvent) + val onIteration: Option[Int => Watched.Action] = key.get(Keys.watchOnIteration) + val onMetaBuildEvent: Option[WatchOnEvent] = key.get(Keys.watchOnMetaBuildEvent) + val onStart: Option[Arguments => () => Watched.Action] = key.get(Keys.watchOnStart) + val onTermination: Option[(Watched.Action, String, Int, State) => State] = + key.get(Keys.watchOnTermination) + val onTrigger: Option[Arguments => Event => Unit] = key.get(Keys.watchOnTrigger) + val onTriggerEvent: Option[WatchOnEvent] = key.get(Keys.watchOnTriggerEvent) + val startMessage: StartMessage = getStartMessage(key) + val trackMetaBuild: Boolean = key.get(Keys.watchTrackMetaBuild).getOrElse(true) + val triggerMessage: TriggerMessage = getTriggerMessage(key) + + // Unlike the rest of the settings, InputStream is a TaskKey which means that if it is set, + // we have to use Extracted.runTask to get the value. The reason for this is because it is + // logical that users may want to use a different InputStream on each task invocation. The + // alternative would be SettingKey[() => InputStream], but that doesn't feel right because + // one might want the InputStream to depend on other tasks. + val inputStream: Option[TaskKey[InputStream]] = key.get(Keys.watchInputStream) + } + + /** + * Container class for all of the components we need to setup a watch for a particular task or + * input task. + * @param key the [[ScopedKey]] instance for the task we will watch + * @param repository the task [[FileTreeRepository]] instance + * @param inputs the transitive task inputs (see [[InputGraph]]) + * @param triggers the transitive triggers (see [[InputGraph]]) + * @param watchSettings the [[WatchSettings]] instance for the task + */ + private final class Config private[internal] ( + val key: ScopedKey[_], + val repository: FileTreeRepository[FileAttributes], + val inputs: Seq[Glob], + val triggers: Seq[Glob], + val watchSettings: WatchSettings + ) { + private[sbt] def watchState(count: Int): DeprecatedWatchState = + WatchState.empty(inputs ++ triggers).withCount(count) + def arguments(logger: Logger): Arguments = new Arguments(logger, inputs, triggers) + } + private def getStartMessage(key: ScopedKey[_])(implicit e: Extracted): StartMessage = Some { + lazy val default = key.get(Keys.watchStartMessage).getOrElse(Watched.defaultStartWatch) + key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default)) + } + private def getTriggerMessage(key: ScopedKey[_])(implicit e: Extracted): TriggerMessage = Some { + lazy val default = + key.get(Keys.watchTriggeredMessage).getOrElse(Watched.defaultOnTriggerMessage) + key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default)) + } + + private implicit class ScopeOps(val scope: Scope) { + + /** + * This shows the [[Scope]] in the format that a user would likely type it in a build + * or in the sbt console. For example, the key corresponding to the command + * foo/Compile/compile will pretty print as "foo / Compile / compile", not + * "ProjectRef($URI, foo) / compile / compile", where the ProjectRef part is just noise that + * is rarely relevant for debugging. + * @return the pretty printed output. + */ + def show: String = { + val mask = ScopeMask( + config = scope.config.toOption.isDefined, + task = scope.task.toOption.isDefined, + extra = scope.extra.toOption.isDefined + ) + Scope + .displayMasked(scope, " ", (_: Reference) match { + case p: ProjectRef => s"${p.project.trim} /" + case _ => "Global /" + }, mask) + .dropRight(3) // delete trailing "/" + .trim + } + } + + private implicit class ScopedKeyOps(val scopedKey: ScopedKey[_]) extends AnyVal { + + /** + * Gets the value for a setting key scoped to the wrapped [[ScopedKey]]. If the task axis is not + * set in the [[ScopedKey]], then we first set the task axis and try to extract the setting + * from that scope otherwise we fallback on the [[ScopedKey]] instance's scope. We use the + * reverse order if the task is set. + * + * @param settingKey the [[SettingKey]] to extract + * @param extracted the provided [[Extracted]] instance + * @tparam T the type of the [[SettingKey]] + * @return the optional value of the [[SettingKey]] if it is defined at the input + * [[ScopedKey]] instance's scope or task scope. + */ + def get[T](settingKey: SettingKey[T])(implicit extracted: Extracted): Option[T] = { + lazy val taskScope = Project.fillTaskAxis(scopedKey).scope + scopedKey.scope match { + case scope if scope.task.toOption.isDefined => + extracted.getOpt(settingKey in scope) orElse extracted.getOpt(settingKey in taskScope) + case scope => + extracted.getOpt(settingKey in taskScope) orElse extracted.getOpt(settingKey in scope) + } + } + + /** + * Gets the [[ScopedKey]] for a task scoped to the wrapped [[ScopedKey]]. If the task axis is + * not set in the [[ScopedKey]], then we first set the task axis and try to extract the tak + * from that scope otherwise we fallback on the [[ScopedKey]] instance's scope. We use the + * reverse order if the task is set. + * + * @param taskKey the [[TaskKey]] to extract + * @param extracted the provided [[Extracted]] instance + * @tparam T the type of the [[SettingKey]] + * @return the optional value of the [[SettingKey]] if it is defined at the input + * [[ScopedKey]] instance's scope or task scope. + */ + def get[T](taskKey: TaskKey[T])(implicit extracted: Extracted): Option[TaskKey[T]] = { + lazy val taskScope = Project.fillTaskAxis(scopedKey).scope + scopedKey.scope match { + case scope if scope.task.toOption.isDefined => + if (extracted.getOpt(taskKey in scope).isDefined) Some(taskKey in scope) + else if (extracted.getOpt(taskKey in taskScope).isDefined) Some(taskKey in taskScope) + else None + case scope => + if (extracted.getOpt(taskKey in taskScope).isDefined) Some(taskKey in taskScope) + else if (extracted.getOpt(taskKey in scope).isDefined) Some(taskKey in scope) + else None + } + } + + /** + * This shows the [[ScopedKey[_]] in the format that a user would likely type it in a build + * or in the sbt console. For example, the key corresponding to the command + * foo/Compile/compile will pretty print as "foo / Compile / compile", not + * "ProjectRef($URI, foo) / compile / compile", where the ProjectRef part is just noise that + * is rarely relevant for debugging. + * @return the pretty printed output. + */ + def show: String = s"${scopedKey.scope.show} / ${scopedKey.key}" + } + + private implicit class LoggerOps(val logger: Logger) extends AnyVal { + + /** + * Creates a logger that adds a prefix to the messages that it logs. The motivation is so that + * we can tell from which FileEventMonitor an event originated. + * @param prefix the string to prefix the message with + * @return the wrapped Logger. + */ + def withPrefix(prefix: String): Logger = new Logger { + override def trace(t: => Throwable): Unit = logger.trace(t) + override def success(message: => String): Unit = logger.success(message) + override def log(level: Level.Value, message: => String): Unit = + logger.log(level, s"$prefix - $message") + } + } + +} diff --git a/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala new file mode 100644 index 000000000..742c1aa46 --- /dev/null +++ b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala @@ -0,0 +1,19 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import sbt.internal.io.{ WatchState => WS } + +private[internal] trait DeprecatedContinuous { + protected type Event = sbt.io.FileEventMonitor.Event[FileAttributes] + protected type StartMessage = Option[Either[WS => String, Int => Option[String]]] + protected type TriggerMessage = Option[Either[WS => String, (Int, Event) => Option[String]]] + protected type DeprecatedWatchState = WS + protected val deprecatedWatchingMessage = sbt.Keys.watchingMessage + protected val deprecatedTriggeredMessage = sbt.Keys.triggeredMessage +} diff --git a/main/src/main/scala/sbt/internal/DupedInputStream.scala b/main/src/main/scala/sbt/internal/DupedInputStream.scala new file mode 100644 index 000000000..6334d5cbd --- /dev/null +++ b/main/src/main/scala/sbt/internal/DupedInputStream.scala @@ -0,0 +1,73 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.{ InputStream, PipedInputStream, PipedOutputStream } +import java.util.concurrent.LinkedBlockingQueue + +import scala.annotation.tailrec +import scala.collection.JavaConverters._ + +/** + * Creates a copy of the provided [[InputStream]] that forwards its contents to an arbitrary + * number of connected [[InputStream]] instances via pipe. + * @param in the [[InputStream]] to wrap. + */ +private[internal] class DupedInputStream(val in: InputStream) + extends InputStream + with AutoCloseable { + + /** + * Returns a copied [[InputStream]] that will receive the same bytes as System.in. + * @return + */ + def duped: InputStream = { + val pipedOutputStream = new PipedOutputStream() + pipes += pipedOutputStream + val res = new PollingInputStream(new PipedInputStream(pipedOutputStream)) + buffer.forEach(pipedOutputStream.write(_)) + res + } + + private[this] val pipes = new java.util.Vector[PipedOutputStream].asScala + private[this] val buffer = new LinkedBlockingQueue[Int] + private class PollingInputStream(val pipedInputStream: PipedInputStream) extends InputStream { + override def available(): Int = { + fillBuffer() + pipedInputStream.available() + } + override def read(): Int = { + fillBuffer() + pipedInputStream.read + } + } + override def available(): Int = { + fillBuffer() + buffer.size + } + override def read(): Int = { + fillBuffer() + buffer.take() + } + + private[this] def fillBuffer(): Unit = synchronized { + @tailrec + def impl(): Unit = in.available match { + case i if i > 0 => + val res = in.read() + buffer.add(res) + pipes.foreach { p => + p.write(res) + p.flush() + } + impl() + case _ => + } + impl() + } +} diff --git a/main/src/main/scala/sbt/internal/FileManagement.scala b/main/src/main/scala/sbt/internal/FileManagement.scala index 87f067e21..77c2c0624 100644 --- a/main/src/main/scala/sbt/internal/FileManagement.scala +++ b/main/src/main/scala/sbt/internal/FileManagement.scala @@ -12,10 +12,10 @@ import java.io.IOException import java.util.concurrent.ConcurrentHashMap import sbt.BasicCommandStrings.ContinuousExecutePrefix -import sbt.Keys._ import sbt.internal.io.HybridPollingFileTreeRepository import sbt.internal.util.Util import sbt.io.FileTreeDataView.{ Entry, Observable, Observer, Observers } +import sbt.io.Glob.TraversableGlobOps import sbt.io.{ FileTreeRepository, _ } import sbt.util.{ Level, Logger } @@ -100,10 +100,53 @@ private[sbt] object FileManagement { override def close(): Unit = monitor.close() } } + private[sbt] implicit class FileTreeRepositoryOps[T](val repo: FileTreeRepository[T]) + extends AnyVal { + def copy(): FileTreeRepository[T] = + copy(ConcurrentHashMap.newKeySet[Glob].asScala, closeUnderlying = false) - private[sbt] def repo: Def.Initialize[Task[FileTreeRepository[FileAttributes]]] = Def.task { - lazy val msg = s"Tried to get FileTreeRepository for uninitialized state." - state.value.get(Keys.globalFileTreeRepository).getOrElse(throw new IllegalStateException(msg)) + /** + * Creates a copied FileTreeRepository that keeps track of all of the globs that are explicitly + * registered with it. + * + * @param registered the registered globs + * @param closeUnderlying toggles whether or not close should actually close the delegate + * repository + * + * @return the copied FileTreeRepository + */ + def copy(registered: mutable.Set[Glob], closeUnderlying: Boolean): FileTreeRepository[T] = + new FileTreeRepository[T] { + private val entryFilter: FileTreeDataView.Entry[T] => Boolean = + (entry: FileTreeDataView.Entry[T]) => registered.toEntryFilter(entry) + private[this] val observers = new Observers[T] { + override def onCreate(newEntry: FileTreeDataView.Entry[T]): Unit = + if (entryFilter(newEntry)) super.onCreate(newEntry) + override def onDelete(oldEntry: FileTreeDataView.Entry[T]): Unit = + if (entryFilter(oldEntry)) super.onDelete(oldEntry) + override def onUpdate( + oldEntry: FileTreeDataView.Entry[T], + newEntry: FileTreeDataView.Entry[T] + ): Unit = if (entryFilter(newEntry)) super.onUpdate(oldEntry, newEntry) + } + private[this] val handle = repo.addObserver(observers) + override def register(glob: Glob): Either[IOException, Boolean] = { + registered.add(glob) + repo.register(glob) + } + override def unregister(glob: Glob): Unit = repo.unregister(glob) + override def addObserver(observer: FileTreeDataView.Observer[T]): Int = + observers.addObserver(observer) + override def removeObserver(handle: Int): Unit = observers.removeObserver(handle) + override def close(): Unit = { + repo.removeObserver(handle) + if (closeUnderlying) repo.close() + } + override def toString: String = s"CopiedFileTreeRepository(base = $repo)" + override def list(glob: Glob): Seq[TypedPath] = repo.list(glob) + override def listEntries(glob: Glob): Seq[FileTreeDataView.Entry[T]] = + repo.listEntries(glob) + } } private[sbt] class HybridMonitoringRepository[T]( diff --git a/project/SbtLauncherPlugin.scala b/project/SbtLauncherPlugin.scala index 7386b79cc..7d95ed9ee 100644 --- a/project/SbtLauncherPlugin.scala +++ b/project/SbtLauncherPlugin.scala @@ -21,7 +21,8 @@ object SbtLauncherPlugin extends AutoPlugin { case Some(jar) => jar.data case None => sys.error( - s"Could not resolve sbt launcher!, dependencies := ${libraryDependencies.value}") + s"Could not resolve sbt launcher!, dependencies := ${libraryDependencies.value}" + ) } }, sbtLaunchJar := { diff --git a/sbt/src/sbt-test/tests/interproject-inputs/build.sbt b/sbt/src/sbt-test/tests/interproject-inputs/build.sbt new file mode 100644 index 000000000..61996f5be --- /dev/null +++ b/sbt/src/sbt-test/tests/interproject-inputs/build.sbt @@ -0,0 +1,58 @@ +import sbt.internal.TransitiveGlobs._ +val cached = settingKey[Unit]("") +val newInputs = settingKey[Unit]("") +Compile / cached / fileInputs := (Compile / unmanagedSources / fileInputs).value ++ + (Compile / unmanagedResources / fileInputs).value +Test / cached / fileInputs := (Test / unmanagedSources / fileInputs).value ++ + (Test / unmanagedResources / fileInputs).value +Compile / newInputs / fileInputs += baseDirectory.value * "*.sc" + +Compile / unmanagedSources / fileInputs ++= (Compile / newInputs / fileInputs).value + +val checkCompile = taskKey[Unit]("check compile inputs") +checkCompile := { + val actual = (Compile / compile / transitiveInputs).value.toSet + val expected = ((Compile / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"$actual did not equal $expected\n" + + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } + +} + +val checkRun = taskKey[Unit]("check runtime inputs") +checkRun := { + val actual = (Runtime / run / transitiveInputs).value.toSet + // Runtime doesn't add any new inputs, but it should correctly find the Compile inputs via + // delegation. + val expected = ((Compile / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields: $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } +} + +val checkTest = taskKey[Unit]("check test inputs") +checkTest := { + val actual = (Test / compile / transitiveInputs).value.toSet + val expected = ((Test / cached / fileInputs).value ++ (Compile / newInputs / fileInputs).value ++ + (Compile / cached / fileInputs).value).toSet + streams.value.log.debug(s"actual: $actual\nexpected:$expected") + if (actual != expected) { + val actualExtra = actual diff expected + val expectedExtra = expected diff actual + throw new IllegalStateException( + s"$actual did not equal $expected\n" + + s"${if (actualExtra.nonEmpty) s"Actual result had extra fields $actualExtra" else ""}" + + s"${if (expectedExtra.nonEmpty) s"Actual result was missing: $expectedExtra" else ""}") + } +} diff --git a/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala b/sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/bar/Bar.scala similarity index 100% rename from sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/bar/Bar.scala rename to sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/bar/Bar.scala diff --git a/sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala b/sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/foo/Foo.scala similarity index 100% rename from sbt/src/sbt-test/tests/transitive-inputs/src/main/scala/foo/Foo.scala rename to sbt/src/sbt-test/tests/interproject-inputs/src/main/scala/foo/Foo.scala diff --git a/sbt/src/sbt-test/tests/interproject-inputs/test b/sbt/src/sbt-test/tests/interproject-inputs/test new file mode 100644 index 000000000..7aca28678 --- /dev/null +++ b/sbt/src/sbt-test/tests/interproject-inputs/test @@ -0,0 +1,5 @@ +> checkCompile + +> checkRun + +> checkTest diff --git a/sbt/src/sbt-test/tests/transitive-inputs/build.sbt b/sbt/src/sbt-test/tests/transitive-inputs/build.sbt deleted file mode 100644 index f3151f5c5..000000000 --- a/sbt/src/sbt-test/tests/transitive-inputs/build.sbt +++ /dev/null @@ -1,46 +0,0 @@ -val foo = taskKey[Int]("foo") -foo := { - val _ = (foo / fileInputs).value - 1 -} -foo / fileInputs += baseDirectory.value * "foo.txt" -val checkFoo = taskKey[Unit]("check foo inputs") -checkFoo := { - val actual = (foo / transitiveDependencies).value.toSet - val expected = (foo / fileInputs).value.toSet - assert(actual == expected) -} - -val bar = taskKey[Int]("bar") -bar := { - val _ = (bar / fileInputs).value - foo.value + 1 -} -bar / fileInputs += baseDirectory.value * "bar.txt" - -val checkBar = taskKey[Unit]("check bar inputs") -checkBar := { - val actual = (bar / transitiveDependencies).value.toSet - val expected = ((bar / fileInputs).value ++ (foo / fileInputs).value).toSet - assert(actual == expected) -} - -val baz = taskKey[Int]("baz") -baz / fileInputs += baseDirectory.value * "baz.txt" -baz := { - println(resolvedScoped.value) - val _ = (baz / fileInputs).value - bar.value + 1 -} -baz := Def.taskDyn { - val _ = (bar / transitiveDependencies).value - val len = (baz / fileInputs).value.length - Def.task(bar.value + len) -}.value - -val checkBaz = taskKey[Unit]("check bar inputs") -checkBaz := { - val actual = (baz / transitiveDependencies).value.toSet - val expected = ((bar / fileInputs).value ++ (foo / fileInputs).value ++ (baz / fileInputs).value).toSet - assert(actual == expected) -} diff --git a/sbt/src/sbt-test/tests/transitive-inputs/test b/sbt/src/sbt-test/tests/transitive-inputs/test deleted file mode 100644 index 24a3714e8..000000000 --- a/sbt/src/sbt-test/tests/transitive-inputs/test +++ /dev/null @@ -1,5 +0,0 @@ -#> checkFoo - -#> checkBar - -> checkBaz diff --git a/sbt/src/sbt-test/watch/watch-parser/build.sbt b/sbt/src/sbt-test/watch/command-parser/build.sbt similarity index 57% rename from sbt/src/sbt-test/watch/watch-parser/build.sbt rename to sbt/src/sbt-test/watch/command-parser/build.sbt index c29f61af0..1e26f0e48 100644 --- a/sbt/src/sbt-test/watch/watch-parser/build.sbt +++ b/sbt/src/sbt-test/watch/command-parser/build.sbt @@ -8,6 +8,6 @@ setStringValue := setStringValueImpl.evaluated checkStringValue := checkStringValueImpl.evaluated -watchSources += file("string.txt") +setStringValue / watchTriggers := baseDirectory.value * "string.txt" :: Nil -watchOnEvent := { _ => Watched.CancelWatch } +watchOnEvent := { _ => _ => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/watch-parser/project/Build.scala b/sbt/src/sbt-test/watch/command-parser/project/Build.scala similarity index 52% rename from sbt/src/sbt-test/watch/watch-parser/project/Build.scala rename to sbt/src/sbt-test/watch/command-parser/project/Build.scala index 19380e885..0650b3ad8 100644 --- a/sbt/src/sbt-test/watch/watch-parser/project/Build.scala +++ b/sbt/src/sbt-test/watch/command-parser/project/Build.scala @@ -1,16 +1,16 @@ import sbt._ +import Keys.baseDirectory + object Build { - private[this] var string: String = "" - private[this] val stringFile = file("string.txt") val setStringValue = inputKey[Unit]("set a global string to a value") val checkStringValue = inputKey[Unit]("check the value of a global") def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { - string = Def.spaceDelimited().parsed.mkString(" ").trim - IO.write(stringFile, string) + val Seq(stringFile, string) = Def.spaceDelimited().parsed + IO.write(baseDirectory.value / stringFile, string) } def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { - assert(string == Def.spaceDelimited().parsed.mkString(" ").trim) - assert(IO.read(stringFile) == string) + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(baseDirectory.value / stringFile) == string) } } \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/command-parser/test b/sbt/src/sbt-test/watch/command-parser/test new file mode 100644 index 000000000..e8733214f --- /dev/null +++ b/sbt/src/sbt-test/watch/command-parser/test @@ -0,0 +1,21 @@ +> ~; setStringValue string.txt foo; setStringValue string.txt bar + +> checkStringValue string.txt bar + +> ~;setStringValue string.txt foo;setStringValue string.txt bar; checkStringValue string.txt bar + +> ~; setStringValue string.txt foo;setStringValue string.txt bar; checkStringValue string.txt bar + +> ~; setStringValue string.txt foo; setStringValue string.txt bar; checkStringValue string.txt bar + +# no leading semicolon +> ~ setStringValue string.txt foo; setStringValue string.txt bar; checkStringValue string.txt bar + +> ~ setStringValue string.txt foo + +> checkStringValue string.txt foo + +# All of the other tests have involved input tasks, so include commands with regular tasks as well. +> ~; compile; setStringValue string.txt baz; checkStringValue string.txt baz +# Ensure that trailing semi colons work +> ~ compile; setStringValue string.txt baz; checkStringValue string.txt baz; diff --git a/sbt/src/sbt-test/watch/custom-config/build.sbt b/sbt/src/sbt-test/watch/custom-config/build.sbt new file mode 100644 index 000000000..1c8d34f46 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/build.sbt @@ -0,0 +1,5 @@ +import sbt.input.aggregation.Build + +val root = Build.root +val foo = Build.foo +val bar = Build.bar diff --git a/sbt/src/sbt-test/watch/custom-config/project/Build.scala b/sbt/src/sbt-test/watch/custom-config/project/Build.scala new file mode 100644 index 000000000..2696d5c75 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/project/Build.scala @@ -0,0 +1,40 @@ +package sbt.input.aggregation + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val foo = project.settings( + watchStartMessage := { (count: Int) => Some(s"FOO $count") }, + Compile / compile / watchTriggers += baseDirectory.value * "foo.txt", + Compile / compile / watchStartMessage := { (count: Int) => + // this checks that Compile / compile / watchStartMessage + // is preferred to Compile / watchStartMessage + val outputFile = baseDirectory.value / "foo.txt" + IO.write(outputFile, "compile") + Some(s"compile $count") + }, + Compile / watchStartMessage := { (count: Int) => Some(s"Compile $count") }, + Runtime / watchStartMessage := { (count: Int) => Some(s"Runtime $count") }, + setStringValue := { + val _ = (fileInputs in (bar, setStringValue)).value + setStringValueImpl.evaluated + }, + checkStringValue := checkStringValueImpl.evaluated, + watchOnEvent := { _ => _ => Watched.CancelWatch } + ) + lazy val bar = project.settings(fileInputs in setStringValue += baseDirectory.value * "foo.txt") + lazy val root = (project in file(".")).aggregate(foo, bar).settings( + watchOnEvent := { _ => _ => Watched.CancelWatch } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/custom-config/test b/sbt/src/sbt-test/watch/custom-config/test new file mode 100644 index 000000000..1b878cf44 --- /dev/null +++ b/sbt/src/sbt-test/watch/custom-config/test @@ -0,0 +1,7 @@ +> ~ foo/Runtime/setStringValue bar/foo.txt foo + +> checkStringValue bar/foo.txt foo + +> ~ foo/compile + +> checkStringValue foo/foo.txt compile \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-aggregation/build.sbt b/sbt/src/sbt-test/watch/input-aggregation/build.sbt new file mode 100644 index 000000000..aa95faf82 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/build.sbt @@ -0,0 +1,7 @@ +import sbt.input.aggregation.Build + +val root = Build.root +val foo = Build.foo +val bar = Build.bar + +Global / watchTriggers += baseDirectory.value * "baz.txt" diff --git a/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala new file mode 100644 index 000000000..eca66f9b5 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala @@ -0,0 +1,94 @@ +package sbt.input.aggregation + +import sbt._ +import Keys._ +import sbt.internal.TransitiveGlobs._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + val checkTriggers = taskKey[Unit]("Check that the triggers are correctly aggregated.") + val checkGlobs = taskKey[Unit]("Check that the globs are correctly aggregated and that the globs are the union of the inputs and the triggers") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + def checkGlobsImpl: Def.Initialize[Task[Unit]] = Def.task { + val (globInputs, globTriggers) = (Compile / compile / transitiveGlobs).value + val inputs = (Compile / compile / transitiveInputs).value.toSet + val triggers = (Compile / compile / transitiveTriggers).value.toSet + assert(globInputs.toSet == inputs) + assert(globTriggers.toSet == triggers) + } + lazy val foo = project.settings( + setStringValue := { + val _ = (fileInputs in (bar, setStringValue)).value + setStringValueImpl.evaluated + }, + checkStringValue := checkStringValueImpl.evaluated, + watchOnTriggerEvent := { (_, _) => Watched.CancelWatch }, + watchOnInputEvent := { (_, _) => Watched.CancelWatch }, + Compile / compile / watchOnStart := { _ => () => Watched.CancelWatch }, + checkTriggers := { + val actual = (Compile / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + // This checks that since foo depends on bar there is a transitive trigger generated + // for the "bar.txt" trigger added to bar / Compile / unmanagedResources (which is a + // transitive dependency of + val expected: Set[Glob] = Set(base * "baz.txt", (base / "bar") * "bar.txt") + assert(actual == expected) + }, + Test / test / watchTriggers += baseDirectory.value * "test.txt", + Test / checkTriggers := { + val testTriggers = (Test / test / transitiveTriggers).value.toSet + // This validates that since the "test.txt" trigger is only added to the Test / test task, + // that the Test / compile does not pick it up. Both of them pick up the the triggers that + // are found in the test above for the compile configuration because of the transitive + // classpath dependency that is added in Defaults.internalDependencies. + val compileTriggers = (Test / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + val expected: Set[Glob] = Set( + base * "baz.txt", (base / "bar") * "bar.txt", (base / "foo") * "test.txt") + assert(testTriggers == expected) + assert((testTriggers - ((base / "foo") * "test.txt")) == compileTriggers) + }, + checkGlobs := checkGlobsImpl.value + ).dependsOn(bar) + lazy val bar = project.settings( + fileInputs in setStringValue += baseDirectory.value * "foo.txt", + setStringValue / watchTriggers += baseDirectory.value * "bar.txt", + // This trigger should transitively propagate to foo / compile and foo / Test / compile + Compile / unmanagedResources / watchTriggers += baseDirectory.value * "bar.txt", + checkTriggers := { + val base = baseDirectory.value.getParentFile + val actual = (Compile / compile / transitiveTriggers).value + val expected: Set[Glob] = Set((base / "bar") * "bar.txt", base * "baz.txt") + assert(actual.toSet == expected) + }, + // This trigger should not transitively propagate to any foo task + Test / unmanagedResources / watchTriggers += baseDirectory.value * "bar-test.txt", + Test / checkTriggers := { + val testTriggers = (Test / test / transitiveTriggers).value.toSet + val compileTriggers = (Test / compile / transitiveTriggers).value.toSet + val base = baseDirectory.value.getParentFile + val expected: Set[Glob] = Set( + base * "baz.txt", (base / "bar") * "bar.txt", (base / "bar") * "bar-test.txt") + assert(testTriggers == expected) + assert(testTriggers == compileTriggers) + }, + checkGlobs := checkGlobsImpl.value + ) + lazy val root = (project in file(".")).aggregate(foo, bar).settings( + watchOnEvent := { _ => _ => Watched.CancelWatch }, + checkTriggers := { + val actual = (Compile / compile / transitiveTriggers).value + val expected: Seq[Glob] = baseDirectory.value * "baz.txt" :: Nil + assert(actual == expected) + }, + checkGlobs := checkGlobsImpl.value + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-aggregation/test b/sbt/src/sbt-test/watch/input-aggregation/test new file mode 100644 index 000000000..052d414d6 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-aggregation/test @@ -0,0 +1,11 @@ +> checkTriggers + +> Test / checkTriggers + +> checkGlobs + +# do not set the project here to ensure the bar/bar.txt trigger is captured by aggregation +# also add random spaces and multiple commands to ensure the parser is sane. +> ~ setStringValue bar/bar.txt bar; root / setStringValue bar/bar.txt baz + +> checkStringValue bar/bar.txt baz diff --git a/sbt/src/sbt-test/watch/input-parser/build.sbt b/sbt/src/sbt-test/watch/input-parser/build.sbt new file mode 100644 index 000000000..5de1267dc --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/build.sbt @@ -0,0 +1,9 @@ +import sbt.input.parser.Build + +watchInputStream := Build.inputStream + +watchStartMessage := { count => + Build.outputStream.write('\n'.toByte) + Build.outputStream.flush() + Some("default start message") +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-parser/project/Build.scala b/sbt/src/sbt-test/watch/input-parser/project/Build.scala new file mode 100644 index 000000000..d430bdb76 --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/project/Build.scala @@ -0,0 +1,27 @@ +package sbt +package input.parser + +import complete.Parser +import complete.Parser._ + +import java.io.{ PipedInputStream, PipedOutputStream } + +object Build { + val outputStream = new PipedOutputStream() + val inputStream = new PipedInputStream(outputStream) + val byeParser: Parser[Watched.Action] = "bye" ^^^ Watched.CancelWatch + val helloParser: Parser[Watched.Action] = "hello" ^^^ Watched.Ignore + // Note that the order is byeParser | helloParser. In general, we want the higher priority + // action to come first because otherwise we would potentially scan past it. + val helloOrByeParser: Parser[Watched.Action] = byeParser | helloParser + val alternativeStartMessage: Int => Option[String] = { _ => + outputStream.write("xybyexyblahxyhelloxy".getBytes) + outputStream.flush() + Some("alternative start message") + } + val otherAlternativeStartMessage: Int => Option[String] = { _ => + outputStream.write("xyhellobyexyblahx".getBytes) + outputStream.flush() + Some("other alternative start message") + } +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/input-parser/test b/sbt/src/sbt-test/watch/input-parser/test new file mode 100644 index 000000000..981496f0b --- /dev/null +++ b/sbt/src/sbt-test/watch/input-parser/test @@ -0,0 +1,17 @@ +# this should exit because watchStartMessage writes "\n" to Build.outputStream, which in turn +# triggers a CancelWatch +> ~ compile + +> set watchStartMessage := sbt.input.parser.Build.alternativeStartMessage + +> set watchInputParser := sbt.input.parser.Build.helloOrByeParser + +# this should exit because we write "xybyexyblahxyhelloxy" to Build.outputStream. The +# helloOrByeParser will produce Watched.Ignore and Watched.CancelWatch but the +# Watched.CancelWatch event should win. +> ~ compile + +> set watchStartMessage := sbt.input.parser.Build.otherAlternativeStartMessage + +# this is the same as above except that hello appears before bye in the string +> ~ compile diff --git a/sbt/src/sbt-test/watch/legacy-sources/build.sbt b/sbt/src/sbt-test/watch/legacy-sources/build.sbt new file mode 100644 index 000000000..5ee39a863 --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/build.sbt @@ -0,0 +1,13 @@ +import sbt.legacy.sources.Build._ + +Global / watchSources += new sbt.internal.io.Source(baseDirectory.value, "global.txt", NothingFilter, false) + +watchSources in setStringValue += new sbt.internal.io.Source(baseDirectory.value, "foo.txt", NothingFilter, false) + +setStringValue := setStringValueImpl.evaluated + +checkStringValue := checkStringValueImpl.evaluated + +watchOnTriggerEvent := { (_, _) => Watched.CancelWatch } +watchOnInputEvent := { (_, _) => Watched.CancelWatch } +watchOnMetaBuildEvent := { (_, _) => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala b/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala new file mode 100644 index 000000000..17643092a --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/project/Build.scala @@ -0,0 +1,17 @@ +package sbt.legacy.sources + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/legacy-sources/test b/sbt/src/sbt-test/watch/legacy-sources/test new file mode 100644 index 000000000..834087ccb --- /dev/null +++ b/sbt/src/sbt-test/watch/legacy-sources/test @@ -0,0 +1,3 @@ +> ~ setStringValue foo.txt foo + +> checkStringValue foo.txt foo \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/on-start-watch/build.sbt b/sbt/src/sbt-test/watch/on-start-watch/build.sbt index 1c6dab6c1..b66bb6199 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/build.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/build.sbt @@ -1,11 +1,6 @@ -import scala.util.Try - val checkCount = inputKey[Unit]("check that compile has run a specified number of times") -val checkReloadCount = inputKey[Unit]("check whether the project was reloaded") val failingTask = taskKey[Unit]("should always fail") -val maybeReload = settingKey[(Int, Boolean) => Watched.Action]("possibly reload") val resetCount = taskKey[Unit]("reset compile count") -val reloadFile = settingKey[File]("get the current reload file") checkCount := { val expected = Def.spaceDelimited().parsed.head.toInt @@ -13,12 +8,6 @@ checkCount := { throw new IllegalStateException(s"Expected ${expected} compilation runs, got ${Count.get}") } -maybeReload := { (_, _) => - if (Count.reloadCount(reloadFile.value) == 0) Watched.Reload else Watched.CancelWatch -} - -reloadFile := baseDirectory.value / "reload-count" - resetCount := { Count.reset() } @@ -27,24 +16,6 @@ failingTask := { throw new IllegalStateException("failed") } -watchPreWatch := maybeReload.value - -checkReloadCount := { - val expected = Def.spaceDelimited().parsed.head.toInt - assert(Count.reloadCount(reloadFile.value) == expected) -} - -val addReloadShutdownHook = Command.command("addReloadShutdownHook") { state => - state.addExitHook { - val base = Project.extract(state).get(baseDirectory) - val file = base / "reload-count" - val currentCount = Try(Count.reloadCount(file)).getOrElse(0) - IO.write(file, s"${currentCount + 1}".getBytes) - } -} - -commands += addReloadShutdownHook - Compile / compile := { Count.increment() // Trigger a new build by updating the last modified time diff --git a/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt b/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt new file mode 100644 index 000000000..e8b658ba1 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt @@ -0,0 +1,4 @@ +val checkReloaded = taskKey[Unit]("Asserts that the build was reloaded") +checkReloaded := { () } + +watchOnIteration := { _ => Watched.CancelWatch } diff --git a/sbt/src/sbt-test/watch/on-start-watch/extra.sbt b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt new file mode 100644 index 000000000..6af4f2331 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt @@ -0,0 +1 @@ +watchOnStart := { _ => () => Watched.Reload } diff --git a/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala b/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala index 67d3bf940..db2258f5c 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala +++ b/sbt/src/sbt-test/watch/on-start-watch/project/Count.scala @@ -4,7 +4,11 @@ import scala.util.Try object Count { private var count = 0 def get: Int = count - def increment(): Unit = count += 1 - def reset(): Unit = count = 0 + def increment(): Unit = { + count += 1 + } + def reset(): Unit = { + count = 0 + } def reloadCount(file: File): Int = Try(IO.read(file).toInt).getOrElse(0) } diff --git a/sbt/src/sbt-test/watch/on-start-watch/test b/sbt/src/sbt-test/watch/on-start-watch/test index 37781fce3..f550e66b6 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/test +++ b/sbt/src/sbt-test/watch/on-start-watch/test @@ -1,28 +1,24 @@ -# verify that reloading occurs if watchPreWatch returns Watched.Reload -> addReloadShutdownHook -> checkReloadCount 0 +# verify that reloading occurs if watchOnStart returns Watched.Reload +$ copy-file changes/extra.sbt extra.sbt + > ~compile -> checkReloadCount 1 +> checkReloaded # verify that the watch terminates when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watched.CancelWatch else Watched.Ignore } > ~compile > checkCount 2 # verify that the watch terminates and returns an error when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.HandleError else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) new Watched.HandleError(new Exception("")) else Watched.Ignore } # Returning Watched.HandleError causes the '~' command to fail -> ~compile > checkCount 2 # verify that a re-build is triggered when we reach the specified count > resetCount -> set watchPreWatch := { (count: Int, _) => if (count == 2) Watched.Trigger else if (count == 3) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watched.Trigger else if (count == 3) Watched.CancelWatch else Watched.Ignore } > ~compile > checkCount 3 - -# verify that the watch exits and returns an error if the task fails -> set watchPreWatch := { (_, lastStatus: Boolean) => if (lastStatus) Watched.Ignore else Watched.HandleError } --> ~failingTask diff --git a/sbt/src/sbt-test/watch/task/build.sbt b/sbt/src/sbt-test/watch/task/build.sbt new file mode 100644 index 000000000..3e1169f6d --- /dev/null +++ b/sbt/src/sbt-test/watch/task/build.sbt @@ -0,0 +1,3 @@ +import sbt.watch.task.Build + +val root = Build.root diff --git a/sbt/src/sbt-test/watch/task/changes/Build.scala b/sbt/src/sbt-test/watch/task/changes/Build.scala new file mode 100644 index 000000000..1c5162d37 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/changes/Build.scala @@ -0,0 +1,27 @@ +package sbt.watch.task + +import sbt._ +import Keys._ + +object Build { + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val root = (project in file(".")).settings( + setStringValue / watchTriggers += baseDirectory.value * "foo.txt", + setStringValue := setStringValueImpl.evaluated, + checkStringValue := checkStringValueImpl.evaluated, + watchStartMessage := { _ => + IO.touch(baseDirectory.value / "foo.txt", true) + Some("watching") + }, + watchOnStart := { _ => () => Watched.CancelWatch } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/task/project/Build.scala b/sbt/src/sbt-test/watch/task/project/Build.scala new file mode 100644 index 000000000..f0beda1c1 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/project/Build.scala @@ -0,0 +1,34 @@ +package sbt.watch.task + +import sbt._ +import Keys._ + +object Build { + val reloadFile = settingKey[File]("file to toggle whether or not to reload") + val setStringValue = inputKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + def setStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed.map(_.trim) + IO.write(file(stringFile), string) + } + def checkStringValueImpl: Def.Initialize[InputTask[Unit]] = Def.inputTask { + val Seq(stringFile, string) = Def.spaceDelimited().parsed + assert(IO.read(file(stringFile)) == string) + } + lazy val root = (project in file(".")).settings( + reloadFile := baseDirectory.value / "reload", + setStringValue / watchTriggers += baseDirectory.value * "foo.txt", + setStringValue := setStringValueImpl.evaluated, + checkStringValue := checkStringValueImpl.evaluated, + watchStartMessage := { _ => + IO.touch(baseDirectory.value / "foo.txt", true) + Some("watching") + }, + watchOnTriggerEvent := { (f, e) => + if (reloadFile.value.exists) Watched.CancelWatch else { + IO.touch(reloadFile.value, true) + Watched.Reload + } + } + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/task/test b/sbt/src/sbt-test/watch/task/test new file mode 100644 index 000000000..b3e02de19 --- /dev/null +++ b/sbt/src/sbt-test/watch/task/test @@ -0,0 +1,12 @@ +# this tests that if the watch _task_ is able to reload the project + +# the original version of the build will only return Watched.Reload for trigger events while the +# updated version will return Watched.CancelWatch. If this test exits, it more or less works. +$ copy-file changes/Build.scala project/Build.scala + +# setStringValue has foo.txt as a watch source so running that command should first trigger a +# reload. After the project has been reloaded, the next write to setStringValue will also +# trigger a CancelWatch event, hence we exit. +> watch root / setStringValue foo.txt bar + +> checkStringValue foo.txt bar diff --git a/sbt/src/sbt-test/watch/watch-parser/test b/sbt/src/sbt-test/watch/watch-parser/test deleted file mode 100644 index 4d5358af7..000000000 --- a/sbt/src/sbt-test/watch/watch-parser/test +++ /dev/null @@ -1,21 +0,0 @@ -> ~; setStringValue foo; setStringValue bar - -> checkStringValue bar - -> ~;setStringValue foo;setStringValue bar; checkStringValue bar - -> ~; setStringValue foo;setStringValue bar; checkStringValue bar - -> ~; setStringValue foo; setStringValue bar; checkStringValue bar - -# no leading semicolon -> ~ setStringValue foo; setStringValue bar; checkStringValue bar - -> ~ setStringValue foo - -> checkStringValue foo - -# All of the other tests have involved input tasks, so include commands with regular tasks as well. -> ~; compile; setStringValue baz; checkStringValue baz -# Ensure that trailing semi colons work -> ~ compile; setStringValue baz; checkStringValue baz; From 40d8d8876d93fc2d2f01308f070502cd13f01b61 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Wed, 27 Mar 2019 20:42:10 -0700 Subject: [PATCH 06/11] Create Watch.scala I decided that it makes sense to move all of the new watch code out of the Watched companion object since the Watched trait itself is now deprecated. I don't really like having the new code in Watched.scala mixed with the legacy code, so I pulled it all out and moved it into the Watch object. Since we have to put all of the logic for the Continuous object in main in order to access the sbt.Keys object, it makes sense to move the logic out of main-command and into command so that most of the watch related logic is in the same subproject. --- main-command/src/main/scala/sbt/Watched.scala | 375 +----------------- main/src/main/scala/sbt/Defaults.scala | 20 +- main/src/main/scala/sbt/Keys.scala | 18 +- main/src/main/scala/sbt/Watch.scala | 373 +++++++++++++++++ .../scala/sbt/internal/CommandExchange.scala | 36 +- .../main/scala/sbt/internal/Continuous.scala | 161 ++++---- .../main/scala/sbt/internal/GlobLister.scala | 24 +- .../src/test/scala/sbt/WatchSpec.scala | 26 +- .../sbt-test/watch/command-parser/build.sbt | 2 +- .../watch/custom-config/project/Build.scala | 6 +- .../input-aggregation/project/Build.scala | 10 +- sbt/src/sbt-test/watch/input-parser/build.sbt | 10 +- .../watch/input-parser/project/Build.scala | 16 +- sbt/src/sbt-test/watch/input-parser/test | 4 +- .../sbt-test/watch/legacy-sources/build.sbt | 6 +- .../watch/on-start-watch/changes/extra.sbt | 2 +- .../sbt-test/watch/on-start-watch/extra.sbt | 2 +- .../watch/on-start-watch/project/Count.scala | 8 +- sbt/src/sbt-test/watch/on-start-watch/test | 10 +- .../sbt-test/watch/task/changes/Build.scala | 4 +- .../sbt-test/watch/task/project/Build.scala | 4 +- sbt/src/sbt-test/watch/task/test | 4 +- 22 files changed, 563 insertions(+), 558 deletions(-) create mode 100644 main/src/main/scala/sbt/Watch.scala rename main-command/src/test/scala/sbt/WatchedSpec.scala => main/src/test/scala/sbt/WatchSpec.scala (90%) diff --git a/main-command/src/main/scala/sbt/Watched.scala b/main-command/src/main/scala/sbt/Watched.scala index abc1f9412..1543a3bba 100644 --- a/main-command/src/main/scala/sbt/Watched.scala +++ b/main-command/src/main/scala/sbt/Watched.scala @@ -7,25 +7,18 @@ package sbt -import java.io.{ File, InputStream } +import java.io.File import java.nio.file.FileSystems -import sbt.BasicCommandStrings.ContinuousExecutePrefix import sbt.internal.LabeledFunctions._ -import sbt.internal.{ FileAttributes, LegacyWatched } +import sbt.internal.LegacyWatched import sbt.internal.io.{ EventMonitor, Source, WatchState } import sbt.internal.util.Types.const -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.internal.util.AttributeKey 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 { @@ -58,283 +51,13 @@ trait Watched { object Watched { - /** - * This trait is used to communicate what the watch should do next at various points in time. It - * is heavily linked to a number of callbacks in [[WatchConfig]]. For example, when the event - * monitor detects a changed source we expect [[WatchConfig.onWatchEvent]] to return [[Trigger]]. - */ - 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. - */ - 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. - */ - 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. - */ - 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 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 ContinueWatch - - /** - * A user defined Action. It is not sealed so that the user can create custom instances. If any - * of the [[Watched.watch]] callbacks return [[Custom]], then watch will terminate. - */ - 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) - /** - * A constant function that returns [[Trigger]]. - */ - final val trigger: (Int, Event[FileAttributes]) => Watched.Action = { - (_: Int, _: Event[FileAttributes]) => - Trigger - }.label("Watched.trigger") - - 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 - } - - 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$options)" + s"Waiting for source changes$project... (press enter to interrupt)" - /** - * 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") - - /** - * 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") @@ -361,87 +84,6 @@ 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 = { @@ -479,9 +121,9 @@ object Watched { ((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") + 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 @@ -522,7 +164,8 @@ object Watched { @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") + ((ws: WatchState) => s"${ws.count}. ${waitMessage("")} ") + .label("Watched.projectWatchingMessage") @deprecated( "Use defaultOnTriggerMessage in conjunction with the watchTriggeredMessage key", "1.3.0" diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 516f4d919..0d151743e 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -333,21 +333,21 @@ 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, + pollInterval :== Watch.defaultPollInterval, + watchAntiEntropy :== Watch.defaultAntiEntropy, + watchAntiEntropyRetentionPeriod :== Watch.defaultAntiEntropyRetentionPeriod, watchLogLevel :== Level.Info, - watchOnEnter :== Watched.defaultOnEnter, - watchOnMetaBuildEvent :== Watched.ifChanged(Watched.Reload), - watchOnInputEvent :== Watched.trigger, - watchOnTriggerEvent :== Watched.trigger, - watchDeletionQuarantinePeriod :== Watched.defaultDeletionQuarantinePeriod, + watchOnEnter :== Watch.defaultOnEnter, + watchOnMetaBuildEvent :== Watch.ifChanged(Watch.Reload), + watchOnInputEvent :== Watch.trigger, + watchOnTriggerEvent :== Watch.trigger, + watchDeletionQuarantinePeriod :== Watch.defaultDeletionQuarantinePeriod, watchService :== Watched.newWatchService, - watchStartMessage :== Watched.defaultStartWatch, + watchStartMessage :== Watch.defaultStartWatch, watchTasks := Continuous.continuousTask.evaluated, aggregate in watchTasks :== false, watchTrackMetaBuild :== true, - watchTriggeredMessage :== Watched.defaultOnTriggerMessage, + watchTriggeredMessage :== Watch.defaultOnTriggerMessage, ) ) diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 0d78d65fc..e25f962cf 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -100,19 +100,19 @@ object Keys { 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 watchInputHandler = settingKey[InputStream => Watch.Action]("Function that is periodically invoked to determine if the continuous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands. This is only invoked if watchInputStream is set.").withRank(DSetting) val 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 watchInputParser = settingKey[Parser[Watch.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 watchOnInputEvent = settingKey[(Int, Event[FileAttributes]) => Watch.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] => Watch.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]) => Watch.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[(Watch.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 watchOnTriggerEvent = settingKey[(Int, Event[FileAttributes]) => Watch.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 => 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) // 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. diff --git a/main/src/main/scala/sbt/Watch.scala b/main/src/main/scala/sbt/Watch.scala new file mode 100644 index 000000000..730993d9f --- /dev/null +++ b/main/src/main/scala/sbt/Watch.scala @@ -0,0 +1,373 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +import java.io.InputStream + +import sbt.BasicCommandStrings.ContinuousExecutePrefix +import sbt.internal.FileAttributes +import sbt.internal.LabeledFunctions._ +import sbt.internal.util.{ JLine, Util } +import sbt.internal.util.complete.Parser +import sbt.internal.util.complete.Parser._ +import sbt.io.FileEventMonitor.{ Creation, Deletion, Event, Update } +import sbt.util.{ Level, Logger } + +import scala.annotation.tailrec +import scala.concurrent.duration._ +import scala.util.control.NonFatal + +object Watch { + + /** + * This trait is used to control the state of [[Watch.apply]]. The [[Watch.Trigger]] action + * indicates that [[Watch.apply]] should re-run the input task. The [[Watch.CancelWatch]] + * actions indicate that [[Watch.apply]] should exit and return the [[Watch.CancelWatch]] + * instance that caused the function to exit. The [[Watch.Ignore]] action is used to indicate + * that the method should keep polling for new actions. + */ + 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. + */ + 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. + */ + 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. + */ + 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 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 + // 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 ContinueWatch + + /** + * A user defined Action. It is not sealed so that the user can create custom instances. If + * the onStart or nextAction function passed into [[Watch.apply]] returns [[Watch.Custom]], then + * the watch will terminate. + */ + trait Custom extends CancelWatch + + private type NextAction = () => Watch.Action + + /** + * 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 [[Watch.Action]]. + * @return the exit [[Watch.Action]] that can be used to potentially modify the build state and + * the count of the number of iterations that were run. If + */ + def apply(task: () => Unit, onStart: NextAction, nextAction: NextAction): Watch.Action = { + def safeNextAction(delegate: NextAction): Watch.Action = + try delegate() + catch { case NonFatal(t) => new HandleError(t) } + @tailrec def next(): Watch.Action = safeNextAction(nextAction) match { + // This should never return Ignore due to this condition. + case Ignore => next() + case action => action + } + @tailrec def impl(): Watch.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]] > [[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) + + /** + * A constant function that returns [[Trigger]]. + */ + final val trigger: (Int, Event[FileAttributes]) => Watch.Action = { + (_: Int, _: Event[FileAttributes]) => + Trigger + }.label("Watched.trigger") + + def ifChanged(action: Action): (Int, Event[FileAttributes]) => Watch.Action = + (_: Int, event: Event[FileAttributes]) => + event match { + case Update(prev, cur, _) if prev.value != cur.value => action + case _: Creation[_] | _: Deletion[_] => action + case _ => Ignore + } + + /** + * 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 + } + + 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)" + + /** + * 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") + + /** + * 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 [[Watch.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 + * [[Watch.Action]] is [[HandleError]], the handler returns the result of [[StateOps.fail]]. + * When the [[Watch.Action]] is [[Watch.Run]], we add the commands specified by + * [[Watch.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(Watched.clearScreen)).label("Watched.clearOnTrigger") +} diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 5f601a74e..be1a15214 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -9,38 +9,28 @@ package sbt package internal import java.io.IOException +import java.net.Socket import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic._ -import scala.collection.mutable.ListBuffer -import scala.annotation.tailrec -import BasicKeys.{ - autoStartServer, - fullServerHandlers, - logLevel, - serverAuthentication, - serverConnectionType, - serverHost, - serverLogLevel, - serverPort -} -import java.net.Socket - -import sbt.Watched.NullLogger +import sbt.BasicKeys._ +import sbt.Watch.NullLogger +import sbt.internal.langserver.{ LogMessageParams, MessageType } +import sbt.internal.server._ +import sbt.internal.util.codec.JValueFormats +import sbt.internal.util.{ MainAppender, ObjectEvent, StringEvent } +import sbt.io.syntax._ +import sbt.io.{ Hash, IO } +import sbt.protocol.{ EventMessage, ExecStatusEvent } +import sbt.util.{ Level, LogExchange, Logger } import sjsonnew.JsonFormat import sjsonnew.shaded.scalajson.ast.unsafe._ +import scala.annotation.tailrec +import scala.collection.mutable.ListBuffer import scala.concurrent.Await import scala.concurrent.duration._ import scala.util.{ Failure, Success, Try } -import sbt.io.syntax._ -import sbt.io.{ Hash, IO } -import sbt.internal.server._ -import sbt.internal.langserver.{ LogMessageParams, MessageType } -import sbt.internal.util.{ MainAppender, ObjectEvent, StringEvent } -import sbt.internal.util.codec.JValueFormats -import sbt.protocol.{ EventMessage, ExecStatusEvent } -import sbt.util.{ Level, LogExchange, Logger } /** * The command exchange merges multiple command channels (e.g. network and console), diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 277fa968b..ce6eaf86b 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -20,13 +20,13 @@ import sbt.BasicCommandStrings.{ import sbt.BasicCommands.otherCommandParser import sbt.Def._ import sbt.Scope.Global -import sbt.Watched.Monitor import sbt.internal.FileManagement.FileTreeRepositoryOps +import sbt.internal.LabeledFunctions._ 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.internal.util.{ AttributeKey, AttributeMap, Util } import sbt.io._ import sbt.util.{ Level, _ } @@ -92,17 +92,17 @@ object Continuous extends DeprecatedContinuous { } /** - * Create a function from InputStream => [[Watched.Action]] from a [[Parser]]. This is intended + * Create a function from InputStream => [[Watch.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 = { + def defaultInputHandler(parser: Parser[Watch.Action]): InputStream => Watch.Action = { val builder = new StringBuilder val any = matched(Parsers.any.*) val fullParser = any ~> parser ~ any - inputStream => - parse(inputStream, builder, fullParser) + ((inputStream: InputStream) => parse(inputStream, builder, fullParser)) + .label("Continuous.defaultInputHandler") } /** @@ -254,7 +254,7 @@ object Continuous extends DeprecatedContinuous { command: String, count: Int, isCommand: Boolean - ): State = Watched.withCharBufferedStdIn { in => + ): State = Watch.withCharBufferedStdIn { in => val duped = new DupedInputStream(in) setup(state.put(DupedSystemIn, duped), command) { (s, commands, valid, invalid) => implicit val extracted: Extracted = Project.extract(s) @@ -276,7 +276,7 @@ object Continuous extends DeprecatedContinuous { // 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) + val terminationAction = Watch(task, callbacks.onStart, callbacks.nextEvent) callbacks.onTermination(terminationAction, command, currentCount.get(), state) } finally callbacks.onExit() } else { @@ -315,11 +315,11 @@ object Continuous extends DeprecatedContinuous { } private class Callbacks( - val nextEvent: () => Watched.Action, + val nextEvent: () => Watch.Action, val onEnter: () => Unit, val onExit: () => Unit, - val onStart: () => Watched.Action, - val onTermination: (Watched.Action, String, Int, State) => State + val onStart: () => Watch.Action, + val onTermination: (Watch.Action, String, Int, State) => State ) /** @@ -329,19 +329,19 @@ object Continuous extends DeprecatedContinuous { * 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 + * to each of the individual callbacks. For the callbacks that return a [[Watch.Action]], + * the aggregated callback will select the minimum [[Watch.Action]] returned where the ordering + * is such that the highest priority [[Watch.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]]. + * [[Watch.Action]] returned by the parsers (when the parsers fail, they just return + * [[Watch.Ignore]], which is the lowest priority [[Watch.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]] + * @return the [[Callbacks]] to pass into [[Watch.apply]] */ private def aggregate( configs: Seq[Config], @@ -355,11 +355,11 @@ object Continuous extends DeprecatedContinuous { ): 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) = + val onStart: () => Watch.Action = getOnStart(configs, logger, count) + val nextInputEvent: () => Watch.Action = parseInputEvents(configs, state, inputStream, logger) + val (nextFileEvent, cleanupFileMonitor): (() => Watch.Action, () => Unit) = getFileEvents(configs, logger, state, count) - val nextEvent: () => Watched.Action = + val nextEvent: () => Watch.Action = combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger) val onExit = () => { cleanupFileMonitor() @@ -372,7 +372,7 @@ object Continuous extends DeprecatedContinuous { private def getOnTermination( configs: Seq[Config], isCommand: Boolean - ): (Watched.Action, String, Int, State) => State = { + ): (Watch.Action, String, Int, State) => State = { configs.flatMap(_.watchSettings.onTermination).distinct match { case Seq(head, tail @ _*) => tail.foldLeft(head) { @@ -381,7 +381,7 @@ object Continuous extends DeprecatedContinuous { configOnTermination(action, cmd, count, onTermination(action, cmd, count, state)) } case _ => - if (isCommand) Watched.defaultCommandOnTermination else Watched.defaultTaskOnTermination + if (isCommand) Watch.defaultCommandOnTermination else Watch.defaultTaskOnTermination } } @@ -389,7 +389,7 @@ object Continuous extends DeprecatedContinuous { configs: Seq[Config], logger: Logger, count: AtomicInteger - ): () => Watched.Action = { + ): () => Watch.Action = { val f = configs.map { params => val ws = params.watchSettings ws.onStart.map(_.apply(params.arguments(logger))).getOrElse { () => @@ -398,10 +398,10 @@ object Continuous extends DeprecatedContinuous { 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(_)) + case None => Watch.defaultStartWatch(count.get()).foreach(logger.info(_)) } } - Watched.Ignore + Watch.Ignore } } } @@ -409,7 +409,7 @@ object Continuous extends DeprecatedContinuous { { 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(_)) + if (configs.size > 1) Watch.defaultStartWatch(count.get()).foreach(logger.info(_)) res } } @@ -418,7 +418,7 @@ object Continuous extends DeprecatedContinuous { logger: Logger, state: State, count: AtomicInteger, - )(implicit extracted: Extracted): (() => Watched.Action, () => Unit) = { + )(implicit extracted: Extracted): (() => Watch.Action, () => Unit) = { val trackMetaBuild = configs.forall(_.watchSettings.trackMetaBuild) val buildGlobs = if (trackMetaBuild) extracted.getOpt(Keys.fileInputs in Keys.settingsData).getOrElse(Nil) @@ -430,7 +430,7 @@ object Continuous extends DeprecatedContinuous { * 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 onTrigger: Event => Watch.Action = { val f: Seq[Event => Unit] = configs.map { params => val ws = params.watchSettings ws.onTrigger @@ -449,38 +449,39 @@ object Continuous extends DeprecatedContinuous { } event: Event => f.view.foreach(_.apply(event)) - Watched.Trigger + Watch.Trigger } - val onEvent: Event => (Event, Watched.Action) = { + val defaultTrigger = if (Util.isWindows) Watch.ifChanged(Watch.Trigger) else Watch.trigger + val onEvent: Event => (Event, Watch.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 onInputEvent = ws.onInputEvent.getOrElse(defaultTrigger) + val onTriggerEvent = ws.onTriggerEvent.getOrElse(defaultTrigger) + val onMetaBuildEvent = ws.onMetaBuildEvent.getOrElse(Watch.ifChanged(Watch.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 + Seq[Watch.Action]( + if (inputFilter(event.entry)) onInputEvent(c, event) else Watch.Ignore, + if (triggerFilter(event.entry)) onTriggerEvent(c, event) else Watch.Ignore, + if (buildFilter(event.entry)) onMetaBuildEvent(c, event) else Watch.Ignore ).min } event: Event => event -> (oe(event) match { - case Watched.Trigger => onTrigger(event) - case a => a + case Watch.Trigger => onTrigger(event) + case a => a }) } event: Event => f.view.map(_.apply(event)).minBy(_._2) } - val monitor: Monitor = new FileEventMonitor[FileAttributes] { + val monitor: FileEventMonitor[FileAttributes] = new FileEventMonitor[FileAttributes] { private def setup( monitor: FileEventMonitor[FileAttributes], globs: Seq[Glob] @@ -527,11 +528,11 @@ object Continuous extends DeprecatedContinuous { ) (() => { val actions = antiEntropyMonitor.poll(2.milliseconds).map(onEvent) - if (actions.exists(_._2 != Watched.Ignore)) { + 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 Watched.Ignore + } else Watch.Ignore }, () => monitor.close()) } @@ -539,16 +540,16 @@ object Continuous extends DeprecatedContinuous { * 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]`. + * one from `String => Seq[Watch.Action]` and another from `() => Seq[Watch.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 + * we update the buffer and then try to parse a Watch.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. + * `() => Seq[Watch.Action]` which avoids actually exposing the InputStream anywhere. */ private def parseInputEvents( configs: Seq[Config], @@ -557,15 +558,15 @@ object Continuous extends DeprecatedContinuous { logger: Logger )( implicit extracted: Extracted - ): () => Watched.Action = { + ): () => Watch.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 + type ActionParser = String => Watch.Action // Transform the Config.watchSettings.inputParser instances to functions of type - // String => Watched.Action. The String that is provided will contain any characters that + // String => Watch.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 @@ -581,7 +582,7 @@ object Continuous extends DeprecatedContinuous { 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 = + val default: String => Watch.Action = string => parse(inputStream(string), systemInBuilder, parser) val alternative = c.watchSettings.inputStream .map { inputStreamKey => @@ -590,7 +591,7 @@ object Continuous extends DeprecatedContinuous { () => handler(is) } - .getOrElse(() => Watched.Ignore) + .getOrElse(() => Watch.Ignore) (string: String) => (default(string) :: alternative() :: Nil).min } @@ -599,32 +600,32 @@ object Continuous extends DeprecatedContinuous { 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 parse: ActionParser => Watch.Action = parser => parser(newBytes) + val allEvents = inputHandlers.map(parse).filterNot(_ == Watch.Ignore) + if (allEvents.exists(_ != Watch.Ignore)) { val res = allEvents.min logger.debug(s"Received input events: ${allEvents mkString ","}. Taking $res") res - } else Watched.Ignore + } else Watch.Ignore } } private def combineInputAndFileEvents( - nextInputEvent: () => Watched.Action, - nextFileEvent: () => Watched.Action, + nextInputEvent: () => Watch.Action, + nextFileEvent: () => Watch.Action, logger: Logger - ): () => Watched.Action = () => { - val Seq(inputEvent: Watched.Action, fileEvent: Watched.Action) = + ): () => Watch.Action = () => { + val Seq(inputEvent: Watch.Action, fileEvent: Watch.Action) = Seq(nextInputEvent, nextFileEvent).par.map(_.apply()).toIndexedSeq - val min: Watched.Action = Seq[Watched.Action](inputEvent, fileEvent).min + val min: Watch.Action = Seq[Watch.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) + if (inputEvent != Watch.Ignore) logger.debug(inputMessage) + if (fileEvent != Watch.Ignore) logger.debug(fileMessage) min } @@ -632,8 +633,8 @@ object Continuous extends DeprecatedContinuous { private final def parse( is: InputStream, builder: StringBuilder, - parser: Parser[(Watched.Action, String)] - ): Watched.Action = { + parser: Parser[(Watch.Action, String)] + ): Watch.Action = { if (is.available > 0) builder += is.read().toChar Parser.parse(builder.toString, parser) match { case Right((action, rest)) => @@ -641,7 +642,7 @@ object Continuous extends DeprecatedContinuous { builder ++= rest action case _ if is.available > 0 => parse(is, builder, parser) - case _ => Watched.Ignore + case _ => Watch.Ignore } } @@ -672,14 +673,14 @@ object Continuous extends DeprecatedContinuous { } } - private type WatchOnEvent = (Int, Event) => Watched.Action + private type WatchOnEvent = (Int, Event) => Watch.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 + * instance that is used to produce the arguments to [[Watch.apply]]. 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 + * `(Int, Boolean) => Watch.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]] @@ -703,25 +704,25 @@ object Continuous extends DeprecatedContinuous { implicit extracted: Extracted ) { val antiEntropy: FiniteDuration = - key.get(Keys.watchAntiEntropy).getOrElse(Watched.defaultAntiEntropy) + key.get(Keys.watchAntiEntropy).getOrElse(Watch.defaultAntiEntropy) val antiEntropyRetentionPeriod: FiniteDuration = key .get(Keys.watchAntiEntropyRetentionPeriod) - .getOrElse(Watched.defaultAntiEntropyRetentionPeriod) + .getOrElse(Watch.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) + key.get(Keys.watchDeletionQuarantinePeriod).getOrElse(Watch.defaultDeletionQuarantinePeriod) + val inputHandler: Option[InputStream => Watch.Action] = key.get(Keys.watchInputHandler) + val inputParser: Parser[Watch.Action] = + key.get(Keys.watchInputParser).getOrElse(Watch.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 onEvent: Option[Arguments => Event => Watch.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 onIteration: Option[Int => Watch.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] = + val onStart: Option[Arguments => () => Watch.Action] = key.get(Keys.watchOnStart) + val onTermination: Option[(Watch.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) @@ -758,12 +759,12 @@ object Continuous extends DeprecatedContinuous { 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) + 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 { lazy val default = - key.get(Keys.watchTriggeredMessage).getOrElse(Watched.defaultOnTriggerMessage) + key.get(Keys.watchTriggeredMessage).getOrElse(Watch.defaultOnTriggerMessage) key.get(deprecatedWatchingMessage).map(Left(_)).getOrElse(Right(default)) } diff --git a/main/src/main/scala/sbt/internal/GlobLister.scala b/main/src/main/scala/sbt/internal/GlobLister.scala index 90d0aaa3e..03483b312 100644 --- a/main/src/main/scala/sbt/internal/GlobLister.scala +++ b/main/src/main/scala/sbt/internal/GlobLister.scala @@ -20,47 +20,47 @@ import sbt.io.Glob private[sbt] sealed trait GlobLister extends Any { /** - * Get the sources described this [[GlobLister]]. + * Get the sources described this `GlobLister`. * * @param repository the [[FileTree.Repository]] to delegate file i/o. - * @return the files described by this [[GlobLister]]. + * @return the files described by this `GlobLister`. */ def all(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] /** - * Get the unique sources described this [[GlobLister]]. + * Get the unique sources described this `GlobLister`. * * @param repository the [[FileTree.Repository]] to delegate file i/o. - * @return the files described by this [[GlobLister]] with any duplicates removed. + * @return the files described by this `GlobLister` with any duplicates removed. */ def unique(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] } /** - * Provides implicit definitions to provide a [[GlobLister]] given a Glob or + * Provides implicit definitions to provide a `GlobLister` given a Glob or * Traversable[Glob]. */ -object GlobLister extends GlobListers +private[sbt] object GlobLister extends GlobListers /** - * Provides implicit definitions to provide a [[GlobLister]] given a Glob or + * Provides implicit definitions to provide a `GlobLister` given a Glob or * Traversable[Glob]. */ private[sbt] trait GlobListers { import GlobListers._ /** - * Generate a [[GlobLister]] given a particular [[Glob]]s. + * Generate a GlobLister given a particular [[Glob]]s. * * @param source the input Glob */ implicit def fromGlob(source: Glob): GlobLister = new impl(source :: Nil) /** - * Generate a [[GlobLister]] given a collection of Globs. If the input collection type - * preserves uniqueness, e.g. `Set[Glob]`, then the output of [[GlobLister.all]] will be + * Generate a GlobLister given a collection of Globs. If the input collection type + * preserves uniqueness, e.g. `Set[Glob]`, then the output of `GlobLister.all` will be * the unique source list. Otherwise duplicates are possible in all and it is necessary to call - * [[GlobLister.unique]] to de-duplicate the files. + * `GlobLister.unique` to de-duplicate the files. * * @param sources the collection of sources * @tparam T the source collection type @@ -71,7 +71,7 @@ private[sbt] trait GlobListers { private[internal] object GlobListers { /** - * Implements [[GlobLister]] given a collection of Globs. If the input collection type + * Implements `GlobLister` given a collection of Globs. If the input collection type * preserves uniqueness, e.g. `Set[Glob]`, then the output will be the unique source list. * Otherwise duplicates are possible. * diff --git a/main-command/src/test/scala/sbt/WatchedSpec.scala b/main/src/test/scala/sbt/WatchSpec.scala similarity index 90% rename from main-command/src/test/scala/sbt/WatchedSpec.scala rename to main/src/test/scala/sbt/WatchSpec.scala index 538321c9a..c5139a228 100644 --- a/main-command/src/test/scala/sbt/WatchedSpec.scala +++ b/main/src/test/scala/sbt/WatchSpec.scala @@ -12,8 +12,8 @@ import java.nio.file.{ Files, Path } import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger } import org.scalatest.{ FlatSpec, Matchers } -import sbt.Watched._ -import sbt.WatchedSpec._ +import sbt.Watch.{ NullLogger, _ } +import sbt.WatchSpec._ import sbt.internal.FileAttributes import sbt.io.FileEventMonitor.Event import sbt.io._ @@ -23,18 +23,18 @@ import sbt.util.Logger import scala.collection.mutable import scala.concurrent.duration._ -class WatchedSpec extends FlatSpec with Matchers { - private type NextAction = () => Watched.Action - private def watch(task: Task, callbacks: (NextAction, NextAction)): Watched.Action = - Watched.watch(task, callbacks._1, callbacks._2) +class WatchSpec extends FlatSpec with Matchers { + private type NextAction = () => Watch.Action + private def watch(task: Task, callbacks: (NextAction, NextAction)): Watch.Action = + Watch(task, callbacks._1, callbacks._2) object TestDefaults { def callbacks( inputs: Seq[Glob], fileEventMonitor: Option[FileEventMonitor[FileAttributes]] = None, logger: Logger = NullLogger, - parseEvent: () => Action = () => Ignore, - onStartWatch: () => Action = () => CancelWatch: Action, - onWatchEvent: Event[FileAttributes] => Action = _ => Ignore, + parseEvent: () => Watch.Action = () => Ignore, + onStartWatch: () => Watch.Action = () => CancelWatch: Watch.Action, + onWatchEvent: Event[FileAttributes] => Watch.Action = _ => Ignore, triggeredMessage: Event[FileAttributes] => Option[String] = _ => None, watchingMessage: () => Option[String] = () => None ): (NextAction, NextAction) = { @@ -57,7 +57,7 @@ class WatchedSpec extends FlatSpec with Matchers { val onTrigger: Event[FileAttributes] => Unit = event => { triggeredMessage(event).foreach(logger.info(_)) } - val onStart: () => Watched.Action = () => { + val onStart: () => Watch.Action = () => { watchingMessage().foreach(logger.info(_)) onStartWatch() } @@ -66,7 +66,7 @@ class WatchedSpec extends FlatSpec with Matchers { val fileActions = monitor.poll(10.millis).map { e: Event[FileAttributes] => onWatchEvent(e) match { case Trigger => onTrigger(e); Trigger - case act => act + case action => action } } (inputAction +: fileActions).min @@ -86,7 +86,7 @@ class WatchedSpec extends FlatSpec with Matchers { } def getCount: Int = count.get() } - "Watched.watch" should "stop" in IO.withTemporaryDirectory { dir => + "Watch" should "stop" in IO.withTemporaryDirectory { dir => val task = new Task watch(task, TestDefaults.callbacks(inputs = Seq(dir.toRealPath ** AllPassFilter))) shouldBe CancelWatch } @@ -168,7 +168,7 @@ class WatchedSpec extends FlatSpec with Matchers { } } -object WatchedSpec { +object WatchSpec { implicit class FileOps(val f: File) { def toRealPath: File = f.toPath.toRealPath().toFile } diff --git a/sbt/src/sbt-test/watch/command-parser/build.sbt b/sbt/src/sbt-test/watch/command-parser/build.sbt index 1e26f0e48..dbd347c9b 100644 --- a/sbt/src/sbt-test/watch/command-parser/build.sbt +++ b/sbt/src/sbt-test/watch/command-parser/build.sbt @@ -10,4 +10,4 @@ checkStringValue := checkStringValueImpl.evaluated setStringValue / watchTriggers := baseDirectory.value * "string.txt" :: Nil -watchOnEvent := { _ => _ => Watched.CancelWatch } +watchOnEvent := { _ => _ => Watch.CancelWatch } diff --git a/sbt/src/sbt-test/watch/custom-config/project/Build.scala b/sbt/src/sbt-test/watch/custom-config/project/Build.scala index 2696d5c75..a9acf4e87 100644 --- a/sbt/src/sbt-test/watch/custom-config/project/Build.scala +++ b/sbt/src/sbt-test/watch/custom-config/project/Build.scala @@ -31,10 +31,10 @@ object Build { setStringValueImpl.evaluated }, checkStringValue := checkStringValueImpl.evaluated, - watchOnEvent := { _ => _ => Watched.CancelWatch } + watchOnEvent := { _ => _ => Watch.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 } + watchOnEvent := { _ => _ => Watch.CancelWatch } ) -} \ No newline at end of file +} diff --git a/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala index eca66f9b5..d99c46b54 100644 --- a/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala +++ b/sbt/src/sbt-test/watch/input-aggregation/project/Build.scala @@ -30,9 +30,9 @@ object Build { setStringValueImpl.evaluated }, checkStringValue := checkStringValueImpl.evaluated, - watchOnTriggerEvent := { (_, _) => Watched.CancelWatch }, - watchOnInputEvent := { (_, _) => Watched.CancelWatch }, - Compile / compile / watchOnStart := { _ => () => Watched.CancelWatch }, + watchOnTriggerEvent := { (_, _) => Watch.CancelWatch }, + watchOnInputEvent := { (_, _) => Watch.CancelWatch }, + Compile / compile / watchOnStart := { _ => () => Watch.CancelWatch }, checkTriggers := { val actual = (Compile / compile / transitiveTriggers).value.toSet val base = baseDirectory.value.getParentFile @@ -83,7 +83,7 @@ object Build { checkGlobs := checkGlobsImpl.value ) lazy val root = (project in file(".")).aggregate(foo, bar).settings( - watchOnEvent := { _ => _ => Watched.CancelWatch }, + watchOnEvent := { _ => _ => Watch.CancelWatch }, checkTriggers := { val actual = (Compile / compile / transitiveTriggers).value val expected: Seq[Glob] = baseDirectory.value * "baz.txt" :: Nil @@ -91,4 +91,4 @@ object Build { }, checkGlobs := checkGlobsImpl.value ) -} \ No newline at end of file +} diff --git a/sbt/src/sbt-test/watch/input-parser/build.sbt b/sbt/src/sbt-test/watch/input-parser/build.sbt index 5de1267dc..0e7f77deb 100644 --- a/sbt/src/sbt-test/watch/input-parser/build.sbt +++ b/sbt/src/sbt-test/watch/input-parser/build.sbt @@ -1,9 +1 @@ -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 +val root = sbt.input.parser.Build.root diff --git a/sbt/src/sbt-test/watch/input-parser/project/Build.scala b/sbt/src/sbt-test/watch/input-parser/project/Build.scala index d430bdb76..2bb9dde58 100644 --- a/sbt/src/sbt-test/watch/input-parser/project/Build.scala +++ b/sbt/src/sbt-test/watch/input-parser/project/Build.scala @@ -5,15 +5,25 @@ import complete.Parser import complete.Parser._ import java.io.{ PipedInputStream, PipedOutputStream } +import Keys._ object Build { + val root = (project in file(".")).settings( + useSuperShell := false, + watchInputStream := inputStream, + watchStartMessage := { count => + Build.outputStream.write('\n'.toByte) + Build.outputStream.flush() + Some("default start message") + } + ) 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 + val byeParser: Parser[Watch.Action] = "bye" ^^^ Watch.CancelWatch + val helloParser: Parser[Watch.Action] = "hello" ^^^ Watch.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 helloOrByeParser: Parser[Watch.Action] = byeParser | helloParser val alternativeStartMessage: Int => Option[String] = { _ => outputStream.write("xybyexyblahxyhelloxy".getBytes) outputStream.flush() diff --git a/sbt/src/sbt-test/watch/input-parser/test b/sbt/src/sbt-test/watch/input-parser/test index 981496f0b..8169581ba 100644 --- a/sbt/src/sbt-test/watch/input-parser/test +++ b/sbt/src/sbt-test/watch/input-parser/test @@ -7,8 +7,8 @@ > 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. +# helloOrByeParser will produce Watch.Ignore and Watch.CancelWatch but the +# Watch.CancelWatch event should win. > ~ compile > set watchStartMessage := sbt.input.parser.Build.otherAlternativeStartMessage diff --git a/sbt/src/sbt-test/watch/legacy-sources/build.sbt b/sbt/src/sbt-test/watch/legacy-sources/build.sbt index 5ee39a863..bd3099063 100644 --- a/sbt/src/sbt-test/watch/legacy-sources/build.sbt +++ b/sbt/src/sbt-test/watch/legacy-sources/build.sbt @@ -8,6 +8,6 @@ setStringValue := setStringValueImpl.evaluated checkStringValue := checkStringValueImpl.evaluated -watchOnTriggerEvent := { (_, _) => Watched.CancelWatch } -watchOnInputEvent := { (_, _) => Watched.CancelWatch } -watchOnMetaBuildEvent := { (_, _) => Watched.CancelWatch } +watchOnTriggerEvent := { (_, _) => Watch.CancelWatch } +watchOnInputEvent := { (_, _) => Watch.CancelWatch } +watchOnMetaBuildEvent := { (_, _) => Watch.CancelWatch } 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 index e8b658ba1..36ddd06e3 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/changes/extra.sbt @@ -1,4 +1,4 @@ val checkReloaded = taskKey[Unit]("Asserts that the build was reloaded") checkReloaded := { () } -watchOnIteration := { _ => Watched.CancelWatch } +watchOnIteration := { _ => Watch.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 index 6af4f2331..d6ee90d91 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/extra.sbt +++ b/sbt/src/sbt-test/watch/on-start-watch/extra.sbt @@ -1 +1 @@ -watchOnStart := { _ => () => Watched.Reload } +watchOnStart := { _ => () => Watch.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 db2258f5c..67d3bf940 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,11 +4,7 @@ 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 f550e66b6..a917df61d 100644 --- a/sbt/src/sbt-test/watch/on-start-watch/test +++ b/sbt/src/sbt-test/watch/on-start-watch/test @@ -1,4 +1,4 @@ -# verify that reloading occurs if watchOnStart returns Watched.Reload +# verify that reloading occurs if watchOnStart returns Watch.Reload $ copy-file changes/extra.sbt extra.sbt > ~compile @@ -6,19 +6,19 @@ $ copy-file changes/extra.sbt extra.sbt # verify that the watch terminates when we reach the specified count > resetCount -> set watchOnIteration := { (count: Int) => if (count == 2) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watch.CancelWatch else Watch.Ignore } > ~compile > checkCount 2 # verify that the watch terminates and returns an error when we reach the specified count > resetCount -> set watchOnIteration := { (count: Int) => if (count == 2) new Watched.HandleError(new Exception("")) else Watched.Ignore } -# Returning Watched.HandleError causes the '~' command to fail +> set watchOnIteration := { (count: Int) => if (count == 2) new Watch.HandleError(new Exception("")) else Watch.Ignore } +# Returning Watch.HandleError causes the '~' command to fail -> ~compile > checkCount 2 # verify that a re-build is triggered when we reach the specified count > resetCount -> set watchOnIteration := { (count: Int) => if (count == 2) Watched.Trigger else if (count == 3) Watched.CancelWatch else Watched.Ignore } +> set watchOnIteration := { (count: Int) => if (count == 2) Watch.Trigger else if (count == 3) Watch.CancelWatch else Watch.Ignore } > ~compile > checkCount 3 diff --git a/sbt/src/sbt-test/watch/task/changes/Build.scala b/sbt/src/sbt-test/watch/task/changes/Build.scala index 1c5162d37..abb5f37b9 100644 --- a/sbt/src/sbt-test/watch/task/changes/Build.scala +++ b/sbt/src/sbt-test/watch/task/changes/Build.scala @@ -22,6 +22,6 @@ object Build { IO.touch(baseDirectory.value / "foo.txt", true) Some("watching") }, - watchOnStart := { _ => () => Watched.CancelWatch } + watchOnStart := { _ => () => Watch.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 index f0beda1c1..f1a6adbd9 100644 --- a/sbt/src/sbt-test/watch/task/project/Build.scala +++ b/sbt/src/sbt-test/watch/task/project/Build.scala @@ -25,9 +25,9 @@ object Build { Some("watching") }, watchOnTriggerEvent := { (f, e) => - if (reloadFile.value.exists) Watched.CancelWatch else { + if (reloadFile.value.exists) Watch.CancelWatch else { IO.touch(reloadFile.value, true) - Watched.Reload + Watch.Reload } } ) diff --git a/sbt/src/sbt-test/watch/task/test b/sbt/src/sbt-test/watch/task/test index b3e02de19..93022959f 100644 --- a/sbt/src/sbt-test/watch/task/test +++ b/sbt/src/sbt-test/watch/task/test @@ -1,7 +1,7 @@ # 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. +# the original version of the build will only return Watch.Reload for trigger events while the +# updated version will return Watch.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 From 9cdeb7120ed7bdb9291e24060a16a268eeb9cfcf Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sat, 12 Jan 2019 15:44:09 -0800 Subject: [PATCH 07/11] Add StateTransform class This commit cleans up the approach for transforming the sbt state upon completion of a task returning State. I add a new approach where a task can return an instance of StateTransform, which is just a wrapper around State. I then update EvaluateTask to apply this stateTransform rather than the (optional) state transformation that may be stored in the Task info parameter. By requiring that the user return StateTransform rather than State directly, we ensure that no existing tasks that depend on the state transformation function embedded in the Task info break. In sbt 2, I could see the possibility of making this automatic (and probably removing the state transformation function via attribute). The problem with using the transformState attribute key is that it is applied non-deterministically. This means that if you decorate a task returning State, then the state transformation may or may not be correctly applied. I tracked this non-determinism down to the stateTransform method in EvaluateTask. It iterates through the task result map and chains all of the defined transformState attribute values. Because the result is a map, this order is not specified. This chaining is arguably a bad design because State => State does not imply commutivity. Indeed, the problem here was that my state transformation functions were constant functions, which are obviously non-commutative. I believe that this logic likely written under the assumption that there would be no more than one of these tranformations in a given result map. --- main-settings/src/main/scala/sbt/Def.scala | 2 +- main/src/main/scala/sbt/EvaluateTask.scala | 10 +++++-- main/src/main/scala/sbt/Keys.scala | 2 +- main/src/main/scala/sbt/StateTransform.scala | 20 +++++++++++++ .../main/scala/sbt/internal/Continuous.scala | 16 ++++------ .../sbt-test/watch/on-termination/build.sbt | 3 ++ .../watch/on-termination/project/Build.scala | 29 +++++++++++++++++++ sbt/src/sbt-test/watch/on-termination/test | 7 +++++ 8 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 main/src/main/scala/sbt/StateTransform.scala create mode 100644 sbt/src/sbt-test/watch/on-termination/build.sbt create mode 100644 sbt/src/sbt-test/watch/on-termination/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/on-termination/test diff --git a/main-settings/src/main/scala/sbt/Def.scala b/main-settings/src/main/scala/sbt/Def.scala index 1451b5ffc..a42255f4e 100644 --- a/main-settings/src/main/scala/sbt/Def.scala +++ b/main-settings/src/main/scala/sbt/Def.scala @@ -14,7 +14,7 @@ import sbt.KeyRanks.{ DTask, Invisible } import sbt.Scope.{ GlobalScope, ThisScope } import sbt.internal.util.Types.const import sbt.internal.util.complete.Parser -import sbt.internal.util.{ AttributeKey, Attributed, ConsoleAppender, Init } +import sbt.internal.util._ import sbt.util.Show /** A concrete settings system that uses `sbt.Scope` for the scope type. */ diff --git a/main/src/main/scala/sbt/EvaluateTask.scala b/main/src/main/scala/sbt/EvaluateTask.scala index ada880ef8..ca08ee093 100644 --- a/main/src/main/scala/sbt/EvaluateTask.scala +++ b/main/src/main/scala/sbt/EvaluateTask.scala @@ -14,6 +14,7 @@ import sbt.Def.{ ScopedKey, Setting, dummyState } import sbt.Keys.{ TaskProgress => _, name => _, _ } import sbt.Project.richInitializeTask import sbt.Scope.Global +import sbt.internal.Aggregation.KeyValue import sbt.internal.TaskName._ import sbt.internal.TransitiveGlobs._ import sbt.internal.util._ @@ -479,8 +480,13 @@ object EvaluateTask { results: RMap[Task, Result], state: State, root: Task[T] - ): (State, Result[T]) = - (stateTransform(results)(state), results(root)) + ): (State, Result[T]) = { + val newState = results(root) match { + case Value(KeyValue(_, st: StateTransform) :: Nil) => st.state + case _ => stateTransform(results)(state) + } + (newState, results(root)) + } def stateTransform(results: RMap[Task, Result]): State => State = Function.chain( results.toTypedSeq flatMap { diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index e25f962cf..977cc928a 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -116,7 +116,7 @@ object Keys { 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 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) diff --git a/main/src/main/scala/sbt/StateTransform.scala b/main/src/main/scala/sbt/StateTransform.scala new file mode 100644 index 000000000..abea411d5 --- /dev/null +++ b/main/src/main/scala/sbt/StateTransform.scala @@ -0,0 +1,20 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt + +final class StateTransform(val state: State) { + override def equals(o: Any): Boolean = o match { + case that: StateTransform => this.state == that.state + case _ => false + } + override def hashCode: Int = state.hashCode + override def toString: String = s"StateTransform($state)" +} +object StateTransform { + def apply(state: State): State = state +} diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index ce6eaf86b..613c3c3a7 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -23,10 +23,9 @@ import sbt.Scope.Global import sbt.internal.FileManagement.FileTreeRepositoryOps import sbt.internal.LabeledFunctions._ 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, Util } +import sbt.internal.util.{ AttributeKey, Util } import sbt.io._ import sbt.util.{ Level, _ } @@ -122,16 +121,13 @@ object Continuous extends DeprecatedContinuous { * 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]] = + private[sbt] def continuousTask: Def.Initialize[InputTask[StateTransform]] = 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) - }) + new StateTransform( + runToTermination(Keys.state.value, command, initialCount, isCommand = false) + ) + } private[this] val DupedSystemIn = AttributeKey[DupedInputStream]( diff --git a/sbt/src/sbt-test/watch/on-termination/build.sbt b/sbt/src/sbt-test/watch/on-termination/build.sbt new file mode 100644 index 000000000..3e1169f6d --- /dev/null +++ b/sbt/src/sbt-test/watch/on-termination/build.sbt @@ -0,0 +1,3 @@ +import sbt.watch.task.Build + +val root = Build.root diff --git a/sbt/src/sbt-test/watch/on-termination/project/Build.scala b/sbt/src/sbt-test/watch/on-termination/project/Build.scala new file mode 100644 index 000000000..31751e291 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-termination/project/Build.scala @@ -0,0 +1,29 @@ +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, + watchOnTriggerEvent := { (_, _) => Watch.CancelWatch }, + watchTasks := Def.inputTask { + val prev = watchTasks.evaluated + new StateTransform(prev.state.fail) + }.evaluated + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/on-termination/test b/sbt/src/sbt-test/watch/on-termination/test new file mode 100644 index 000000000..6da243996 --- /dev/null +++ b/sbt/src/sbt-test/watch/on-termination/test @@ -0,0 +1,7 @@ +# This tests that we can override the state transformation in the watch task +# In the build, watchOnEvent should return CancelWatch which should be successful, but we +# override watchTasks to fail the state instead + +-> watch root / setStringValue foo.txt bar + +> checkStringValue foo.txt bar From 7c2607b1ae73421a8576323c8205f52753ec4884 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 29 Mar 2019 08:51:57 -0700 Subject: [PATCH 08/11] Clean up file repository management I had needed to add proxy classes for the global FileTreeRepository so that tasks that called the close method wouldn't actually stop the monitoring done by the global repository. I realized that it makes a lot more sense to just not provide direct access to the underlying file tree repository and let the registerGlobalCaches manage its life cycle instead. --- main/src/main/scala/sbt/Main.scala | 13 ++- .../main/scala/sbt/internal/Continuous.scala | 3 +- .../scala/sbt/internal/FileManagement.scala | 100 +----------------- 3 files changed, 14 insertions(+), 102 deletions(-) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 1a2856a73..73057a5a9 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -17,6 +17,7 @@ import sbt.Project.LoadAction import sbt.compiler.EvalImports import sbt.internal.Aggregation.AnyKeys import sbt.internal.CommandStrings.BootCommand +import sbt.internal.FileManagement.CopiedFileTreeRepository import sbt.internal._ import sbt.internal.inc.ScalaInstance import sbt.internal.util.Types.{ const, idFun } @@ -847,15 +848,18 @@ object BuiltinCommands { } s.put(Keys.stateCompilerCache, cache) } + private[this] val rawGlobalFileTreeRepository = AttributeKey[FileTreeRepository[FileAttributes]]( + "raw-global-file-tree-repository", + "Provides a view into the file system that may or may not cache the tree in memory", + 1000 + ) private[sbt] def registerGlobalCaches(s: State): State = try { val extracted = Project.extract(s) val cleanedUp = new AtomicBoolean(false) def cleanup(): Unit = { - s.get(Keys.globalFileTreeRepository).foreach(_.close()) - s.attributes.remove(Keys.globalFileTreeRepository) + s.get(rawGlobalFileTreeRepository).foreach(_.close()) s.get(Keys.taskRepository).foreach(_.close()) - s.attributes.remove(Keys.taskRepository) () } cleanup() @@ -863,7 +867,8 @@ object BuiltinCommands { val newState = s.addExitHook(if (cleanedUp.compareAndSet(false, true)) cleanup()) newState .put(Keys.taskRepository, new TaskRepository.Repr) - .put(Keys.globalFileTreeRepository, fileTreeRepository) + .put(rawGlobalFileTreeRepository, fileTreeRepository) + .put(Keys.globalFileTreeRepository, new CopiedFileTreeRepository(fileTreeRepository)) } catch { case NonFatal(_) => s } diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 613c3c3a7..d332bfb86 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -20,7 +20,6 @@ import sbt.BasicCommandStrings.{ import sbt.BasicCommands.otherCommandParser import sbt.Def._ import sbt.Scope.Global -import sbt.internal.FileManagement.FileTreeRepositoryOps import sbt.internal.LabeledFunctions._ import sbt.internal.io.WatchState import sbt.internal.util.complete.Parser._ @@ -191,7 +190,7 @@ object Continuous extends DeprecatedContinuous { new IllegalStateException("Tried to access FileTreeRepository for uninitialized state") state .get(Keys.globalFileTreeRepository) - .map(FileManagement.toMonitoringRepository(_).copy()) + .map(FileManagement.toMonitoringRepository) .getOrElse(throw exception) } diff --git a/main/src/main/scala/sbt/internal/FileManagement.scala b/main/src/main/scala/sbt/internal/FileManagement.scala index 77c2c0624..88c61ed95 100644 --- a/main/src/main/scala/sbt/internal/FileManagement.scala +++ b/main/src/main/scala/sbt/internal/FileManagement.scala @@ -13,9 +13,7 @@ import java.util.concurrent.ConcurrentHashMap import sbt.BasicCommandStrings.ContinuousExecutePrefix 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 } @@ -55,11 +53,8 @@ private[sbt] object FileManagement { watchLogger ) } else { - if (Util.isWindows) new PollingFileRepository(FileAttributes.default) - else { - val service = Watched.createWatchService(pollInterval) - FileTreeRepository.legacy(FileAttributes.default _, (_: Any) => {}, service) - } + val service = Watched.createWatchService(pollInterval) + FileTreeRepository.legacy(FileAttributes.default _, (_: Any) => {}, service) } } @@ -100,55 +95,6 @@ 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) - - /** - * 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]( underlying: HybridPollingFileTreeRepository[T], delay: FiniteDuration, @@ -174,11 +120,10 @@ private[sbt] object FileManagement { private[sbt] def toMonitoringRepository[T]( repository: FileTreeRepository[T] ): FileTreeRepository[T] = repository match { - case p: PollingFileRepository[T] => p.toMonitoringRepository case h: HybridMonitoringRepository[T] => h.toMonitoringRepository - case r: FileTreeRepository[T] => new CopiedFileRepository(r) + case r: FileTreeRepository[T] => r } - private class CopiedFileRepository[T](underlying: FileTreeRepository[T]) + private[sbt] class CopiedFileTreeRepository[T](underlying: FileTreeRepository[T]) extends FileTreeRepository[T] { def addObserver(observer: Observer[T]) = underlying.addObserver(observer) def close(): Unit = {} // Don't close the underlying observable @@ -188,41 +133,4 @@ private[sbt] object FileManagement { def register(glob: Glob): Either[IOException, Boolean] = underlying.register(glob) def unregister(glob: Glob): Unit = underlying.unregister(glob) } - private[sbt] class PollingFileRepository[T](converter: TypedPath => T) - extends FileTreeRepository[T] { self => - private val registered: mutable.Set[Glob] = ConcurrentHashMap.newKeySet[Glob].asScala - private[this] val view = FileTreeView.DEFAULT - private[this] val dataView = view.asDataView(converter) - private[this] val handles: mutable.Map[FileTreeRepository[T], Int] = - new ConcurrentHashMap[FileTreeRepository[T], Int].asScala - private val observers: Observers[T] = new Observers - override def addObserver(observer: Observer[T]): Int = observers.addObserver(observer) - override def close(): Unit = { - handles.foreach { case (repo, handle) => repo.removeObserver(handle) } - observers.close() - } - override def list(glob: Glob): Seq[TypedPath] = view.list(glob) - override def listEntries(glob: Glob): Seq[Entry[T]] = dataView.listEntries(glob) - override def removeObserver(handle: Int): Unit = observers.removeObserver(handle) - override def register(glob: Glob): Either[IOException, Boolean] = Right(registered.add(glob)) - override def unregister(glob: Glob): Unit = registered -= glob - - private[sbt] def toMonitoringRepository: FileTreeRepository[T] = { - val legacy = FileTreeRepository.legacy(converter) - registered.foreach(legacy.register) - handles += legacy -> legacy.addObserver(observers) - new FileTreeRepository[T] { - override def listEntries(glob: Glob): Seq[Entry[T]] = legacy.listEntries(glob) - override def list(glob: Glob): Seq[TypedPath] = legacy.list(glob) - def addObserver(observer: Observer[T]): Int = legacy.addObserver(observer) - override def removeObserver(handle: Int): Unit = legacy.removeObserver(handle) - override def close(): Unit = legacy.close() - override def register(glob: Glob): Either[IOException, Boolean] = { - self.register(glob) - legacy.register(glob) - } - override def unregister(glob: Glob): Unit = legacy.unregister(glob) - } - } - } } From c72005fd2b5be05df1df40acd6a0a158b9355b9b Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sun, 13 Jan 2019 16:15:06 -0800 Subject: [PATCH 09/11] Support inputs in dynamic tasks Prior to this commit, it was necessary to add breadcrumbs for every input that is used within a dynamic task. In this commit, I rework the watch setup so that we can track the dynamic inputs that are used. To simplify the discussion, I'm going to ignore aggregation and multi-commands, but they are both supported. To implement this change, I update the GlobLister.all method to take a second implicit argument: DynamicInputs. This is effectively a mutable Set of Globs that is updated every time a task looks up files from a glob. The repository.get method should already register the glob with the repository. The set of globs are necessary because the repository may not do any file filtering so the file event monitor needs to check the input globs to ensure that the file event is for a file that actually requested by a task during evaluation. * Long term, I plan to add support for lifting tasks into a dynamic task in a way that records _all_ of the possible dependencies for the task through each of the dynamic code paths. We should revisit this change to determine if its still necessary after that change. --- .../util/appmacro/MacroDefaults.scala | 12 ++ main/src/main/scala/sbt/Defaults.scala | 1 + .../main/scala/sbt/internal/Continuous.scala | 184 +++++++++++------- .../scala/sbt/internal/ExternalHooks.scala | 15 +- .../scala/sbt/internal/FileManagement.scala | 1 + .../main/scala/sbt/internal/FileTree.scala | 13 +- .../main/scala/sbt/internal/GlobLister.scala | 83 +++++--- sbt/src/sbt-test/tests/glob-dsl/build.sbt | 5 +- sbt/src/sbt-test/tests/inputs/build.sbt | 2 - .../sbt-test/watch/dynamic-inputs/build.sbt | 3 + .../watch/dynamic-inputs/project/Build.scala | 41 ++++ sbt/src/sbt-test/watch/dynamic-inputs/test | 7 + 12 files changed, 253 insertions(+), 114 deletions(-) create mode 100644 sbt/src/sbt-test/watch/dynamic-inputs/build.sbt create mode 100644 sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala create mode 100644 sbt/src/sbt-test/watch/dynamic-inputs/test diff --git a/core-macros/src/main/scala/sbt/internal/util/appmacro/MacroDefaults.scala b/core-macros/src/main/scala/sbt/internal/util/appmacro/MacroDefaults.scala index 4de9b65e5..43526a373 100644 --- a/core-macros/src/main/scala/sbt/internal/util/appmacro/MacroDefaults.scala +++ b/core-macros/src/main/scala/sbt/internal/util/appmacro/MacroDefaults.scala @@ -22,4 +22,16 @@ object MacroDefaults { import c.universe._ q"sbt.Keys.fileTreeRepository.value: @sbtUnchecked" } + + /** + * Macro to generated default file tree repository. It must be defined as an untyped tree because + * sbt.Keys is not available in this project. This is meant for internal use only, but must be + * public because its a macro. + * @param c the macro context + * @return the tree expressing the default file tree repository. + */ + def dynamicInputs(c: blackbox.Context): c.Tree = { + import c.universe._ + q"sbt.internal.Continuous.dynamicInputs.value: @sbtUnchecked" + } } diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 0d151743e..d368eb5d2 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -293,6 +293,7 @@ object Defaults extends BuildCommon { case Some(r) => r case None => FileTreeView.DEFAULT.asDataView(FileAttributes.default) }), + Continuous.dynamicInputs := Continuous.dynamicInputsImpl.value, externalHooks := { val repository = fileTreeRepository.value compileOptions => diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index d332bfb86..66601d19b 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -134,6 +134,18 @@ object Continuous extends DeprecatedContinuous { "Receives a copy of all of the bytes from System.in.", 10000 ) + val dynamicInputs = taskKey[FileTree.DynamicInputs]( + "The input globs found during task evaluation that are used in watch." + ) + def dynamicInputsImpl: Def.Initialize[Task[FileTree.DynamicInputs]] = Def.task { + Keys.state.value.get(DynamicInputs).getOrElse(FileTree.DynamicInputs.none) + } + private[sbt] val DynamicInputs = + AttributeKey[FileTree.DynamicInputs]( + "dynamic-inputs", + "Stores the inputs (dynamic and regular) for a task", + 10000 + ) private[this] val continuousParser: State => Parser[(Int, String)] = { def toInt(s: String): Int = Try(s.toInt).getOrElse(0) @@ -175,12 +187,14 @@ object Continuous extends DeprecatedContinuous { } val repository = getRepository(state) - (inputs ++ triggers).foreach(repository.register) + val registeringSet = state.get(DynamicInputs).get + registeringSet.value.foreach(_ ++= inputs) + (inputs ++ triggers).foreach(repository.register(_: Glob)) val watchSettings = new WatchSettings(scopedKey) new Config( scopedKey, repository, - inputs, + () => registeringSet.value.fold(Nil: Seq[Glob])(_.toSeq).sorted, triggers, watchSettings ) @@ -195,25 +209,13 @@ object Continuous extends DeprecatedContinuous { } private[sbt] def setup[R](state: State, command: String)( - f: (State, Seq[String], Seq[() => Boolean], Seq[String]) => R + f: (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 // 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( @@ -221,6 +223,35 @@ object Continuous extends DeprecatedContinuous { definedCommands = state.definedCommands :+ onFail ) + /* + * 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. Each + * task gets its own state with its own file tree repository. This is so that we can keep + * track of what globs are actually used by the task to ensure that we monitor them, even + * if they are not visible in the input graph due to the use of Def.taskDyn. + */ + def makeTask(cmd: String): (String, State, () => Boolean) = { + val newState = s.put(DynamicInputs, FileTree.DynamicInputs.empty) + val task = Parser + .parse(cmd, Command.combine(newState.definedCommands)(newState)) + .getOrElse( + throw new IllegalStateException( + "No longer able to parse command after transforming state" + ) + ) + ( + cmd, + newState, + () => { + MainLoop + .processCommand(Exec(cmd, None), newState, task) + .remainingCommands + .forall(_.commandLine != failureCommandName) + } + ) + } + // We support multiple commands in watch, so it's necessary to run the command string through // the multi parser. val trimmed = command.trim @@ -234,14 +265,15 @@ object Continuous extends DeprecatedContinuous { 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) + val (invalid, valid) = + commands.foldLeft((Nil: Seq[String], Nil: Seq[(String, State, () => Boolean)])) { + case ((i, v), cmd) => + Parser.parse(cmd, taskParser) match { + case Right(_) => (i, v :+ makeTask(cmd)) + case Left(c) => (i :+ c, v) + } + } + f(s, valid, invalid) } private[sbt] def runToTermination( @@ -251,18 +283,18 @@ object Continuous extends DeprecatedContinuous { isCommand: Boolean ): State = Watch.withCharBufferedStdIn { in => val duped = new DupedInputStream(in) - setup(state.put(DupedSystemIn, duped), command) { (s, commands, valid, invalid) => + setup(state.put(DupedSystemIn, duped), command) { (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 callbacks = - aggregate(getAllConfigs(s, commands), logger, in, state, currentCount, isCommand) + val configs = getAllConfigs(valid.map(v => v._1 -> v._2)) + val callbacks = aggregate(configs, logger, in, s, currentCount, isCommand) val task = () => { currentCount.getAndIncrement() // abort as soon as one of the tasks fails - valid.takeWhile(_.apply()) + valid.takeWhile(_._3.apply()) () } callbacks.onEnter() @@ -273,7 +305,10 @@ object Continuous extends DeprecatedContinuous { try { val terminationAction = Watch(task, callbacks.onStart, callbacks.nextEvent) callbacks.onTermination(terminationAction, command, currentCount.get(), state) - } finally callbacks.onExit() + } finally { + configs.foreach(_.repository.close()) + 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. @@ -285,28 +320,27 @@ object Continuous extends DeprecatedContinuous { } } - private def parseCommands(state: State, commands: Seq[String]): Seq[ScopedKey[_]] = { + private def parseCommand(command: String, state: State): 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[_]] - } + Parser.parse(command, scopedKeyParser) match { + case Right(scopedKeys: Seq[ScopedKey[_]]) => scopedKeys + case Left(e) => + throw new IllegalStateException(s"Error attempting to extract scope from $command: $e.") + case _ => Nil: Seq[ScopedKey[_]] } } private def getAllConfigs( - state: State, - commands: Seq[String] + inputs: Seq[(String, State)] )(implicit extracted: Extracted, logger: Logger): Seq[Config] = { - val commandKeys = parseCommands(state, commands) + val commandKeys = inputs.map { case (c, s) => s -> parseCommand(c, s) } val compiledMap = InputGraph.compile(extracted.structure) - commandKeys.map((scopedKey: ScopedKey[_]) => getConfig(state, scopedKey, compiledMap)) + commandKeys.flatMap { + case (s, scopedKeys) => scopedKeys.map(getConfig(s, _, compiledMap)) + } } private class Callbacks( @@ -430,16 +464,16 @@ object Continuous extends DeprecatedContinuous { 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 - } + .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 => @@ -457,14 +491,16 @@ object Continuous extends DeprecatedContinuous { val onInputEvent = ws.onInputEvent.getOrElse(defaultTrigger) val onTriggerEvent = ws.onTriggerEvent.getOrElse(defaultTrigger) val onMetaBuildEvent = ws.onMetaBuildEvent.getOrElse(Watch.ifChanged(Watch.Reload)) - val inputFilter = params.inputs.toEntryFilter val triggerFilter = params.triggers.toEntryFilter + val excludedBuildFilter = buildFilter event: Event => + val inputFilter = params.inputs().toEntryFilter val c = count.get() + val entry = event.entry Seq[Watch.Action]( - if (inputFilter(event.entry)) onInputEvent(c, event) else Watch.Ignore, - if (triggerFilter(event.entry)) onTriggerEvent(c, event) else Watch.Ignore, - if (buildFilter(event.entry)) onMetaBuildEvent(c, event) else Watch.Ignore + if (inputFilter(entry)) onInputEvent(c, event) else Watch.Ignore, + if (triggerFilter(entry)) onTriggerEvent(c, event) else Watch.Ignore, + if (excludedBuildFilter(entry)) onMetaBuildEvent(c, event) else Watch.Ignore ).min } event: Event => @@ -477,18 +513,26 @@ object Continuous extends DeprecatedContinuous { f.view.map(_.apply(event)).minBy(_._2) } val monitor: FileEventMonitor[FileAttributes] = new FileEventMonitor[FileAttributes] { - private def setup( + + /** + * Create a filtered monitor that only accepts globs that have been registered for the + * task at hand. + * @param monitor the file event monitor to filter + * @param globs the globs to accept. This must be a function because we want to be able + * to accept globs that are added dynamically as part of task evaluation. + * @return the filtered FileEventMonitor. + */ + private def filter( monitor: FileEventMonitor[FileAttributes], - globs: Seq[Glob] + 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) + monitor.poll(duration).filter(e => globs().toEntryFilter(e.entry)) override def close(): Unit = monitor.close() } } + // TODO make this a normal monitor 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 @@ -496,17 +540,20 @@ object Continuous extends DeprecatedContinuous { 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) + val allGlobs: () => Seq[Glob] = () => (config.inputs() ++ config.triggers).distinct.sorted + filter(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 + val antiEntropy = configs.map(_.watchSettings.antiEntropy).max + val repo = getRepository(state) + buildGlobs.foreach(repo.register) + val monitor = FileManagement.monitor(repo, antiEntropy, l) + filter(monitor, () => 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 + val res = monitors.flatMap(_.poll(0.millis)).toSet.toVector + if (res.isEmpty) Thread.sleep(duration.toMillis) + res } override def close(): Unit = monitors.foreach(_.close()) } @@ -745,13 +792,13 @@ object Continuous extends DeprecatedContinuous { private final class Config private[internal] ( val key: ScopedKey[_], val repository: FileTreeRepository[FileAttributes], - val inputs: Seq[Glob], + 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) + 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(Watch.defaultStartWatch) @@ -865,5 +912,4 @@ object Continuous extends DeprecatedContinuous { logger.log(level, s"$prefix - $message") } } - } diff --git a/main/src/main/scala/sbt/internal/ExternalHooks.scala b/main/src/main/scala/sbt/internal/ExternalHooks.scala index df30bcbd3..4ee753be5 100644 --- a/main/src/main/scala/sbt/internal/ExternalHooks.scala +++ b/main/src/main/scala/sbt/internal/ExternalHooks.scala @@ -7,13 +7,13 @@ package sbt.internal -import java.nio.file.{ Path, Paths } +import java.nio.file.Paths import java.util.Optional +import sbt.Stamped import sbt.internal.inc.ExternalLookup import sbt.io.syntax._ -import sbt.io.{ AllPassFilter, Glob, TypedPath } -import sbt.Stamped +import sbt.io.{ AllPassFilter, TypedPath } import xsbti.compile._ import xsbti.compile.analysis.Stamp @@ -22,7 +22,6 @@ import scala.collection.mutable private[sbt] object ExternalHooks { private val javaHome = Option(System.getProperty("java.home")).map(Paths.get(_)) def apply(options: CompileOptions, repo: FileTree.Repository): DefaultExternalHooks = { - def listEntries(glob: Glob): Seq[(Path, FileAttributes)] = repo.get(glob) import scala.collection.JavaConverters._ val sources = options.sources() val cachedSources = new java.util.HashMap[File, Stamp] @@ -34,13 +33,9 @@ private[sbt] object ExternalHooks { val allBinaries = new java.util.HashMap[File, Stamp] options.classpath.foreach { case f if f.getName.endsWith(".jar") => - // This gives us the entry for the path itself, which is necessary if the path is a jar file - // rather than a directory. - listEntries(f.toGlob) foreach { case (p, a) => allBinaries.put(p.toFile, a.stamp) } + repo.get(f.toGlob) foreach { case (p, a) => allBinaries.put(p.toFile, a.stamp) } case f => - listEntries(f ** AllPassFilter) foreach { - case (p, a) => allBinaries.put(p.toFile, a.stamp) - } + repo.get(f ** AllPassFilter) foreach { case (p, a) => allBinaries.put(p.toFile, a.stamp) } } val lookup = new ExternalLookup { diff --git a/main/src/main/scala/sbt/internal/FileManagement.scala b/main/src/main/scala/sbt/internal/FileManagement.scala index 88c61ed95..42a7d4a43 100644 --- a/main/src/main/scala/sbt/internal/FileManagement.scala +++ b/main/src/main/scala/sbt/internal/FileManagement.scala @@ -95,6 +95,7 @@ private[sbt] object FileManagement { override def close(): Unit = monitor.close() } } + private[sbt] class HybridMonitoringRepository[T]( underlying: HybridPollingFileTreeRepository[T], delay: FiniteDuration, diff --git a/main/src/main/scala/sbt/internal/FileTree.scala b/main/src/main/scala/sbt/internal/FileTree.scala index 7b0919056..14895b48c 100644 --- a/main/src/main/scala/sbt/internal/FileTree.scala +++ b/main/src/main/scala/sbt/internal/FileTree.scala @@ -14,12 +14,22 @@ import sbt.internal.util.appmacro.MacroDefaults import sbt.io.FileTreeDataView.Entry import sbt.io._ +import scala.collection.mutable import scala.language.experimental.macros object FileTree { private def toPair(e: Entry[FileAttributes]): Option[(Path, FileAttributes)] = e.value.toOption.map(a => e.typedPath.toPath -> a) trait Repository extends sbt.internal.Repository[Seq, Glob, (Path, FileAttributes)] + private[sbt] trait DynamicInputs { + def value: Option[mutable.Set[Glob]] + } + private[sbt] object DynamicInputs { + def empty: DynamicInputs = new impl(Some(mutable.Set.empty[Glob])) + final val none: DynamicInputs = new impl(None) + private final class impl(override val value: Option[mutable.Set[Glob]]) extends DynamicInputs + implicit def default: DynamicInputs = macro MacroDefaults.dynamicInputs + } private[sbt] object Repository { /** @@ -45,7 +55,8 @@ object FileTree { extends Repository { override def get(key: Glob): Seq[(Path, FileAttributes)] = { underlying.register(key) - underlying.listEntries(key).flatMap(toPair) + //underlying.listEntries(key).flatMap(toPair).distinct + Repository.polling.get(key) } override def close(): Unit = underlying.close() } diff --git a/main/src/main/scala/sbt/internal/GlobLister.scala b/main/src/main/scala/sbt/internal/GlobLister.scala index 03483b312..050d3affb 100644 --- a/main/src/main/scala/sbt/internal/GlobLister.scala +++ b/main/src/main/scala/sbt/internal/GlobLister.scala @@ -8,9 +8,14 @@ package sbt package internal +import java.io.File import java.nio.file.Path +import java.util.concurrent.ConcurrentSkipListMap -import sbt.io.Glob +import sbt.io.{ FileFilter, Glob, SimpleFileFilter } + +import scala.collection.JavaConverters._ +import scala.collection.mutable /** * Retrieve files from a repository. This should usually be an extension class for @@ -19,21 +24,21 @@ import sbt.io.Glob */ private[sbt] sealed trait GlobLister extends Any { - /** - * Get the sources described this `GlobLister`. - * - * @param repository the [[FileTree.Repository]] to delegate file i/o. - * @return the files described by this `GlobLister`. - */ - def all(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] + final def all(repository: FileTree.Repository): Seq[(Path, FileAttributes)] = + all(repository, FileTree.DynamicInputs.empty) /** - * Get the unique sources described this `GlobLister`. + * Get the sources described this `GlobLister`. The results should not return any duplicate + * entries for each path in the result set. * - * @param repository the [[FileTree.Repository]] to delegate file i/o. - * @return the files described by this `GlobLister` with any duplicates removed. + * @param repository the file tree repository for retrieving the files for a given glob. + * @param dynamicInputs the task dynamic inputs to track for watch. + * @return the files described by this `GlobLister`. */ - def unique(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] + def all( + implicit repository: FileTree.Repository, + dynamicInputs: FileTree.DynamicInputs + ): Seq[(Path, FileAttributes)] } /** @@ -57,10 +62,7 @@ private[sbt] trait GlobListers { implicit def fromGlob(source: Glob): GlobLister = new impl(source :: Nil) /** - * Generate a GlobLister given a collection of Globs. If the input collection type - * preserves uniqueness, e.g. `Set[Glob]`, then the output of `GlobLister.all` will be - * the unique source list. Otherwise duplicates are possible in all and it is necessary to call - * `GlobLister.unique` to de-duplicate the files. + * Generate a GlobLister given a collection of Globs. * * @param sources the collection of sources * @tparam T the source collection type @@ -69,6 +71,34 @@ private[sbt] trait GlobListers { new impl(sources) } private[internal] object GlobListers { + private def covers(left: Glob, right: Glob): Boolean = { + right.base.startsWith(left.base) && { + left.depth == Int.MaxValue || { + val depth = left.base.relativize(right.base).getNameCount + depth < left.depth - right.depth + } + } + } + private def aggregate(globs: Traversable[Glob]): Seq[(Glob, Traversable[Glob])] = { + val sorted = globs.toSeq.sorted + val map = new ConcurrentSkipListMap[Path, (Glob, mutable.Set[Glob])] + if (sorted.size > 1) { + sorted.foreach { glob => + map.subMap(glob.base.getRoot, glob.base.resolve(Char.MaxValue.toString)).asScala.find { + case (_, (g, _)) => covers(g, glob) + } match { + case Some((_, (_, globs))) => globs += glob + case None => + val globs = mutable.Set(glob) + val filter: FileFilter = new SimpleFileFilter((file: File) => { + globs.exists(_.toFileFilter.accept(file)) + }) + map.put(glob.base, (Glob(glob.base, filter, glob.depth), globs)) + } + } + map.asScala.values.toIndexedSeq + } else sorted.map(g => g -> (g :: Nil)) + } /** * Implements `GlobLister` given a collection of Globs. If the input collection type @@ -79,18 +109,15 @@ private[internal] object GlobListers { * @tparam T the collection type */ private class impl[T <: Traversable[Glob]](val globs: T) extends AnyVal with GlobLister { - private def get[T0 <: Traversable[Glob]]( - traversable: T0, - repository: FileTree.Repository - ): Seq[(Path, FileAttributes)] = - traversable.flatMap { glob => - val sourceFilter = glob.toFileFilter - repository.get(glob).filter { case (p, _) => sourceFilter.accept(p.toFile) } + override def all( + implicit repository: FileTree.Repository, + dynamicInputs: FileTree.DynamicInputs + ): Seq[(Path, FileAttributes)] = { + aggregate(globs).flatMap { + case (glob, allGlobs) => + dynamicInputs.value.foreach(_ ++= allGlobs) + repository.get(glob) }.toIndexedSeq - - override def all(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] = - get(globs, repository) - override def unique(implicit repository: FileTree.Repository): Seq[(Path, FileAttributes)] = - get(globs.toSet[Glob], repository) + } } } diff --git a/sbt/src/sbt-test/tests/glob-dsl/build.sbt b/sbt/src/sbt-test/tests/glob-dsl/build.sbt index 16b161d89..c6452cd51 100644 --- a/sbt/src/sbt-test/tests/glob-dsl/build.sbt +++ b/sbt/src/sbt-test/tests/glob-dsl/build.sbt @@ -46,12 +46,9 @@ val checkSet = taskKey[Unit]("Verify that redundant sources are handled") checkSet := { val redundant = (set / fileInputs).value.all.map(_._1.toFile) - assert(redundant.size == 4) // It should get Foo.txt and Bar.md twice + assert(redundant.size == 2) val deduped = (set / fileInputs).value.toSet[Glob].all.map(_._1.toFile) val expected = Seq("Bar.md", "Foo.txt").map(baseDirectory.value / "base/subdir/nested-subdir" / _) assert(deduped.sorted == expected) - - val altDeduped = (set / fileInputs).value.unique.map(_._1.toFile) - assert(altDeduped.sorted == expected) } diff --git a/sbt/src/sbt-test/tests/inputs/build.sbt b/sbt/src/sbt-test/tests/inputs/build.sbt index 88cc5a636..54e1ce2fb 100644 --- a/sbt/src/sbt-test/tests/inputs/build.sbt +++ b/sbt/src/sbt-test/tests/inputs/build.sbt @@ -1,8 +1,6 @@ import java.nio.file.Path import sbt.internal.{FileAttributes, FileTree} -import sbt.io.FileTreeDataView -import xsbti.compile.analysis.Stamp val allInputs = taskKey[Seq[File]]("") val allInputsExplicit = taskKey[Seq[File]]("") diff --git a/sbt/src/sbt-test/watch/dynamic-inputs/build.sbt b/sbt/src/sbt-test/watch/dynamic-inputs/build.sbt new file mode 100644 index 000000000..3e1169f6d --- /dev/null +++ b/sbt/src/sbt-test/watch/dynamic-inputs/build.sbt @@ -0,0 +1,3 @@ +import sbt.watch.task.Build + +val root = Build.root diff --git a/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala b/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala new file mode 100644 index 000000000..474a6b715 --- /dev/null +++ b/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala @@ -0,0 +1,41 @@ +package sbt.watch.task + +import sbt._ +import Keys._ +import sbt.internal.FileTree + +object Build { + val reloadFile = settingKey[File]("file to toggle whether or not to reload") + val setStringValue = taskKey[Unit]("set a global string to a value") + val checkStringValue = inputKey[Unit]("check the value of a global") + val foo = taskKey[Unit]("foo") + def setStringValueImpl: Def.Initialize[Task[Unit]] = Def.task { + val i = (setStringValue / fileInputs).value + val (stringFile, string) = ("foo.txt", "bar") + 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", + foo / fileInputs += baseDirectory.value * "foo.txt", + setStringValue := Def.taskDyn { + // This hides foo / fileInputs from the input graph + Def.taskDyn { + val _ = (foo / fileInputs).value.all + // By putting setStringValueImpl.value inside a Def.task, we ensure that + // (foo / fileInputs).value is registered with the file repository before modifying the file. + Def.task(setStringValueImpl.value) + } + }.value, + checkStringValue := checkStringValueImpl.evaluated, + watchOnInputEvent := { (_, _) => Watch.CancelWatch }, + watchOnTriggerEvent := { (_, _) => Watch.CancelWatch }, + watchTasks := Def.inputTask { + val prev = watchTasks.evaluated + new StateTransform(prev.state.fail) + }.evaluated + ) +} \ No newline at end of file diff --git a/sbt/src/sbt-test/watch/dynamic-inputs/test b/sbt/src/sbt-test/watch/dynamic-inputs/test new file mode 100644 index 000000000..57725e3a4 --- /dev/null +++ b/sbt/src/sbt-test/watch/dynamic-inputs/test @@ -0,0 +1,7 @@ +# This tests that we can override the state transformation in the watch task +# In the build, watchOnEvent should return CancelWatch which should be successful, but we +# override watchTasks to fail the state instead + +-> watch root / setStringValue + +> checkStringValue foo.txt bar From 247d2420085129b20f9e5fc08a976b0a2d423a6e Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 29 Mar 2019 08:33:36 -0700 Subject: [PATCH 10/11] 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] : return to the shell [info] 'r': repeat the current command [info] 'x': exit sbt It will also print which path triggered the build. --- main-command/src/main/scala/sbt/Watched.scala | 7 +- main/src/main/scala/sbt/Defaults.scala | 1 - main/src/main/scala/sbt/Keys.scala | 4 +- main/src/main/scala/sbt/Watch.scala | 64 +++++--- .../main/scala/sbt/internal/Continuous.scala | 140 ++++++++++-------- .../sbt/internal/DeprecatedContinuous.scala | 5 +- .../watch/custom-config/project/Build.scala | 8 +- .../watch/dynamic-inputs/project/Build.scala | 3 +- .../watch/input-parser/project/Build.scala | 8 +- .../sbt-test/watch/task/changes/Build.scala | 2 +- .../sbt-test/watch/task/project/Build.scala | 4 +- 11 files changed, 140 insertions(+), 106 deletions(-) diff --git a/main-command/src/main/scala/sbt/Watched.scala b/main-command/src/main/scala/sbt/Watched.scala index 1543a3bba..a2f79995c 100644 --- a/main-command/src/main/scala/sbt/Watched.scala +++ b/main-command/src/main/scala/sbt/Watched.scala @@ -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") diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index d368eb5d2..a2f1805b4 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -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, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 977cc928a..2bcd76c9a 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -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") diff --git a/main/src/main/scala/sbt/Watch.scala b/main/src/main/scala/sbt/Watch.scala index 730993d9f..d9bdab1bb 100644 --- a/main/src/main/scala/sbt/Watch.scala +++ b/main/src/main/scala/sbt/Watch.scala @@ -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 = "" + 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. diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 66601d19b..d747d7fbb 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -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)) diff --git a/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala index 742c1aa46..4cea72eb0 100644 --- a/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala +++ b/main/src/main/scala/sbt/internal/DeprecatedContinuous.scala @@ -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 diff --git a/sbt/src/sbt-test/watch/custom-config/project/Build.scala b/sbt/src/sbt-test/watch/custom-config/project/Build.scala index a9acf4e87..4dc816d21 100644 --- a/sbt/src/sbt-test/watch/custom-config/project/Build.scala +++ b/sbt/src/sbt-test/watch/custom-config/project/Build.scala @@ -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 diff --git a/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala b/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala index 474a6b715..bc2ecaea4 100644 --- a/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala +++ b/sbt/src/sbt-test/watch/dynamic-inputs/project/Build.scala @@ -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 diff --git a/sbt/src/sbt-test/watch/input-parser/project/Build.scala b/sbt/src/sbt-test/watch/input-parser/project/Build.scala index 2bb9dde58..c5aafd05f 100644 --- a/sbt/src/sbt-test/watch/input-parser/project/Build.scala +++ b/sbt/src/sbt-test/watch/input-parser/project/Build.scala @@ -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") } -} \ No newline at end of file +} diff --git a/sbt/src/sbt-test/watch/task/changes/Build.scala b/sbt/src/sbt-test/watch/task/changes/Build.scala index abb5f37b9..3ef51694f 100644 --- a/sbt/src/sbt-test/watch/task/changes/Build.scala +++ b/sbt/src/sbt-test/watch/task/changes/Build.scala @@ -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") }, diff --git a/sbt/src/sbt-test/watch/task/project/Build.scala b/sbt/src/sbt-test/watch/task/project/Build.scala index f1a6adbd9..bf4f61aaf 100644 --- a/sbt/src/sbt-test/watch/task/project/Build.scala +++ b/sbt/src/sbt-test/watch/task/project/Build.scala @@ -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 { } } ) -} \ No newline at end of file +} From eb2926b0044478708de6397fe1907b967573b4e7 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 29 Mar 2019 21:07:18 -0700 Subject: [PATCH 11/11] Validate the cache by default This commit change the default FileTree.Repository to always use a polling file repository but one that validates the current file system results against the cache results. On windows, we do not validate the cache because the cache can cause io contention in scripted tests. The cache does seem to work ok on my VM, but not on appveyor for whatever reason. Validating the cache by default was suggested by @smarter in a comment in https://github.com/sbt/sbt/issues/4543. --- main/src/main/scala/sbt/Defaults.scala | 9 +- main/src/main/scala/sbt/Keys.scala | 1 - main/src/main/scala/sbt/Main.scala | 16 ++-- .../main/scala/sbt/internal/Continuous.scala | 83 +++++++++++-------- .../scala/sbt/internal/FileManagement.scala | 70 +--------------- .../main/scala/sbt/internal/FileTree.scala | 66 +++++++++++---- 6 files changed, 116 insertions(+), 129 deletions(-) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index a2f1805b4..2b69c710a 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -288,11 +288,10 @@ object Defaults extends BuildCommon { Previous.references :== new Previous.References, concurrentRestrictions := defaultRestrictions.value, parallelExecution :== true, - fileTreeRepository := - FileTree.repository(state.value.get(Keys.globalFileTreeRepository) match { - case Some(r) => r - case None => FileTreeView.DEFAULT.asDataView(FileAttributes.default) - }), + fileTreeRepository := state.value + .get(globalFileTreeRepository) + .map(FileTree.repository) + .getOrElse(FileTree.Repository.polling), Continuous.dynamicInputs := Continuous.dynamicInputsImpl.value, externalHooks := { val repository = fileTreeRepository.value diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 2bcd76c9a..a056afca2 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -92,7 +92,6 @@ object Keys { val analysis = AttributeKey[CompileAnalysis]("analysis", "Analysis of compilation, including dependencies and generated outputs.", DSetting) 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.").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) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 73057a5a9..1aae438c6 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -848,14 +848,13 @@ object BuiltinCommands { } s.put(Keys.stateCompilerCache, cache) } - private[this] val rawGlobalFileTreeRepository = AttributeKey[FileTreeRepository[FileAttributes]]( + private[sbt] val rawGlobalFileTreeRepository = AttributeKey[FileTreeRepository[FileAttributes]]( "raw-global-file-tree-repository", "Provides a view into the file system that may or may not cache the tree in memory", 1000 ) private[sbt] def registerGlobalCaches(s: State): State = try { - val extracted = Project.extract(s) val cleanedUp = new AtomicBoolean(false) def cleanup(): Unit = { s.get(rawGlobalFileTreeRepository).foreach(_.close()) @@ -863,12 +862,17 @@ object BuiltinCommands { () } cleanup() - val fileTreeRepository = FileManagement.defaultFileTreeRepository(s, extracted) - val newState = s.addExitHook(if (cleanedUp.compareAndSet(false, true)) cleanup()) - newState + val fileTreeRepository = FileTreeRepository.default(FileAttributes.default) + val fileCache = System.getProperty("sbt.io.filecache", "validate") + val newState = s + .addExitHook(if (cleanedUp.compareAndSet(false, true)) cleanup()) .put(Keys.taskRepository, new TaskRepository.Repr) .put(rawGlobalFileTreeRepository, fileTreeRepository) - .put(Keys.globalFileTreeRepository, new CopiedFileTreeRepository(fileTreeRepository)) + if (fileCache == "false" || (fileCache != "true" && Util.isWindows)) newState + else { + val copied = new CopiedFileTreeRepository(fileTreeRepository) + newState.put(Keys.globalFileTreeRepository, copied) + } } catch { case NonFatal(_) => s } diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index d747d7fbb..7123525ea 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -20,6 +20,7 @@ import sbt.BasicCommandStrings.{ import sbt.BasicCommands.otherCommandParser import sbt.Def._ import sbt.Scope.Global +import sbt.internal.FileManagement.CopiedFileTreeRepository import sbt.internal.LabeledFunctions._ import sbt.internal.io.WatchState import sbt.internal.util.complete.Parser._ @@ -204,7 +205,6 @@ object Continuous extends DeprecatedContinuous { new IllegalStateException("Tried to access FileTreeRepository for uninitialized state") state .get(Keys.globalFileTreeRepository) - .map(FileManagement.toMonitoringRepository) .getOrElse(throw exception) } @@ -283,41 +283,58 @@ object Continuous extends DeprecatedContinuous { isCommand: Boolean ): State = Watch.withCharBufferedStdIn { in => val duped = new DupedInputStream(in) - 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, commands) - val task = () => { - currentCount.getAndIncrement() - // abort as soon as one of the tasks fails - valid.takeWhile(_._3.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 = Watch(task, callbacks.onStart, callbacks.nextEvent) - callbacks.onTermination(terminationAction, command, currentCount.get(), state) - } finally { - configs.foreach(_.repository.close()) - callbacks.onExit() - } + implicit val extracted: Extracted = Project.extract(state) + val (stateWithRepo, repo) = state.get(Keys.globalFileTreeRepository) match { + case Some(r) => (state, r) + case _ => + val repo = if ("polling" == System.getProperty("sbt.watch.mode")) { + val service = + new PollingWatchService(extracted.getOpt(Keys.pollInterval).getOrElse(500.millis)) + FileTreeRepository.legacy(FileAttributes.default _, (_: Any) => {}, service) } else { - // At least one of the commands in the multi command string could not be parsed, so we - // log an error and exit. - val invalidCommands = invalid.mkString("'", "', '", "'") - logger.error(s"Terminating watch due to invalid command(s): $invalidCommands") - state.fail + state + .get(BuiltinCommands.rawGlobalFileTreeRepository) + .map(new CopiedFileTreeRepository(_)) + .getOrElse(FileTreeRepository.default(FileAttributes.default)) } - }) + (state.put(Keys.globalFileTreeRepository, repo), repo) } + try { + setup(stateWithRepo.put(DupedSystemIn, duped), command) { (commands, s, valid, invalid) => + 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, commands) + val task = () => { + currentCount.getAndIncrement() + // abort as soon as one of the tasks fails + valid.takeWhile(_._3.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 = Watch(task, callbacks.onStart, callbacks.nextEvent) + callbacks.onTermination(terminationAction, command, currentCount.get(), state) + } finally { + configs.foreach(_.repository.close()) + 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 invalidCommands = invalid.mkString("'", "', '", "'") + logger.error(s"Terminating watch due to invalid command(s): $invalidCommands") + state.fail + } + }) + } + } finally repo.close() } private def parseCommand(command: String, state: State): Seq[ScopedKey[_]] = { diff --git a/main/src/main/scala/sbt/internal/FileManagement.scala b/main/src/main/scala/sbt/internal/FileManagement.scala index 42a7d4a43..35734c5be 100644 --- a/main/src/main/scala/sbt/internal/FileManagement.scala +++ b/main/src/main/scala/sbt/internal/FileManagement.scala @@ -9,55 +9,15 @@ package sbt package internal import java.io.IOException -import java.util.concurrent.ConcurrentHashMap -import sbt.BasicCommandStrings.ContinuousExecutePrefix import sbt.internal.io.HybridPollingFileTreeRepository import sbt.io.FileTreeDataView.{ Entry, Observable, Observer, Observers } import sbt.io.{ FileTreeRepository, _ } -import sbt.util.{ Level, Logger } +import sbt.util.Logger -import scala.collection.JavaConverters._ -import scala.collection.mutable import scala.concurrent.duration._ private[sbt] object FileManagement { - private[sbt] def defaultFileTreeRepository( - state: State, - extracted: Extracted - ): FileTreeRepository[FileAttributes] = { - val pollingGlobs = extracted.getOpt(Keys.pollingGlobs).getOrElse(Nil) - val remaining = state.remainingCommands.map(_.commandLine) - // If the session is interactive or if the commands include a continuous build, then use - // the default configuration. Otherwise, use the sbt1_2_compat config, which does not cache - // anything, which makes it less likely to cause issues with CI. - val interactive = - remaining.contains("shell") || remaining.lastOption.contains("iflast shell") - val scripted = remaining.contains("setUpScripted") - val continuous = remaining.lastOption.exists(_.startsWith(ContinuousExecutePrefix)) - val enableCache = extracted - .getOpt(Keys.enableGlobalCachingFileTreeRepository) - .getOrElse(!scripted && (interactive || continuous)) - val pollInterval = extracted.getOpt(Keys.pollInterval).getOrElse(500.milliseconds) - val watchLogger: WatchLogger = extracted.getOpt(Keys.logLevel) match { - case Level.Debug => - new WatchLogger { override def debug(msg: => Any): Unit = println(s"[watch-debug] $msg") } - case _ => new WatchLogger { override def debug(msg: => Any): Unit = {} } - } - if (enableCache) { - if (pollingGlobs.isEmpty) FileTreeRepository.default(FileAttributes.default) - else - new HybridMonitoringRepository[FileAttributes]( - FileTreeRepository.hybrid(FileAttributes.default, pollingGlobs: _*), - pollInterval, - watchLogger - ) - } else { - val service = Watched.createWatchService(pollInterval) - FileTreeRepository.legacy(FileAttributes.default _, (_: Any) => {}, service) - } - } - private[sbt] def monitor( repository: FileTreeRepository[FileAttributes], antiEntropy: FiniteDuration, @@ -96,34 +56,6 @@ private[sbt] object FileManagement { } } - private[sbt] class HybridMonitoringRepository[T]( - underlying: HybridPollingFileTreeRepository[T], - delay: FiniteDuration, - logger: WatchLogger - ) extends FileTreeRepository[T] { - private val registered: mutable.Set[Glob] = ConcurrentHashMap.newKeySet[Glob].asScala - override def listEntries(glob: Glob): Seq[Entry[T]] = underlying.listEntries(glob) - override def list(glob: Glob): Seq[TypedPath] = underlying.list(glob) - override def addObserver(observer: Observer[T]): Int = underlying.addObserver(observer) - override def removeObserver(handle: Int): Unit = underlying.removeObserver(handle) - override def close(): Unit = underlying.close() - override def register(glob: Glob): Either[IOException, Boolean] = { - registered.add(glob) - underlying.register(glob) - } - override def unregister(glob: Glob): Unit = underlying.unregister(glob) - private[sbt] def toMonitoringRepository: FileTreeRepository[T] = { - val polling = underlying.toPollingRepository(delay, logger) - registered.foreach(polling.register) - polling - } - } - private[sbt] def toMonitoringRepository[T]( - repository: FileTreeRepository[T] - ): FileTreeRepository[T] = repository match { - case h: HybridMonitoringRepository[T] => h.toMonitoringRepository - case r: FileTreeRepository[T] => r - } private[sbt] class CopiedFileTreeRepository[T](underlying: FileTreeRepository[T]) extends FileTreeRepository[T] { def addObserver(observer: Observer[T]) = underlying.addObserver(observer) diff --git a/main/src/main/scala/sbt/internal/FileTree.scala b/main/src/main/scala/sbt/internal/FileTree.scala index 14895b48c..2619666cb 100644 --- a/main/src/main/scala/sbt/internal/FileTree.scala +++ b/main/src/main/scala/sbt/internal/FileTree.scala @@ -18,8 +18,14 @@ import scala.collection.mutable import scala.language.experimental.macros object FileTree { - private def toPair(e: Entry[FileAttributes]): Option[(Path, FileAttributes)] = - e.value.toOption.map(a => e.typedPath.toPath -> a) + private sealed trait CacheOptions + private case object NoCache extends CacheOptions + private case object UseCache extends CacheOptions + private case object LogDifferences extends CacheOptions + private def toPair( + filter: Entry[FileAttributes] => Boolean + )(e: Entry[FileAttributes]): Option[(Path, FileAttributes)] = + e.value.toOption.flatMap(a => if (filter(e)) Some(e.typedPath.toPath -> a) else None) trait Repository extends sbt.internal.Repository[Seq, Glob, (Path, FileAttributes)] private[sbt] trait DynamicInputs { def value: Option[mutable.Set[Glob]] @@ -42,27 +48,57 @@ object FileTree { private[sbt] object polling extends Repository { val view = FileTreeView.DEFAULT.asDataView(FileAttributes.default) override def get(key: Glob): Seq[(Path, FileAttributes)] = - view.listEntries(key).flatMap(toPair) + view.listEntries(key).flatMap(toPair(key.toEntryFilter)) override def close(): Unit = {} } } - private class ViewRepository(underlying: FileTreeDataView[FileAttributes]) extends Repository { - override def get(key: Glob): Seq[(Path, FileAttributes)] = - underlying.listEntries(key).flatMap(toPair) - override def close(): Unit = {} - } private class CachingRepository(underlying: FileTreeRepository[FileAttributes]) extends Repository { + lazy val cacheOptions = System.getProperty("sbt.io.filecache") match { + case "false" => NoCache + case "true" => UseCache + case _ => LogDifferences + } override def get(key: Glob): Seq[(Path, FileAttributes)] = { underlying.register(key) - //underlying.listEntries(key).flatMap(toPair).distinct - Repository.polling.get(key) + cacheOptions match { + case LogDifferences => + val res = Repository.polling.get(key) + val filter = key.toEntryFilter + val cacheRes = underlying + .listEntries(key) + .flatMap(e => if (filter(e)) Some(e.typedPath.toPath) else None) + .toSet + val resSet = res.map(_._1).toSet + if (cacheRes != resSet) { + val msg = "Warning: got different files when using the internal file cache compared " + + s"to polling the file system for key: $key.\n" + val fileDiff = cacheRes diff resSet match { + case d if d.nonEmpty => + new Exception("hmm").printStackTrace() + s"Cache had files not found in the file system:\n${d.mkString("\n")}.\n" + case _ => "" + } + val cacheDiff = resSet diff cacheRes match { + case d if d.nonEmpty => + (if (fileDiff.isEmpty) "" else " ") + + s"File system had files not in the cache:\n${d.mkString("\n")}.\n" + case _ => "" + } + val diff = fileDiff + cacheDiff + val instructions = "Please open an issue at https://github.com/sbt/sbt. To disable " + + "this warning, run sbt with -Dsbt.io.filecache=false" + System.err.println(msg + diff + instructions) + } + res + case UseCache => + underlying.listEntries(key).flatMap(toPair(key.toEntryFilter)) + case NoCache => + Repository.polling.get(key) + } } override def close(): Unit = underlying.close() } - private[sbt] def repository(underlying: FileTreeDataView[FileAttributes]): Repository = - underlying match { - case r: FileTreeRepository[FileAttributes] => new CachingRepository(r) - case v => new ViewRepository(v) - } + private[sbt] def repository(underlying: FileTreeRepository[FileAttributes]): Repository = + new CachingRepository(underlying) }