From 15f8f00332f9e102e39c312e29b7959e89259dcd Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 17:08:00 +0100 Subject: [PATCH 01/21] Rust: Add Herb Rust FFI Bidinings --- .envrc | 1 + .gitattributes | 2 + .github/labeler.yml | 1 + .github/workflows/rust.yml | 57 ++++++ .gitignore | 9 +- docs/.vitepress/config/theme.mts | 8 + docs/docs/bindings/javascript/index.md | 4 +- docs/docs/bindings/ruby/index.md | 4 +- docs/docs/bindings/rust/index.md | 85 ++++++++ docs/docs/bindings/rust/reference.md | 260 ++++++++++++++++++++++++ rust/Cargo.toml | 24 +++ rust/Makefile | 55 +++++ rust/README.md | 57 ++++++ rust/bin/herb-rust | 15 ++ rust/build.rs | 65 ++++++ rust/rustfmt.toml | 9 + rust/src/ast/mod.rs | 3 + rust/src/convert.rs | 42 ++++ rust/src/ffi.rs | 70 +++++++ rust/src/herb.rs | 106 ++++++++++ rust/src/lex_result.rs | 15 ++ rust/src/lib.rs | 56 +++++ rust/src/location.rs | 24 +++ rust/src/main.rs | 145 +++++++++++++ rust/src/parse_result.rs | 13 ++ rust/src/position.rs | 23 +++ rust/src/range.rs | 27 +++ rust/src/token.rs | 63 ++++++ rust/tests/cli_commands_test.rs | 48 +++++ rust/tests/error_handling_test.rs | 36 ++++ rust/tests/node_field_test.rs | 28 +++ rust/tests/tree_inspect_test.rs | 30 +++ templates/rust/src/ast/nodes.rs.erb | 236 +++++++++++++++++++++ templates/rust/src/errors.rs.erb | 187 +++++++++++++++++ templates/rust/src/nodes.rs.erb | 271 +++++++++++++++++++++++++ 35 files changed, 2076 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/rust.yml create mode 100644 docs/docs/bindings/rust/index.md create mode 100644 docs/docs/bindings/rust/reference.md create mode 100644 rust/Cargo.toml create mode 100644 rust/Makefile create mode 100644 rust/README.md create mode 100755 rust/bin/herb-rust create mode 100644 rust/build.rs create mode 100644 rust/rustfmt.toml create mode 100644 rust/src/ast/mod.rs create mode 100644 rust/src/convert.rs create mode 100644 rust/src/ffi.rs create mode 100644 rust/src/herb.rs create mode 100644 rust/src/lex_result.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/location.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/parse_result.rs create mode 100644 rust/src/position.rs create mode 100644 rust/src/range.rs create mode 100644 rust/src/token.rs create mode 100644 rust/tests/cli_commands_test.rs create mode 100644 rust/tests/error_handling_test.rs create mode 100644 rust/tests/node_field_test.rs create mode 100644 rust/tests/tree_inspect_test.rs create mode 100644 templates/rust/src/ast/nodes.rs.erb create mode 100644 templates/rust/src/errors.rs.erb create mode 100644 templates/rust/src/nodes.rs.erb diff --git a/.envrc b/.envrc index d4a7ef2b6..4ff640987 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/rust/bin:$PATH" diff --git a/.gitattributes b/.gitattributes index 808330fe7..0513e0bc2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,8 @@ templates/**/*.c.erb linguist-language=C templates/**/*.h.erb linguist-language=C templates/**/*.rb.erb linguist-language=Ruby templates/**/*.cpp.erb linguist-language=C++ +templates/**/*.rs.erb linguist-language=Rust +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..1c8c5bf9f 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..129841468 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,57 @@ +name: Rust + +on: + push: [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: rustfmt, clippy + + - 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: Check Rust formatting + run: cargo fmt --check + working-directory: rust + + - name: Clippy + run: cargo clippy --all-targets --all-features + working-directory: rust + + - name: Build Rust + run: cargo build --verbose + working-directory: rust + + - name: Run Rust tests + run: cargo test --verbose -- --test-threads=1 + working-directory: rust diff --git a/.gitignore b/.gitignore index 8261787ab..37a986012 100644 --- a/.gitignore +++ b/.gitignore @@ -100,8 +100,11 @@ javascript/packages/node/extension/nodes.h lib/herb/ast/nodes.rb lib/herb/errors.rb lib/herb/visitor.rb -sig/serialized_ast_nodes.rbs +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 src/ast_pretty_print.c src/errors.c @@ -114,6 +117,10 @@ wasm/error_helpers.h wasm/nodes.cpp wasm/nodes.h +# Rust Build Artifacts +rust/target/ +rust/Cargo.lock + # NX Monorepo .nx/ .nx/cache diff --git a/docs/.vitepress/config/theme.mts b/docs/.vitepress/config/theme.mts index a36754b6f..4da514b47 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: "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/javascript/index.md b/docs/docs/bindings/javascript/index.md index 9e9792f8a..836b27156 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/) +> - [Rust](/bindings/rust/) ## Installation diff --git a/docs/docs/bindings/ruby/index.md b/docs/docs/bindings/ruby/index.md index 68ba75d7b..394c064ae 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/) +> - [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..c150c355f --- /dev/null +++ b/docs/docs/bindings/rust/reference.md @@ -0,0 +1,260 @@ +--- +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:28)-(1:29)) +// │ +// ├── is_void: false +// └── source: "" +``` +::: + +### `ParseResult` + +The `ParseResult` struct provides access to the parsed AST tree inspect output: + +```rust +pub struct ParseResult { + pub tree_inspect: String, +} + +impl ParseResult { + pub fn tree_inspect(&self) -> &str; +} +``` + +## 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) -> &[ErrorNode]; + fn tree_inspect(&self) -> String; +} +``` + +### Error Handling + +Parse errors are included in the AST as `ErrorNode` structs: + +```rust +pub struct ErrorNode { + pub error_type: String, + pub location: Location, + pub message: String, +} +``` + +Errors remain accessible through the `errors()` method on nodes, allowing you to handle them as needed. diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..967e24228 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "herb" +version = "0.7.5" +edition = "2021" +authors = ["Marco Roth "] +description = "Rust bindings for Herb" +license = "MIT" +repository = "https://github.com/marcoroth/herb" + +[lib] +name = "herb" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "herb-rs" +path = "src/main.rs" + +[dependencies] +libc = "0.2" + +[build-dependencies] +cc = "1.0" +glob = "0.3" diff --git a/rust/Makefile b/rust/Makefile new file mode 100644 index 000000000..e9e376029 --- /dev/null +++ b/rust/Makefile @@ -0,0 +1,55 @@ +# Rust Makefile for Herb + +BUILD_DIR = target +BIN_NAME = herb-rs +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 build --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/herb-rs [command] [file]" + @echo "" + @./bin/herb-rs || true + +test: build + cargo test + +format: + cargo fmt + @echo "Formatted 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..d3676ed47 --- /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-rs" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: herb-rs 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..b3bbf7418 --- /dev/null +++ b/rust/build.rs @@ -0,0 +1,65 @@ +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"); + + for source in &c_sources { + println!("cargo:rerun-if-changed={}", source.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..d55aec87b --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1,9 @@ +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 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/convert.rs b/rust/src/convert.rs new file mode 100644 index 000000000..6d25853a0 --- /dev/null +++ b/rust/src/convert.rs @@ -0,0 +1,42 @@ +use crate::ffi::{CLocation, CPosition, CRange, CToken}; +use crate::{Location, Position, Range, Token}; +use std::ffi::CStr; + +impl From for Position { + fn from(c_pos: CPosition) -> Self { + Position::new(c_pos.line, c_pos.column) + } +} + +impl From for Range { + fn from(c_range: CRange) -> Self { + Range::new(c_range.from as usize, c_range.to as usize) + } +} + +impl From for Location { + fn from(c_loc: CLocation) -> Self { + Location::new(c_loc.start.into(), c_loc.end.into()) + } +} + +pub unsafe fn token_from_c(c_token: *const CToken) -> Token { + let token = &*c_token; + + 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.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..fdabc3995 --- /dev/null +++ b/rust/src/ffi.rs @@ -0,0 +1,70 @@ +use std::os::raw::{c_char, c_uint, c_void}; + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct CPosition { + pub line: u32, + pub column: u32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct CRange { + pub from: u32, + pub to: u32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct CLocation { + pub start: CPosition, + pub end: CPosition, +} + +#[repr(C)] +pub struct CToken { + pub value: *mut c_char, + pub range: CRange, + pub location: CLocation, + pub token_type: c_uint, +} + +#[repr(C)] +pub struct HbArray { + pub items: *mut *mut c_void, + pub size: usize, + pub capacity: usize, +} + +#[repr(C)] +pub struct CDocumentNode { + _private: [u8; 0], +} + +#[repr(C)] +pub struct ParserOptions { + _private: [u8; 0], +} + +#[repr(C)] +pub enum HerbExtractLanguage { + Ruby = 0, + Html = 1, +} + +extern "C" { + pub fn herb_lex(source: *const c_char) -> *mut HbArray; + pub fn herb_parse(source: *const c_char, options: *mut ParserOptions) -> *mut CDocumentNode; + pub fn herb_version() -> *const c_char; + pub fn herb_prism_version() -> *const c_char; + pub fn herb_free_tokens(tokens: *mut *mut HbArray); + + pub fn hb_array_size(array: *const HbArray) -> usize; + pub fn hb_array_get(array: *const HbArray, index: usize) -> *mut c_void; + + pub fn token_type_to_string(token_type: c_uint) -> *const c_char; + + pub fn ast_node_free(node: *mut CDocumentNode); + + pub fn herb_extract(source: *const c_char, language: HerbExtractLanguage) -> *mut c_char; +} diff --git a/rust/src/herb.rs b/rust/src/herb.rs new file mode 100644 index 000000000..1d0618689 --- /dev/null +++ b/rust/src/herb.rs @@ -0,0 +1,106 @@ +use crate::convert::token_from_c; +use crate::ffi::{CToken, HbArray}; +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 i in 0..array_size { + let c_token = crate::ffi::hb_array_get(c_tokens, i) as *const CToken; + + if !c_token.is_null() { + tokens.push(token_from_c(c_token)); + } + } + + let mut c_tokens_ptr = c_tokens; + crate::ffi::herb_free_tokens(&mut c_tokens_ptr as *mut *mut HbArray); + + 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 doc_node = crate::ast::convert_document_node(ast as *const std::ffi::c_void) + .ok_or_else(|| "Failed to convert AST".to_string())?; + + crate::ffi::ast_node_free(ast); + + let tree_inspect = crate::nodes::Node::tree_inspect(&doc_node); + + Ok(ParseResult::new(tree_inspect)) + } +} + +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::ffi::HerbExtractLanguage::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(); + + 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::ffi::HerbExtractLanguage::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(); + + 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..4842bfbd2 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,56 @@ +pub mod ast; +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, Error, 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, ErrorNode, 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.clone(), + 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..a5e5f362d --- /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-rs [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..ef9756c21 --- /dev/null +++ b/rust/src/parse_result.rs @@ -0,0 +1,13 @@ +pub struct ParseResult { + pub tree_inspect: String, +} + +impl ParseResult { + pub fn new(tree_inspect: String) -> Self { + Self { tree_inspect } + } + + pub fn tree_inspect(&self) -> &str { + &self.tree_inspect + } +} 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..49faa732e --- /dev/null +++ b/rust/tests/cli_commands_test.rs @@ -0,0 +1,48 @@ +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, + "
\n \n

