Java
Galley-generated parsers can be consumed from Java through Panama FFI (Java 22+): Galley compiles a generated parser into a shared library (lib<name>.dylib / .so) together with the C header bindings/c/galley.h, and the Java bindings (java.lang.foreign) wrap that API in a safe, typed surface (sessions, node handles, structured diagnostics, tree editing) with no third-party runtime.
The bindings live in bindings/java. A complete, runnable consumer lives in examples/java; it is built and executed by CI on every push, byte-for-byte identical in output to the Python, Go, Rust, and TypeScript examples.
Getting Started
Requires java ≥ 22, javac, and zig 0.16 (git is only needed by examples/scripts/fetch-galley.sh).
Build the bindings (no Maven, no JNA):
javac --release 22 -d bindings/java/out $(find bindings/java/src/main/java -name "*.java")Then generate and build the shared library for your language directory (a directory containing ll.grm and config.zig):
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out org.sanbus.galley.build.GalleyBuild <language-dir> [generator flags...]Generator flags forward verbatim to the generator ahead of --emit-metadata: every flag the tool does not own goes to the generator, which owns its surface (documented in Configuration).
The tool requires GALLEY_CHECKOUT (a Galley working tree); ZIG_EXECUTABLE selects zig. For convenience, GALLEY_CHECKOUT=$(examples/scripts/fetch-galley.sh) fetches one into the system cache, but that cache is examples-only, not part of the bindings. It generates the parser (--emit-metadata), builds the shared library through bindings/c/consumer/build.zig directly next to the grammar, and detects optional hook files next to your grammar (procedures.java for native Java hooks, procedures.c for legacy C hooks, ll_error_messages.zig). Regenerate after changing the grammar; commit nothing it generates. One library embeds one parser — split grammars across language directories exactly like the other bindings.
Run the demo:
javac --release 22 -d bindings/java/out $(find bindings/java/src/main/java -name "*.java")
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out org.sanbus.galley.build.GalleyBuild examples/java
javac --release 22 -cp bindings/java/out -d examples/java/out $(find examples/java/src/main/java -name "*.java")
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out:examples/java/out com.example.Demo
# With file argument
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out:examples/java/out com.example.Demo path/to/file
# Benchmark (no AST/procedures/recovery)
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out org.sanbus.galley.build.GalleyBuild examples/java/benchmark
java --enable-native-access=ALL-UNNAMED -cp bindings/java/out:examples/java/out com.example.BenchmarkPass the built file with SessionOptions.libraryPath, or name it once with GALLEY_LIBRARY_PATH (or -Dgalley.library.path). Nothing is searched: a missing file is a loud error naming the exact path.
Performance Notes
The Panama FFI boundary is the only overhead over the C API:
- Every method is a direct
Linker.downcallHandle/MemorySegmentcall; no JSON or subprocess marshalling. Sessions are not thread-safe — keep one per thread or guard it externally. - Node handles are
Nodeobjects that wrap a stable address in the library's non-relocating node storage and keep a strong reference to their owningSession; plainlongaddresses are also accepted wherever a node is expected. Iteration and indexing are zero-copy (for (Node child : session.children(root)),root.children(),node.length). - Text accessors (
text,symbolName, diagnostic tokens) returnbyte[]copies with no UTF-8 decoding; decode on demand. parse(byte[])allocates a confinedArenaper call (arena.allocateFrom(ValueLayout.JAVA_BYTE, input)) — no cachedMemory; directByteBufferis zero-copy viaMemorySegment.ofBuffer(no allocation, no copy). UseFileChannel→allocateDirect→flip()→rewind()before eachparsefor benchmark-grade throughput, mirroring Go'sunsafe.Pointer(&input[0]), Rust'sas_ptr(), and Python'sPyBytes_AS_STRING. HeapByteBuffercopies viaArenalikebyte[].parse(String)encodes to UTF-8 once per call (String.getBytes(UTF_8)). The session copies into its own storage so node text stays valid after return.
Node text, diagnostics, and expected-token data remain valid only until the next parse on the same session; every accessor copies before returning. Node methods check that their session is still open and throw after session.close().
Procedures
Set pub const procedures = true; in your grammar's config.zig and implement the hooks in Java in a procedures.java file next to your grammar — ordinary Java registered at runtime into the generated shim:
// procedures.java
import org.sanbus.galley.*;
public final class procedures {
public static void reduction_Pair(ProcedureArguments args) {
Node node = args.currentNode();
if (node == null) return;
byte[] text = node.text();
int[] pos = node.lineColumn();
System.err.printf("Pair %s (%d children) at %d:%d%n",
new String(text), node.length(), pos[0], pos[1]);
}
public static void reduction_KeyTail(ProcedureArguments args) {
args.dropIfEmpty();
}
public static void hook_print(ProcedureArguments args) {
Node node = args.currentNode();
if (node == null) return;
int[] pos = node.lineColumn();
System.err.printf("@print \"%s\" at %d:%d%n",
new String(node.text()), pos[0], pos[1]);
}
public static void register() {
Procedures.installProcedure("reduction_Pair", procedures::reduction_Pair);
Procedures.installProcedure("reduction_KeyTail", procedures::reduction_KeyTail);
Procedures.installProcedure("hook_print", procedures::hook_print);
}
}Then register before parsing:
procedures.register(); // or manually
Procedures.installProcedure("reduction_Pair", args -> {
Node n = args.currentNode();
System.err.println(new String(n.text()));
});Mechanically, the build tool reads the generator's hook list (procedures in metadata.json) and produces a Zig shim (procedures_java.zig) containing one dispatch slot per hook; the JVM registers each Java hook address into that single slot at Procedures.installProcedure time (via JNA galley_install_java_dispatch). The parser calls through the slot directly, so hook code executes in the host's JVM. Unregistered slots are no-ops. Reduction hooks keep their reduction_<VariableName> names (plus the general reduction); author-defined grammar hooks are declared as hook_<name>. Semantic payloads are unavailable through bindings.
You can also bulk-register from a map or object:
Map<String, Consumer<ProcedureArguments>> map = Map.of(
"reduction_Pair", args -> {},
"hook_print", args -> {}
);
Procedures.installProcedures(map);
Procedures.listProcedures(); // Map<String, Consumer>
Procedures.clearProcedures();Legacy procedures.c / procedures.cpp hooks continue to work exactly like the C/C++ consumers: the build compiles the C file into the shared library when no procedures.java is present. If both Java and C files exist, Java takes precedence and a warning is emitted.
Semantic Errors
A hook reports a semantic error when the input parses but its meaning is invalid. reportSemanticError records the diagnostic, marks the node, and returns the running total so hooks can limit themselves. Parsing continues; a syntax-clean parse with any semantic error throws GalleyException with code -12:
if (Long.parseLong(text) > 999) {
args.reportSemanticError("value out of range");
}Read them through session.diagnostic() / session.diagnostics(); the snapshot carries getKind() == Diagnostic.KIND_SEMANTIC and getSemantic() returning [variable, message].
Tree Walking
session.walk(node, skipSemanticErrors) returns a pre-order Walker over the last successful parse, yielding one Walker.WalkStep{node, depth, isSemanticError} per step with the root at depth 0 — the shared runtime walker, so order and depths match every other binding. Walker is Iterable and AutoCloseable: close it (try-with-resources) before closing the session or parsing again. skipChildren() prunes the last yielded node's children:
try (Walker walker = session.walk(session.rootNode(), false)) {
for (Walker.WalkStep step : walker) {
System.err.println(" ".repeat(step.depth) + new String(session.symbolName(step.node)));
}
}Error Messages
To replace messages with fixed strings — no Zig file at all — pass messageOverrides in the session options. Keys are structured identities: the innermost in-progress variable name (for example "Number"), or "*" for every syntax and indentation error. Variable keys win over "*", and overrides take priority over hooks. Placeholders expand against the failing diagnostic:
SessionOptions opts = SessionOptions.builder()
.messageOverride("Number", "expected a number after ':' (digits only) at line {line}")
.build();
try (Session s = new Session(opts)) { ... }
// Or per-session:
s.setMessageOverride("Number", "expected a number after ':' (digits only) at line {line}");Override messages may contain {line}, {column}, {unexpected}, {expected}, and {context} placeholders, expanded against the failing diagnostic. Run galley --fill-error-messages <language-dir> and edit the generated ll_error_messages.zig next to your grammar for Zig-level hooks instead; the build detects it and compiles it into the shared library.
Sessions
try (Session session = new Session(SessionOptions.builder()
.maxErrors(10)
.recoveryWindow(500)
.build())) {
int parsed = session.parse("alpha:12,beta:3");
Node root = session.rootNode();
if (root != null) {
for (Node child : session.children(root)) {
System.out.println(new String(session.symbolName(child)));
System.out.println(" → " + new String(session.text(child)));
}
// Node convenience: root.children(), root.text(), etc.
for (Node child : root) {
System.out.println(child.symbolNameString() + " " + new String(child.text()));
}
}
// Zero-copy direct buffer for throughput-sensitive paths (benchmarks):
// ByteBuffer buf = ByteBuffer.allocateDirect(data.length);
// buf.put(data).flip();
// for (int i = 0; i < iterations; i++) { buf.rewind(); session.parse(buf); }
// Heap ByteBuffer and byte[] reuse the same cached native Memory.
} catch (GalleyException e) {
Diagnostic d = e.getDiagnostic();
if (d != null) System.err.println(d.getLine() + ":" + d.getColumn() + " " + d.getMessage());
}
// Or inspect after catch:
// if (session.hasDiagnostic()) { Diagnostic d = session.diagnostic(); }Parsing entry points: parse(byte[]), parse(ByteBuffer), parse(String), parseSentinel(String|byte[]|ByteBuffer), and parseFile(String|File). ByteBuffer honors position/remaining/arrayOffset; direct buffers are passed by pointer with Reference.reachabilityFence.
Sessions own their IO backend and allocator and are not safe for concurrent use — keep one per thread or guard it externally. Node handles, text slices, and diagnostics remain valid until the next parse on the same session or close(). try (Session s = new Session()) is the idiomatic close pattern (s.close() is idempotent).
Tree editing follows the same address-stable model as the C API: addresses never invalidate across edits.
Node head = session.cleanChildren(root);
session.appendChildren(root, head);
// Node wrappers:
Node head2 = root.cleanChildren();
root.appendChildren(head2);Module Queries
Galley.version(); // String
Galley.parserType(); // PARSER_TYPE_LL / PARSER_TYPE_LR
Galley.hasAst(); // boolean
Galley.hasProcedures();
Galley.errorRecoveryMode(); // RECOVERY_MODE_*
Galley.statusString(-2); // "syntax error" / null
Galley.symbolCount(); // long
Galley.variableCount();
session.symbolNameAt(0); // byte[] / null
session.symbolIsTerminal(0);
session.variableNameAt(0);Development builds
Every green CI run uploads the built jar as a workflow artifact (Actions → the run → Artifacts → pkg-java). Download it and depend on it like any Central release. Versioned releases go to Maven Central as usual.
Related Pages
- C and C++ — the underlying C ABI
- Rust — bindings over the same shared library
- Go — cgo bindings over the same shared library
- Python — Python bindings over the same shared library
- JavaScript — FFI bindings over the same shared library
- Configuration — the config.zig contract
- Grammar Guidelines