Skip to content

Latest commit

 

History

History

java

README.md

java

The pure-Java library dev.gecode:iris (Java 27, module dev.gecode.iris), the Kotlin extensions module dev.gecode:iris-kotlin, and the annotation processor dev.gecode:iris-processor. PLAN.md §8.3 describes them; milestone M4d builds them.

The library parses reader specs, applies every parse-time check of the Specification, reads input into columnar data under the work budgets of chapter 10, lists the instances of an input, and writes dzn. iris-kotlin adds Kotlin extensions; its README shows them.

import dev.gecode.iris.Data;
import dev.gecode.iris.IrisException;
import dev.gecode.iris.Params;
import dev.gecode.iris.Spec;

try {
  Spec spec = Spec.parse(source, "sat.iris");  // byte[] or String
  Data data = spec.read(input, "sat.cnf", Params.of("instance", "2"));
  long vars = data.get("vars").asLong();
  long[] first = data.get("clauses").get(0).longs();  // a copy
  String document = data.toJson();
  String dzn = data.toDzn();
  Data catalog = spec.catalog(input, "sat.cnf");  // the entries instances, name and failed
  long instances = catalog.get("instances").asLong();
} catch (IrisException e) {
  e.code();                  // ErrorCode.EXPECTED_VALUE, whose code() is "E-EXPECTED-VALUE"
  e.origin();                // origin, line and column of the input if the error has them,
  e.line();                  // and otherwise of the reader spec; any of them can be null
  e.column();
  e.path();                  // the indices of a failed check's witness, or null
  e.error().inputLocation(); // origin, line and column of the input
  e.error().specLocation();  // origin, line and, for E-SPEC-UTF8, column of the reader spec
}

read and catalog also take a Path, whose text is the origin. They then wrap an IOException in an UncheckedIOException. Asking a Value for something it is not throws IllegalStateException or IndexOutOfBoundsException, never IrisException, as that is a mistake in the calling code. ints() throws ArithmeticException for an integer that does not fit an int.

longs(), ints() and doubles() return a new array on each call, and strings() an unmodifiable list. Values are equal if they have the same type, shape and leaves. The comparison walks the levels of an array in a loop, so a value of any depth can be compared and hashed.

Value and IrisError are sealed, so a switch over them needs no default. dev.gecode.iris and the module are both @NullMarked: the package annotation covers tools that do not read module-info, such as those on the class path.

Building reader specs

The exported package dev.gecode.iris.build builds reader specs from Java code and reads their data through typed handles, as the Java section of docs/builders.md describes. A factory such as int2("machine") declares a handle, the static methods of Iris add statements to the innermost open block, which a scoped value holds, and one generic Data.get(Field<V>) returns the handle's Java type:

import static dev.gecode.iris.build.Iris.*;

import dev.gecode.iris.Data;
import dev.gecode.iris.Spec;
import dev.gecode.iris.build.IntField;

IntField<Long> n = int0("n");
IntField<long[]> x = int1("x");
Spec spec =
    spec(
        "numbers",
        () -> {
          read(n, atLeast(0));
          repeat(n, () -> read(x, intType()));
        });
Data data = spec.read("2 7 9");
long count = data.get(n);  // 2
long[] values = data.get(x);  // {7, 9}

The builder prints Iris text, which builder(...).source() returns, and parses it with Spec.parse. A mistake throws BuildException, whose kind() names the check (SCOPE, PROVENANCE, REDEFINITION, UNPRINTABLE, BINDING, or SPEC when the language rejects the text) and whose callSite() is the line of the calling code that made the statement, found with StackWalker. Builder checks that one statement shows throw at that call, and the others from build() or source(). The spec that build() returns keeps the call site of each emitted line, so an IrisException from parsing, reading or cataloging names the statement too: callSite() returns it, and the message ends with a call site: line. An error that belongs to no statement, such as E-TRAILING-INPUT, has none, and neither has a catalog rejection, which is a summary in the data, or a spec parsed from text.

Reading data into records

Spec.reader(Class, MethodHandles.Lookup) returns a RecordReader that reads data into a record. Each component reads the entry of its exact name, or the one that @Entry names, as the type a handle of that entry reads, or as a Value. A component for an entry that may be absent needs @Nullable. The reader checks every component when it is created and throws IllegalArgumentException for a mismatch, so a read fails only as Spec.read does:

record Shop(
    @Nullable String name,
    @Entry("n_jobs") long nJobs,
    @Entry("n_machines") long nMachines,
    List<long[]> machine,
    List<long[]> duration) {}

RecordReader<Shop> reader = spec.reader(Shop.class, MethodHandles.lookup());
Shop shop = reader.read(Path.of("ft06.txt"));

The reader calls the canonical constructor through a method handle that the lookup finds, so the record's package need not be open. A method handle passes at most 253 parameter slots, where a long or double takes two, so a record whose components take 254, the most a constructor takes, is constructed through Constructor.newInstance with the lookup class as its caller, which needs a lookup with original access, as MethodHandles.lookup() has.

The value type, depth and optionality of each entry come from a port of gecode-iris-sema's output entries in dev.gecode.iris.internal.syntax.

Typed handles for a text spec

iris gen --lang java SPEC prints a class with a typed handle for each entry of a text spec, named after its file: public static final IntField<List<long[]>> machine = int2("machine"); for the jobshop format's Jobshop, read as data.get(Jobshop.machine). iris gen --lang kotlin SPEC prints an object of the same handles. The annotation processor dev.gecode:iris-processor writes the same handles while javac compiles: @IrisSpec("jobshop.iris") on a top-level type Jobshop gives JobshopHandles, with the parsed spec, the handles and a record reader, and the record JobshopData. Its README shows the Gradle setup. Neither adds a dependency to dev.gecode:iris.

Using it from another project

dev.gecode:iris, dev.gecode:iris-kotlin and dev.gecode:iris-processor are not on Maven Central yet. A java-vVERSION tag will publish them there under those coordinates, as RELEASING.md describes, and then mavenLocal() is no longer needed. Until then, install them in the local Maven repository to use them from another project:

./gradlew publishToMavenLocal

A Gradle project then needs Java 27 and these lines:

repositories {
    mavenLocal()
    mavenCentral()
}

java { toolchain { languageVersion = JavaLanguageVersion.of(27) } }

dependencies {
    implementation("dev.gecode:iris:0.1.0")
}

A Maven project adds this dependency and sets maven.compiler.release to 27:

<dependency>
  <groupId>dev.gecode</groupId>
  <artifactId>iris</artifactId>
  <version>0.1.0</version>
</dependency>

For the Kotlin extensions, use dev.gecode:iris-kotlin in the same way:

dependencies {
    implementation("dev.gecode:iris-kotlin:0.1.0")
}
<dependency>
  <groupId>dev.gecode</groupId>
  <artifactId>iris-kotlin</artifactId>
  <version>0.1.0</version>
</dependency>

Kotlin has no JVM 27 target, so a Kotlin project on Java 27 needs these lines in Gradle, as this build does (see Java 27 gaps):

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.jvm.JvmTargetValidationMode
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile

kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_26 } }

tasks.withType<KotlinJvmCompile>().configureEach {
    jvmTargetValidationMode = JvmTargetValidationMode.IGNORE
}

The site guide shows these snippets and the site check compares them with this file, so change both together.

Layout