\n \n
" + ); +} diff --git a/rust/tests/error_handling_test.rs b/rust/tests/error_handling_test.rs new file mode 100644 index 000000000..0af99e350 --- /dev/null +++ b/rust/tests/error_handling_test.rs @@ -0,0 +1,36 @@ +use herb::parse; + +#[test] +fn test_unclosed_element_error() { + let source = "
"; + let result = parse(source).unwrap(); + + assert!(result.tree_inspect.contains("UNCLOSED_ELEMENT_ERROR")); + assert!(result + .tree_inspect + .contains("Tag `
` opened at (1:1) was never closed")); + assert!(result.tree_inspect.contains("MISSING_CLOSING_TAG_ERROR")); + assert!(result + .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(); + + assert!(result.tree_inspect.contains("TAG_NAMES_MISMATCH_ERROR")); + assert!(result + .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(); + + assert!(!result.tree_inspect.contains("error")); + assert!(!result.tree_inspect.contains("ERROR")); +} diff --git a/rust/tests/node_field_test.rs b/rust/tests/node_field_test.rs new file mode 100644 index 000000000..5ef4fd1e4 --- /dev/null +++ b/rust/tests/node_field_test.rs @@ -0,0 +1,28 @@ +use herb::parse; + +#[test] +fn test_open_tag_field_is_displayed() { + let source = "
Hello
"; + let result = parse(source).unwrap(); + + assert!(result.tree_inspect.contains("open_tag:")); + assert!(result.tree_inspect.contains("@ HTMLOpenTagNode")); + assert!(result.tree_inspect.contains("tag_opening: \"<\"")); + assert!(result.tree_inspect.contains("tag_name: \"div\"")); + + assert!(result.tree_inspect.contains("close_tag:")); + assert!(result.tree_inspect.contains("@ HTMLCloseTagNode")); + assert!(result.tree_inspect.contains("tag_opening: \"Hello
"; + let result = parse(source).unwrap(); + + assert!(result.tree_inspect.contains("@ HTMLAttributeNode")); + assert!(result.tree_inspect.contains("name:")); + assert!(result.tree_inspect.contains("@ HTMLAttributeNameNode")); + assert!(result.tree_inspect.contains("value:")); + assert!(result.tree_inspect.contains("@ HTMLAttributeValueNode")); +} 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..e8c691215 --- /dev/null +++ b/templates/rust/src/ast/nodes.rs.erb @@ -0,0 +1,236 @@ +use crate::convert::token_from_c; +use crate::ffi::{CToken, HbArray}; +use crate::nodes::*; +use crate::{Location, Position}; +use std::ffi::CStr; +use std::os::raw::{c_char, c_void}; + +#[repr(C)] +#[derive(Copy, Clone)] +struct CPosition { + line: u32, + column: u32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct CLocation { + start: CPosition, + end: CPosition, +} + +#[repr(C)] +struct CAstNode { + node_type: u32, + location: CLocation, + errors: *mut HbArray, +} + +<%- nodes.each_with_index do |node, index| -%> +const <%= node.type %>: u32 = <%= index %>; +<%- end -%> + +<%- nodes.each do |node| -%> +#[repr(C)] +struct C<%= node.name %> { + base: CAstNode, + <%- node.fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + <%= field.name %>: *const c_char, + <%- when Herb::Template::TokenField -%> + <%= field.name %>: *mut CToken, + <%- when Herb::Template::BooleanField -%> + <%= field.name %>: bool, + <%- when Herb::Template::ArrayField -%> + <%= field.name %>: *mut HbArray, + <%- when Herb::Template::NodeField -%> + <%= field.name %>: *mut c_void, + <%- end -%> + <%- end -%> +} + +<%- end -%> + +extern "C" { + fn hb_array_size(array: *const HbArray) -> usize; + fn hb_array_get(array: *const HbArray, index: usize) -> *mut c_void; +} + +unsafe fn convert_location(c_loc: CLocation) -> Location { + Location::new( + Position::new(c_loc.start.line, c_loc.start.column), + Position::new(c_loc.end.line, c_loc.end.column), + ) +} + +use crate::nodes::ErrorNode; + +#[repr(C)] +struct CErrorNode { + error_type: u32, + location: CLocation, + message: *const c_char, +} + +<%- errors.each_with_index do |error, index| -%> +const <%= error.type %>: u32 = <%= index %>; +<%- end -%> + +unsafe fn error_type_to_string(error_type: u32) -> String { + match error_type { + <%- errors.each do |error| -%> + <%= error.type %> => "<%= error.type %>".to_string(), + <%- end -%> + _ => format!("UNKNOWN_ERROR_{}", error_type), + } +} + +unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { + if errors_array.is_null() { + return Vec::new(); + } + + let count = hb_array_size(errors_array); + let mut errors = Vec::with_capacity(count); + + for i in 0..count { + let error_ptr = hb_array_get(errors_array, i) as *const CErrorNode; + if !error_ptr.is_null() { + let error_ref = &*error_ptr; + let message = if error_ref.message.is_null() { + String::new() + } else { + CStr::from_ptr(error_ref.message).to_string_lossy().into_owned() + }; + + errors.push(ErrorNode::new( + error_type_to_string(error_ref.error_type), + convert_location(error_ref.location), + message, + )); + } + } + + errors +} + +unsafe fn get_string_field(ptr: *const c_char) -> String { + if ptr.is_null() { + String::new() + } else { + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } +} + +unsafe fn convert_token_field(ptr: *mut CToken) -> Option { + if ptr.is_null() { + None + } else { + Some(token_from_c(ptr)) + } +} + +unsafe fn convert_children(children_array: *mut HbArray) -> Vec { + if children_array.is_null() { + return Vec::new(); + } + + let count = hb_array_size(children_array); + let mut children = Vec::with_capacity(count); + + for i in 0..count { + let child_ptr = hb_array_get(children_array, i); + 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(ptr: *mut c_void) -> Option> { + if ptr.is_null() { + None + } else { + convert_node(ptr).map(Box::new) + } +} + +macro_rules! convert_specific_node_field { + ($ptr:expr, $expected_type:expr, $convert_fn:ident, $node_type:ty) => { + if $ptr.is_null() { + None + } else { + let base = $ptr as *const CAstNode; + let base_ref = &*base; + let node_type = base_ref.node_type; + + if node_type != $expected_type { + eprintln!("Warning: Expected node type {} but got {}", $expected_type, node_type); + None + } else { + $convert_fn($ptr).map(Box::new) + } + } + }; +} + +unsafe fn convert_node(node_ptr: *const c_void) -> Option { + if node_ptr.is_null() { + return None; + } + + let base = node_ptr as *const CAstNode; + let base_ref = &*base; + let node_type = base_ref.node_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| -%> + <%= 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 = node_ptr as *const C<%= node.name %>; + let base_ref = &(*c_node).base; + + Some(<%= node.name %> { + node_type: "<%= node.name %>".to_string(), + location: convert_location(base_ref.location), + errors: convert_errors(base_ref.errors), + <%- node.fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + <%= field.name %>: get_string_field((*c_node).<%= field.name %>), + <%- when Herb::Template::TokenField -%> + <%= field.name %>: convert_token_field((*c_node).<%= field.name %>), + <%- when Herb::Template::BooleanField -%> + <%= field.name %>: (*c_node).<%= field.name %>, + <%- when Herb::Template::ArrayField -%> + <%= field.name %>: convert_children((*c_node).<%= 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).<%= field.name %>, <%= specific_node.type %>, convert_<%= specific_node.human %>, <%= field.specific_kind %>), + <%- else -%> + <%= field.name %>: convert_node_field((*c_node).<%= field.name %>), + <%- 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..c8a33f1dd --- /dev/null +++ b/templates/rust/src/errors.rs.erb @@ -0,0 +1,187 @@ +use crate::{Location, Token}; + +#[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 Error { + 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 -%> + } + } +} + +<%- 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!("@ {} {}\n", self.error_type, self.location)); + <%- symbol = error.fields.any? ? "├──" : "└──" -%> + output.push_str(&format!("<%= symbol %> message: \"{}\"\\n", + self.message + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") + .replace('"', "\\\""))); + <%- error.fields.each do |field| -%> + <%- symbol = error.fields.last == field ? "└──" : "├──" -%> + <%- name = "#{symbol} #{field.name}: " -%> + <%- case field -%> + <%- when Herb::Template::StringField -%> + output.push_str(&format!("<%= name %>\"{}\"\\n", + self.<%= field.name %> + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") + .replace('"', "\\\""))); + <%- when Herb::Template::TokenField -%> + if let Some(ref token) = self.<%= field.name %> { + output.push_str(&format!("<%= name %>{}\\n", token.tree_inspect())); + } else { + output.push_str("<%= name %>∅\\n"); + } + <%- when Herb::Template::TokenTypeField -%> + if let Some(ref token_type) = self.<%= field.name %> { + output.push_str(&format!("<%= name %>\"{}\"\\n", token_type)); + } else { + output.push_str("<%= name %>∅\\n"); + } + <%- when Herb::Template::PositionField -%> + if let Some(ref pos) = self.<%= field.name %> { + output.push_str(&format!("<%= name %>{}\\n", pos)); + } else { + output.push_str("<%= name %>∅\\n"); + } + <%- when Herb::Template::SizeTField -%> + output.push_str(&format!("<%= name %>{}\\n", self.<%= field.name %>)); + <%- end -%> + <%- end -%> + + output + } +} + +impl Error 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..2198d1cec --- /dev/null +++ b/templates/rust/src/nodes.rs.erb @@ -0,0 +1,271 @@ +use crate::{Location, Token}; + +#[derive(Debug, Clone)] +pub struct ErrorNode { + pub error_type: String, + pub location: Location, + pub message: String, +} + +impl ErrorNode { + pub fn new(error_type: String, location: Location, message: String) -> Self { + Self { + error_type, + location, + message, + } + } + + pub fn tree_inspect(&self) -> String { + format!( + "@ {} {}\n└── message: \"{}\"", + self.error_type, self.location, self.message + ) + } +} + +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(node: &AnyNode, 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("└── "); + 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) -> &[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) -> &[ErrorNode] { + 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 -%> + } + } +} + +<%- 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) -> &[ErrorNode] { + &self.errors + } + + fn tree_inspect(&self) -> String { + let mut output = String::new(); + output.push_str(&format!("@ {} (location: {})\n", self.node_type, self.location)); + + if !self.errors.is_empty() { + let prefix = <%- if node.fields.any? -%>"│ "<%- else -%>" "<%- end -%>; + + output.push_str(&format!("├── errors: ({} error{})\n", + self.errors.len(), + if self.errors.len() == 1 { "" } else { "s" } + )); + + for (i, error) in self.errors.iter().enumerate() { + let is_last = i == self.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)); + } + + <%- 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", self.<%= field.name %>.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t").replace('"', "\\\""))); + <%- when Herb::Template::TokenField -%> + if let Some(ref token) = self.<%= field.name %> { + output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", token.tree_inspect())); + } else { + output.push_str("<%= symbol %><%= field.name %>: ∅\n"); + } + <%- when Herb::Template::BooleanField -%> + output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", self.<%= field.name %>)); + <%- when Herb::Template::ArrayField -%> + output.push_str("<%= symbol %><%= field.name %>: "); + output.push_str(&inspect_array(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>")); + <%- when Herb::Template::NodeField -%> + if let Some(ref node) = self.<%= field.name %> { + output.push_str("<%= symbol %><%= field.name %>:\n"); + <%- if field.specific_kind && field.specific_kind != "Node" -%> + // Specific node type - call tree_inspect directly + let prefix = "<%= is_last ? " " : "│ " %>"; + let tree = node.tree_inspect(); + let tree = tree.trim_end_matches('\n'); + let lines: Vec<&str> = tree.split('\n').collect(); + if !lines.is_empty() { + output.push_str(prefix); + output.push_str("└── "); + output.push_str(lines[0]); + output.push('\n'); + for line in lines.iter().skip(1) { + if line.is_empty() { + output.push_str(prefix); + output.push('\n'); + } else { + output.push_str(prefix); + output.push_str(" "); + output.push_str(line); + output.push_str("\n"); + } + } + } + <%- else -%> + output.push_str("<%= is_last ? " " : "│ " %>"); + output.push_str(&inspect_node(node, "<%= is_last ? " " : "│ " %>")); + <%- end -%> + <%- unless is_last -%> + output.push_str("<%= "│ " %>\n"); + <%- end -%> + } else { + output.push_str("<%= symbol %><%= field.name %>: ∅\n"); + } + <%- end -%> + <%- end -%> + <%- else -%> + output.push_str("└── (no fields)\n"); + <%- end -%> + + output + } +} + +<%- end -%> From 3ee52d0e352bc0f2e882261b7cc7270228b55ace Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:17:38 +0100 Subject: [PATCH 02/21] Update `on` --- .github/workflows/rust.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 129841468..1f4236511 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,7 +1,9 @@ name: Rust on: - push: [main] + push: + branches: + - main pull_request: permissions: From 68c9ed020a9c7729e820914482fbd1ee6b641530 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:23:53 +0100 Subject: [PATCH 03/21] Rustfmt --- rust/tests/cli_commands_test.rs | 5 ++++- templates/template.rb | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/rust/tests/cli_commands_test.rs b/rust/tests/cli_commands_test.rs index 49faa732e..5a3d09548 100644 --- a/rust/tests/cli_commands_test.rs +++ b/rust/tests/cli_commands_test.rs @@ -2,7 +2,10 @@ 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)"); + assert_eq!( + version(), + "herb rust v0.7.5, libprism v1.6.0, libherb v0.7.5 (Rust FFI)" + ); } #[test] diff --git a/templates/template.rb b/templates/template.rb index 64097371f..6b70540c1 100755 --- a/templates/template.rb +++ b/templates/template.rb @@ -311,6 +311,14 @@ def self.heading_for(file, template_file) # NOTE: This file is generated by the templates/template.rb script and should not be # modified manually. See #{template_file} + HEADING + when ".rs" + <<~HEADING + // NOTE: This file is generated by the templates/template.rb script and should not + // be modified manually. See #{template_file} + + #![rustfmt::skip] + HEADING else <<~HEADING From 031795b7d5a45939ee27734f6528887d10d99fb5 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:32:41 +0100 Subject: [PATCH 04/21] Use Rustfmt nightly so we can ignore files --- .github/workflows/rust.yml | 10 ++++++++-- rust/Makefile | 2 +- rust/rustfmt.toml | 8 ++++++++ templates/template.rb | 8 -------- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1f4236511..64732c13a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -24,7 +24,13 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - components: rustfmt, clippy + 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 @@ -43,7 +49,7 @@ jobs: run: bundle exec rake templates - name: Check Rust formatting - run: cargo fmt --check + run: cargo +nightly fmt --check working-directory: rust - name: Clippy diff --git a/rust/Makefile b/rust/Makefile index e9e376029..13cf2aad3 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -29,7 +29,7 @@ test: build cargo test format: - cargo fmt + cargo +nightly fmt @echo "Formatted Rust code" clean: diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index d55aec87b..8f1920a1a 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -7,3 +7,11 @@ 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/templates/template.rb b/templates/template.rb index 6b70540c1..64097371f 100755 --- a/templates/template.rb +++ b/templates/template.rb @@ -311,14 +311,6 @@ def self.heading_for(file, template_file) # NOTE: This file is generated by the templates/template.rb script and should not be # modified manually. See #{template_file} - HEADING - when ".rs" - <<~HEADING - // NOTE: This file is generated by the templates/template.rb script and should not - // be modified manually. See #{template_file} - - #![rustfmt::skip] - HEADING else <<~HEADING From fe425f567e255963133a59a138cbc26e8b735896 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:36:41 +0100 Subject: [PATCH 05/21] Use stable for other commands --- .github/workflows/rust.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 64732c13a..f91ce5c43 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -53,13 +53,13 @@ jobs: working-directory: rust - name: Clippy - run: cargo clippy --all-targets --all-features + run: cargo +stable clippy --all-targets --all-features working-directory: rust - name: Build Rust - run: cargo build --verbose + run: cargo +stable build --verbose working-directory: rust - name: Run Rust tests - run: cargo test --verbose -- --test-threads=1 + run: cargo +stable test --verbose -- --test-threads=1 working-directory: rust From 19d78ce3426be393d405fa2e67e43d03c5e05c44 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:41:00 +0100 Subject: [PATCH 06/21] Address clippy warnings --- rust/src/convert.rs | 6 ++++++ rust/src/lib.rs | 2 +- templates/rust/src/ast/nodes.rs.erb | 8 ++++++++ templates/rust/src/nodes.rs.erb | 4 ++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/rust/src/convert.rs b/rust/src/convert.rs index 6d25853a0..f9ab604ee 100644 --- a/rust/src/convert.rs +++ b/rust/src/convert.rs @@ -20,6 +20,12 @@ impl From for Location { } } +/// Converts a C token pointer to a Rust Token. +/// +/// # Safety +/// +/// The caller must ensure that `c_token` is a valid, non-null pointer to a `CToken` +/// and that the token's string fields (`value`, `token_type`) point to valid C strings. pub unsafe fn token_from_c(c_token: *const CToken) -> Token { let token = &*c_token; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4842bfbd2..8ec1acaff 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -34,7 +34,7 @@ mod tests { let text_node = HTMLTextNode { node_type: "HTMLTextNode".to_string(), - location: loc.clone(), + location: loc, errors: vec![], content: "Hello".to_string(), }; diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index e8c691215..9f05995e0 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -199,6 +199,14 @@ unsafe fn convert_node(node_ptr: *const c_void) -> Option { } <%- 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; diff --git a/templates/rust/src/nodes.rs.erb b/templates/rust/src/nodes.rs.erb index 2198d1cec..ac970ff58 100644 --- a/templates/rust/src/nodes.rs.erb +++ b/templates/rust/src/nodes.rs.erb @@ -47,7 +47,7 @@ fn inspect_array(array: &[AnyNode], prefix: &str) -> String { if !is_last { output.push_str(prefix); - output.push_str(&next_prefix); + output.push_str(next_prefix); output.push('\n'); } } @@ -244,7 +244,7 @@ impl Node for <%= node.name %> { output.push_str(prefix); output.push_str(" "); output.push_str(line); - output.push_str("\n"); + output.push('\n'); } } } From f99de2328870b0e90c86160148ee87e861769269 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 18:43:41 +0100 Subject: [PATCH 07/21] Build prism first --- .github/workflows/rust.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f91ce5c43..9242ac496 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -48,6 +48,9 @@ jobs: - name: Render Templates run: bundle exec rake templates + - name: Compile Herb + run: bundle exec rake make + - name: Check Rust formatting run: cargo +nightly fmt --check working-directory: rust From 843e65ca534a6c50d912365b1032a83236b5a8d4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 19:01:40 +0100 Subject: [PATCH 08/21] Upate Cargo.toml --- rust/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 967e24228..4a83148e1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -4,8 +4,11 @@ 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" From cb6ee655289681d9f83c551d77fcbcd40e666167 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 19:04:17 +0100 Subject: [PATCH 09/21] Remove duplicate sig/serialized_ast_nodes.rbs in gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8e96522ee..c2c1bde21 100644 --- a/.gitignore +++ b/.gitignore @@ -113,7 +113,6 @@ rust/src/errors.rs rust/src/nodes.rs sig/serialized_ast_errors.rbs sig/serialized_ast_nodes.rbs -sig/serialized_ast_nodes.rbs src/ast_nodes.c src/ast_pretty_print.c src/errors.c From c4f8e530cba11b4db2322a08ee9d123e9a4279f3 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 19:40:41 +0100 Subject: [PATCH 10/21] Free string --- rust/src/herb.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 1d0618689..5e9203470 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -62,6 +62,8 @@ pub fn extract_ruby(source: &str) -> Result { 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) } } @@ -78,6 +80,8 @@ pub fn extract_html(source: &str) -> Result { 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) } } From 13c1a14c886caab5c107241b60d1b816457be2c0 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 21:43:33 +0100 Subject: [PATCH 11/21] Extend ParseResult to have a value --- docs/docs/bindings/rust/reference.md | 63 ++++++++++++++++- rust/src/herb.rs | 10 +-- rust/src/main.rs | 2 +- rust/src/parse_result.rs | 36 ++++++++-- rust/tests/error_handling_test.rs | 25 +++---- rust/tests/node_field_test.rs | 26 +++---- rust/tests/parse_result_test.rs | 94 +++++++++++++++++++++++++ templates/rust/src/ast/nodes.rs.erb | 80 ++++++++++----------- templates/rust/src/nodes.rs.erb | 100 +++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 79 deletions(-) create mode 100644 rust/tests/parse_result_test.rs diff --git a/docs/docs/bindings/rust/reference.md b/docs/docs/bindings/rust/reference.md index c150c355f..edf6a5b75 100644 --- a/docs/docs/bindings/rust/reference.md +++ b/docs/docs/bindings/rust/reference.md @@ -114,18 +114,64 @@ match parse(source) { ### `ParseResult` -The `ParseResult` struct provides access to the parsed AST tree inspect output: +The `ParseResult` struct provides access to the parsed AST and any parse-level errors: ```rust pub struct ParseResult { - pub tree_inspect: String, + pub value: DocumentNode, + pub source: String, + pub errors: Vec, } impl ParseResult { - pub fn tree_inspect(&self) -> &str; + pub fn tree_inspect(&self) -> String; + pub fn errors(&self) -> &[ErrorNode]; + pub fn recursive_errors(&self) -> Vec; + 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 +- `recursive_errors()` - Returns parse-level errors combined with all node errors recursively +- `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` @@ -241,10 +287,21 @@ pub trait Node { fn node_type(&self) -> &str; fn location(&self) -> &Location; fn errors(&self) -> &[ErrorNode]; + fn child_nodes(&self) -> Vec<&dyn Node>; + fn recursive_errors(&self) -> Vec; 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 +- `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 +- `tree_inspect()` - Returns a formatted string representation of the node and its children + ### Error Handling Parse errors are included in the AST as `ErrorNode` structs: diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 5e9203470..01dad921c 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -39,14 +39,16 @@ pub fn parse(source: &str) -> Result { return Err("Failed to parse source".to_string()); } - let doc_node = crate::ast::convert_document_node(ast as *const std::ffi::c_void) + let document_node = crate::ast::convert_document_node(ast as *const std::ffi::c_void) .ok_or_else(|| "Failed to convert AST".to_string())?; crate::ffi::ast_node_free(ast); - let tree_inspect = crate::nodes::Node::tree_inspect(&doc_node); - - Ok(ParseResult::new(tree_inspect)) + Ok(ParseResult::new( + document_node, + source.to_string(), + Vec::new(), + )) } } diff --git a/rust/src/main.rs b/rust/src/main.rs index a5e5f362d..abc3eb30c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -132,7 +132,7 @@ fn html_command(file_path: &str) { } fn print_usage() { - println!("Usage: herb-rs [command] [file]"); + println!("Usage: herb-rust [command] [file]"); println!(); println!("Herb 🌿 Powerful and seamless HTML-aware ERB parsing and tooling."); println!(); diff --git a/rust/src/parse_result.rs b/rust/src/parse_result.rs index ef9756c21..6add0d333 100644 --- a/rust/src/parse_result.rs +++ b/rust/src/parse_result.rs @@ -1,13 +1,39 @@ +use crate::nodes::{DocumentNode, ErrorNode, Node}; + pub struct ParseResult { - pub tree_inspect: String, + pub value: DocumentNode, + pub source: String, + pub errors: Vec, } impl ParseResult { - pub fn new(tree_inspect: String) -> Self { - Self { tree_inspect } + 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) -> &[ErrorNode] { + &self.errors + } + + pub fn recursive_errors(&self) -> Vec { + let mut all_errors = self.errors.clone(); + all_errors.extend(self.value.recursive_errors()); + all_errors + } + + pub fn failed(&self) -> bool { + !self.recursive_errors().is_empty() } - pub fn tree_inspect(&self) -> &str { - &self.tree_inspect + pub fn success(&self) -> bool { + self.recursive_errors().is_empty() } } diff --git a/rust/tests/error_handling_test.rs b/rust/tests/error_handling_test.rs index 0af99e350..5d04632e1 100644 --- a/rust/tests/error_handling_test.rs +++ b/rust/tests/error_handling_test.rs @@ -5,14 +5,11 @@ fn test_unclosed_element_error() { let source = "
"; let result = parse(source).unwrap(); - assert!(result.tree_inspect.contains("UNCLOSED_ELEMENT_ERROR")); - assert!(result - .tree_inspect - .contains("Tag `
` opened at (1:1) was never closed")); - assert!(result.tree_inspect.contains("MISSING_CLOSING_TAG_ERROR")); - assert!(result - .tree_inspect - .contains("Opening tag `
` at (1:1) doesn't have a matching closing tag")); + 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] @@ -20,10 +17,9 @@ fn test_tag_names_mismatch_error() { let source = "
"; let result = parse(source).unwrap(); - assert!(result.tree_inspect.contains("TAG_NAMES_MISMATCH_ERROR")); - assert!(result - .tree_inspect - .contains("Opening tag `
` at (1:1) closed with ``")); + 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] @@ -31,6 +27,7 @@ fn test_no_errors_with_valid_html() { let source = "
Hello
"; let result = parse(source).unwrap(); - assert!(!result.tree_inspect.contains("error")); - assert!(!result.tree_inspect.contains("ERROR")); + let tree_inspect = result.tree_inspect(); + assert!(!tree_inspect.contains("error")); + assert!(!tree_inspect.contains("ERROR")); } diff --git a/rust/tests/node_field_test.rs b/rust/tests/node_field_test.rs index 5ef4fd1e4..77b4b2d0c 100644 --- a/rust/tests/node_field_test.rs +++ b/rust/tests/node_field_test.rs @@ -5,14 +5,15 @@ fn test_open_tag_field_is_displayed() { let source = "
Hello
"; let result = parse(source).unwrap(); - assert!(result.tree_inspect.contains("open_tag:")); - assert!(result.tree_inspect.contains("@ HTMLOpenTagNode")); - assert!(result.tree_inspect.contains("tag_opening: \"<\"")); - assert!(result.tree_inspect.contains("tag_name: \"div\"")); + 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!(result.tree_inspect.contains("close_tag:")); - assert!(result.tree_inspect.contains("@ HTMLCloseTagNode")); - assert!(result.tree_inspect.contains("tag_opening: \"Hello
"; let result = parse(source).unwrap(); - assert!(result.tree_inspect.contains("@ HTMLAttributeNode")); - assert!(result.tree_inspect.contains("name:")); - assert!(result.tree_inspect.contains("@ HTMLAttributeNameNode")); - assert!(result.tree_inspect.contains("value:")); - assert!(result.tree_inspect.contains("@ HTMLAttributeValueNode")); + 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..ecf4fdde7 --- /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/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index 9f05995e0..b54598659 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -198,47 +198,47 @@ unsafe fn convert_node(node_ptr: *const c_void) -> Option { } } -<%- 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 -%> + <%- 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; - } + if node_ptr.is_null() { + return None; + } - let c_node = node_ptr as *const C<%= node.name %>; - let base_ref = &(*c_node).base; - - Some(<%= node.name %> { - node_type: "<%= node.name %>".to_string(), - location: convert_location(base_ref.location), - errors: convert_errors(base_ref.errors), - <%- node.fields.each do |field| -%> - <%- case field -%> - <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> - <%= field.name %>: get_string_field((*c_node).<%= field.name %>), - <%- when Herb::Template::TokenField -%> - <%= field.name %>: convert_token_field((*c_node).<%= field.name %>), - <%- when Herb::Template::BooleanField -%> - <%= field.name %>: (*c_node).<%= field.name %>, - <%- when Herb::Template::ArrayField -%> - <%= field.name %>: convert_children((*c_node).<%= 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).<%= field.name %>, <%= specific_node.type %>, convert_<%= specific_node.human %>, <%= field.specific_kind %>), - <%- else -%> - <%= field.name %>: convert_node_field((*c_node).<%= field.name %>), - <%- end -%> - <%- end -%> - <%- end -%> - }) -} + let c_node = node_ptr as *const C<%= node.name %>; + let base_ref = &(*c_node).base; + + Some(<%= node.name %> { + node_type: "<%= node.name %>".to_string(), + location: convert_location(base_ref.location), + errors: convert_errors(base_ref.errors), + <%- node.fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + <%= field.name %>: get_string_field((*c_node).<%= field.name %>), + <%- when Herb::Template::TokenField -%> + <%= field.name %>: convert_token_field((*c_node).<%= field.name %>), + <%- when Herb::Template::BooleanField -%> + <%= field.name %>: (*c_node).<%= field.name %>, + <%- when Herb::Template::ArrayField -%> + <%= field.name %>: convert_children((*c_node).<%= 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).<%= field.name %>, <%= specific_node.type %>, convert_<%= specific_node.human %>, <%= field.specific_kind %>), + <%- else -%> + <%= field.name %>: convert_node_field((*c_node).<%= field.name %>), + <%- end -%> + <%- end -%> + <%- end -%> + }) + } <%- end -%> diff --git a/templates/rust/src/nodes.rs.erb b/templates/rust/src/nodes.rs.erb index ac970ff58..506cfcd90 100644 --- a/templates/rust/src/nodes.rs.erb +++ b/templates/rust/src/nodes.rs.erb @@ -88,6 +88,8 @@ pub trait Node { fn node_type(&self) -> &str; fn location(&self) -> &Location; fn errors(&self) -> &[ErrorNode]; + fn child_nodes(&self) -> Vec<&dyn Node>; + fn recursive_errors(&self) -> Vec; fn tree_inspect(&self) -> String; } @@ -130,6 +132,48 @@ impl AnyNode { <%- 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 { + 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) -> &[ErrorNode] { + self.errors() + } + + fn child_nodes(&self) -> Vec<&dyn Node> { + self.child_nodes() + } + + fn recursive_errors(&self) -> Vec { + self.recursive_errors() + } + + fn tree_inspect(&self) -> String { + self.tree_inspect() + } } <%- nodes.each do |node| -%> @@ -173,6 +217,62 @@ impl Node for <%= node.name %> { &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 { + let mut all_errors = self.errors.clone(); + + all_errors.extend( + self.child_nodes() + .iter() + .flat_map(|child| child.recursive_errors()) + ); + <%- node.fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::NodeField -%> + <%- if field.specific_kind && field.specific_kind != "Node" -%> + + if let Some(ref node) = self.<%= field.name %> { + all_errors.extend(node.recursive_errors()); + } + <%- end -%> + <%- end -%> + <%- end -%> + + all_errors + } + fn tree_inspect(&self) -> String { let mut output = String::new(); output.push_str(&format!("@ {} (location: {})\n", self.node_type, self.location)); From bfe03dd5338c3e7b30de141378b8e434a8a1a7ac Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 21:55:32 +0100 Subject: [PATCH 12/21] Move ast_node_free --- rust/src/herb.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 01dad921c..91a03431c 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -42,13 +42,11 @@ pub fn parse(source: &str) -> Result { 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); - Ok(ParseResult::new( - document_node, - source.to_string(), - Vec::new(), - )) + Ok(result) } } From c5362ab409ab3df306655d4f897798289efcd327 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 23:15:16 +0100 Subject: [PATCH 13/21] Fully support Errors in Rust and refactor tree_inspect --- .github/workflows/rust.yml | 8 +- rust/Cargo.toml | 2 +- rust/Makefile | 18 ++- rust/bin/herb-rust | 4 +- rust/src/lib.rs | 4 +- rust/src/parse_result.rs | 14 +- rust/tests/parse_result_test.rs | 6 +- templates/rust/src/ast/nodes.rs.erb | 84 ++++++++--- templates/rust/src/errors.rs.erb | 92 +++++++----- templates/rust/src/nodes.rs.erb | 209 ++++++++++++++-------------- templates/template.rb | 15 +- 11 files changed, 268 insertions(+), 188 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9242ac496..114bacda1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -52,17 +52,17 @@ jobs: run: bundle exec rake make - name: Check Rust formatting - run: cargo +nightly fmt --check + run: make format-check working-directory: rust - name: Clippy - run: cargo +stable clippy --all-targets --all-features + run: make lint working-directory: rust - name: Build Rust - run: cargo +stable build --verbose + run: make build working-directory: rust - name: Run Rust tests - run: cargo +stable test --verbose -- --test-threads=1 + run: make test working-directory: rust diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4a83148e1..e6ae5d406 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,7 +16,7 @@ path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [[bin]] -name = "herb-rs" +name = "herb-rust" path = "src/main.rs" [dependencies] diff --git a/rust/Makefile b/rust/Makefile index 13cf2aad3..4390698cf 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -1,7 +1,7 @@ # Rust Makefile for Herb BUILD_DIR = target -BIN_NAME = herb-rs +BIN_NAME = herb-rust BIN_PATH = $(BUILD_DIR)/debug/$(BIN_NAME) RELEASE_BIN_PATH = $(BUILD_DIR)/release/$(BIN_NAME) @@ -13,7 +13,7 @@ templates: cd .. && bundle exec rake templates build: templates - cargo build --bin $(BIN_NAME) + cargo +stable build --verbose --bin $(BIN_NAME) @echo "Built debug binary: $(BIN_PATH)" release: templates @@ -21,17 +21,25 @@ release: templates @echo "Built release binary: $(RELEASE_BIN_PATH)" cli: build - @echo "CLI ready! Use: ./bin/herb-rs [command] [file]" + @echo "CLI ready! Use: ./bin/$(BIN_NAME) [command] [file]" @echo "" - @./bin/herb-rs || true + @./bin/$(BIN_NAME) || true test: build - cargo test + 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" diff --git a/rust/bin/herb-rust b/rust/bin/herb-rust index d3676ed47..4beed564d 100755 --- a/rust/bin/herb-rust +++ b/rust/bin/herb-rust @@ -3,10 +3,10 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" RUST_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" -BINARY_PATH="$RUST_DIR/target/debug/herb-rs" +BINARY_PATH="$RUST_DIR/target/debug/herb-rust" if [ ! -f "$BINARY_PATH" ]; then - echo "Error: herb-rs binary not found at $BINARY_PATH" + echo "Error: herb-rust binary not found at $BINARY_PATH" echo "Please run 'make build' in the rust/ directory first." exit 1 diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 8ec1acaff..1d2109dee 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,11 +11,11 @@ pub mod position; pub mod range; pub mod token; -pub use errors::{AnyError, Error, ErrorType}; +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, ErrorNode, Node}; +pub use nodes::{AnyNode, Node}; pub use parse_result::ParseResult; pub use position::Position; pub use range::Range; diff --git a/rust/src/parse_result.rs b/rust/src/parse_result.rs index 6add0d333..756215195 100644 --- a/rust/src/parse_result.rs +++ b/rust/src/parse_result.rs @@ -1,13 +1,14 @@ -use crate::nodes::{DocumentNode, ErrorNode, Node}; +use crate::errors::{AnyError, ErrorNode}; +use crate::nodes::{DocumentNode, Node}; pub struct ParseResult { pub value: DocumentNode, pub source: String, - pub errors: Vec, + pub errors: Vec, } impl ParseResult { - pub fn new(value: DocumentNode, source: String, errors: Vec) -> Self { + pub fn new(value: DocumentNode, source: String, errors: Vec) -> Self { Self { value, source, @@ -19,12 +20,13 @@ impl ParseResult { self.value.tree_inspect() } - pub fn errors(&self) -> &[ErrorNode] { + pub fn errors(&self) -> &[AnyError] { &self.errors } - pub fn recursive_errors(&self) -> Vec { - let mut all_errors = self.errors.clone(); + 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 } diff --git a/rust/tests/parse_result_test.rs b/rust/tests/parse_result_test.rs index ecf4fdde7..930536774 100644 --- a/rust/tests/parse_result_test.rs +++ b/rust/tests/parse_result_test.rs @@ -45,11 +45,11 @@ fn test_parse_result_failed_with_unclosed_element() { assert!(errors .iter() - .any(|e| e.error_type == "UNCLOSED_ELEMENT_ERROR")); + .any(|e| e.error_type() == "UNCLOSED_ELEMENT_ERROR")); assert!(errors .iter() - .any(|e| e.error_type == "MISSING_CLOSING_TAG_ERROR")); + .any(|e| e.error_type() == "MISSING_CLOSING_TAG_ERROR")); } #[test] @@ -73,7 +73,7 @@ fn test_parse_result_failed_with_tag_mismatch() { assert!(errors .iter() - .any(|e| e.error_type == "TAG_NAMES_MISMATCH_ERROR")); + .any(|e| e.error_type() == "TAG_NAMES_MISMATCH_ERROR")); } #[test] diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index b54598659..577ed9327 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -1,4 +1,5 @@ use crate::convert::token_from_c; +use crate::errors::*; use crate::ffi::{CToken, HbArray}; use crate::nodes::*; use crate::{Location, Position}; @@ -64,10 +65,8 @@ unsafe fn convert_location(c_loc: CLocation) -> Location { ) } -use crate::nodes::ErrorNode; - #[repr(C)] -struct CErrorNode { +struct CErrorBase { error_type: u32, location: CLocation, message: *const c_char, @@ -77,16 +76,55 @@ struct CErrorNode { const <%= error.type %>: u32 = <%= index %>; <%- end -%> -unsafe fn error_type_to_string(error_type: u32) -> String { - match error_type { - <%- errors.each do |error| -%> - <%= error.type %> => "<%= error.type %>".to_string(), +<%- 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 -%> +#[repr(C)] +struct C<%= error.name %> { + base: CErrorBase, + <%- error.fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::StringField -%> + <%= field.name %>: *const c_char, + <%- when Herb::Template::TokenField -%> + <%= field.name %>: *mut CToken, + <%- when Herb::Template::TokenTypeField -%> + <%= field.name %>: u32, + <%- end -%> + <%- end -%> +} + +unsafe fn convert_<%= snake_name %>(error_ptr: *const C<%= error.name %>) -> <%= 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 -%> - _ => format!("UNKNOWN_ERROR_{}", error_type), - } + <%- end -%> + ) } -unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { +<%- end -%> + +unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { if errors_array.is_null() { return Vec::new(); } @@ -95,20 +133,22 @@ unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { let mut errors = Vec::with_capacity(count); for i in 0..count { - let error_ptr = hb_array_get(errors_array, i) as *const CErrorNode; - if !error_ptr.is_null() { - let error_ref = &*error_ptr; - let message = if error_ref.message.is_null() { - String::new() - } else { - CStr::from_ptr(error_ref.message).to_string_lossy().into_owned() + let error_base_ptr = hb_array_get(errors_array, i) as *const CErrorBase; + if !error_base_ptr.is_null() { + let error_base = &*error_base_ptr; + + let error = match error_base.error_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 C<%= error.name %>; + AnyError::<%= error.name %>(convert_<%= snake_name %>(error_ptr)) + } + <%- end -%> + _ => continue, }; - errors.push(ErrorNode::new( - error_type_to_string(error_ref.error_type), - convert_location(error_ref.location), - message, - )); + errors.push(error); } } diff --git a/templates/rust/src/errors.rs.erb b/templates/rust/src/errors.rs.erb index c8a33f1dd..dbd859601 100644 --- a/templates/rust/src/errors.rs.erb +++ b/templates/rust/src/errors.rs.erb @@ -1,5 +1,35 @@ 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| -%> @@ -17,7 +47,7 @@ impl ErrorType { } } -pub trait Error { +pub trait ErrorNode { fn error_type(&self) -> &str; fn message(&self) -> &str; fn location(&self) -> &Location; @@ -65,6 +95,24 @@ impl AnyError { } } +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 %> { @@ -118,47 +166,23 @@ impl <%= error.name %> { pub fn tree_inspect(&self) -> String { let mut output = String::new(); - output.push_str(&format!("@ {} {}\n", self.error_type, self.location)); + + output.push_str(&format!("@ <%= error.type %> {}\n", self.location)); <%- symbol = error.fields.any? ? "├──" : "└──" -%> - output.push_str(&format!("<%= symbol %> message: \"{}\"\\n", - self.message - .replace('\\', "\\\\") - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t") - .replace('"', "\\\""))); + output.push_str(&format!("<%= symbol %> message: {}\n", format_string_value(&self.message))); <%- error.fields.each do |field| -%> <%- symbol = error.fields.last == field ? "└──" : "├──" -%> - <%- name = "#{symbol} #{field.name}: " -%> <%- case field -%> <%- when Herb::Template::StringField -%> - output.push_str(&format!("<%= name %>\"{}\"\\n", - self.<%= field.name %> - .replace('\\', "\\\\") - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t") - .replace('"', "\\\""))); + output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_string_value(&self.<%= field.name %>))); <%- when Herb::Template::TokenField -%> - if let Some(ref token) = self.<%= field.name %> { - output.push_str(&format!("<%= name %>{}\\n", token.tree_inspect())); - } else { - output.push_str("<%= name %>∅\\n"); - } + output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_token_value(&self.<%= field.name %>))); <%- when Herb::Template::TokenTypeField -%> - if let Some(ref token_type) = self.<%= field.name %> { - output.push_str(&format!("<%= name %>\"{}\"\\n", token_type)); - } else { - output.push_str("<%= name %>∅\\n"); - } + output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_token_type_value(&self.<%= field.name %>))); <%- when Herb::Template::PositionField -%> - if let Some(ref pos) = self.<%= field.name %> { - output.push_str(&format!("<%= name %>{}\\n", pos)); - } else { - output.push_str("<%= name %>∅\\n"); - } + output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_position_value(&self.<%= field.name %>))); <%- when Herb::Template::SizeTField -%> - output.push_str(&format!("<%= name %>{}\\n", self.<%= field.name %>)); + output.push_str(&format!("<%= symbol %> <%= field.name %>: {}\n", format_size_value(self.<%= field.name %>))); <%- end -%> <%- end -%> @@ -166,7 +190,7 @@ impl <%= error.name %> { } } -impl Error for <%= error.name %> { +impl ErrorNode for <%= error.name %> { fn error_type(&self) -> &str { &self.error_type } diff --git a/templates/rust/src/nodes.rs.erb b/templates/rust/src/nodes.rs.erb index 506cfcd90..c68784233 100644 --- a/templates/rust/src/nodes.rs.erb +++ b/templates/rust/src/nodes.rs.erb @@ -1,27 +1,75 @@ +use crate::errors::{AnyError, ErrorNode}; use crate::{Location, Token}; -#[derive(Debug, Clone)] -pub struct ErrorNode { - pub error_type: String, - pub location: Location, - pub message: String, +fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") + .replace('"', "\\\"") } -impl ErrorNode { - pub fn new(error_type: String, location: Location, message: String) -> Self { - Self { - error_type, - location, - message, +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() } +} - pub fn tree_inspect(&self) -> String { - format!( - "@ {} {}\n└── message: \"{}\"", - self.error_type, self.location, self.message - ) +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 { @@ -55,7 +103,7 @@ fn inspect_array(array: &[AnyNode], prefix: &str) -> String { output } -fn inspect_node(node: &AnyNode, prefix: &str) -> String { +fn inspect_node_field(node: &T, prefix: &str) -> String { let tree = node.tree_inspect(); let tree = tree.trim_end_matches('\n'); @@ -65,6 +113,7 @@ fn inspect_node(node: &AnyNode, prefix: &str) -> String { } let mut result = String::new(); + result.push_str(prefix); result.push_str("└── "); result.push_str(lines[0]); result.push('\n'); @@ -87,9 +136,9 @@ fn inspect_node(node: &AnyNode, prefix: &str) -> String { pub trait Node { fn node_type(&self) -> &str; fn location(&self) -> &Location; - fn errors(&self) -> &[ErrorNode]; + fn errors(&self) -> &[AnyError]; fn child_nodes(&self) -> Vec<&dyn Node>; - fn recursive_errors(&self) -> Vec; + fn recursive_errors(&self) -> Vec<&dyn ErrorNode>; fn tree_inspect(&self) -> String; } @@ -117,7 +166,7 @@ impl AnyNode { } } - pub fn errors(&self) -> &[ErrorNode] { + pub fn errors(&self) -> &[AnyError] { match self { <%- nodes.each do |node| -%> AnyNode::<%= node.name %>(n) => &n.errors, @@ -141,7 +190,7 @@ impl AnyNode { } } - pub fn recursive_errors(&self) -> Vec { + pub fn recursive_errors(&self) -> Vec<&dyn ErrorNode> { match self { <%- nodes.each do |node| -%> AnyNode::<%= node.name %>(n) => n.recursive_errors(), @@ -159,7 +208,7 @@ impl Node for AnyNode { self.location() } - fn errors(&self) -> &[ErrorNode] { + fn errors(&self) -> &[AnyError] { self.errors() } @@ -167,7 +216,7 @@ impl Node for AnyNode { self.child_nodes() } - fn recursive_errors(&self) -> Vec { + fn recursive_errors(&self) -> Vec<&dyn ErrorNode> { self.recursive_errors() } @@ -181,7 +230,7 @@ impl Node for AnyNode { pub struct <%= node.name %> { pub node_type: String, pub location: Location, - pub errors: Vec, + pub errors: Vec, <%- node.fields.each do |field| -%> <%- case field -%> <%- when Herb::Template::StringField -%> @@ -213,7 +262,7 @@ impl Node for <%= node.name %> { &self.location } - fn errors(&self) -> &[ErrorNode] { + fn errors(&self) -> &[AnyError] { &self.errors } @@ -250,114 +299,60 @@ impl Node for <%= node.name %> { <%- end -%> } - fn recursive_errors(&self) -> Vec { - let mut all_errors = self.errors.clone(); + 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)); - all_errors.extend( - self.child_nodes() - .iter() - .flat_map(|child| child.recursive_errors()) - ); <%- 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 field.specific_kind && field.specific_kind != "Node" -%> - if let Some(ref node) = self.<%= field.name %> { all_errors.extend(node.recursive_errors()); } <%- end -%> <%- 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!("@ {} (location: {})\n", self.node_type, self.location)); - - if !self.errors.is_empty() { - let prefix = <%- if node.fields.any? -%>"│ "<%- else -%>" "<%- end -%>; - - output.push_str(&format!("├── errors: ({} error{})\n", - self.errors.len(), - if self.errors.len() == 1 { "" } else { "s" } - )); - - for (i, error) in self.errors.iter().enumerate() { - let is_last = i == self.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.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", self.<%= field.name %>.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t").replace('"', "\\\""))); + output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", format_string_value(&self.<%= field.name %>))); <%- when Herb::Template::TokenField -%> - if let Some(ref token) = self.<%= field.name %> { - output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", token.tree_inspect())); - } else { - output.push_str("<%= symbol %><%= field.name %>: ∅\n"); - } + 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", self.<%= field.name %>)); + output.push_str(&format!("<%= symbol %><%= field.name %>: {}\n", format_bool_value(self.<%= field.name %>))); <%- when Herb::Template::ArrayField -%> - output.push_str("<%= symbol %><%= field.name %>: "); - output.push_str(&inspect_array(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>")); + output.push_str(&format!("<%= symbol %><%= field.name %>: {}", format_array_value(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>"))); <%- when Herb::Template::NodeField -%> - if let Some(ref node) = self.<%= field.name %> { - output.push_str("<%= symbol %><%= field.name %>:\n"); - <%- if field.specific_kind && field.specific_kind != "Node" -%> - // Specific node type - call tree_inspect directly - let prefix = "<%= is_last ? " " : "│ " %>"; - let tree = node.tree_inspect(); - let tree = tree.trim_end_matches('\n'); - let lines: Vec<&str> = tree.split('\n').collect(); - if !lines.is_empty() { - output.push_str(prefix); - output.push_str("└── "); - output.push_str(lines[0]); - output.push('\n'); - for line in lines.iter().skip(1) { - if line.is_empty() { - output.push_str(prefix); - output.push('\n'); - } else { - output.push_str(prefix); - output.push_str(" "); - output.push_str(line); - output.push('\n'); - } - } - } - <%- else -%> - output.push_str("<%= is_last ? " " : "│ " %>"); - output.push_str(&inspect_node(node, "<%= is_last ? " " : "│ " %>")); - <%- end -%> - <%- unless is_last -%> - output.push_str("<%= "│ " %>\n"); - <%- end -%> - } else { - output.push_str("<%= symbol %><%= field.name %>: ∅\n"); - } + output.push_str(&format!("<%= symbol %><%= field.name %>: {}", format_node_value(&self.<%= field.name %>, "<%= is_last ? " " : "│ " %>", <%= !is_last %>))); <%- end -%> <%- end -%> <%- else -%> diff --git a/templates/template.rb b/templates/template.rb index 64097371f..86f70410c 100755 --- a/templates/template.rb +++ b/templates/template.rb @@ -331,6 +331,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 +353,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 +367,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 From eb3606f73d577157f2b24c7bd5ff8d15382cbc0c Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 23:20:38 +0100 Subject: [PATCH 14/21] Change enum types from u32 to c_int --- templates/rust/src/ast/nodes.rs.erb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index 577ed9327..4b295eab7 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -4,7 +4,7 @@ use crate::ffi::{CToken, HbArray}; use crate::nodes::*; use crate::{Location, Position}; use std::ffi::CStr; -use std::os::raw::{c_char, c_void}; +use std::os::raw::{c_char, c_int, c_void}; #[repr(C)] #[derive(Copy, Clone)] @@ -67,13 +67,13 @@ unsafe fn convert_location(c_loc: CLocation) -> Location { #[repr(C)] struct CErrorBase { - error_type: u32, + error_type: c_int, location: CLocation, message: *const c_char, } <%- errors.each_with_index do |error, index| -%> -const <%= error.type %>: u32 = <%= index %>; +const <%= error.type %>: c_int = <%= index %>; <%- end -%> <%- errors.each do |error| -%> @@ -88,7 +88,7 @@ struct C<%= error.name %> { <%- when Herb::Template::TokenField -%> <%= field.name %>: *mut CToken, <%- when Herb::Template::TokenTypeField -%> - <%= field.name %>: u32, + <%= field.name %>: c_int, <%- end -%> <%- end -%> } @@ -112,10 +112,10 @@ unsafe fn convert_<%= snake_name %>(error_ptr: *const C<%= error.name %>) -> <%= <%- when Herb::Template::TokenField -%> convert_token_field(error_ref.<%= field.name %>), <%- when Herb::Template::TokenTypeField -%> - if error_ref.<%= field.name %> == u32::MAX { + if error_ref.<%= field.name %> == -1 { None } else { - Some(CStr::from_ptr(crate::ffi::token_type_to_string(error_ref.<%= field.name %>)).to_string_lossy().into_owned()) + Some(CStr::from_ptr(crate::ffi::token_type_to_string(error_ref.<%= field.name %> as u32)).to_string_lossy().into_owned()) }, <%- end -%> <%- end -%> From 66b916631e0f44daecf0b8a235eb30fbfec86c49 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 2 Nov 2025 23:27:26 +0100 Subject: [PATCH 15/21] Update docs --- docs/docs/bindings/rust/reference.md | 37 ++++++++++++++-------------- templates/rust/src/ast/nodes.rs.erb | 19 ++++++++------ 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/docs/docs/bindings/rust/reference.md b/docs/docs/bindings/rust/reference.md index edf6a5b75..71a79b463 100644 --- a/docs/docs/bindings/rust/reference.md +++ b/docs/docs/bindings/rust/reference.md @@ -120,13 +120,13 @@ The `ParseResult` struct provides access to the parsed AST and any parse-level e pub struct ParseResult { pub value: DocumentNode, pub source: String, - pub errors: Vec, + pub errors: Vec, } impl ParseResult { pub fn tree_inspect(&self) -> String; - pub fn errors(&self) -> &[ErrorNode]; - pub fn recursive_errors(&self) -> Vec; + pub fn errors(&self) -> &[AnyError]; + pub fn recursive_errors(&self) -> Vec<&dyn ErrorNode>; pub fn failed(&self) -> bool; pub fn success(&self) -> bool; } @@ -135,8 +135,8 @@ impl ParseResult { **Methods:** - `tree_inspect()` - Returns a string representation of the AST -- `errors()` - Returns only the parse-level errors -- `recursive_errors()` - Returns parse-level errors combined with all node errors recursively +- `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 @@ -155,9 +155,9 @@ match parse(source) { for error in result.recursive_errors() { println!(" {} at {}: {}", - error.error_type, - error.location, - error.message + error.error_type(), + error.location(), + error.message() ); } } else { @@ -286,9 +286,9 @@ All AST nodes implement the `Node` trait: pub trait Node { fn node_type(&self) -> &str; fn location(&self) -> &Location; - fn errors(&self) -> &[ErrorNode]; + fn errors(&self) -> &[AnyError]; fn child_nodes(&self) -> Vec<&dyn Node>; - fn recursive_errors(&self) -> Vec; + fn recursive_errors(&self) -> Vec<&dyn ErrorNode>; fn tree_inspect(&self) -> String; } ``` @@ -297,21 +297,22 @@ pub trait Node { - `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 +- `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 +- `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 are included in the AST as `ErrorNode` structs: +Parse errors use a trait-based system for flexibility and type safety. All errors implement the `ErrorNode` trait: ```rust -pub struct ErrorNode { - pub error_type: String, - pub location: Location, - pub message: String, +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, allowing you to handle them as needed. +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/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index 4b295eab7..7c3f6cec2 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -7,14 +7,14 @@ use std::ffi::CStr; use std::os::raw::{c_char, c_int, c_void}; #[repr(C)] -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] struct CPosition { line: u32, column: u32, } #[repr(C)] -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] struct CLocation { start: CPosition, end: CPosition, @@ -66,6 +66,7 @@ unsafe fn convert_location(c_loc: CLocation) -> Location { } #[repr(C)] +#[derive(Debug)] struct CErrorBase { error_type: c_int, location: CLocation, @@ -134,10 +135,13 @@ unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { for i in 0..count { let error_base_ptr = hb_array_get(errors_array, i) as *const CErrorBase; - if !error_base_ptr.is_null() { - let error_base = &*error_base_ptr; + if error_base_ptr.is_null() { + continue; + } + + let error_base = &*error_base_ptr; - let error = match error_base.error_type { + let error = match error_base.error_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 %> => { @@ -146,10 +150,9 @@ unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { } <%- end -%> _ => continue, - }; + }; - errors.push(error); - } + errors.push(error); } errors From 7c1bc630d8dfbc4f6fc82f0086b901569af4cf19 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 00:02:53 +0100 Subject: [PATCH 16/21] Properly Handle ElementSourceField --- rust/src/ffi.rs | 8 ++++++++ templates/rust/src/ast/nodes.rs.erb | 22 ++++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index fdabc3995..19739fe10 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -52,6 +52,12 @@ pub enum HerbExtractLanguage { Html = 1, } +#[repr(C)] +pub struct HbString { + pub data: *mut c_char, + pub length: u32, +} + extern "C" { pub fn herb_lex(source: *const c_char) -> *mut HbArray; pub fn herb_parse(source: *const c_char, options: *mut ParserOptions) -> *mut CDocumentNode; @@ -67,4 +73,6 @@ extern "C" { pub fn ast_node_free(node: *mut CDocumentNode); pub fn herb_extract(source: *const c_char, language: HerbExtractLanguage) -> *mut c_char; + + pub fn element_source_to_string(source: std::os::raw::c_int) -> HbString; } diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index 7c3f6cec2..28a19671c 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -22,13 +22,13 @@ struct CLocation { #[repr(C)] struct CAstNode { - node_type: u32, + node_type: c_int, location: CLocation, errors: *mut HbArray, } <%- nodes.each_with_index do |node, index| -%> -const <%= node.type %>: u32 = <%= index %>; +const <%= node.type %>: c_int = <%= index %>; <%- end -%> <%- nodes.each do |node| -%> @@ -37,8 +37,10 @@ struct C<%= node.name %> { base: CAstNode, <%- node.fields.each do |field| -%> <%- case field -%> - <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + <%- when Herb::Template::StringField -%> <%= field.name %>: *const c_char, + <%- when Herb::Template::ElementSourceField -%> + <%= field.name %>: c_int, <%- when Herb::Template::TokenField -%> <%= field.name %>: *mut CToken, <%- when Herb::Template::BooleanField -%> @@ -166,6 +168,16 @@ unsafe fn get_string_field(ptr: *const c_char) -> String { } } +unsafe fn convert_element_source(source: c_int) -> 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(ptr: *mut CToken) -> Option { if ptr.is_null() { None @@ -264,8 +276,10 @@ unsafe fn convert_node(node_ptr: *const c_void) -> Option { errors: convert_errors(base_ref.errors), <%- node.fields.each do |field| -%> <%- case field -%> - <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + <%- when Herb::Template::StringField -%> <%= field.name %>: get_string_field((*c_node).<%= field.name %>), + <%- when Herb::Template::ElementSourceField -%> + <%= field.name %>: convert_element_source((*c_node).<%= field.name %>), <%- when Herb::Template::TokenField -%> <%= field.name %>: convert_token_field((*c_node).<%= field.name %>), <%- when Herb::Template::BooleanField -%> From 6265fd0308cbc94541c65652ef1efba47d12234e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 00:06:54 +0100 Subject: [PATCH 17/21] RuboCop --- .rubocop.yml | 2 ++ 1 file changed, 2 insertions(+) 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/**/* From 98db90be770a9c39e5f96da0cb5d46c3a8a69f2c Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 00:46:26 +0100 Subject: [PATCH 18/21] Use bindgen --- rust/Cargo.toml | 1 + rust/build.rs | 49 +++++++- rust/src/bindings.rs | 7 ++ rust/src/convert.rs | 2 +- rust/src/ffi.rs | 81 ++----------- rust/src/herb.rs | 12 +- rust/src/lib.rs | 1 + templates/rust/src/ast/nodes.rs.erb | 173 ++++++++-------------------- templates/template.rb | 8 ++ 9 files changed, 130 insertions(+), 204 deletions(-) create mode 100644 rust/src/bindings.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e6ae5d406..2b797c763 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,3 +25,4 @@ libc = "0.2" [build-dependencies] cc = "1.0" glob = "0.3" +bindgen = "0.70" diff --git a/rust/build.rs b/rust/build.rs index b3bbf7418..6defd4137 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -33,15 +33,62 @@ fn main() { .flag("-fPIC") .opt_level(2) .include(src_dir.join("include")) - .include(prism_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"); } 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 index f9ab604ee..67e8cad4e 100644 --- a/rust/src/convert.rs +++ b/rust/src/convert.rs @@ -35,7 +35,7 @@ pub unsafe fn token_from_c(c_token: *const CToken) -> Token { CStr::from_ptr(token.value).to_string_lossy().into_owned() }; - let token_type = CStr::from_ptr(crate::ffi::token_type_to_string(token.token_type)) + let token_type = CStr::from_ptr(crate::ffi::token_type_to_string(token.type_)) .to_string_lossy() .into_owned(); diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 19739fe10..8da9affda 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -1,78 +1,15 @@ -use std::os::raw::{c_char, c_uint, c_void}; - -#[repr(C)] -#[derive(Copy, Clone)] -pub struct CPosition { - pub line: u32, - pub column: u32, -} - -#[repr(C)] -#[derive(Copy, Clone)] -pub struct CRange { - pub from: u32, - pub to: u32, -} - -#[repr(C)] -#[derive(Copy, Clone)] -pub struct CLocation { - pub start: CPosition, - pub end: CPosition, -} - -#[repr(C)] -pub struct CToken { - pub value: *mut c_char, - pub range: CRange, - pub location: CLocation, - pub token_type: c_uint, -} - -#[repr(C)] -pub struct HbArray { - pub items: *mut *mut c_void, - pub size: usize, - pub capacity: usize, -} - -#[repr(C)] -pub struct CDocumentNode { - _private: [u8; 0], -} +pub use crate::bindings::{ + hb_array_T as HbArray, hb_string_T as HbString, herb_extract_language_T as HerbExtractLanguage, + location_T as CLocation, position_T as CPosition, range_T as CRange, token_T as CToken, + AST_DOCUMENT_NODE_T as CDocumentNode, +}; #[repr(C)] pub struct ParserOptions { _private: [u8; 0], } -#[repr(C)] -pub enum HerbExtractLanguage { - Ruby = 0, - Html = 1, -} - -#[repr(C)] -pub struct HbString { - pub data: *mut c_char, - pub length: u32, -} - -extern "C" { - pub fn herb_lex(source: *const c_char) -> *mut HbArray; - pub fn herb_parse(source: *const c_char, options: *mut ParserOptions) -> *mut CDocumentNode; - pub fn herb_version() -> *const c_char; - pub fn herb_prism_version() -> *const c_char; - pub fn herb_free_tokens(tokens: *mut *mut HbArray); - - pub fn hb_array_size(array: *const HbArray) -> usize; - pub fn hb_array_get(array: *const HbArray, index: usize) -> *mut c_void; - - pub fn token_type_to_string(token_type: c_uint) -> *const c_char; - - pub fn ast_node_free(node: *mut CDocumentNode); - - pub fn herb_extract(source: *const c_char, language: HerbExtractLanguage) -> *mut c_char; - - pub fn element_source_to_string(source: std::os::raw::c_int) -> HbString; -} +pub use crate::bindings::{ + ast_node_free, element_source_to_string, hb_array_get, hb_array_size, 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 index 91a03431c..cc5e3b33c 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -44,7 +44,7 @@ pub fn parse(source: &str) -> Result { let result = ParseResult::new(document_node, source.to_string(), Vec::new()); - crate::ffi::ast_node_free(ast); + crate::ffi::ast_node_free(ast as *mut crate::bindings::AST_NODE_T); Ok(result) } @@ -53,7 +53,10 @@ pub fn parse(source: &str) -> 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::ffi::HerbExtractLanguage::Ruby); + let result = crate::ffi::herb_extract( + c_source.as_ptr(), + crate::bindings::HERB_EXTRACT_LANGUAGE_RUBY, + ); if result.is_null() { return Ok(String::new()); @@ -71,7 +74,10 @@ pub fn extract_ruby(source: &str) -> Result { 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::ffi::HerbExtractLanguage::Html); + let result = crate::ffi::herb_extract( + c_source.as_ptr(), + crate::bindings::HERB_EXTRACT_LANGUAGE_HTML, + ); if result.is_null() { return Ok(String::new()); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1d2109dee..87329dbdc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,4 +1,5 @@ pub mod ast; +pub mod bindings; pub mod convert; pub mod errors; pub mod ffi; diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index 28a19671c..1cd175e4a 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -1,102 +1,21 @@ +use crate::bindings::*; use crate::convert::token_from_c; use crate::errors::*; -use crate::ffi::{CToken, HbArray}; use crate::nodes::*; use crate::{Location, Position}; use std::ffi::CStr; -use std::os::raw::{c_char, c_int, c_void}; +use std::os::raw::{c_char, c_void}; -#[repr(C)] -#[derive(Copy, Clone, Debug)] -struct CPosition { - line: u32, - column: u32, -} - -#[repr(C)] -#[derive(Copy, Clone, Debug)] -struct CLocation { - start: CPosition, - end: CPosition, -} - -#[repr(C)] -struct CAstNode { - node_type: c_int, - location: CLocation, - errors: *mut HbArray, -} - -<%- nodes.each_with_index do |node, index| -%> -const <%= node.type %>: c_int = <%= index %>; -<%- end -%> - -<%- nodes.each do |node| -%> -#[repr(C)] -struct C<%= node.name %> { - base: CAstNode, - <%- node.fields.each do |field| -%> - <%- case field -%> - <%- when Herb::Template::StringField -%> - <%= field.name %>: *const c_char, - <%- when Herb::Template::ElementSourceField -%> - <%= field.name %>: c_int, - <%- when Herb::Template::TokenField -%> - <%= field.name %>: *mut CToken, - <%- when Herb::Template::BooleanField -%> - <%= field.name %>: bool, - <%- when Herb::Template::ArrayField -%> - <%= field.name %>: *mut HbArray, - <%- when Herb::Template::NodeField -%> - <%= field.name %>: *mut c_void, - <%- end -%> - <%- end -%> -} - -<%- end -%> - -extern "C" { - fn hb_array_size(array: *const HbArray) -> usize; - fn hb_array_get(array: *const HbArray, index: usize) -> *mut c_void; -} - -unsafe fn convert_location(c_loc: CLocation) -> Location { +unsafe fn convert_location(c_location: location_T) -> Location { Location::new( - Position::new(c_loc.start.line, c_loc.start.column), - Position::new(c_loc.end.line, c_loc.end.column), + Position::new(c_location.start.line, c_location.start.column), + Position::new(c_location.end.line, c_location.end.column), ) } -#[repr(C)] -#[derive(Debug)] -struct CErrorBase { - error_type: c_int, - location: CLocation, - message: *const c_char, -} - -<%- errors.each_with_index do |error, index| -%> -const <%= error.type %>: c_int = <%= index %>; -<%- end -%> - <%- 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 -%> -#[repr(C)] -struct C<%= error.name %> { - base: CErrorBase, - <%- error.fields.each do |field| -%> - <%- case field -%> - <%- when Herb::Template::StringField -%> - <%= field.name %>: *const c_char, - <%- when Herb::Template::TokenField -%> - <%= field.name %>: *mut CToken, - <%- when Herb::Template::TokenTypeField -%> - <%= field.name %>: c_int, - <%- end -%> - <%- end -%> -} - -unsafe fn convert_<%= snake_name %>(error_ptr: *const C<%= error.name %>) -> <%= error.name %> { +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() @@ -115,10 +34,10 @@ unsafe fn convert_<%= snake_name %>(error_ptr: *const C<%= error.name %>) -> <%= <%- when Herb::Template::TokenField -%> convert_token_field(error_ref.<%= field.name %>), <%- when Herb::Template::TokenTypeField -%> - if error_ref.<%= field.name %> == -1 { + if error_ref.<%= field.name %> == u32::MAX { None } else { - Some(CStr::from_ptr(crate::ffi::token_type_to_string(error_ref.<%= field.name %> as u32)).to_string_lossy().into_owned()) + Some(CStr::from_ptr(crate::ffi::token_type_to_string(error_ref.<%= field.name %>)).to_string_lossy().into_owned()) }, <%- end -%> <%- end -%> @@ -127,7 +46,7 @@ unsafe fn convert_<%= snake_name %>(error_ptr: *const C<%= error.name %>) -> <%= <%- end -%> -unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { +unsafe fn convert_errors(errors_array: *mut hb_array_T) -> Vec { if errors_array.is_null() { return Vec::new(); } @@ -135,19 +54,19 @@ unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { let count = hb_array_size(errors_array); let mut errors = Vec::with_capacity(count); - for i in 0..count { - let error_base_ptr = hb_array_get(errors_array, i) as *const CErrorBase; + 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.error_type { + 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 C<%= error.name %>; + let error_ptr = error_base_ptr as *const <%= error.c_type %>; AnyError::<%= error.name %>(convert_<%= snake_name %>(error_ptr)) } <%- end -%> @@ -160,15 +79,15 @@ unsafe fn convert_errors(errors_array: *mut HbArray) -> Vec { errors } -unsafe fn get_string_field(ptr: *const c_char) -> String { - if ptr.is_null() { +unsafe fn get_string_field(string_ptr: *const c_char) -> String { + if string_ptr.is_null() { String::new() } else { - CStr::from_ptr(ptr).to_string_lossy().into_owned() + CStr::from_ptr(string_ptr).to_string_lossy().into_owned() } } -unsafe fn convert_element_source(source: c_int) -> String { +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() @@ -178,15 +97,15 @@ unsafe fn convert_element_source(source: c_int) -> String { } } -unsafe fn convert_token_field(ptr: *mut CToken) -> Option { - if ptr.is_null() { +unsafe fn convert_token_field(token_ptr: *mut token_T) -> Option { + if token_ptr.is_null() { None } else { - Some(token_from_c(ptr)) + Some(token_from_c(token_ptr)) } } -unsafe fn convert_children(children_array: *mut HbArray) -> Vec { +unsafe fn convert_children(children_array: *mut hb_array_T) -> Vec { if children_array.is_null() { return Vec::new(); } @@ -194,8 +113,8 @@ unsafe fn convert_children(children_array: *mut HbArray) -> Vec { let count = hb_array_size(children_array); let mut children = Vec::with_capacity(count); - for i in 0..count { - let child_ptr = hb_array_get(children_array, i); + 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); @@ -206,28 +125,28 @@ unsafe fn convert_children(children_array: *mut HbArray) -> Vec { children } -unsafe fn convert_node_field(ptr: *mut c_void) -> Option> { - if ptr.is_null() { +unsafe fn convert_node_field(node_ptr: *mut c_void) -> Option> { + if node_ptr.is_null() { None } else { - convert_node(ptr).map(Box::new) + convert_node(node_ptr).map(Box::new) } } macro_rules! convert_specific_node_field { - ($ptr:expr, $expected_type:expr, $convert_fn:ident, $node_type:ty) => { - if $ptr.is_null() { + ($node_ptr:expr, $expected_type:expr, $convert_fn:ident, $node_type:ty) => { + if $node_ptr.is_null() { None } else { - let base = $ptr as *const CAstNode; - let base_ref = &*base; - let node_type = base_ref.node_type; + 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($ptr).map(Box::new) + $convert_fn($node_ptr as *const c_void).map(Box::new) } } }; @@ -238,9 +157,9 @@ unsafe fn convert_node(node_ptr: *const c_void) -> Option { return None; } - let base = node_ptr as *const CAstNode; - let base_ref = &*base; - let node_type = base_ref.node_type; + 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| -%> @@ -267,31 +186,31 @@ unsafe fn convert_node(node_ptr: *const c_void) -> Option { return None; } - let c_node = node_ptr as *const C<%= node.name %>; - let base_ref = &(*c_node).base; + 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(base_ref.location), - errors: convert_errors(base_ref.errors), + 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).<%= field.name %>), + <%= field.name %>: get_string_field((*c_node_ptr).<%= field.name %>), <%- when Herb::Template::ElementSourceField -%> - <%= field.name %>: convert_element_source((*c_node).<%= field.name %>), + <%= field.name %>: convert_element_source((*c_node_ptr).<%= field.name %>), <%- when Herb::Template::TokenField -%> - <%= field.name %>: convert_token_field((*c_node).<%= field.name %>), + <%= field.name %>: convert_token_field((*c_node_ptr).<%= field.name %>), <%- when Herb::Template::BooleanField -%> - <%= field.name %>: (*c_node).<%= field.name %>, + <%= field.name %>: (*c_node_ptr).<%= field.name %>, <%- when Herb::Template::ArrayField -%> - <%= field.name %>: convert_children((*c_node).<%= field.name %>), + <%= 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).<%= field.name %>, <%= specific_node.type %>, convert_<%= specific_node.human %>, <%= 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).<%= field.name %>), + <%= field.name %>: convert_node_field((*c_node_ptr).<%= field.name %> as *mut c_void), <%- end -%> <%- end -%> <%- end -%> diff --git a/templates/template.rb b/templates/template.rb index 86f70410c..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 From 5e0fd95af810d6b944c5fc6aaa6c2a7a5d77959c Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 00:52:19 +0100 Subject: [PATCH 19/21] Cleanup --- rust/src/convert.rs | 24 ++++++++++++------------ rust/src/ffi.rs | 8 +------- rust/src/herb.rs | 12 ++++++------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/rust/src/convert.rs b/rust/src/convert.rs index 67e8cad4e..3fba6a6b9 100644 --- a/rust/src/convert.rs +++ b/rust/src/convert.rs @@ -1,22 +1,22 @@ -use crate::ffi::{CLocation, CPosition, CRange, CToken}; +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_pos: CPosition) -> Self { - Position::new(c_pos.line, c_pos.column) +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: CRange) -> Self { +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_loc: CLocation) -> Self { - Location::new(c_loc.start.into(), c_loc.end.into()) +impl From for Location { + fn from(c_location: location_T) -> Self { + Location::new(c_location.start.into(), c_location.end.into()) } } @@ -24,10 +24,10 @@ impl From for Location { /// /// # Safety /// -/// The caller must ensure that `c_token` is a valid, non-null pointer to a `CToken` +/// 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(c_token: *const CToken) -> Token { - let token = &*c_token; +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() diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 8da9affda..98bb12fd4 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -1,15 +1,9 @@ -pub use crate::bindings::{ - hb_array_T as HbArray, hb_string_T as HbString, herb_extract_language_T as HerbExtractLanguage, - location_T as CLocation, position_T as CPosition, range_T as CRange, token_T as CToken, - AST_DOCUMENT_NODE_T as CDocumentNode, -}; - #[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, herb_extract, + 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 index cc5e3b33c..ecc1e9075 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -1,5 +1,5 @@ +use crate::bindings::{hb_array_T, token_T}; use crate::convert::token_from_c; -use crate::ffi::{CToken, HbArray}; use crate::{LexResult, ParseResult}; use std::ffi::CString; @@ -15,16 +15,16 @@ pub fn lex(source: &str) -> LexResult { let array_size = crate::ffi::hb_array_size(c_tokens); let mut tokens = Vec::with_capacity(array_size); - for i in 0..array_size { - let c_token = crate::ffi::hb_array_get(c_tokens, i) as *const CToken; + for index in 0..array_size { + let token_ptr = crate::ffi::hb_array_get(c_tokens, index) as *const token_T; - if !c_token.is_null() { - tokens.push(token_from_c(c_token)); + 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 HbArray); + crate::ffi::herb_free_tokens(&mut c_tokens_ptr as *mut *mut hb_array_T); LexResult::new(tokens) } From 306c97a62b987335357df8b8017f97e6bc93e05e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 01:15:49 +0100 Subject: [PATCH 20/21] Add snapshot tests --- rust/Cargo.toml | 3 + rust/tests/fixtures/test.html.erb | 11 ++ rust/tests/snapshot_test.rs | 59 +++++++ .../snapshot_test__extract_html_output.snap | 14 ++ .../snapshot_test__extract_ruby_output.snap | 15 ++ .../snapshot_test__herb_version_output.snap | 5 + .../snapshots/snapshot_test__lex_output.snap | 69 ++++++++ .../snapshot_test__parse_output.snap | 156 ++++++++++++++++++ .../snapshot_test__prism_version_output.snap | 5 + .../snapshot_test__version_output.snap | 5 + 10 files changed, 342 insertions(+) create mode 100644 rust/tests/fixtures/test.html.erb create mode 100644 rust/tests/snapshot_test.rs create mode 100644 rust/tests/snapshots/snapshot_test__extract_html_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__extract_ruby_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__herb_version_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__lex_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__parse_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__prism_version_output.snap create mode 100644 rust/tests/snapshots/snapshot_test__version_output.snap diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2b797c763..f20b08d80 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,6 +22,9 @@ path = "src/main.rs" [dependencies] libc = "0.2" +[dev-dependencies] +insta = "1.40" + [build-dependencies] cc = "1.0" glob = "0.3" 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/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: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: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) From 252ae91aaeedd5b00662fe41da4d70f97886e7d3 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Nov 2025 01:25:40 +0100 Subject: [PATCH 21/21] Add `.cargo/config.toml` --- rust/.cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 rust/.cargo/config.toml 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"]