diff --git a/.envrc b/.envrc
index d4a7ef2b6..284677508 100644
--- a/.envrc
+++ b/.envrc
@@ -4,3 +4,4 @@ export PATH="$PWD/javascript/packages/formatter/bin:$PATH"
export PATH="$PWD/javascript/packages/language-server/bin:$PATH"
export PATH="$PWD/javascript/packages/highlighter/bin:$PATH"
export PATH="$PWD/javascript/packages/stimulus-lint/bin:$PATH"
+export PATH="$PWD/java/bin:$PATH"
diff --git a/.gitattributes b/.gitattributes
index 808330fe7..17cf88579 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,8 +1,11 @@
# Templates
templates/**/*.c.erb linguist-language=C
+templates/**/*.cpp.erb linguist-language=C++
templates/**/*.h.erb linguist-language=C
+templates/**/*.java.erb linguist-language=Java
+templates/**/*.js.erb linguist-language=JavaScript
templates/**/*.rb.erb linguist-language=Ruby
-templates/**/*.cpp.erb linguist-language=C++
+templates/**/*.ts.erb linguist-language=TypeScript
# Template-generated RBS files
sig/**/*.rbs linguist-generated
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 99b5e021b..55f91577d 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -36,6 +36,12 @@ typescript:
- '**/javascript/**/*.ts'
- '**/javascript/**/*.ts.erb'
+java:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '**/*.java'
+ - '**/*.java.erb'
+
ruby:
- changed-files:
- any-glob-to-any-file:
diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml
new file mode 100644
index 000000000..807c5896d
--- /dev/null
+++ b/.github/workflows/java.yml
@@ -0,0 +1,53 @@
+name: Java
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Render Templates
+ run: bundle exec rake templates
+
+ - name: Compile Herb
+ run: bundle exec rake make
+
+ - name: Build JNI library
+ run: make jni
+ working-directory: java
+
+ - name: Compile Java classes
+ run: make java
+ working-directory: java
+
+ - name: Run tests
+ run: ./run_tests.sh
+ working-directory: java
+
+ - name: Test CLI version command
+ run: ./bin/herb-java version
+ working-directory: java
diff --git a/.gitignore b/.gitignore
index 8261787ab..61604e4c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,9 +89,17 @@ ext/herb/error_helpers.c
ext/herb/error_helpers.h
ext/herb/nodes.c
ext/herb/nodes.h
+java/error_helpers.c
+java/error_helpers.h
+java/nodes.c
+java/nodes.h
+java/org/herb/ast/Errors.java
+java/org/herb/ast/Nodes.java
+java/org/herb/ast/NodeVisitor.java
+java/org/herb/ast/Visitor.java
javascript/packages/core/src/errors.ts
-javascript/packages/core/src/nodes.ts
javascript/packages/core/src/node-type-guards.ts
+javascript/packages/core/src/nodes.ts
javascript/packages/core/src/visitor.ts
javascript/packages/node/extension/error_helpers.cpp
javascript/packages/node/extension/error_helpers.h
@@ -100,8 +108,8 @@ javascript/packages/node/extension/nodes.h
lib/herb/ast/nodes.rb
lib/herb/errors.rb
lib/herb/visitor.rb
-sig/serialized_ast_nodes.rbs
sig/serialized_ast_errors.rbs
+sig/serialized_ast_nodes.rbs
src/ast_nodes.c
src/ast_pretty_print.c
src/errors.c
@@ -114,6 +122,10 @@ wasm/error_helpers.h
wasm/nodes.cpp
wasm/nodes.h
+# Java Build Artifacts
+java/target/
+java/.java_compiled
+
# NX Monorepo
.nx/
.nx/cache
diff --git a/docs/.vitepress/config/theme.mts b/docs/.vitepress/config/theme.mts
index a36754b6f..0a19e2f10 100644
--- a/docs/.vitepress/config/theme.mts
+++ b/docs/.vitepress/config/theme.mts
@@ -90,6 +90,14 @@ const defaultSidebar = [
{ text: "Reference", link: "/bindings/javascript/reference" },
],
},
+ {
+ text: "Java",
+ collapsed: false,
+ items: [
+ { text: "Installation", link: "/bindings/java/" },
+ { text: "Reference", link: "/bindings/java/reference" },
+ ],
+ },
{ text: "WebAssembly", link: "/projects/webassembly" },
],
},
diff --git a/docs/docs/bindings/java/index.md b/docs/docs/bindings/java/index.md
new file mode 100644
index 000000000..12322d281
--- /dev/null
+++ b/docs/docs/bindings/java/index.md
@@ -0,0 +1,93 @@
+---
+outline: deep
+---
+
+# Herb Java Bindings
+
+Herb provides official Java bindings through JNI (Java Native Interface) to the C library, allowing you to parse HTML+ERB in Java projects with native performance.
+
+> [!TIP] More Language Bindings
+> Herb also has bindings for:
+> - [Ruby](/bindings/ruby/)
+> - [JavaScript/Node.js](/bindings/javascript/)
+
+## Installation
+
+### Prerequisites
+
+Ensure you have Java installed:
+
+:::code-group
+```shell
+java -version
+```
+:::
+
+### Build from Source
+
+Clone the repository and build the Java bindings:
+
+:::code-group
+```shell
+git clone https://github.com/your-org/herb.git
+cd herb/java
+make templates
+make jni
+make java
+```
+:::
+
+This creates the native library (`libherb_jni.dylib` on macOS, `.so` on Linux).
+
+### Setting Up Your Project
+
+Add the compiled classes to your classpath and ensure the native library is in your `java.library.path`.
+
+## Getting Started
+
+### Basic Example
+
+Here's a simple example of parsing HTML+ERB:
+
+:::code-group
+```java
+import org.herb.Herb;
+import org.herb.ParseResult;
+
+public class Example {
+ public static void main(String[] args) {
+ String source = "
<%= user.name %>
";
+
+ ParseResult result = Herb.parse(source);
+
+ if (result.getValue() != null) {
+ System.out.println(result.getValue().treeInspect());
+ }
+ }
+}
+```
+:::
+
+### Lexing Example
+
+You can also tokenize HTML+ERB source:
+
+:::code-group
+```java
+import org.herb.Herb;
+import org.herb.LexResult;
+import org.herb.Token;
+
+public class LexExample {
+ public static void main(String[] args) {
+ String source = "<%= user.name %>
";
+
+ LexResult result = Herb.lex(source);
+
+ for (Token token : result.getTokens()) {
+ System.out.println(token.inspect());
+ }
+ }
+}
+```
+:::
diff --git a/docs/docs/bindings/java/reference.md b/docs/docs/bindings/java/reference.md
new file mode 100644
index 000000000..76006619c
--- /dev/null
+++ b/docs/docs/bindings/java/reference.md
@@ -0,0 +1,322 @@
+---
+outline: deep
+---
+
+# Java Reference
+
+The `org.herb` package exposes classes for lexing, parsing, and extracting Ruby and HTML from HTML+ERB source code through JNI.
+
+## Java API
+
+The `Herb` class provides the following static methods:
+
+* `Herb.lex(source)`
+* `Herb.parse(source)`
+* `Herb.parse(source, options)`
+* `Herb.extractRuby(source)`
+* `Herb.extractHTML(source)`
+* `Herb.version()`
+* `Herb.herbVersion()`
+* `Herb.prismVersion()`
+
+## Lexing
+
+The `lex` method tokenizes an HTML document with embedded Ruby and returns a `LexResult` containing all tokens.
+
+### `Herb.lex(String source)`
+
+:::code-group
+```java
+import org.herb.Herb;
+import org.herb.LexResult;
+import org.herb.Token;
+
+String source = "Hello <%= user.name %>
";
+LexResult result = Herb.lex(source);
+
+for (Token token : result.getTokens()) {
+ System.out.println(token.inspect());
+}
+// Output:
+// #
+// #
+// #
+// ...
+```
+:::
+
+### `LexResult`
+
+The `LexResult` class provides access to the lexed tokens:
+
+```java
+public class LexResult {
+ public List getTokens();
+ public String getSource();
+ public int getTokenCount();
+ public boolean isEmpty();
+}
+```
+
+## Parsing
+
+The `parse` method parses an HTML document with embedded Ruby and returns a `ParseResult` containing the parsed AST.
+
+### `Herb.parse(String source)`
+
+:::code-group
+```java
+import org.herb.Herb;
+import org.herb.ParseResult;
+
+String source = "Hello <%= user.name %>
";
+
+ParseResult result = Herb.parse(source);
+
+if (result.getValue() != null) {
+ System.out.println(result.getValue().treeInspect());
+}
+// Output:
+// @ DocumentNode (location: (1:0)-(1:29))
+// └── children: (1 item)
+// └── @ HTMLElementNode (location: (1:0)-(1:29))
+// ├── open_tag:
+// │ └── @ HTMLOpenTagNode (location: (1:0)-(1:3))
+// │ ├── tag_opening: "<" (location: (1:0)-(1:1))
+// │ ├── tag_name: "p" (location: (1:1)-(1:2))
+// │ ├── tag_closing: ">" (location: (1:2)-(1:3))
+// │ ├── children: []
+// │ └── is_void: false
+// │
+// ├── tag_name: "p" (location: (1:1)-(1:2))
+// ├── body: (2 items)
+// │ ├── @ HTMLTextNode (location: (1:3)-(1:9))
+// │ │ └── content: "Hello "
+// │ │
+// │ └── @ ERBContentNode (location: (1:9)-(1:25))
+// │ ├── tag_opening: "<%=" (location: (1:9)-(1:12))
+// │ ├── content: " user.name " (location: (1:12)-(1:23))
+// │ ├── tag_closing: "%>" (location: (1:23)-(1:25))
+// │ ├── parsed: false
+// │ └── valid: false
+// │
+// ├── close_tag:
+// │ └── @ HTMLCloseTagNode (location: (1:25)-(1:29))
+// │ ├── tag_opening: "" (location: (1:25)-(1:27))
+// │ ├── tag_name: "p" (location: (1:27)-(1:28))
+// │ ├── children: []
+// │ └── tag_closing: ">" (location: (1:28)-(1:29))
+// │
+// ├── is_void: false
+// └── source: ""
+```
+:::
+
+### `Herb.parse(String source, ParserOptions options)`
+
+Parse with custom options:
+
+:::code-group
+```java
+import org.herb.Herb;
+import org.herb.ParseResult;
+import org.herb.ParserOptions;
+
+ParserOptions options = new ParserOptions();
+ParseResult result = Herb.parse(source, options);
+```
+:::
+
+### `ParseResult`
+
+The `ParseResult` class provides access to the parsed AST and any errors:
+
+```java
+public class ParseResult {
+ public Node getValue();
+ public List getErrors();
+ public String getSource();
+ public boolean hasErrors();
+ public int getErrorCount();
+ public boolean isSuccess();
+}
+```
+
+## Extracting Code
+
+### `Herb.extractRuby(String source)`
+
+The `extractRuby` method extracts only the Ruby parts of an HTML document with embedded Ruby.
+
+:::code-group
+```java
+import org.herb.Herb;
+
+String source = "Hello <%= user.name %>
";
+
+String ruby = Herb.extractRuby(source);
+System.out.println(ruby);
+// Output: " user.name "
+```
+:::
+
+### `Herb.extractHTML(String source)`
+
+The `extractHTML` method extracts only the HTML parts of an HTML document with embedded Ruby.
+
+:::code-group
+```java
+import org.herb.Herb;
+
+String source = "Hello <%= user.name %>
";
+
+String html = Herb.extractHTML(source);
+System.out.println(html);
+// Output: "Hello
"
+```
+:::
+
+## Version Information
+
+### `Herb.version()`
+
+Returns the full version information including Herb, Prism, and JNI details:
+
+:::code-group
+```java
+import org.herb.Herb;
+
+System.out.println(Herb.version());
+// Output: "herb java v0.7.5, libprism v1.6.0, libherb v0.7.5 (Java JNI)"
+```
+:::
+
+### `Herb.herbVersion()`
+
+Returns just the Herb library version:
+
+:::code-group
+```java
+import org.herb.Herb;
+
+System.out.println(Herb.herbVersion());
+// Output: "0.7.5"
+```
+:::
+
+### `Herb.prismVersion()`
+
+Returns the Prism parser version:
+
+:::code-group
+```java
+import org.herb.Herb;
+
+System.out.println(Herb.prismVersion());
+// Output: "1.6.0"
+```
+:::
+
+## Core Types
+
+### Position
+
+Represents a position in the source code:
+
+```java
+public class Position {
+ public int getLine();
+ public int getColumn();
+ public String inspect();
+}
+```
+
+### Location
+
+Represents a location span in the source:
+
+```java
+public class Location {
+ public Position getStart();
+ public Position getEnd();
+}
+```
+
+### Range
+
+Represents a byte range in the source:
+
+```java
+public class Range {
+ public int getStart();
+ public int getEnd();
+ public String inspect();
+}
+```
+
+### Token
+
+Represents a token from lexing:
+
+```java
+public class Token {
+ public String getType();
+ public String getValue();
+ public Location getLocation();
+ public Range getRange();
+ public String inspect();
+ public String treeInspect();
+}
+```
+
+## AST Node Types
+
+All AST nodes implement the `Node` interface:
+
+```java
+public interface Node {
+ String getNodeType();
+ Location getLocation();
+ List getErrors();
+ String treeInspect();
+ T accept(Visitor visitor);
+}
+```
+
+### Error Handling
+
+Parse errors are accessible through the `ParseResult`:
+
+```java
+ParseResult result = Herb.parse(source);
+
+if (result.hasErrors()) {
+ for (Node error : result.getErrors()) {
+ System.out.println(error.treeInspect());
+ }
+}
+```
+
+## Visitor Pattern
+
+The Java bindings support the visitor pattern for traversing the AST:
+
+```java
+import org.herb.ast.Visitor;
+import org.herb.ast.Node;
+
+public class MyVisitor implements Visitor {
+ @Override
+ public Void visitHTMLElementNode(HTMLElementNode node) {
+ System.out.println("Found HTML element: " + node.getTagName());
+ return null;
+ }
+
+ // Implement other visit methods...
+}
+
+ParseResult result = Herb.parse(source);
+Node root = result.getValue();
+MyVisitor visitor = new MyVisitor();
+root.accept(visitor);
+```
diff --git a/docs/docs/bindings/javascript/index.md b/docs/docs/bindings/javascript/index.md
index 9e9792f8a..9447ffc67 100644
--- a/docs/docs/bindings/javascript/index.md
+++ b/docs/docs/bindings/javascript/index.md
@@ -5,7 +5,9 @@ Herb provides official JavaScript bindings as NPM packages, published under the
Herb supports both **browser** and **Node.js** environments with separate packages, ensuring optimized compatibility for each platform.
> [!TIP] More Language Bindings
-> Herb also has [bindings for Ruby](/bindings/ruby/)
+> Herb also has bindings for:
+> - [Ruby](/bindings/ruby/)
+> - [Java](/bindings/java/)
## Installation
diff --git a/docs/docs/bindings/ruby/index.md b/docs/docs/bindings/ruby/index.md
index 68ba75d7b..296994173 100644
--- a/docs/docs/bindings/ruby/index.md
+++ b/docs/docs/bindings/ruby/index.md
@@ -7,7 +7,9 @@ outline: deep
Herb is bundled and packaged up as a precompiled RubyGem and available to be installed from [RubyGems.org](https://rubygems.org).
> [!TIP] More Language Bindings
-> Herb also has [bindings for JavaScript/Node.js](/bindings/javascript/)
+> Herb also has bindings for:
+> - [JavaScript/Node.js](/bindings/javascript/)
+> - [Java](/bindings/java/)
## Installation
diff --git a/java/Makefile b/java/Makefile
new file mode 100644
index 000000000..6a883fd10
--- /dev/null
+++ b/java/Makefile
@@ -0,0 +1,103 @@
+# Java JNI Makefile for Herb
+
+JAVA_HOME ?= $(shell /usr/libexec/java_home 2>/dev/null || dirname $$(dirname $$(which java)))
+JNI_INCLUDES = -I$(JAVA_HOME)/include
+
+os := $(shell uname -s)
+
+ifeq ($(os),Darwin)
+ JNI_INCLUDES += -I$(JAVA_HOME)/include/darwin
+ JNI_LIB_EXT = dylib
+ JNI_LIB_PREFIX = lib
+ LLVM_PATH = $(shell brew --prefix llvm@21)
+ CC = $(LLVM_PATH)/bin/clang
+else ifeq ($(os),Linux)
+ JNI_INCLUDES += -I$(JAVA_HOME)/include/linux
+ JNI_LIB_EXT = so
+ JNI_LIB_PREFIX = lib
+ CC = clang
+else
+ JNI_LIB_EXT = dll
+ JNI_LIB_PREFIX =
+ CC = clang
+endif
+
+BUILD_DIR = ../build
+SRC_DIR = ../src
+PRISM_PATH = $(shell cd .. && bundle show prism)
+PRISM_INCLUDE = $(PRISM_PATH)/include
+PRISM_BUILD = $(PRISM_PATH)/build
+
+JAVAC = $(JAVA_HOME)/bin/javac
+JAVA_CMD = $(JAVA_HOME)/bin/java
+CFLAGS = -std=c99 -Wall -Wextra -fPIC -O2
+INCLUDES = -I. -I$(SRC_DIR)/include -I$(PRISM_INCLUDE) $(JNI_INCLUDES)
+LDFLAGS = -shared
+LIBS = $(PRISM_BUILD)/libprism.a
+
+HERB_SOURCES = $(wildcard $(SRC_DIR)/*.c) $(wildcard $(SRC_DIR)/**/*.c)
+HERB_OBJECTS = $(filter-out $(SRC_DIR)/main.o, $(HERB_SOURCES:.c=.o))
+
+JNI_SOURCES = herb_jni.c extension_helpers.c
+JNI_GENERATED_SOURCES = nodes.c error_helpers.c
+
+JNI_OBJECTS = $(JNI_SOURCES:.c=.o) $(JNI_GENERATED_SOURCES:.c=.o)
+
+JNI_LIB = $(BUILD_DIR)/$(JNI_LIB_PREFIX)herb_jni.$(JNI_LIB_EXT)
+
+JAVA_SOURCES = $(shell find org -name "*.java" 2>/dev/null)
+JAVA_TIMESTAMP = target/.java_compiled
+
+.PHONY: all clean java jni test
+
+all: templates jni java
+
+templates:
+ cd .. && bundle exec rake templates
+
+jni: $(JNI_LIB)
+
+$(JNI_LIB): $(JNI_OBJECTS) $(HERB_OBJECTS)
+ @mkdir -p $(BUILD_DIR)
+ $(CC) $(LDFLAGS) -o $@ $(JNI_OBJECTS) $(HERB_OBJECTS) $(LIBS)
+ @echo "Built JNI library: $(JNI_LIB)"
+
+%.o: %.c
+ $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
+
+java: $(JAVA_TIMESTAMP)
+
+$(JAVA_TIMESTAMP): $(JAVA_SOURCES)
+ @mkdir -p target/classes
+ @if [ -n "$(JAVA_SOURCES)" ]; then \
+ $(JAVAC) -d target/classes $(JAVA_SOURCES); \
+ touch $(JAVA_TIMESTAMP); \
+ echo "Compiled Java classes"; \
+ fi
+
+cli: all
+ @echo "CLI ready! Use: ./bin/herb-java [command] [file]"
+ @echo ""
+ @./bin/herb-java || true
+
+clean:
+ rm -f $(JNI_OBJECTS) $(JNI_LIB)
+ rm -rf target/
+ rm -f nodes.o error_helpers.o
+
+help:
+ @echo "Herb Java/JNI Build System"
+ @echo ""
+ @echo "Targets:"
+ @echo " all - Build everything (templates + JNI + Java)"
+ @echo " templates - Generate code from ERB templates"
+ @echo " jni - Build JNI shared library"
+ @echo " java - Compile Java classes"
+ @echo " cli - Show CLI usage"
+ @echo " clean - Remove built files"
+ @echo " help - Show this help"
+ @echo ""
+ @echo "Environment:"
+ @echo " JAVA_HOME = $(JAVA_HOME)"
+ @echo " OS = $(os)"
+ @echo " JNI_LIB = $(JNI_LIB)"
diff --git a/java/README.md b/java/README.md
new file mode 100644
index 000000000..e8a5fd47b
--- /dev/null
+++ b/java/README.md
@@ -0,0 +1,23 @@
+# Herb Java/JNI Bindings
+
+Java bindings for the Herb HTML+ERB parser using JNI (Java Native Interface).
+
+## Quick Start
+
+### Prerequisites
+
+```bash
+java -version
+```
+
+### Build
+
+```bash
+cd java
+
+make templates
+make jni
+make java
+```
+
+This creates `../build/libherb_jni.dylib` (or `.so` on Linux).
diff --git a/java/bin/herb-java b/java/bin/herb-java
new file mode 100755
index 000000000..5e2fc247d
--- /dev/null
+++ b/java/bin/herb-java
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# Herb Java CLI wrapper script
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+BUILD_DIR="$SCRIPT_DIR/../../build"
+CLASSES_DIR="$SCRIPT_DIR/../target/classes"
+
+JAVA_CMD=java
+
+exec "$JAVA_CMD" \
+ --enable-native-access=ALL-UNNAMED \
+ -Djava.library.path="$BUILD_DIR" \
+ -cp "$CLASSES_DIR" \
+ org.herb.CLI "$@"
diff --git a/java/extension_helpers.c b/java/extension_helpers.c
new file mode 100644
index 000000000..7ad963410
--- /dev/null
+++ b/java/extension_helpers.c
@@ -0,0 +1,110 @@
+#include "extension_helpers.h"
+#include "error_helpers.h"
+#include "nodes.h"
+
+#include "../../src/include/ast_nodes.h"
+#include "../../src/include/herb.h"
+#include "../../src/include/io.h"
+#include "../../src/include/location.h"
+#include "../../src/include/position.h"
+#include "../../src/include/range.h"
+#include "../../src/include/token.h"
+#include "../../src/include/util/hb_array.h"
+
+#include
+#include
+#include
+
+jobject CreatePosition(JNIEnv* env, position_T position) {
+ jclass positionClass = (*env)->FindClass(env, "org/herb/Position");
+ jmethodID constructor = (*env)->GetMethodID(env, positionClass, "", "(II)V");
+
+ return (*env)->NewObject(env, positionClass, constructor, (jint) position.line, (jint) position.column);
+}
+
+jobject CreateLocation(JNIEnv* env, location_T location) {
+ jclass locationClass = (*env)->FindClass(env, "org/herb/Location");
+ jmethodID constructor =
+ (*env)->GetMethodID(env, locationClass, "", "(Lorg/herb/Position;Lorg/herb/Position;)V");
+
+ jobject start = CreatePosition(env, location.start);
+ jobject end = CreatePosition(env, location.end);
+
+ return (*env)->NewObject(env, locationClass, constructor, start, end);
+}
+
+jobject CreateRange(JNIEnv* env, range_T range) {
+ jclass rangeClass = (*env)->FindClass(env, "org/herb/Range");
+ jmethodID constructor = (*env)->GetMethodID(env, rangeClass, "", "(II)V");
+
+ return (*env)->NewObject(env, rangeClass, constructor, (jint) range.from, (jint) range.to);
+}
+
+jobject CreateToken(JNIEnv* env, token_T* token) {
+ if (!token) { return NULL; }
+
+ jclass tokenClass = (*env)->FindClass(env, "org/herb/Token");
+ jmethodID constructor = (*env)->GetMethodID(
+ env, tokenClass, "", "(Ljava/lang/String;Ljava/lang/String;Lorg/herb/Location;Lorg/herb/Range;)V");
+
+ jstring type = (*env)->NewStringUTF(env, token_type_to_string(token->type));
+ jstring value = (*env)->NewStringUTF(env, token->value);
+ jobject location = CreateLocation(env, token->location);
+ jobject range = CreateRange(env, token->range);
+
+ return (*env)->NewObject(env, tokenClass, constructor, type, value, location, range);
+}
+
+jobject CreateLexResult(JNIEnv* env, hb_array_T* tokens, jstring source) {
+ jclass arrayListClass = (*env)->FindClass(env, "java/util/ArrayList");
+ jmethodID arrayListConstructor = (*env)->GetMethodID(env, arrayListClass, "", "(I)V");
+ jmethodID addMethod = (*env)->GetMethodID(env, arrayListClass, "add", "(Ljava/lang/Object;)Z");
+
+ jobject tokensList = (*env)->NewObject(env, arrayListClass, arrayListConstructor, (jint) hb_array_size(tokens));
+
+ for (size_t i = 0; i < hb_array_size(tokens); i++) {
+ token_T* token = (token_T*) hb_array_get(tokens, i);
+ jobject tokenObj = CreateToken(env, token);
+ (*env)->CallBooleanMethod(env, tokensList, addMethod, tokenObj);
+ }
+
+ jclass lexResultClass = (*env)->FindClass(env, "org/herb/LexResult");
+ jmethodID constructor =
+ (*env)->GetMethodID(env, lexResultClass, "", "(Ljava/util/List;Ljava/lang/String;)V");
+
+ return (*env)->NewObject(env, lexResultClass, constructor, tokensList, source);
+}
+
+jobject CreateParseResult(JNIEnv* env, AST_DOCUMENT_NODE_T* root, jstring source) {
+ jobject value = CreateDocumentNode(env, root);
+
+ jclass arrayListClass = (*env)->FindClass(env, "java/util/ArrayList");
+ jmethodID arrayListConstructor = (*env)->GetMethodID(env, arrayListClass, "", "()V");
+ jmethodID addMethod = (*env)->GetMethodID(env, arrayListClass, "add", "(Ljava/lang/Object;)Z");
+
+ jobject errorsList = (*env)->NewObject(env, arrayListClass, arrayListConstructor);
+
+ if (root->base.errors) {
+ for (size_t i = 0; i < hb_array_size(root->base.errors); i++) {
+ AST_NODE_T* error_node = (AST_NODE_T*) hb_array_get(root->base.errors, i);
+ jobject errorObj = CreateErrorNode(env, error_node);
+ (*env)->CallBooleanMethod(env, errorsList, addMethod, errorObj);
+ }
+ }
+
+ jclass parseResultClass = (*env)->FindClass(env, "org/herb/ParseResult");
+ jmethodID constructor = (*env)->GetMethodID(
+ env, parseResultClass, "", "(Lorg/herb/ast/Node;Ljava/util/List;Ljava/lang/String;)V");
+
+ return (*env)->NewObject(env, parseResultClass, constructor, value, errorsList, source);
+}
+
+jstring ReadFileToString(JNIEnv* env, const char* path) {
+ char* content = herb_read_file(path);
+ if (!content) { return NULL; }
+
+ jstring result = (*env)->NewStringUTF(env, content);
+ free(content);
+
+ return result;
+}
diff --git a/java/extension_helpers.h b/java/extension_helpers.h
new file mode 100644
index 000000000..536535040
--- /dev/null
+++ b/java/extension_helpers.h
@@ -0,0 +1,29 @@
+#ifndef HERB_JNI_EXTENSION_HELPERS_H
+#define HERB_JNI_EXTENSION_HELPERS_H
+
+#include
+
+#include "../../src/include/ast_nodes.h"
+#include "../../src/include/location.h"
+#include "../../src/include/position.h"
+#include "../../src/include/range.h"
+#include "../../src/include/token.h"
+#include "../../src/include/util/hb_array.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+jobject CreatePosition(JNIEnv* env, position_T position);
+jobject CreateLocation(JNIEnv* env, location_T location);
+jobject CreateRange(JNIEnv* env, range_T range);
+jobject CreateToken(JNIEnv* env, token_T* token);
+jobject CreateLexResult(JNIEnv* env, hb_array_T* tokens, jstring source);
+jobject CreateParseResult(JNIEnv* env, AST_DOCUMENT_NODE_T* root, jstring source);
+jstring ReadFileToString(JNIEnv* env, const char* path);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/java/herb_jni.c b/java/herb_jni.c
new file mode 100644
index 000000000..c34200b20
--- /dev/null
+++ b/java/herb_jni.c
@@ -0,0 +1,114 @@
+#include "herb_jni.h"
+#include "extension_helpers.h"
+
+#include "../../src/include/analyze.h"
+#include "../../src/include/herb.h"
+#include "../../src/include/util/hb_buffer.h"
+
+#include
+#include
+
+JNIEXPORT jstring JNICALL
+Java_org_herb_Herb_herbVersion(JNIEnv* env, jclass clazz) {
+ const char* version = herb_version();
+
+ return (*env)->NewStringUTF(env, version);
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_herb_Herb_prismVersion(JNIEnv* env, jclass clazz) {
+ const char* version = herb_prism_version();
+
+ return (*env)->NewStringUTF(env, version);
+}
+
+JNIEXPORT jobject JNICALL
+Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject options) {
+ const char* src = (*env)->GetStringUTFChars(env, source, 0);
+
+ parser_options_T* parser_options = NULL;
+ parser_options_T opts = { 0 };
+
+ if (options != NULL) {
+ jclass optionsClass = (*env)->GetObjectClass(env, options);
+ jmethodID getTrackWhitespace =
+ (*env)->GetMethodID(env, optionsClass, "isTrackWhitespace", "()Z");
+
+ if (getTrackWhitespace != NULL) {
+ jboolean trackWhitespace = (*env)->CallBooleanMethod(env, options, getTrackWhitespace);
+
+ if (trackWhitespace == JNI_TRUE) {
+ opts.track_whitespace = true;
+ parser_options = &opts;
+ }
+ }
+ }
+
+ AST_DOCUMENT_NODE_T* ast = herb_parse(src, parser_options);
+ herb_analyze_parse_tree(ast, src);
+
+ jobject result = CreateParseResult(env, ast, source);
+
+ ast_node_free((AST_NODE_T*) ast);
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return result;
+}
+
+JNIEXPORT jobject JNICALL
+Java_org_herb_Herb_lex(JNIEnv* env, jclass clazz, jstring source) {
+ const char* src = (*env)->GetStringUTFChars(env, source, 0);
+
+ hb_array_T* tokens = herb_lex(src);
+
+ jobject result = CreateLexResult(env, tokens, source);
+
+ herb_free_tokens(&tokens);
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return result;
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_herb_Herb_extractRuby(JNIEnv* env, jclass clazz, jstring source) {
+ const char* src = (*env)->GetStringUTFChars(env, source, 0);
+
+ hb_buffer_T output;
+
+ if (!hb_buffer_init(&output, strlen(src))) {
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return NULL;
+ }
+
+ herb_extract_ruby_to_buffer(src, &output);
+
+ jstring result = (*env)->NewStringUTF(env, output.value);
+
+ free(output.value);
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return result;
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_herb_Herb_extractHTML(JNIEnv* env, jclass clazz, jstring source) {
+ const char* src = (*env)->GetStringUTFChars(env, source, 0);
+
+ hb_buffer_T output;
+
+ if (!hb_buffer_init(&output, strlen(src))) {
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return NULL;
+ }
+
+ herb_extract_html_to_buffer(src, &output);
+
+ jstring result = (*env)->NewStringUTF(env, output.value);
+
+ free(output.value);
+ (*env)->ReleaseStringUTFChars(env, source, src);
+
+ return result;
+}
diff --git a/java/herb_jni.h b/java/herb_jni.h
new file mode 100644
index 000000000..8895209cc
--- /dev/null
+++ b/java/herb_jni.h
@@ -0,0 +1,21 @@
+#ifndef HERB_JNI_H
+#define HERB_JNI_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+JNIEXPORT jstring JNICALL Java_org_herb_Herb_herbVersion(JNIEnv*, jclass);
+JNIEXPORT jstring JNICALL Java_org_herb_Herb_prismVersion(JNIEnv*, jclass);
+JNIEXPORT jobject JNICALL Java_org_herb_Herb_parse(JNIEnv*, jclass, jstring, jobject);
+JNIEXPORT jobject JNICALL Java_org_herb_Herb_lex(JNIEnv*, jclass, jstring);
+JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractRuby(JNIEnv*, jclass, jstring);
+JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractHTML(JNIEnv*, jclass, jstring);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/java/org/herb/CLI.java b/java/org/herb/CLI.java
new file mode 100644
index 000000000..a66cbe19f
--- /dev/null
+++ b/java/org/herb/CLI.java
@@ -0,0 +1,105 @@
+package org.herb;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+public class CLI {
+ public static void main(String[] args) {
+ if (args.length < 1) {
+ printUsage();
+
+ System.exit(1);
+ }
+
+ String command = args[0];
+
+ if (command.equals("version")) {
+ System.out.println(Herb.version());
+
+ return;
+ }
+
+ if (args.length < 2) {
+ System.err.println("Please specify input file.");
+ System.exit(1);
+ }
+
+ String filepath = args[1];
+ String source;
+
+ try {
+ source = new String(Files.readAllBytes(Paths.get(filepath)));
+ } catch (IOException e) {
+ System.err.println("Error reading file: " + e.getMessage());
+ System.exit(1);
+
+ return;
+ }
+
+ switch (command) {
+
+ case "lex":
+ LexResult lexResult = Herb.lex(source);
+
+ if (lexResult.getTokens() != null) {
+ for (Token token : lexResult.getTokens()) {
+ System.out.println(token.inspect());
+ }
+ }
+ break;
+
+ case "parse":
+ ParseResult parseResult = Herb.parse(source);
+
+ if (parseResult.getValue() != null) {
+ System.out.print(parseResult.getValue().treeInspect());
+ }
+
+ if (parseResult.getErrors() != null && !parseResult.getErrors().isEmpty()) {
+ System.out.println("Errors:");
+
+ for (Object error : parseResult.getErrors()) {
+ if (error instanceof org.herb.ast.Node) {
+ System.out.println(((org.herb.ast.Node) error).treeInspect());
+ } else {
+ System.out.println(" " + error);
+ }
+ }
+ }
+
+ break;
+
+ case "ruby":
+ String ruby = Herb.extractRuby(source);
+ System.out.println(ruby);
+
+ break;
+
+ case "html":
+ String html = Herb.extractHTML(source);
+ System.out.println(html);
+
+ break;
+
+ default:
+ System.err.println("Unknown command: " + command);
+ printUsage();
+
+ System.exit(1);
+ }
+ }
+
+ private static void printUsage() {
+ System.out.println("Usage: bin/herb-java [command] [file]");
+ System.out.println();
+ System.out.println("Herb 🌿 Powerful and seamless HTML-aware ERB parsing and tooling.");
+ System.out.println();
+ System.out.println("Commands:");
+ System.out.println(" version - Show version information");
+ System.out.println(" lex [file] - Lex a file");
+ System.out.println(" parse [file] - Parse a file and display AST tree");
+ System.out.println(" ruby [file] - Extract Ruby from a file");
+ System.out.println(" html [file] - Extract HTML from a file");
+ }
+}
diff --git a/java/org/herb/Herb.java b/java/org/herb/Herb.java
new file mode 100644
index 000000000..e1a739449
--- /dev/null
+++ b/java/org/herb/Herb.java
@@ -0,0 +1,31 @@
+package org.herb;
+
+public class Herb {
+ static {
+ String libName = System.getProperty("herb.jni.library", "herb_jni");
+
+ try {
+ System.loadLibrary(libName);
+ } catch (UnsatisfiedLinkError error) {
+ System.err.println("Failed to load native library: " + libName);
+ System.err.println("java.library.path: " + System.getProperty("java.library.path"));
+
+ throw error;
+ }
+ }
+
+ public static native String herbVersion();
+ public static native String prismVersion();
+ public static native ParseResult parse(String source, ParserOptions options);
+ public static native LexResult lex(String source);
+ public static native String extractRuby(String source);
+ public static native String extractHTML(String source);
+
+ public static ParseResult parse(String source) {
+ return parse(source, null);
+ }
+
+ public static String version() {
+ return String.format("herb java v%s, libprism v%s, libherb v%s (Java JNI)", herbVersion(), prismVersion(), herbVersion());
+ }
+}
diff --git a/java/org/herb/LexResult.java b/java/org/herb/LexResult.java
new file mode 100644
index 000000000..a3621d7e9
--- /dev/null
+++ b/java/org/herb/LexResult.java
@@ -0,0 +1,35 @@
+package org.herb;
+
+import java.util.Collections;
+import java.util.List;
+
+public class LexResult {
+ private final List tokens;
+ private final String source;
+
+ public LexResult(List tokens, String source) {
+ this.tokens = Collections.unmodifiableList(tokens);
+ this.source = source;
+ }
+
+ public List getTokens() {
+ return tokens;
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public int getTokenCount() {
+ return tokens.size();
+ }
+
+ public boolean isEmpty() {
+ return tokens.isEmpty();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("LexResult{tokens=%d, source=%d chars}", tokens.size(), source.length());
+ }
+}
diff --git a/java/org/herb/Location.java b/java/org/herb/Location.java
new file mode 100644
index 000000000..07e884b0c
--- /dev/null
+++ b/java/org/herb/Location.java
@@ -0,0 +1,41 @@
+package org.herb;
+
+public class Location {
+ private final Position start;
+ private final Position end;
+
+ public Location(Position start, Position end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ public Position getStart() {
+ return start;
+ }
+
+ public Position getEnd() {
+ return end;
+ }
+
+ @Override
+ public String toString() {
+ return inspect();
+ }
+
+ public String inspect() {
+ return String.format("%s-%s", start, end);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof Location)) return false;
+ Location other = (Location) obj;
+ return start.equals(other.start) && end.equals(other.end);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * start.hashCode() + end.hashCode();
+ }
+}
diff --git a/java/org/herb/ParseResult.java b/java/org/herb/ParseResult.java
new file mode 100644
index 000000000..1fc56da02
--- /dev/null
+++ b/java/org/herb/ParseResult.java
@@ -0,0 +1,46 @@
+package org.herb;
+
+import org.herb.ast.Node;
+import java.util.Collections;
+import java.util.List;
+
+public class ParseResult {
+ private final Node value;
+ private final List errors;
+ private final String source;
+
+ public ParseResult(Node value, List errors, String source) {
+ this.value = value;
+ this.errors = Collections.unmodifiableList(errors);
+ this.source = source;
+ }
+
+ public Node getValue() {
+ return value;
+ }
+
+ public List getErrors() {
+ return errors;
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public boolean hasErrors() {
+ return !errors.isEmpty();
+ }
+
+ public int getErrorCount() {
+ return errors.size();
+ }
+
+ public boolean isSuccess() {
+ return errors.isEmpty();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("ParseResult{errors=%d, source=%d chars}", errors.size(), source.length());
+ }
+}
diff --git a/java/org/herb/ParserOptions.java b/java/org/herb/ParserOptions.java
new file mode 100644
index 000000000..3e168b919
--- /dev/null
+++ b/java/org/herb/ParserOptions.java
@@ -0,0 +1,20 @@
+package org.herb;
+
+public class ParserOptions {
+ private boolean trackWhitespace = false;
+
+ public ParserOptions() {}
+
+ public ParserOptions trackWhitespace(boolean value) {
+ this.trackWhitespace = value;
+ return this;
+ }
+
+ public boolean isTrackWhitespace() {
+ return trackWhitespace;
+ }
+
+ public static ParserOptions create() {
+ return new ParserOptions();
+ }
+}
diff --git a/java/org/herb/Position.java b/java/org/herb/Position.java
new file mode 100644
index 000000000..5a36aad94
--- /dev/null
+++ b/java/org/herb/Position.java
@@ -0,0 +1,41 @@
+package org.herb;
+
+public class Position {
+ private final int line;
+ private final int column;
+
+ public Position(int line, int column) {
+ this.line = line;
+ this.column = column;
+ }
+
+ public int getLine() {
+ return line;
+ }
+
+ public int getColumn() {
+ return column;
+ }
+
+ @Override
+ public String toString() {
+ return inspect();
+ }
+
+ public String inspect() {
+ return String.format("(%d:%d)", line, column);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof Position)) return false;
+ Position other = (Position) obj;
+ return line == other.line && column == other.column;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * line + column;
+ }
+}
diff --git a/java/org/herb/Range.java b/java/org/herb/Range.java
new file mode 100644
index 000000000..209418a99
--- /dev/null
+++ b/java/org/herb/Range.java
@@ -0,0 +1,47 @@
+package org.herb;
+
+public class Range {
+ private final int from;
+ private final int to;
+
+ public Range(int from, int to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ public int getFrom() {
+ return from;
+ }
+
+ public int getTo() {
+ return to;
+ }
+
+ public int getLength() {
+ return to - from;
+ }
+
+ @Override
+ public String toString() {
+ return inspect();
+ }
+
+ public String inspect() {
+ return String.format("[%d, %d]", from, to);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof Range)) return false;
+
+ Range other = (Range) obj;
+
+ return from == other.from && to == other.to;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * from + to;
+ }
+}
diff --git a/java/org/herb/Token.java b/java/org/herb/Token.java
new file mode 100644
index 000000000..28ea2220f
--- /dev/null
+++ b/java/org/herb/Token.java
@@ -0,0 +1,91 @@
+package org.herb;
+
+public class Token {
+ private final String type;
+ private final String value;
+ private final Location location;
+ private final Range range;
+
+ public Token(String type, String value, Location location, Range range) {
+ this.type = type;
+ this.value = value;
+ this.location = location;
+ this.range = range;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public Range getRange() {
+ return range;
+ }
+
+ @Override
+ public String toString() {
+ return inspect();
+ }
+
+ public String treeInspect() {
+ String escapedValue = value.replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ .replace("\"", "\\\"");
+ return String.format("\"%s\" (location: %s)", escapedValue, location);
+ }
+
+ public String inspect() {
+ return String.format("#",
+ type,
+ inspectValue(),
+ range.inspect(),
+ location.getStart().inspect(),
+ location.getEnd().inspect()
+ );
+ }
+
+ private String inspectValue() {
+ if (type.equals("TOKEN_EOF")) {
+ return "";
+ }
+
+ return escaped(value);
+ }
+
+ private String escaped(String string) {
+ return string.replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ .replace("\"", "\\\"");
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof Token)) return false;
+
+ Token other = (Token) obj;
+
+ return type.equals(other.type) && value.equals(other.value) && location.equals(other.location);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = type.hashCode();
+
+ result = 31 * result + value.hashCode();
+ result = 31 * result + location.hashCode();
+
+ return result;
+ }
+}
diff --git a/java/org/herb/ast/BaseNode.java b/java/org/herb/ast/BaseNode.java
new file mode 100644
index 000000000..9e7bb5ecf
--- /dev/null
+++ b/java/org/herb/ast/BaseNode.java
@@ -0,0 +1,151 @@
+package org.herb.ast;
+
+import org.herb.Location;
+
+/**
+ * Abstract base class for all AST nodes.
+ */
+public abstract class BaseNode implements Node {
+ protected final String type;
+ protected final Location location;
+ protected final java.util.List errors;
+
+ protected BaseNode(String type, Location location, java.util.List errors) {
+ this.type = type;
+ this.location = location;
+ this.errors = errors != null ? errors : java.util.Collections.emptyList();
+ }
+
+ @Override
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public Location getLocation() {
+ return location;
+ }
+
+ public java.util.List getErrors() {
+ return errors;
+ }
+
+ @Override
+ public R accept(NodeVisitor visitor, C context) {
+ return visitor.visitNode(this, context);
+ }
+
+ @Override
+ public void visitChildren(NodeVisitor visitor, C context) {
+ // Base implementation does nothing - subclasses override to visit their children
+ }
+
+ /**
+ * Helper to format errors for tree inspection.
+ */
+ protected String inspectErrors(String prefix) {
+ if (errors == null || errors.isEmpty()) return "";
+
+ StringBuilder output = new StringBuilder();
+ int errorCount = errors.size();
+ String countLabel = errorCount == 1 ? "error" : "errors";
+
+ output.append("├── errors: (").append(errorCount).append(" ").append(countLabel).append(")\n");
+
+ for (int i = 0; i < errors.size(); i++) {
+ Node error = errors.get(i);
+ boolean isLast = i == errors.size() - 1;
+ String symbol = isLast ? "└── " : "├── ";
+ String nextPrefix = isLast ? " " : "│ ";
+
+ if (error != null) {
+ String tree = error.treeInspect();
+ if (tree.endsWith("\n")) {
+ tree = tree.substring(0, tree.length() - 1);
+ }
+ output.append(prefix).append(symbol).append(tree.replaceAll("\n", "\n" + prefix + nextPrefix)).append("\n");
+
+ if (!isLast) {
+ output.append(prefix).append(nextPrefix).append("\n");
+ }
+ }
+ }
+ output.append(prefix).append("\n");
+
+ return output.toString();
+ }
+
+ /**
+ * Helper to format an array of nodes for tree inspection.
+ */
+ protected String inspectArray(java.util.List array, String prefix) {
+ if (array == null) return "∅\n";
+ if (array.isEmpty()) return "[]\n";
+
+ StringBuilder output = new StringBuilder();
+ output.append(String.format("(%d item%s)\n", array.size(), array.size() == 1 ? "" : "s"));
+
+ for (int i = 0; i < array.size(); i++) {
+ Node item = array.get(i);
+ boolean isLast = i == array.size() - 1;
+ String symbol = isLast ? "└── " : "├── ";
+ String nextPrefix = isLast ? " " : "│ ";
+
+ if (item != null) {
+ String tree = item.treeInspect();
+
+ if (tree.endsWith("\n")) {
+ tree = tree.substring(0, tree.length() - 1);
+ }
+
+ output.append(prefix).append(symbol).append(tree.replaceAll("\n", "\n" + prefix + nextPrefix)).append("\n");
+
+ if (!isLast) {
+ output.append(prefix).append(nextPrefix).append("\n");
+ }
+ } else {
+ output.append(prefix).append(symbol).append("null\n");
+
+ if (!isLast) {
+ output.append(prefix).append(nextPrefix).append("\n");
+ }
+ }
+ }
+
+ return output.toString();
+ }
+
+ /**
+ * Helper to format a single node for tree inspection.
+ */
+ protected String inspectNode(Node node, String prefix) {
+ if (node == null) return "∅\n";
+ String tree = node.treeInspect();
+
+ if (tree.endsWith("\n")) {
+ tree = tree.substring(0, tree.length() - 1);
+ }
+
+ String[] lines = tree.split("\n", -1);
+ if (lines.length == 0) return "∅\n";
+
+ StringBuilder result = new StringBuilder();
+
+ result.append("└── ").append(lines[0]).append("\n");
+
+ for (int i = 1; i < lines.length; i++) {
+ if (lines[i].isEmpty()) {
+ result.append(prefix).append("\n");
+ } else {
+ result.append(prefix).append(" ").append(lines[i]).append("\n");
+ }
+ }
+
+ return result.toString();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s@%s", type, location);
+ }
+}
diff --git a/java/org/herb/ast/ErrorNode.java b/java/org/herb/ast/ErrorNode.java
new file mode 100644
index 000000000..6ebf7be84
--- /dev/null
+++ b/java/org/herb/ast/ErrorNode.java
@@ -0,0 +1,37 @@
+package org.herb.ast;
+
+import org.herb.Location;
+
+/**
+ * Simple wrapper for parser errors returned in ParseResult.
+ * This is a lightweight representation that can be cast to specific error types if needed.
+ */
+public class ErrorNode extends BaseNode {
+ private final String errorMessage;
+
+ public ErrorNode(String type, Location location, String errorMessage) {
+ super(type, location, null);
+ this.errorMessage = errorMessage;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ @Override
+ public String treeInspect() {
+ StringBuilder output = new StringBuilder();
+
+ output.append("@ ").append(type).append(" ");
+ output.append(location != null ? "(location: " + location.toString() + ")" : "no-location");
+ output.append("\n");
+ output.append("└── message: \"").append(errorMessage).append("\"\n");
+
+ return output.toString();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("ErrorNode{type='%s', message='%s', location=%s}", type, errorMessage, location);
+ }
+}
diff --git a/java/org/herb/ast/Node.java b/java/org/herb/ast/Node.java
new file mode 100644
index 000000000..4af7282fb
--- /dev/null
+++ b/java/org/herb/ast/Node.java
@@ -0,0 +1,33 @@
+package org.herb.ast;
+
+import org.herb.Location;
+
+/**
+ * Base interface for all AST nodes.
+ */
+public interface Node {
+ /**
+ * Get the type of this node.
+ */
+ String getType();
+
+ /**
+ * Get the location of this node in the source.
+ */
+ Location getLocation();
+
+ /**
+ * Accept a visitor.
+ */
+ R accept(NodeVisitor visitor, C context);
+
+ /**
+ * Visit all children of this node with the given visitor.
+ */
+ void visitChildren(NodeVisitor visitor, C context);
+
+ /**
+ * Return a tree-like string representation of this node with all its fields.
+ */
+ String treeInspect();
+}
diff --git a/java/org/herb/ast/TreePrintVisitor.java b/java/org/herb/ast/TreePrintVisitor.java
new file mode 100644
index 000000000..f5f72d865
--- /dev/null
+++ b/java/org/herb/ast/TreePrintVisitor.java
@@ -0,0 +1,49 @@
+package org.herb.ast;
+
+import java.io.PrintStream;
+
+/**
+ * Visitor that prints the AST hierarchy with indentation.
+ * Displays node names with proper tree structure.
+ */
+public class TreePrintVisitor extends Visitor {
+ private final PrintStream out;
+ private final String indent;
+
+ /**
+ * Create a new TreePrintVisitor.
+ *
+ * @param out the output stream to write to
+ */
+ public TreePrintVisitor(PrintStream out) {
+ this.out = out;
+ this.indent = " ";
+ }
+
+ /**
+ * Print the tree starting from a root node.
+ *
+ * @param node the root node to print
+ */
+ public void printTree(Node node) {
+ if (node != null) {
+ node.accept(this, 0);
+ }
+ }
+
+ @Override
+ public Void visitNode(Node node, Integer depth) {
+ printIndent(depth);
+
+ out.println(node.getType());
+ visitChildNodes(node, depth + 1);
+
+ return null;
+ }
+
+ private void printIndent(int depth) {
+ for (int i = 0; i < depth; i++) {
+ out.print(indent);
+ }
+ }
+}
diff --git a/java/run_tests.sh b/java/run_tests.sh
new file mode 100755
index 000000000..54eb0f71c
--- /dev/null
+++ b/java/run_tests.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+
+# Simple test script for Java bindings
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+echo "Running Java binding tests..."
+echo
+
+TEST_FILE="snapshots/test.html.erb"
+
+echo "Test 1: Version command"
+
+./bin/herb-java version
+echo "✓ Version test passed"
+echo
+
+echo "Test 2: Lex command"
+
+OUTPUT=$(./bin/herb-java lex "$TEST_FILE")
+EXPECTED=$(cat snapshots/test.lex.txt)
+
+if [ "$OUTPUT" = "$EXPECTED" ]; then
+ echo "✓ Lex test passed"
+else
+ echo "✗ Lex test failed"
+ echo "Expected:"
+ echo "$EXPECTED"
+ echo "Got:"
+ echo "$OUTPUT"
+ exit 1
+fi
+echo
+
+echo "Test 3: Parse command"
+
+OUTPUT=$(./bin/herb-java parse "$TEST_FILE")
+EXPECTED=$(cat snapshots/test.parse.txt)
+
+if [ "$OUTPUT" = "$EXPECTED" ]; then
+ echo "✓ Parse test passed"
+else
+ echo "✗ Parse test failed"
+ echo "Expected:"
+ echo "$EXPECTED"
+ echo "Got:"
+ echo "$OUTPUT"
+ exit 1
+fi
+echo
+
+echo "Test 4: Extract Ruby"
+
+OUTPUT=$(./bin/herb-java ruby "$TEST_FILE")
+EXPECTED=$(cat snapshots/test.ruby.txt)
+
+if [ "$OUTPUT" = "$EXPECTED" ]; then
+ echo "✓ Extract Ruby test passed"
+else
+ echo "✗ Extract Ruby test failed"
+ echo "Expected:"
+ echo "$EXPECTED"
+ echo "Got:"
+ echo "$OUTPUT"
+ exit 1
+fi
+echo
+
+echo "Test 5: Extract HTML"
+
+OUTPUT=$(./bin/herb-java html "$TEST_FILE")
+EXPECTED=$(cat snapshots/test.html.txt)
+
+if [ "$OUTPUT" = "$EXPECTED" ]; then
+ echo "✓ Extract HTML test passed"
+else
+ echo "✗ Extract HTML test failed"
+ echo "Expected:"
+ echo "$EXPECTED"
+ echo "Got:"
+ echo "$OUTPUT"
+ exit 1
+fi
+echo
+
+echo "All Java tests passed! ✓"
diff --git a/java/snapshots/test.html.erb b/java/snapshots/test.html.erb
new file mode 100644
index 000000000..49cc40405
--- /dev/null
+++ b/java/snapshots/test.html.erb
@@ -0,0 +1,11 @@
+
+
+<% if valid? %>
+
+ <%= "Valid" %>
+
+<% else %>
+
+ Invalid
+
+<% end %>
diff --git a/java/snapshots/test.html.txt b/java/snapshots/test.html.txt
new file mode 100644
index 000000000..c0b21d4e1
--- /dev/null
+++ b/java/snapshots/test.html.txt
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+ Invalid
+
+
+
diff --git a/java/snapshots/test.lex.txt b/java/snapshots/test.lex.txt
new file mode 100644
index 000000000..2dab7e796
--- /dev/null
+++ b/java/snapshots/test.lex.txt
@@ -0,0 +1,65 @@
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
diff --git a/java/snapshots/test.parse.txt b/java/snapshots/test.parse.txt
new file mode 100644
index 000000000..7f2b30225
--- /dev/null
+++ b/java/snapshots/test.parse.txt
@@ -0,0 +1,147 @@
+@ DocumentNode (location: (1:0)-(12:0))
+└── children: (4 items)
+ ├── @ HTMLElementNode (location: (1:0)-(1:18))
+ │ ├── open_tag:
+ │ │ └── @ HTMLOpenTagNode (location: (1:0)-(1:18))
+ │ │ ├── tag_opening: "<" (location: (1:0)-(1:1))
+ │ │ ├── tag_name: "input" (location: (1:1)-(1:6))
+ │ │ ├── tag_closing: "/>" (location: (1:16)-(1:18))
+ │ │ ├── children: (1 item)
+ │ │ │ └── @ HTMLAttributeNode (location: (1:7)-(1:15))
+ │ │ │ ├── name:
+ │ │ │ │ └── @ HTMLAttributeNameNode (location: (1:7)-(1:15))
+ │ │ │ │ └── children: (1 item)
+ │ │ │ │ └── @ LiteralNode (location: (1:7)-(1:15))
+ │ │ │ │ └── content: "required"
+ │ │ │ │
+ │ │ │ ├── equals: ∅
+ │ │ │ └── value: ∅
+ │ │ └── is_void: true
+ │ │
+ │ ├── tag_name: "input" (location: (1:1)-(1:6))
+ │ ├── body: []
+ │ ├── close_tag: ∅
+ │ ├── is_void: true
+ │ └── source: ""
+ │
+ ├── @ HTMLTextNode (location: (1:18)-(3:0))
+ │ └── content: "\n\n"
+ │
+ ├── @ ERBIfNode (location: (3:0)-(11:9))
+ │ ├── tag_opening: "<%" (location: (3:0)-(3:2))
+ │ ├── content: " if valid? " (location: (3:2)-(3:13))
+ │ ├── tag_closing: "%>" (location: (3:13)-(3:15))
+ │ ├── statements: (3 items)
+ │ │ ├── @ HTMLTextNode (location: (3:15)-(4:2))
+ │ │ │ └── content: "\n "
+ │ │ │
+ │ │ ├── @ HTMLElementNode (location: (4:2)-(6:7))
+ │ │ │ ├── open_tag:
+ │ │ │ │ └── @ HTMLOpenTagNode (location: (4:2)-(4:37))
+ │ │ │ │ ├── tag_opening: "<" (location: (4:2)-(4:3))
+ │ │ │ │ ├── tag_name: "h1" (location: (4:3)-(4:5))
+ │ │ │ │ ├── tag_closing: ">" (location: (4:36)-(4:37))
+ │ │ │ │ ├── children: (1 item)
+ │ │ │ │ │ └── @ HTMLAttributeNode (location: (4:6)-(4:36))
+ │ │ │ │ │ ├── name:
+ │ │ │ │ │ │ └── @ HTMLAttributeNameNode (location: (4:6)-(4:11))
+ │ │ │ │ │ │ └── children: (1 item)
+ │ │ │ │ │ │ └── @ LiteralNode (location: (4:6)-(4:11))
+ │ │ │ │ │ │ └── content: "class"
+ │ │ │ │ │ │
+ │ │ │ │ │ ├── equals: "=" (location: (4:11)-(4:12))
+ │ │ │ │ │ └── value:
+ │ │ │ │ │ └── @ HTMLAttributeValueNode (location: (4:12)-(4:36))
+ │ │ │ │ │ ├── open_quote: "\"" (location: (4:12)-(4:13))
+ │ │ │ │ │ ├── children: (1 item)
+ │ │ │ │ │ │ └── @ LiteralNode (location: (4:13)-(4:35))
+ │ │ │ │ │ │ └── content: "bg-green-500 text-gray"
+ │ │ │ │ │ ├── close_quote: "\"" (location: (4:35)-(4:36))
+ │ │ │ │ │ └── quoted: true
+ │ │ │ │ └── is_void: false
+ │ │ │ │
+ │ │ │ ├── tag_name: "h1" (location: (4:3)-(4:5))
+ │ │ │ ├── body: (3 items)
+ │ │ │ │ ├── @ HTMLTextNode (location: (4:37)-(5:4))
+ │ │ │ │ │ └── content: "\n "
+ │ │ │ │ │
+ │ │ │ │ ├── @ ERBContentNode (location: (5:4)-(5:18))
+ │ │ │ │ │ ├── tag_opening: "<%=" (location: (5:4)-(5:7))
+ │ │ │ │ │ ├── content: " \"Valid\" " (location: (5:7)-(5:16))
+ │ │ │ │ │ ├── tag_closing: "%>" (location: (5:16)-(5:18))
+ │ │ │ │ │ ├── parsed: true
+ │ │ │ │ │ └── valid: true
+ │ │ │ │ │
+ │ │ │ │ └── @ HTMLTextNode (location: (5:18)-(6:2))
+ │ │ │ │ └── content: "\n "
+ │ │ │ ├── close_tag:
+ │ │ │ │ └── @ HTMLCloseTagNode (location: (6:2)-(6:7))
+ │ │ │ │ ├── tag_opening: "" (location: (6:2)-(6:4))
+ │ │ │ │ ├── tag_name: "h1" (location: (6:4)-(6:6))
+ │ │ │ │ ├── children: []
+ │ │ │ │ └── tag_closing: ">" (location: (6:6)-(6:7))
+ │ │ │ │
+ │ │ │ ├── is_void: false
+ │ │ │ └── source: ""
+ │ │ │
+ │ │ └── @ HTMLTextNode (location: (6:7)-(7:0))
+ │ │ └── content: "\n"
+ │ ├── subsequent:
+ │ │ └── @ ERBElseNode (location: (7:0)-(11:0))
+ │ │ ├── tag_opening: "<%" (location: (7:0)-(7:2))
+ │ │ ├── content: " else " (location: (7:2)-(7:8))
+ │ │ ├── tag_closing: "%>" (location: (7:8)-(7:10))
+ │ │ └── statements: (3 items)
+ │ │ ├── @ HTMLTextNode (location: (7:10)-(8:2))
+ │ │ │ └── content: "\n "
+ │ │ │
+ │ │ ├── @ HTMLElementNode (location: (8:2)-(10:7))
+ │ │ │ ├── open_tag:
+ │ │ │ │ └── @ HTMLOpenTagNode (location: (8:2)-(8:36))
+ │ │ │ │ ├── tag_opening: "<" (location: (8:2)-(8:3))
+ │ │ │ │ ├── tag_name: "h1" (location: (8:3)-(8:5))
+ │ │ │ │ ├── tag_closing: ">" (location: (8:35)-(8:36))
+ │ │ │ │ ├── children: (1 item)
+ │ │ │ │ │ └── @ HTMLAttributeNode (location: (8:6)-(8:35))
+ │ │ │ │ │ ├── name:
+ │ │ │ │ │ │ └── @ HTMLAttributeNameNode (location: (8:6)-(8:11))
+ │ │ │ │ │ │ └── children: (1 item)
+ │ │ │ │ │ │ └── @ LiteralNode (location: (8:6)-(8:11))
+ │ │ │ │ │ │ └── content: "class"
+ │ │ │ │ │ │
+ │ │ │ │ │ ├── equals: "=" (location: (8:11)-(8:12))
+ │ │ │ │ │ └── value:
+ │ │ │ │ │ └── @ HTMLAttributeValueNode (location: (8:12)-(8:35))
+ │ │ │ │ │ ├── open_quote: "\"" (location: (8:12)-(8:13))
+ │ │ │ │ │ ├── children: (1 item)
+ │ │ │ │ │ │ └── @ LiteralNode (location: (8:13)-(8:34))
+ │ │ │ │ │ │ └── content: "bg-red-500 text-black"
+ │ │ │ │ │ ├── close_quote: "\"" (location: (8:34)-(8:35))
+ │ │ │ │ │ └── quoted: true
+ │ │ │ │ └── is_void: false
+ │ │ │ │
+ │ │ │ ├── tag_name: "h1" (location: (8:3)-(8:5))
+ │ │ │ ├── body: (1 item)
+ │ │ │ │ └── @ HTMLTextNode (location: (8:36)-(10:2))
+ │ │ │ │ └── content: "\n Invalid\n "
+ │ │ │ ├── close_tag:
+ │ │ │ │ └── @ HTMLCloseTagNode (location: (10:2)-(10:7))
+ │ │ │ │ ├── tag_opening: "" (location: (10:2)-(10:4))
+ │ │ │ │ ├── tag_name: "h1" (location: (10:4)-(10:6))
+ │ │ │ │ ├── children: []
+ │ │ │ │ └── tag_closing: ">" (location: (10:6)-(10:7))
+ │ │ │ │
+ │ │ │ ├── is_void: false
+ │ │ │ └── source: ""
+ │ │ │
+ │ │ └── @ HTMLTextNode (location: (10:7)-(11:0))
+ │ │ └── content: "\n"
+ │ │
+ │ └── end_node:
+ │ └── @ ERBEndNode (location: (11:0)-(11:9))
+ │ ├── tag_opening: "<%" (location: (11:0)-(11:2))
+ │ ├── content: " end " (location: (11:2)-(11:7))
+ │ └── tag_closing: "%>" (location: (11:7)-(11:9))
+ │
+ └── @ HTMLTextNode (location: (11:9)-(12:0))
+ └── content: "\n"
diff --git a/java/snapshots/test.ruby.txt b/java/snapshots/test.ruby.txt
new file mode 100644
index 000000000..68172876b
--- /dev/null
+++ b/java/snapshots/test.ruby.txt
@@ -0,0 +1,12 @@
+
+
+ if valid?
+
+ "Valid"
+
+ else
+
+
+
+ end
+
diff --git a/templates/java/error_helpers.c.erb b/templates/java/error_helpers.c.erb
new file mode 100644
index 000000000..e5ca9cca5
--- /dev/null
+++ b/templates/java/error_helpers.c.erb
@@ -0,0 +1,75 @@
+#include "error_helpers.h"
+#include "extension_helpers.h"
+
+#include "../../src/include/ast_node.h"
+#include "../../src/include/errors.h"
+
+#include
+
+<%- errors.each do |error| -%>
+jobject <%= error.name %>FromCStruct(JNIEnv* env, <%= error.struct_type %>* <%= error.human %>) {
+ if (!<%= error.human %>) { return NULL; }
+
+ jclass errorClass = (*env)->FindClass(env, "org/herb/ast/ErrorNode");
+ if (!errorClass) { return NULL; }
+
+ jmethodID constructor = (*env)->GetMethodID(env, errorClass, "", "(Ljava/lang/String;Lorg/herb/Location;Ljava/lang/String;)V");
+ if (!constructor) { return NULL; }
+
+ jstring jtype = (*env)->NewStringUTF(env, "<%= error.name %>");
+ jobject location = CreateLocation(env, <%= error.human %>->base.location);
+ jstring jmessage = (*env)->NewStringUTF(env, <%= error.human %>->base.message);
+
+ return (*env)->NewObject(env, errorClass, constructor, jtype, location, jmessage);
+}
+
+<%- end -%>
+
+jobject ErrorFromCStruct(JNIEnv* env, ERROR_T* error) {
+ if (!error) { return NULL; }
+
+ switch (error->type) {
+ <%- errors.each do |error| -%>
+ case <%= error.type %>:
+ return <%= error.name %>FromCStruct(env, (<%= error.struct_type %>*) error);
+ <%- end -%>
+ default:
+ return NULL;
+ }
+}
+
+jobject ErrorsArrayFromCArray(JNIEnv* env, hb_array_T* array) {
+ jclass arrayListClass = (*env)->FindClass(env, "java/util/ArrayList");
+ jmethodID arrayListConstructor = (*env)->GetMethodID(env, arrayListClass, "", "(I)V");
+ jmethodID addMethod = (*env)->GetMethodID(env, arrayListClass, "add", "(Ljava/lang/Object;)Z");
+
+ jobject javaList = (*env)->NewObject(env, arrayListClass, arrayListConstructor, 0);
+
+ if (array) {
+ for (size_t i = 0; i < hb_array_size(array); i++) {
+ ERROR_T* error = (ERROR_T*) hb_array_get(array, i);
+
+ if (error) {
+ jobject errorObj = ErrorFromCStruct(env, error);
+ if (errorObj) {
+ (*env)->CallBooleanMethod(env, javaList, addMethod, errorObj);
+ }
+ }
+ }
+ }
+
+ return javaList;
+}
+
+jobject CreateErrorNode(JNIEnv* env, AST_NODE_T* error_node) {
+ if (!error_node) { return NULL; }
+
+ switch (error_node->type) {
+ <%- errors.each do |error| -%>
+ case <%= error.type %>:
+ return <%= error.name %>FromCStruct(env, (<%= error.struct_type %>*) error_node);
+ <%- end -%>
+ default:
+ return NULL;
+ }
+}
diff --git a/templates/java/error_helpers.h.erb b/templates/java/error_helpers.h.erb
new file mode 100644
index 000000000..f6ad7524f
--- /dev/null
+++ b/templates/java/error_helpers.h.erb
@@ -0,0 +1,20 @@
+#ifndef HERB_JNI_ERROR_HELPERS_H
+#define HERB_JNI_ERROR_HELPERS_H
+
+#include
+
+#include "../../src/include/ast_node.h"
+#include "../../src/include/util/hb_array.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+jobject CreateErrorNode(JNIEnv* env, AST_NODE_T* error_node);
+jobject ErrorsArrayFromCArray(JNIEnv* env, hb_array_T* array);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/templates/java/nodes.c.erb b/templates/java/nodes.c.erb
new file mode 100644
index 000000000..dd1d6111d
--- /dev/null
+++ b/templates/java/nodes.c.erb
@@ -0,0 +1,97 @@
+#include "nodes.h"
+#include "extension_helpers.h"
+#include "error_helpers.h"
+
+#include "../../src/include/ast_node.h"
+#include "../../src/include/ast_nodes.h"
+#include "../../src/include/location.h"
+#include "../../src/include/util/hb_array.h"
+
+#include
+
+jobject NodeFromCStruct(JNIEnv* env, AST_NODE_T* node);
+jobject NodesArrayFromCArray(JNIEnv* env, hb_array_T* array);
+
+<%- nodes.each do |node| -%>
+jobject <%= node.name %>FromCStruct(JNIEnv* env, <%= node.struct_type %>* <%= node.human %>) {
+ if (!<%= node.human %>) { return NULL; }
+
+ jclass nodeClass = (*env)->FindClass(env, "org/herb/ast/<%= node.name %>");
+ if (!nodeClass) { return NULL; }
+
+ jstring type = (*env)->NewStringUTF(env, "<%= node.name %>");
+ jobject location = CreateLocation(env, <%= node.human %>->base.location);
+ jobject errors = ErrorsArrayFromCArray(env, <%= node.human %>->base.errors);
+
+ <%- node.fields.each do |field| -%>
+ <%- if field.is_a?(Herb::Template::StringField) -%>
+ jstring <%= field.name %> = (*env)->NewStringUTF(env, <%= node.human %>-><%= field.name %>);
+ <%- elsif field.is_a?(Herb::Template::TokenField) -%>
+ jobject <%= field.name %> = <%= node.human %>-><%= field.name %> ? CreateToken(env, <%= node.human %>-><%= field.name %>) : NULL;
+ <%- elsif field.is_a?(Herb::Template::BooleanField) -%>
+ jboolean <%= field.name %> = <%= node.human %>-><%= field.name %> ? JNI_TRUE : JNI_FALSE;
+ <%- elsif field.is_a?(Herb::Template::ArrayField) -%>
+ jobject <%= field.name %> = NodesArrayFromCArray(env, <%= node.human %>-><%= field.name %>);
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ jobject <%= field.name %> = <%= node.human %>-><%= field.name %> ? NodeFromCStruct(env, (AST_NODE_T*) <%= node.human %>-><%= field.name %>) : NULL;
+ <%- elsif field.is_a?(Herb::Template::ElementSourceField) -%>
+ // TODO: Convert element_source to string
+ jstring <%= field.name %> = (*env)->NewStringUTF(env, "");
+ <%- elsif field.is_a?(Herb::Template::AnalyzedRubyField) || field.is_a?(Herb::Template::PrismNodeField) -%>
+ // Skip <%= field.name %> (<%= field.class.name.split('::').last %>) - not supported in Java yet
+ <%- end -%>
+ <%- end -%>
+
+ const char* signature = "(Ljava/lang/String;Lorg/herb/Location;Ljava/util/List;<%- node.fields.each do |f| -%><%- unless f.is_a?(Herb::Template::AnalyzedRubyField) || f.is_a?(Herb::Template::PrismNodeField) -%><%- if f.is_a?(Herb::Template::StringField) -%>Ljava/lang/String;<%- elsif f.is_a?(Herb::Template::TokenField) -%>Lorg/herb/Token;<%- elsif f.is_a?(Herb::Template::BooleanField) -%>Z<%- elsif f.is_a?(Herb::Template::ArrayField) -%>Ljava/util/List;<%- elsif f.is_a?(Herb::Template::NodeField) -%>Lorg/herb/ast/<%= f.specific_kind || 'Node' %>;<%- elsif f.is_a?(Herb::Template::ElementSourceField) -%>Ljava/lang/String;<%- end -%><%- end -%><%- end -%>)V";
+ jmethodID constructor = (*env)->GetMethodID(env, nodeClass, "", signature);
+ if (!constructor) { return NULL; }
+
+ jobject result = (*env)->NewObject(env, nodeClass, constructor, type, location, errors<%- node.fields.each do |field| -%><%- unless field.is_a?(Herb::Template::AnalyzedRubyField) || field.is_a?(Herb::Template::PrismNodeField) -%>, <%= field.name %><%- end -%><%- end -%>);
+
+ return result;
+}
+
+<%- end -%>
+
+jobject NodeFromCStruct(JNIEnv* env, AST_NODE_T* node) {
+ if (!node) { return NULL; }
+
+ switch (node->type) {
+ <%- nodes.each do |node| -%>
+ case <%= node.type %>:
+ return <%= node.name %>FromCStruct(env, (<%= node.struct_type %>*) node);
+ <%- end -%>
+ default:
+ return NULL;
+ }
+}
+
+jobject NodesArrayFromCArray(JNIEnv* env, hb_array_T* array) {
+ jclass arrayListClass = (*env)->FindClass(env, "java/util/ArrayList");
+ jmethodID arrayListConstructor = (*env)->GetMethodID(env, arrayListClass, "", "(I)V");
+ jmethodID addMethod = (*env)->GetMethodID(env, arrayListClass, "add", "(Ljava/lang/Object;)Z");
+
+ if (!array) {
+ return (*env)->NewObject(env, arrayListClass, arrayListConstructor, 0);
+ }
+
+ jobject javaList = (*env)->NewObject(env, arrayListClass, arrayListConstructor, (jint) hb_array_size(array));
+
+ for (size_t i = 0; i < hb_array_size(array); i++) {
+ AST_NODE_T* child_node = (AST_NODE_T*) hb_array_get(array, i);
+
+ if (child_node) {
+ jobject nodeObj = NodeFromCStruct(env, child_node);
+ (*env)->CallBooleanMethod(env, javaList, addMethod, nodeObj);
+ }
+ }
+
+ return javaList;
+}
+
+<%- nodes.each do |node| -%>
+jobject Create<%= node.name %>(JNIEnv* env, <%= node.struct_type %>* <%= node.human %>) {
+ return <%= node.name %>FromCStruct(env, <%= node.human %>);
+}
+
+<%- end -%>
diff --git a/templates/java/nodes.h.erb b/templates/java/nodes.h.erb
new file mode 100644
index 000000000..f0fa21c4f
--- /dev/null
+++ b/templates/java/nodes.h.erb
@@ -0,0 +1,23 @@
+#ifndef HERB_JNI_NODES_H
+#define HERB_JNI_NODES_H
+
+#include
+
+#include "../../src/include/ast_nodes.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+<%- nodes.each do |node| -%>
+jobject Create<%= node.name %>(JNIEnv* env, <%= node.struct_type %>* node);
+<%- end -%>
+
+jobject CreateErrorNode(JNIEnv* env, AST_NODE_T* error_node);
+jobject CreateASTNode(JNIEnv* env, AST_NODE_T* node);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/templates/java/org/herb/ast/Errors.java.erb b/templates/java/org/herb/ast/Errors.java.erb
new file mode 100644
index 000000000..13c6bbd1b
--- /dev/null
+++ b/templates/java/org/herb/ast/Errors.java.erb
@@ -0,0 +1,121 @@
+package org.herb.ast;
+
+import org.herb.Location;
+import org.herb.Token;
+
+/**
+ * Base class for all Herb parsing errors.
+ */
+abstract class HerbError {
+ private final String type;
+ private final String message;
+ private final Location location;
+
+ public HerbError(String type, String message, Location location) {
+ this.type = type;
+ this.message = message;
+ this.location = location;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("HerbError{type='%s', message='%s', location=%s}", type, message, location);
+ }
+
+ public abstract String treeInspect();
+
+ protected String indent(String str, int level) {
+ if (level == 0) return str;
+ String prefix = " ".repeat(level);
+ return str.lines()
+ .map(line -> prefix + line)
+ .collect(java.util.stream.Collectors.joining("\n"));
+ }
+}
+
+<%- errors.each do |error| -%>
+/**
+ * <%= error.name %>: <%= error.message_template %>
+ */
+class <%= error.name %> extends HerbError {
+ <%- error.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ private final String <%= field.name %>;
+ <%- when Herb::Template::TokenField -%>
+ private final Token <%= field.name %>;
+ <%- when Herb::Template::TokenTypeField -%>
+ private final String <%= field.name %>;
+ <%- else -%>
+ // Unsupported field type: <%= field.class %>
+ <%- end -%>
+ <%- end -%>
+
+ public <%= error.name %>(String type, String message, Location location<%- error.fields.each do |field| -%><%- case field -%><%- when Herb::Template::StringField -%>, String <%= field.name %><%- when Herb::Template::TokenField -%>, Token <%= field.name %><%- when Herb::Template::TokenTypeField -%>, String <%= field.name %><%- end -%><%- end -%>) {
+ super(type, message, location);
+ <%- error.fields.each do |field| -%>
+ this.<%= field.name %> = <%= field.name %>;
+ <%- end -%>
+ }
+
+ <%- error.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ public String get<%= field.name.capitalize %>() {
+ return <%= field.name %>;
+ }
+
+ <%- when Herb::Template::TokenField -%>
+ public Token get<%= field.name.capitalize %>() {
+ return <%= field.name %>;
+ }
+
+ <%- when Herb::Template::TokenTypeField -%>
+ public String get<%= field.name.capitalize %>() {
+ return <%= field.name %>;
+ }
+
+ <%- end -%>
+ <%- end -%>
+ @Override
+ public String treeInspect() {
+ StringBuilder output = new StringBuilder();
+
+ output.append("@ <%= error.name %> ");
+ output.append(getLocation() != null ? "(location: " + getLocation().toString() + ")" : "no-location");
+ output.append("\n");
+ <%- symbol = error.fields.any? ? "├──" : "└──" -%>
+ output.append("<%= symbol %> message: ");
+ output.append("\"").append(getMessage()).append("\"");
+ output.append("\n");
+ <%- error.fields.each do |field| -%>
+ <%- symbol = error.fields.last == field ? "└──" : "├──" -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField, Herb::Template::TokenTypeField -%>
+ output.append("<%= symbol %> <%= field.name %>: ");
+ output.append("\"").append(<%= field.name %>).append("\"");
+ output.append("\n");
+ <%- when Herb::Template::TokenField -%>
+ output.append("<%= symbol %> <%= field.name %>: ");
+ output.append(<%= field.name %> != null ? <%= field.name %>.treeInspect() : "∅");
+ output.append("\n");
+ <%- end -%>
+ <%- end -%>
+
+ return output.toString();
+ }
+}
+
+<%- end -%>
diff --git a/templates/java/org/herb/ast/NodeVisitor.java.erb b/templates/java/org/herb/ast/NodeVisitor.java.erb
new file mode 100644
index 000000000..7ee65a93b
--- /dev/null
+++ b/templates/java/org/herb/ast/NodeVisitor.java.erb
@@ -0,0 +1,14 @@
+package org.herb.ast;
+
+/**
+ * Visitor interface for traversing AST nodes.
+ *
+ * @param the return type
+ * @param the context type
+ */
+public interface NodeVisitor {
+ R visitNode(Node node, C context);
+ <%- nodes.each do |node| -%>
+ R visit<%= node.name %>(<%=node.name %> node, C context);
+ <%- end -%>
+}
diff --git a/templates/java/org/herb/ast/Nodes.java.erb b/templates/java/org/herb/ast/Nodes.java.erb
new file mode 100644
index 000000000..d0e91b51c
--- /dev/null
+++ b/templates/java/org/herb/ast/Nodes.java.erb
@@ -0,0 +1,187 @@
+package org.herb.ast;
+
+import org.herb.Location;
+import org.herb.Token;
+
+import java.util.List;
+
+<%- nodes.each do |node| -%>
+class <%= node.name %> extends BaseNode {
+ <%- if node.fields.any? -%>
+ <%- node.fields.each do |field| -%>
+ <%- if field.is_a?(Herb::Template::StringField) -%>
+ private final String <%= field.name %>;
+ <%- elsif field.is_a?(Herb::Template::TokenField) -%>
+ private final Token <%= field.name %>;
+ <%- elsif field.is_a?(Herb::Template::BooleanField) -%>
+ private final boolean <%= field.name %>;
+ <%- elsif field.is_a?(Herb::Template::ArrayField) -%>
+ private final List <%= field.name %>;
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ <%- if field.specific_kind -%>
+ private final <%= field.specific_kind %> <%= field.name %>;
+ <%- else -%>
+ private final Node <%= field.name %>;
+ <%- end -%>
+ <%- elsif field.is_a?(Herb::Template::ElementSourceField) -%>
+ private final String <%= field.name %>;
+ <%- else -%>
+ // private final Object <%= field.name %>;
+ <%- end -%>
+ <%- end -%>
+ <%- end -%>
+
+ public <%= node.name %>(
+ String type,
+ Location location,
+ List errors<%- if node.fields.any? %>,<% end %>
+ <%- if node.fields.any? -%>
+ <%- node.fields.each do |field| -%>
+ <%- if field.is_a?(Herb::Template::StringField) -%>
+ String <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- elsif field.is_a?(Herb::Template::TokenField) -%>
+ Token <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- elsif field.is_a?(Herb::Template::BooleanField) -%>
+ boolean <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- elsif field.is_a?(Herb::Template::ArrayField) -%>
+ List <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ <%= field.specific_kind || 'Node' %> <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- elsif field.is_a?(Herb::Template::ElementSourceField) -%>
+ String <%= field.name %><%- if node.fields.last != field %>,<% end %>
+ <%- else -%>
+ // <%= field.c_type %> <%= field.name %>
+ <%- end -%>
+ <%- end -%>
+ <%- end -%>
+ ) {
+ super(type, location, errors);
+
+ <%- if node.fields.any? -%>
+ <%- node.fields.each do |field| -%>
+ <%- unless field.is_a?(Herb::Template::AnalyzedRubyField) || field.is_a?(Herb::Template::PrismNodeField) -%>
+ this.<%= field.name %> = <%= field.name %>;
+ <%- else -%>
+ // this.<%= field.name %> = <%= field.name %>;
+ <%- end -%>
+ <%- end -%>
+ <%- end -%>
+ }
+
+ <%- if node.fields.any? -%>
+ <%- node.fields.each do |field| -%>
+ <%- if field.is_a?(Herb::Template::StringField) -%>
+ public String get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- elsif field.is_a?(Herb::Template::TokenField) -%>
+ public Token get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- elsif field.is_a?(Herb::Template::BooleanField) -%>
+ public boolean is<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- elsif field.is_a?(Herb::Template::ArrayField) -%>
+ public List get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ public <%= field.specific_kind || 'Node' %> get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- elsif field.is_a?(Herb::Template::ElementSourceField) -%>
+ public String get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+
+ <%- else -%>
+ /*
+ public Object get<%= field.name.split('_').map(&:capitalize).join %>() {
+ return <%= field.name %>;
+ }
+ */
+
+ <%- end -%>
+ <%- end -%>
+ @Override
+ public R accept(NodeVisitor visitor, C context) {
+ return visitor.visit<%= node.name %>(this, context);
+ }
+
+ @Override
+ public void visitChildren(NodeVisitor visitor, C context) {
+ <%- if node.fields.any? -%>
+ <%- node.fields.each do |field| -%>
+ <%- if field.is_a?(Herb::Template::ArrayField) -%>
+ if (<%= field.name %> != null) {
+ for (Node child : <%= field.name %>) {
+ if (child != null) child.accept(visitor, context);
+ }
+ }
+
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ if (<%= field.name %> != null) <%= field.name %>.accept(visitor, context);
+
+ <%- end -%>
+ <%- end -%>
+ <%- else -%>
+ // No children to visit
+ <%- end -%>
+ }
+
+ @Override
+ public String treeInspect() {
+ StringBuilder output = new StringBuilder();
+
+ output.append("@ <%= node.name %> ").append(location != null ? "(location: " + location.toString() + ")" : "no-location").append("\n");
+ <%- if node.fields.any? -%>
+ output.append(inspectErrors("│ "));
+ <%- else -%>
+ output.append(inspectErrors(" "));
+ <%- end -%>
+ <%- if node.fields.any? -%>
+ <%- node.fields.each_with_index do |field, index| -%>
+ <%- is_last = index == node.fields.length - 1 -%>
+ <%- symbol = is_last ? "└── " : "├── " -%>
+ <%- prefix = is_last ? " " : "│ " -%>
+ <%- if field.is_a?(Herb::Template::StringField) || field.is_a?(Herb::Template::ElementSourceField) -%>
+ output.append("<%= symbol %><%= field.name %>: ").append(<%= field.name %> != null ? "\"" + <%= field.name %>.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replace("\"", "\\\"") + "\"" : "∅").append("\n");
+ <%- elsif field.is_a?(Herb::Template::TokenField) -%>
+ output.append("<%= symbol %><%= field.name %>: ").append(<%= field.name %> != null ? <%= field.name %>.treeInspect() : "∅").append("\n");
+ <%- elsif field.is_a?(Herb::Template::BooleanField) -%>
+ output.append("<%= symbol %><%= field.name %>: ").append(<%= field.name %>).append("\n");
+ <%- elsif field.is_a?(Herb::Template::ArrayField) -%>
+ output.append("<%= symbol %><%= field.name %>: ").append(inspectArray(<%= field.name %>, "<%= prefix %>"));
+ <%- elsif field.is_a?(Herb::Template::NodeField) -%>
+ if (<%= field.name %> != null) {
+ output.append("<%= symbol %><%= field.name %>:\n");
+ output.append("<%= prefix %>").append(inspectNode(<%= field.name %>, "<%= prefix %>"));
+ <%- unless is_last -%>
+ output.append("<%= prefix %>").append("\n");
+ <%- end -%>
+ } else {
+ output.append("<%= symbol %><%= field.name %>: ∅\n");
+ }
+ <%- end -%>
+ <%- end -%>
+ <%- else -%>
+ output.append("└── (no fields)\n");
+ <%- end -%>
+
+ return output.toString();
+ }
+
+ @Override
+ public String toString() {
+ return "<%= node.name %> {}";
+ }
+}
+
+<%- end -%>
+<%- end -%>
diff --git a/templates/java/org/herb/ast/Visitor.java.erb b/templates/java/org/herb/ast/Visitor.java.erb
new file mode 100644
index 000000000..10cdcd905
--- /dev/null
+++ b/templates/java/org/herb/ast/Visitor.java.erb
@@ -0,0 +1,56 @@
+package org.herb.ast;
+
+import java.util.List;
+
+/**
+ * Abstract base class for node visitors that provides default implementations.
+ * Subclasses can override specific visit methods to customize behavior.
+ *
+ * @param the return type
+ * @param the context type
+ */
+public abstract class Visitor implements NodeVisitor {
+
+ /**
+ * Default visit method that can be overridden by subclasses.
+ */
+ @Override
+ public R visitNode(Node node, C context) {
+ visitChildNodes(node, context);
+
+ return null;
+ }
+
+ /**
+ * Visit all child nodes of a given node.
+ * Subclasses can call this to recursively visit children.
+ */
+ protected void visitChildNodes(Node node, C context) {
+ if (node != null) {
+ node.visitChildren(this, context);
+ }
+ }
+
+ /**
+ * Visit a list of nodes.
+ */
+ protected void visitAll(List nodes, C context) {
+ if (nodes == null) return;
+
+ for (Node node : nodes) {
+ if (node != null) node.accept(this, context);
+ }
+ }
+
+ <%- nodes.each do |node| -%>
+ /**
+ * Default implementation for <%= node.name %>.
+ * Delegates to visitNode by default.
+ */
+ @Override
+ public R visit<%= node.name %>(<%= node.name %> node, C context) {
+ return visitNode(node, context);
+ }
+
+ <%- end -%>
+}