Migration of an Asciidoctor RevealJS plugin to buildSrc Kotlin: reverse engineering a Groovy API
Published on 16 April 2026
Context and objective
The starting point
The project`slider-gradle`generates Reveal.js presentations from AsciiDoc files via the Gradle plugin`org.asciidoctor.jvm.revealjs`. The main task configuration`asciidoctorRevealJs`lived directly in the root buildscript`build.gradle.kts`:
plugins { id("org.asciidoctor.jvm.revealjs") }
apply<slides.SlidesPlugin>()
project.tasks.getByName<AsciidoctorJRevealJSTask>(TASK_ASCIIDOCTOR_REVEALJS) {
repositories { ruby { gems() } }
revealjs {
version = "3.1.0"
templateGitHub {
setOrganisation("hakimel")
setRepository("reveal.js")
setTag("3.9.1")
}
}
revealjsOptions {
// ... configuration complète
}
}
The objective
Move all this configuration into`buildSrc/src/main/kotlin/slides/SlidesPlugin.kt`so that the consuming buildscript is reduced to:
apply<slides.SlidesPlugin>()
The plugin must be totally autonomous: it applies its own plugin dependencies, configures its repos, and manages its Ruby gems.
The first obstacle: ruby { gems() }
What the Groovy syntactic sugar hides
The line`repositories { ruby { gems() } }`is a Groovy DSL extension available only within the buildscript execution context. It does not exist as a static Kotlin API accessible from buildSrc.
When attempting to call it from`SlidesPlugin.kt`, compilation fails immediately.
Deconstructing the mechanism
After analyzing the Gradle cache (~/.gradle/caches/modules-2/files-2.1/rubygems/), we discover in the file`ivy-3.1.0.xml`:
<artifact type='gem' url='https://rubygems.org/gems/asciidoctor-revealjs-3.1.0.gem' />
`ruby { gems() }`actually performedthree distinct operations:
-
Registering an Ivy repo pointing to`https://rubygems.org/gems/`
-
Excluding the group`rubygems`from Maven repos to avoid conflicts
-
Registering the gem in the configuration`asciidoctorGems`so that JRuby loads it at runtime
These three responsibilities must be reproduced separately in Kotlin.
The three-part solution
Part 1: the Ivy repo for rubygems
project.repositories.mavenCentral() {
content { excludeGroup("rubygems") }
}
project.repositories.ivy {
url = project.uri("https://rubygems.org/gems/")
patternLayout { artifact("[module]-[revision].gem") } (1)
metadataSources { artifact() }
content { includeGroup("rubygems") }
}
| 1 | The extension`.gem`must be hardcoded.[ext]`resolves by default to.jar`, which causes an error`Resource missing`. |
| The Ivy repo is declared directly on`project.repositories`and not in a`repositories { }`block because the receiver of this block is not the`RepositoryHandler`standard Gradle one but a grolifant API incompatible with the`ivy`Kotlin DSL extension. |
Part 2: the asciidoctorGems dependency
The plugin`org.asciidoctor.jvm.gems`must be applied first — it creates the configuration`asciidoctorGems`and the task`asciidoctorGemsPrepare`:
project.plugins.apply("org.asciidoctor.jvm.gems")
project.plugins.apply("org.asciidoctor.jvm.revealjs") (1)
project.dependencies {
add("asciidoctorGems", "rubygems:asciidoctor-revealjs:3.1.0@gem") (2)
}
| 1 | Application order is important:`gems`before`revealjs`. <2> The qualifier`@gem`forces the correct extension on the dependency. |
Part 3: settings.gradle.kts
`dependencyResolutionManagement`in`settings.gradle.kts`must allow projects to declare their own repos:
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
}
Without this line, Gradle ignores the repos declared in the plugin and gem resolution fails.
API Introspection via bytecodes
Why javap?
The plugin`asciidoctor-gradle-jvm-slides`is version`4.0.0-alpha.1`. Its documentation is non-existent or incomplete. The only reliable source is direct inspection of the compiled classes.
Task hierarchy
javap -p -classpath asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.slides.AsciidoctorJRevealJSTask
Result:
public class AsciidoctorJRevealJSTask
extends org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask
implements org.asciidoctor.gradle.base.slides.SlidesToExportAware
Discovery of forkOptions
By inspecting`AbstractAsciidoctorTask`:
javap -p -classpath asciidoctor-gradle-jvm-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask | grep -i "fork\|exec\|jvm"
We find:
final org.ysb33r.grolifant.api.v4.JavaForkOptions javaForkOptions;
public void forkOptions(org.gradle.api.Action<org.ysb33r.grolifant.api.v4.JavaForkOptions>);
public static final org.asciidoctor.gradle.base.process.ProcessMode JAVA_EXEC;
The JavaForkOptions (grolifant) API
`javaLauncher`does not exist on this task. The actual API of`org.ysb33r.grolifant.api.v4.JavaForkOptions`exposes:
public void executable(java.lang.Object); (1)
public void setExecutable(java.lang.Object);
| 1 | The method`executable(Object)`replaces the assignment`executable = …`which does not compile (`val`cannot be reassigned). |
RevealJSExtension
`revealjs { }`is not a task method but aproject extension:
javap -p -classpath asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.slides.RevealJSExtension
It is accessed via:
project.extensions.getByType<RevealJSExtension>().apply {
version = "3.1.0"
templateGitHub {
setOrganisation("hakimel")
setRepository("reveal.js")
setTag("3.9.1")
}
}
Java toolchain configuration
The JavaToolchainService problem
`JavaToolchainService`is not a project extension. The following call fails:
// ERREUR : Extension of type 'JavaToolchainService' does not exist
project.extensions.getByType<JavaToolchainService>()
The correct API is`serviceOf`:
import org.gradle.kotlin.dsl.support.serviceOf
project.tasks.getByName<AsciidoctorJRevealJSTask>(TASK_ASCIIDOCTOR_REVEALJS) {
setInProcess("JAVA_EXEC")
forkOptions {
executable(
project.serviceOf<JavaToolchainService>()
.launcherFor {
languageVersion.set(JavaLanguageVersion.of(17))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
.get()
.executablePath
.asFile
.absolutePath
)
}
}
Automatic Docker detection
Context
The Asciidoctor/JRuby plugin requires Java 17. Kotlin 2.0.x in buildSrc does not support Java 25 (the version parser crashes on`"25.0.2"`). Therefore, the Gradle daemon must run on Java 17 or Docker must be used.
Strategy
-
Docker available → execution via container`eclipse-temurin:17`(default behavior)
-
Docker absent + Java 17 → local execution
-
Docker absent + Java > 17 → explicit error
val isDockerAvailable = try {
Runtime.getRuntime().exec(arrayOf("docker", "info")).waitFor() == 0
} catch (e: Exception) {
false
}
val javaVersion = JavaVersion.current().majorVersion.toInt()
when {
isDockerAvailable -> project.tasks.register<Exec>(TASK_ASCIIDOCTOR_REVEALJS) {
group = GROUP_TASK_SLIDER
description = "Slider settings and generation (via Docker)"
dependsOn(TASK_CLEAN_SLIDES_BUILD)
finalizedBy(TASK_DASHBOARD_SLIDES_BUILD)
commandLine(
"docker", "run", "--rm",
"-v", "${project.rootDir.absolutePath}:/workspace",
"-v", "${System.getProperty("user.home")}/.gradle:/root/.gradle",
"-w", "/workspace",
"eclipse-temurin:17",
"./gradlew", TASK_ASCIIDOCTOR_REVEALJS
)
workingDir = project.rootDir
}
javaVersion == 17 -> {
project.repositories.mavenCentral() {
content { excludeGroup("rubygems") }
}
project.repositories.ivy {
url = project.uri("https://rubygems.org/gems/")
patternLayout { artifact("[module]-[revision].gem") }
metadataSources { artifact() }
content { includeGroup("rubygems") }
}
project.extensions.getByType<RevealJSExtension>().apply {
version = "3.1.0"
templateGitHub {
setOrganisation("hakimel")
setRepository("reveal.js")
setTag("3.9.1")
}
}
project.tasks.getByName<AsciidoctorJRevealJSTask>(TASK_ASCIIDOCTOR_REVEALJS) {
setInProcess("JAVA_EXEC")
forkOptions {
executable(
project.serviceOf<JavaToolchainService>()
.launcherFor {
languageVersion.set(JavaLanguageVersion.of(17))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
.get()
.executablePath
.asFile
.absolutePath
)
}
// ... reste de la configuration
}
}
else -> error(
"Docker est requis pour exécuter $TASK_ASCIIDOCTOR_REVEALJS " +
"avec Java $javaVersion. Installez Docker ou utilisez Java 17."
)
}
Final result
The consuming buildscript
apply<slides.SlidesPlugin>()
That’s it. The plugin carries the entire responsibility.
settings.gradle.kts
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
}
rootProject.name = "slider-gradle"
Summary of pitfalls and solutions
| Problem | Cause | Solution |
|---|---|---|
`ruby { gems() }`not available in Kotlin |
Groovy DSL extension only |
Three separate mechanisms: Ivy repo + Maven exclusion + |
Gradle looks for a`.jar`instead of a`.gem` |
`[ext]`resolves to`jar`by default |
Hardcode`.gem`in the Ivy pattern + qualifier`@gem` |
|
Grolifant receiver incompatible with Kotlin DSL |
Direct call`project.repositories.ivy { }` |
`javaLauncher`unresolved |
Non-existent property on`AsciidoctorJRevealJSTask` |
|
`executable = …`does not compile |
Property`val`in`JavaForkOptions`grolifant |
Method`executable(Object)`instead |
|
It is a Gradle service, not an extension |
|
`revealjs { }`unresolved in the task |
Project extension, not task method |
|
Build crashes with Java 25 |
Kotlin 2.0.x does not parse two-digit Java versions |
Automatic Docker detection + Java 17 fallback |
Investigation method: reading an unknown API with javap
Principle
When documentation is absent or incomplete, bytecodes are the source of truth. javap`is the standard JDK tool that decompiles.class`files into readable Java signatures, without requiring source code.
Step 1: locate the jar in the Gradle cache
Gradle downloads all its dependencies into`~/.gradle/caches/modules-2/files-2.1/`. The first step is to find the jar containing the class to inspect:
find ~/.gradle/caches -name "asciidoctor-gradle-jvm-slides*.jar" 2>/dev/null
/home/user/.gradle/caches/modules-2/files-2.1/org.asciidoctor/
asciidoctor-gradle-jvm-slides/4.0.0-alpha.1/.../
asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar
Step 2: list the classes of the jar
Before inspecting a class, verify it actually exists in the jar:
jar tf asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar | grep -i "RevealJS\|revealjs"
Step 3: inspect a class
AsciidoctorJRevealJSTask, RevealJSExtension, RevealJSOptions, etc.javap -p -classpath asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.slides.AsciidoctorJRevealJSTask
The option`-p`displays all members including private ones. The result immediately shows the key line:
public class AsciidoctorJRevealJSTask
extends org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask
Step 4: climb the hierarchy
The task extends`AbstractAsciidoctorTask`. We inspect it in turn by first locating its jar:
find ~/.gradle/caches -name "asciidoctor-gradle-jvm-[0-9]*.jar" 2>/dev/null
javap -p -classpath asciidoctor-gradle-jvm-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask | grep -i "fork\|exec\|jvm\|java"
This is where we discover`forkOptions`, JAVA_EXEC, et javaForkOptions of type`org.ysb33r.grolifant.api.v4.JavaForkOptions`.
Step 5: follow unknown types
`JavaForkOptions`is an unknown grolifant class. We locate its jar:
find ~/.gradle/caches -name "grolifant*.jar" 2>/dev/null
Then we inspect it:
javap -p -classpath grolifant40-legacy-api-2.0.0-alpha.6.jar \
org.ysb33r.grolifant.api.v4.JavaForkOptions
We find`executable(java.lang.Object)`— the correct method to call, as opposed to`executable = …`which does not compile because it is a`val`property.
Step 6: verify project extensions
For`revealjs { }`, the question was: is it a task method or a project extension? Inspection of`AsciidoctorJRevealJSTask` shows no`revealjs`method. We then inspect`RevealJSExtension`:
javap -p -classpath asciidoctor-gradle-jvm-slides-4.0.0-alpha.1.jar \
org.asciidoctor.gradle.jvm.slides.RevealJSExtension | head -5
public class RevealJSExtension implements groovy.lang.GroovyObject {
public static final java.lang.String NAME;
The presence of`NAME`confirms it is an extension registered on the project, accessible via`project.extensions.getByType<RevealJSExtension>()`.
Method Summary
| Step | Action |
|---|---|
1 |
|
2 |
|
3 |
|
4 |
Identify`extends`and climb the hierarchy |
5 |
Follow unknown types in their own jars |
6 |
Search for`NAME`to identify a project extension |
This method applies to any Gradle plugin whose API is undocumented or whose alpha version no longer matches the existing documentation.
Next step
This buildSrc plugin will be extracted into an independent project published on the Gradle Plugin Portal or Maven Local. The consuming buildscript will then become:
plugins { id("slides") version "1.0.0" }
Et settings.gradle.kts`will be reduced to its strict minimum without reference to`foojay-resolver-convention, with JDK provisioning handled by the plugin itself or documented as a prerequisite.