Skip to content
Open
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
3 changes: 2 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ bump that property to ship a new engine. A user-supplied `$HEGEL_LIBHEGEL_PATH`
version triggers a warning against `BuildInfo.ENGINE_VERSION`.

`libhegel` resolves from `$HEGEL_LIBHEGEL_PATH` (explicit override), else the OS's standard
shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS), else the
shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS, `PATH` on
Windows), else the
native bundled in the jar for the host OS/arch (unpacked to a per-user cache; the cache is
best-effort — if it cannot be read or written, e.g. under a sandbox that denies writes to the user
cache dir, the native is extracted to a fresh directory under the system temp dir instead). The bundled libraries are fetched at build time by
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-14]
os: [macos-14, windows-2025]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ or with Gradle:
testImplementation("dev.hegel:hegel: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) and macOS (Apple Silicon).
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).

Because Hegel calls native code, pass `--enable-native-access=ALL-UNNAMED` to silence the JVM's native-access warning. With Maven Surefire:

Expand Down
5 changes: 5 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RELEASE_TYPE: patch

This patch adds Windows support (x86-64 and arm64). The jar now bundles the Windows engine alongside the Linux and macOS ones, so Hegel tests run on Windows with no extra setup.

On Windows, a `libhegel.dll` placed on `PATH` takes precedence over the bundled engine (matching `LD_LIBRARY_PATH` on Linux and `DYLD_LIBRARY_PATH` on macOS), and the bundled engine is unpacked to a per-user cache under `%LOCALAPPDATA%`. `HEGEL_LIBHEGEL_PATH` overrides both, as on every OS.
10 changes: 9 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
# Recipes assume a POSIX shell; on Windows run them under Git Bash.
set windows-shell := ["bash", "-uc"]

build-libhegel:
#!/usr/bin/env bash
set -euo pipefail
if [ -d ../hegel-rust ]; then
(cd ../hegel-rust && cargo build --release -p hegeltest-c)
case "$(uname -s)" in
Darwin*) lib=libhegel.dylib ;;
MINGW*|MSYS*|CYGWIN*) lib=hegel.dll ;; # cargo emits no lib prefix on Windows
*) lib=libhegel.so ;;
esac
echo "Built libhegel. Point the tests at it with:"
echo " export HEGEL_LIBHEGEL_PATH=$(cd ../hegel-rust && pwd)/target/release/libhegel.\$(uname -s | grep -qi darwin && echo dylib || echo so)"
echo " export HEGEL_LIBHEGEL_PATH=$(cd ../hegel-rust && pwd)/target/release/$lib"
else
echo "No sibling ../hegel-rust checkout; tests use the libhegel bundled in the jar."
fi
Expand Down
18 changes: 17 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
<!-- Set -Dhegel.natives.skip=true to skip the build-time native fetch (e.g. offline
local dev against a sibling hegel-rust checkout). -->
<hegel.natives.skip>false</hegel.natives.skip>
<!-- The Python interpreter used to run scripts/fetch_natives.py; the windows-python
profile overrides it, since Windows installs no python3 launcher. -->
<hegel.python>python3</hegel.python>
<!-- FFM requires native access; surefire passes this to the test JVM. -->
<hegel.argLine>--enable-native-access=ALL-UNNAMED</hegel.argLine>
</properties>
Expand Down Expand Up @@ -175,7 +178,7 @@
</goals>
<configuration>
<skip>${hegel.natives.skip}</skip>
<executable>python3</executable>
<executable>${hegel.python}</executable>
<arguments>
<argument>${project.basedir}/scripts/fetch_natives.py</argument>
<argument>--version</argument>
Expand Down Expand Up @@ -290,6 +293,19 @@
</build>

<profiles>
<!-- Windows ships python.exe but no python3 launcher. -->
<profile>
<id>windows-python</id>
<activation>
<os>
<family>windows</family>
</os>
</activation>
<properties>
<hegel.python>python</hegel.python>
</properties>
</profile>

<!-- Activated for publishing to Maven Central, via `mvn deploy -P release`. -->
<profile>
<id>release</id>
Expand Down
5 changes: 3 additions & 2 deletions scripts/fetch_natives.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
import urllib.request
from pathlib import Path

