Skip to content

Commit 0544f50

Browse files
authored
Parser: Support Action View render calls (#1385)
This pull request adds support for detecting ActionView `render` calls in ERB templates and representing them as structured `ERBRenderNode` nodes. A new `render_nodes` parser option is available to enable this analysis (defaults to `false`, since `render` may mean something different outside of Rails). When enabled, `ERBContentNodes` containing a render call are transformed into `ERBRenderNodes` with extracted fields that mirror ActionView's render keyword arguments. For example, the following template: ```erb <%= render partial: "card", locals: { title: @title, body: "Hello" } %> ``` Produces the following with `render_nodes: true`: ```js @ DocumentNode (location: (1:0)-(1:71)) └── children: (1 item) └── @ ERBRenderNode (location: (1:0)-(1:71)) ├── tag_opening: "<%=" (location: (1:0)-(1:3)) ├── content: " render partial: "card", locals: { title: @title, body: "Hello" } " (location: (1:3)-(1:69)) ├── tag_closing: "%>" (location: (1:69)-(1:71)) ├── partial: "card" (location: (1:20)-(1:26)) ├── template_path: ∅ ├── layout: ∅ ├── file: ∅ ├── inline_template: ∅ ├── body: ∅ ├── plain: ∅ ├── html: ∅ ├── renderable: ∅ ├── collection: ∅ ├── object: ∅ ├── as: ∅ ├── spacer_template: ∅ ├── formats: ∅ ├── variants: ∅ ├── handlers: ∅ ├── content_type: ∅ └── locals: (2 items) ├── @ RubyRenderLocalNode (location: (1:38)-(1:51)) │ ├── name: "title" (location: (1:38)-(1:44)) │ └── value: │ └── @ RubyLiteralNode (location: (1:45)-(1:51)) │ └── content: "@title" │ └── @ RubyRenderLocalNode (location: (1:53)-(1:66)) ├── name: "body" (location: (1:53)-(1:58)) └── value: └── @ RubyLiteralNode (location: (1:59)-(1:66)) └── content: "\"Hello\"" ``` Resolves #1373 Enables #160 Enables #181 Enables #182 Enables #639 Enables #654 Enables #1386
1 parent 3e20757 commit 0544f50

File tree

68 files changed

+2887
-9
lines changed

Some content is hidden

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

68 files changed

+2887
-9
lines changed

config.yml

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,80 @@ errors:
336336
- name: nested_tag_column
337337
type: size_t
338338
339+
- name: RenderAmbiguousLocalsError
340+
message:
341+
template: "Did you mean `render partial: '%s', locals: { ... }`? Using `render '%s', locals: { ... }` passes a local variable named `locals` to the partial instead of setting the `locals:` option."
342+
arguments:
343+
- partial
344+
- partial
345+
346+
fields:
347+
- name: partial
348+
type: string
349+
350+
- name: RenderMissingLocalsError
351+
message:
352+
template: "Wrap `%s` in `locals: { ... }` when using `partial:`. Use `render partial: '%s', locals: { %s }` instead. Without `locals:`, these keyword arguments are ignored."
353+
arguments:
354+
- keywords
355+
- partial
356+
- keywords
357+
358+
fields:
359+
- name: partial
360+
type: string
361+
362+
- name: keywords
363+
type: string
364+
365+
- name: RenderNoArgumentsError
366+
message:
367+
template: "No arguments passed to `render`. It needs a partial name, a keyword argument like `partial:`, `template:`, or `layout:`, or a renderable object."
368+
arguments: []
369+
370+
fields: []
371+
372+
- name: RenderConflictingPartialError
373+
message:
374+
template: "Both a positional partial `'%s'` and a keyword `partial: '%s'` were passed to `render`. Use one form or the other, not both."
375+
arguments:
376+
- positional_partial
377+
- keyword_partial
378+
379+
fields:
380+
- name: positional_partial
381+
type: string
382+
383+
- name: keyword_partial
384+
type: string
385+
386+
- name: RenderInvalidAsOptionError
387+
message:
388+
template: "The `as:` value `'%s'` is not a valid Ruby identifier. Use a name that starts with a lowercase letter or underscore, like `as: :item`."
389+
arguments:
390+
- as_value
391+
392+
fields:
393+
- name: as_value
394+
type: string
395+
396+
- name: RenderObjectAndCollectionError
397+
message:
398+
template: "The `object:` and `collection:` options are mutually exclusive. Use one or the other in your `render` call."
399+
arguments: []
400+
401+
fields: []
402+
403+
- name: RenderLayoutWithoutBlockError
404+
message:
405+
template: "Layout rendering needs a block. Use `render layout: '%s' do ... end` so the layout has content to wrap."
406+
arguments:
407+
- layout
408+
409+
fields:
410+
- name: layout
411+
type: string
412+
339413
warnings:
340414
fields: []
341415
types: []
@@ -1013,6 +1087,88 @@ nodes:
10131087
type: node
10141088
kind: ERBEndNode
10151089
1090+
- name: RubyRenderLocalNode
1091+
fields:
1092+
- name: name
1093+
type: token
1094+
1095+
- name: value
1096+
type: node
1097+
kind: RubyLiteralNode
1098+
1099+
- name: ERBRenderNode
1100+
fields:
1101+
- name: tag_opening
1102+
type: token
1103+
1104+
- name: content
1105+
type: token
1106+
1107+
- name: tag_closing
1108+
type: token
1109+
1110+
- name: analyzed_ruby
1111+
type: analyzed_ruby
1112+
1113+
- name: prism_node
1114+
type: prism_node
1115+
1116+
- name: partial
1117+
type: token
1118+
1119+
- name: template_path
1120+
type: token
1121+
1122+
- name: layout
1123+
type: token
1124+
1125+
- name: file
1126+
type: token
1127+
1128+
- name: inline_template
1129+
type: token
1130+
1131+
- name: body
1132+
type: token
1133+
1134+
- name: plain
1135+
type: token
1136+
1137+
- name: html
1138+
type: token
1139+
1140+
- name: renderable
1141+
type: token
1142+
1143+
- name: collection
1144+
type: token
1145+
1146+
- name: object
1147+
type: token
1148+
1149+
- name: as_name
1150+
type: token
1151+
1152+
- name: spacer_template
1153+
type: token
1154+
1155+
- name: formats
1156+
type: token
1157+
1158+
- name: variants
1159+
type: token
1160+
1161+
- name: handlers
1162+
type: token
1163+
1164+
- name: content_type
1165+
type: token
1166+
1167+
- name: locals
1168+
type: array
1169+
kind:
1170+
- RubyRenderLocalNode
1171+
10161172
- name: ERBYieldNode
10171173
fields:
10181174
- name: tag_opening

ext/herb/extension.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ static VALUE Herb_parse(int argc, VALUE* argv, VALUE self) {
136136
}
137137
if (!NIL_P(action_view_helpers) && RTEST(action_view_helpers)) { parser_options.action_view_helpers = true; }
138138

139+
VALUE render_nodes = rb_hash_lookup(options, rb_utf8_str_new_cstr("render_nodes"));
140+
if (NIL_P(render_nodes)) { render_nodes = rb_hash_lookup(options, ID2SYM(rb_intern("render_nodes"))); }
141+
if (!NIL_P(render_nodes) && RTEST(render_nodes)) { parser_options.render_nodes = true; }
142+
139143
VALUE prism_nodes = rb_hash_lookup(options, rb_utf8_str_new_cstr("prism_nodes"));
140144
if (NIL_P(prism_nodes)) { prism_nodes = rb_hash_lookup(options, ID2SYM(rb_intern("prism_nodes"))); }
141145
if (!NIL_P(prism_nodes) && RTEST(prism_nodes)) { parser_options.prism_nodes = true; }

ext/herb/extension_helpers.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ VALUE create_parse_result(AST_DOCUMENT_NODE_T* root, VALUE source, const parser_
8989
rb_hash_aset(kwargs, ID2SYM(rb_intern("track_whitespace")), options->track_whitespace ? Qtrue : Qfalse);
9090
rb_hash_aset(kwargs, ID2SYM(rb_intern("analyze")), options->analyze ? Qtrue : Qfalse);
9191
rb_hash_aset(kwargs, ID2SYM(rb_intern("action_view_helpers")), options->action_view_helpers ? Qtrue : Qfalse);
92+
rb_hash_aset(kwargs, ID2SYM(rb_intern("render_nodes")), options->render_nodes ? Qtrue : Qfalse);
9293
rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes")), options->prism_nodes ? Qtrue : Qfalse);
9394
rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes_deep")), options->prism_nodes_deep ? Qtrue : Qfalse);
9495
rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_program")), options->prism_program ? Qtrue : Qfalse);

java/herb_jni.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject opti
6969
parser_options.action_view_helpers = (actionViewHelpers == JNI_TRUE);
7070
}
7171

72+
jmethodID getRenderNodes =
73+
(*env)->GetMethodID(env, optionsClass, "isRenderNodes", "()Z");
74+
75+
if (getRenderNodes != NULL) {
76+
jboolean renderNodes = (*env)->CallBooleanMethod(env, options, getRenderNodes);
77+
parser_options.render_nodes = (renderNodes == JNI_TRUE);
78+
}
79+
7280
jmethodID getPrismNodes =
7381
(*env)->GetMethodID(env, optionsClass, "isPrismNodes", "()Z");
7482

java/org/herb/ParserOptions.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public class ParserOptions {
55
private boolean analyze = true;
66
private boolean strict = true;
77
private boolean actionViewHelpers = false;
8+
private boolean renderNodes = false;
89
private boolean prismNodes = false;
910
private boolean prismNodesDeep = false;
1011
private boolean prismProgram = false;
@@ -47,6 +48,15 @@ public boolean isActionViewHelpers() {
4748
return actionViewHelpers;
4849
}
4950

51+
public ParserOptions renderNodes(boolean value) {
52+
this.renderNodes = value;
53+
return this;
54+
}
55+
56+
public boolean isRenderNodes() {
57+
return renderNodes;
58+
}
59+
5060
public ParserOptions prismNodes(boolean value) {
5161
this.prismNodes = value;
5262
return this;

javascript/packages/core/src/parser-options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface ParseOptions {
33
analyze?: boolean
44
strict?: boolean
55
action_view_helpers?: boolean
6+
render_nodes?: boolean
67
prism_nodes?: boolean
78
prism_nodes_deep?: boolean
89
prism_program?: boolean
@@ -15,6 +16,7 @@ export const DEFAULT_PARSER_OPTIONS: SerializedParserOptions = {
1516
analyze: true,
1617
strict: true,
1718
action_view_helpers: false,
19+
render_nodes: false,
1820
prism_nodes: false,
1921
prism_nodes_deep: false,
2022
prism_program: false,
@@ -36,6 +38,9 @@ export class ParserOptions {
3638
/** Whether ActionView tag helper transformation was enabled during parsing. */
3739
readonly action_view_helpers: boolean
3840

41+
/** Whether ActionView render call detection was enabled during parsing. */
42+
readonly render_nodes: boolean
43+
3944
/** Whether Prism node serialization was enabled during parsing. */
4045
readonly prism_nodes: boolean
4146

@@ -54,6 +59,7 @@ export class ParserOptions {
5459
this.track_whitespace = options.track_whitespace ?? DEFAULT_PARSER_OPTIONS.track_whitespace
5560
this.analyze = options.analyze ?? DEFAULT_PARSER_OPTIONS.analyze
5661
this.action_view_helpers = options.action_view_helpers ?? DEFAULT_PARSER_OPTIONS.action_view_helpers
62+
this.render_nodes = options.render_nodes ?? DEFAULT_PARSER_OPTIONS.render_nodes
5763
this.prism_nodes = options.prism_nodes ?? DEFAULT_PARSER_OPTIONS.prism_nodes
5864
this.prism_nodes_deep = options.prism_nodes_deep ?? DEFAULT_PARSER_OPTIONS.prism_nodes_deep
5965
this.prism_program = options.prism_program ?? DEFAULT_PARSER_OPTIONS.prism_program

javascript/packages/formatter/src/format-printer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ import {
8787
ERBUnlessNode,
8888
ERBYieldNode,
8989
ERBInNode,
90+
ERBRenderNode,
91+
RubyRenderLocalNode,
9092
ERBOpenTagNode,
9193
HTMLVirtualCloseTagNode,
9294
XMLDeclarationNode,
@@ -1019,6 +1021,14 @@ export class FormatPrinter extends Printer implements TextFlowDelegate, Attribut
10191021
this.printERBNode(node)
10201022
}
10211023

1024+
visitERBRenderNode(node: ERBRenderNode) {
1025+
this.printERBNode(node)
1026+
}
1027+
1028+
visitRubyRenderLocalNode(_node: RubyRenderLocalNode) {
1029+
// extracted metadata, nothing to print
1030+
}
1031+
10221032
visitERBYieldNode(node: ERBYieldNode) {
10231033
this.trackBoundary(node, () => {
10241034
this.printERBNode(node)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, test, beforeAll } from "vitest"
2+
import { Herb } from "@herb-tools/node-wasm"
3+
import { Formatter } from "../../src"
4+
import { createExpectFormattedToMatch } from "../helpers.js"
5+
6+
import dedent from "dedent"
7+
8+
let formatter: Formatter
9+
let expectFormattedToMatch: ReturnType<typeof createExpectFormattedToMatch>
10+
11+
describe("@herb-tools/formatter - Render Nodes", () => {
12+
beforeAll(async () => {
13+
await Herb.load()
14+
15+
formatter = new Formatter(Herb, {
16+
indentWidth: 2,
17+
maxLineLength: 80,
18+
}, {
19+
render_nodes: true,
20+
})
21+
22+
expectFormattedToMatch = createExpectFormattedToMatch(formatter)
23+
})
24+
25+
test("render partial string preserves source", () => {
26+
expectFormattedToMatch(`<%= render "card" %>`)
27+
})
28+
29+
test("render with keyword partial preserves source", () => {
30+
expectFormattedToMatch(`<%= render partial: "card" %>`)
31+
})
32+
33+
test("render with locals preserves source", () => {
34+
expectFormattedToMatch(`<%= render partial: "card", locals: { title: @title } %>`)
35+
})
36+
37+
test("render with implicit locals preserves source", () => {
38+
expectFormattedToMatch(`<%= render "card", title: @title, body: "Hello" %>`)
39+
})
40+
41+
test("render with collection preserves source", () => {
42+
expectFormattedToMatch(`<%= render partial: "product", collection: @products %>`)
43+
})
44+
45+
test("render object preserves source", () => {
46+
expectFormattedToMatch(`<%= render @product %>`)
47+
})
48+
49+
test("render inside HTML element", () => {
50+
expectFormattedToMatch(dedent`
51+
<div>
52+
<%= render "card" %>
53+
</div>
54+
`)
55+
})
56+
})

javascript/packages/linter/test/parse-cache.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe("ParseCache", () => {
7171
prism_nodes: false,
7272
prism_nodes_deep: false,
7373
prism_program: false,
74+
render_nodes: false,
7475
strict: true,
7576
action_view_helpers: false,
7677
})
@@ -86,6 +87,7 @@ describe("ParseCache", () => {
8687
prism_nodes: false,
8788
prism_nodes_deep: false,
8889
prism_program: false,
90+
render_nodes: false,
8991
strict: false,
9092
action_view_helpers: false,
9193
})
@@ -101,6 +103,7 @@ describe("ParseCache", () => {
101103
prism_nodes: false,
102104
prism_nodes_deep: false,
103105
prism_program: false,
106+
render_nodes: false,
104107
strict: true,
105108
action_view_helpers: false,
106109
})
@@ -116,6 +119,7 @@ describe("ParseCache", () => {
116119
prism_nodes: false,
117120
prism_nodes_deep: false,
118121
prism_program: false,
122+
render_nodes: false,
119123
strict: false,
120124
action_view_helpers: false,
121125
})

javascript/packages/node/binding.gyp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"./extension/libherb/analyze/missing_end.c",
2222
"./extension/libherb/analyze/parse_errors.c",
2323
"./extension/libherb/analyze/prism_annotate.c",
24+
"./extension/libherb/analyze/render_nodes.c",
2425
"./extension/libherb/analyze/transform.c",
2526
"./extension/libherb/analyze/action_view/attribute_extraction_helpers.c",
2627
"./extension/libherb/analyze/action_view/content_tag.c",

0 commit comments

Comments
 (0)