From fbb06027b55bf1c57476131f4f5bb2f7b44fc70c Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 28 Feb 2026 15:05:10 +0100 Subject: [PATCH] Rust: Implement Visitors in Rust Bindings --- .gitignore | 1 + rust/.cargo/config.toml | 2 - rust/Cargo.toml | 7 +- rust/Makefile | 35 ++++--- rust/build.rs | 149 ++++++++++++++++-------------- rust/rustfmt.toml | 3 +- rust/src/convert.rs | 4 +- rust/src/ffi.rs | 5 +- rust/src/herb.rs | 19 +--- rust/src/lex_result.rs | 7 +- rust/src/lib.rs | 8 +- rust/src/location.rs | 7 ++ rust/src/parse_result.rs | 7 +- rust/src/token.rs | 6 +- rust/tests/cli_commands_test.rs | 15 +-- rust/tests/error_handling_test.rs | 8 +- rust/tests/parse_result_test.rs | 72 +++------------ rust/tests/parser_options_test.rs | 27 +----- rust/tests/snapshot_test.rs | 7 +- templates/rust/src/nodes.rs.erb | 27 ++++++ templates/rust/src/visitor.rs.erb | 81 ++++++++++++++++ 21 files changed, 266 insertions(+), 231 deletions(-) create mode 100644 templates/rust/src/visitor.rs.erb diff --git a/.gitignore b/.gitignore index 77aa2ff92..23736e2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ rust/src/ast/nodes.rs rust/src/errors.rs rust/src/nodes.rs rust/src/union_types.rs +rust/src/visitor.rs sig/serialized_ast_errors.rbs sig/serialized_ast_nodes.rbs src/analyze_missing_end.c diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml index 5546e2060..e69de29bb 100644 --- a/rust/.cargo/config.toml +++ b/rust/.cargo/config.toml @@ -1,2 +0,0 @@ -[build] -rerun-if-changed = ["../src"] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 48c3a34db..e16d8088a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,3 +1,8 @@ +[workspace] +members = [ + "." +] + [package] name = "herb" version = "0.8.10" @@ -21,7 +26,7 @@ include = [ [lib] name = "herb" path = "src/lib.rs" -crate-type = ["cdylib", "rlib"] +crate-type = ["rlib"] [[bin]] name = "herb-rust" diff --git a/rust/Makefile b/rust/Makefile index e462a8613..3fa0eb0f6 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -5,7 +5,7 @@ BIN_NAME = herb-rust BIN_PATH = $(BUILD_DIR)/debug/$(BIN_NAME) RELEASE_BIN_PATH = $(BUILD_DIR)/release/$(BIN_NAME) -.PHONY: all build release clean test cli help templates format vendor +.PHONY: all build release clean test cli help templates format vendor wasm all: templates build @@ -13,12 +13,12 @@ templates: cd .. && bundle exec rake templates build: templates - cargo +stable build --verbose --bin $(BIN_NAME) - @echo "Built debug binary: $(BIN_PATH)" + cargo +stable build --verbose --workspace + @echo "Built workspace (debug)" release: templates - cargo build --release --bin $(BIN_NAME) - @echo "Built release binary: $(RELEASE_BIN_PATH)" + cargo build --release --workspace + @echo "Built workspace (release)" cli: build @echo "CLI ready! Use: ./bin/$(BIN_NAME) [command] [file]" @@ -26,24 +26,34 @@ cli: build @./bin/$(BIN_NAME) || true test: build - NO_COLOR=1 cargo +stable test --verbose + NO_COLOR=1 cargo +stable test --verbose --workspace format: - cargo +nightly fmt + cargo +nightly fmt --all @echo "Formatted Rust code" format-check: - cargo +nightly fmt --check + cargo +nightly fmt --all --check @echo "Checked Rust code formatting" lint: - cargo +stable clippy --all-targets --all-features + cargo +stable clippy --workspace --all-targets --all-features @echo "Linted Rust code" clean: cargo clean @echo "Cleaned Rust build artifacts" +wasm: templates + @echo "Building herb-linter for wasm32-unknown-emscripten..." + @# Temporarily remove cdylib from herb crate-type to avoid emscripten linker errors + sed 's/crate-type = \["cdylib", "rlib"\]/crate-type = ["rlib"]/' Cargo.toml > Cargo.toml.wasm + mv Cargo.toml Cargo.toml.bak && mv Cargo.toml.wasm Cargo.toml + cargo rustc --release --target wasm32-unknown-emscripten -p herb-linter --lib --crate-type staticlib || \ + (mv Cargo.toml.bak Cargo.toml && exit 1) + mv Cargo.toml.bak Cargo.toml + @echo "Built target/wasm32-unknown-emscripten/release/libherb_linter.a" + vendor: @echo "Vendoring C sources into vendor/libherb..." rm -rf vendor/ @@ -62,11 +72,12 @@ help: @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 " build - Build workspace (debug)" + @echo " release - Build workspace (release)" @echo " cli - Show CLI usage" - @echo " test - Run Rust tests" + @echo " test - Run all workspace tests" @echo " format - Format Rust code with rustfmt" + @echo " wasm - Build herb-linter staticlib for wasm32-unknown-emscripten" @echo " vendor - Vendor C sources into vendor/" @echo " clean - Remove build artifacts" @echo " help - Show this help" diff --git a/rust/build.rs b/rust/build.rs index 0c4cd0cb0..ca1515ed5 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -3,6 +3,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; fn main() { + let target = env::var("TARGET").unwrap_or_default(); + let is_wasm = target.contains("wasm32") || target.contains("emscripten"); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let vendor_src_dir = manifest_dir.join("vendor/libherb/src"); let vendor_include_dir = manifest_dir.join("vendor/libherb/src/include"); @@ -11,71 +14,78 @@ fn main() { (vendor_src_dir, vendor_include_dir, manifest_dir.clone()) } else { let root = manifest_dir.parent().unwrap(); - ( - root.join("src"), - root.join("src/include"), - root.to_path_buf(), - ) + (root.join("src"), root.join("src/include"), root.to_path_buf()) }; - let vendor_prism_src = manifest_dir.join("vendor/prism/src"); - let vendor_prism_include = manifest_dir.join("vendor/prism/include"); + let prism_include = if is_wasm { + let vendor_prism_include = manifest_dir.join("vendor/prism/include"); - let prism_include = if vendor_prism_src.exists() { - let mut prism_sources = Vec::new(); - for path in glob::glob(vendor_prism_src.join("**/*.c").to_str().unwrap()) - .unwrap() - .flatten() - { - prism_sources.push(path); + if vendor_prism_include.exists() { + vendor_prism_include + } else { + let prism_path = get_prism_path(&root_dir); + prism_path.join("include") + } + } else { + let vendor_prism_src = manifest_dir.join("vendor/prism/src"); + let vendor_prism_include = manifest_dir.join("vendor/prism/include"); + + let prism_include = if vendor_prism_src.exists() { + let mut prism_sources = Vec::new(); + for path in glob::glob(vendor_prism_src.join("**/*.c").to_str().unwrap()).unwrap().flatten() { + prism_sources.push(path); + } + + let mut prism_build = cc::Build::new(); + prism_build + .flag("-std=c99") + .flag("-fPIC") + .opt_level(2) + .include(&vendor_prism_include) + .files(&prism_sources) + .warnings(false); + + prism_build.compile("prism"); + + vendor_prism_include + } else { + let prism_path = get_prism_path(&root_dir); + let prism_build = prism_path.join("build"); + println!("cargo:rustc-link-search=native={}", prism_build.display()); + println!("cargo:rustc-link-lib=static=prism"); + prism_path.join("include") + }; + + 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 prism_build = cc::Build::new(); - prism_build + let mut build = cc::Build::new(); + build .flag("-std=c99") + .flag("-Wall") + .flag("-Wextra") + .flag("-Wno-unused-parameter") .flag("-fPIC") .opt_level(2) - .include(&vendor_prism_include) - .files(&prism_sources) - .warnings(false); - - prism_build.compile("prism"); + .include(&include_dir) + .include(&prism_include) + .files(&c_sources); - vendor_prism_include - } else { - let prism_path = get_prism_path(&root_dir); - let prism_build = prism_path.join("build"); - println!("cargo:rustc-link-search=native={}", prism_build.display()); - println!("cargo:rustc-link-lib=static=prism"); - prism_path.join("include") - }; + build.compile("herb"); - 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); + for source in &c_sources { + println!("cargo:rerun-if-changed={}", source.display()); } - } - let mut build = cc::Build::new(); - build - .flag("-std=c99") - .flag("-Wall") - .flag("-Wextra") - .flag("-Wno-unused-parameter") - .flag("-fPIC") - .opt_level(2) - .include(&include_dir) - .include(&prism_include) - .files(&c_sources); - - build.compile("herb"); - - let bindings = bindgen::Builder::default() + prism_include + }; + + let mut builder = bindgen::Builder::default() .header(include_dir.join("analyze.h").to_str().unwrap()) .header(include_dir.join("herb.h").to_str().unwrap()) .header(include_dir.join("ast_nodes.h").to_str().unwrap()) @@ -116,19 +126,23 @@ fn main() { .derive_debug(true) .derive_default(false) .prepend_enum_name(false) - .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) - .generate() - .expect("Unable to generate bindings"); + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())); - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - bindings - .write_to_file(out_path.join("bindings.rs")) - .expect("Couldn't write bindings!"); + if is_wasm { + let host = env::var("HOST").unwrap_or_default(); + + if !host.is_empty() { + builder = builder.clang_arg(format!("--target={}", host)); + } - for source in &c_sources { - println!("cargo:rerun-if-changed={}", source.display()); + builder = builder.layout_tests(false); } + let bindings = builder.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!"); + println!("cargo:rerun-if-changed={}", include_dir.display()); println!("cargo:rerun-if-changed=build.rs"); } @@ -140,14 +154,9 @@ fn get_prism_path(root_dir: &Path) -> PathBuf { .output() .expect("Failed to run `bundle show prism`"); - let output_str = String::from_utf8(output.stdout).expect("Failed to parse bundle output"); + let output_string = 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(); + let path_string = output_string.lines().last().expect("No output from bundle show prism").trim().to_string(); - PathBuf::from(path_str) + PathBuf::from(path_string) } diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index 051b8bda2..2c87c7e37 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -1,5 +1,5 @@ edition = "2021" -max_width = 100 +max_width = 160 hard_tabs = false tab_spaces = 2 newline_style = "Unix" @@ -15,4 +15,5 @@ ignore = [ "src/errors.rs", "src/nodes.rs", "src/union_types.rs", + "src/visitor.rs", ] diff --git a/rust/src/convert.rs b/rust/src/convert.rs index 3fba6a6b9..f551313fc 100644 --- a/rust/src/convert.rs +++ b/rust/src/convert.rs @@ -35,9 +35,7 @@ pub unsafe fn token_from_c(token_ptr: *const token_T) -> Token { CStr::from_ptr(token.value).to_string_lossy().into_owned() }; - let token_type = CStr::from_ptr(crate::ffi::token_type_to_string(token.type_)) - .to_string_lossy() - .into_owned(); + let token_type = CStr::from_ptr(crate::ffi::token_type_to_string(token.type_)).to_string_lossy().into_owned(); Token { token_type, diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 191f7a9ba..bb9247590 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -1,5 +1,4 @@ pub use crate::bindings::{ - ast_node_free, element_source_to_string, hb_array_get, hb_array_size, hb_buffer_init, - hb_buffer_value, hb_string_T, herb_extract, herb_extract_ruby_to_buffer_with_options, - herb_free_tokens, herb_lex, herb_parse, herb_prism_version, herb_version, token_type_to_string, + ast_node_free, element_source_to_string, hb_array_get, hb_array_size, hb_buffer_init, hb_buffer_value, hb_string_T, herb_extract, + herb_extract_ruby_to_buffer_with_options, 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 2b7e6cff2..0decef607 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -84,8 +84,7 @@ pub fn parse_with_options(source: &str, options: &ParserOptions) -> Result Result { extract_ruby_with_options(source, &ExtractRubyOptions::default()) } -pub fn extract_ruby_with_options( - source: &str, - options: &ExtractRubyOptions, -) -> Result { +pub fn extract_ruby_with_options(source: &str, options: &ExtractRubyOptions) -> Result { unsafe { let c_source = CString::new(source).map_err(|e| e.to_string())?; @@ -119,11 +115,7 @@ pub fn extract_ruby_with_options( preserve_positions: options.preserve_positions, }; - crate::ffi::herb_extract_ruby_to_buffer_with_options( - c_source.as_ptr(), - &mut output, - &c_options, - ); + crate::ffi::herb_extract_ruby_to_buffer_with_options(c_source.as_ptr(), &mut output, &c_options); let c_str = std::ffi::CStr::from_ptr(crate::ffi::hb_buffer_value(&output)); let rust_str = c_str.to_string_lossy().into_owned(); @@ -137,10 +129,7 @@ pub fn extract_ruby_with_options( pub fn extract_html(source: &str) -> Result { unsafe { let c_source = CString::new(source).map_err(|e| e.to_string())?; - let result = crate::ffi::herb_extract( - c_source.as_ptr(), - crate::bindings::HERB_EXTRACT_LANGUAGE_HTML, - ); + 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/lex_result.rs b/rust/src/lex_result.rs index 1e9977448..645c35f56 100644 --- a/rust/src/lex_result.rs +++ b/rust/src/lex_result.rs @@ -15,12 +15,7 @@ impl LexResult { } pub fn inspect(&self) -> String { - self - .tokens - .iter() - .map(|token| token.inspect()) - .collect::>() - .join("\n") + self.tokens.iter().map(|token| token.inspect()).collect::>().join("\n") } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1f5e2e225..ea12df905 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,18 +12,20 @@ pub mod position; pub mod range; pub mod token; pub mod union_types; +pub mod visitor; pub use errors::{AnyError, ErrorNode, ErrorType}; pub use herb::{ - extract_html, extract_ruby, extract_ruby_with_options, herb_version, lex, parse, - parse_with_options, prism_version, version, ExtractRubyOptions, ParserOptions, + extract_html, extract_ruby, extract_ruby_with_options, herb_version, lex, parse, parse_with_options, prism_version, version, ExtractRubyOptions, + ParserOptions, }; pub use lex_result::LexResult; pub use location::Location; -pub use nodes::{AnyNode, Node}; +pub use nodes::{AnyNode, ERBNode, Node}; pub use parse_result::ParseResult; pub use position::Position; pub use range::Range; pub use token::Token; +pub use visitor::Visitor; pub const VERSION: &str = "0.8.10"; diff --git a/rust/src/location.rs b/rust/src/location.rs index 2e66cc0bd..1dbe29286 100644 --- a/rust/src/location.rs +++ b/rust/src/location.rs @@ -12,6 +12,13 @@ impl Location { Self { start, end } } + pub fn from(start_line: u32, start_column: u32, end_line: u32, end_column: u32) -> Self { + Self { + start: Position::new(start_line, start_column), + end: Position::new(end_line, end_column), + } + } + pub fn inspect(&self) -> String { format!("{}-{}", self.start, self.end) } diff --git a/rust/src/parse_result.rs b/rust/src/parse_result.rs index d2a4b52b3..4b6e3f08b 100644 --- a/rust/src/parse_result.rs +++ b/rust/src/parse_result.rs @@ -11,12 +11,7 @@ pub struct ParseResult { } impl ParseResult { - pub fn new( - value: DocumentNode, - source: String, - errors: Vec, - options: &ParserOptions, - ) -> Self { + pub fn new(value: DocumentNode, source: String, errors: Vec, options: &ParserOptions) -> Self { Self { value, source, diff --git a/rust/src/token.rs b/rust/src/token.rs index 60bc2c9e3..53ae44eea 100644 --- a/rust/src/token.rs +++ b/rust/src/token.rs @@ -59,10 +59,6 @@ impl Token { 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 - ) + 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 index bc22dad0d..bdff577f9 100644 --- a/rust/tests/cli_commands_test.rs +++ b/rust/tests/cli_commands_test.rs @@ -2,10 +2,7 @@ use herb::{extract_html, extract_ruby, version}; #[test] fn test_version_functions() { - assert_eq!( - version(), - "herb rust v0.8.10, libprism v1.9.0, libherb v0.8.10 (Rust FFI)" - ); + assert_eq!(version(), "herb rust v0.8.10, libprism v1.9.0, libherb v0.8.10 (Rust FFI)"); } #[test] @@ -30,10 +27,7 @@ fn test_extract_ruby_complex() { <% end %> "#; let ruby = extract_ruby(source).unwrap(); - assert_eq!( - ruby, - " \n users.each do |user| ;\n user.name ; \n end ;\n " - ); + assert_eq!(ruby, " \n users.each do |user| ;\n user.name ; \n end ;\n "); } #[test] @@ -44,8 +38,5 @@ fn test_extract_html_complex() { <% end %> "#; let html = extract_html(source).unwrap(); - assert_eq!( - html, - "
\n \n

\n \n
" - ); + assert_eq!(html, "
\n \n

\n \n
"); } diff --git a/rust/tests/error_handling_test.rs b/rust/tests/error_handling_test.rs index 777f9c4c6..ee6fbe7c6 100644 --- a/rust/tests/error_handling_test.rs +++ b/rust/tests/error_handling_test.rs @@ -26,14 +26,10 @@ fn test_tag_names_mismatch_error() { print!("{}", tree_inspect); assert!(tree_inspect.contains("MissingClosingTagError (location: (1:0)-(1:5))")); - assert!(tree_inspect.contains( - "Opening tag `
` at (1:1) doesn't have a matching closing tag `
` in the same scope." - )); + assert!(tree_inspect.contains("Opening tag `
` at (1:1) doesn't have a matching closing tag `
` in the same scope.")); assert!(tree_inspect.contains("MissingOpeningTagError (location: (1:5)-(1:12))")); - assert!(tree_inspect.contains( - "Found closing tag `` at (1:7) without a matching opening tag in the same scope." - )); + assert!(tree_inspect.contains("Found closing tag `` at (1:7) without a matching opening tag in the same scope.")); } #[test] diff --git a/rust/tests/parse_result_test.rs b/rust/tests/parse_result_test.rs index f87fd2869..c6178a74c 100644 --- a/rust/tests/parse_result_test.rs +++ b/rust/tests/parse_result_test.rs @@ -9,20 +9,9 @@ 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" - ); + 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] @@ -32,26 +21,13 @@ 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" - ); + 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() == "MISSING_CLOSING_TAG_ERROR")); + assert!(errors.iter().any(|e| e.error_type() == "MISSING_CLOSING_TAG_ERROR")); } #[test] @@ -61,27 +37,14 @@ 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" - ); + 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() == "MISSING_OPENING_TAG_ERROR")); - - assert!(errors - .iter() - .any(|e| e.error_type() == "MISSING_CLOSING_TAG_ERROR")); + assert!(errors.iter().any(|e| e.error_type() == "MISSING_OPENING_TAG_ERROR")); + assert!(errors.iter().any(|e| e.error_type() == "MISSING_CLOSING_TAG_ERROR")); } #[test] @@ -90,15 +53,8 @@ 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" - ); + + assert!(!errors.is_empty(), "Expected recursive errors to be collected"); + assert!(result.failed(), "Expected failed() to be true when there are recursive errors"); } diff --git a/rust/tests/parser_options_test.rs b/rust/tests/parser_options_test.rs index 50a85b334..ff592defe 100644 --- a/rust/tests/parser_options_test.rs +++ b/rust/tests/parser_options_test.rs @@ -28,10 +28,7 @@ fn test_track_whitespace_enabled_tracks_whitespace() { let result = parse_with_options(source, &options).unwrap(); let output = result.inspect(); - assert!( - output.contains("WhitespaceNode"), - "Expected WhitespaceNode with track_whitespace enabled" - ); + assert!(output.contains("WhitespaceNode"), "Expected WhitespaceNode with track_whitespace enabled"); } #[test] @@ -42,15 +39,8 @@ fn test_analyze_enabled_by_default_transforms_erb_nodes() { let result = parse(source).unwrap(); let output = result.inspect(); - assert!( - output.contains("ERBIfNode"), - "Expected ERBIfNode with analyze enabled (default)" - ); - - assert!( - !output.contains("ERBContentNode"), - "Expected no ERBContentNode with analyze enabled (default)" - ); + assert!(output.contains("ERBIfNode"), "Expected ERBIfNode with analyze enabled (default)"); + assert!(!output.contains("ERBContentNode"), "Expected no ERBContentNode with analyze enabled (default)"); } #[test] @@ -65,13 +55,6 @@ fn test_analyze_disabled_skips_erb_node_transformation() { let result = parse_with_options(source, &options).unwrap(); let output = result.inspect(); - assert!( - output.contains("ERBContentNode"), - "Expected ERBContentNode with analyze disabled" - ); - - assert!( - !output.contains("ERBIfNode"), - "Expected no ERBIfNode with analyze disabled" - ); + assert!(output.contains("ERBContentNode"), "Expected ERBContentNode with analyze disabled"); + assert!(!output.contains("ERBIfNode"), "Expected no ERBIfNode with analyze disabled"); } diff --git a/rust/tests/snapshot_test.rs b/rust/tests/snapshot_test.rs index 5bcf311d2..4dc905406 100644 --- a/rust/tests/snapshot_test.rs +++ b/rust/tests/snapshot_test.rs @@ -33,12 +33,7 @@ fn test_lex_output() { common::no_color(); let result = lex(TEST_INPUT).expect("Failed to lex"); - let output = result - .tokens() - .iter() - .map(|token| token.inspect()) - .collect::>() - .join("\n"); + let output = result.tokens().iter().map(|token| token.inspect()).collect::>().join("\n"); insta::assert_snapshot!(output); } diff --git a/templates/rust/src/nodes.rs.erb b/templates/rust/src/nodes.rs.erb index 271e07959..994aa2ee3 100644 --- a/templates/rust/src/nodes.rs.erb +++ b/templates/rust/src/nodes.rs.erb @@ -179,6 +179,13 @@ pub trait Node { fn tree_inspect(&self) -> String; } +pub trait ERBNode { + fn tag_opening(&self) -> &Option; + fn content(&self) -> &Option; + fn tag_closing(&self) -> &Option; + fn location(&self) -> &Location; +} + #[derive(Debug, Clone)] pub enum AnyNode { <%- nodes.each do |node| -%> @@ -416,4 +423,24 @@ impl Node for <%= node.name %> { } } +<%- if node.name.start_with?("ERB") -%> +impl ERBNode for <%= node.name %> { + fn tag_opening(&self) -> &Option { + &self.tag_opening + } + + fn content(&self) -> &Option { + &self.content + } + + fn tag_closing(&self) -> &Option { + &self.tag_closing + } + + fn location(&self) -> &Location { + &self.location + } +} + +<%- end -%> <%- end -%> diff --git a/templates/rust/src/visitor.rs.erb b/templates/rust/src/visitor.rs.erb new file mode 100644 index 000000000..af4187940 --- /dev/null +++ b/templates/rust/src/visitor.rs.erb @@ -0,0 +1,81 @@ +use crate::nodes::*; +use crate::union_types::*; + +/// Visitor trait for traversing the Herb AST. +/// +/// Provides a visit/walk separation pattern: +/// - `visit_*` methods are the entry points for each node type (override these in rules) +/// - `walk_*` methods traverse child nodes (call these explicitly from `visit_*` overrides) +/// +/// Default `visit_*` implementations call the corresponding `walk_*` method. +/// Override `visit_*` to add custom behavior, then call `walk_*` to continue traversal. +pub trait Visitor { + fn visit(&mut self, node: &AnyNode) { + match node { + <%- nodes.each do |node| -%> + AnyNode::<%= node.name %>(n) => self.visit_<%= node.human %>(n), + <%- end -%> + } + } + + fn visit_all(&mut self, nodes: &[AnyNode]) { + for node in nodes { + self.visit(node); + } + } + + fn visit_erb_node(&mut self, _node: &dyn ERBNode) { + // Default implementation does nothing + } + + <%- nodes.each do |node| -%> + fn visit_<%= node.human %>(&mut self, node: &<%= node.name %>) { + <%- if node.name.start_with?("ERB") -%> + self.visit_erb_node(node); + <%- end -%> + self.walk_<%= node.human %>(node); + } + + fn walk_<%= node.human %>(&mut self, node: &<%= node.name %>) { + <%- children_fields = node.fields.select { |field| + case field + when Herb::Template::ArrayField + true + when Herb::Template::NodeField, Herb::Template::BorrowedNodeField + true + else + false + end + } -%> + <%- if children_fields.any? -%> + <%- children_fields.each do |field| -%> + <%- case field -%> + <%- when Herb::Template::ArrayField -%> + self.visit_all(&node.<%= field.name %>); + <%- when Herb::Template::NodeField, Herb::Template::BorrowedNodeField -%> + <%- if field.specific_kind && field.specific_kind != "Node" -%> + if let Some(ref child) = node.<%= field.name %> { + self.visit_<%= field.specific_kind.gsub(/(?<=[a-zA-Z])(?=[A-Z][a-z])/, "_").downcase %>(child); + } + <%- elsif field.union_kind -%> + if let Some(ref child) = node.<%= field.name %> { + match child { + <%- field.union_kind.each do |kind| -%> + <%= field.union_type_name %>::<%= kind %>(n) => self.visit_<%= kind.gsub(/(?<=[a-zA-Z])(?=[A-Z][a-z])/, "_").downcase %>(n), + <%- end -%> + } + } + <%- else -%> + if let Some(ref child) = node.<%= field.name %> { + self.visit(child); + } + <%- end -%> + <%- end -%> + <%- end -%> + <%- else -%> + let _ = node; + <%- end -%> + } + + <%- end -%> +}