# Asset names look like ``libhegel-linux-amd64.so`` / ``libhegel-darwin-arm64.dylib``.
ASSET_RE = re.compile(r"^libhegel-([A-Za-z0-9]+)-([A-Za-z0-9]+)\.(so|dylib)$")
# Asset names look like ``libhegel-linux-amd64.so`` / ``libhegel-darwin-arm64.dylib`` /
# ``libhegel-windows-amd64.dll``.
ASSET_RE = re.compile(r"^libhegel-([A-Za-z0-9]+)-([A-Za-z0-9]+)\.(so|dylib|dll)$")
DEFAULT_REPO = "hegeldev/hegel-rust"


Expand Down
46 changes: 38 additions & 8 deletions src/main/java/dev/hegel/LibraryLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
* <li>{@code $HEGEL_LIBHEGEL_PATH} — explicit override (e.g. for local engine development); if
* set it must point at an existing file, otherwise resolution fails.
* <li>the OS's standard shared-library search path ({@code LD_LIBRARY_PATH} on Linux, {@code
* DYLD_LIBRARY_PATH} on macOS): the first directory containing the library file is used.
* <li>the native library bundled in the jar for this OS/arch, unpacked to a per-user cache.
* DYLD_LIBRARY_PATH} on macOS, {@code PATH} on Windows): the first directory containing the
* library file is used.
* <li>the native library bundled in the jar for this OS/arch, unpacked to a per-user cache
* ({@code $XDG_CACHE_HOME}/{@code ~/.cache} on Linux and macOS, {@code %LOCALAPPDATA%} on
* Windows).
* </ol>
*
* <p>The bundled libraries are placed on the classpath at build time (see {@code
Expand Down Expand Up @@ -72,10 +75,11 @@ interface TempDirSupplier {
*/
static LibraryLoader fromEnvironment() {
Map<String, String> env = System.getenv();
String os = mapOs(System.getProperty("os.name"));
return new LibraryLoader(
env,
defaultCacheDir(env),
mapOs(System.getProperty("os.name")),
defaultCacheDir(env, os),
os,
mapArch(System.getProperty("os.arch")),
LibraryLoader::classpathResource);
}
Expand All @@ -85,9 +89,26 @@ static InputStream classpathResource(String name) {
return LibraryLoader.class.getClassLoader().getResourceAsStream(name);
}

static Path defaultCacheDir(Map<String, String> env) {
/**
* The per-user cache directory for unpacked natives: {@code $XDG_CACHE_HOME} if set (an
* explicit override on every OS), else the idiomatic per-OS cache root — {@code
* %LOCALAPPDATA%} on Windows, {@code ~/.cache} elsewhere.
*/
static Path defaultCacheDir(Map<String, String> env, String os) {
String xdg = env.get("XDG_CACHE_HOME");
Path base = (xdg != null && !xdg.isEmpty()) ? Path.of(xdg) : Path.of(home(env), ".cache");
if (xdg != null && !xdg.isEmpty()) {
return cacheSubdir(Path.of(xdg));
}
if (os.equals("windows")) {
String localAppData = env.get("LOCALAPPDATA");
if (localAppData != null && !localAppData.isEmpty()) {
return cacheSubdir(Path.of(localAppData));
}
}
return cacheSubdir(Path.of(home(env), ".cache"));
}

private static Path cacheSubdir(Path base) {
return base.resolve("hegel-java").resolve("libhegel");
}

Expand All @@ -104,8 +125,11 @@ static String mapOs(String osName) {
if (os.contains("linux")) {
return "linux";
}
if (os.contains("windows")) {
return "windows";
}
throw new HegelException(
"libhegel does not support this operating system: '" + osName + "' (linux/macOS only).");
"libhegel does not support this operating system: '" + osName + "' (Linux, macOS, and Windows only).");
}

static String mapArch(String osArch) {
Expand All @@ -120,16 +144,22 @@ static String mapArch(String osArch) {
}

private String libExt() {
if (os.equals("windows")) {
return "dll";
}
return os.equals("darwin") ? "dylib" : "so";
}

/** The shared-library file name for this OS (e.g. {@code libhegel.so}). */
/** The shared-library file name for this OS (e.g. {@code libhegel.so}, {@code libhegel.dll}). */
private String libFileName() {
return "libhegel." + libExt();
}

/** The OS's conventional shared-library search-path environment variable. */
private String libraryPathVar() {
if (os.equals("windows")) {
return "PATH";
}
return os.equals("darwin") ? "DYLD_LIBRARY_PATH" : "LD_LIBRARY_PATH";
}

Expand Down
49 changes: 43 additions & 6 deletions src/test/java/dev/hegel/LibraryLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ void mapOsAndArch() {
assertEquals("linux", LibraryLoader.mapOs("Linux"));
assertEquals("darwin", LibraryLoader.mapOs("Mac OS X"));
assertEquals("darwin", LibraryLoader.mapOs("Darwin"));
assertThrows(HegelException.class, () -> LibraryLoader.mapOs("Windows 11"));
assertEquals("windows", LibraryLoader.mapOs("Windows 11"));
assertThrows(HegelException.class, () -> LibraryLoader.mapOs("FreeBSD"));

assertEquals("amd64", LibraryLoader.mapArch("amd64"));
assertEquals("amd64", LibraryLoader.mapArch("x86_64"));
Expand All @@ -54,23 +55,47 @@ void mapOsAndArch() {
@Test
void defaultCacheDirHonoursXdgThenHome() {
assertEquals(
Path.of("/xdg/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg")));
assertEquals(Path.of("/h/.cache/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("HOME", "/h")));
Path.of("/xdg/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg"), "linux"));
assertEquals(
Path.of("/h/.cache/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("HOME", "/h"), "linux"));
}

@Test
void cacheDirAndHomeEdgeCases() {
assertEquals(
Path.of("/h/.cache/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "", "HOME", "/h")));
LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "", "HOME", "/h"), "linux"));
// No HOME and no XDG falls back to the user.home system property.
Path d = LibraryLoader.defaultCacheDir(Map.of());
Path d = LibraryLoader.defaultCacheDir(Map.of(), "linux");
assertTrue(d.endsWith(Path.of("hegel-java/libhegel")));
// Empty HOME also falls back to user.home.
Path d2 = LibraryLoader.defaultCacheDir(Map.of("HOME", ""));
Path d2 = LibraryLoader.defaultCacheDir(Map.of("HOME", ""), "linux");
assertTrue(d2.endsWith(Path.of("hegel-java/libhegel")));
}

@Test
void windowsCacheDirPrefersLocalAppData() {
assertEquals(
Path.of("/lad/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "/lad", "HOME", "/h"), "windows"));
// XDG_CACHE_HOME is an explicit override on every OS, Windows included.
assertEquals(
Path.of("/xdg/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg", "LOCALAPPDATA", "/lad"), "windows"));
// Unset or empty LOCALAPPDATA falls back to the POSIX-style default.
assertEquals(
Path.of("/h/.cache/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("HOME", "/h"), "windows"));
assertEquals(
Path.of("/h/.cache/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "", "HOME", "/h"), "windows"));
// LOCALAPPDATA is ignored off-Windows.
assertEquals(
Path.of("/h/.cache/hegel-java/libhegel"),
LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "/lad", "HOME", "/h"), "linux"));
}

@Test
void sha256OfEmptyInput() {
assertEquals(
Expand All @@ -84,6 +109,8 @@ void resourcePathPerPlatform(@TempDir Path dir) {
assertEquals("native/linux-amd64/libhegel.so", linux.resourcePath());
LibraryLoader darwin = new LibraryLoader(Map.of(), dir, "darwin", "arm64", NO_RESOURCES);
assertEquals("native/darwin-arm64/libhegel.dylib", darwin.resourcePath());
LibraryLoader windows = new LibraryLoader(Map.of(), dir, "windows", "amd64", NO_RESOURCES);
assertEquals("native/windows-amd64/libhegel.dll", windows.resourcePath());
}

@Test
Expand Down Expand Up @@ -167,6 +194,16 @@ void darwinSearchesDyldLibraryPath(@TempDir Path dir) throws IOException {
assertEquals(lib, l.resolve());
}

@Test
void windowsSearchesPath(@TempDir Path dir) throws IOException {
Path libDir = Files.createDirectories(dir.resolve("libs"));
Path lib = libDir.resolve("libhegel.dll");
Files.writeString(lib, "win-lib");
Map<String, String> env = Map.of("PATH", libDir.toString());
LibraryLoader l = new LibraryLoader(new HashMap<>(env), dir.resolve("cache"), "windows", "amd64", NO_RESOURCES);
assertEquals(lib, l.resolve());
}

@Test
void bundledNativeUnpackedAndCached(@TempDir Path dir) throws IOException {
byte[] payload = "ELF-ish-bytes".getBytes(StandardCharsets.UTF_8);
Expand Down