Designing a Gradle plugin for J2CL

tbroyer

Thomas Broyer

Posted on July 28, 2020

Designing a Gradle plugin for J2CL

In the previous post, I explored how J2CL is used in Bazel. Starting with this post, with this knowledge, I'll try to design a Gradle plugin for J2CL.

Building blocks

Let's start with the low-level building blocks.

All the J2CL (GwtIncompatibleStrip and J2clTranspile) and Closure compiler (CommandLineRunner) tools can easily be called from Java processes (the Bazel workers actually call different, sometimes internal, APIs), so they could be called from Gradle workers. It also shouldn't be a problem to call the J2CL tools incrementally, only processing changed files; well, actually, it won't be a problem for the GwtIncompatibleStrip which processes files one by one in isolation; the J2clTranspile however, just like javac, would also need to reprocess other Java files referencing the changed files, so making it incremental would mean knowing about those dependencies; let's leave this optimization work for later (FWIW, the way Bazel deals with it is to not do any incremental/partial processing of any kind, but instead using small sets of files, generally at the Java package level).

That means we'd have to declare 3 configurations (for the 3 tools' dependencies), and we could create corresponding tasks with proper inputs and outputs.

Inputs and outputs of the GwtIncompatibleStrip task

Inputs and outputs of the J2clTranspile task

Inputs and outputs of the ClosureCompile task

That would work for project sources though, but not external dependencies. For those, we could probably use artifact transforms, but that excludes compiling the source files with javac, at least if we want to leverage the Gradle standard JavaCompile task (I've been floating the idea of an ASM-based GwtIncompatibleStrip, which would solve the problem then, as an artifact transform of the binary JAR). Let's keep external dependencies for later though.

Speaking of the JavaCompile tasks, for J2CL we'd want to configure their bootstrapClasspath to the Java Runtime Emulation JAR. Unfortunately, -bootclasspath can only be used when compiling for Java 8, so that rules out using any Java 9+ syntax: private methods in interfaces, var for type inference, etc. For those more recent Java versions, we'd want to use --system, but that's an entirely different format, and one that's not even supported by Bazel yet, let alone produced by J2CL (though maybe it's not that hard to create from the Java Runtime Emulation JAR). Fwiw, Google also faces the same issue for Android for adding Java 9+ syntax support, as well as J2ObjC, so we can be sure they'll find a solution (they're actually already working on it).

Tests

For tests, the annotation processor and its @J2clTestInput have been designed as implementation details, hidden behind a j2cl_test rule in Bazel. Contrary to Bazel where one j2cl_test rule only runs a single test class (which could actually be a test suite), with a gen_j2cl_tests macro to generate them automatically from source files using a naming convention, in Gradle we'll have a single task for the whole src/test. We could however have a task that generates a JUnit suite, annotated with @J2clTestInput, and referencing the test classes, using a naming convention, and then processes it with the annotation processor.

This will generate some Java code to be transpiled with J2CL. This phase can reuse the J2clTranspile task from above, using the src/test/java and the generate Java code all at once.

The test_summary.json file then needs to be processed and fan out one Closure compilation per JS entrypoint (one per non-suite test class, with .testsuite file extension). This cannot reuse the ClosureCompile task though: we want a single task driving multiple Closure compilations. The result will thus be several JS applications; we'll put them into separate directories (named after the original Java test), and generate an additional HTML page to run them. The task could be made incremental, only recompiling tests that have changed, but that would need dependency information between files (that can be extracted using Closure, with some additional work; or possibly even just analyzing the goog.provide/goog.require). BTW, that knowledge could possibly also be used by the ClosureCompile task to skip compilation if a file has changed that's not needed by anything.

Finally, we'll need to run those generated tests in browsers. The best way to do that is through Selenium WebDriver. We'll thus want a task taking those directories of compiled tests, that starts an HTTP server to serve them, and loads each of them in a web browser through WebDriver, generating reports (that helps skipping the task if no input has changed).

Inputs and outputs of the GenerateTests task

Inputs and outputs of the ClosureCompileTests task

Inputs and outputs of the J2clTest task

Putting it all to work

For an application like the HelloWorld sample from the Bazel repository, wiring all those tasks together would lead to a graph like the following:

Directed graph of tasks and their input/output files in the HelloWorld sample

The code for these tasks and sample project is available on Github.

GitHub logo tbroyer / gradle-j2cl-plugin

Gradle J2CL Plugin

Next steps

Now that we have the building blocks and are able to wire them together for a simple example, the next steps will be:

  • handling external dependencies (stripping and transpiling them on-the-fly)
  • handling project dependencies (a library subproject would expose its transpiled sources to an application subproject)
  • defining conventions to make things as simple as applying a Gradle plugin (a J2CL application would be the easiest, as libraries could target J2CL, GWT, the JVM, J2ObjC, etc.)
💖 💪 🙅 🚩
tbroyer
Thomas Broyer

Posted on July 28, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related