Skip to content

Commit a21d406

Browse files
authored
Parser: Handle more Action View helpers transformations cases (#1465)
This pull request improves how the parser handles several Action View helper patterns to more closely match Rails' runtime behavior. The attribute extraction now handles static string and symbol arrays in class attributes by joining them into space-separated values, matching how Rails renders `class: ["kitties", "puppies"]` as `class="kitties puppies"`. Mixed arrays containing variables or conditional hashes are wrapped in `token_list(...)` instead, so the output evaluates correctly at runtime. For example, `class: { active: true, hidden: false }` now produces `class="<%= token_list({ active: true, hidden: false }) %>"` rather than leaving the hash as a raw Ruby literal. For `data:` and `aria:` hash values, non-string types are now wrapped in `.to_json` to match Rails' `prefix_tag_option` behavior, while integers and symbols are inlined directly as literal values. Additionally, Ruby introspection methods like `tag.send`, `tag.class`, `tag.method`, and methods ending in `?` or `!` are no longer treated as tag names. These are recognized as dynamic calls with `tag_name: null`, following the same pattern used by `content_tag` with a variable tag name. The `escape` keyword argument is now filtered from extracted attributes, matching Rails which consumes it internally and does not render it as an HTML attribute. The `search_tag_helper_node` function was also fixed to correctly find the outermost matching call when tag helpers are nested as arguments, such as `content_tag(:div, content_tag(:p, "Hello world!"))`.
1 parent 8ebb067 commit a21d406

File tree

43 files changed

+1415
-17
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1415
-17
lines changed

bin/actionview-render

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class ActionViewRenderer
6767
end
6868

6969
def render_template
70+
require "bigdecimal"
7071
require "action_view"
7172

7273
lookup_context = ActionView::LookupContext.new([])

javascript/packages/language-service/test/parse-html-document.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ describe("parseHTMLDocument", () => {
3434
expect((root.herbNode as any).element_source).toBe("ActionView::Helpers::TagHelper#tag")
3535
})
3636

37+
test("parses ActionView tag.img with content argument as void element", () => {
38+
const service = createService()
39+
const document = createDocument('<%= tag.img "/image.png" %>')
40+
const html = service.parseHTMLDocument(document)
41+
42+
expect(html.roots).toHaveLength(1)
43+
44+
const root = html.roots[0] as HerbHTMLNode
45+
expect(root.tag).toBe("img")
46+
expect(root.herbNode).toBeDefined()
47+
expect((root.herbNode as any).element_source).toBe("ActionView::Helpers::TagHelper#tag")
48+
expect((root.herbNode as any).is_void).toBe(true)
49+
})
50+
51+
test("parses ActionView tag.img with content argument and data attributes", () => {
52+
const service = createService()
53+
const document = createDocument('<%= tag.img "/image.png", data: { controller: "image" } %>')
54+
const html = service.parseHTMLDocument(document)
55+
56+
expect(html.roots).toHaveLength(1)
57+
58+
const root = html.roots[0] as HerbHTMLNode
59+
expect(root.tag).toBe("img")
60+
expect(root.attributes?.["data-controller"]).toBe('"image"')
61+
})
62+
3763
test("parses ActionView nested data hash into data-* attributes", () => {
3864
const service = createService()
3965
const document = createDocument(`<%= tag.div data: { controller: "scroll", action: "click->scroll#go" } %>`)

javascript/packages/node/binding.gyp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"./extension/libherb/prism/ruby_parser.c",
6565
"./extension/libherb/util/html_util.c",
6666
"./extension/libherb/util/io.c",
67+
"./extension/libherb/util/ruby_util.c",
6768
"./extension/libherb/util/utf8.c",
6869
"./extension/libherb/util/util.c",
6970
"./extension/libherb/visitor.c",

javascript/packages/rewriter/test/action-view-tag-helper-to-html.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
143143
`
144144

145145
const expected = dedent`
146-
<div data-controller="content" data-user-id="<%= 123 %>">
146+
<div data-controller="content" data-user-id="123">
147147
Content
148148
</div>
149149
`
@@ -167,6 +167,18 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
167167
)
168168
})
169169

170+
test("tag.img with content argument reports parser error", () => {
171+
expect(() => transform('<%= tag.img "/image.png" %>')).toThrow(
172+
"Void element `img` cannot have content"
173+
)
174+
})
175+
176+
test("tag.img with content argument and data attributes reports parser error", () => {
177+
expect(() => transform('<%= tag.img "/image.png", data: { controller: "image" } %>')).toThrow(
178+
"Void element `img` cannot have content"
179+
)
180+
})
181+
170182
test("tag.details with inline block", () => {
171183
expect(transform('<%= tag.details { "Some content" } %>')).toBe(
172184
"<details>Some content</details>"
@@ -684,6 +696,64 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
684696
})
685697
})
686698

699+
describe("class attribute handling", () => {
700+
test("tag.div with conditional class hash wraps in token_list", () => {
701+
expect(transform('<%= tag.div class: { active: true, hidden: false } %>')).toBe(
702+
'<div class="<%= token_list({ active: true, hidden: false }) %>"></div>'
703+
)
704+
})
705+
706+
test("tag.div with dynamic conditional class hash wraps in token_list", () => {
707+
expect(transform('<%= tag.div class: { active: @is_active } %>')).toBe(
708+
'<div class="<%= token_list({ active: @is_active }) %>"></div>'
709+
)
710+
})
711+
712+
test("tag.div with mixed array class wraps in token_list", () => {
713+
expect(transform('<%= tag.div class: ["a", variable] %>')).toBe(
714+
'<div class="<%= token_list(["a", variable]) %>"></div>'
715+
)
716+
})
717+
718+
test("tag.div with static string array class joins with spaces", () => {
719+
expect(transform('<%= tag.div class: ["kitties", "puppies"] %>')).toBe(
720+
'<div class="kitties puppies"></div>'
721+
)
722+
})
723+
724+
test("tag.div with %w() class joins with spaces", () => {
725+
expect(transform('<%= tag.div class: %w( kitties puppies ) %>')).toBe(
726+
'<div class="kitties puppies"></div>'
727+
)
728+
})
729+
730+
test("content_tag with mixed array and conditional hash class wraps in token_list", () => {
731+
expect(transform('<%= content_tag(:div, "Hello world!", class: ["strong", { highlight: current_user.admin? }]) %>')).toBe(
732+
'<div class="<%= token_list(["strong", { highlight: current_user.admin? }]) %>">Hello world!</div>'
733+
)
734+
})
735+
})
736+
737+
describe("data attribute handling", () => {
738+
test("tag.div with integer data attribute inlines directly", () => {
739+
expect(transform('<%= tag.div data: { count: 42 } %>')).toBe(
740+
'<div data-count="42"></div>'
741+
)
742+
})
743+
744+
test("tag.div with array data attribute wraps in .to_json", () => {
745+
expect(transform('<%= tag.div data: { items: ["a", "b"] } %>')).toBe(
746+
'<div data-items="<%= ["a", "b"].to_json %>"></div>'
747+
)
748+
})
749+
750+
test("tag.div with nested hash data attribute wraps in .to_json", () => {
751+
expect(transform('<%= tag.div data: { config: { nested: "hash" } } %>')).toBe(
752+
'<div data-config="<%= { nested: "hash" }.to_json %>"></div>'
753+
)
754+
})
755+
})
756+
687757
describe("tag.attributes", () => {
688758
test("tag.attributes extracts attributes into parent element", () => {
689759
expect(transform('<input <%= tag.attributes(type: :text, aria: { label: "Search" }) %>>')).toBe(

src/analyze/action_view/attribute_extraction_helpers.c

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "../../include/analyze/action_view/tag_helper_node_builders.h"
33
#include "../../include/lib/hb_allocator.h"
44
#include "../../include/lib/hb_array.h"
5+
#include "../../include/lib/hb_buffer.h"
56
#include "../../include/lib/hb_string.h"
67
#include "../../include/util/html_util.h"
78
#include "../../include/util/util.h"
@@ -208,12 +209,56 @@ static char* build_prefixed_key(const char* prefix, const char* raw_key, hb_allo
208209
return result;
209210
}
210211

212+
static bool is_static_string_array(pm_array_node_t* array) {
213+
if (!array || array->elements.size == 0) { return false; }
214+
215+
for (size_t i = 0; i < array->elements.size; i++) {
216+
pm_node_t* element = array->elements.nodes[i];
217+
218+
if (element->type != PM_STRING_NODE && element->type != PM_SYMBOL_NODE) { return false; }
219+
}
220+
221+
return true;
222+
}
223+
224+
static char* join_static_string_array(pm_array_node_t* array, hb_allocator_T* allocator) {
225+
hb_buffer_T buffer;
226+
hb_buffer_init(&buffer, 64, allocator);
227+
228+
for (size_t i = 0; i < array->elements.size; i++) {
229+
if (i > 0) { hb_buffer_append(&buffer, " "); }
230+
231+
char* value = extract_string_from_prism_node(array->elements.nodes[i], allocator);
232+
233+
if (value) {
234+
hb_buffer_append(&buffer, value);
235+
hb_allocator_dealloc(allocator, value);
236+
}
237+
}
238+
239+
char* result = hb_allocator_strdup(allocator, hb_buffer_value(&buffer));
240+
241+
return result;
242+
}
243+
211244
static AST_HTML_ATTRIBUTE_NODE_T* create_attribute_from_value(
212245
const char* name_string,
213246
pm_node_t* value_node,
214247
attribute_positions_T* positions,
215-
hb_allocator_T* allocator
248+
hb_allocator_T* allocator,
249+
bool is_nested
216250
) {
251+
if (value_node->type == PM_ARRAY_NODE && !is_nested && is_static_string_array((pm_array_node_t*) value_node)) {
252+
char* joined = join_static_string_array((pm_array_node_t*) value_node, allocator);
253+
if (!joined) { return NULL; }
254+
255+
AST_HTML_ATTRIBUTE_NODE_T* attribute =
256+
create_html_attribute_node_precise(name_string, joined, positions, allocator);
257+
hb_allocator_dealloc(allocator, joined);
258+
259+
return attribute;
260+
}
261+
217262
if (value_node->type == PM_SYMBOL_NODE || value_node->type == PM_STRING_NODE) {
218263
char* value_string = extract_string_from_prism_node(value_node, allocator);
219264
if (!value_string) { return NULL; }
@@ -227,10 +272,22 @@ static AST_HTML_ATTRIBUTE_NODE_T* create_attribute_from_value(
227272
if (is_boolean_attribute(hb_string((char*) name_string))) {
228273
return create_html_attribute_node_precise(name_string, NULL, positions, allocator);
229274
}
275+
230276
return create_html_attribute_node_precise(name_string, "true", positions, allocator);
231277
} else if (value_node->type == PM_FALSE_NODE) {
232278
if (is_boolean_attribute(hb_string((char*) name_string))) { return NULL; }
279+
233280
return create_html_attribute_node_precise(name_string, "false", positions, allocator);
281+
} else if (value_node->type == PM_INTEGER_NODE) {
282+
size_t value_length = value_node->location.end - value_node->location.start;
283+
char* value_string = hb_allocator_strndup(allocator, (const char*) value_node->location.start, value_length);
284+
if (!value_string) { return NULL; }
285+
286+
AST_HTML_ATTRIBUTE_NODE_T* attribute =
287+
create_html_attribute_node_precise(name_string, value_string, positions, allocator);
288+
hb_allocator_dealloc(allocator, value_string);
289+
290+
return attribute;
234291
} else if (value_node->type == PM_INTERPOLATED_STRING_NODE) {
235292
return create_html_attribute_with_interpolated_value(
236293
name_string,
@@ -241,12 +298,36 @@ static AST_HTML_ATTRIBUTE_NODE_T* create_attribute_from_value(
241298
);
242299
} else {
243300
size_t value_length = value_node->location.end - value_node->location.start;
244-
char* ruby_content = hb_allocator_strndup(allocator, (const char*) value_node->location.start, value_length);
301+
char* raw_content = hb_allocator_strndup(allocator, (const char*) value_node->location.start, value_length);
302+
303+
if (raw_content && value_node->location.start) {
304+
char* ruby_content = raw_content;
305+
306+
if (!is_nested && strcmp(name_string, "class") == 0
307+
&& (value_node->type == PM_HASH_NODE || value_node->type == PM_ARRAY_NODE)) {
308+
hb_buffer_T class_buffer;
309+
hb_buffer_init(&class_buffer, value_length + 16, allocator);
310+
hb_buffer_append(&class_buffer, "token_list(");
311+
hb_buffer_append(&class_buffer, raw_content);
312+
hb_buffer_append(&class_buffer, ")");
313+
314+
ruby_content = hb_buffer_value(&class_buffer);
315+
}
316+
317+
// Rails calls .to_json on non-string/symbol values inside data:/aria: hashes
318+
if (is_nested) {
319+
hb_buffer_T json_buffer;
320+
hb_buffer_init(&json_buffer, value_length + 16, allocator);
321+
hb_buffer_append(&json_buffer, raw_content);
322+
hb_buffer_append(&json_buffer, ".to_json");
323+
324+
ruby_content = hb_buffer_value(&json_buffer);
325+
}
245326

246-
if (ruby_content && value_node->location.start) {
247327
AST_HTML_ATTRIBUTE_NODE_T* attribute =
248328
create_html_attribute_with_ruby_literal_precise(name_string, ruby_content, positions, allocator);
249-
hb_allocator_dealloc(allocator, ruby_content);
329+
hb_allocator_dealloc(allocator, raw_content);
330+
250331
return attribute;
251332
}
252333

@@ -318,6 +399,11 @@ AST_HTML_ATTRIBUTE_NODE_T* extract_html_attribute_from_assoc(
318399
char* name_string = extract_string_from_prism_node(assoc->key, allocator);
319400
if (!name_string) { return NULL; }
320401

402+
if (strcmp(name_string, "escape") == 0) {
403+
hb_allocator_dealloc(allocator, name_string);
404+
return NULL;
405+
}
406+
321407
if (!assoc->value) {
322408
hb_allocator_dealloc(allocator, name_string);
323409
return NULL;
@@ -365,7 +451,7 @@ AST_HTML_ATTRIBUTE_NODE_T* extract_html_attribute_from_assoc(
365451

366452
char* dashed_name = convert_underscores_to_dashes(name_string);
367453
AST_HTML_ATTRIBUTE_NODE_T* attribute_node =
368-
create_attribute_from_value(dashed_name ? dashed_name : name_string, assoc->value, &positions, allocator);
454+
create_attribute_from_value(dashed_name ? dashed_name : name_string, assoc->value, &positions, allocator, false);
369455

370456
if (dashed_name) { free(dashed_name); }
371457
hb_allocator_dealloc(allocator, name_string);
@@ -467,7 +553,7 @@ hb_array_T* extract_html_attributes_from_keyword_hash(
467553
fill_attribute_positions(hash_assoc, source, original_source, erb_content_offset, &hash_positions);
468554

469555
AST_HTML_ATTRIBUTE_NODE_T* attribute =
470-
create_attribute_from_value(attribute_key_string, hash_assoc->value, &hash_positions, allocator);
556+
create_attribute_from_value(attribute_key_string, hash_assoc->value, &hash_positions, allocator, true);
471557

472558
if (attribute) { hb_array_append(attributes, attribute); }
473559
hb_allocator_dealloc(allocator, attribute_key_string);

src/analyze/action_view/tag.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "../../include/analyze/action_view/tag_helper_handler.h"
2+
#include "../../include/util/ruby_util.h"
23

34
#include <prism.h>
45
#include <stdbool.h>
@@ -23,6 +24,10 @@ char* extract_tag_dot_name(pm_call_node_t* call_node, pm_parser_t* parser, hb_al
2324
pm_constant_t* constant = pm_constant_pool_id_to_constant(&parser->constant_pool, call_node->name);
2425
if (!constant) { return NULL; }
2526

27+
if (is_ruby_introspection_method(hb_string_from_data((const char*) constant->start, constant->length))) {
28+
return NULL;
29+
}
30+
2631
char* name = hb_allocator_strndup(allocator, (const char*) constant->start, constant->length);
2732

2833
for (size_t i = 0; i < constant->length && name[i] != '\0'; i++) {

src/analyze/action_view/tag_helpers.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ bool search_tag_helper_node(const pm_node_t* node, void* data) {
132132
search_data->info->has_block = handlers[i].supports_block();
133133
}
134134

135-
return true;
135+
return false;
136136
}
137137
}
138138

src/include/util/ruby_util.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#ifndef HERB_RUBY_UTIL_H
2+
#define HERB_RUBY_UTIL_H
3+
4+
#include "../lib/hb_string.h"
5+
#include <stdbool.h>
6+
7+
bool is_ruby_introspection_method(hb_string_T method_name);
8+
9+
#endif

src/util/ruby_util.c

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#include "../include/util/ruby_util.h"
2+
#include "../include/lib/hb_string.h"
3+
4+
#include <stdbool.h>
5+
#include <stddef.h>
6+
7+
static hb_string_T ruby_introspection_methods[] = HB_STRING_LIST(
8+
"send",
9+
"public_send",
10+
"__send__",
11+
"try",
12+
"try!",
13+
"method",
14+
"class",
15+
"inspect",
16+
"to_s",
17+
"object_id",
18+
"__id__",
19+
"dup",
20+
"clone",
21+
"freeze",
22+
"frozen",
23+
"tap",
24+
"then",
25+
"yield_self"
26+
);
27+
28+
bool is_ruby_introspection_method(hb_string_T method_name) {
29+
size_t count = sizeof(ruby_introspection_methods) / sizeof(ruby_introspection_methods[0]);
30+
31+
for (size_t index = 0; index < count; index++) {
32+
if (hb_string_equals(method_name, ruby_introspection_methods[index])) { return true; }
33+
}
34+
35+
if (method_name.length > 0) {
36+
char last_char = method_name.data[method_name.length - 1];
37+
38+
if (last_char == '?' || last_char == '!') { return true; }
39+
}
40+
41+
return false;
42+
}

0 commit comments

Comments
 (0)