Polyglot Maven: Kotlin instead of XML

What is Polyglot Maven?

Most people assume that their Maven build logic should always be written in XML. What most people don’t know is that Maven allows extending the build tool itself using core extensions, which were significantly improved in Maven 3.3.1.

Core extensions as the name suggests allow you to extend the core functionality of Maven itself. Note that you shouldn’t confuse these core extensions with extensions from plugins.

An example of such a Maven core extension is Polyglot Kotlin which allows you to write your POM in Kotlin instead of XML. It was recently improved so it uses the newest Kotlin script engine. A huge benefit of using this scripting engine is that it adds auto-completion support.

This makes the experience similar to writing a Gradle build script (for those that have done that).

The Polyglot Kotlin core extension is part of the Polyglot Maven project. It consists of multiple core extensions that allow you to write the POM (Project Object Model) in languages other than XML. Currently supported languages are Clojure, Groovy, Java, Kotlin, Ruby, Scala and YAML.

Why should I use Polyglot Maven instead of XML?

Now you might ask yourself: why would I want to write my build script in something other than XML? Personally I find the XML format to be too restrictive and verbose for a build tool. Using an actual programming language such as Kotlin gives you a considerable amount of flexibility and expressiveness in writing your build logic.

The only danger of using Kotlin is that your POM quickly becomes too complex for others to understand.

A use-case where you would want such amount of flexibility is when you’d like to use a dynamic version. Recently the use of semantic versioning is gaining popularity. Implementing semantic versioning correctly is hard though. You have to make sure that everyone in your team understands when to increment either the major, minor or patch version when releasing.

Suppose that instead of setting the version manually with each release you want to automatically increment your major, minor or update version based on the Git commit history. This would be incredibly hard, maybe even impossible, to do in XML.

You could of course write a Maven plugin for that, but it will probably only work for the project you are working on. It is not as expressive as directly writing such logic in your POM. Allow me to demonstrate:

How do I use Polyglot Maven?

For this example I created a simple REST service containing ToDo tasks.
The sources of this example can be found on GitLab.

First we need to apply the appropriate core extension so Maven will pick up a POM written in Kotlin. To do this create the file .mvn/extensions.xml and add the following:

<?xml version="1.0" encoding="UTF-8"?>
<extensions>
  <extension>
    <groupId>io.takari.polyglot</groupId>
    <artifactId>polyglot-kotlin</artifactId>
    <version>0.4.2</version> 
  </extension>
</extensions>

Then we need some rules to decide when to increment which part of the version:

    • the major if there is a commit message containing ‘BREAKING CHANGE’
    • the minor if there is a commit message starting with ‘feat’
    • the patch in all other cases

Note: these commit message checks are inspired by the Conventional Commits specification.

Implementing the above checks in Kotlin is not that complicated.
Here is the resulting pom.kts file:

import java.io.File

// define a comparable data class to simplify handling versions
data class Version(val major: Int, val minor: Int, val patch: Int) : Comparable<Version> {
    override fun compareTo(other: Version): Int =
            compareValuesBy(this, other, Version::major, Version::minor, Version::patch)

    override fun toString(): String = "$major.$minor.$patch"
}

// define a function to execute the git command and return its standard output
fun git(vararg args: String): String {
    // use the basedir of the project as the command's working dir if it contains a '.git' subdir
    // otherwise use the current working directory of this script if it contains a '.git' subdir
    // if both conditions are false the result will be null; the git command will probably fail
    val workingDir = basedir.takeIf { it.resolve(".git").exists() }
            ?: File(".").takeIf { it.resolve(".git").exists() }
    // run the git command with the provided arguments
    val process = ProcessBuilder()
            .directory(workingDir)
            .redirectErrorStream(true)
            .command(listOf("git") + args)
            .start()
    // read the standard output completely as a String
    val output = process.inputStream.bufferedReader().readText().trim()
    // return the output if the exit value is 0 or throw an exception otherwise
    if (process.waitFor() == 0) return output
    else throw IllegalStateException(output)
}

