Invalidate unmanagedFileStampCache in allOutputFiles

In the code formatting use case, the formatting task may modify the
source files in place. If the formatting task uses the nio
inputFileStamps, then it would fill the in-memory cache of source paths
to file stamps. This would cause compile to see the pre-formatted
stamps. To fix this, we can invalidate the file cache entries for the
outputs of a task. This will cause the side-effect of some extra io
because the hashes may be computed three times: once for the format
inputs, once for the format outputs and once for the compile inputs. I
think most users would understand that adding auto-formatting would
potentially slowdown compilation.

To really prove this out, I implemented a poor man's scalafmt plugin in
a scripted test. It is fully incremental. Even in the case when some
files cannot be formatted it will update all of the files that can be
formatted and not re-format them until they change.
This commit is contained in:
Ethan Atkins 2019-08-09 10:52:50 -07:00
parent 3f026972d5
commit 7c483909af
10 changed files with 174 additions and 1 deletions

View File

@ -348,7 +348,11 @@ private[sbt] object Settings {
case LastModified => FileStamp.lastModified
case Hash => FileStamp.hash
}
(allOutputFiles in scope).value.flatMap(p => stamper(p).map(p -> _))
val allFiles = (allOutputFiles in scope).value
// The cache invalidation is specifically so that source formatters can run before
// the compile task and the file stamps seen by compile match the post-format stamps.
allFiles.foreach((unmanagedFileStampCache in scope).value.invalidate)
allFiles.flatMap(p => stamper(p).map(p -> _))
})
}

View File

@ -0,0 +1,21 @@
version = 2.0.0
maxColumn = 100
project.git = true
project.excludeFilters = [ "\\Wsbt-test\\W", "\\Winput_sources\\W", "\\Wcontraband-scala\\W" ]
# http://docs.scala-lang.org/style/scaladoc.html recommends the JavaDoc style.
# scala/scala is written that way too https://github.com/scala/scala/blob/v2.12.2/src/library/scala/Predef.scala
docstrings = JavaDoc
# This also seems more idiomatic to include whitespace in import x.{ yyy }
spaces.inImportCurlyBraces = true
# This is more idiomatic Scala.
# http://docs.scala-lang.org/style/indentation.html#methods-with-numerous-arguments
align.openParenCallSite = false
align.openParenDefnSite = false
# For better code clarity
danglingParentheses = true
trailingCommas = preserve

View File

@ -0,0 +1,40 @@
import java.nio.file.Path
import complete.DefaultParsers._
enablePlugins(ScalafmtPlugin)
val classFiles = taskKey[Seq[Path]]("The classfiles generated by compile")
classFiles := {
val classes = (Compile / classDirectory).value.toGlob / ** / "*.class"
fileTreeView.value.list(classes).map(_._1)
}
classFiles := classFiles.dependsOn(Compile / compile).value
val compileAndCheckNoClassFileUpdates = taskKey[Unit]("Checks that there are no class file updates")
compileAndCheckNoClassFileUpdates := {
val current = (classFiles / outputFileStamps).value.toSet
val previous = (classFiles / outputFileStamps).previous.getOrElse(Nil).toSet
assert(current == previous)
}
val checkLastModified = inputKey[Unit]("Check the last modified time for a file")
checkLastModified := {
(Space ~> OptSpace ~> matched(charClass(_ != ' ').+) ~ (Space ~> ('!'.? ~ Digit.+.map(
_.mkString.toLong
)))).parsed match {
case (file, (negate, expectedLastModified)) =>
val sourceFile = baseDirectory.value / "src" / "main" / "scala" / file
val lastModified = IO.getModifiedTimeOrZero(sourceFile)
negate match {
case Some(_) => assert(lastModified != expectedLastModified)
case None => assert(lastModified == expectedLastModified)
}
}
}
val setLastModified = inputKey[Unit]("Set the last modified time for a file")
setLastModified := {
val Seq(file, lm) = Def.spaceDelimited().parsed
val sourceFile = baseDirectory.value / "src" / "main" / "scala" / file
IO.setModifiedTimeOrFalse(sourceFile, lm.toLong)
}

View File

@ -0,0 +1 @@
class Bar { val x = }

View File

@ -0,0 +1 @@
class Bar {val x=2}

View File

@ -0,0 +1 @@
class Foo{val x=1}

