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: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: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: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 -%> +}