Path Contents
iris/ The library. dev.gecode.iris and the spec builder dev.gecode.iris.build are exported; dev.gecode.iris.internal.syntax holds the lexer, parser, resolver and output entries, and dev.gecode.iris.internal.read the reader.
iris-driver/ The conformance driver, which is not published. It implements parse, read, catalog (`--format json
iris-examples/ The examples of the site's Java and Kotlin guide, which are not published: sources in src/main/java/examples and src/main/kotlin/examples, each with its recorded .out beside it, among them a Java and a Kotlin builder for each format in formats/, and the iris gen commands of the views as view.sh.
iris-processor/ The annotation processor dev.gecode:iris-processor, which writes the handles of iris gen --lang java, the parsed spec and a record for @IrisSpec. It has no module descriptor, since it reads the library's internal syntax packages.
iris-views/ The tests of the views, which are not published: the views that iris gen --lang java and --lang kotlin print for every format and the specs in specs/, generated into the build, and the classes iris-processor writes for the same specs.
iris-round-trip/ The runner of the Java builder round trip, which is not published. Its roundTrip task copies the builder programs that iris gen builder --lang java printed into the build, checks them with Spotless, compiles them with the runner and records what each prints; without -PirisRoundTripSources the module holds only the runner.
iris-kotlin-round-trip/ The runner of the Kotlin builder round trip, which is not published. Its roundTrip task does for the programs of iris gen builder --lang kotlin what the Java one does, with ktfmt, detekt and kotlinc.
iris-jmh/ The JMH benchmarks, which are not published. Nothing else depends on this module, so JMH stays out of dev.gecode:iris.
iris-kotlin/ The Kotlin extensions, in the exported package dev.gecode.iris.kotlin of the module of that name, and the Kotlin spec builder in dev.gecode.iris.kotlin.build.
build-logic/ The convention plugins iris.java-conventions, shared by the modules, iris.kotlin-conventions, iris.kotlin-docs for Dokka, and iris.published for the three published modules: their POM metadata, sources and javadoc jars, signing and the Central Portal through the vanniktech Maven publish plugin.
gradle/libs.versions.toml The pinned versions.
CHANGELOG.md The changes of each release of the three published modules.
detekt.yml The detekt configuration: what differs from the default rules.

The legal nesting of §5.12 needs up to about 900 KiB of stack on a JVM, so Spec.parse, Spec.read and Spec.catalog run on a shared pool of threads with 4 MiB stacks (LargeStack). A legal spec parses and reads whatever stack the caller has.

§5.12 bounds parsing, not the tree. Left operands can chain for tens of thousands of nodes, as in (((1+1)+1)+1), and aliases can chain to any length. A walk of an expression therefore loops over Expr.leftSpine and recurses only into the other operands, which hold a counter entry. A use of a named type keeps its reading kind and refers to its definition by number, so it has no chain to follow.

Nor does anything bound the dimensions of a value. A value is a view of a Table: flat leaves and one array of offsets per level. Values are made on access, and nothing that walks them recurses.

Budget keeps the units left of the two limits of §10.1, which stay below 2^36. A charge is compared whole with what is left, so no charge can overflow a long. Every site of §10.2 to §10.4 charges before it allocates what the charge pays for, so a huge repeat or array of empty rows fails at the limit instead of running out of time or memory.

Building and testing

The build needs Java 27 as java on PATH or in JAVA_HOME, and stable Rust with cargo on PATH, since the conformance test runs the Rust runner. It fails rather than skips without cargo.

./gradlew build          # compile, spotlessCheck, detekt, all tests, conformance, and javadoc
./gradlew spotlessApply  # rewrite the Java and Kotlin sources
./gradlew conformance    # every conformance case, through the installed driver
./gradlew :iris:javadoc  # the API reference, in iris/build/docs/javadoc
./gradlew :iris-kotlin:dokkaGenerate  # the Kotlin API reference, in iris-kotlin/build/dokka/html
./gradlew :iris-examples:test  # run the guide's examples and compare their output
./gradlew :iris-examples:test --tests examples.FormatBuildersTest  # every format case, both builders
./gradlew :iris-processor:build  # the annotation processor, its tests and its javadoc
./gradlew :iris-views:test  # read every case through the generated views
./gradlew :iris:test --tests dev.gecode.iris.BudgetLimitsTest
./gradlew :iris:test --tests 'dev.gecode.iris.build.*'  # the spec builder
./gradlew :iris:test --tests dev.gecode.iris.FloatDifferentialTest
./gradlew :iris:test --tests dev.gecode.iris.RecordReaderTest  # the record reader
./gradlew :iris:test --tests 'dev.gecode.iris.internal.syntax.OutputEntries*'  # entry shapes
./gradlew :iris-kotlin:check  # the Kotlin tests, detekt and the dependency check
./gradlew jmh            # the benchmarks, which take about 3 minutes

-Xlint:all -Werror applies to main and test sources. Error Prone and NullAway run inside compileJava, so they have no task of their own: any Error Prone warning fails the build through -Werror, and NullAway reports at error level for @NullMarked code, which is all of dev.gecode.iris. NullAway runs in JSpecify mode, so it checks the @Nullable type arguments of the builder's optional handles.

iris-kotlin compiles with allWarningsAsErrors, and detekt and ktfmt run in check; its README has the details.

:iris:javadoc documents only the exported packages and fails on any warning, a missing comment included. The build runs it through check. ApiTest checks the module descriptor, that public signatures name no internal type, and the error fields.

The tests of dev.gecode.iris.build follow "Testing builders" in docs/builders.md. ChecksTest covers the builder checks and asserts the caller's line of each failure, CallSitesTest the call sites of the language's errors from parsing, reading and cataloging a built spec, a port of rust/iris-build/tests/call_sites.rs, PrintingTest the text of every statement and expression, ReadingTest what get returns, and JobshopTest the running example against the cases of formats/jobshop. CompileTest compiles user code with javac: the type of get for each handle, the wrong types and depths that javac rejects, and, by running the toolchain's javac with Error Prone and NullAway in JSpecify mode, the dereference of an optional entry.

RecordReaderTest reads the jobshop format and each value type and depth into records, reads records of 253 and 254 parameter slots that it compiles with javac, and checks the errors of a reader's creation. OutputEntriesTest ports the output-entry tests of rust/iris-sema/tests/model.rs, and OutputEntriesDifferentialTest compares the value type, depth and optionality of every entry of every spec in conformance/cases/0.1 and formats with the handles that iris gen --lang rust prints. Like FloatDifferentialTest, it builds iris with Cargo, or runs the binary that -PirisCli=PATH names.

:iris-kotlin:dokkaGenerate also fails on any warning and runs through check. The site build runs both tasks and publishes the result in its own pages (see site/README.md).

ExamplesTest runs every example in iris-examples from the repository root, in a JVM of its own, and requires exit status 0, nothing on standard error, and the output in the .out file beside the source. A new example needs that file and an entry in site/scripts/java-examples.mjs: the site check fails on a file the guide does not show. iris-examples has the same compiler, Error Prone, NullAway, Spotless, ktfmt and detekt checks as the library, except for Kotlin's explicit API mode, which a program does not need.

FormatBuildersTest runs the conformance runner on each directory of formats/ with FormatBuilderDriver, the driver of iris-driver with a SpecLoader that answers the format's spec path with the spec of its builder, once with the Java builder and once with the Kotlin one. Every case must pass, its errors included, and it prints the counts per format and language. It builds the Rust runner with Cargo, as the conformance test does.

iris-views runs iris gen --lang java and --lang kotlin, built with Cargo, on every format, on the specs in iris-views/specs and on a sweep of declaration widths, adds a package line, and compiles the output with the build's checks: Error Prone, NullAway, detekt with the exceptions in iris-views/detekt.yml, google-java-format and ktfmt. Each spec also has a type annotated with @IrisSpec in dev.gecode.iris.views.processed, so the same compilation runs iris-processor. The Java views go in dev.gecode.iris.views.java and the Kotlin views in dev.gecode.iris.views.kotlin; those packages and the processor's declare classes named after the platform types that the generated code uses, such as Long, String and List, so the compilation fails if generated code resolves a type to a class of its package. ViewsTest pins every handle's type against the library's output entries, checks that the processor's handles have the CLI view's identifiers and types, and reads each format case with data and the input beside each spec through the Java view, the Kotlin view, the processor's handles and its record, comparing every entry with untyped access. It prints the counts of views, handles, reads and values. The processor's own tests compile annotated sources in process with javac and check its errors.

./gradlew conformance selects every case in conformance/cases/0.1 and formats, runs them with the installed driver's classes through the Rust runner, and prints the counts per operation and the totals. It has no driver ID and no exemptions, and it fails on any failing case or an operation without cases. ./gradlew test leaves it out and ./gradlew build runs it once. Add --rerun to repeat it when nothing changed.

BudgetLimitsTest charges a known cost at each site of §10.2 to §10.4 and uses up the rest of the limit: the spec that ends exactly on the limit must pass for read and catalog, and the one with a unit more must fail with E-BUDGET.

FloatDifferentialTest compares Spec.read with the Rust iris read --json and iris read --dzn byte for byte on a million floats from a fixed seed, and checks that each printed value parses to the same double. -PirisFloatValues=N sets the number and -PirisCli=PATH runs a given iris binary instead of cargo run.

The Java builder round trip generates a builder program for every reader spec in conformance/cases/0.1 and formats that iris check accepts, checks each with google-java-format through Spotless, compiles them all in one javac run with Error Prone and NullAway, runs them and compares the spec each prints with the original. It runs from rust/, and drives the iris-round-trip module through this wrapper:

cargo test --locked -p gecode-iris --test builder_round_trip_java -- --ignored --nocapture

The Kotlin builder round trip does the same for Kotlin programs: ktfmt in kotlinlang style through Spotless, detekt with the build's rules and the exceptions for generated code in iris-kotlin-round-trip/detekt.yml, one kotlinc run with the warnings as errors, and the iris-kotlin-round-trip module:

cargo test --locked -p gecode-iris --test builder_round_trip_kotlin -- --ignored --nocapture

By hand from the root, after ./gradlew :iris-driver:installDist in java/:

(cd rust && cargo run --locked -p gecode-iris-conformance -- run --driver "\"$PWD/../java/iris-driver/build/install/iris-driver/bin/iris-driver\"" ../conformance/cases/0.1)

Run the same command with ../formats for the format cases. This form needs a Unix shell script; see Windows.

Continuous integration

java.yml runs ./gradlew build on Ubuntu, macOS and Windows with Temurin 27 and stable Rust, and the Java builder round trip on Ubuntu. It runs on branch pushes and pull requests when java/, conformance/, formats/, rust/ or the workflow changes, and it uploads the test reports of a failed build. A java-vVERSION tag runs it through java-release.yml, which then publishes the three artifacts to Maven Central. Lint both from the root:

/tmp/iris-zdev/actionlint .github/workflows/java.yml .github/workflows/java-release.yml

Windows

As of 2026-11-05 no run of the workflow has been observed on any platform, so nothing below has run on Windows. It comes from reading the sources of the runner, the Rust standard library and the JDK launcher.

  • Line endings. The cases compare bytes, so the job sets core.autocrlf to false before checkout, as cpp.yml and python.yml do. Spotless asks for LF on every platform; by default it would ask for CRLF there.
  • The driver command. ConformanceSuite starts java with the installed lib/* on the class path instead of bin/iris-driver.bat. Every params.json ends in a newline, and Rust refuses to pass a newline to a batch file. So the by-hand command above cannot use the batch launcher either. The conformance task runs the same command on every platform, so what passes here is what runs there.
  • Verbatim paths. The runner canonicalizes paths, which gives \\?\D:\... on Windows. Path.of reads that prefix on Java 27, for drives and UNC shares alike, so the driver opens these paths as they are. The runner's report spells them with forward slashes, and ConformanceSuite restores the backslashes.
  • Symbolic links. Each format case's spec.iris is a link, so the job needs a Git that checks out links, as the Windows jobs of cpp.yml and python.yml do.
  • Non-ASCII --params. java.exe reads its command line in the ANSI code page, and changing that takes a reboot. chcp sets only the console's code page and -Dfile.encoding applies after the arguments are decoded, so neither helps. On a runner with code page 1252, sun.jnu.encoding is Cp1252, and the driver refuses é with exit status 3. It cannot see 😀, which the code page has already turned into ??, and the value read is wrong. Either way such a case fails instead of passing. No params.json in conformance/cases/0.1 or formats has a non-ASCII byte, so the suite does not reach this. A new case should spell such a value with JSON escapes, which work everywhere. theLauncherKeepsParamsOrRefusesThem stays off on Windows. The library is not affected: Params takes Java strings.

Benchmarks

./gradlew jmh runs ReadBenchmark, in about 3 minutes, and writes iris-jmh/build/results/jmh.json. The benchmarks measure and do not gate: ./gradlew build compiles iris-jmh and runs its tests, which read each input once and check that every format case that reads has a benchmark, but it runs no benchmark. --args replaces the JMH options:

./gradlew :iris-jmh:jmh --args="-f 1 -wi 1 -i 1 -p input=SUDOKU_FOUR_BY_FOUR"

ReadBenchmark.read measures one call of Spec.read(byte[], String, Params), which includes the handoff to the LargeStack pool. The spec is parsed and the input is loaded in @Setup, so parsing and file access are not measured, and neither is writing JSON or dzn. The inputs are the format cases that read without an error. They are at most a few hundred bytes, so the numbers show the cost of a small read and say nothing about throughput on large files.

Error Prone skips the classes that JMH generates, in jmh_generated, and only those; -Xlint:-processing applies to this module, as the JMH processor leaves the JSpecify annotations unclaimed. The default is one fork per input, and -foe true makes a failed benchmark fail the task. A first run with three forks exited 0 with no results, after JMH lost the link to its forks on this machine; the cause was not established.

Baseline

This is a local measurement, not a CI result, taken on 2026-11-05.

  • Machine: Apple M1 Max, 10 cores, 64 GiB, macOS 27.0.1.
  • JVM: Homebrew OpenJDK 27.0.1.0.3, 64-Bit Server VM, default options (G1), with JMH's compiler blackholes.
  • JMH 1.37: average time, one thread, one fork, 5 warmup iterations of 2 s, 10 measurement iterations of 1 s. ./gradlew jmh ran five times, so each input had five JVMs.

The times are microseconds per read. Other jobs shared the machine, with load averages from 9 to 160, a Gradle build ran during some of the runs, and some runs took two or three times as long as others. Load, JVM variation and CPU state were not controlled. The lowest column is the minimum observed for each input under that load, not undisturbed performance, so a comparison with it is rough: rerun on a quiet machine before reading anything into a small difference.

Input Bytes Lowest Runs 1 to 5
DIMACS_THREE_VARIABLES 66 9.0 9.5, 9.0, 19.9, 12.0, 17.4
JOBSHOP_JSPLIB 119 15.7 15.7, 15.9, 37.3, 29.9, 15.7
JOBSHOP_OR_LIBRARY 182 15.5 15.6, 15.5, 44.1, 18.6, 15.9
NONOGRAM_FIVE_BY_FIVE 86 16.4 17.4, 16.9, 16.4, 42.2, 18.0
SUDOKU_FOUR_BY_FOUR 37 8.3 8.5, 8.5, 8.5, 19.3, 8.3
TSPLIB_EUC_2D_FIVE 164 15.5 15.5, 15.7, 40.1, 40.5, 15.6
TSPLIB_EXPLICIT_FIVE 230 11.0 11.0, 20.8, 25.6, 25.7, 11.0
TSPTW_THREE_NODES 70 10.1 10.1, 22.4, 10.1, 26.4, 10.8

Pinned tools

Gradle 9.8.2 (wrapper), Spotless 8.11.0 with google-java-format 2.0.0, Error Prone 2.52.0, NullAway 0.14.3, JSpecify 1.0.1, JUnit 5.14.4, Kotlin 2.4.21, ktfmt 0.64, detekt 2.0.0-alpha.6, Dokka 2.2.0 and JMH 1.37.

Java 27 gaps

As of 2026-11-04 every tool runs on Java 27, and no check is off. The Kotlin compiler inside Gradle has no JVM 27 target, so compiling build-logic warns and targets JVM 26; this affects only the build logic. Gradle may also print Did not receive connection preamble within 10s during a --no-daemon run, which is noise unless the build fails.

iris-kotlin has the same gap. Kotlin 2.4.21 has no JVM 27 target, so its classes target JVM 26 (class file version 70), while module-info.class and dev.gecode:iris are version 71. The build turns off the Gradle plugin's check that the Java and Kotlin targets match. The module still needs Java 27, as dev.gecode:iris does. The compiler reads the Java 27 classes and the module-info of dev.gecode.iris, and rejects a use of a package that module does not export.

detekt has no stable release for Kotlin 2.4: the 1.x line stops at 1.23.8, so 2.0.0-alpha.6, the newest release, is pinned. It is built against Kotlin 2.4.10 and analyzes this code, with type resolution, without complaint.

Dokka 2.2.0, the newest stable release, runs on Java 27 with Kotlin 2.4.21 without a warning. It cannot resolve the types of dev.gecode:iris, which it leaves unlinked; site/scripts/prepare-java-api.mjs links them to the Javadoc pages. It stays in its own Gradle configurations, so it is not on the compile or runtime class path that checkDependencies checks, nor in the published POM.