diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5b6f..4b0837e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: with: java-version: ${{ matrix.java }} - run: git fetch --tags -f + - run: + # for git tests + git config --global user.email "scalafmt@scalameta.org" && git config --global user.name "scalafmt" - run: sbt plugin/scripted formatting: runs-on: ubuntu-latest diff --git a/build.sbt b/build.sbt index df80bc2..b203696 100644 --- a/build.sbt +++ b/build.sbt @@ -40,6 +40,7 @@ lazy val plugin = project .settings( moduleName := "sbt-scalafmt", libraryDependencies ++= List( + "org.scalameta" %% "scalafmt-sysops" % scalafmtVersion, "org.scalameta" %% "scalafmt-dynamic" % scalafmtVersion ), scriptedBufferLog := false, diff --git a/plugin/src/main/scala/org/scalafmt/sbt/ScalafmtPlugin.scala b/plugin/src/main/scala/org/scalafmt/sbt/ScalafmtPlugin.scala index f24b0d4..cd91beb 100644 --- a/plugin/src/main/scala/org/scalafmt/sbt/ScalafmtPlugin.scala +++ b/plugin/src/main/scala/org/scalafmt/sbt/ScalafmtPlugin.scala @@ -9,13 +9,15 @@ import sbt.librarymanagement.MavenRepository import sbt.util.CacheImplicits._ import sbt.util.CacheStoreFactory import sbt.util.FileInfo -import sbt.util.Logger import scala.util.Failure import scala.util.Success import scala.util.Try import org.scalafmt.interfaces.Scalafmt +import org.scalafmt.sysops.AbsoluteFile +import org.scalafmt.sysops.FileOps +import org.scalafmt.sysops.GitOps import complete.DefaultParsers._ @@ -65,6 +67,9 @@ object ScalafmtPlugin extends AutoPlugin { settingKey[Boolean]( "Enables logging of detailed errors with stacktraces, disabled by default" ) + val scalafmtFilter = settingKey[String]( + "File filtering mode when running scalafmt." + ) } import autoImport._ @@ -103,204 +108,197 @@ object ScalafmtPlugin extends AutoPlugin { .create(this.getClass.getClassLoader) .withRespectProjectFilters(true) - private def withFormattedSources[T]( - sources: Seq[File], + private object FilterMode { + val diffDirty = "diff-dirty" + val diffRefPrefix = "diff-ref=" + } + + private class FormatSession( config: Path, - log: Logger, - writer: OutputStreamWriter, + taskStreams: TaskStreams, resolvers: Seq[Resolver], + currentProject: ResolvedProject, + filterMode: String, detailedErrorEnabled: Boolean - )( - onFormat: (File, Input, Output) => T - ): Seq[Option[T]] = { - val reporter = new ScalafmtSbtReporter(log, writer, detailedErrorEnabled) - val repositories = resolvers.collect { case r: MavenRepository => - r.root - } - log.debug( - s"Adding repositories ${repositories.mkString("[", ",", "]")}" + ) { + private val log = taskStreams.log + private val reporter = new ScalafmtSbtReporter( + log, + new OutputStreamWriter(taskStreams.binary()), + detailedErrorEnabled ) - val scalafmtSession = - globalInstance - .withReporter(reporter) - .withMavenRepositories(repositories: _*) - .createSession(config.toAbsolutePath) - if (scalafmtSession == null) - throw new MessageOnlyException( - "failed to create formatting session. Please report bug to https://github.com/scalameta/sbt-scalafmt" + + private val scalafmtSession = { + val repositories = resolvers.collect { case r: MavenRepository => + r.root + } + log.debug( + s"Adding repositories ${repositories.mkString("[", ",", "]")}" ) + val scalafmtSession = + globalInstance + .withReporter(reporter) + .withMavenRepositories(repositories: _*) + .createSession(config.toAbsolutePath) + if (scalafmtSession == null) + throw new MessageOnlyException( + "failed to create formatting session. Please report bug to https://github.com/scalameta/sbt-scalafmt" + ) + scalafmtSession + } - sources.map { file => - val path = file.toPath.toAbsolutePath - if (scalafmtSession.matchesProjectFilters(path)) { - val input = IO.read(file) - val output = scalafmtSession.format(path, input) - Some(onFormat(file, input, output)) - } else None + private def filterFiles(sources: Seq[File]): Seq[File] = { + val filter = getFileFilter() + sources.distinct.filter { file => + val path = file.toPath.toAbsolutePath + scalafmtSession.matchesProjectFilters(path) && filter(path) + } } - } - private def formatSources( - cacheStoreFactory: CacheStoreFactory, - sources: Seq[File], - config: Path, - log: Logger, - writer: OutputStreamWriter, - resolvers: Seq[Resolver], - detailedErrorEnabled: Boolean - ): Unit = - trackSourcesAndConfig(cacheStoreFactory, sources, config) { - (outDiff, configChanged, prev) => - log.debug(outDiff.toString) - val updatedOrAdded = outDiff.modified & outDiff.checked - val filesToFormat: Set[File] = - if (configChanged) sources.toSet - else { - // in addition to the detected changes, process files that failed scalafmtCheck - // we can ignore the succeeded files because, they don't require reformatting - updatedOrAdded | prev.failedScalafmtCheck - } - if (filesToFormat.nonEmpty) { - log.info(s"Formatting ${filesToFormat.size} Scala sources...") - formatSources( - filesToFormat, - config, - log, - writer, - resolvers, - detailedErrorEnabled - ) - } - ScalafmtAnalysis(Set.empty) + private def getFileFilter(): Path => Boolean = { + def gitOps = GitOps.FactoryImpl(AbsoluteFile(currentProject.base.toPath)) + val files = + if (filterMode == FilterMode.diffDirty) + gitOps.status() + else if (filterMode.startsWith(FilterMode.diffRefPrefix)) + gitOps.diff(filterMode.substring(FilterMode.diffRefPrefix.length)) + else if (scalafmtSession.isGitOnly) + gitOps.lsTree() + else null + if (files eq null) _ => true + else FileOps.getFileMatcher(files.map(_.path)) } - private def formatSources( - sources: Set[File], - config: Path, - log: Logger, - writer: OutputStreamWriter, - resolvers: Seq[Resolver], - detailedErrorEnabled: Boolean - ): Unit = { - val cnt = - withFormattedSources( - sources.toSeq, - config, - log, - writer, - resolvers, - detailedErrorEnabled - ) { (file, input, output) => - if (input != output) { - IO.write(file, output) - 1 - } else { - 0 + private def withFormattedSources[T](sources: Seq[File])( + onFormat: (File, Input, Output) => T + ): Seq[Option[T]] = + sources.map { file => + val path = file.toPath.toAbsolutePath + Try(IO.read(file)) match { + case Failure(x) => + reporter.error(path, "Failed to read", x) + None + case Success(x) => + val output = scalafmtSession.format(path, x) + Some(onFormat(file, x, output)) } - }.flatten.sum + } - if (cnt > 1) { - log.info(s"Reformatted $cnt Scala sources") + def formatTrackedSources( + cacheStoreFactory: CacheStoreFactory, + sources: Seq[File] + ): Unit = { + val filteredSources = filterFiles(sources) + trackSourcesAndConfig(cacheStoreFactory, filteredSources) { + (outDiff, configChanged, prev) => + val updatedOrAdded = outDiff.modified & outDiff.checked + val filesToFormat: Seq[File] = + if (configChanged) filteredSources + else { + // in addition to the detected changes, process files that failed scalafmtCheck + // we can ignore the succeeded files because, they don't require reformatting + (updatedOrAdded | prev.failedScalafmtCheck).toSeq + } + formatFilteredSources(filesToFormat) + ScalafmtAnalysis(Set.empty) + } } - } + def formatSources(sources: Seq[File]): Unit = + formatFilteredSources(filterFiles(sources)) + + private def formatFilteredSources(sources: Seq[File]): Unit = { + if (sources.nonEmpty) + log.info(s"Formatting ${sources.length} Scala sources...") + val cnt = + withFormattedSources(sources) { (file, input, output) => + if (input != output) { + IO.write(file, output) + 1 + } else { + 0 + } + }.flatten.sum - private def checkSources( - cacheStoreFactory: CacheStoreFactory, - sources: Seq[File], - config: Path, - log: Logger, - writer: OutputStreamWriter, - resolvers: Seq[Resolver], - detailedErrorEnabled: Boolean - ): ScalafmtAnalysis = - trackSourcesAndConfig(cacheStoreFactory, sources, config) { - (outDiff, configChanged, prev) => - log.debug(outDiff.toString) - val updatedOrAdded = outDiff.modified & outDiff.checked - val filesToCheck: Set[File] = - if (configChanged) sources.toSet - else updatedOrAdded - val prevFailed: Set[File] = - if (configChanged) Set.empty - else prev.failedScalafmtCheck & outDiff.unmodified - prevFailed foreach { warnBadFormat(_, log) } - val result = - checkSources( - filesToCheck.toSeq, - config, - log, - writer, - resolvers, - detailedErrorEnabled + if (cnt > 1) { + log.info(s"Reformatted $cnt Scala sources") + } + } + + def checkTrackedSources( + cacheStoreFactory: CacheStoreFactory, + sources: Seq[File] + ): ScalafmtAnalysis = { + val filteredSources = filterFiles(sources) + trackSourcesAndConfig(cacheStoreFactory, filteredSources) { + (outDiff, configChanged, prev) => + val updatedOrAdded = outDiff.modified & outDiff.checked + val filesToCheck: Seq[File] = + if (configChanged) filteredSources else updatedOrAdded.toSeq + val prevFailed: Set[File] = + if (configChanged) Set.empty + else prev.failedScalafmtCheck & outDiff.unmodified + prevFailed.foreach(warnBadFormat) + val result = + checkFilteredSources(filesToCheck) + prev.copy( + failedScalafmtCheck = result.failedScalafmtCheck | prevFailed ) - prev.copy( - failedScalafmtCheck = result.failedScalafmtCheck | prevFailed - ) + } } - private def warnBadFormat(file: File, log: Logger): Unit = - log.warn(s"${file.toString} isn't formatted properly!") + private def warnBadFormat(file: File): Unit = + log.warn(s"${file.toString} isn't formatted properly!") - private def checkSources( - sources: Seq[File], - config: Path, - log: Logger, - writer: OutputStreamWriter, - resolvers: Seq[Resolver], - detailedErrorEnabled: Boolean - ): ScalafmtAnalysis = { - if (sources.nonEmpty) { - log.info(s"Checking ${sources.size} Scala sources...") + def checkSources(sources: Seq[File]): ScalafmtAnalysis = + checkFilteredSources(filterFiles(sources)) + + private def checkFilteredSources(sources: Seq[File]): ScalafmtAnalysis = { + if (sources.nonEmpty) { + log.info(s"Checking ${sources.size} Scala sources...") + } + val unformatted = + withFormattedSources(sources) { (file, input, output) => + val diff = input != output + if (diff) { + warnBadFormat(file) + Some(file) + } else None + }.flatten.flatten.toSet + ScalafmtAnalysis(failedScalafmtCheck = unformatted) } - val unformatted = - withFormattedSources( - sources, - config, - log, - writer, - resolvers, - detailedErrorEnabled - ) { (file, input, output) => - val diff = input != output - if (diff) { - warnBadFormat(file, log) - Some(file) - } else None - }.flatten.flatten.toSet - ScalafmtAnalysis(failedScalafmtCheck = unformatted) - } - // This tracks - // 1. previous value - // 2. changes to the config file - // 3. changes to source and their last modified dates after the operation - // The tracking is shared between scalafmt and scalafmtCheck - private def trackSourcesAndConfig( - cacheStoreFactory: CacheStoreFactory, - sources: Seq[File], - config: Path - )( - f: (ChangeReport[File], Boolean, ScalafmtAnalysis) => ScalafmtAnalysis - ): ScalafmtAnalysis = { - // use prevTracker to share previous values between tasks - val prevTracker = Tracked.lastOutput[Unit, ScalafmtAnalysis]( - cacheStoreFactory.make("last") - ) { (_, prev0) => - val prev = prev0.getOrElse(ScalafmtAnalysis(Set.empty)) - val tracker = Tracked.inputChanged[HashFileInfo, ScalafmtAnalysis]( - cacheStoreFactory.make("config") - ) { case (configChanged, configHash) => - Tracked.diffOutputs( - cacheStoreFactory.make("output-diff"), - FileInfo.lastModified - )(sources.toSet) { (outDiff: ChangeReport[File]) => - f(outDiff, configChanged, prev) + // This tracks + // 1. previous value + // 2. changes to the config file + // 3. changes to source and their last modified dates after the operation + // The tracking is shared between scalafmt and scalafmtCheck + private def trackSourcesAndConfig( + cacheStoreFactory: CacheStoreFactory, + sources: Seq[File] + )( + f: (ChangeReport[File], Boolean, ScalafmtAnalysis) => ScalafmtAnalysis + ): ScalafmtAnalysis = { + // use prevTracker to share previous values between tasks + val prevTracker = Tracked.lastOutput[Unit, ScalafmtAnalysis]( + cacheStoreFactory.make("last") + ) { (_, prev0) => + val prev = prev0.getOrElse(ScalafmtAnalysis(Set.empty)) + val tracker = Tracked.inputChanged[HashFileInfo, ScalafmtAnalysis]( + cacheStoreFactory.make("config") + ) { case (configChanged, configHash) => + Tracked.diffOutputs( + cacheStoreFactory.make("output-diff"), + FileInfo.lastModified + )(sources.toSet) { (outDiff: ChangeReport[File]) => + log.debug(outDiff.toString()) + f(outDiff, configChanged, prev) + } } + tracker(FileInfo.hash(config.toFile)) } - tracker(FileInfo.hash(config.toFile)) + prevTracker(()) } - prevTracker(()) } private def throwOnFailure(analysis: ScalafmtAnalysis): Unit = { @@ -344,72 +342,43 @@ object ScalafmtPlugin extends AutoPlugin { } } - private def scalafmtTask(sources: Seq[File]) = + private def scalafmtTask(sources: Seq[File], session: FormatSession) = Def.task { - formatSources( - streams.value.cacheStoreFactory, - sources, - scalaConfig.value, - streams.value.log, - outputStreamWriter(streams.value), - fullResolvers.value, - scalafmtDetailedError.value - ) + session.formatTrackedSources(streams.value.cacheStoreFactory, sources) } tag (ScalafmtTagPack: _*) - private def scalafmtCheckTask(sources: Seq[File]) = + private def scalafmtCheckTask(sources: Seq[File], session: FormatSession) = Def.task { - val analysis = checkSources( + val analysis = session.checkTrackedSources( (scalafmt / streams).value.cacheStoreFactory, - sources, - scalaConfig.value, - streams.value.log, - outputStreamWriter(streams.value), - fullResolvers.value, - scalafmtDetailedError.value + sources ) throwOnFailure(analysis) } tag (ScalafmtTagPack: _*) private def getScalafmtSourcesTask( - f: Seq[File] => InitTask + f: (Seq[File], FormatSession) => InitTask ) = Def.taskDyn[Unit] { val sources = (unmanagedSources in scalafmt).?.value.getOrElse(Seq.empty) - if (sources.isEmpty) Def.task(Unit) - else f(sources) + getScalafmtTask(f)(sources, scalaConfig.value) } private def scalafmtSbtTask( sources: Seq[File], - config: Path + session: FormatSession ) = Def.task { - formatSources( - sources.toSet, - config, - streams.value.log, - outputStreamWriter(streams.value), - fullResolvers.value, - scalafmtDetailedError.value - ) + session.formatSources(sources) } tag (ScalafmtTagPack: _*) private def scalafmtSbtCheckTask( sources: Seq[File], - config: Path + session: FormatSession ) = Def.task { - val analysis = checkSources( - sources, - config, - streams.value.log, - outputStreamWriter(streams.value), - fullResolvers.value, - scalafmtDetailedError.value - ) - throwOnFailure(analysis) + throwOnFailure(session.checkSources(sources)) } tag (ScalafmtTagPack: _*) private def getScalafmtSbtTasks( - func: (Seq[File], Path) => InitTask + func: (Seq[File], FormatSession) => InitTask ) = Def.taskDyn { joinScalafmtTasks(func)( (sbtSources.value, sbtConfig.value), @@ -418,7 +387,7 @@ object ScalafmtPlugin extends AutoPlugin { } private def joinScalafmtTasks( - func: (Seq[File], Path) => InitTask + func: (Seq[File], FormatSession) => InitTask )(tuples: (Seq[File], Path)*) = { val tasks = tuples .map { case (files, config) => getScalafmtTask(func)(files, config) } @@ -426,9 +395,20 @@ object ScalafmtPlugin extends AutoPlugin { } private def getScalafmtTask( - func: (Seq[File], Path) => InitTask + func: (Seq[File], FormatSession) => InitTask )(files: Seq[File], config: Path) = Def.taskDyn[Unit] { - if (files.isEmpty) Def.task(Unit) else func(files, config) + if (files.isEmpty) Def.task(Unit) + else { + val session = new FormatSession( + config, + streams.value, + fullResolvers.value, + thisProject.value, + scalafmtFilter.value, + scalafmtDetailedError.value + ) + func(files, session) + } } lazy val scalafmtConfigSettings: Seq[Def.Setting[_]] = Seq( @@ -459,20 +439,17 @@ object ScalafmtPlugin extends AutoPlugin { } // scalaConfig - formatSources( - absFiles.toSet, + new FormatSession( scalaConfig.value, - streams.value.log, - outputStreamWriter(streams.value), + streams.value, fullResolvers.value, + thisProject.value, + "", scalafmtDetailedError.value - ) + ).formatSources(absFiles) } ) - private def outputStreamWriter(streams: TaskStreams): OutputStreamWriter = - new OutputStreamWriter(streams.binary()) - private val anyConfigsInThisProject = ScopeFilter( configurations = inAnyConfiguration ) @@ -493,6 +470,7 @@ object ScalafmtPlugin extends AutoPlugin { override def globalSettings: Seq[Def.Setting[_]] = Seq( + scalafmtFilter := "", scalafmtOnCompile := false, scalafmtDetailedError := false ) diff --git a/plugin/src/sbt-test/scalafmt-sbt/sbt/test b/plugin/src/sbt-test/scalafmt-sbt/sbt/test index bc850b2..cd4ee6f 100644 --- a/plugin/src/sbt-test/scalafmt-sbt/sbt/test +++ b/plugin/src/sbt-test/scalafmt-sbt/sbt/test @@ -142,6 +142,40 @@ $ delete p17/src/main/scala/Test2.scala $ copy-file changes/good.scala p17/src/main/scala/Test2.scala > p17/scalafmtCheck > p17/scalafmt +$ delete p17/src/main/scala/Test.scala + +# set up git +$ exec git init -b main p17 +# filter dirty files +> set p17/scalafmtFilter := ("diff-dirty") +# dirty but should succeed +$ copy-file changes/good.scala p17/src/main/scala/TestGood.scala +> p17/scalafmtCheck +# dirty and should fail +$ copy-file changes/bad.scala p17/src/main/scala/TestBad.scala +-> p17/scalafmtCheck +# tracked yet still fail +$ exec git -C p17 add "src/main/scala/TestBad.scala" +-> p17/scalafmtCheck +# no longer dirty, should succeed +$ exec git -C p17 commit -m 'added TestBad.scala' +> p17/scalafmtCheck +# filter tracked modifications since branch=main +> set p17/scalafmtFilter := ("diff-ref=main") +# TestBad is checked in, TestGood not tracked +> p17/scalafmtCheck +# copy but unchanged +$ copy-file changes/bad.scala p17/src/main/scala/TestBad.scala +> p17/scalafmtCheck +# copy to new file but untracked +$ copy-file changes/bad.scala p17/src/main/scala/TestBad2.scala +> p17/scalafmtCheck +# now track it +$ exec git -C p17 add "src/main/scala/TestBad2.scala" +-> p17/scalafmtCheck +# now commit it, no longer modified +$ exec git -C p17 commit -m 'added TestBad2.scala' +> p17/scalafmtCheck $ copy-file changes/target/managed.scala project/target/managed.scala $ copy-file changes/x/Something.scala project/x/Something.scala