diff --git a/.envrc b/.envrc
index 284677508..b391dfa7a 100644
--- a/.envrc
+++ b/.envrc
@@ -5,3 +5,4 @@ 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"
+export PATH="$PWD/rust/bin:$PATH"
diff --git a/.gitattributes b/.gitattributes
index 17cf88579..2ec249f00 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,6 +5,7 @@ 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/**/*.rs.erb linguist-language=Rust
templates/**/*.ts.erb linguist-language=TypeScript
# Template-generated RBS files
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 55f91577d..25249c340 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -22,6 +22,7 @@ rust:
- changed-files:
- any-glob-to-any-file:
- '**/*.rs'
+ - '**/*.rs.erb'
- '**/Cargo.toml'
- '**/Cargo.lock'
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
new file mode 100644
index 000000000..114bacda1
--- /dev/null
+++ b/.github/workflows/rust.yml
@@ -0,0 +1,68 @@
+name: Rust
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Rust
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: stable
+ components: clippy
+
+ - name: Set up Rust Nightly (for rustfmt)
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: nightly
+ components: rustfmt
+
+ - name: Rust Cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: rust
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: bundle install
+ run: bundle install
+
+ - name: Render Templates
+ run: bundle exec rake templates
+
+ - name: Compile Herb
+ run: bundle exec rake make
+
+ - name: Check Rust formatting
+ run: make format-check
+ working-directory: rust
+
+ - name: Clippy
+ run: make lint
+ working-directory: rust
+
+ - name: Build Rust
+ run: make build
+ working-directory: rust
+
+ - name: Run Rust tests
+ run: make test
+ working-directory: rust
diff --git a/.gitignore b/.gitignore
index 61604e4c5..c2c1bde21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,6 +108,9 @@ javascript/packages/node/extension/nodes.h
lib/herb/ast/nodes.rb
lib/herb/errors.rb
lib/herb/visitor.rb
+rust/src/ast/nodes.rs
+rust/src/errors.rs
+rust/src/nodes.rs
sig/serialized_ast_errors.rbs
sig/serialized_ast_nodes.rbs
src/ast_nodes.c
@@ -126,6 +129,10 @@ wasm/nodes.h
java/target/
java/.java_compiled
+# Rust Build Artifacts
+rust/target/
+rust/Cargo.lock
+
# NX Monorepo
.nx/
.nx/cache
diff --git a/.rubocop.yml b/.rubocop.yml
index 27c6e3532..793a7639e 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -93,6 +93,7 @@ Metrics/ClassLength:
Metrics/ModuleLength:
Exclude:
- test/**/*.rb
+ - templates/**/*.rb
Metrics/BlockLength:
Max: 30
@@ -118,6 +119,7 @@ Metrics/PerceivedComplexity:
- lib/herb/project.rb
- lib/herb/engine.rb
- lib/herb/engine/**/*.rb
+ - templates/template.rb
- test/**/*.rb
- bin/**/*
diff --git a/docs/.vitepress/config/theme.mts b/docs/.vitepress/config/theme.mts
index 0a19e2f10..329cc6bc7 100644
--- a/docs/.vitepress/config/theme.mts
+++ b/docs/.vitepress/config/theme.mts
@@ -98,6 +98,14 @@ const defaultSidebar = [
{ text: "Reference", link: "/bindings/java/reference" },
],
},
+ {
+ text: "Rust",
+ collapsed: false,
+ items: [
+ { text: "Installation", link: "/bindings/rust/" },
+ { text: "Reference", link: "/bindings/rust/reference" },
+ ],
+ },
{ text: "WebAssembly", link: "/projects/webassembly" },
],
},
diff --git a/docs/docs/bindings/java/index.md b/docs/docs/bindings/java/index.md
index 12322d281..6ea7950c3 100644
--- a/docs/docs/bindings/java/index.md
+++ b/docs/docs/bindings/java/index.md
@@ -10,6 +10,7 @@ Herb provides official Java bindings through JNI (Java Native Interface) to the
> Herb also has bindings for:
> - [Ruby](/bindings/ruby/)
> - [JavaScript/Node.js](/bindings/javascript/)
+> - [Rust](/bindings/rust/)
## Installation
diff --git a/docs/docs/bindings/javascript/index.md b/docs/docs/bindings/javascript/index.md
index 9447ffc67..6600c45ad 100644
--- a/docs/docs/bindings/javascript/index.md
+++ b/docs/docs/bindings/javascript/index.md
@@ -8,6 +8,7 @@ Herb supports both **browser** and **Node.js** environments with separate packag
> Herb also has bindings for:
> - [Ruby](/bindings/ruby/)
> - [Java](/bindings/java/)
+> - [Rust](/bindings/rust/)
## Installation
diff --git a/docs/docs/bindings/ruby/index.md b/docs/docs/bindings/ruby/index.md
index 296994173..0ea75d4b2 100644
--- a/docs/docs/bindings/ruby/index.md
+++ b/docs/docs/bindings/ruby/index.md
@@ -10,6 +10,7 @@ Herb is bundled and packaged up as a precompiled RubyGem and available to be ins
> Herb also has bindings for:
> - [JavaScript/Node.js](/bindings/javascript/)
> - [Java](/bindings/java/)
+> - [Rust](/bindings/rust/)
## Installation
diff --git a/docs/docs/bindings/rust/index.md b/docs/docs/bindings/rust/index.md
new file mode 100644
index 000000000..f22747a7c
--- /dev/null
+++ b/docs/docs/bindings/rust/index.md
@@ -0,0 +1,85 @@
+---
+outline: deep
+---
+
+# Herb Rust Bindings
+
+Herb provides official Rust bindings through FFI (Foreign Function Interface) to the C library, allowing you to parse HTML+ERB in Rust projects with native performance.
+
+> [!TIP] More Language Bindings
+> Herb also has bindings for:
+> - [Ruby](/bindings/ruby/)
+> - [JavaScript/Node.js](/bindings/javascript/)
+
+## Installation
+
+Add the dependency to your `Cargo.toml`:
+
+:::code-group
+```toml [Cargo.toml]
+[dependencies]
+herb = "0.7.5"
+```
+:::
+
+Or use `cargo` to add the dependency to your project:
+
+:::code-group
+```shell
+cargo add herb
+```
+:::
+
+## Getting Started
+
+Import the `herb` crate in your project:
+
+:::code-group
+```rust
+use herb::{parse, lex};
+```
+:::
+
+You are now ready to parse HTML+ERB in Rust.
+
+### Basic Example
+
+Here's a simple example of parsing HTML+ERB:
+
+:::code-group
+```rust
+use herb::parse;
+
+fn main() {
+ let source = "
<%= user.name %> ";
+
+ match parse(source) {
+ Ok(result) => {
+ println!("{}", result.tree_inspect());
+ }
+ Err(e) => {
+ eprintln!("Parse error: {}", e);
+ }
+ }
+}
+```
+:::
+
+### Lexing Example
+
+You can also tokenize HTML+ERB source:
+
+:::code-group
+```rust
+use herb::lex;
+
+fn main() {
+ let source = "<%= user.name %> ";
+ let result = lex(source);
+
+ for token in result.tokens() {
+ println!("{}", token.inspect());
+ }
+}
+```
+:::
diff --git a/docs/docs/bindings/rust/reference.md b/docs/docs/bindings/rust/reference.md
new file mode 100644
index 000000000..71a79b463
--- /dev/null
+++ b/docs/docs/bindings/rust/reference.md
@@ -0,0 +1,318 @@
+---
+outline: deep
+---
+
+# Rust Reference
+
+The `herb` crate exposes functions for lexing, parsing, and extracting Ruby and HTML from HTML+ERB source code.
+
+## Rust API
+
+`herb` provides the following key functions:
+
+* `herb::lex(source)`
+* `herb::parse(source)`
+* `herb::extract_ruby(source)`
+* `herb::extract_html(source)`
+* `herb::version()`
+* `herb::herb_version()`
+* `herb::prism_version()`
+
+## Lexing
+
+The `herb::lex` function tokenizes an HTML document with embedded Ruby and returns a `LexResult` containing all tokens.
+
+### `herb::lex(source: &str) -> LexResult`
+
+:::code-group
+```rust
+use herb::lex;
+
+let source = "Hello <%= user.name %>
";
+let result = lex(source);
+
+for token in result.tokens() {
+ println!("{}", token.inspect());
+}
+// Output:
+// #
+// #
+// #
+// ...
+```
+:::
+
+### `LexResult`
+
+The `LexResult` struct provides access to the lexed tokens:
+
+```rust
+pub struct LexResult {
+ pub tokens: Vec,
+}
+
+impl LexResult {
+ pub fn tokens(&self) -> &[Token];
+}
+```
+
+## Parsing
+
+The `herb::parse` function parses an HTML document with embedded Ruby and returns a `Result` containing the parsed AST.
+
+### `herb::parse(source: &str) -> Result`
+
+:::code-group
+```rust
+use herb::parse;
+
+let source = "Hello <%= user.name %>
";
+
+match parse(source) {
+ Ok(result) => {
+ println!("{}", result.tree_inspect());
+ }
+ Err(e) => {
+ eprintln!("Parse error: {}", e);
+ }
+}
+// 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: ""
+```
+:::
+
+### `ParseResult`
+
+The `ParseResult` struct provides access to the parsed AST and any parse-level errors:
+
+```rust
+pub struct ParseResult {
+ pub value: DocumentNode,
+ pub source: String,
+ pub errors: Vec,
+}
+
+impl ParseResult {
+ pub fn tree_inspect(&self) -> String;
+ pub fn errors(&self) -> &[AnyError];
+ pub fn recursive_errors(&self) -> Vec<&dyn ErrorNode>;
+ pub fn failed(&self) -> bool;
+ pub fn success(&self) -> bool;
+}
+```
+
+**Methods:**
+
+- `tree_inspect()` - Returns a string representation of the AST
+- `errors()` - Returns only the parse-level errors as `AnyError` enum variants
+- `recursive_errors()` - Returns parse-level errors combined with all node errors recursively as trait objects (`&dyn ErrorNode`)
+- `failed()` - Returns `true` if there are any errors (parse-level or node errors)
+- `success()` - Returns `true` if there are no errors
+
+**Example with error handling:**
+
+:::code-group
+```rust
+use herb::parse;
+
+let source = ""; // Mismatched tags
+
+match parse(source) {
+ Ok(result) => {
+ if result.failed() {
+ println!("Parsing failed with {} errors:", result.recursive_errors().len());
+
+ for error in result.recursive_errors() {
+ println!(" {} at {}: {}",
+ error.error_type(),
+ error.location(),
+ error.message()
+ );
+ }
+ } else {
+ println!("Parse successful!");
+ println!("{}", result.tree_inspect());
+ }
+ }
+ Err(e) => {
+ eprintln!("Parse error: {}", e);
+ }
+}
+```
+:::
+
+## Extracting Code
+
+### `herb::extract_ruby(source: &str) -> Result
`
+
+The `extract_ruby` function extracts only the Ruby parts of an HTML document with embedded Ruby.
+
+:::code-group
+```rust
+use herb::extract_ruby;
+
+let source = "Hello <%= user.name %>
";
+
+match extract_ruby(source) {
+ Ok(ruby) => println!("{}", ruby),
+ Err(e) => eprintln!("Error: {}", e),
+}
+// Output: " user.name "
+```
+:::
+
+### `herb::extract_html(source: &str) -> Result`
+
+The `extract_html` function extracts only the HTML parts of an HTML document with embedded Ruby.
+
+:::code-group
+```rust
+use herb::extract_html;
+
+let source = "Hello <%= user.name %>
";
+
+match extract_html(source) {
+ Ok(html) => println!("{}", html),
+ Err(e) => eprintln!("Error: {}", e),
+}
+// Output: "Hello
"
+```
+:::
+
+## Version Information
+
+### `herb::version() -> String`
+
+Returns the full version information including Herb, Prism, and FFI details:
+
+:::code-group
+```rust
+use herb::version;
+
+println!("{}", version());
+// Output: "herb rust v0.7.5, libprism v1.6.0, libherb v0.7.5 (Rust FFI)"
+```
+:::
+
+### `herb::herb_version() -> String`
+
+Returns just the Herb library version:
+
+:::code-group
+```rust
+use herb::herb_version;
+
+println!("{}", herb_version());
+// Output: "0.7.5"
+```
+:::
+
+### `herb::prism_version() -> String`
+
+Returns the Prism parser version:
+
+:::code-group
+```rust
+use herb::prism_version;
+
+println!("{}", prism_version());
+// Output: "1.6.0"
+```
+:::
+
+## AST Types
+
+The parsed AST consists of various node types that represent different parts of the document:
+
+### Core Types
+
+```rust
+// Position in the source
+pub struct Position {
+ pub line: u32,
+ pub column: u32,
+}
+
+// Location span in the source
+pub struct Location {
+ pub start: Position,
+ pub end: Position,
+}
+
+// Token from lexing
+pub struct Token {
+ pub value: String,
+ pub token_type: String,
+ pub location: Location,
+}
+```
+
+### AST Node Types
+
+All AST nodes implement the `Node` trait:
+
+```rust
+pub trait Node {
+ fn node_type(&self) -> &str;
+ fn location(&self) -> &Location;
+ fn errors(&self) -> &[AnyError];
+ fn child_nodes(&self) -> Vec<&dyn Node>;
+ fn recursive_errors(&self) -> Vec<&dyn ErrorNode>;
+ fn tree_inspect(&self) -> String;
+}
+```
+
+**Methods:**
+
+- `node_type()` - Returns the type of the node (e.g., "DocumentNode", "HTMLElementNode")
+- `location()` - Returns the source location span of the node
+- `errors()` - Returns direct errors on this node as `AnyError` enum variants
+- `child_nodes()` - Returns all child nodes as trait objects (`&dyn Node`), including both generic and specific-typed fields
+- `recursive_errors()` - Returns all errors from this node and its children recursively as trait objects (`&dyn ErrorNode`)
+- `tree_inspect()` - Returns a formatted string representation of the node and its children
+
+### Error Handling
+
+Parse errors use a trait-based system for flexibility and type safety. All errors implement the `ErrorNode` trait:
+
+```rust
+pub trait ErrorNode {
+ fn error_type(&self) -> &str;
+ fn message(&self) -> &str;
+ fn location(&self) -> &Location;
+ fn tree_inspect(&self) -> String;
+}
+```
+
+Errors remain accessible through the `errors()` method on nodes (returning `&[AnyError]`) or `recursive_errors()` (returning `Vec<&dyn ErrorNode>`), allowing you to handle them as needed.
diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml
new file mode 100644
index 000000000..5546e2060
--- /dev/null
+++ b/rust/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+rerun-if-changed = ["../src"]
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 000000000..f20b08d80
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "herb"
+version = "0.7.5"
+edition = "2021"
+authors = ["Marco Roth "]
+description = "Rust bindings for Herb"
+readme = "README.md"
+license = "MIT"
+repository = "https://github.com/marcoroth/herb"
+keywords = ["erb", "html", "html+erb", "ruby", "rails"]
+categories = ["parser-implementations", "development-tools"]
+
+[lib]
+name = "herb"
+path = "src/lib.rs"
+crate-type = ["cdylib", "rlib"]
+
+[[bin]]
+name = "herb-rust"
+path = "src/main.rs"
+
+[dependencies]
+libc = "0.2"
+
+[dev-dependencies]
+insta = "1.40"
+
+[build-dependencies]
+cc = "1.0"
+glob = "0.3"
+bindgen = "0.70"
diff --git a/rust/Makefile b/rust/Makefile
new file mode 100644
index 000000000..4390698cf
--- /dev/null
+++ b/rust/Makefile
@@ -0,0 +1,63 @@
+# Rust Makefile for Herb
+
+BUILD_DIR = target
+BIN_NAME = herb-rust
+BIN_PATH = $(BUILD_DIR)/debug/$(BIN_NAME)
+RELEASE_BIN_PATH = $(BUILD_DIR)/release/$(BIN_NAME)
+
+.PHONY: all build release clean test cli help templates format
+
+all: templates build
+
+templates:
+ cd .. && bundle exec rake templates
+
+build: templates
+ cargo +stable build --verbose --bin $(BIN_NAME)
+ @echo "Built debug binary: $(BIN_PATH)"
+
+release: templates
+ cargo build --release --bin $(BIN_NAME)
+ @echo "Built release binary: $(RELEASE_BIN_PATH)"
+
+cli: build
+ @echo "CLI ready! Use: ./bin/$(BIN_NAME) [command] [file]"
+ @echo ""
+ @./bin/$(BIN_NAME) || true
+
+test: build
+ cargo +stable test --verbose -- --test-threads=1
+
+format:
+ cargo +nightly fmt
+ @echo "Formatted Rust code"
+
+format-check:
+ cargo +nightly fmt --check
+ @echo "Checked Rust code formatting"
+
+lint:
+ cargo +stable clippy --all-targets --all-features
+ @echo "Linted Rust code"
+
+clean:
+ cargo clean
+ @echo "Cleaned Rust build artifacts"
+
+help:
+ @echo "Herb Rust Build System"
+ @echo ""
+ @echo "Targets:"
+ @echo " all - Build everything (templates + Rust)"
+ @echo " templates - Generate code from ERB templates"
+ @echo " build - Build debug binary"
+ @echo " release - Build release binary"
+ @echo " cli - Show CLI usage"
+ @echo " test - Run Rust tests"
+ @echo " format - Format Rust code with rustfmt"
+ @echo " clean - Remove build artifacts"
+ @echo " help - Show this help"
+ @echo ""
+ @echo "Binaries:"
+ @echo " Debug: $(BIN_PATH)"
+ @echo " Release: $(RELEASE_BIN_PATH)"
diff --git a/rust/README.md b/rust/README.md
new file mode 100644
index 000000000..8ee9a6e73
--- /dev/null
+++ b/rust/README.md
@@ -0,0 +1,57 @@
+# Herb Rust Bindings
+
+Rust bindings for Herb - Powerful and seamless HTML-aware ERB parsing and tooling.
+
+## Building
+
+### Prerequisites
+
+- Rust toolchain
+- Bundler with Prism gem installed in the parent directory
+
+### Build
+
+```bash
+make build # Build debug binary
+make release # Build release binary
+make all # Generate templates and build
+```
+
+## Usage
+
+### CLI
+
+```bash
+./bin/herb-rust version
+
+./bin/herb-rust lex path/to/file.erb
+
+./bin/herb-rust parse path/to/file.erb
+```
+
+### As a Library
+
+```rust
+use herb::{lex, Token};
+
+fn main() {
+ let source = r#"<%= name %>
"#;
+ let result = lex(source);
+
+ for token in result.tokens() {
+ println!("{}", token.inspect());
+ }
+}
+```
+
+## Testing
+
+```bash
+cargo test
+```
+
+## Cleaning
+
+```bash
+make clean
+```
diff --git a/rust/bin/herb-rust b/rust/bin/herb-rust
new file mode 100755
index 000000000..4beed564d
--- /dev/null
+++ b/rust/bin/herb-rust
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+RUST_DIR="$( cd "$SCRIPT_DIR/.." && pwd )"
+
+BINARY_PATH="$RUST_DIR/target/debug/herb-rust"
+
+if [ ! -f "$BINARY_PATH" ]; then
+ echo "Error: herb-rust binary not found at $BINARY_PATH"
+ echo "Please run 'make build' in the rust/ directory first."
+
+ exit 1
+fi
+
+exec "$BINARY_PATH" "$@"
diff --git a/rust/build.rs b/rust/build.rs
new file mode 100644
index 000000000..6defd4137
--- /dev/null
+++ b/rust/build.rs
@@ -0,0 +1,112 @@
+use std::env;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn main() {
+ let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
+ let root_dir = manifest_dir.parent().unwrap();
+ let src_dir = root_dir.join("src");
+ let prism_path = get_prism_path(root_dir);
+ let prism_include = prism_path.join("include");
+ let prism_build = prism_path.join("build");
+
+ println!("cargo:rustc-link-search=native={}", prism_build.display());
+ println!("cargo:rustc-link-lib=static=prism");
+
+ let mut c_sources = Vec::new();
+
+ for path in glob::glob(src_dir.join("**/*.c").to_str().unwrap())
+ .unwrap()
+ .flatten()
+ {
+ if !path.ends_with("main.c") {
+ c_sources.push(path);
+ }
+ }
+
+ let mut build = cc::Build::new();
+ build
+ .flag("-std=c99")
+ .flag("-Wall")
+ .flag("-Wextra")
+ .flag("-Wno-unused-parameter")
+ .flag("-fPIC")
+ .opt_level(2)
+ .include(src_dir.join("include"))
+ .include(&prism_include)
+ .files(&c_sources);
+
+ build.compile("herb");
+
+ let bindings = bindgen::Builder::default()
+ .header(src_dir.join("include/herb.h").to_str().unwrap())
+ .header(src_dir.join("include/ast_nodes.h").to_str().unwrap())
+ .header(src_dir.join("include/errors.h").to_str().unwrap())
+ .header(src_dir.join("include/element_source.h").to_str().unwrap())
+ .header(src_dir.join("include/token_struct.h").to_str().unwrap())
+ .header(src_dir.join("include/util/hb_string.h").to_str().unwrap())
+ .header(src_dir.join("include/util/hb_array.h").to_str().unwrap())
+ .clang_arg(format!("-I{}", src_dir.join("include").display()))
+ .clang_arg(format!("-I{}", prism_include.display()))
+ .allowlist_function("herb_.*")
+ .allowlist_function("hb_array_.*")
+ .allowlist_function("token_type_to_string")
+ .allowlist_function("ast_node_free")
+ .allowlist_function("element_source_to_string")
+ .allowlist_type("AST_.*")
+ .allowlist_type("ERROR_.*")
+ .allowlist_type(".*_ERROR_T")
+ .allowlist_type("element_source_t")
+ .allowlist_type("ast_node_type_T")
+ .allowlist_type("error_type_T")
+ .allowlist_type("hb_array_T")
+ .allowlist_type("hb_string_T")
+ .allowlist_type("token_T")
+ .allowlist_type("position_T")
+ .allowlist_type("location_T")
+ .allowlist_type("herb_extract_language_T")
+ .allowlist_var("AST_.*")
+ .allowlist_var("ERROR_.*")
+ .allowlist_var("ELEMENT_SOURCE_.*")
+ .allowlist_var("HERB_EXTRACT_.*")
+ .derive_debug(true)
+ .derive_default(false)
+ .prepend_enum_name(false)
+ .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
+ .generate()
+ .expect("Unable to generate bindings");
+
+ let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
+ bindings
+ .write_to_file(out_path.join("bindings.rs"))
+ .expect("Couldn't write bindings!");
+
+ for source in &c_sources {
+ println!("cargo:rerun-if-changed={}", source.display());
+ }
+
+ println!(
+ "cargo:rerun-if-changed={}",
+ src_dir.join("include").display()
+ );
+ println!("cargo:rerun-if-changed=build.rs");
+}
+
+fn get_prism_path(root_dir: &Path) -> PathBuf {
+ let output = Command::new("bundle")
+ .args(["show", "prism"])
+ .current_dir(root_dir)
+ .output()
+ .expect("Failed to run `bundle show prism`");
+
+ let output_str = String::from_utf8(output.stdout).expect("Failed to parse bundle output");
+
+ let path_str = output_str
+ .lines()
+ .last()
+ .expect("No output from bundle show prism")
+ .trim()
+ .to_string();
+
+ PathBuf::from(path_str)
+}
diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml
new file mode 100644
index 000000000..8f1920a1a
--- /dev/null
+++ b/rust/rustfmt.toml
@@ -0,0 +1,17 @@
+edition = "2021"
+max_width = 100
+hard_tabs = false
+tab_spaces = 2
+newline_style = "Unix"
+use_small_heuristics = "Default"
+reorder_imports = true
+reorder_modules = true
+remove_nested_parens = true
+
+# Ignore generated files from templates
+# run using `cargo +nightly fmt`
+ignore = [
+ "src/ast/nodes.rs",
+ "src/errors.rs",
+ "src/nodes.rs",
+]
diff --git a/rust/src/ast/mod.rs b/rust/src/ast/mod.rs
new file mode 100644
index 000000000..bdcd5a541
--- /dev/null
+++ b/rust/src/ast/mod.rs
@@ -0,0 +1,3 @@
+pub mod nodes;
+
+pub use nodes::convert_document_node;
diff --git a/rust/src/bindings.rs b/rust/src/bindings.rs
new file mode 100644
index 000000000..0cf64d2a3
--- /dev/null
+++ b/rust/src/bindings.rs
@@ -0,0 +1,7 @@
+#![allow(non_upper_case_globals)]
+#![allow(non_camel_case_types)]
+#![allow(non_snake_case)]
+#![allow(dead_code)]
+#![allow(clippy::all)]
+
+include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
diff --git a/rust/src/convert.rs b/rust/src/convert.rs
new file mode 100644
index 000000000..3fba6a6b9
--- /dev/null
+++ b/rust/src/convert.rs
@@ -0,0 +1,48 @@
+use crate::bindings::{location_T, position_T, range_T, token_T};
+use crate::{Location, Position, Range, Token};
+use std::ffi::CStr;
+
+impl From for Position {
+ fn from(c_position: position_T) -> Self {
+ Position::new(c_position.line, c_position.column)
+ }
+}
+
+impl From for Range {
+ fn from(c_range: range_T) -> Self {
+ Range::new(c_range.from as usize, c_range.to as usize)
+ }
+}
+
+impl From for Location {
+ fn from(c_location: location_T) -> Self {
+ Location::new(c_location.start.into(), c_location.end.into())
+ }
+}
+
+/// Converts a C token pointer to a Rust Token.
+///
+/// # Safety
+///
+/// The caller must ensure that `token_ptr` is a valid, non-null pointer to a `token_T`
+/// and that the token's string fields (`value`, `token_type`) point to valid C strings.
+pub unsafe fn token_from_c(token_ptr: *const token_T) -> Token {
+ let token = &*token_ptr;
+
+ let value = if token.value.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(token.value).to_string_lossy().into_owned()
+ };
+
+ let token_type = CStr::from_ptr(crate::ffi::token_type_to_string(token.type_))
+ .to_string_lossy()
+ .into_owned();
+
+ Token {
+ token_type,
+ value,
+ range: token.range.into(),
+ location: token.location.into(),
+ }
+}
diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs
new file mode 100644
index 000000000..98bb12fd4
--- /dev/null
+++ b/rust/src/ffi.rs
@@ -0,0 +1,9 @@
+#[repr(C)]
+pub struct ParserOptions {
+ _private: [u8; 0],
+}
+
+pub use crate::bindings::{
+ ast_node_free, element_source_to_string, hb_array_get, hb_array_size, hb_string_T, herb_extract,
+ herb_free_tokens, herb_lex, herb_parse, herb_prism_version, herb_version, token_type_to_string,
+};
diff --git a/rust/src/herb.rs b/rust/src/herb.rs
new file mode 100644
index 000000000..ecc1e9075
--- /dev/null
+++ b/rust/src/herb.rs
@@ -0,0 +1,116 @@
+use crate::bindings::{hb_array_T, token_T};
+use crate::convert::token_from_c;
+use crate::{LexResult, ParseResult};
+use std::ffi::CString;
+
+pub fn lex(source: &str) -> LexResult {
+ unsafe {
+ let c_source = CString::new(source).expect("Failed to create CString");
+ let c_tokens = crate::ffi::herb_lex(c_source.as_ptr());
+
+ if c_tokens.is_null() {
+ return LexResult::new(Vec::new());
+ }
+
+ let array_size = crate::ffi::hb_array_size(c_tokens);
+ let mut tokens = Vec::with_capacity(array_size);
+
+ for index in 0..array_size {
+ let token_ptr = crate::ffi::hb_array_get(c_tokens, index) as *const token_T;
+
+ if !token_ptr.is_null() {
+ tokens.push(token_from_c(token_ptr));
+ }
+ }
+
+ let mut c_tokens_ptr = c_tokens;
+ crate::ffi::herb_free_tokens(&mut c_tokens_ptr as *mut *mut hb_array_T);
+
+ LexResult::new(tokens)
+ }
+}
+
+pub fn parse(source: &str) -> Result {
+ unsafe {
+ let c_source = CString::new(source).map_err(|e| e.to_string())?;
+ let ast = crate::ffi::herb_parse(c_source.as_ptr(), std::ptr::null_mut());
+
+ if ast.is_null() {
+ return Err("Failed to parse source".to_string());
+ }
+
+ let document_node = crate::ast::convert_document_node(ast as *const std::ffi::c_void)
+ .ok_or_else(|| "Failed to convert AST".to_string())?;
+
+ let result = ParseResult::new(document_node, source.to_string(), Vec::new());
+
+ crate::ffi::ast_node_free(ast as *mut crate::bindings::AST_NODE_T);
+
+ Ok(result)
+ }
+}
+
+pub fn extract_ruby(source: &str) -> Result {
+ unsafe {
+ let c_source = CString::new(source).map_err(|e| e.to_string())?;
+ let result = crate::ffi::herb_extract(
+ c_source.as_ptr(),
+ crate::bindings::HERB_EXTRACT_LANGUAGE_RUBY,
+ );
+
+ if result.is_null() {
+ return Ok(String::new());
+ }
+
+ let c_str = std::ffi::CStr::from_ptr(result);
+ let rust_str = c_str.to_string_lossy().into_owned();
+
+ libc::free(result as *mut std::ffi::c_void);
+
+ Ok(rust_str)
+ }
+}
+
+pub fn extract_html(source: &str) -> Result {
+ unsafe {
+ let c_source = CString::new(source).map_err(|e| e.to_string())?;
+ let result = crate::ffi::herb_extract(
+ c_source.as_ptr(),
+ crate::bindings::HERB_EXTRACT_LANGUAGE_HTML,
+ );
+
+ if result.is_null() {
+ return Ok(String::new());
+ }
+
+ let c_str = std::ffi::CStr::from_ptr(result);
+ let rust_str = c_str.to_string_lossy().into_owned();
+
+ libc::free(result as *mut std::ffi::c_void);
+
+ Ok(rust_str)
+ }
+}
+
+pub fn herb_version() -> String {
+ unsafe {
+ let c_str = std::ffi::CStr::from_ptr(crate::ffi::herb_version());
+ c_str.to_string_lossy().into_owned()
+ }
+}
+
+pub fn prism_version() -> String {
+ unsafe {
+ let c_str = std::ffi::CStr::from_ptr(crate::ffi::herb_prism_version());
+ c_str.to_string_lossy().into_owned()
+ }
+}
+
+pub fn version() -> String {
+ format!(
+ "herb rust v{}, libprism v{}, libherb v{} (Rust FFI)",
+ herb_version(),
+ prism_version(),
+ herb_version()
+ )
+}
diff --git a/rust/src/lex_result.rs b/rust/src/lex_result.rs
new file mode 100644
index 000000000..3c776744e
--- /dev/null
+++ b/rust/src/lex_result.rs
@@ -0,0 +1,15 @@
+use crate::Token;
+
+pub struct LexResult {
+ pub tokens: Vec,
+}
+
+impl LexResult {
+ pub fn new(tokens: Vec) -> Self {
+ Self { tokens }
+ }
+
+ pub fn tokens(&self) -> &[Token] {
+ &self.tokens
+ }
+}
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
new file mode 100644
index 000000000..87329dbdc
--- /dev/null
+++ b/rust/src/lib.rs
@@ -0,0 +1,57 @@
+pub mod ast;
+pub mod bindings;
+pub mod convert;
+pub mod errors;
+pub mod ffi;
+pub mod herb;
+pub mod lex_result;
+pub mod location;
+pub mod nodes;
+pub mod parse_result;
+pub mod position;
+pub mod range;
+pub mod token;
+
+pub use errors::{AnyError, ErrorNode, ErrorType};
+pub use herb::{extract_html, extract_ruby, herb_version, lex, parse, prism_version, version};
+pub use lex_result::LexResult;
+pub use location::Location;
+pub use nodes::{AnyNode, Node};
+pub use parse_result::ParseResult;
+pub use position::Position;
+pub use range::Range;
+pub use token::Token;
+
+pub const VERSION: &str = "0.7.5";
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::nodes::{DocumentNode, HTMLTextNode};
+
+ #[test]
+ fn test_tree_inspect() {
+ let loc = Location::new(Position::new(1, 0), Position::new(1, 5));
+
+ let text_node = HTMLTextNode {
+ node_type: "HTMLTextNode".to_string(),
+ location: loc,
+ errors: vec![],
+ content: "Hello".to_string(),
+ };
+
+ let doc_node = DocumentNode {
+ node_type: "DocumentNode".to_string(),
+ location: Location::new(Position::new(1, 0), Position::new(2, 0)),
+ errors: vec![],
+ children: vec![AnyNode::HTMLTextNode(text_node)],
+ };
+
+ let output = doc_node.tree_inspect();
+
+ assert!(output.contains("@ DocumentNode"));
+ assert!(output.contains("children: (1 item)"));
+ assert!(output.contains("@ HTMLTextNode"));
+ assert!(output.contains("Hello"));
+ }
+}
diff --git a/rust/src/location.rs b/rust/src/location.rs
new file mode 100644
index 000000000..2e66cc0bd
--- /dev/null
+++ b/rust/src/location.rs
@@ -0,0 +1,24 @@
+use crate::position::Position;
+use std::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Location {
+ pub start: Position,
+ pub end: Position,
+}
+
+impl Location {
+ pub fn new(start: Position, end: Position) -> Self {
+ Self { start, end }
+ }
+
+ pub fn inspect(&self) -> String {
+ format!("{}-{}", self.start, self.end)
+ }
+}
+
+impl fmt::Display for Location {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.inspect())
+ }
+}
diff --git a/rust/src/main.rs b/rust/src/main.rs
new file mode 100644
index 000000000..abc3eb30c
--- /dev/null
+++ b/rust/src/main.rs
@@ -0,0 +1,145 @@
+fn main() {
+ let args: Vec = std::env::args().collect();
+
+ if args.len() < 2 {
+ print_usage();
+ std::process::exit(1);
+ }
+
+ let command = &args[1];
+
+ match command.as_str() {
+ "version" => {
+ println!("{}", herb::version());
+ }
+ "lex" => {
+ if args.len() < 3 {
+ eprintln!("Error: lex command requires a file argument");
+ print_usage();
+ std::process::exit(1);
+ }
+ let file_path = &args[2];
+ lex_command(file_path);
+ }
+ "parse" => {
+ if args.len() < 3 {
+ eprintln!("Error: parse command requires a file argument");
+ print_usage();
+ std::process::exit(1);
+ }
+ let file_path = &args[2];
+ parse_command(file_path);
+ }
+ "ruby" => {
+ if args.len() < 3 {
+ eprintln!("Error: ruby command requires a file argument");
+ print_usage();
+ std::process::exit(1);
+ }
+ let file_path = &args[2];
+ ruby_command(file_path);
+ }
+ "html" => {
+ if args.len() < 3 {
+ eprintln!("Error: html command requires a file argument");
+ print_usage();
+ std::process::exit(1);
+ }
+ let file_path = &args[2];
+ html_command(file_path);
+ }
+ _ => {
+ eprintln!("Unknown command: {}", command);
+ print_usage();
+ std::process::exit(1);
+ }
+ }
+}
+
+fn lex_command(file_path: &str) {
+ let source = match std::fs::read_to_string(file_path) {
+ Ok(content) => content,
+ Err(e) => {
+ eprintln!("Error reading file '{}': {}", file_path, e);
+ std::process::exit(1);
+ }
+ };
+
+ let result = herb::lex(&source);
+ for token in result.tokens() {
+ println!("{}", token.inspect());
+ }
+}
+
+fn parse_command(file_path: &str) {
+ let source = match std::fs::read_to_string(file_path) {
+ Ok(content) => content,
+ Err(e) => {
+ eprintln!("Error reading file '{}': {}", file_path, e);
+ std::process::exit(1);
+ }
+ };
+
+ match herb::parse(&source) {
+ Ok(result) => {
+ println!("{}", result.tree_inspect());
+ }
+ Err(e) => {
+ eprintln!("Parse error: {}", e);
+ std::process::exit(1);
+ }
+ }
+}
+
+fn ruby_command(file_path: &str) {
+ let source = match std::fs::read_to_string(file_path) {
+ Ok(content) => content,
+ Err(e) => {
+ eprintln!("Error reading file '{}': {}", file_path, e);
+ std::process::exit(1);
+ }
+ };
+
+ match herb::extract_ruby(&source) {
+ Ok(ruby) => {
+ print!("{}", ruby);
+ }
+ Err(e) => {
+ eprintln!("Extract error: {}", e);
+ std::process::exit(1);
+ }
+ }
+}
+
+fn html_command(file_path: &str) {
+ let source = match std::fs::read_to_string(file_path) {
+ Ok(content) => content,
+ Err(e) => {
+ eprintln!("Error reading file '{}': {}", file_path, e);
+ std::process::exit(1);
+ }
+ };
+
+ match herb::extract_html(&source) {
+ Ok(html) => {
+ print!("{}", html);
+ }
+ Err(e) => {
+ eprintln!("Extract error: {}", e);
+ std::process::exit(1);
+ }
+ }
+}
+
+fn print_usage() {
+ println!("Usage: herb-rust [command] [file]");
+ println!();
+ println!("Herb 🌿 Powerful and seamless HTML-aware ERB parsing and tooling.");
+ println!();
+ println!("Commands:");
+ println!(" version - Show version information");
+ println!(" lex [file] - Lex a file");
+ println!(" parse [file] - Parse a file and display AST tree");
+ println!(" ruby [file] - Extract Ruby from a file");
+ println!(" html [file] - Extract HTML from a file");
+}
diff --git a/rust/src/parse_result.rs b/rust/src/parse_result.rs
new file mode 100644
index 000000000..756215195
--- /dev/null
+++ b/rust/src/parse_result.rs
@@ -0,0 +1,41 @@
+use crate::errors::{AnyError, ErrorNode};
+use crate::nodes::{DocumentNode, Node};
+
+pub struct ParseResult {
+ pub value: DocumentNode,
+ pub source: String,
+ pub errors: Vec,
+}
+
+impl ParseResult {
+ pub fn new(value: DocumentNode, source: String, errors: Vec) -> Self {
+ Self {
+ value,
+ source,
+ errors,
+ }
+ }
+
+ pub fn tree_inspect(&self) -> String {
+ self.value.tree_inspect()
+ }
+
+ pub fn errors(&self) -> &[AnyError] {
+ &self.errors
+ }
+
+ pub fn recursive_errors(&self) -> Vec<&dyn ErrorNode> {
+ let mut all_errors: Vec<&dyn ErrorNode> = Vec::new();
+ all_errors.extend(self.errors.iter().map(|e| e as &dyn ErrorNode));
+ all_errors.extend(self.value.recursive_errors());
+ all_errors
+ }
+
+ pub fn failed(&self) -> bool {
+ !self.recursive_errors().is_empty()
+ }
+
+ pub fn success(&self) -> bool {
+ self.recursive_errors().is_empty()
+ }
+}
diff --git a/rust/src/position.rs b/rust/src/position.rs
new file mode 100644
index 000000000..85340e6b5
--- /dev/null
+++ b/rust/src/position.rs
@@ -0,0 +1,23 @@
+use std::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Position {
+ pub line: u32,
+ pub column: u32,
+}
+
+impl Position {
+ pub fn new(line: u32, column: u32) -> Self {
+ Self { line, column }
+ }
+
+ pub fn inspect(&self) -> String {
+ format!("({}:{})", self.line, self.column)
+ }
+}
+
+impl fmt::Display for Position {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.inspect())
+ }
+}
diff --git a/rust/src/range.rs b/rust/src/range.rs
new file mode 100644
index 000000000..b8540ab3b
--- /dev/null
+++ b/rust/src/range.rs
@@ -0,0 +1,27 @@
+use std::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Range {
+ pub from: usize,
+ pub to: usize,
+}
+
+impl Range {
+ pub fn new(from: usize, to: usize) -> Self {
+ Self { from, to }
+ }
+
+ pub fn length(&self) -> usize {
+ self.to - self.from
+ }
+
+ pub fn inspect(&self) -> String {
+ format!("[{}, {}]", self.from, self.to)
+ }
+}
+
+impl fmt::Display for Range {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.inspect())
+ }
+}
diff --git a/rust/src/token.rs b/rust/src/token.rs
new file mode 100644
index 000000000..2ddee20fb
--- /dev/null
+++ b/rust/src/token.rs
@@ -0,0 +1,63 @@
+use crate::location::Location;
+use crate::range::Range;
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Token {
+ pub token_type: String,
+ pub value: String,
+ pub location: Location,
+ pub range: Range,
+}
+
+impl Token {
+ pub fn new(token_type: String, value: String, location: Location, range: Range) -> Self {
+ Self {
+ token_type,
+ value,
+ location,
+ range,
+ }
+ }
+
+ pub fn inspect(&self) -> String {
+ let display_value = if self.token_type == "TOKEN_EOF" && self.value.is_empty() {
+ "".to_string()
+ } else {
+ self.escaped_value()
+ };
+
+ format!(
+ "#",
+ self.token_type,
+ display_value,
+ self.range.inspect(),
+ self.location.start.inspect(),
+ self.location.end.inspect()
+ )
+ }
+
+ fn escaped_value(&self) -> String {
+ self
+ .value
+ .replace('\\', "\\\\")
+ .replace('\n', "\\n")
+ .replace('\r', "\\r")
+ .replace('\t', "\\t")
+ .replace('"', "\\\"")
+ }
+
+ pub fn tree_inspect(&self) -> String {
+ format!("\"{}\" (location: {})", self.escaped_value(), self.location)
+ }
+}
+
+impl fmt::Display for Token {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "Token{{type='{}', value='{}', location={}}}",
+ self.token_type, self.value, self.location
+ )
+ }
+}
diff --git a/rust/tests/cli_commands_test.rs b/rust/tests/cli_commands_test.rs
new file mode 100644
index 000000000..5a3d09548
--- /dev/null
+++ b/rust/tests/cli_commands_test.rs
@@ -0,0 +1,51 @@
+use herb::{extract_html, extract_ruby, version};
+
+#[test]
+fn test_version_functions() {
+ assert_eq!(
+ version(),
+ "herb rust v0.7.5, libprism v1.6.0, libherb v0.7.5 (Rust FFI)"
+ );
+}
+
+#[test]
+fn test_extract_ruby() {
+ let source = "<%= name %>
";
+ let ruby = extract_ruby(source).unwrap();
+ assert_eq!(ruby, " name ");
+}
+
+#[test]
+fn test_extract_html() {
+ let source = "<%= name %>
";
+ let html = extract_html(source).unwrap();
+ assert_eq!(html, "
");
+}
+
+#[test]
+fn test_extract_ruby_complex() {
+ let source = r#"
+ <% users.each do |user| %>
+
<%= user.name %>
+ <% end %>
+
"#;
+ let ruby = extract_ruby(source).unwrap();
+ assert_eq!(
+ ruby,
+ " \n users.each do |user| \n user.name \n end \n "
+ );
+}
+
+#[test]
+fn test_extract_html_complex() {
+ let source = r#"
+ <% users.each do |user| %>
+
<%= user.name %>
+ <% end %>
+
"#;
+ let html = extract_html(source).unwrap();
+ assert_eq!(
+ html,
+ ""
+ );
+}
diff --git a/rust/tests/error_handling_test.rs b/rust/tests/error_handling_test.rs
new file mode 100644
index 000000000..5d04632e1
--- /dev/null
+++ b/rust/tests/error_handling_test.rs
@@ -0,0 +1,33 @@
+use herb::parse;
+
+#[test]
+fn test_unclosed_element_error() {
+ let source = "";
+ let result = parse(source).unwrap();
+
+ let tree_inspect = result.tree_inspect();
+ assert!(tree_inspect.contains("UNCLOSED_ELEMENT_ERROR"));
+ assert!(tree_inspect.contains("Tag `
` opened at (1:1) was never closed"));
+ assert!(tree_inspect.contains("MISSING_CLOSING_TAG_ERROR"));
+ assert!(tree_inspect.contains("Opening tag `
` at (1:1) doesn't have a matching closing tag"));
+}
+
+#[test]
+fn test_tag_names_mismatch_error() {
+ let source = "
";
+ let result = parse(source).unwrap();
+
+ let tree_inspect = result.tree_inspect();
+ assert!(tree_inspect.contains("TAG_NAMES_MISMATCH_ERROR"));
+ assert!(tree_inspect.contains("Opening tag `
` at (1:1) closed with ``"));
+}
+
+#[test]
+fn test_no_errors_with_valid_html() {
+ let source = "
Hello
";
+ let result = parse(source).unwrap();
+
+ let tree_inspect = result.tree_inspect();
+ assert!(!tree_inspect.contains("error"));
+ assert!(!tree_inspect.contains("ERROR"));
+}
diff --git a/rust/tests/fixtures/test.html.erb b/rust/tests/fixtures/test.html.erb
new file mode 100644
index 000000000..49cc40405
--- /dev/null
+++ b/rust/tests/fixtures/test.html.erb
@@ -0,0 +1,11 @@
+
+
+<% if valid? %>
+
+ <%= "Valid" %>
+
+<% else %>
+
+ Invalid
+
+<% end %>
diff --git a/rust/tests/node_field_test.rs b/rust/tests/node_field_test.rs
new file mode 100644
index 000000000..77b4b2d0c
--- /dev/null
+++ b/rust/tests/node_field_test.rs
@@ -0,0 +1,30 @@
+use herb::parse;
+
+#[test]
+fn test_open_tag_field_is_displayed() {
+ let source = "
Hello
";
+ let result = parse(source).unwrap();
+
+ let tree_inspect = result.tree_inspect();
+ assert!(tree_inspect.contains("open_tag:"));
+ assert!(tree_inspect.contains("@ HTMLOpenTagNode"));
+ assert!(tree_inspect.contains("tag_opening: \"<\""));
+ assert!(tree_inspect.contains("tag_name: \"div\""));
+
+ assert!(tree_inspect.contains("close_tag:"));
+ assert!(tree_inspect.contains("@ HTMLCloseTagNode"));
+ assert!(tree_inspect.contains("tag_opening: \"\""));
+}
+
+#[test]
+fn test_nested_node_fields() {
+ let source = "
Hello
";
+ let result = parse(source).unwrap();
+
+ let tree_inspect = result.tree_inspect();
+ assert!(tree_inspect.contains("@ HTMLAttributeNode"));
+ assert!(tree_inspect.contains("name:"));
+ assert!(tree_inspect.contains("@ HTMLAttributeNameNode"));
+ assert!(tree_inspect.contains("value:"));
+ assert!(tree_inspect.contains("@ HTMLAttributeValueNode"));
+}
diff --git a/rust/tests/parse_result_test.rs b/rust/tests/parse_result_test.rs
new file mode 100644
index 000000000..930536774
--- /dev/null
+++ b/rust/tests/parse_result_test.rs
@@ -0,0 +1,94 @@
+use herb::parse;
+
+#[test]
+fn test_parse_result_success_with_valid_html() {
+ let source = "
Hello
";
+ let result = parse(source).unwrap();
+
+ assert!(
+ result.success(),
+ "Expected success() to be true for valid HTML"
+ );
+
+ assert!(
+ !result.failed(),
+ "Expected failed() to be false for valid HTML"
+ );
+
+ assert!(
+ result.errors().is_empty(),
+ "Expected no errors for valid HTML"
+ );
+}
+
+#[test]
+fn test_parse_result_failed_with_unclosed_element() {
+ let source = "
";
+ let result = parse(source).unwrap();
+
+ assert!(
+ !result.success(),
+ "Expected success() to be false for invalid HTML"
+ );
+
+ assert!(
+ result.failed(),
+ "Expected failed() to be true for invalid HTML"
+ );
+
+ assert!(
+ !result.recursive_errors().is_empty(),
+ "Expected errors for invalid HTML"
+ );
+
+ let errors = result.recursive_errors();
+
+ assert!(errors
+ .iter()
+ .any(|e| e.error_type() == "UNCLOSED_ELEMENT_ERROR"));
+
+ assert!(errors
+ .iter()
+ .any(|e| e.error_type() == "MISSING_CLOSING_TAG_ERROR"));
+}
+
+#[test]
+fn test_parse_result_failed_with_tag_mismatch() {
+ let source = "
";
+ let result = parse(source).unwrap();
+
+ assert!(
+ !result.success(),
+ "Expected success() to be false for mismatched tags"
+ );
+
+ assert!(
+ result.failed(),
+ "Expected failed() to be true for mismatched tags"
+ );
+
+ let errors = result.recursive_errors();
+
+ assert!(!errors.is_empty(), "Expected errors for mismatched tags");
+
+ assert!(errors
+ .iter()
+ .any(|e| e.error_type() == "TAG_NAMES_MISMATCH_ERROR"));
+}
+
+#[test]
+fn test_parse_result_errors_are_recursive() {
+ let source = "
";
+ let result = parse(source).unwrap();
+
+ let errors = result.recursive_errors();
+ assert!(
+ !errors.is_empty(),
+ "Expected recursive errors to be collected"
+ );
+
+ assert!(
+ result.failed(),
+ "Expected failed() to be true when there are recursive errors"
+ );
+}
diff --git a/rust/tests/snapshot_test.rs b/rust/tests/snapshot_test.rs
new file mode 100644
index 000000000..07f389fd2
--- /dev/null
+++ b/rust/tests/snapshot_test.rs
@@ -0,0 +1,59 @@
+use herb::{extract_html, extract_ruby, herb_version, lex, parse, prism_version, version};
+
+const TEST_INPUT: &str = include_str!("./fixtures/test.html.erb");
+
+#[test]
+fn test_version_output() {
+ let output = version();
+
+ insta::assert_snapshot!(output);
+}
+
+#[test]
+fn test_herb_version_output() {
+ let output = herb_version();
+
+ insta::assert_snapshot!(output);
+}
+
+#[test]
+fn test_prism_version_output() {
+ let output = prism_version();
+
+ insta::assert_snapshot!(output);
+}
+
+#[test]
+fn test_lex_output() {
+ let result = lex(TEST_INPUT);
+ let output = result
+ .tokens()
+ .iter()
+ .map(|token| token.inspect())
+ .collect::
>()
+ .join("\n");
+
+ insta::assert_snapshot!(output);
+}
+
+#[test]
+fn test_parse_output() {
+ let result = parse(TEST_INPUT).expect("Failed to parse");
+ let output = result.tree_inspect();
+
+ insta::assert_snapshot!(output);
+}
+
+#[test]
+fn test_extract_ruby_output() {
+ let result = extract_ruby(TEST_INPUT).expect("Failed to extract Ruby");
+
+ insta::assert_snapshot!(result);
+}
+
+#[test]
+fn test_extract_html_output() {
+ let result = extract_html(TEST_INPUT).expect("Failed to extract HTML");
+
+ insta::assert_snapshot!(result);
+}
diff --git a/rust/tests/snapshots/snapshot_test__extract_html_output.snap b/rust/tests/snapshots/snapshot_test__extract_html_output.snap
new file mode 100644
index 000000000..af9b7ec93
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__extract_html_output.snap
@@ -0,0 +1,14 @@
+---
+source: tests/snapshot_test.rs
+expression: result
+---
+
+
+
+
+
+
+
+
+ Invalid
+
diff --git a/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap b/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap
new file mode 100644
index 000000000..d8e79d9ff
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap
@@ -0,0 +1,15 @@
+---
+source: tests/snapshot_test.rs
+expression: result
+---
+
+
+ if valid?
+
+ "Valid"
+
+ else
+
+
+
+ end
diff --git a/rust/tests/snapshots/snapshot_test__herb_version_output.snap b/rust/tests/snapshots/snapshot_test__herb_version_output.snap
new file mode 100644
index 000000000..192993920
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__herb_version_output.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot_test.rs
+expression: output
+---
+0.7.5
diff --git a/rust/tests/snapshots/snapshot_test__lex_output.snap b/rust/tests/snapshots/snapshot_test__lex_output.snap
new file mode 100644
index 000000000..781b7c888
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__lex_output.snap
@@ -0,0 +1,69 @@
+---
+source: tests/snapshot_test.rs
+expression: output
+---
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
diff --git a/rust/tests/snapshots/snapshot_test__parse_output.snap b/rust/tests/snapshots/snapshot_test__parse_output.snap
new file mode 100644
index 000000000..02d76cc48
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__parse_output.snap
@@ -0,0 +1,156 @@
+---
+source: tests/snapshot_test.rs
+expression: output
+---
+@ DocumentNode (location: (1:0)-(12:0))
+└── children: (12 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: "HTML"
+ │
+ ├── @ HTMLTextNode (location: (1:18)-(3:0))
+ │ └── content: "\n\n"
+ │
+ ├── @ ERBContentNode (location: (3:0)-(3:15))
+ │ ├── tag_opening: "<%" (location: (3:0)-(3:2))
+ │ ├── content: " if valid? " (location: (3:2)-(3:13))
+ │ ├── tag_closing: "%>" (location: (3:13)-(3:15))
+ │ ├── parsed: false
+ │ └── valid: false
+ │
+ ├── @ 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: false
+ │ │ │ └── valid: false
+ │ │ │
+ │ │ └── @ 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: "HTML"
+ │
+ ├── @ HTMLTextNode (location: (6:7)-(7:0))
+ │ └── content: "\n"
+ │
+ ├── @ ERBContentNode (location: (7:0)-(7:10))
+ │ ├── tag_opening: "<%" (location: (7:0)-(7:2))
+ │ ├── content: " else " (location: (7:2)-(7:8))
+ │ ├── tag_closing: "%>" (location: (7:8)-(7:10))
+ │ ├── parsed: false
+ │ └── valid: false
+ │
+ ├── @ 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: "HTML"
+ │
+ ├── @ HTMLTextNode (location: (10:7)-(11:0))
+ │ └── content: "\n"
+ │
+ ├── @ ERBContentNode (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))
+ │ ├── parsed: false
+ │ └── valid: false
+ │
+ └── @ HTMLTextNode (location: (11:9)-(12:0))
+ └── content: "\n"
diff --git a/rust/tests/snapshots/snapshot_test__prism_version_output.snap b/rust/tests/snapshots/snapshot_test__prism_version_output.snap
new file mode 100644
index 000000000..f5516a1a8
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__prism_version_output.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot_test.rs
+expression: output
+---
+1.6.0
diff --git a/rust/tests/snapshots/snapshot_test__version_output.snap b/rust/tests/snapshots/snapshot_test__version_output.snap
new file mode 100644
index 000000000..b46419a0c
--- /dev/null
+++ b/rust/tests/snapshots/snapshot_test__version_output.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot_test.rs
+expression: output
+---
+herb rust v0.7.5, libprism v1.6.0, libherb v0.7.5 (Rust FFI)
diff --git a/rust/tests/tree_inspect_test.rs b/rust/tests/tree_inspect_test.rs
new file mode 100644
index 000000000..42826b6bd
--- /dev/null
+++ b/rust/tests/tree_inspect_test.rs
@@ -0,0 +1,30 @@
+use herb::nodes::{AnyNode, DocumentNode, HTMLTextNode, Node};
+use herb::{Location, Position};
+
+#[test]
+fn test_document_with_text_node() {
+ let loc = Location::new(Position::new(1, 0), Position::new(1, 5));
+
+ let text_node = HTMLTextNode {
+ node_type: "HTMLTextNode".to_string(),
+ location: loc,
+ errors: vec![],
+ content: "Hello".to_string(),
+ };
+
+ let doc_node = DocumentNode {
+ node_type: "DocumentNode".to_string(),
+ location: Location::new(Position::new(1, 0), Position::new(2, 0)),
+ errors: vec![],
+ children: vec![AnyNode::HTMLTextNode(text_node)],
+ };
+
+ let output = doc_node.tree_inspect();
+
+ println!("Tree inspect output:\n{}", output);
+
+ assert!(output.contains("@ DocumentNode"));
+ assert!(output.contains("children: (1 item)"));
+ assert!(output.contains("@ HTMLTextNode"));
+ assert!(output.contains("content: \"Hello\""));
+}
diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb
new file mode 100644
index 000000000..1cd175e4a
--- /dev/null
+++ b/templates/rust/src/ast/nodes.rs.erb
@@ -0,0 +1,220 @@
+use crate::bindings::*;
+use crate::convert::token_from_c;
+use crate::errors::*;
+use crate::nodes::*;
+use crate::{Location, Position};
+use std::ffi::CStr;
+use std::os::raw::{c_char, c_void};
+
+unsafe fn convert_location(c_location: location_T) -> Location {
+ Location::new(
+ Position::new(c_location.start.line, c_location.start.column),
+ Position::new(c_location.end.line, c_location.end.column),
+ )
+}
+
+<%- errors.each do |error| -%>
+<%- snake_name = error.name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase -%>
+unsafe fn convert_<%= snake_name %>(error_ptr: *const <%= error.c_type %>) -> <%= error.name %> {
+ let error_ref = &*error_ptr;
+ let message = if error_ref.base.message.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(error_ref.base.message).to_string_lossy().into_owned()
+ };
+ let location = convert_location(error_ref.base.location);
+
+ <%= error.name %>::new(
+ message,
+ location,
+ <%- error.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ get_string_field(error_ref.<%= field.name %>),
+ <%- when Herb::Template::TokenField -%>
+ convert_token_field(error_ref.<%= field.name %>),
+ <%- when Herb::Template::TokenTypeField -%>
+ if error_ref.<%= field.name %> == u32::MAX {
+ None
+ } else {
+ Some(CStr::from_ptr(crate::ffi::token_type_to_string(error_ref.<%= field.name %>)).to_string_lossy().into_owned())
+ },
+ <%- end -%>
+ <%- end -%>
+ )
+}
+
+<%- end -%>
+
+unsafe fn convert_errors(errors_array: *mut hb_array_T) -> Vec {
+ if errors_array.is_null() {
+ return Vec::new();
+ }
+
+ let count = hb_array_size(errors_array);
+ let mut errors = Vec::with_capacity(count);
+
+ for index in 0..count {
+ let error_base_ptr = hb_array_get(errors_array, index) as *const ERROR_T;
+ if error_base_ptr.is_null() {
+ continue;
+ }
+
+ let error_base = &*error_base_ptr;
+
+ let error = match error_base.type_ {
+ <%- errors.each do |error| -%>
+ <%- snake_name = error.name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase -%>
+ <%= error.type %> => {
+ let error_ptr = error_base_ptr as *const <%= error.c_type %>;
+ AnyError::<%= error.name %>(convert_<%= snake_name %>(error_ptr))
+ }
+ <%- end -%>
+ _ => continue,
+ };
+
+ errors.push(error);
+ }
+
+ errors
+}
+
+unsafe fn get_string_field(string_ptr: *const c_char) -> String {
+ if string_ptr.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(string_ptr).to_string_lossy().into_owned()
+ }
+}
+
+unsafe fn convert_element_source(source: u32) -> String {
+ let hb_string = crate::ffi::element_source_to_string(source);
+ if hb_string.data.is_null() {
+ String::new()
+ } else {
+ let slice = std::slice::from_raw_parts(hb_string.data as *const u8, hb_string.length as usize);
+ String::from_utf8_lossy(slice).into_owned()
+ }
+}
+
+unsafe fn convert_token_field(token_ptr: *mut token_T) -> Option {
+ if token_ptr.is_null() {
+ None
+ } else {
+ Some(token_from_c(token_ptr))
+ }
+}
+
+unsafe fn convert_children(children_array: *mut hb_array_T) -> Vec {
+ if children_array.is_null() {
+ return Vec::new();
+ }
+
+ let count = hb_array_size(children_array);
+ let mut children = Vec::with_capacity(count);
+
+ for index in 0..count {
+ let child_ptr = hb_array_get(children_array, index);
+ if !child_ptr.is_null() {
+ if let Some(node) = convert_node(child_ptr as *const c_void) {
+ children.push(node);
+ }
+ }
+ }
+
+ children
+}
+
+unsafe fn convert_node_field(node_ptr: *mut c_void) -> Option> {
+ if node_ptr.is_null() {
+ None
+ } else {
+ convert_node(node_ptr).map(Box::new)
+ }
+}
+
+macro_rules! convert_specific_node_field {
+ ($node_ptr:expr, $expected_type:expr, $convert_fn:ident, $node_type:ty) => {
+ if $node_ptr.is_null() {
+ None
+ } else {
+ let ast_base_ptr = $node_ptr as *const AST_NODE_T;
+ let ast_base_ref = &*ast_base_ptr;
+ let node_type = ast_base_ref.type_;
+
+ if node_type != $expected_type {
+ eprintln!("Warning: Expected node type {} but got {}", $expected_type, node_type);
+ None
+ } else {
+ $convert_fn($node_ptr as *const c_void).map(Box::new)
+ }
+ }
+ };
+}
+
+unsafe fn convert_node(node_ptr: *const c_void) -> Option {
+ if node_ptr.is_null() {
+ return None;
+ }
+
+ let ast_base_ptr = node_ptr as *const AST_NODE_T;
+ let ast_base_ref = &*ast_base_ptr;
+ let node_type = ast_base_ref.type_;
+
+ match node_type {
+ <%- nodes.each do |node| -%>
+ <%= node.type %> => convert_<%= node.human %>(node_ptr).map(AnyNode::<%= node.name %>),
+ <%- end -%>
+ _ => {
+ eprintln!("Warning: Unknown node type {}", node_type);
+ None
+ }
+ }
+}
+
+ <%- nodes.each do |node| -%>
+ <%- if node.name == "DocumentNode" -%>
+ /// Converts a C document node pointer to a Rust DocumentNode.
+ ///
+ /// # Safety
+ ///
+ /// The caller must ensure that `node_ptr` is a valid pointer to a C document node
+ /// structure with properly initialized fields.
+ <%- end -%>
+ <%= node.name == "DocumentNode" ? "pub " : "" %>unsafe fn convert_<%= node.human %>(node_ptr: *const c_void) -> Option<<%= node.name %>> {
+ if node_ptr.is_null() {
+ return None;
+ }
+
+ let c_node_ptr = node_ptr as *const <%= node.c_type %>;
+ let node_base_ref = &(*c_node_ptr).base;
+
+ Some(<%= node.name %> {
+ node_type: "<%= node.name %>".to_string(),
+ location: convert_location(node_base_ref.location),
+ errors: convert_errors(node_base_ref.errors),
+ <%- node.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ <%= field.name %>: get_string_field((*c_node_ptr).<%= field.name %>),
+ <%- when Herb::Template::ElementSourceField -%>
+ <%= field.name %>: convert_element_source((*c_node_ptr).<%= field.name %>),
+ <%- when Herb::Template::TokenField -%>
+ <%= field.name %>: convert_token_field((*c_node_ptr).<%= field.name %>),
+ <%- when Herb::Template::BooleanField -%>
+ <%= field.name %>: (*c_node_ptr).<%= field.name %>,
+ <%- when Herb::Template::ArrayField -%>
+ <%= field.name %>: convert_children((*c_node_ptr).<%= field.name %>),
+ <%- when Herb::Template::NodeField -%>
+ <%- if field.specific_kind && field.specific_kind != "Node" -%>
+ <%- specific_node = nodes.find { |n| n.name == field.specific_kind } -%>
+ <%= field.name %>: convert_specific_node_field!((*c_node_ptr).<%= field.name %>, <%= specific_node.type %>, convert_<%= specific_node.human %>, <%= field.specific_kind %>),
+ <%- else -%>
+ <%= field.name %>: convert_node_field((*c_node_ptr).<%= field.name %> as *mut c_void),
+ <%- end -%>
+ <%- end -%>
+ <%- end -%>
+ })
+ }
+
+<%- end -%>
diff --git a/templates/rust/src/errors.rs.erb b/templates/rust/src/errors.rs.erb
new file mode 100644
index 000000000..dbd859601
--- /dev/null
+++ b/templates/rust/src/errors.rs.erb
@@ -0,0 +1,211 @@
+use crate::{Location, Token};
+
+fn escape_string(s: &str) -> String {
+ s.replace('\\', "\\\\")
+ .replace('\n', "\\n")
+ .replace('\r', "\\r")
+ .replace('\t', "\\t")
+ .replace('"', "\\\"")
+}
+
+fn format_string_value(value: &str) -> String {
+ format!("\"{}\"", escape_string(value))
+}
+
+fn format_token_value(token: &Option) -> String {
+ token.as_ref().map(|t| t.tree_inspect()).unwrap_or_else(|| "∅".to_string())
+}
+
+fn format_token_type_value(token_type: &Option) -> String {
+ token_type.as_ref().map(|t| format!("\"{}\"", t)).unwrap_or_else(|| "∅".to_string())
+}
+
+#[allow(dead_code)]
+fn format_position_value(position: &Option) -> String {
+ position.as_ref().map(|p| p.to_string()).unwrap_or_else(|| "∅".to_string())
+}
+
+#[allow(dead_code)]
+fn format_size_value(value: usize) -> String {
+ value.to_string()
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ErrorType {
+ <%- errors.each do |error| -%>
+ <%= error.name %>,
+ <%- end -%>
+}
+
+impl ErrorType {
+ pub fn as_str(&self) -> &str {
+ match self {
+ <%- errors.each do |error| -%>
+ ErrorType::<%= error.name %> => "<%= error.type %>",
+ <%- end -%>
+ }
+ }
+}
+
+pub trait ErrorNode {
+ fn error_type(&self) -> &str;
+ fn message(&self) -> &str;
+ fn location(&self) -> &Location;
+ fn tree_inspect(&self) -> String;
+}
+
+#[derive(Debug, Clone)]
+pub enum AnyError {
+ <%- errors.each do |error| -%>
+ <%= error.name %>(<%= error.name %>),
+ <%- end -%>
+}
+
+impl AnyError {
+ pub fn error_type(&self) -> &str {
+ match self {
+ <%- errors.each do |error| -%>
+ AnyError::<%= error.name %>(e) => &e.error_type,
+ <%- end -%>
+ }
+ }
+
+ pub fn message(&self) -> &str {
+ match self {
+ <%- errors.each do |error| -%>
+ AnyError::<%= error.name %>(e) => &e.message,
+ <%- end -%>
+ }
+ }
+
+ pub fn location(&self) -> &Location {
+ match self {
+ <%- errors.each do |error| -%>
+ AnyError::<%= error.name %>(e) => &e.location,
+ <%- end -%>
+ }
+ }
+
+ pub fn tree_inspect(&self) -> String {
+ match self {
+ <%- errors.each do |error| -%>
+ AnyError::<%= error.name %>(e) => e.tree_inspect(),
+ <%- end -%>
+ }
+ }
+}
+
+impl ErrorNode for AnyError {
+ fn error_type(&self) -> &str {
+ self.error_type()
+ }
+
+ fn message(&self) -> &str {
+ self.message()
+ }
+
+ fn location(&self) -> &Location {
+ self.location()
+ }
+
+ fn tree_inspect(&self) -> String {
+ self.tree_inspect()
+ }
+}
+
+<%- errors.each do |error| -%>
+#[derive(Debug, Clone)]
+pub struct <%= error.name %> {
+ pub error_type: String,
+ pub message: String,
+ pub location: Location,
+ <%- error.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ pub <%= field.name %>: String,
+ <%- when Herb::Template::TokenField -%>
+ pub <%= field.name %>: Option,
+ <%- when Herb::Template::TokenTypeField -%>
+ pub <%= field.name %>: Option,
+ <%- when Herb::Template::PositionField -%>
+ pub <%= field.name %>: Option,
+ <%- when Herb::Template::SizeTField -%>
+ pub <%= field.name %>: usize,
+ <%- end -%>
+ <%- end -%>
+}
+
+impl <%= error.name %> {
+ pub fn new(
+ message: String,
+ location: Location,
+ <%- error.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ <%= field.name %>: String,
+ <%- when Herb::Template::TokenField -%>
+ <%= field.name %>: Option,
+ <%- when Herb::Template::TokenTypeField -%>
+ <%= field.name %>: Option,
+ <%- when Herb::Template::PositionField -%>
+ <%= field.name %>: Option,
+ <%- when Herb::Template::SizeTField -%>
+ <%= field.name %>: usize,
+ <%- end -%>
+ <%- end -%>
+ ) -> Self {
+ Self {
+ error_type: "<%= error.type %>".to_string(),
+ message,
+ location,
+ <%- error.fields.each do |field| -%>
+ <%= field.name %>,
+ <%- end -%>
+ }
+ }
+
+ pub fn tree_inspect(&self) -> String {
+ let mut output = String::new();
+
+ output.push_str(&format!("@ <%= error.type %> {}\n", self.location));
+ <%- symbol = error.fields.any? ? "├──" : "└──" -%>
+ output.push_str(&format!("<%= symbol %> message: {}\n", format_string_value(&self.message)));
+ <%- error.fields.each do |field| -%>
+ <%- symbol = error.fields.last == field ? "└──" : "├──" -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_string_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::TokenField -%>
+ output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_token_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::TokenTypeField -%>
+ output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_token_type_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::PositionField -%>
+ output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_position_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::SizeTField -%>
+ output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_size_value(self.<%= field.name %>)));
+ <%- end -%>
+ <%- end -%>
+
+ output
+ }
+}
+
+impl ErrorNode for <%= error.name %> {
+ fn error_type(&self) -> &str {
+ &self.error_type
+ }
+
+ fn message(&self) -> &str {
+ &self.message
+ }
+
+ fn location(&self) -> &Location {
+ &self.location
+ }
+
+ fn tree_inspect(&self) -> String {
+ <%= error.name %>::tree_inspect(self)
+ }
+}
+
+<%- end -%>
diff --git a/templates/rust/src/nodes.rs.erb b/templates/rust/src/nodes.rs.erb
new file mode 100644
index 000000000..c68784233
--- /dev/null
+++ b/templates/rust/src/nodes.rs.erb
@@ -0,0 +1,366 @@
+use crate::errors::{AnyError, ErrorNode};
+use crate::{Location, Token};
+
+fn escape_string(s: &str) -> String {
+ s.replace('\\', "\\\\")
+ .replace('\n', "\\n")
+ .replace('\r', "\\r")
+ .replace('\t', "\\t")
+ .replace('"', "\\\"")
+}
+
+fn format_string_value(value: &str) -> String {
+ format!("\"{}\"", escape_string(value))
+}
+
+fn format_token_value(token: &Option) -> String {
+ token.as_ref().map(|t| t.tree_inspect()).unwrap_or_else(|| "∅".to_string())
+}
+
+fn format_bool_value(value: bool) -> String {
+ value.to_string()
+}
+
+fn format_node_value(node: &Option>, prefix: &str, add_spacing: bool) -> String {
+ if let Some(ref n) = node {
+ let mut output = String::new();
+ output.push('\n');
+ output.push_str(&inspect_node_field(n.as_ref(), prefix));
+ if add_spacing {
+ output.push_str(&format!("{}\n", prefix));
+ }
+ output
+ } else {
+ "∅\n".to_string()
+ }
+}
+
+fn format_array_value(array: &[AnyNode], prefix: &str) -> String {
+ inspect_array(array, prefix)
+}
+
+fn format_errors_field(errors: &[AnyError], prefix: &str) -> String {
+ inspect_errors(errors, prefix)
+}
+
+fn inspect_errors(errors: &[AnyError], prefix: &str) -> String {
+ if errors.is_empty() {
+ return String::new();
+ }
+
+ let mut output = String::new();
+ output.push_str(&format!("├── errors: ({} error{})\n",
+ errors.len(),
+ if errors.len() == 1 { "" } else { "s" }
+ ));
+
+ for (i, error) in errors.iter().enumerate() {
+ let is_last = i == errors.len() - 1;
+ let symbol = if is_last { "└── " } else { "├── " };
+ let next_prefix = if is_last { " " } else { "│ " };
+
+ let tree = error.tree_inspect();
+ let tree = tree.trim_end_matches('\n');
+ output.push_str(&format!("{}{}{}\n", prefix, symbol, tree.replace('\n', &format!("\n{}{}", prefix, next_prefix))));
+
+ if !is_last {
+ output.push_str(&format!("{}│ \n", prefix));
+ }
+ }
+
+ output.push_str(&format!("{}\n", prefix));
+ output
+}
+
+fn inspect_array(array: &[AnyNode], prefix: &str) -> String {
+ if array.is_empty() {
+ return "[]\n".to_string();
+ }
+
+ let mut output = String::new();
+ output.push_str(&format!("({} item{})\n", array.len(), if array.len() == 1 { "" } else { "s" }));
+
+ for (i, item) in array.iter().enumerate() {
+ let is_last = i == array.len() - 1;
+ let symbol = if is_last { "└── " } else { "├── " };
+ let next_prefix = if is_last { " " } else { "│ " };
+
+ let tree = item.tree_inspect();
+ let tree = tree.trim_end_matches('\n');
+
+ output.push_str(prefix);
+ output.push_str(symbol);
+ output.push_str(&tree.replace('\n', &format!("\n{}{}", prefix, next_prefix)));
+ output.push('\n');
+
+ if !is_last {
+ output.push_str(prefix);
+ output.push_str(next_prefix);
+ output.push('\n');
+ }
+ }
+
+ output
+}
+
+fn inspect_node_field(node: &T, prefix: &str) -> String {
+ let tree = node.tree_inspect();
+ let tree = tree.trim_end_matches('\n');
+
+ let lines: Vec<&str> = tree.split('\n').collect();
+ if lines.is_empty() {
+ return "∅\n".to_string();
+ }
+
+ let mut result = String::new();
+ result.push_str(prefix);
+ result.push_str("└── ");
+ result.push_str(lines[0]);
+ result.push('\n');
+
+ for line in lines.iter().skip(1) {
+ if line.is_empty() {
+ result.push_str(prefix);
+ result.push('\n');
+ } else {
+ result.push_str(prefix);
+ result.push_str(" ");
+ result.push_str(line);
+ result.push('\n');
+ }
+ }
+
+ result
+}
+
+pub trait Node {
+ fn node_type(&self) -> &str;
+ fn location(&self) -> &Location;
+ fn errors(&self) -> &[AnyError];
+ fn child_nodes(&self) -> Vec<&dyn Node>;
+ fn recursive_errors(&self) -> Vec<&dyn ErrorNode>;
+ fn tree_inspect(&self) -> String;
+}
+
+#[derive(Debug, Clone)]
+pub enum AnyNode {
+ <%- nodes.each do |node| -%>
+ <%= node.name %>(<%= node.name %>),
+ <%- end -%>
+}
+
+impl AnyNode {
+ pub fn node_type(&self) -> &str {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => &n.node_type,
+ <%- end -%>
+ }
+ }
+
+ pub fn location(&self) -> &Location {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => &n.location,
+ <%- end -%>
+ }
+ }
+
+ pub fn errors(&self) -> &[AnyError] {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => &n.errors,
+ <%- end -%>
+ }
+ }
+
+ pub fn tree_inspect(&self) -> String {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => n.tree_inspect(),
+ <%- end -%>
+ }
+ }
+
+ pub fn child_nodes(&self) -> Vec<&dyn Node> {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => n.child_nodes(),
+ <%- end -%>
+ }
+ }
+
+ pub fn recursive_errors(&self) -> Vec<&dyn ErrorNode> {
+ match self {
+ <%- nodes.each do |node| -%>
+ AnyNode::<%= node.name %>(n) => n.recursive_errors(),
+ <%- end -%>
+ }
+ }
+}
+
+impl Node for AnyNode {
+ fn node_type(&self) -> &str {
+ self.node_type()
+ }
+
+ fn location(&self) -> &Location {
+ self.location()
+ }
+
+ fn errors(&self) -> &[AnyError] {
+ self.errors()
+ }
+
+ fn child_nodes(&self) -> Vec<&dyn Node> {
+ self.child_nodes()
+ }
+
+ fn recursive_errors(&self) -> Vec<&dyn ErrorNode> {
+ self.recursive_errors()
+ }
+
+ fn tree_inspect(&self) -> String {
+ self.tree_inspect()
+ }
+}
+
+<%- nodes.each do |node| -%>
+#[derive(Debug, Clone)]
+pub struct <%= node.name %> {
+ pub node_type: String,
+ pub location: Location,
+ pub errors: Vec,
+ <%- node.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField -%>
+ pub <%= field.name %>: String,
+ <%- when Herb::Template::TokenField -%>
+ pub <%= field.name %>: Option,
+ <%- when Herb::Template::BooleanField -%>
+ pub <%= field.name %>: bool,
+ <%- when Herb::Template::ArrayField -%>
+ pub <%= field.name %>: Vec,
+ <%- when Herb::Template::NodeField -%>
+ <%- if field.specific_kind && field.specific_kind != "Node" -%>
+ pub <%= field.name %>: Option>>,
+ <%- else -%>
+ pub <%= field.name %>: Option>,
+ <%- end -%>
+ <%- when Herb::Template::ElementSourceField -%>
+ pub <%= field.name %>: String,
+ <%- end -%>
+ <%- end -%>
+}
+
+impl Node for <%= node.name %> {
+ fn node_type(&self) -> &str {
+ &self.node_type
+ }
+
+ fn location(&self) -> &Location {
+ &self.location
+ }
+
+ fn errors(&self) -> &[AnyError] {
+ &self.errors
+ }
+
+ fn child_nodes(&self) -> Vec<&dyn Node> {
+ <%- has_children = node.fields.any? { |field|
+ case field
+ when Herb::Template::ArrayField
+ true
+ when Herb::Template::NodeField
+ true
+ else
+ false
+ end
+ } -%>
+ <%- if has_children -%>
+ let mut children: Vec<&dyn Node> = Vec::new();
+ <%- node.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::ArrayField -%>
+ children.extend(self.<%= field.name %>.iter().map(|n| n as &dyn Node));
+ <%- when Herb::Template::NodeField -%>
+ if let Some(ref node) = self.<%= field.name %> {
+ <%- if field.specific_kind && field.specific_kind != "Node" -%>
+ children.push(node.as_ref() as &dyn Node);
+ <%- else -%>
+ children.push(node.as_ref() as &dyn Node);
+ <%- end -%>
+ }
+ <%- end -%>
+ <%- end -%>
+ children
+ <%- else -%>
+ Vec::new()
+ <%- end -%>
+ }
+
+ fn recursive_errors(&self) -> Vec<&dyn ErrorNode> {
+ <%- has_children = node.fields.any? { |field|
+ case field
+ when Herb::Template::ArrayField
+ true
+ when Herb::Template::NodeField
+ true
+ else
+ false
+ end
+ } -%>
+ <%- if has_children -%>
+ let mut all_errors: Vec<&dyn ErrorNode> = Vec::new();
+ all_errors.extend(self.errors.iter().map(|e| e as &dyn ErrorNode));
+
+ <%- node.fields.each do |field| -%>
+ <%- case field -%>
+ <%- when Herb::Template::ArrayField -%>
+ for child in &self.<%= field.name %> {
+ all_errors.extend(child.recursive_errors());
+ }
+ <%- when Herb::Template::NodeField -%>
+ if let Some(ref node) = self.<%= field.name %> {
+ all_errors.extend(node.recursive_errors());
+ }
+ <%- end -%>
+ <%- end -%>
+
+ all_errors
+ <%- else -%>
+ self.errors.iter().map(|e| e as &dyn ErrorNode).collect()
+ <%- end -%>
+ }
+
+ fn tree_inspect(&self) -> String {
+ let mut output = String::new();
+
+ output.push_str(&format!("@ <%= node.name %> (location: {})\n", self.location));
+ output.push_str(&format_errors_field(&self.errors, <%- if node.fields.any? -%>"│ "<%- else -%>" "<%- end -%>));
+ <%- if node.fields.any? -%>
+ <%- node.fields.each_with_index do |field, index| -%>
+ <%- is_last = index == node.fields.length - 1 -%>
+ <%- symbol = is_last ? "└── " : "├── " -%>
+ <%- case field -%>
+ <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%>
+ output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", format_string_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::TokenField -%>
+ output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", format_token_value(&self.<%= field.name %>)));
+ <%- when Herb::Template::BooleanField -%>
+ output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", format_bool_value(self.<%= field.name %>)));
+ <%- when Herb::Template::ArrayField -%>
+ output.push_str(&format!("<%= symbol %><%= field.name %>: {}", format_array_value(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>")));
+ <%- when Herb::Template::NodeField -%>
+ output.push_str(&format!("<%= symbol %><%= field.name %>: {}", format_node_value(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>", <%= !is_last %>)));
+ <%- end -%>
+ <%- end -%>
+ <%- else -%>
+ output.push_str("└── (no fields)\n");
+ <%- end -%>
+
+ output
+ }
+}
+
+<%- end -%>
diff --git a/templates/template.rb b/templates/template.rb
index 64097371f..749c970db 100755
--- a/templates/template.rb
+++ b/templates/template.rb
@@ -245,6 +245,10 @@ def initialize(config)
type.new(name: field_name, kind: kind)
end
end
+
+ def c_type
+ @struct_type
+ end
end
class NodeType
@@ -268,6 +272,10 @@ def initialize(config)
type.new(name: field_name, kind: kind)
end
end
+
+ def c_type
+ @struct_type
+ end
end
class PrintfMessageTemplate
@@ -331,6 +339,8 @@ def self.check_gitignore(name)
end
def self.render(template_file)
+ template_file_display = template_file.delete_prefix("#{File.expand_path("../", __dir__)}/")
+
name = Pathname.new(template_file)
name = if name.absolute?
template_file.gsub(
@@ -351,8 +361,6 @@ def self.render(template_file)
)
end
- puts "Rendering #{template_file.delete_prefix("#{File.expand_path("../", __dir__)}/")} → #{destination}"
-
template_file = Pathname.new(template_file)
template_path = if template_file.absolute?
template_file
@@ -367,6 +375,17 @@ def self.render(template_file)
check_gitignore(name)
+ if File.exist?(destination)
+ existing_content = File.read(destination, encoding: Encoding::UTF_8)
+
+ if existing_content == content
+ puts "[unchanged] #{destination}"
+ return
+ end
+ end
+
+ puts "Rendering #{template_file_display} → #{destination}"
+
FileUtils.mkdir_p(File.dirname(destination))
File.write(destination, content)
rescue SyntaxError => e