diff --git a/README.md b/README.md index bbd5492..29781c1 100644 --- a/README.md +++ b/README.md @@ -8,41 +8,31 @@ An sbt plugin for formatting Java code. This plugin began as a combination of id [blog post](https://ssscripting.wordpress.com/2009/06/10/how-to-use-the-eclipse-code-formatter-from-your-code/) and this [maven plugin](https://github.com/revelc/formatter-maven-plugin), though it has evolved since. +`google-java-format` relies on internal `jdk.compiler` APIs. On Java 17 and newer, access to those APIs is strongly encapsulated by the module system. + +To keep the formatter commands working without requiring manual JVM flags, the plugin runs `google-java-format` in a forked JVM with the required module access flags. + # Usage Add the plugin to `project/plugins.sbt`: ```scala -// Default plugin: addSbtPlugin("com.github.sbt" % "sbt-java-formatter" % --latest version---) - -// Alternative for Java 17+: wraps formatter commands in a fresh sbt JVM, -// so you do not need to configure `--add-opens` manually, see below. -addSbtPlugin("com.github.sbt" % "sbt-java-formatter-add-opens" % --latest version---) ``` For available versions see [releases](https://github.com/sbt/sbt-java-formatter/releases). -The following commands are available: - * `javafmt` formats Java files * `javafmtAll` formats Java files for all configurations (`Compile` and `Test` by default) * `javafmtCheck` fails if files need reformatting * `javafmtCheckAll` fails if files need reformatting in any configuration (`Compile` and `Test` by default) -The `sbt-java-formatter-add-opens` plugin wraps the above commands and, on Java 17+, runs them in a fresh sbt JVM with the required `jdk.compiler` module access flags. From a user perspective, the commands stay the same and no manual JVM flags need to be configured. - * The `javafmtOnCompile` setting controls whether the formatter kicks in on compile (`false` by default). * The `javafmtStyle` setting defines the formatting style: Google Java Style (by default) or AOSP style. +* The `javafmtJavaMaxHeap` setting controls the maximum heap passed to the forked `google-java-format` JVM (`Some("256m")` by default). This plugin requires sbt 1.3.0+. -## Java 17+ - -`google-java-format` relies on internal `jdk.compiler` APIs. On Java 17 and newer, access to those APIs is strongly encapsulated by the module system. - -If you depend on `sbt-java-formatter-add-opens`, the formatter commands (`javafmt`, `javafmtAll`, `javafmtCheck`, `javafmtCheckAll`) automatically relaunch in a JVM with the required module flags, instead of requiring manual `-J--add-opens=...` setup. - ## Enable in other scopes (eg `IntegrationTest`) The sbt plugin is enabled by default for the `Test` and `Compile` configurations. Use `JavaFormatterPlugin.toBeScopedSettings` to enable the plugin for the `IntegrationTest` scope and then use `It/javafmt` to format. @@ -55,6 +45,22 @@ inConfig(IntegrationTest)(JavaFormatterPlugin.toBeScopedSettings) This plugin uses the [Google Java Format](https://github.com/google/google-java-format) library, which makes it quite opinionated and not particularly configurable. +## Formatter JVM + +The formatter runs in a forked JVM managed by the plugin. + +Use `javafmtJavaMaxHeap` to control the maximum heap size passed to that JVM: + +```scala +ThisBuild / javafmtJavaMaxHeap := Some("512m") +``` + +Set it to `None` to disable the explicit heap cap: + +```scala +ThisBuild / javafmtJavaMaxHeap := None +``` + If you want to tweak the format, take a minute to consider whether it is really worth it, and have a look at the motivations in the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html). If you decide you really need more flexibility, you could consider other plugins such as the [sbt-checkstyle-plugin](https://github.com/etsy/sbt-checkstyle-plugin) diff --git a/build.sbt b/build.sbt index a93cfc9..80a66bf 100644 --- a/build.sbt +++ b/build.sbt @@ -3,63 +3,51 @@ lazy val scala3 = "3.8.3" ThisBuild / scalaVersion := scala212 ThisBuild / crossScalaVersions := Seq(scala212, scala3) -def commonSettings: Seq[Setting[?]] = Seq( - homepage := scmInfo.value.map(_.browseUrl), - scmInfo := Some( - ScmInfo(url("https://github.com/sbt/sbt-java-formatter"), "scm:git:git@github.com:sbt/sbt-java-formatter.git")), - developers := List( - Developer("ktoso", "Konrad 'ktoso' Malawski", "", url("https://github.com/ktoso"))), - startYear := Some(2015), - description := "Formats Java code in your project.", - licenses += ("Apache-2.0", url("https://www.apache.org/licenses/LICENSE-2.0.html")), - (pluginCrossBuild / sbtVersion) := { - scalaBinaryVersion.value match { - case "2.12" => "1.9.0" - case _ => "2.0.0-RC11" - } - }, - scalacOptions ++= { - Vector("-encoding", "UTF-8", "-unchecked", "-deprecation", "-feature") ++ (scalaBinaryVersion.value match { - case "2.12" => Vector("-Xsource:3", "-release:11") - case _ => Vector("-Wconf:error") - }) - }, - javacOptions ++= Seq("-encoding", "UTF-8"), - scriptedLaunchOpts := { - scriptedLaunchOpts.value ++ - Seq("-Xmx1024M", "-Dplugin.version=" + version.value) - }, - scriptedLaunchOpts ++= { - if (scala.util.Properties.isJavaAtLeast("17")) { - Seq("api", "code", "file", "parser", "tree", "util").map { x => - s"--add-exports=jdk.compiler/com.sun.tools.javac.${x}=ALL-UNNAMED" - } - } else { - Nil - } - }, - scriptedBufferLog := false, - scalafmtOnCompile := !insideCI.value) - -lazy val sbtJavaFormatter = - project.in(file(".")).aggregate(plugin).aggregate(`plugin-add-opens`).settings(publish / skip := true) +lazy val sbtJavaFormatter = project.in(file(".")).aggregate(plugin).settings(publish / skip := true) lazy val plugin = project .in(file("plugin")) .enablePlugins(SbtPlugin) .enablePlugins(AutomateHeaderPlugin) - .settings(commonSettings *) .settings( name := "sbt-java-formatter", - libraryDependencies ++= Seq("com.google.googlejavaformat" % "google-java-format" % "1.24.0")) - -lazy val `plugin-add-opens` = project - .in(file("plugin-add-opens")) - .enablePlugins(SbtPlugin) - .enablePlugins(AutomateHeaderPlugin) - .settings(commonSettings *) - .settings(name := "sbt-java-formatter-add-opens") - .dependsOn(plugin) + homepage := scmInfo.value.map(_.browseUrl), + scmInfo := Some( + ScmInfo(url("https://github.com/sbt/sbt-java-formatter"), "scm:git:git@github.com:sbt/sbt-java-formatter.git")), + developers := List( + Developer("ktoso", "Konrad 'ktoso' Malawski", "", url("https://github.com/ktoso"))), + libraryDependencies ++= Seq("com.google.googlejavaformat" % "google-java-format" % "1.24.0"), + startYear := Some(2015), + description := "Formats Java code in your project.", + licenses += ("Apache-2.0", url("https://www.apache.org/licenses/LICENSE-2.0.html")), + (pluginCrossBuild / sbtVersion) := { + scalaBinaryVersion.value match { + case "2.12" => "1.9.0" + case _ => "2.0.0-RC11" + } + }, + scalacOptions ++= { + Vector("-encoding", "UTF-8", "-unchecked", "-deprecation", "-feature") ++ (scalaBinaryVersion.value match { + case "2.12" => Vector("-Xsource:3", "-release:11") + case _ => Vector("-Wconf:error") + }) + }, + javacOptions ++= Seq("-encoding", "UTF-8"), + scriptedLaunchOpts := { + scriptedLaunchOpts.value ++ + Seq("-Xmx1024M", "-Dplugin.version=" + version.value) + }, + scriptedLaunchOpts ++= { + if (scala.util.Properties.isJavaAtLeast("17")) { + Seq("api", "code", "file", "parser", "tree", "util").map { x => + s"--add-exports=jdk.compiler/com.sun.tools.javac.${x}=ALL-UNNAMED" + } + } else { + Nil + } + }, + scriptedBufferLog := false, + scalafmtOnCompile := !insideCI.value) ThisBuild / organization := "com.github.sbt" ThisBuild / organizationName := "sbt community" diff --git a/plugin-add-opens/src/main/scala/com/github/sbt/JavaFormatterWrapperPlugin.scala b/plugin-add-opens/src/main/scala/com/github/sbt/JavaFormatterWrapperPlugin.scala deleted file mode 100644 index 94224d2..0000000 --- a/plugin-add-opens/src/main/scala/com/github/sbt/JavaFormatterWrapperPlugin.scala +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2015 sbt community - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.github.sbt - -import scala.sys.process.Process - -import sbt._ -import sbt.Keys._ - -//import com.github.sbt.JavaFormatterPlugin - -object JavaFormatterWrapperPlugin extends AutoPlugin { - override def requires = JavaFormatterPlugin - - override def trigger = allRequirements - - override def globalSettings = - Seq(commands += javafmt, commands += javafmtCheck, commands += javafmtAll, commands += javafmtCheckAll) - - private val javafmtWrapperProp = "play.javafmt.wrapper" - - private val javafmtExports = - Seq("api", "code", "file", "parser", "tree", "util").map { exportedPackage => - s"-J--add-opens=jdk.compiler/com.sun.tools.javac.${exportedPackage}=ALL-UNNAMED" - } - - private def javafmtCommand(name: String, delegatedCommand: String): Command = - Command.command( - name, - Help.more( - name, - s"Runs $delegatedCommand in a fresh sbt JVM with the required jdk.compiler module-opening flags")) { state => - if (sys.props.get(javafmtWrapperProp).contains("true")) { - delegatedCommand :: state - } else { - val extracted = Project.extract(state) - val base = extracted.get(ThisBuild / baseDirectory) - val sbtArgs = Seq("sbt", "--server", s"-D$javafmtWrapperProp=true") ++ javafmtExports ++ Seq(name) - val exitCode = Process(sbtArgs, base).! - if (exitCode == 0) state else state.fail - } - } - - private val javafmt = javafmtCommand("javafmt", "javafmt") - private val javafmtCheck = javafmtCommand("javafmtCheck", "javafmtCheck") - private val javafmtAll = javafmtCommand("javafmtAll", "all javafmtAll") - private val javafmtCheckAll = javafmtCommand("javafmtCheckAll", "all javafmtCheckAll") -} diff --git a/plugin/src/main/scala/com/github/sbt/JavaFormatterPlugin.scala b/plugin/src/main/scala/com/github/sbt/JavaFormatterPlugin.scala index 182226e..0ae6ecd 100644 --- a/plugin/src/main/scala/com/github/sbt/JavaFormatterPlugin.scala +++ b/plugin/src/main/scala/com/github/sbt/JavaFormatterPlugin.scala @@ -48,6 +48,8 @@ object JavaFormatterPlugin extends AutoPlugin { val javafmtOnCompile = settingKey[Boolean]("Format Java source files on compile, off by default.") val javafmtStyle = settingKey[JavaFormatterOptions.Style]("Define formatting style, Google Java Style (default) or AOSP") + val javafmtJavaMaxHeap = + settingKey[Option[String]]("Maximum heap size passed to the forked google-java-format JVM, e.g. Some(\"256m\").") val javafmtOptions = settingKey[JavaFormatterOptions]( "Define all formatting options such as style or enabling Javadoc formatting. See _JavaFormatterOptions_ for more") } @@ -73,7 +75,10 @@ object JavaFormatterPlugin extends AutoPlugin { } override def globalSettings: Seq[Def.Setting[?]] = - Seq(javafmtOnCompile := false, javafmtStyle := JavaFormatterOptions.Style.GOOGLE) + Seq( + javafmtOnCompile := false, + javafmtStyle := JavaFormatterOptions.Style.GOOGLE, + javafmtJavaMaxHeap := Some("256m")) def toBeScopedSettings: Seq[Setting[?]] = List( @@ -86,7 +91,8 @@ object JavaFormatterPlugin extends AutoPlugin { val eF = (javafmt / excludeFilter).value val cache = streamz.cacheStoreFactory val options = javafmtOptions.value - JavaFormatter(sD, iF, eF, streamz, cache, options) + val javaMaxHeap = javafmtJavaMaxHeap.value + JavaFormatter(sD, iF, eF, streamz, cache, options, javaMaxHeap) }, javafmtCheck := { val streamz = streams.value @@ -96,7 +102,8 @@ object JavaFormatterPlugin extends AutoPlugin { val eF = (javafmt / excludeFilter).value val cache = (javafmt / streams).value.cacheStoreFactory val options = javafmtOptions.value - JavaFormatter.check(baseDir, sD, iF, eF, streamz, cache, options) + val javaMaxHeap = javafmtJavaMaxHeap.value + JavaFormatter.check(baseDir, sD, iF, eF, streamz, cache, options, javaMaxHeap) }, javafmtDoFormatOnCompile := Def.settingDyn { if (javafmtOnCompile.value) { diff --git a/plugin/src/main/scala/com/github/sbt/javaformatter/JavaFormatter.scala b/plugin/src/main/scala/com/github/sbt/javaformatter/JavaFormatter.scala index 5cc1964..af9d6b1 100644 --- a/plugin/src/main/scala/com/github/sbt/javaformatter/JavaFormatter.scala +++ b/plugin/src/main/scala/com/github/sbt/javaformatter/JavaFormatter.scala @@ -16,24 +16,35 @@ package com.github.sbt.javaformatter +import java.io.File +import java.net.URLClassLoader + import _root_.sbt.Keys._ import _root_.sbt._ import _root_.sbt.util.CacheImplicits._ import _root_.sbt.util.{ CacheStoreFactory, FileInfo, Logger } -import com.google.googlejavaformat.java.{ Formatter, JavaFormatterOptions } +import com.google.googlejavaformat.java.JavaFormatterOptions import scala.collection.immutable.Seq +import scala.sys.process.{ Process, ProcessLogger } object JavaFormatter { + private val GoogleJavaFormatMain = "com.google.googlejavaformat.java.Main" + + private val JavaExports = Seq("api", "code", "file", "parser", "tree", "util").map { exportedPackage => + s"--add-exports=jdk.compiler/com.sun.tools.javac.$exportedPackage=ALL-UNNAMED" + } + def apply( sourceDirectories: Seq[File], includeFilter: FileFilter, excludeFilter: FileFilter, streams: TaskStreams, cacheStoreFactory: CacheStoreFactory, - options: JavaFormatterOptions): Unit = { + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Unit = { val files = sourceDirectories.descendantsExcept(includeFilter, excludeFilter).get().toList - cachedFormatSources(cacheStoreFactory, files, streams.log)(using new Formatter(options)) + cachedFormatSources(cacheStoreFactory, files, streams.log, options, javaMaxHeap) } def check( @@ -43,9 +54,10 @@ object JavaFormatter { excludeFilter: FileFilter, streams: TaskStreams, cacheStoreFactory: CacheStoreFactory, - options: JavaFormatterOptions): Boolean = { + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Boolean = { val files = sourceDirectories.descendantsExcept(includeFilter, excludeFilter).get().toList - val analysis = cachedCheckSources(cacheStoreFactory, baseDir, files, streams.log)(using new Formatter(options)) + val analysis = cachedCheckSources(cacheStoreFactory, baseDir, files, streams.log, options, javaMaxHeap) trueOrBoom(analysis) } @@ -72,15 +84,20 @@ object JavaFormatter { }) } - private def cachedCheckSources(cacheStoreFactory: CacheStoreFactory, baseDir: File, sources: Seq[File], log: Logger)( - implicit formatter: Formatter): Analysis = { + private def cachedCheckSources( + cacheStoreFactory: CacheStoreFactory, + baseDir: File, + sources: Seq[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Analysis = { trackSourcesViaCache(cacheStoreFactory, sources) { (outDiff, prev) => log.debug(outDiff.toString) val updatedOrAdded = outDiff.modified & outDiff.checked val filesToCheck: Set[File] = updatedOrAdded val prevFailed: Set[File] = prev.failedCheck & outDiff.unmodified prevFailed.foreach { file => warnBadFormat(file.relativeTo(baseDir).getOrElse(file), log) } - val result = checkSources(baseDir, filesToCheck.toList, log) + val result = checkSources(baseDir, filesToCheck.toList, log, options, javaMaxHeap) prev.copy(failedCheck = result.failedCheck | prevFailed) } } @@ -89,43 +106,49 @@ object JavaFormatter { log.warn(s"${file.toString} isn't formatted properly!") } - private def checkSources(baseDir: File, sources: Seq[File], log: Logger)(implicit formatter: Formatter): Analysis = { + private def checkSources( + baseDir: File, + sources: Seq[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Analysis = { if (sources.nonEmpty) { log.info(s"Checking ${sources.size} Java source${plural(sources.size)}...") } - val unformatted = withFormattedSources(sources, log)((file, input, output) => { - val diff = input != output - if (diff) { - warnBadFormat(file.relativeTo(baseDir).getOrElse(file), log) - Some(file) - } else None - }).flatten.flatten.toSet + val unformatted = runCheck(baseDir, sources, log, options, javaMaxHeap) + unformatted.foreach { file => warnBadFormat(file.relativeTo(baseDir).getOrElse(file), log) } Analysis(failedCheck = unformatted) } - private def cachedFormatSources(cacheStoreFactory: CacheStoreFactory, sources: Seq[File], log: Logger)(implicit - formatter: Formatter): Unit = { + private def cachedFormatSources( + cacheStoreFactory: CacheStoreFactory, + sources: Seq[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Unit = { trackSourcesViaCache(cacheStoreFactory, sources) { (outDiff, prev) => log.debug(outDiff.toString) val updatedOrAdded = outDiff.modified & outDiff.checked val filesToFormat: Set[File] = updatedOrAdded | prev.failedCheck if (filesToFormat.nonEmpty) { log.info(s"Formatting ${filesToFormat.size} Java source${plural(filesToFormat.size)}...") - formatSources(filesToFormat, log) + formatSources(filesToFormat, log, options, javaMaxHeap) } Analysis(Set.empty) } } - private def formatSources(sources: Set[File], log: Logger)(implicit formatter: Formatter): Unit = { - val cnt = withFormattedSources(sources.toList, log)((file, input, output) => { - if (input != output) { - IO.write(file, output) - 1 - } else { - 0 - } - }).flatten.sum + private def formatSources( + sources: Set[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Unit = { + val changed = + runCheck(baseDir = new File("."), sources.toList, log, options, javaMaxHeap, warnOnFailure = false) + if (changed.nonEmpty) { + runReplace(changed.toList, log, options, javaMaxHeap) + } + val cnt = changed.size log.info(s"Reformatted $cnt Java source${plural(cnt)}") } @@ -141,16 +164,108 @@ object JavaFormatter { prevTracker(()) } - private def withFormattedSources[T](sources: Seq[File], log: Logger)(onFormat: (File, String, String) => T)(implicit - formatter: Formatter): Seq[Option[T]] = { - sources.map { file => - val input = IO.read(file) - try { - val output = formatter.formatSourceAndFixImports(input) - Some(onFormat(file, input, output)) - } catch { - case e: Exception => Some(onFormat(file, input, input)) + private def cliFlags(options: JavaFormatterOptions): Seq[String] = { + if (!options.reorderModifiers()) { + throw new MessageOnlyException( + "The forked google-java-format CLI does not support reorderModifiers = false. " + + "Please use the default reorderModifiers setting.") + } + val styleFlags = + if (options.style() == JavaFormatterOptions.Style.AOSP) Seq("--aosp") + else Nil + val javadocFlags = + if (options.formatJavadoc()) Nil + else Seq("--skip-javadoc-formatting") + styleFlags ++ javadocFlags + } + + private case class CliResult(exitCode: Int, stdout: Vector[String], stderr: Vector[String]) + + private def classpathFrom(loader: ClassLoader): List[String] = + loader match { + case null => Nil + case urlLoader: URLClassLoader => + urlLoader.getURLs.iterator.map(url => new File(url.toURI).getAbsolutePath).toList ++ classpathFrom( + loader.getParent) + case _ => + classpathFrom(loader.getParent) + } + + private lazy val formatterClasspath: String = + classpathFrom(getClass.getClassLoader).distinct.mkString(File.pathSeparator) + + private lazy val javaBin: String = { + val javaHome = new File(sys.props("java.home")) + val unixJava = new File(javaHome, "bin/java") + val windowsJava = new File(javaHome, "bin/java.exe") + val javaExec = + if (unixJava.isFile) unixJava + else if (windowsJava.isFile) windowsJava + else { + throw new MessageOnlyException(s"Could not locate a Java launcher under java.home=${javaHome.getAbsolutePath}") } + javaExec.getAbsolutePath + } + + private def javaArgs(args: Seq[String], javaMaxHeap: Option[String]): Seq[String] = + javaMaxHeap.toList + .map(heap => s"-Xmx$heap") ++ JavaExports ++ Seq("-cp", formatterClasspath, GoogleJavaFormatMain) ++ args + + private def renderJavaArg(arg: String): String = + if (arg.isEmpty || arg.exists(_.isWhitespace) || arg.contains("\"")) { + "\"" + arg.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + } else { + arg + } + + private def runCli(args: Seq[String], log: Logger, javaMaxHeap: Option[String]): CliResult = + IO.withTemporaryFile("google-java-format-java", ".args") { argFile => + IO.writeLines(argFile, javaArgs(args, javaMaxHeap).map(renderJavaArg)) + val stdout = Vector.newBuilder[String] + val stderr = Vector.newBuilder[String] + val exitCode = Process(Seq(javaBin, s"@${argFile.getAbsolutePath}")).!(ProcessLogger(stdout += _, stderr += _)) + CliResult(exitCode, stdout.result(), stderr.result()) + } + + private def runCheck( + baseDir: File, + sources: Seq[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String], + warnOnFailure: Boolean = true): Set[File] = { + if (sources.isEmpty) { + return Set.empty + } + val args = cliFlags(options) ++ Seq("--dry-run", "--set-exit-if-changed") ++ sources.map(_.getAbsolutePath) + val result = runCli(args, log, javaMaxHeap) + val changed = result.stdout.iterator.map(path => file(path)).toSet + result.exitCode match { + case 0 | 1 => + changed + case _ => + if (warnOnFailure) { + result.stderr.foreach(line => log.error(line)) + result.stdout.foreach(line => log.error(line)) + } + throw new MessageOnlyException("google-java-format check failed") + } + } + + private def runReplace( + sources: Seq[File], + log: Logger, + options: JavaFormatterOptions, + javaMaxHeap: Option[String]): Unit = { + if (sources.isEmpty) { + return + } + val args = cliFlags(options) ++ Seq("--replace") ++ sources.map(_.getAbsolutePath) + val result = runCli(args, log, javaMaxHeap) + if (result.exitCode != 0) { + result.stderr.foreach(line => log.error(line)) + result.stdout.foreach(line => log.error(line)) + throw new MessageOnlyException("google-java-format failed") } }