Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 50 additions & 17 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
# hegel-java — developer notes

Property-based testing for Java, binding the native Hegel engine (`libhegel`) in-process over the
Java Foreign Function & Memory API (FFM, `java.lang.foreign`). No JNI, no cgo-equivalent.
Property-based testing for Java, binding the native Hegel engine (`libhegel`) in-process. Two
published artifacts share one API and one source tree: `dev.hegel:hegel` binds over the Java
Foreign Function & Memory API (FFM, `java.lang.foreign`, Java 22+) and `dev.hegel:hegel-jna` binds
over JNA (Java 17+). Each jar is self-contained (users depend on exactly one); there is no shared
`hegel-core` artifact.

This implementation was generated by Claude following the `hegel-implementation-guide` skills. The
authoritative references are hegel-rust (the engine and canonical client) and its C ABI header
`hegel-c/include/hegel.h`.

## Layout

Multi-module Maven build (`dev.hegel:hegel-parent`, not itself useful to consumers):

- `shared/` — **not a Maven module**: the backend-neutral source tree (`src/main/java`,
`src/main/java-templates`, `src/test/java`) compiled into *both* jars via
build-helper-maven-plugin `add-source`/`add-test-source`. Everything except the concrete
binding lives here, including `FakeLibhegel` and the whole behaviour suite, which therefore
runs against both backends.
- `hegel/` — the FFM artifact: `RealLibhegel` + `LibhegelBackend` (main), `RealLibhegelTest` +
`FfmCoverageTest` (test), and the JPMS `module-consumer` invoker IT under `src/it`.
- `hegel-jna/` — the JNA artifact: `JnaLibhegel` + `LibhegelBackend` (main), `JnaLibhegelTest`.

Each module defines the same package-private `dev.hegel.LibhegelBackend.open(Path)`; the shared
`Engine` calls it without knowing which backend it got. IDE caveat: IntelliJ attaches a shared
source root to only one module at a time (duplicate content roots are unsupported); the Maven CLI
is authoritative.

## Build & test

- `just coverage` / `mvn verify` — runs all tests and enforces **100% instruction + branch
coverage** (JaCoCo). This is a hard gate.
- `just test` / `mvn test` — full suite, no coverage gate.
coverage** (JaCoCo, per module). This is a hard gate.
- `just test` / `mvn test` — full suite, no coverage gate. Building the full reactor needs JDK
22+ (the FFM module); `just test-jna` / `just coverage-jna` build only parent + `hegel-jna`
and work on JDK 17+ (CI runs them on 17 and 21).
- `mvn test -Dtest=RunnerTest` / `-Dtest=RunnerTest#happyPathMarksValidAndFreesEverything` — one
class / one method. `-Dtest='*Conformance*,*Behaviour*'` is what `just conformance` runs.
- `just conformance` — the behaviour suite against the real engine.
- `just build-libhegel` — build `libhegel` from a sibling `../hegel-rust` checkout.
- `just format` / `just lint` — palantir-java-format via spotless-maven-plugin.
- `just check` — full CI gate: lint + coverage + docs.

Java 22 (`maven.compiler.release`) for the FFM API; tests run with
`--enable-native-access=ALL-UNNAMED` (the `hegel.argLine` property). The pinned engine version
(`<libhegel.version>` in `pom.xml`) is filtered into `BuildInfo.ENGINE_VERSION` from
`src/main/java-templates/dev/hegel/BuildInfo.java` (maven templating-plugin → generated-sources);
`maven.compiler.release` is 22 for the `hegel` module (FFM) and 17 for `hegel-jna` and the shared
tree; keep shared code Java-17-clean. Both modules' tests run with
`--enable-native-access=ALL-UNNAMED` (the `hegel.argLine` property — FFM needs it on 22+, JNA on
24+ under JEP 472; the flag is accepted on every supported JDK). The
pinned engine version (`<libhegel.version>` in the parent `pom.xml`) is filtered into
`BuildInfo.ENGINE_VERSION` from `shared/src/main/java-templates/dev/hegel/BuildInfo.java`
(maven-resources-plugin filtering → generated-sources, added to the compile path by build-helper);
bump that property to ship a new engine. A user-supplied `$HEGEL_LIBHEGEL_PATH` of a different
version triggers a warning against `BuildInfo.ENGINE_VERSION`.