View File

@ -0,0 +1 @@
libraryDependencies += "org.scalameta" %% "scalafmt-dynamic" % "2.0.0"

View File

@ -0,0 +1,56 @@
import java.io.PrintWriter
import java.nio.file._
import sbt._
import sbt.Keys.{ baseDirectory, unmanagedSources }
import sbt.nio.Keys.{ fileInputs, inputFileStamps, outputFileStamper, outputFileStamps }
import sbt.nio.FileStamper
import org.scalafmt.interfaces.{ Scalafmt, ScalafmtReporter }
object ScalafmtPlugin extends AutoPlugin {
private val reporter = new ScalafmtReporter {
override def error(file: Path, message: String): Unit = throw new Exception(s"$file $message")
override def error(file: Path, e: Throwable): Unit = throw e
override def excluded(file: Path): Unit = {}
override def parsedConfig(config: Path, scalafmtVersion: String): Unit = {}
override def downloadWriter: PrintWriter = new PrintWriter(System.out, true)
}
private val formatter = Scalafmt.create(this.getClass.getClassLoader).withReporter(reporter)
object autoImport {
val scalafmtImpl = taskKey[Seq[Path]]("Format scala sources")
val scalafmt = taskKey[Unit]("Format scala sources and validate results")
}
import autoImport._
override lazy val projectSettings = super.projectSettings ++ Seq(
Compile / scalafmtImpl / fileInputs := (Compile / unmanagedSources / fileInputs).value,
Compile / scalafmtImpl / outputFileStamper := FileStamper.Hash,
Compile / scalafmtImpl := {
val config = baseDirectory.value.toPath / ".scalafmt.conf"
val allInputStamps = (Compile / scalafmtImpl / inputFileStamps).value
val previous =
(Compile / scalafmtImpl / outputFileStamps).previous.map(_.toMap).getOrElse(Map.empty)
allInputStamps.flatMap {
case (p, s) if previous.get(p).fold(false)(_ == s) => Some(p)
case (p, s) =>
try {
println(s"Formatting $p")
Files.write(p, formatter.format(config, p, new String(Files.readAllBytes(p))).getBytes)
Some(p)
} catch {
case e: Exception =>
println(e)
None
}
}
},
Compile / scalafmt := {
val outputs = (Compile / scalafmtImpl / outputFileStamps).value.toMap
val improperlyFormatted = (Compile / scalafmtImpl).inputFiles.filterNot(outputs.contains _)
if (improperlyFormatted.nonEmpty) {
val msg = s"There were improperly formatted files:\n${improperlyFormatted mkString "\n"}"
throw new IllegalStateException(msg)
}
},
Compile / unmanagedSources / inputFileStamps :=
(Compile / unmanagedSources / inputFileStamps).dependsOn(Compile / scalafmt).value
)
}

View File

@ -0,0 +1 @@
class Foo{val x=1}

View File

@ -0,0 +1,47 @@
> setLastModified Foo.scala 12345678
# The first time we run compile, we expect an updated class file for Foo.class
-> compileAndCheckNoClassFileUpdates
# scalafmt should modify Foo.scala
> checkLastModified Foo.scala !12345678
# The first time we run compile, there should be no updates since Foo.scala hasn't changed since
# scalafmt modified it in the first run
> compileAndCheckNoClassFileUpdates
$ copy-file changes/Foo.scala src/main/scala/Foo.scala
$ copy-file changes/Bar-bad.scala src/main/scala/Bar.scala
> setLastModified Foo.scala 12345678
> setLastModified Bar.scala 12345678
# formatting should fail because Bar.scala is invalid, but Foo.scala should be re-formatted
-> scalafmt
> checkLastModified Foo.scala !12345678
> checkLastModified Bar.scala 12345678
$ copy-file changes/Bar.scala src/main/scala/Bar.scala
> setLastModified Foo.scala 12345678
> setLastModified Bar.scala 12345678
# Formatting should now succeed and Foo.scala should not be re-formatted
> scalafmt
> checkLastModified Foo.scala 12345678
> checkLastModified Bar.scala !12345678
# make sure that the custom clean task doesn't blow away the scala source files (it should exclude
# any files not in the target directory
> scalafmt / clean
$ exists src/main/scala/Foo.scala
$ exists src/main/scala/Bar.scala