diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index cdf93db..9f6c0aa 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e06bbaf..78a8e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/README.md b/README.md index 5442985..b0354c6 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..2230989 --- /dev/null +++ b/RELEASE.md @@ -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. diff --git a/justfile b/justfile index 3c21329..82bb96e 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/pom.xml b/pom.xml index 94574b2..51401b8 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,9 @@ false + + python3 --enable-native-access=ALL-UNNAMED @@ -175,7 +178,7 @@ ${hegel.natives.skip} - python3 + ${hegel.python} ${project.basedir}/scripts/fetch_natives.py --version @@ -290,6 +293,19 @@ + + + windows-python + + + windows + + + + python + + + release diff --git a/scripts/fetch_natives.py b/scripts/fetch_natives.py index f211d41..c1a1d33 100644 --- a/scripts/fetch_natives.py +++ b/scripts/fetch_natives.py @@ -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" diff --git a/src/main/java/dev/hegel/LibraryLoader.java b/src/main/java/dev/hegel/LibraryLoader.java index ce91a4c..d48996f 100644 --- a/src/main/java/dev/hegel/LibraryLoader.java +++ b/src/main/java/dev/hegel/LibraryLoader.java @@ -21,8 +21,11 @@ *
  • {@code $HEGEL_LIBHEGEL_PATH} — explicit override (e.g. for local engine development); if * set it must point at an existing file, otherwise resolution fails. *
  • 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. - *
  • 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. + *
  • 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). * * *

    The bundled libraries are placed on the classpath at build time (see {@code @@ -72,10 +75,11 @@ interface TempDirSupplier { */ static LibraryLoader fromEnvironment() { Map 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); } @@ -85,9 +89,26 @@ static InputStream classpathResource(String name) { return LibraryLoader.class.getClassLoader().getResourceAsStream(name); } - static Path defaultCacheDir(Map 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 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"); } @@ -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) { @@ -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"; } diff --git a/src/test/java/dev/hegel/LibraryLoaderTest.java b/src/test/java/dev/hegel/LibraryLoaderTest.java index 797c5d4..25b22db 100644 --- a/src/test/java/dev/hegel/LibraryLoaderTest.java +++ b/src/test/java/dev/hegel/LibraryLoaderTest.java @@ -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")); @@ -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( @@ -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 @@ -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 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);