val gitVersions by lazy {
    // run the `git tag` command
    git("tag")
            // the returned list of tags is separated by newlines
            .split("\n")
            // filter out only tags that are versions (such as 1.231.15)
            .filter { it.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]")) }
            // the separate parts of each version are separated by dots,
            // also parse each part as an int
            .map { it.split('.').map { it.toInt() } }
            // map each triple of numbers to an instance of the `Version` class
            .map { (major, minor, patch) -> Version(major, minor, patch) }
            // sort the list of versions
            .sorted()
}

// the last release is always the tag with the highest version number
val lastRelease by lazy {
    gitVersions.max()
}

// the next version is determined based on the git commit log
val nextVersion by lazy {
    // use the lsat released version as the base
    val baseVersion = lastRelease
    // if there are no releases yet, we use the version 0.0.1
    if (baseVersion == null) Version(0, 0, 1)
    else {
        // split the base version in each separate part using destructuring
        val (major, minor, patch) = baseVersion
        // create a separator to split each log message on (log messages are multiline)
        val separator = "-".repeat(5) + "commit" + "-".repeat(5)
        // get all log messages from the last release tag until the current HEAD
        // for each commit the separator is printed + the full commit message
        val logMessages = git("log", "--pretty=format:$separator%n%B", "$baseVersion..HEAD")
                // split the output on each separator generated earlier
                .split(separator)
                // trim each message, removing excess newlines
                .map { it.trim() }
                // only keep non-empty messages
                .filter { it.isNotEmpty() }
        when {
            // increment the major and reset the minor + patch if any 
            // message contains the words 'BREAKING CHANGE'
            logMessages.any { it.contains("BREAKING CHANGE") } -> Version(major + 1, 0, 0)
            // increment the minor and reset the patch if any message starts with 'feat'
            logMessages.any { it.startsWith("feat") } -> Version(major, minor + 1, 0)
            // increment the patch in all other cases
            else -> Version(major, minor, patch + 1)
        }
    }
}

project {

    // use the next version calculated above when defining our project id
    id("nl.craftsmen.blog.kotlin:kotlin-rest-service:${nextVersion}")

    dependencies {
        compile("org.glassfish.jersey.inject:jersey-hk2:2.29")
        compile("org.glassfish.jersey.containers:jersey-container-netty-http:2.29")
        compile("org.glassfish.jersey.media:jersey-media-json-jackson:2.29")

        runtime("ch.qos.logback:logback-classic:1.2.3")
    }

    properties {
        "project.build.sourceEncoding" to "UTF-8"
        "maven.compiler.source" to "11"
        "maven.compiler.target" to "11"
    }

    distributionManagement {
        repository("local") {
            url(basedir.resolve("repo").toURI().toASCIIString())
        }
    }

    build {
        execute(id = "release", phase = "deploy") {
            // create a new tag using the next version calculated above
            git("tag", "-am", "Release $nextVersion", "$nextVersion")
            // print some output
            println("Tagged current HEAD as $nextVersion")
        }
    }
}

Building this project with ‘mvn install’ will automatically select the correct next version based on the latest git commit messages.

Running ‘mvn deploy’ will first upload the artefacts and then tag the current HEAD with the next version as tag name.

Enabling Kotlin auto-completion in your IDE when using Polyglot Maven

For auto-completion to work the polyglot-kotlin JAR and its dependencies must be on the compile classpath so IntelliJ IDEA will index them. The easiest way to achieve this without changing your project’s dependencies is to add it as a global library.

To do this open Project Structure (Ctrl+Alt+Shift+S) -> Global Libraries -> click the + sign and choose From maven....

Fill in io.takari.polyglot:polyglot-kotlin:0.4.2, check the Sources checkbox and press OK.
Add the global library to your (IntelliJ IDEA) module and auto-completion should work.

Give it a go – and let me know how it works for you?

Leave a Reply

Your email address will not be published. Required fields are marked *