From eddebe66de14ef44e75470ce810e74e6e6c92bd4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 18 Nov 2025 07:05:16 -0800 Subject: [PATCH 1/4] Parser: Re-parse on `unexpected ";"` error --- src/analyze.c | 42 ++++ src/ast_node.c | 29 +++ src/extract.c | 66 +++--- src/include/ast_node.h | 2 + src/include/position.h | 5 + src/include/prism_helpers.h | 6 +- src/position.c | 27 +++ src/prism_helpers.c | 29 ++- test/analyze/if_test.rb | 14 ++ test/c/test_extract.c | 38 ++-- test/extractor/extract_ruby_test.rb | 12 +- test/parser/erb_test.rb | 71 +++++++ ...error_ce5658e89dd99cb6740835d0d0666353.txt | 2 +- ...ional_ee03fbe2e88a2b56ca5c044200fe19bf.txt | 33 +++ ...yntax_1c3b5ad1abe4440ca61ee62d2db330f2.txt | 33 +++ ...b_tag_d7de40920545c14e5afcda3a2e9f0eb9.txt | 20 +- ...ition_ee03fbe2e88a2b56ca5c044200fe19bf.txt | 16 +- ..._tags_5c81bac82089f414dd42d8becdaa4ef3.txt | 27 +++ ...n_div_6b3ce8ff3d1ac8f4a5cf2a377ddcbae4.txt | 45 ++++ ...n_div_566fdf7ce4fea5ab67f856680ed3dbba.txt | 56 +++++ ...n_div_bc4767160a429bf77c068d0263c18645.txt | 122 +++++++++++ ..._tags_8cafde6814ee57b2ee27e3392f23c2ae.txt | 56 +++++ ..._keys_5aa8c6b298cf6e7261db5fe1341a4eb1.txt | 194 ++++++++++++++++++ 23 files changed, 855 insertions(+), 90 deletions(-) create mode 100644 src/position.c create mode 100644 test/snapshots/analyze/if_test/test_0016_if_with_missing_conditional_ee03fbe2e88a2b56ca5c044200fe19bf.txt create mode 100644 test/snapshots/analyze/if_test/test_0017_if_with_invalid_syntax_1c3b5ad1abe4440ca61ee62d2db330f2.txt create mode 100644 test/snapshots/parser/erb_test/test_0052_hash_with_trailing_key_across_tags_5c81bac82089f414dd42d8becdaa4ef3.txt create mode 100644 test/snapshots/parser/erb_test/test_0053_single_hash_with_trailing_key_in_div_6b3ce8ff3d1ac8f4a5cf2a377ddcbae4.txt create mode 100644 test/snapshots/parser/erb_test/test_0054_multiple_hash_with_trailing_key_in_div_566fdf7ce4fea5ab67f856680ed3dbba.txt create mode 100644 test/snapshots/parser/erb_test/test_0055_many_hash_with_trailing_key_in_div_bc4767160a429bf77c068d0263c18645.txt create mode 100644 test/snapshots/parser/erb_test/test_0056_hash_shorthand_in_parentheses_across_tags_8cafde6814ee57b2ee27e3392f23c2ae.txt create mode 100644 test/snapshots/parser/erb_test/test_0057_complex_form_with_trailing_hash_keys_5aa8c6b298cf6e7261db5fe1341a4eb1.txt diff --git a/src/analyze.c b/src/analyze.c index fe82b658a..4e2070a6a 100644 --- a/src/analyze.c +++ b/src/analyze.c @@ -1409,6 +1409,35 @@ void herb_analyze_parse_tree(AST_DOCUMENT_NODE_T* document, const char* source) free(invalid_context); } +static void parse_erb_content_errors(AST_NODE_T* erb_node, AST_DOCUMENT_NODE_T* document, const char* source) { + if (!erb_node || erb_node->type != AST_ERB_CONTENT_NODE) { return; } + AST_ERB_CONTENT_NODE_T* content_node = (AST_ERB_CONTENT_NODE_T*) erb_node; + + if (!content_node->content || !content_node->content->value) { return; } + + const char* content = content_node->content->value; + if (strlen(content) == 0) { return; } + + pm_parser_t parser; + pm_options_t options = { 0, .partial_script = true }; + pm_parser_init(&parser, (const uint8_t*) content, strlen(content), &options); + + pm_node_t* root = pm_parse(&parser); + + const pm_diagnostic_t* error = (const pm_diagnostic_t*) parser.error_list.head; + + if (error != NULL) { + RUBY_PARSE_ERROR_T* parse_error = + ruby_parse_error_from_prism_error_with_positions(error, erb_node->location.start, erb_node->location.end); + + hb_array_append(erb_node->errors, parse_error); + } + + pm_node_destroy(&parser, root); + pm_parser_free(&parser); + pm_options_free(&options); +} + void herb_analyze_parse_errors(AST_DOCUMENT_NODE_T* document, const char* source) { char* extracted_ruby = herb_extract_ruby_with_semicolons(source); @@ -1422,6 +1451,19 @@ void herb_analyze_parse_errors(AST_DOCUMENT_NODE_T* document, const char* source for (const pm_diagnostic_t* error = (const pm_diagnostic_t*) parser.error_list.head; error != NULL; error = (const pm_diagnostic_t*) error->node.next) { + size_t error_offset = (size_t) (error->location.start - parser.start); + + if (strstr(error->message, "unexpected ';'") != NULL) { + if (error_offset < strlen(extracted_ruby) && extracted_ruby[error_offset] == ';') { + if (error_offset >= strlen(source) || source[error_offset] != ';') { + AST_NODE_T* erb_node = find_erb_content_at_offset(document, source, error_offset); + + if (erb_node) { parse_erb_content_errors(erb_node, document, source); } + + continue; + } + } + } RUBY_PARSE_ERROR_T* parse_error = ruby_parse_error_from_prism_error(error, (AST_NODE_T*) document, source, &parser); hb_array_append(document->base.errors, parse_error); diff --git a/src/ast_node.c b/src/ast_node.c index e66414249..14d3d914f 100644 --- a/src/ast_node.c +++ b/src/ast_node.c @@ -1,8 +1,10 @@ #include "include/ast_node.h" #include "include/ast_nodes.h" #include "include/errors.h" +#include "include/position.h" #include "include/token.h" #include "include/util.h" +#include "include/visitor.h" #include #include @@ -76,3 +78,30 @@ void ast_node_set_positions_from_token(AST_NODE_T* node, const token_T* token) { bool ast_node_is(const AST_NODE_T* node, const ast_node_type_T type) { return node->type == type; } + +typedef struct { + position_T position; + AST_NODE_T* found_node; +} find_erb_at_position_context_T; + +static bool find_erb_at_position_visitor(const AST_NODE_T* node, void* data) { + find_erb_at_position_context_T* context = (find_erb_at_position_context_T*) data; + + if (node->type == AST_ERB_CONTENT_NODE) { + if (position_is_within_range(context->position, node->location.start, node->location.end)) { + context->found_node = (AST_NODE_T*) node; + return false; + } + } + + return true; +} + +AST_NODE_T* find_erb_content_at_offset(AST_DOCUMENT_NODE_T* document, const char* source, size_t offset) { + position_T position = position_from_source_with_offset(source, offset); + find_erb_at_position_context_T context = { .position = position, .found_node = NULL }; + + herb_visit_node((AST_NODE_T*) document, find_erb_at_position_visitor, &context); + + return context.found_node; +} diff --git a/src/extract.c b/src/extract.c index 297afe0fe..7e5f2450a 100644 --- a/src/extract.c +++ b/src/extract.c @@ -76,27 +76,9 @@ void herb_extract_ruby_to_buffer_with_semicolons(const char* source, hb_buffer_T break; } - bool needs_semicolon = false; - uint32_t current_line = token->location.end.line; - - for (size_t j = i + 1; j < hb_array_size(tokens); j++) { - const token_T* next_token = hb_array_get(tokens, j); - - if (next_token->type == TOKEN_NEWLINE) { break; } - - if (next_token->type == TOKEN_ERB_START && next_token->location.start.line == current_line) { - needs_semicolon = true; - break; - } - } - - if (needs_semicolon) { - hb_buffer_append_char(output, ' '); - hb_buffer_append_char(output, ';'); - hb_buffer_append_whitespace(output, range_length(token->range) - 2); - } else { - hb_buffer_append_whitespace(output, range_length(token->range)); - } + hb_buffer_append_char(output, ' '); + hb_buffer_append_char(output, ';'); + hb_buffer_append_whitespace(output, range_length(token->range) - 2); break; } @@ -112,6 +94,7 @@ void herb_extract_ruby_to_buffer_with_semicolons(const char* source, hb_buffer_T void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { hb_array_T* tokens = herb_lex(source); bool skip_erb_content = false; + bool is_comment_tag = false; for (size_t i = 0; i < hb_array_size(tokens); i++) { const token_T* token = hb_array_get(tokens, i); @@ -123,8 +106,15 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { } case TOKEN_ERB_START: { - if (strcmp(token->value, "<%#") == 0 || strcmp(token->value, "<%%") == 0 || strcmp(token->value, "<%%=") == 0) { + if (strcmp(token->value, "<%#") == 0) { skip_erb_content = true; + is_comment_tag = true; + } else if (strcmp(token->value, "<%%") == 0 || strcmp(token->value, "<%%=") == 0) { + skip_erb_content = true; + is_comment_tag = false; + } else { + skip_erb_content = false; + is_comment_tag = false; } hb_buffer_append_whitespace(output, range_length(token->range)); @@ -133,7 +123,26 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { case TOKEN_ERB_CONTENT: { if (skip_erb_content == false) { - hb_buffer_append(output, token->value); + bool is_inline_comment = false; + + if (!is_comment_tag && token->value != NULL) { + const char* content = token->value; + + while (*content == ' ' || *content == '\t') { + content++; + } + + if (*content == '#' && token->location.start.line == token->location.end.line) { + is_comment_tag = true; + is_inline_comment = true; + } + } + + if (is_inline_comment) { + hb_buffer_append_whitespace(output, range_length(token->range)); + } else { + hb_buffer_append(output, token->value); + } } else { hb_buffer_append_whitespace(output, range_length(token->range)); } @@ -142,9 +151,18 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { } case TOKEN_ERB_END: { + bool was_comment = is_comment_tag; skip_erb_content = false; + is_comment_tag = false; - hb_buffer_append_whitespace(output, range_length(token->range)); + if (was_comment) { + hb_buffer_append_whitespace(output, range_length(token->range)); + break; + } + + hb_buffer_append_char(output, ' '); + hb_buffer_append_char(output, ';'); + hb_buffer_append_whitespace(output, range_length(token->range) - 2); break; } diff --git a/src/include/ast_node.h b/src/include/ast_node.h index 144e35334..98b3801b0 100644 --- a/src/include/ast_node.h +++ b/src/include/ast_node.h @@ -32,4 +32,6 @@ void ast_node_set_positions_from_token(AST_NODE_T* node, const token_T* token); bool ast_node_is(const AST_NODE_T* node, ast_node_type_T type); +AST_NODE_T* find_erb_content_at_offset(AST_DOCUMENT_NODE_T* document, const char* source, size_t offset); + #endif diff --git a/src/include/position.h b/src/include/position.h index 8fad03d5f..7dc192cc1 100644 --- a/src/include/position.h +++ b/src/include/position.h @@ -1,6 +1,8 @@ #ifndef HERB_POSITION_H #define HERB_POSITION_H +#include +#include #include typedef struct POSITION_STRUCT { @@ -8,4 +10,7 @@ typedef struct POSITION_STRUCT { uint32_t column; } position_T; +position_T position_from_source_with_offset(const char* source, size_t offset); +bool position_is_within_range(position_T position, position_T start, position_T end); + #endif diff --git a/src/include/prism_helpers.h b/src/include/prism_helpers.h index ebfb53b4f..89c61baac 100644 --- a/src/include/prism_helpers.h +++ b/src/include/prism_helpers.h @@ -16,6 +16,10 @@ RUBY_PARSE_ERROR_T* ruby_parse_error_from_prism_error( pm_parser_t* parser ); -position_T position_from_source_with_offset(const char* source, size_t offset); +RUBY_PARSE_ERROR_T* ruby_parse_error_from_prism_error_with_positions( + const pm_diagnostic_t* error, + position_T start, + position_T end +); #endif diff --git a/src/position.c b/src/position.c new file mode 100644 index 000000000..29246b429 --- /dev/null +++ b/src/position.c @@ -0,0 +1,27 @@ +#include "include/position.h" +#include "include/util.h" + +position_T position_from_source_with_offset(const char* source, size_t offset) { + position_T position = { .line = 1, .column = 0 }; + + for (size_t i = 0; i < offset; i++) { + if (is_newline(source[i])) { + position.line++; + position.column = 0; + } else { + position.column++; + } + } + + return position; +} + +bool position_is_within_range(position_T position, position_T start, position_T end) { + if (position.line < start.line) { return false; } + if (position.line == start.line && position.column < start.column) { return false; } + + if (position.line > end.line) { return false; } + if (position.line == end.line && position.column > end.column) { return false; } + + return true; +} diff --git a/src/prism_helpers.c b/src/prism_helpers.c index 06ac155b5..5cd1c9d29 100644 --- a/src/prism_helpers.c +++ b/src/prism_helpers.c @@ -16,21 +16,6 @@ const char* pm_error_level_to_string(pm_error_level_t level) { } } -position_T position_from_source_with_offset(const char* source, size_t offset) { - position_T position = { .line = 1, .column = 0 }; - - for (size_t i = 0; i < offset; i++) { - if (is_newline(source[i])) { - position.line++; - position.column = 0; - } else { - position.column++; - } - } - - return position; -} - RUBY_PARSE_ERROR_T* ruby_parse_error_from_prism_error( const pm_diagnostic_t* error, const AST_NODE_T* node, @@ -51,3 +36,17 @@ RUBY_PARSE_ERROR_T* ruby_parse_error_from_prism_error( end ); } + +RUBY_PARSE_ERROR_T* ruby_parse_error_from_prism_error_with_positions( + const pm_diagnostic_t* error, + position_T start, + position_T end +) { + return ruby_parse_error_init( + error->message, + pm_diagnostic_id_human(error->diag_id), + pm_error_level_to_string(error->level), + start, + end + ); +} diff --git a/test/analyze/if_test.rb b/test/analyze/if_test.rb index 48f0ac305..683a3b5b5 100644 --- a/test/analyze/if_test.rb +++ b/test/analyze/if_test.rb @@ -152,5 +152,19 @@ class IfTest < Minitest::Spec > HTML end + + test "if with missing conditional" do + assert_parsed_snapshot(<<~HTML) + <% if %> + <% end %> + HTML + end + + test "if with invalid syntax" do + assert_parsed_snapshot(<<~HTML) + <% if true true %> + <% end %> + HTML + end end end diff --git a/test/c/test_extract.c b/test/c/test_extract.c index d707622c7..0d268f01b 100644 --- a/test/c/test_extract.c +++ b/test/c/test_extract.c @@ -4,11 +4,11 @@ #include "../../src/include/extract.h" #include "../../src/include/util/hb_buffer.h" -TEST(extract_ruby_single_erb_no_semicolon) +TEST(extract_ruby_single_erb_with_semicolons) char* source = "<% if %>\n<% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " if \n end "; + char expected[] = " if ;\n end ;"; ck_assert_str_eq(result, expected); free(result); @@ -18,7 +18,7 @@ TEST(extract_ruby_multiple_erb_same_line_with_semicolon) char* source = "<% x = 1 %> <% y = 2 %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " x = 1 ; y = 2 "); + ck_assert_str_eq(result, " x = 1 ; y = 2 ;"); free(result); END @@ -27,16 +27,16 @@ TEST(extract_ruby_three_erb_same_line_with_semicolons) char* source = "<% a = 1 %> <% b = 2 %> <% c = 3 %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " a = 1 ; b = 2 ; c = 3 "); + ck_assert_str_eq(result, " a = 1 ; b = 2 ; c = 3 ;"); free(result); END -TEST(extract_ruby_different_lines_no_semicolons) +TEST(extract_ruby_different_lines_with_semicolons) char* source = "<% x = 1 %>\n<% y = 2 %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " x = 1 \n y = 2 "; + char expected[] = " x = 1 ;\n y = 2 ;"; ck_assert_str_eq(result, expected); free(result); @@ -46,7 +46,7 @@ TEST(extract_ruby_mixed_lines) char* source = "<% a = 1 %> <% b = 2 %>\n<% c = 3 %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " a = 1 ; b = 2 \n c = 3 "; + char expected[] = " a = 1 ; b = 2 ;\n c = 3 ;"; ck_assert_str_eq(result, expected); free(result); @@ -56,7 +56,7 @@ TEST(extract_ruby_output_tags_same_line) char* source = "<%= x %> <%= y %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " x ; y "); + ck_assert_str_eq(result, " x ; y ;"); free(result); END @@ -65,7 +65,7 @@ TEST(extract_ruby_empty_erb_same_line) char* source = "<% %> <% %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " ; "); + ck_assert_str_eq(result, " ; ;"); free(result); END @@ -74,7 +74,7 @@ TEST(extract_ruby_comments_skipped) char* source = "<%# comment %> <% code %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " code "); + ck_assert_str_eq(result, " code ;"); free(result); END @@ -83,7 +83,7 @@ TEST(extract_ruby_issue_135_if_without_condition) char* source = "<% if %>\n<% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " if \n end "; + char expected[] = " if ;\n end ;"; ck_assert_str_eq(result, expected); free(result); @@ -93,7 +93,7 @@ TEST(extract_ruby_inline_comment_same_line) char* source = "<% if true %><% # Comment here %><% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " if true ; end "); + ck_assert_str_eq(result, " if true ; end ;"); free(result); END @@ -102,7 +102,7 @@ TEST(extract_ruby_inline_comment_with_newline) char* source = "<% if true %><% # Comment here %>\n<% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " if true ; \n end "; + char expected[] = " if true ; \n end ;"; ck_assert_str_eq(result, expected); free(result); @@ -112,7 +112,7 @@ TEST(extract_ruby_inline_comment_with_spaces) char* source = "<% # Comment %> <% code %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " code "); + ck_assert_str_eq(result, " code ;"); free(result); END @@ -121,7 +121,7 @@ TEST(extract_ruby_inline_comment_multiline) char* source = "<% # Comment\nmore %> <% code %>"; char* result = herb_extract_ruby_with_semicolons(source); - char expected[] = " # Comment\nmore ; code "; + char expected[] = " # Comment\nmore ; code ;"; ck_assert_str_eq(result, expected); free(result); @@ -131,7 +131,7 @@ TEST(extract_ruby_inline_comment_between_code) char* source = "<% if true %><% # Comment here %><%= hello %><% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " if true ; hello ; end "); + ck_assert_str_eq(result, " if true ; hello ; end ;"); free(result); END @@ -140,7 +140,7 @@ TEST(extract_ruby_inline_comment_complex) char* source = "<% # Comment here %><% if true %><% # Comment here %><%= hello %><% end %>"; char* result = herb_extract_ruby_with_semicolons(source); - ck_assert_str_eq(result, " if true ; hello ; end "); + ck_assert_str_eq(result, " if true ; hello ; end ;"); free(result); END @@ -148,10 +148,10 @@ END TCase *extract_tests(void) { TCase *extract = tcase_create("Extract"); - tcase_add_test(extract, extract_ruby_single_erb_no_semicolon); + tcase_add_test(extract, extract_ruby_single_erb_with_semicolons); tcase_add_test(extract, extract_ruby_multiple_erb_same_line_with_semicolon); tcase_add_test(extract, extract_ruby_three_erb_same_line_with_semicolons); - tcase_add_test(extract, extract_ruby_different_lines_no_semicolons); + tcase_add_test(extract, extract_ruby_different_lines_with_semicolons); tcase_add_test(extract, extract_ruby_mixed_lines); tcase_add_test(extract, extract_ruby_output_tags_same_line); tcase_add_test(extract, extract_ruby_empty_erb_same_line); diff --git a/test/extractor/extract_ruby_test.rb b/test/extractor/extract_ruby_test.rb index 2724827ab..0f5f260b8 100644 --- a/test/extractor/extract_ruby_test.rb +++ b/test/extractor/extract_ruby_test.rb @@ -7,13 +7,13 @@ class ExtractRubyTest < Minitest::Spec test "basic silent" do ruby = Herb.extract_ruby("

<% RUBY_VERSION %>

") - assert_equal " RUBY_VERSION ", ruby + assert_equal " RUBY_VERSION ; ", ruby end test "basic loud" do ruby = Herb.extract_ruby("

<%= RUBY_VERSION %>

") - assert_equal " RUBY_VERSION ", ruby + assert_equal " RUBY_VERSION ; ", ruby end test "with newlines" do @@ -23,7 +23,7 @@ class ExtractRubyTest < Minitest::Spec HTML - assert_equal " \n RUBY_VERSION \n \n", actual + assert_equal " \n RUBY_VERSION ;\n \n", actual end test "nested" do @@ -37,7 +37,7 @@ class ExtractRubyTest < Minitest::Spec HTML - expected = " array = [1, 2, 3] \n\n \n array.each do |item| \n item \n end \n \n" + expected = " array = [1, 2, 3] ;\n\n \n array.each do |item| ;\n item ; \n end ;\n \n" assert_equal expected, actual end @@ -100,7 +100,7 @@ class ExtractRubyTest < Minitest::Spec <% if %><%# comment %><% end %> HTML - expected = " if end \n" + expected = " if ; end ;\n" assert_equal expected, actual end @@ -110,7 +110,7 @@ class ExtractRubyTest < Minitest::Spec <% if %><% # comment %><% end %> HTML - expected = " if # comment end \n" + expected = " if ; # comment ; end ;\n" assert_equal expected, actual end diff --git a/test/parser/erb_test.rb b/test/parser/erb_test.rb index 285b0d8e3..746ed5e8d 100644 --- a/test/parser/erb_test.rb +++ b/test/parser/erb_test.rb @@ -309,5 +309,76 @@ class ERBTest < Minitest::Spec <% end %> HTML end + + test "hash with trailing key across tags" do + assert_parsed_snapshot(<<~HTML) + <%= render "form", f: %> + <%= f.input :name %> + HTML + end + + test "single hash with trailing key in div" do + assert_parsed_snapshot(<<~HTML) +
+ <%= render "something", thing: %> +
+ HTML + end + + test "multiple hash with trailing key in div" do + assert_parsed_snapshot(<<~HTML) +
+ <%= render "something", thing: %> + <%= render "something", thing: %> +
+ HTML + end + + test "many hash with trailing key in div" do + assert_parsed_snapshot(<<~HTML) +
+ <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> +
+ HTML + end + + test "hash shorthand in parentheses across tags" do + assert_parsed_snapshot(<<~HTML) +
+ <%= something(thing:) %> + <%= something(thing:) %> +
+ HTML + end + + test "complex form with trailing hash keys" do + assert_parsed_snapshot(<<~HTML) + <%= form_with url:, model: custom_field, scope: :custom_field, data: {turbo_frame: :_top}, class: "my-2" do |form| %> + <%= form.hidden_field :type, value: custom_field.type %> + <%= form.input :name %> + <%= form.check_box :required %> + <%= form.check_box :nullable %> + + <%= component "custom_field_forms/\#{custom_field.model_name.element.underscore}", form: %> + +
+ <%= component :link, path: custom_fields_path, variant: :button, data: {turbo_frame: :_top} do %> + <%= t("buttons.back") %> + <% end %> + + <%= component :button, state: :primary do %> + <%= button_text %> + <% end %> +
+ <% end %> + HTML + end end end diff --git a/test/snapshots/analyze/block_test/test_0020_unclosed_brace_block_with_end_should_error_ce5658e89dd99cb6740835d0d0666353.txt b/test/snapshots/analyze/block_test/test_0020_unclosed_brace_block_with_end_should_error_ce5658e89dd99cb6740835d0d0666353.txt index c3504979f..60db0740b 100644 --- a/test/snapshots/analyze/block_test/test_0020_unclosed_brace_block_with_end_should_error_ce5658e89dd99cb6740835d0d0666353.txt +++ b/test/snapshots/analyze/block_test/test_0020_unclosed_brace_block_with_end_should_error_ce5658e89dd99cb6740835d0d0666353.txt @@ -19,7 +19,7 @@ input: |2- │ │ ├── diagnostic_id: "unexpected_token_close_context" │ │ └── level: "syntax" │ │ -│ └── @ RubyParseError (location: (4:0)-(4:0)) +│ └── @ RubyParseError (location: (3:9)-(3:9)) │ ├── message: "block_term_brace: expected a block beginning with `{` to end with `}`" │ ├── error_message: "expected a block beginning with `{` to end with `}`" │ ├── diagnostic_id: "block_term_brace" diff --git a/test/snapshots/analyze/if_test/test_0016_if_with_missing_conditional_ee03fbe2e88a2b56ca5c044200fe19bf.txt b/test/snapshots/analyze/if_test/test_0016_if_with_missing_conditional_ee03fbe2e88a2b56ca5c044200fe19bf.txt new file mode 100644 index 000000000..ee80a7d71 --- /dev/null +++ b/test/snapshots/analyze/if_test/test_0016_if_with_missing_conditional_ee03fbe2e88a2b56ca5c044200fe19bf.txt @@ -0,0 +1,33 @@ +--- +source: "Analyze::IfTest#test_0016_if with missing conditional" +input: |2- +<% if %> +<% end %> +--- +@ DocumentNode (location: (1:0)-(3:0)) +├── errors: (1 error) +│ └── @ RubyParseError (location: (1:3)-(1:5)) +│ ├── message: "conditional_if_predicate: expected a predicate expression for the `if` statement" +│ ├── error_message: "expected a predicate expression for the `if` statement" +│ ├── diagnostic_id: "conditional_if_predicate" +│ └── level: "syntax" +│ +└── children: (2 items) + ├── @ ERBIfNode (location: (1:0)-(2:9)) + │ ├── tag_opening: "<%" (location: (1:0)-(1:2)) + │ ├── content: " if " (location: (1:2)-(1:6)) + │ ├── tag_closing: "%>" (location: (1:6)-(1:8)) + │ ├── statements: (1 item) + │ │ └── @ HTMLTextNode (location: (1:8)-(2:0)) + │ │ └── content: "\n" + │ │ + │ ├── subsequent: ∅ + │ └── end_node: + │ └── @ ERBEndNode (location: (2:0)-(2:9)) + │ ├── tag_opening: "<%" (location: (2:0)-(2:2)) + │ ├── content: " end " (location: (2:2)-(2:7)) + │ └── tag_closing: "%>" (location: (2:7)-(2:9)) + │ + │ + └── @ HTMLTextNode (location: (2:9)-(3:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/analyze/if_test/test_0017_if_with_invalid_syntax_1c3b5ad1abe4440ca61ee62d2db330f2.txt b/test/snapshots/analyze/if_test/test_0017_if_with_invalid_syntax_1c3b5ad1abe4440ca61ee62d2db330f2.txt new file mode 100644 index 000000000..37de27eec --- /dev/null +++ b/test/snapshots/analyze/if_test/test_0017_if_with_invalid_syntax_1c3b5ad1abe4440ca61ee62d2db330f2.txt @@ -0,0 +1,33 @@ +--- +source: "Analyze::IfTest#test_0017_if with invalid syntax" +input: |2- +<% if true true %> +<% end %> +--- +@ DocumentNode (location: (1:0)-(3:0)) +├── errors: (1 error) +│ └── @ RubyParseError (location: (1:11)-(1:15)) +│ ├── message: "conditional_predicate_term: expected `then` or `;` or '\\n'" +│ ├── error_message: "expected `then` or `;` or '\\n'" +│ ├── diagnostic_id: "conditional_predicate_term" +│ └── level: "syntax" +│ +└── children: (2 items) + ├── @ ERBIfNode (location: (1:0)-(2:9)) + │ ├── tag_opening: "<%" (location: (1:0)-(1:2)) + │ ├── content: " if true true " (location: (1:2)-(1:16)) + │ ├── tag_closing: "%>" (location: (1:16)-(1:18)) + │ ├── statements: (1 item) + │ │ └── @ HTMLTextNode (location: (1:18)-(2:0)) + │ │ └── content: "\n" + │ │ + │ ├── subsequent: ∅ + │ └── end_node: + │ └── @ ERBEndNode (location: (2:0)-(2:9)) + │ ├── tag_opening: "<%" (location: (2:0)-(2:2)) + │ ├── content: " end " (location: (2:2)-(2:7)) + │ └── tag_closing: "%>" (location: (2:7)-(2:9)) + │ + │ + └── @ HTMLTextNode (location: (2:9)-(3:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0039_incomplete_erb_tag_d7de40920545c14e5afcda3a2e9f0eb9.txt b/test/snapshots/parser/erb_test/test_0039_incomplete_erb_tag_d7de40920545c14e5afcda3a2e9f0eb9.txt index b79785e60..fb1dc6402 100644 --- a/test/snapshots/parser/erb_test/test_0039_incomplete_erb_tag_d7de40920545c14e5afcda3a2e9f0eb9.txt +++ b/test/snapshots/parser/erb_test/test_0039_incomplete_erb_tag_d7de40920545c14e5afcda3a2e9f0eb9.txt @@ -3,21 +3,15 @@ source: "Parser::ERBTest#test_0039_incomplete erb tag" input: "<%= 1 + %>" --- @ DocumentNode (location: (1:0)-(1:10)) -├── errors: (2 errors) -│ ├── @ RubyParseError (location: (1:10)-(1:10)) -│ │ ├── message: "expect_expression_after_operator: unexpected end-of-input; expected an expression after the operator" -│ │ ├── error_message: "unexpected end-of-input; expected an expression after the operator" -│ │ ├── diagnostic_id: "expect_expression_after_operator" -│ │ └── level: "syntax" -│ │ -│ └── @ RubyParseError (location: (1:10)-(1:10)) -│ ├── message: "unexpected_token_close_context: unexpected end-of-input, assuming it is closing the parent top level context" -│ ├── error_message: "unexpected end-of-input, assuming it is closing the parent top level context" -│ ├── diagnostic_id: "unexpected_token_close_context" -│ └── level: "syntax" -│ └── children: (1 item) └── @ ERBContentNode (location: (1:0)-(1:10)) + ├── errors: (1 error) + │ └── @ RubyParseError (location: (1:0)-(1:10)) + │ ├── message: "expect_expression_after_operator: unexpected end-of-input; expected an expression after the operator" + │ ├── error_message: "unexpected end-of-input; expected an expression after the operator" + │ ├── diagnostic_id: "expect_expression_after_operator" + │ └── level: "syntax" + │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) ├── content: " 1 + " (location: (1:3)-(1:8)) ├── tag_closing: "%>" (location: (1:8)-(1:10)) diff --git a/test/snapshots/parser/erb_test/test_0040_if_without_condition_ee03fbe2e88a2b56ca5c044200fe19bf.txt b/test/snapshots/parser/erb_test/test_0040_if_without_condition_ee03fbe2e88a2b56ca5c044200fe19bf.txt index f90424832..065295c18 100644 --- a/test/snapshots/parser/erb_test/test_0040_if_without_condition_ee03fbe2e88a2b56ca5c044200fe19bf.txt +++ b/test/snapshots/parser/erb_test/test_0040_if_without_condition_ee03fbe2e88a2b56ca5c044200fe19bf.txt @@ -5,17 +5,11 @@ input: |2- <% end %> --- @ DocumentNode (location: (1:0)-(3:0)) -├── errors: (2 errors) -│ ├── @ RubyParseError (location: (1:3)-(1:5)) -│ │ ├── message: "conditional_if_predicate: expected a predicate expression for the `if` statement" -│ │ ├── error_message: "expected a predicate expression for the `if` statement" -│ │ ├── diagnostic_id: "conditional_if_predicate" -│ │ └── level: "syntax" -│ │ -│ └── @ RubyParseError (location: (2:3)-(2:6)) -│ ├── message: "conditional_predicate_term: expected `then` or `;` or '\\n'" -│ ├── error_message: "expected `then` or `;` or '\\n'" -│ ├── diagnostic_id: "conditional_predicate_term" +├── errors: (1 error) +│ └── @ RubyParseError (location: (1:3)-(1:5)) +│ ├── message: "conditional_if_predicate: expected a predicate expression for the `if` statement" +│ ├── error_message: "expected a predicate expression for the `if` statement" +│ ├── diagnostic_id: "conditional_if_predicate" │ └── level: "syntax" │ └── children: (2 items) diff --git a/test/snapshots/parser/erb_test/test_0052_hash_with_trailing_key_across_tags_5c81bac82089f414dd42d8becdaa4ef3.txt b/test/snapshots/parser/erb_test/test_0052_hash_with_trailing_key_across_tags_5c81bac82089f414dd42d8becdaa4ef3.txt new file mode 100644 index 000000000..b676d932b --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0052_hash_with_trailing_key_across_tags_5c81bac82089f414dd42d8becdaa4ef3.txt @@ -0,0 +1,27 @@ +--- +source: "Parser::ERBTest#test_0052_hash with trailing key across tags" +input: |2- +<%= render "form", f: %> +<%= f.input :name %> +--- +@ DocumentNode (location: (1:0)-(3:0)) +└── children: (4 items) + ├── @ ERBContentNode (location: (1:0)-(1:24)) + │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) + │ ├── content: " render "form", f: " (location: (1:3)-(1:22)) + │ ├── tag_closing: "%>" (location: (1:22)-(1:24)) + │ ├── parsed: true + │ └── valid: true + │ + ├── @ HTMLTextNode (location: (1:24)-(2:0)) + │ └── content: "\n" + │ + ├── @ ERBContentNode (location: (2:0)-(2:20)) + │ ├── tag_opening: "<%=" (location: (2:0)-(2:3)) + │ ├── content: " f.input :name " (location: (2:3)-(2:18)) + │ ├── tag_closing: "%>" (location: (2:18)-(2:20)) + │ ├── parsed: true + │ └── valid: true + │ + └── @ HTMLTextNode (location: (2:20)-(3:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0053_single_hash_with_trailing_key_in_div_6b3ce8ff3d1ac8f4a5cf2a377ddcbae4.txt b/test/snapshots/parser/erb_test/test_0053_single_hash_with_trailing_key_in_div_6b3ce8ff3d1ac8f4a5cf2a377ddcbae4.txt new file mode 100644 index 000000000..5663f391e --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0053_single_hash_with_trailing_key_in_div_6b3ce8ff3d1ac8f4a5cf2a377ddcbae4.txt @@ -0,0 +1,45 @@ +--- +source: "Parser::ERBTest#test_0053_single hash with trailing key in div" +input: |2- +
+ <%= render "something", thing: %> +
+--- +@ DocumentNode (location: (1:0)-(4:0)) +└── children: (2 items) + ├── @ HTMLElementNode (location: (1:0)-(3:6)) + │ ├── open_tag: + │ │ └── @ HTMLOpenTagNode (location: (1:0)-(1:5)) + │ │ ├── tag_opening: "<" (location: (1:0)-(1:1)) + │ │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ │ ├── tag_closing: ">" (location: (1:4)-(1:5)) + │ │ ├── children: [] + │ │ └── is_void: false + │ │ + │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ ├── body: (3 items) + │ │ ├── @ HTMLTextNode (location: (1:5)-(2:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (2:2)-(2:35)) + │ │ │ ├── tag_opening: "<%=" (location: (2:2)-(2:5)) + │ │ │ ├── content: " render "something", thing: " (location: (2:5)-(2:33)) + │ │ │ ├── tag_closing: "%>" (location: (2:33)-(2:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ └── @ HTMLTextNode (location: (2:35)-(3:0)) + │ │ └── content: "\n" + │ │ + │ ├── close_tag: + │ │ └── @ HTMLCloseTagNode (location: (3:0)-(3:6)) + │ │ ├── tag_opening: "" (location: (3:5)-(3:6)) + │ │ + │ ├── is_void: false + │ └── source: "HTML" + │ + └── @ HTMLTextNode (location: (3:6)-(4:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0054_multiple_hash_with_trailing_key_in_div_566fdf7ce4fea5ab67f856680ed3dbba.txt b/test/snapshots/parser/erb_test/test_0054_multiple_hash_with_trailing_key_in_div_566fdf7ce4fea5ab67f856680ed3dbba.txt new file mode 100644 index 000000000..01bcd5b8e --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0054_multiple_hash_with_trailing_key_in_div_566fdf7ce4fea5ab67f856680ed3dbba.txt @@ -0,0 +1,56 @@ +--- +source: "Parser::ERBTest#test_0054_multiple hash with trailing key in div" +input: |2- +
+ <%= render "something", thing: %> + <%= render "something", thing: %> +
+--- +@ DocumentNode (location: (1:0)-(5:0)) +└── children: (2 items) + ├── @ HTMLElementNode (location: (1:0)-(4:6)) + │ ├── open_tag: + │ │ └── @ HTMLOpenTagNode (location: (1:0)-(1:5)) + │ │ ├── tag_opening: "<" (location: (1:0)-(1:1)) + │ │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ │ ├── tag_closing: ">" (location: (1:4)-(1:5)) + │ │ ├── children: [] + │ │ └── is_void: false + │ │ + │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ ├── body: (5 items) + │ │ ├── @ HTMLTextNode (location: (1:5)-(2:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (2:2)-(2:35)) + │ │ │ ├── tag_opening: "<%=" (location: (2:2)-(2:5)) + │ │ │ ├── content: " render "something", thing: " (location: (2:5)-(2:33)) + │ │ │ ├── tag_closing: "%>" (location: (2:33)-(2:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (2:35)-(3:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (3:2)-(3:35)) + │ │ │ ├── tag_opening: "<%=" (location: (3:2)-(3:5)) + │ │ │ ├── content: " render "something", thing: " (location: (3:5)-(3:33)) + │ │ │ ├── tag_closing: "%>" (location: (3:33)-(3:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ └── @ HTMLTextNode (location: (3:35)-(4:0)) + │ │ └── content: "\n" + │ │ + │ ├── close_tag: + │ │ └── @ HTMLCloseTagNode (location: (4:0)-(4:6)) + │ │ ├── tag_opening: "" (location: (4:5)-(4:6)) + │ │ + │ ├── is_void: false + │ └── source: "HTML" + │ + └── @ HTMLTextNode (location: (4:6)-(5:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0055_many_hash_with_trailing_key_in_div_bc4767160a429bf77c068d0263c18645.txt b/test/snapshots/parser/erb_test/test_0055_many_hash_with_trailing_key_in_div_bc4767160a429bf77c068d0263c18645.txt new file mode 100644 index 000000000..8b9a47862 --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0055_many_hash_with_trailing_key_in_div_bc4767160a429bf77c068d0263c18645.txt @@ -0,0 +1,122 @@ +--- +source: "Parser::ERBTest#test_0055_many hash with trailing key in div" +input: |2- +
+ <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> + <%= render "something", thing: %> +
+--- +@ DocumentNode (location: (1:0)-(11:0)) +└── children: (2 items) + ├── @ HTMLElementNode (location: (1:0)-(10:6)) + │ ├── open_tag: + │ │ └── @ HTMLOpenTagNode (location: (1:0)-(1:5)) + │ │ ├── tag_opening: "<" (location: (1:0)-(1:1)) + │ │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ │ ├── tag_closing: ">" (location: (1:4)-(1:5)) + │ │ ├── children: [] + │ │ └── is_void: false + │ │ + │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ ├── body: (17 items) + │ │ ├── @ HTMLTextNode (location: (1:5)-(2:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (2:2)-(2:35)) + │ │ │ ├── tag_opening: "<%=" (location: (2:2)-(2:5)) + │ │ │ ├── content: " render "something", thing: " (location: (2:5)-(2:33)) + │ │ │ ├── tag_closing: "%>" (location: (2:33)-(2:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (2:35)-(3:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (3:2)-(3:35)) + │ │ │ ├── tag_opening: "<%=" (location: (3:2)-(3:5)) + │ │ │ ├── content: " render "something", thing: " (location: (3:5)-(3:33)) + │ │ │ ├── tag_closing: "%>" (location: (3:33)-(3:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (3:35)-(4:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (4:2)-(4:35)) + │ │ │ ├── tag_opening: "<%=" (location: (4:2)-(4:5)) + │ │ │ ├── content: " render "something", thing: " (location: (4:5)-(4:33)) + │ │ │ ├── tag_closing: "%>" (location: (4:33)-(4:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (4:35)-(5:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (5:2)-(5:35)) + │ │ │ ├── tag_opening: "<%=" (location: (5:2)-(5:5)) + │ │ │ ├── content: " render "something", thing: " (location: (5:5)-(5:33)) + │ │ │ ├── tag_closing: "%>" (location: (5:33)-(5:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (5:35)-(6:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (6:2)-(6:35)) + │ │ │ ├── tag_opening: "<%=" (location: (6:2)-(6:5)) + │ │ │ ├── content: " render "something", thing: " (location: (6:5)-(6:33)) + │ │ │ ├── tag_closing: "%>" (location: (6:33)-(6:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (6:35)-(7:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (7:2)-(7:35)) + │ │ │ ├── tag_opening: "<%=" (location: (7:2)-(7:5)) + │ │ │ ├── content: " render "something", thing: " (location: (7:5)-(7:33)) + │ │ │ ├── tag_closing: "%>" (location: (7:33)-(7:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (7:35)-(8:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (8:2)-(8:35)) + │ │ │ ├── tag_opening: "<%=" (location: (8:2)-(8:5)) + │ │ │ ├── content: " render "something", thing: " (location: (8:5)-(8:33)) + │ │ │ ├── tag_closing: "%>" (location: (8:33)-(8:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (8:35)-(9:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (9:2)-(9:35)) + │ │ │ ├── tag_opening: "<%=" (location: (9:2)-(9:5)) + │ │ │ ├── content: " render "something", thing: " (location: (9:5)-(9:33)) + │ │ │ ├── tag_closing: "%>" (location: (9:33)-(9:35)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ └── @ HTMLTextNode (location: (9:35)-(10:0)) + │ │ └── content: "\n" + │ │ + │ ├── close_tag: + │ │ └── @ HTMLCloseTagNode (location: (10:0)-(10:6)) + │ │ ├── tag_opening: "" (location: (10:5)-(10:6)) + │ │ + │ ├── is_void: false + │ └── source: "HTML" + │ + └── @ HTMLTextNode (location: (10:6)-(11:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0056_hash_shorthand_in_parentheses_across_tags_8cafde6814ee57b2ee27e3392f23c2ae.txt b/test/snapshots/parser/erb_test/test_0056_hash_shorthand_in_parentheses_across_tags_8cafde6814ee57b2ee27e3392f23c2ae.txt new file mode 100644 index 000000000..9821690fd --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0056_hash_shorthand_in_parentheses_across_tags_8cafde6814ee57b2ee27e3392f23c2ae.txt @@ -0,0 +1,56 @@ +--- +source: "Parser::ERBTest#test_0056_hash shorthand in parentheses across tags" +input: |2- +
+ <%= something(thing:) %> + <%= something(thing:) %> +
+--- +@ DocumentNode (location: (1:0)-(5:0)) +└── children: (2 items) + ├── @ HTMLElementNode (location: (1:0)-(4:6)) + │ ├── open_tag: + │ │ └── @ HTMLOpenTagNode (location: (1:0)-(1:5)) + │ │ ├── tag_opening: "<" (location: (1:0)-(1:1)) + │ │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ │ ├── tag_closing: ">" (location: (1:4)-(1:5)) + │ │ ├── children: [] + │ │ └── is_void: false + │ │ + │ ├── tag_name: "div" (location: (1:1)-(1:4)) + │ ├── body: (5 items) + │ │ ├── @ HTMLTextNode (location: (1:5)-(2:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (2:2)-(2:26)) + │ │ │ ├── tag_opening: "<%=" (location: (2:2)-(2:5)) + │ │ │ ├── content: " something(thing:) " (location: (2:5)-(2:24)) + │ │ │ ├── tag_closing: "%>" (location: (2:24)-(2:26)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (2:26)-(3:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (3:2)-(3:26)) + │ │ │ ├── tag_opening: "<%=" (location: (3:2)-(3:5)) + │ │ │ ├── content: " something(thing:) " (location: (3:5)-(3:24)) + │ │ │ ├── tag_closing: "%>" (location: (3:24)-(3:26)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ └── @ HTMLTextNode (location: (3:26)-(4:0)) + │ │ └── content: "\n" + │ │ + │ ├── close_tag: + │ │ └── @ HTMLCloseTagNode (location: (4:0)-(4:6)) + │ │ ├── tag_opening: "" (location: (4:5)-(4:6)) + │ │ + │ ├── is_void: false + │ └── source: "HTML" + │ + └── @ HTMLTextNode (location: (4:6)-(5:0)) + └── content: "\n" \ No newline at end of file diff --git a/test/snapshots/parser/erb_test/test_0057_complex_form_with_trailing_hash_keys_5aa8c6b298cf6e7261db5fe1341a4eb1.txt b/test/snapshots/parser/erb_test/test_0057_complex_form_with_trailing_hash_keys_5aa8c6b298cf6e7261db5fe1341a4eb1.txt new file mode 100644 index 000000000..7483ba1e2 --- /dev/null +++ b/test/snapshots/parser/erb_test/test_0057_complex_form_with_trailing_hash_keys_5aa8c6b298cf6e7261db5fe1341a4eb1.txt @@ -0,0 +1,194 @@ +--- +source: "Parser::ERBTest#test_0057_complex form with trailing hash keys" +input: |2- +<%= form_with url:, model: custom_field, scope: :custom_field, data: {turbo_frame: :_top}, class: "my-2" do |form| %> + <%= form.hidden_field :type, value: custom_field.type %> + <%= form.input :name %> + <%= form.check_box :required %> + <%= form.check_box :nullable %> + + <%= component "custom_field_forms/#{custom_field.model_name.element.underscore}", form: %> + +
+ <%= component :link, path: custom_fields_path, variant: :button, data: {turbo_frame: :_top} do %> + <%= t("buttons.back") %> + <% end %> + + <%= component :button, state: :primary do %> + <%= button_text %> + <% end %> +
+<% end %> +--- +@ DocumentNode (location: (1:0)-(19:0)) +└── children: (2 items) + ├── @ ERBBlockNode (location: (1:0)-(18:9)) + │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) + │ ├── content: " form_with url:, model: custom_field, scope: :custom_field, data: {turbo_frame: :_top}, class: "my-2" do |form| " (location: (1:3)-(1:115)) + │ ├── tag_closing: "%>" (location: (1:115)-(1:117)) + │ ├── body: (13 items) + │ │ ├── @ HTMLTextNode (location: (1:117)-(2:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (2:2)-(2:58)) + │ │ │ ├── tag_opening: "<%=" (location: (2:2)-(2:5)) + │ │ │ ├── content: " form.hidden_field :type, value: custom_field.type " (location: (2:5)-(2:56)) + │ │ │ ├── tag_closing: "%>" (location: (2:56)-(2:58)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (2:58)-(3:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (3:2)-(3:25)) + │ │ │ ├── tag_opening: "<%=" (location: (3:2)-(3:5)) + │ │ │ ├── content: " form.input :name " (location: (3:5)-(3:23)) + │ │ │ ├── tag_closing: "%>" (location: (3:23)-(3:25)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (3:25)-(4:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (4:2)-(4:33)) + │ │ │ ├── tag_opening: "<%=" (location: (4:2)-(4:5)) + │ │ │ ├── content: " form.check_box :required " (location: (4:5)-(4:31)) + │ │ │ ├── tag_closing: "%>" (location: (4:31)-(4:33)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (4:33)-(5:2)) + │ │ │ └── content: "\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (5:2)-(5:33)) + │ │ │ ├── tag_opening: "<%=" (location: (5:2)-(5:5)) + │ │ │ ├── content: " form.check_box :nullable " (location: (5:5)-(5:31)) + │ │ │ ├── tag_closing: "%>" (location: (5:31)-(5:33)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (5:33)-(7:2)) + │ │ │ └── content: "\n\n " + │ │ │ + │ │ ├── @ ERBContentNode (location: (7:2)-(7:92)) + │ │ │ ├── tag_opening: "<%=" (location: (7:2)-(7:5)) + │ │ │ ├── content: " component "custom_field_forms/#{custom_field.model_name.element.underscore}", form: " (location: (7:5)-(7:90)) + │ │ │ ├── tag_closing: "%>" (location: (7:90)-(7:92)) + │ │ │ ├── parsed: true + │ │ │ └── valid: true + │ │ │ + │ │ ├── @ HTMLTextNode (location: (7:92)-(9:2)) + │ │ │ └── content: "\n\n " + │ │ │ + │ │ ├── @ HTMLElementNode (location: (9:2)-(17:8)) + │ │ │ ├── open_tag: + │ │ │ │ └── @ HTMLOpenTagNode (location: (9:2)-(9:47)) + │ │ │ │ ├── tag_opening: "<" (location: (9:2)-(9:3)) + │ │ │ │ ├── tag_name: "div" (location: (9:3)-(9:6)) + │ │ │ │ ├── tag_closing: ">" (location: (9:46)-(9:47)) + │ │ │ │ ├── children: (1 item) + │ │ │ │ │ └── @ HTMLAttributeNode (location: (9:7)-(9:46)) + │ │ │ │ │ ├── name: + │ │ │ │ │ │ └── @ HTMLAttributeNameNode (location: (9:7)-(9:12)) + │ │ │ │ │ │ └── children: (1 item) + │ │ │ │ │ │ └── @ LiteralNode (location: (9:7)-(9:12)) + │ │ │ │ │ │ └── content: "class" + │ │ │ │ │ │ + │ │ │ │ │ │ + │ │ │ │ │ ├── equals: "=" (location: (9:12)-(9:13)) + │ │ │ │ │ └── value: + │ │ │ │ │ └── @ HTMLAttributeValueNode (location: (9:13)-(9:46)) + │ │ │ │ │ ├── open_quote: """ (location: (9:13)-(9:14)) + │ │ │ │ │ ├── children: (1 item) + │ │ │ │ │ │ └── @ LiteralNode (location: (9:14)-(9:45)) + │ │ │ │ │ │ └── content: "flex justify-end mt-4 space-x-4" + │ │ │ │ │ │ + │ │ │ │ │ ├── close_quote: """ (location: (9:45)-(9:46)) + │ │ │ │ │ └── quoted: true + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ └── is_void: false + │ │ │ │ + │ │ │ ├── tag_name: "div" (location: (9:3)-(9:6)) + │ │ │ ├── body: (5 items) + │ │ │ │ ├── @ HTMLTextNode (location: (9:47)-(10:4)) + │ │ │ │ │ └── content: "\n " + │ │ │ │ │ + │ │ │ │ ├── @ ERBBlockNode (location: (10:4)-(12:13)) + │ │ │ │ │ ├── tag_opening: "<%=" (location: (10:4)-(10:7)) + │ │ │ │ │ ├── content: " component :link, path: custom_fields_path, variant: :button, data: {turbo_frame: :_top} do " (location: (10:7)-(10:99)) + │ │ │ │ │ ├── tag_closing: "%>" (location: (10:99)-(10:101)) + │ │ │ │ │ ├── body: (3 items) + │ │ │ │ │ │ ├── @ HTMLTextNode (location: (10:101)-(11:6)) + │ │ │ │ │ │ │ └── content: "\n " + │ │ │ │ │ │ │ + │ │ │ │ │ │ ├── @ ERBContentNode (location: (11:6)-(11:30)) + │ │ │ │ │ │ │ ├── tag_opening: "<%=" (location: (11:6)-(11:9)) + │ │ │ │ │ │ │ ├── content: " t("buttons.back") " (location: (11:9)-(11:28)) + │ │ │ │ │ │ │ ├── tag_closing: "%>" (location: (11:28)-(11:30)) + │ │ │ │ │ │ │ ├── parsed: true + │ │ │ │ │ │ │ └── valid: true + │ │ │ │ │ │ │ + │ │ │ │ │ │ └── @ HTMLTextNode (location: (11:30)-(12:4)) + │ │ │ │ │ │ └── content: "\n " + │ │ │ │ │ │ + │ │ │ │ │ └── end_node: + │ │ │ │ │ └── @ ERBEndNode (location: (12:4)-(12:13)) + │ │ │ │ │ ├── tag_opening: "<%" (location: (12:4)-(12:6)) + │ │ │ │ │ ├── content: " end " (location: (12:6)-(12:11)) + │ │ │ │ │ └── tag_closing: "%>" (location: (12:11)-(12:13)) + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ ├── @ HTMLTextNode (location: (12:13)-(14:4)) + │ │ │ │ │ └── content: "\n\n " + │ │ │ │ │ + │ │ │ │ ├── @ ERBBlockNode (location: (14:4)-(16:13)) + │ │ │ │ │ ├── tag_opening: "<%=" (location: (14:4)-(14:7)) + │ │ │ │ │ ├── content: " component :button, state: :primary do " (location: (14:7)-(14:46)) + │ │ │ │ │ ├── tag_closing: "%>" (location: (14:46)-(14:48)) + │ │ │ │ │ ├── body: (3 items) + │ │ │ │ │ │ ├── @ HTMLTextNode (location: (14:48)-(15:6)) + │ │ │ │ │ │ │ └── content: "\n " + │ │ │ │ │ │ │ + │ │ │ │ │ │ ├── @ ERBContentNode (location: (15:6)-(15:24)) + │ │ │ │ │ │ │ ├── tag_opening: "<%=" (location: (15:6)-(15:9)) + │ │ │ │ │ │ │ ├── content: " button_text " (location: (15:9)-(15:22)) + │ │ │ │ │ │ │ ├── tag_closing: "%>" (location: (15:22)-(15:24)) + │ │ │ │ │ │ │ ├── parsed: true + │ │ │ │ │ │ │ └── valid: true + │ │ │ │ │ │ │ + │ │ │ │ │ │ └── @ HTMLTextNode (location: (15:24)-(16:4)) + │ │ │ │ │ │ └── content: "\n " + │ │ │ │ │ │ + │ │ │ │ │ └── end_node: + │ │ │ │ │ └── @ ERBEndNode (location: (16:4)-(16:13)) + │ │ │ │ │ ├── tag_opening: "<%" (location: (16:4)-(16:6)) + │ │ │ │ │ ├── content: " end " (location: (16:6)-(16:11)) + │ │ │ │ │ └── tag_closing: "%>" (location: (16:11)-(16:13)) + │ │ │ │ │ + │ │ │ │ │ + │ │ │ │ └── @ HTMLTextNode (location: (16:13)-(17:2)) + │ │ │ │ └── content: "\n " + │ │ │ │ + │ │ │ ├── close_tag: + │ │ │ │ └── @ HTMLCloseTagNode (location: (17:2)-(17:8)) + │ │ │ │ ├── tag_opening: "" (location: (17:7)-(17:8)) + │ │ │ │ + │ │ │ ├── is_void: false + │ │ │ └── source: "HTML" + │ │ │ + │ │ └── @ HTMLTextNode (location: (17:8)-(18:0)) + │ │ └── content: "\n" + │ │ + │ └── end_node: + │ └── @ ERBEndNode (location: (18:0)-(18:9)) + │ ├── tag_opening: "<%" (location: (18:0)-(18:2)) + │ ├── content: " end " (location: (18:2)-(18:7)) + │ └── tag_closing: "%>" (location: (18:7)-(18:9)) + │ + │ + └── @ HTMLTextNode (location: (18:9)-(19:0)) + └── content: "\n" \ No newline at end of file From 9bfd9eef7f43f111f387e61f21a6d5ca222b9655 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 18 Nov 2025 07:15:23 -0800 Subject: [PATCH 2/4] Cleanup --- java/snapshots/test.ruby.txt | 8 ++++---- javascript/packages/node/binding.gyp | 1 + rust/tests/cli_commands_test.rs | 4 ++-- .../snapshots/snapshot_test__extract_ruby_output.snap | 8 ++++---- src/analyze.c | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/java/snapshots/test.ruby.txt b/java/snapshots/test.ruby.txt index 68172876b..348816426 100644 --- a/java/snapshots/test.ruby.txt +++ b/java/snapshots/test.ruby.txt @@ -1,12 +1,12 @@ - if valid? + if valid? ; - "Valid" + "Valid" ; - else + else ; - end + end ; diff --git a/javascript/packages/node/binding.gyp b/javascript/packages/node/binding.gyp index 4df3694f8..650762632 100644 --- a/javascript/packages/node/binding.gyp +++ b/javascript/packages/node/binding.gyp @@ -30,6 +30,7 @@ "./extension/libherb/parser_helpers.c", "./extension/libherb/parser_match_tags.c", "./extension/libherb/parser.c", + "./extension/libherb/position.c", "./extension/libherb/pretty_print.c", "./extension/libherb/prism_helpers.c", "./extension/libherb/range.c", diff --git a/rust/tests/cli_commands_test.rs b/rust/tests/cli_commands_test.rs index 01b601923..a0482ca93 100644 --- a/rust/tests/cli_commands_test.rs +++ b/rust/tests/cli_commands_test.rs @@ -12,7 +12,7 @@ fn test_version_functions() { fn test_extract_ruby() { let source = "
<%= name %>
"; let ruby = extract_ruby(source).unwrap(); - assert_eq!(ruby, " name "); + assert_eq!(ruby, " name ; "); } #[test] @@ -32,7 +32,7 @@ fn test_extract_ruby_complex() { let ruby = extract_ruby(source).unwrap(); assert_eq!( ruby, - " \n users.each do |user| \n user.name \n end \n " + " \n users.each do |user| ;\n user.name ; \n end ;\n " ); } diff --git a/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap b/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap index d8e79d9ff..80e63ccf4 100644 --- a/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap +++ b/rust/tests/snapshots/snapshot_test__extract_ruby_output.snap @@ -4,12 +4,12 @@ expression: result --- - if valid? + if valid? ; - "Valid" + "Valid" ; - else + else ; - end + end ; diff --git a/src/analyze.c b/src/analyze.c index 4e2070a6a..1d46a0474 100644 --- a/src/analyze.c +++ b/src/analyze.c @@ -1409,7 +1409,7 @@ void herb_analyze_parse_tree(AST_DOCUMENT_NODE_T* document, const char* source) free(invalid_context); } -static void parse_erb_content_errors(AST_NODE_T* erb_node, AST_DOCUMENT_NODE_T* document, const char* source) { +static void parse_erb_content_errors(AST_NODE_T* erb_node, const char* source) { if (!erb_node || erb_node->type != AST_ERB_CONTENT_NODE) { return; } AST_ERB_CONTENT_NODE_T* content_node = (AST_ERB_CONTENT_NODE_T*) erb_node; @@ -1458,7 +1458,7 @@ void herb_analyze_parse_errors(AST_DOCUMENT_NODE_T* document, const char* source if (error_offset >= strlen(source) || source[error_offset] != ';') { AST_NODE_T* erb_node = find_erb_content_at_offset(document, source, error_offset); - if (erb_node) { parse_erb_content_errors(erb_node, document, source); } + if (erb_node) { parse_erb_content_errors(erb_node, source); } continue; } From a114915a2394e35a54d12debfe83273cb5c1b379 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 18 Nov 2025 07:24:38 -0800 Subject: [PATCH 3/4] Rename herb_extract_ruby_to_buffer_with_semicolons to herb_extract_ruby_to_buffer --- src/extract.c | 86 +------------------------------------------ src/include/extract.h | 1 - 2 files changed, 1 insertion(+), 86 deletions(-) diff --git a/src/extract.c b/src/extract.c index 7e5f2450a..784cb3f9a 100644 --- a/src/extract.c +++ b/src/extract.c @@ -7,90 +7,6 @@ #include #include -void herb_extract_ruby_to_buffer_with_semicolons(const char* source, hb_buffer_T* output) { - hb_array_T* tokens = herb_lex(source); - bool skip_erb_content = false; - bool is_comment_tag = false; - - for (size_t i = 0; i < hb_array_size(tokens); i++) { - const token_T* token = hb_array_get(tokens, i); - - switch (token->type) { - case TOKEN_NEWLINE: { - hb_buffer_append(output, token->value); - break; - } - - case TOKEN_ERB_START: { - if (strcmp(token->value, "<%#") == 0) { - skip_erb_content = true; - is_comment_tag = true; - } else if (strcmp(token->value, "<%%") == 0 || strcmp(token->value, "<%%=") == 0) { - skip_erb_content = true; - is_comment_tag = false; - } else { - skip_erb_content = false; - is_comment_tag = false; - } - - hb_buffer_append_whitespace(output, range_length(token->range)); - break; - } - - case TOKEN_ERB_CONTENT: { - if (skip_erb_content == false) { - bool is_inline_comment = false; - - if (!is_comment_tag && token->value != NULL) { - const char* content = token->value; - - while (*content == ' ' || *content == '\t') { - content++; - } - - if (*content == '#' && token->location.start.line == token->location.end.line) { - is_comment_tag = true; - is_inline_comment = true; - } - } - - if (is_inline_comment) { - hb_buffer_append_whitespace(output, range_length(token->range)); - } else { - hb_buffer_append(output, token->value); - } - } else { - hb_buffer_append_whitespace(output, range_length(token->range)); - } - - break; - } - - case TOKEN_ERB_END: { - bool was_comment = is_comment_tag; - skip_erb_content = false; - is_comment_tag = false; - - if (was_comment) { - hb_buffer_append_whitespace(output, range_length(token->range)); - break; - } - - hb_buffer_append_char(output, ' '); - hb_buffer_append_char(output, ';'); - hb_buffer_append_whitespace(output, range_length(token->range) - 2); - break; - } - - default: { - hb_buffer_append_whitespace(output, range_length(token->range)); - } - } - } - - herb_free_tokens(&tokens); -} - void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { hb_array_T* tokens = herb_lex(source); bool skip_erb_content = false; @@ -198,7 +114,7 @@ char* herb_extract_ruby_with_semicolons(const char* source) { hb_buffer_T output; hb_buffer_init(&output, strlen(source)); - herb_extract_ruby_to_buffer_with_semicolons(source, &output); + herb_extract_ruby_to_buffer(source, &output); return output.value; } diff --git a/src/include/extract.h b/src/include/extract.h index 2f1704dcc..36f0b5028 100644 --- a/src/include/extract.h +++ b/src/include/extract.h @@ -12,7 +12,6 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output); void herb_extract_html_to_buffer(const char* source, hb_buffer_T* output); char* herb_extract_ruby_with_semicolons(const char* source); -void herb_extract_ruby_to_buffer_with_semicolons(const char* source, hb_buffer_T* output); char* herb_extract(const char* source, herb_extract_language_T language); char* herb_extract_from_file(const char* path, herb_extract_language_T language); From 7eba55b055cab04dab89c064603dfeeea9f25a4f Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 18 Nov 2025 07:36:25 -0800 Subject: [PATCH 4/4] Update tests --- javascript/packages/browser/test/browser.test.ts | 2 +- javascript/packages/linter/test/rules/parser-no-errors.test.ts | 1 - javascript/packages/node-wasm/test/node-wasm.test.ts | 2 +- javascript/packages/node/test/node.test.ts | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/javascript/packages/browser/test/browser.test.ts b/javascript/packages/browser/test/browser.test.ts index 79f8c5639..dccffddb4 100644 --- a/javascript/packages/browser/test/browser.test.ts +++ b/javascript/packages/browser/test/browser.test.ts @@ -34,7 +34,7 @@ describe("@herb-tools/browser", () => { const simpleHtml = '
<%= "Hello World" %>
' const ruby = Herb.extractRuby(simpleHtml) expect(ruby).toBeDefined() - expect(ruby).toBe(' "Hello World" ') + expect(ruby).toBe(' "Hello World" ; ') }) test("extractHTML() extracts HTML content", async () => { diff --git a/javascript/packages/linter/test/rules/parser-no-errors.test.ts b/javascript/packages/linter/test/rules/parser-no-errors.test.ts index e214756c4..cbff60c02 100644 --- a/javascript/packages/linter/test/rules/parser-no-errors.test.ts +++ b/javascript/packages/linter/test/rules/parser-no-errors.test.ts @@ -57,7 +57,6 @@ describe("ParserNoErrorsRule", () => { test("should report Ruby parse errors in ERB tags", () => { expectError("expect_expression_after_operator: unexpected end-of-input; expected an expression after the operator (`RUBY_PARSE_ERROR`)") - expectError("unexpected_token_close_context: unexpected end-of-input, assuming it is closing the parent top level context (`RUBY_PARSE_ERROR`)") assertOffenses(`<%= 1 + %>`) }) diff --git a/javascript/packages/node-wasm/test/node-wasm.test.ts b/javascript/packages/node-wasm/test/node-wasm.test.ts index 7bf27af98..f2551fd46 100644 --- a/javascript/packages/node-wasm/test/node-wasm.test.ts +++ b/javascript/packages/node-wasm/test/node-wasm.test.ts @@ -34,7 +34,7 @@ describe("@herb-tools/node-wasm", () => { const simpleHtml = '
<%= "Hello World" %>
' const ruby = Herb.extractRuby(simpleHtml) expect(ruby).toBeDefined() - expect(ruby).toBe(' "Hello World" ') + expect(ruby).toBe(' "Hello World" ; ') }) test("extractHTML() extracts HTML content", async () => { diff --git a/javascript/packages/node/test/node.test.ts b/javascript/packages/node/test/node.test.ts index 0f2a981ac..673bdb566 100644 --- a/javascript/packages/node/test/node.test.ts +++ b/javascript/packages/node/test/node.test.ts @@ -34,7 +34,7 @@ describe("@herb-tools/node", () => { const simpleHtml = '
<%= "Hello World" %>
' const ruby = Herb.extractRuby(simpleHtml) expect(ruby).toBeDefined() - expect(ruby).toBe(' "Hello World" ') + expect(ruby).toBe(' "Hello World" ; ') }) test("extractHTML() extracts HTML content", async () => {