Expand Down Expand Up @@ -53,14 +79,19 @@ subpackage (one class per generator: `IntegerGenerator`, `TextGenerator`, `ListG
`RegexGenerator`, `EmailGenerator`, `DateTimeGenerator`, `Derive`/`RecordGenerator`, etc.). The
public `Generator`/`TestCase`/`Generators`/`Hegel`/`Stateful` surface stays in `dev.hegel`.

- **FFI binding** — `Libhegel` (a fakeable interface) and `RealLibhegel` (FFM). `LibraryLoader`
resolves the library (override / OS library path / jar-bundled native unpacked to a cache);
`Engine` is the process-wide lazy holder of the loaded shared object (with a test hook) — it
caches only the immutable, thread-safe library, never per-test state. `RealLibhegel` keeps one
libhegel error context per thread (read back by `lastErrorMessage()`), passes date/time/datetime
structs by value, copies engine-allocated string/bytes buffers out and frees them, and bridges
the per-run output callback (`hegel_output_callback_t`) to a `Consumer<String>` through an FFM
upcall stub whose arena lives until `runFree`. `Abi` holds the C constants.
- **FFI binding** — `Libhegel` (a fakeable, backend-neutral interface: opaque handles cross as
raw `long` addresses, 0 = NULL, so it never mentions any FFI library's types) with one
implementation per artifact: `RealLibhegel` (FFM, in `hegel/`) and `JnaLibhegel` (JNA, in
`hegel-jna/`), each selected by its module's `LibhegelBackend`. `LibraryLoader` resolves the
library (override / OS library path / jar-bundled native unpacked to a cache); `Engine` is the
process-wide lazy holder of the loaded shared object (with a test hook) — it caches only the
immutable, thread-safe library, never per-test state. Both bindings keep one libhegel error
context per thread (read back by `lastErrorMessage()`), pass date/time/datetime structs by
value, copy engine-allocated string/bytes buffers out and free them, and bridge the per-run
output callback (`hegel_output_callback_t`) to a `Consumer<String>` — an FFM upcall stub whose
arena lives until `runFree`, or a JNA `Callback` strongly referenced until `runFree`. In the
JNA binding, C `bool` crosses as `byte` (JNA's default boolean mapping is a 32-bit int) and
`size_t` as `long` (the bundled natives are all 64-bit). `Abi` holds the C constants.
- **Per-case primitives** — `DataSource` is the abstraction generators draw against;
`LiveDataSource` wraps the engine, translating return codes (`StopTest` → OVERRUN,
`AssumeRejected` → INVALID, `INVALID_ARG` → `IllegalArgumentException` with the engine's
Expand Down Expand Up @@ -97,7 +128,9 @@ public `Generator`/`TestCase`/`Generators`/`Hegel`/`Stateful` surface stays in `

## Coverage notes

A few genuinely-unreachable defensive blocks are excluded via `@Generated` (JaCoCo ignores
The 100% gate applies per module, so each backend jar carries its own binding-edge tests
(`RealLibhegelTest` + `FfmCoverageTest` for FFM, `JnaLibhegelTest` for JNA) on top of the shared
suites. A few genuinely-unreachable defensive blocks are excluded via `@Generated` (JaCoCo ignores
`*Generated*`-named annotations): the `NoSuchAlgorithmException` for SHA-256, the lookup of the
output-callback bridge method, and the `IllegalAccessException` after `setAccessible(true)`
succeeded in `Stateful`. Everything else is covered by real-engine integration tests plus
Expand Down
55 changes: 35 additions & 20 deletions .github/scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

ROOT = Path(__file__).resolve().parent.parent.parent
POM = ROOT / "pom.xml"
MODULE_POMS = [ROOT / "hegel" / "pom.xml", ROOT / "hegel-jna" / "pom.xml"]
PUBLISHED_ARTIFACTS = ["hegel", "hegel-jna"]

# Printed by central-publishing-maven-plugin once the bundle is on the portal. From that point
# the deployment validates and publishes server-side (autoPublish) no matter how the mvn
Expand All @@ -45,10 +47,13 @@ def git(*args: str) -> None:


def is_source_file(path: str) -> bool:
# A PR that changes published source (src/main/**) or the pom must carry a RELEASE.md;
# test-only and tooling changes don't. Mirrors the per-library source definition the other
# Hegel libraries use (which likewise exclude their test trees).
return path.startswith("src/main/") or path == "pom.xml"
# A PR that changes published source (any module's src/main/**) or a build pom must carry a
# RELEASE.md; test-only and tooling changes don't. Mirrors the per-library source definition
# the other Hegel libraries use (which likewise exclude their test trees).
return (
path.startswith(("shared/src/main/", "hegel/src/main/", "hegel-jna/src/main/"))
or path in ("pom.xml", "hegel/pom.xml", "hegel-jna/pom.xml")
)


def parse_release_file(path: Path) -> tuple[str, str]:
Expand Down Expand Up @@ -81,18 +86,22 @@ def bump_version(current: str, release_type: str) -> str:


def pom_version() -> str:
"""The last released version, read from the pom's project <version> (the first <version>
element; modelVersion uses a different tag). Seeded at ``0.0.0`` before the first release, so
the bootstrap ``RELEASE_TYPE: minor`` lands at ``0.1.0``."""
"""The last released version, read from the parent pom's project <version> (the first
<version> element; modelVersion uses a different tag). Seeded at ``0.0.0`` before the first
release, so the bootstrap ``RELEASE_TYPE: minor`` lands at ``0.1.0``."""
match = re.search(r"<version>([^<]+)</version>", POM.read_text())
if match is None:
raise ValueError("could not find <version> in pom.xml")
return match.group(1)


def set_pom_version(new_version: str) -> None:
text = POM.read_text()
POM.write_text(re.sub(r"<version>[^<]+</version>", f"<version>{new_version}</version>", text, count=1))
# The first <version> is the project version in the parent pom and the <parent> reference in
# each module pom (modelVersion uses a different tag, and modules declare no version of
# their own).
for pom in [POM, *MODULE_POMS]:
text = pom.read_text()
pom.write_text(re.sub(r"<version>[^<]+</version>", f"<version>{new_version}</version>", text, count=1))


def add_changelog(path: Path, *, version: str, content: str) -> None:
Expand Down Expand Up @@ -123,18 +132,24 @@ def run_deploy(mvn_args: list[str]) -> tuple[int, bool]:


def is_published(version: str) -> bool:
"""Ask the Central Publisher API whether dev.hegel:hegel:{version} is live on Maven Central.
Network and server errors count as "not (yet) published", so a transiently failing status
endpoint — the very thing being recovered from — just means polling again."""
"""Ask the Central Publisher API whether every published artifact (dev.hegel:hegel and
dev.hegel:hegel-jna) at {version} is live on Maven Central. Network and server errors count
as "not (yet) published", so a transiently failing status endpoint — the very thing being
recovered from — just means polling again."""
credentials = f"{os.environ['CENTRAL_TOKEN_USER']}:{os.environ['CENTRAL_TOKEN_PASS']}"
token = base64.b64encode(credentials.encode()).decode()
query = urllib.parse.urlencode({"namespace": "dev.hegel", "name": "hegel", "version": version})
request = urllib.request.Request(f"{CENTRAL_PUBLISHED_URL}?{query}", headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
return bool(json.load(response).get("published"))
except (urllib.error.URLError, TimeoutError, ValueError):
return False
for name in PUBLISHED_ARTIFACTS:
query = urllib.parse.urlencode({"namespace": "dev.hegel", "name": name, "version": version})
request = urllib.request.Request(
f"{CENTRAL_PUBLISHED_URL}?{query}", headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
if not json.load(response).get("published"):
return False
except (urllib.error.URLError, TimeoutError, ValueError):
return False
return True


def deploy_and_verify(mvn_args: list[str], version: str) -> None:
Expand Down Expand Up @@ -246,7 +261,7 @@ def release() -> None:
git("config", "user.name", f"{app_slug}[bot]")
git("config", "user.email", f"{bot_user_id}+{app_slug}[bot]@users.noreply.github.com")

git("add", "pom.xml", "CHANGELOG.md")
git("add", "pom.xml", "hegel/pom.xml", "hegel-jna/pom.xml", "CHANGELOG.md")
git("rm", "RELEASE.md")
git("commit", "-m", f"Bump to version {new_version} and update changelog\n\n[skip ci]")
git("tag", f"v{new_version}")
Expand Down
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: just coverage

test-jna:
name: "test hegel-jna (java ${{ matrix.java-version }})"
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
java-version: ["17", "21"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: ${{ matrix.java-version }}
cache: maven

- uses: ./.github/actions/install-tools
with:
tools: just

- name: Test hegel-jna with 100% coverage enforcement
env:
# Raises the GitHub API rate limit for fetch_natives.py asset discovery.
GITHUB_TOKEN: ${{ github.token }}
run: just coverage-jna

test-os:
name: "test (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -129,7 +159,7 @@ jobs:
release:
name: release
if: github.event_name == 'push' && github.repository == 'hegeldev/hegel-java'
needs: [lint, test, test-os, docs]
needs: [lint, test, test-jna, test-os, docs]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ target/
CLAUDE.local.md
.claude/settings.local.json

.vscode/

# whitelist .claude files, rather than blacklist. Some local claude files
# (skills, agents) have to live in .claude - as opposed to eg CLAUDE.local.md,
# which we gitignore at the top level.
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,21 @@ Instead of writing tests with hand-picked example inputs, you describe a *proper

## Installation

Hegel for Java ships as two interchangeable artifacts with the same API — pick the one that
matches your JVM:

- **`dev.hegel:hegel`** — requires **Java 22+**; binds the engine over the
[Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html)
with no extra dependencies.
- **`dev.hegel:hegel-jna`** — requires **Java 17+**; binds the engine over
[JNA](https://github.com/java-native-access/jna).

Add the dependency with Maven:

```xml
<dependency>
<groupId>dev.hegel</groupId>
<artifactId>hegel</artifactId>
<artifactId>hegel</artifactId> <!-- or "hegel-jna" for Java 17-21 -->
<version>0.1.0</version>
<scope>test</scope>
</dependency>
Expand All @@ -30,12 +39,17 @@ Add the dependency with Maven:
or with Gradle:

```kotlin
testImplementation("dev.hegel:hegel:0.1.0")
testImplementation("dev.hegel:hegel:0.1.0") // or "dev.hegel:hegel-jna:0.1.0"
```

Hegel for Java requires **Java 22+** and uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html). The native engine is bundled in the jar for Linux (x86-64 and arm64), macOS (Apple Silicon), and Windows (x86-64 and arm64).
Depend on exactly one of the two — they contain the same classes and differ only in how they call
the native engine. The engine is bundled in both jars for Linux (x86-64 and arm64), macOS (Apple
Silicon), and Windows (x86-64 and arm64).

Because Hegel calls native code, pass `--enable-native-access=ALL-UNNAMED` to silence the JVM's native-access warning. With Maven Surefire:
Because Hegel calls native code, pass `--enable-native-access=ALL-UNNAMED` to silence the JVM's
native-access warning — printed by JDK 22+ for `hegel` (FFM) and by JDK 24+ for `hegel-jna` (JNA,
under [JEP 472](https://openjdk.org/jeps/472)). The flag is accepted on every supported JDK
(17+), so it is safe to set unconditionally. With Maven Surefire:

```xml
<argLine>--enable-native-access=ALL-UNNAMED</argLine>
Expand Down
22 changes: 22 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
RELEASE_TYPE: patch

This patch adds a second published artifact, `dev.hegel:hegel-jna`, which binds the native engine
over [JNA](https://github.com/java-native-access/jna) and runs on Java 17+. The existing
`dev.hegel:hegel` artifact is unchanged: it binds over the Foreign Function and Memory API and
requires Java 22+.

Both artifacts expose the identical `dev.hegel` API and behave the same, so tests written against
one run unchanged against the other. Depend on exactly one of them — `hegel` on Java 22+, or
`hegel-jna` on older JVMs:

```xml
<dependency>
<groupId>dev.hegel</groupId>
<artifactId>hegel-jna</artifactId>
<version>0.5.1</version>
</dependency>
```

`hegel-jna` pulls in `net.java.dev.jna:jna` as its only dependency. On JDK 24+ pass
`--enable-native-access=ALL-UNNAMED` to silence the JVM's native-access warning (the flag is
accepted on every supported JDK).
Loading
Loading