From d9e96695079190dea67b08b1e2edbac636df973a Mon Sep 17 00:00:00 2001 From: Antonio Cunei Date: Tue, 22 Mar 2016 19:51:07 +0100 Subject: [PATCH] Add support for mixed Scala/Java projects Optimized versions of the code for JDK7 and JDK6 are available. This plugin will currently compile directly to jar only the main artifacts, while the tests will still be compiled as individual classfiles. --- README.md | 22 +- .../scala/com/typesafe/sbttojar/Plugin.scala | 189 +++++++++++++++++- 2 files changed, 204 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 57e1c9f..ad3ad53 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ intermediate creation of the separate classfiles. ## Usage * add to your project/plugin.sbt the line: - `addSbtPlugin("com.typesafe.sbt" % "sbt-tojar" % "0.1")` + `addSbtPlugin("com.typesafe.sbt" % "sbt-tojar" % "0.2")` * then add to the settings of the subprojects for which you would like to enable this functionality: `straightToJar := true` @@ -27,14 +27,24 @@ needed beforehand). ## Notes -Since the packaged jar file is generated directly by the Scala compiler, it will not be possible -to have in the same jar also files that are generated from other sources. Therefore, `straightTojar` cannot -be enabled on mixed Java/Scala subprojects, or on those subprojects that use `copyResources`. +During the compilation of a mixed Java/Scala project, both the Scala and the Java compilers are +called. The Java compiler is unable to produce a jar file directly, therefore initially a first +jar is generated by scalac, then the remaining classes created by javac are appended to it. + +When running under JDK7 and newer, the plugin is able to open directly the existing jar and append +the new classfiles. The facilities used for this operation are not avaliable on JDK6 and older; +instead, a less efficient implementation will create a new jar file, copy the entries from the +first jar, and finally append the new classes. + +Since the packaged jar file is generated from the compiled classes as described above, the +archive cannot contain files that are generated from other sources. Therefore, `straightTojar` +is currently incompatible with bytecode manipulations via the `manipulateBytecode` facility, +and the resulting jar archive cannot contain files copied via `copyResources`. However, in an sbt project that contains many different subprojects, the straight-to-jar compilation can -be enabled on all the subprojects that do not use those features. +be selectively disabled on just the subprojects that use those features. It should also be noted that straight-to-jar compilation will disable the usual incremental compilation mechanisms of sbt: if any source file in a straight-to-jar subproject is modified in a way that makes recompilation necessary, then all of the source files of that subproject will be recompiled. -This plugin requires sbt 0.13.10, final or RC2+. +This plugin requires sbt 0.13.10, final or RC2, or a more recent version.. diff --git a/src/main/scala/com/typesafe/sbttojar/Plugin.scala b/src/main/scala/com/typesafe/sbttojar/Plugin.scala index 7b57f24..9a5b21e 100644 --- a/src/main/scala/com/typesafe/sbttojar/Plugin.scala +++ b/src/main/scala/com/typesafe/sbttojar/Plugin.scala @@ -52,12 +52,35 @@ import sbt.inc.Analysis on 0.13.10-RC1, or previous version of sbt. Instead 0.13.10-RC2 or more recent will be needed. + +-- + + In order to support mixed Java/Scala compilation, + the Java compiler is called after the Scala compiler; + if this plugin is in use, however, the jar file has + already been generated at that point. + + We let javac compile as normal, generating further + classfiles, and we then insert these newer files + into the existing jar. + + If we are running under JDK7 or more recent, we use + a zip-based virtual file system, using the existing + jar file and adding new entries. On JDK6 and older, + however, such support is not available; in that case, + an alternate implementation creates a new jar that + contains the old entries plus the newer ones. + + The default value of the class directory is passed + to the Java compiler via a separate key. */ +// TODO: add support for straight-to-jar for tests as well object ToJar extends AutoPlugin { object autoImport { val straightToJar = settingKey[Boolean]("Enables straight-to-jar compilation") } + val defaultClassDir = settingKey[File]("Default class output directory") import autoImport._ override def globalSettings = Seq( straightToJar := false @@ -98,8 +121,172 @@ object ToJar extends AutoPlugin { } } clean.value - } + }, + compilers := { + case class Y(in:sbt.compiler.JavaTool) extends sbt.compiler.JavaTool { + def compile(contract: sbt.compiler.JavacContract,sources: Seq[java.io.File],classpath: Seq[java.io.File], + outputDirectory: java.io.File,options: Seq[String])(implicit log: sbt.Logger): Unit = { + // TODO: defaultClassDir could be in Compile or Test, or something else + val newDir = defaultClassDir.value + newDir.mkdirs() + in.compile(contract, sources, classpath, newDir, options)(log) + } + def onArgs(f: Seq[String] => Unit): sbt.compiler.JavaTool = { + Y(in.onArgs(f)) + } + } + val x = compilers.value + if (straightToJar.value) { + x.copy(javac=Y(x.javac)) + } else x + }, + manipulateBytecode in Compile := { + if (straightToJar.value) { + val classDir = defaultClassDir.value + val analysisResult: Compiler.CompileResult = (manipulateBytecode in Compile).value + if (analysisResult.hasModified) { + // now, append the java classfiles to the scalac jar + val jarFile = (classDirectory in Compile).value + injectJar(jarFile, classDir) + } + analysisResult + } else (manipulateBytecode in Compile).value + }, + defaultClassDir := new File(((crossTarget in Compile).value / +((if ((configuration in Compile).value.name == Configurations.Compile.name) "" else (configuration in Compile).value.name + "-") + "classes")).getCanonicalPath) ) + override def trigger = allRequirements override def requires = plugins.JvmPlugin + + //-------------------------------------------- + + import sbt.IO._ + import sbt.DirectoryFilter + import collection.JavaConversions._ + + import java.io.File + import java.io.InputStream + import java.io.BufferedInputStream + import java.io.BufferedOutputStream + import java.io.FileInputStream + import java.io.FileOutputStream + import java.io.PrintWriter + + // Two versions of injectJar are provided. + // The first is old-style, and creates a new jar file in order + // to be able to append to it, copying the old content first. + // The second version uses the nio zip filesystem, available w/ Java 7. + + abstract class Injector { + // injects all of the files in dir into the jar + def injectJar(jar: File, dir: File): Unit + } + + val injector = { + // We have separate classes for JDK6 and JDK7; if we run on + // JDK6, the JDK7 injector class is never initialized (which + // is good, since required classes would be missing from the + // classpath). + + class InjectorJDK6 extends Injector { + import java.util.jar.JarInputStream + import java.util.jar.JarOutputStream + import java.util.jar.JarFile + import java.util.jar.JarEntry + + def injectJar(jar: File, dir: File) = { + withTemporaryFile("inject", "tempJar") { temp => + val out = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(temp))) + + // Initially we copy the old jar content, and later we append the new entries + val bufferSize = 131072 + val buffer = new Array[Byte](bufferSize) + def writeEntry(where: JarEntry, source: InputStream) = { + out.putNextEntry(where) + Stream.continually(source.read(buffer, 0, bufferSize)).takeWhile(_ != -1). + foreach { size => + out.write(buffer, 0, size) + } + } + + val in = new JarFile(jar) + // + // The jar may contain duplicate entries (even though it shouldn't); + // for example, the scalap jar in Scala 2.11.4 is broken. + // Rather than aborting, we print a warning and try to continue + val list = in.entries.toSeq + val uniques = list.foldLeft(Map[String, JarEntry]()) { (map, entry) => + if (map.isDefinedAt(entry.getName)) { + map + } else + map.updated(entry.getName, entry) + } + + val files = dir.**(-DirectoryFilter).get + val targets = files.map(relativize(dir, _).getOrElse("Internal error while relativizing, please report.")) + + // Copy all the content, skipping the entries that will be replaced + uniques.valuesIterator.foreach { entry => + if (!targets.contains(entry.getName())) { + writeEntry(entry, in.getInputStream(entry)) + } + } + + // Finally, insert the new entries at the appropriate target locations + files.zip(targets).foreach { + case (file, target) => + writeEntry(new JarEntry(target), new BufferedInputStream(new FileInputStream(file))) + } + in.close() + out.flush() + out.close() + + // Time to move the temporary file back to the original location + move(temp, jar) + } + } + } + + class InjectorJDK7 extends Injector { + import java.util.{ Map => JMap, HashMap => JHashMap, _ } + import java.net.URI + import java.nio.file.Path + import java.nio.file._ + + def injectJar(jar: File, dir: File) = { + val files = dir.**(-DirectoryFilter).get + val targets = files.map("/" + relativize(dir, _).getOrElse("Internal error while relativizing, please report.")) + val env: JMap[String, String] = new JHashMap[String, String]() + env.put("create", "false") + val uri = URI.create("jar:" + jar.toURI) // for escaping blanks&symbols + // val fs = FileSystems.getFileSystem(uri) + val fs = FileSystems.newFileSystem(uri, env, null) + try { + val fileDirs = files.map { f => Option(f.getCanonicalFile().getParentFile()) }.distinct.flatten + fileDirs.map { d => + val targetPath = fs.getPath("/" + (relativize(dir, d).getOrElse(""))) + Files.createDirectories(targetPath) + } + files.zip(targets).foreach { + case (file, target) => + val entryPath = fs.getPath(target) + Files.copy(file.toPath, entryPath, StandardCopyOption.REPLACE_EXISTING) + } + } finally { + fs.close() + } + } + } + + val required = VersionNumber("1.7") + val current = VersionNumber(sys.props("java.specification.version")) + val hasZipFS = current.numbers.zip(required.numbers).foldRight(required.numbers.size <= current.numbers.size)((a, b) => (a._1 > a._2) || (a._1 == a._2 && b)) + if (hasZipFS) (new InjectorJDK7) else (new InjectorJDK6) + } + + def injectJar(jar: File, dir: File) = { + injector.injectJar(jar, dir) + } } +