Override zinc compile analysis for source changes

Zinc records all of the compile source file hashes when compilation
completes. This is problematic because its possible that a source file
was changed during compilation. From the user perspective, this may mean
that their source change will not be recompiled even if a build is
triggered by the change.

To overcome this, I add logic in the sbt provided external hooks to
override the zinc analysis stamps. This is done by writing the source
file stamps to the previous cache after compilation completes. This
allows us to see the source differences from sbt's perspective, rather
than zinc's perspective. We then merge the combined differences in the
actual implementation of ExternalHooks. In some cases this may result in
over-compilation but generally over-compilation is preferred to under
compilation. Most of the time, the results should be the same.

The scripted test that I added modifies a file during compilation by
invoking a macro. It then effectively asserts that the file is
recompiled during the next test run by validating the compilation result
in the test. The test fails on the latest develop hash.
This commit is contained in:
Ethan Atkins 2019-07-27 12:57:43 -07:00
parent 349d365bab
commit 4061dabf4d
9 changed files with 110 additions and 42 deletions

View File

@ -410,6 +410,7 @@ object Defaults extends BuildCommon {
}, },
unmanagedSources := (unmanagedSources / inputFileStamps).value.map(_._1.toFile), unmanagedSources := (unmanagedSources / inputFileStamps).value.map(_._1.toFile),
managedSourceDirectories := Seq(sourceManaged.value), managedSourceDirectories := Seq(sourceManaged.value),
managedSources / outputFileStamper := sbt.nio.FileStamper.Hash,
managedSources := { managedSources := {
val stamper = inputFileStamper.value val stamper = inputFileStamper.value
val cache = managedFileStampCache.value val cache = managedFileStampCache.value
@ -603,7 +604,18 @@ object Defaults extends BuildCommon {
else "" else ""
s"inc_compile$extra.zip" s"inc_compile$extra.zip"
}, },
incOptions := { incOptions.value.withExternalHooks(ExternalHooks.default.value) }, externalHooks := {
val current =
(unmanagedSources / inputFileStamps).value ++ (managedSources / outputFileStamps).value
val previous = (externalHooks / inputFileStamps).previous
ExternalHooks.default.value(previous.flatMap(sbt.nio.Settings.changedFiles(_, current)))
},
externalHooks / inputFileStamps := {
compile.value // ensures the inputFileStamps previous value is only set if compile succeeds.
(unmanagedSources / inputFileStamps).value ++ (managedSources / outputFileStamps).value
},
externalHooks / inputFileStamps := (externalHooks / inputFileStamps).triggeredBy(compile).value,
incOptions := { incOptions.value.withExternalHooks(externalHooks.value) },
compileIncSetup := compileIncSetupTask.value, compileIncSetup := compileIncSetupTask.value,
console := consoleTask.value, console := consoleTask.value,
collectAnalyses := Definition.collectAnalysesTask.map(_ => ()).value, collectAnalyses := Definition.collectAnalysesTask.map(_ => ()).value,

View File

@ -212,6 +212,7 @@ object Keys {
val copyResources = taskKey[Seq[(File, File)]]("Copies resources to the output directory.").withRank(AMinusTask) val copyResources = taskKey[Seq[(File, File)]]("Copies resources to the output directory.").withRank(AMinusTask)
val aggregate = settingKey[Boolean]("Configures task aggregation.").withRank(BMinusSetting) val aggregate = settingKey[Boolean]("Configures task aggregation.").withRank(BMinusSetting)
val sourcePositionMappers = taskKey[Seq[xsbti.Position => Option[xsbti.Position]]]("Maps positions in generated source files to the original source it was generated from").withRank(DTask) val sourcePositionMappers = taskKey[Seq[xsbti.Position => Option[xsbti.Position]]]("Maps positions in generated source files to the original source it was generated from").withRank(DTask)
private[sbt] val externalHooks = taskKey[ExternalHooks]("The external hooks used by zinc.")
// package keys // package keys
val packageBin = taskKey[File]("Produces a main artifact, such as a binary jar.").withRank(ATask) val packageBin = taskKey[File]("Produces a main artifact, such as a binary jar.").withRank(ATask)

View File

@ -7,7 +7,7 @@
package sbt.internal package sbt.internal
import java.nio.file.Paths import java.nio.file.{ Path, Paths }
import java.util.Optional import java.util.Optional
import sbt.Def import sbt.Def
@ -16,18 +16,17 @@ import sbt.internal.inc.ExternalLookup
import sbt.internal.inc.Stamp.equivStamp.equiv import sbt.internal.inc.Stamp.equivStamp.equiv
import sbt.io.syntax._ import sbt.io.syntax._
import sbt.nio.Keys._ import sbt.nio.Keys._
import sbt.nio.file.RecursiveGlob
import sbt.nio.file.syntax._ import sbt.nio.file.syntax._
import sbt.nio.file.{ ChangedFiles, RecursiveGlob }
import sbt.nio.{ FileStamp, FileStamper } import sbt.nio.{ FileStamp, FileStamper }
import xsbti.compile._ import xsbti.compile._
import xsbti.compile.analysis.Stamp import xsbti.compile.analysis.Stamp
import scala.collection.JavaConverters._ import scala.collection.JavaConverters._
import scala.collection.mutable
private[sbt] object ExternalHooks { private[sbt] object ExternalHooks {
private val javaHome = Option(System.getProperty("java.home")).map(Paths.get(_)) private val javaHome = Option(System.getProperty("java.home")).map(Paths.get(_))
def default: Def.Initialize[sbt.Task[ExternalHooks]] = Def.task { def default: Def.Initialize[sbt.Task[Option[ChangedFiles] => ExternalHooks]] = Def.task {
val unmanagedCache = unmanagedFileStampCache.value val unmanagedCache = unmanagedFileStampCache.value
val managedCache = managedFileStampCache.value val managedCache = managedFileStampCache.value
val cp = dependencyClasspath.value.map(_.data) val cp = dependencyClasspath.value.map(_.data)
@ -39,9 +38,11 @@ private[sbt] object ExternalHooks {
fileTreeView.value.list(classGlob).foreach { fileTreeView.value.list(classGlob).foreach {
case (path, _) => managedCache.update(path, FileStamper.LastModified) case (path, _) => managedCache.update(path, FileStamper.LastModified)
} }
apply((compileOptions in compile).value, unmanagedCache, managedCache) val options = (compileOptions in compile).value
apply(_, options, unmanagedCache, managedCache)
} }
private def apply( private def apply(
changedFiles: Option[ChangedFiles],
options: CompileOptions, options: CompileOptions,
unmanagedCache: FileStamp.Cache, unmanagedCache: FileStamp.Cache,
managedCache: FileStamp.Cache managedCache: FileStamp.Cache
@ -53,22 +54,34 @@ private[sbt] object ExternalHooks {
val getRemoved: java.util.Set[File] = new java.util.HashSet[File] val getRemoved: java.util.Set[File] = new java.util.HashSet[File]
val getChanged: java.util.Set[File] = new java.util.HashSet[File] val getChanged: java.util.Set[File] = new java.util.HashSet[File]
val getUnmodified: java.util.Set[File] = new java.util.HashSet[File] val getUnmodified: java.util.Set[File] = new java.util.HashSet[File]
private def add(p: Path, sets: java.util.Set[File]*): Unit = {
sets.foreach(add(p.toFile, _))
}
private def add(f: File, set: java.util.Set[File]): Unit = { set.add(f); () }
val allChanges = new java.util.HashSet[File]
changedFiles foreach {
case ChangedFiles(c, d, u) =>
c.foreach(add(_, getAdded, allChanges))
d.foreach(add(_, getRemoved, allChanges))
u.foreach(add(_, getChanged, allChanges))
}
override def isEmpty: java.lang.Boolean = override def isEmpty: java.lang.Boolean =
getAdded.isEmpty && getRemoved.isEmpty && getChanged.isEmpty getAdded.isEmpty && getRemoved.isEmpty && getChanged.isEmpty
val prevSources: mutable.Map[File, Stamp] = private val prevSources = previousAnalysis.readStamps().getAllSourceStamps
previousAnalysis.readStamps().getAllSourceStamps.asScala prevSources.forEach { (file: File, s: Stamp) =>
prevSources.foreach { if (!allChanges.contains(file)) {
case (file: File, s: Stamp) =>
val path = file.toPath val path = file.toPath
unmanagedCache unmanagedCache
.get(path) .get(path)
.orElse(managedCache.getOrElseUpdate(file.toPath, FileStamper.Hash)) match { .orElse(managedCache.getOrElseUpdate(file.toPath, FileStamper.Hash)) match {
case None => getRemoved.add(file) case None => add(file, getRemoved)
case Some(stamp) => case Some(stamp) =>
if (equiv(stamp.stamp, s)) getUnmodified.add(file) else getChanged.add(file) if (equiv(stamp.stamp, s)) add(file, getUnmodified)
else add(file, getChanged)
} }
}
} }
options.sources.foreach(file => if (!prevSources.contains(file)) getAdded.add(file)) options.sources.foreach(file => if (!prevSources.containsKey(file)) getAdded.add(file))
} }
} }

View File

@ -251,36 +251,38 @@ private[sbt] object Settings {
): Def.Setting[_] = ): Def.Setting[_] =
addTaskDefinition(changeKey in scopedKey.scope := { addTaskDefinition(changeKey in scopedKey.scope := {
val current = (stampKey in scopedKey.scope).value val current = (stampKey in scopedKey.scope).value
(stampKey in scopedKey.scope).previous match { (stampKey in scopedKey.scope).previous.flatMap(changedFiles(_, current))
case Some(previous) =>
val createdBuilder = new VectorBuilder[Path]
val deletedBuilder = new VectorBuilder[Path]
val updatedBuilder = new VectorBuilder[Path]
val currentMap = current.toMap
val prevMap = previous.toMap
current.foreach {
case (path, currentStamp) =>
prevMap.get(path) match {
case Some(oldStamp) => if (oldStamp != currentStamp) updatedBuilder += path
case None => createdBuilder += path
}
}
previous.foreach {
case (path, _) =>
if (currentMap.get(path).isEmpty) deletedBuilder += path
}
val created = createdBuilder.result()
val deleted = deletedBuilder.result()
val updated = updatedBuilder.result()
if (created.isEmpty && deleted.isEmpty && updated.isEmpty) {
None
} else {
val cf = ChangedFiles(created = created, deleted = deleted, updated = updated)
Some(cf)
}
case None => None
}
}) })
private[sbt] def changedFiles(
previous: Seq[(Path, FileStamp)],
current: Seq[(Path, FileStamp)]
): Option[ChangedFiles] = {
val createdBuilder = new VectorBuilder[Path]
val deletedBuilder = new VectorBuilder[Path]
val updatedBuilder = new VectorBuilder[Path]
val currentMap = current.toMap
val prevMap = previous.toMap
current.foreach {
case (path, currentStamp) =>
prevMap.get(path) match {
case Some(oldStamp) => if (oldStamp != currentStamp) updatedBuilder += path
case None => createdBuilder += path
}
}
previous.foreach {
case (path, _) =>
if (currentMap.get(path).isEmpty) deletedBuilder += path
}
val created = createdBuilder.result()
val deleted = deletedBuilder.result()
val updated = updatedBuilder.result()
if (created.isEmpty && deleted.isEmpty && updated.isEmpty) {
None
} else {
val cf = ChangedFiles(created = created, deleted = deleted, updated = updated)
Some(cf)
}
}
/** /**
* Provides an automatically generated clean method for a task that provides fileOutputs. * Provides an automatically generated clean method for a task that provides fileOutputs.

View File

@ -0,0 +1,23 @@
val generateSourceFile = taskKey[Unit]("generate source file")
generateSourceFile := {
val testDir = ((Test / scalaSource).value.toPath / "Foo.scala").toString
val content = s"object Foo { val x = 2 }"
val src =
s"""
|import scala.language.experimental.macros
|import scala.reflect.macros.blackbox
|import java.nio.file.{ Files, Paths }
|
|object Generate {
| def gen: Unit = macro genImpl
| def genImpl(c: blackbox.Context): c.Expr[Unit] = {
| Files.write(Paths.get("${testDir.replace("\\", "\\\\")}"), "$content".getBytes)
| c.universe.reify(())
| }
|}
|""".stripMargin
IO.write((Compile / scalaSource).value / "Generate.scala", src)
}
libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5" % "test"

View File

@ -0,0 +1,3 @@
object Foo {
def x = 1
}

View File

@ -0,0 +1,8 @@
import org.scalatest.FlatSpec
class FooTest extends FlatSpec {
Generate.gen
it should "work" in {
assert(Foo.x == 2)
}
}

View File

@ -0,0 +1,5 @@
> generateSourceFile
-> test
> test

View File

@ -14,6 +14,7 @@ recordPreviousIterations := {
log.info("No previous analysis detected") log.info("No previous analysis detected")
0 0
case Some(a: Analysis) => a.compilations.allCompilations.size case Some(a: Analysis) => a.compilations.allCompilations.size
case Some(_) => -1 // should be unreachable but causes warnings
} }
} }
} }