Skip to content

Repository files navigation

NullMarked Gradle Plugin

CI Codecov Gradle Plugin Portal Version License

Gradle plugin applying JSpecify's @NullMarked convention to Java projects.

Table of Contents

Why bother with NullMarked Plugin

The main goal of this plugin is to stop writing package-info.java files just to apply @NullMarked annotation to it. Let the plugin generate one automatically for all your packages.

Applying io.github.malczuuu.nullmarked to a Java project:

  1. Will generate package-info.java, for every non-empty package that doesn't have one.
  2. Adds the JSpecify dependency as compileOnly, unless the build script already declares it there itself.
  3. Registers generated directory as a source directory, so compileJava picks it up automatically. Hand-written package-info.java files always win.
  4. Verifies that package-info.java files are present for each package (generated or hand-written) and if nullness annotations are applied on packages.

Together with Error Prone and NullAway, it helps protect the project against nullness bugs without hand-writing an annotated package-info.java for every package - see examples/ for sample working setups, including Kotlin interop.

Installation

Apply plugin in build.gradle.kts.

plugins {
    java
    id("io.github.malczuuu.nullmarked") version "0.7.1"
}

Configuration (all optional):

nullmarked {
    // false makes the plugin inert: nothing is generated, nothing is verified and
    // no JSpecify dependency is added
    enabled = true

    // true verifies instead of generating: no package-info.java is generated and
    // the build fails on packages that do not declare one by hand
    verifyOnly = false

    // false omits the "Generated by ..." comment in generated files
    headerEnabled = true

    // version of the auto-added org.jspecify:jspecify dependency, or a full
    // "group:name:version" notation to use a fork instead
    jspecifyVersion = "1.0.1"

    // which packages are processed; empty means all of them
    packages {
        exclude("com.acme.generated..")
    }

    // package-info.java verification strictness; lenient() is the default
    verify {
        lenient()
    }
}

Package identifiers follow ArchUnit's syntax:

  • org.acme matches only org.acme,
  • org.acme.. matches org.acme and all its subpackages,
  • ..internal.. matches any package containing an internal segment,
  • * matches within a single segment.

Selecting Packages

To configure packages used for package-info.java generation, use exclude(...) and include(...). They append to an ordered list of rules, where the last rule matching a package decides whether it is processed. A package no matching rule is automatically included. The include clause only exists to carve exceptions out of an earlier exclude:

nullmarked {
    packages {
        exclude("..internal..")             // skip every internal package
        include("com.acme.internal.api")    // ... except this one
    }
}

A source set's own rules are evaluated after all top-level ones, no matter which block the build script declares first, so a source set can re-admit what the top level excluded:

nullmarked {
    packages {
        exclude("..internal..")
    }
    sourceSet("test") {
        packages {
            include("..internal..")   // test sources keep their internal packages
        }
    }
}

Verify-Only Mode

Every configured source set gets a verifyPackageInfo task that compileJava depends on. It fails the build listing every package that has Java files but no package-info.java, counting hand-written and generated ones alike and skipping whatever the packages { } rules exclude.

Set verifyOnly = true to opt out of generated code and only enforce hand-written package-info.java files:

nullmarked {
    verifyOnly = true
}
enabled verifyOnly Generation Verification JSpecify dependency
true false on on (passes) added
true true off, previous output is deleted on (fails on gaps) added
false ignored off, previous output is deleted off not added

The verifyOnly mode still gets the JSpecify dependency, since hand-written package-info.java files need annotations on the compile classpath. With enabled = false the plugin touches nothing at all, which is also the only way to opt out of the auto-added dependency short of declaring JSpecify yourself.

Verification Strictness

verifyPackageInfo judges each package's package-info.java against a strictness level, configured with a verify { } block:

nullmarked {
    verify {
        strict()   // lenient() is the default, explicit() sits in between
    }
}
  • lenient() (default) - a package-info.java only needs to exist; its content is not inspected.
  • explicit() - it must also declare @NullMarked or @NullUnmarked; a bare file fails.
  • strict() - it must declare @NullMarked specifically; @NullUnmarked or a bare file fails.

Both explicit() and strict() fail on a file declaring both annotations.

The same block works on a `sourceSet (...), overriding whatever it would otherwise inherit - whichever call happens last wins:

nullmarked {
    verify {
        strict()
    }
    sourceSet("test") {
        verify {
            lenient()   // tests stay lenient
        }
    }
}

Failures list a reason per offending package:

Found 2 package(s) with a package-info.java problem:
  - com.acme: missing package-info.java
  - com.acme.bare: package-info.java present but declares neither @NullMarked nor @NullUnmarked
Add a package-info.java to each of them, or exclude them via nullmarked.packages

Important

Note that verification is based on text analysis of package-info.java files, not the compiled output.

Additional Source Sets

By default, only main is processed. Use sourceSet("...") to opt other source sets in, e.g. test:

nullmarked {
    sourceSet("test") {
        // same three properties as above, each defaulting to the top-level value
        enabled = true
        verifyOnly = false
        headerEnabled = true

        // rules appended after the top-level ones
        packages {
            exclude("com.acme.fixtures..")
        }
    }
}

Each sourceSet block inherits enabled, verifyOnly and headerEnabled from the top-level nullmarked { } configuration and only needs to set what it wants to override; its packages { } rules are appended to the top-level ones. To opt a source set in without overriding anything, drop the block: sourceSet("test").

Opting in a name that no Java source set matches fails the build, listing the ones that exist, so a typo does not silently do nothing.

Compatibility

  • Gradle 8.3 or later
  • Java 8+

The plugin reacts to the java plugin and scans the Java source directories of each configured source set.

Building

./gradlew                  # calls configured Gradle default tasks (spotlessApply and build)
./gradlew spotlessApply    # format code: ktfmt for *.kt (Kotlin sources), ktlint for *.kts (Gradle buildscript)
./gradlew build            # compiles the plugin and runs its unit tests
./gradlew integrationTest  # TestKit compatibility test; -Pcompat.gradle.version=9.0.0 targets a specific Gradle

Using Local Snapshot

Expand...
  1. Build project to local Maven repository.
    ./gradlew publishToMavenLocal
  2. Add mavenLocal to pluginManagement in settings.gradle.kts.
    pluginManagement {
        repositories {
            gradlePluginPortal()
            mavenCentral()
            mavenLocal()
        }
    }
  3. Apply plugin in build.gradle.kts.
    plugins {
        java
        id("io.github.malczuuu.nullmarked") version "0.7.2-SNAPSHOT"
    }

License

This project is licensed under the Apache License, Version 2.0.

This project is not affiliated with, sponsored by, or endorsed by Gradle or JSpecify. All product names, logos, and brands are property of their respective owners.

About

Gradle plugin applying JSpecify's NullMarked convention to Java projects

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages