jsx_rosetta 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -0
- data/README.md +69 -7
- data/lib/jsx_rosetta/ast/inflector.rb +1 -0
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +756 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +54 -12
- data/lib/jsx_rosetta/backend/view_component.rb +169 -69
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +631 -104
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +148 -0
- data/lib/jsx_rosetta/ir/types.rb +77 -6
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +3 -1
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "types"
|
|
4
|
+
require_relative "module_shape_classifier"
|
|
4
5
|
|
|
5
6
|
module JsxRosetta
|
|
6
7
|
module IR
|
|
7
8
|
# Lowers a parsed AST::File into an IR::Component tree.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
# -
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# -
|
|
18
|
-
# IR::
|
|
19
|
-
#
|
|
20
|
-
#
|
|
10
|
+
# Responsibilities:
|
|
11
|
+
# - Component discovery — find function/arrow declarations whose
|
|
12
|
+
# name and body shape qualify as a function component.
|
|
13
|
+
# - Module-shape classification — when no component is found,
|
|
14
|
+
# produce a triage-friendly error message via SHAPE_MESSAGES.
|
|
15
|
+
# - Function-body lowering — turn return-bearing block statements,
|
|
16
|
+
# if-chains, switch/try statements, and bare expression returns
|
|
17
|
+
# into IR values (Conditional, Interpolation, Text, etc.).
|
|
18
|
+
# - JSX-node lowering — turn JSXElement / JSXFragment / JSXText /
|
|
19
|
+
# JSXExpressionContainer trees into IR::Element / Fragment /
|
|
20
|
+
# ComponentInvocation / Conditional / Loop / etc.
|
|
21
|
+
# - Pattern recognition — `cn()` / `clsx()` className helpers,
|
|
22
|
+
# `items.map(...)` loops, `cond ? <A/> : <B/>` polymorphic tags,
|
|
23
|
+
# React-hook calls, and onX={...} handlers promotable to
|
|
24
|
+
# Stimulus methods.
|
|
21
25
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# - {cond && X}, {cond ? X : null}, and {cond ? X : Y} lower to
|
|
25
|
-
# IR::Conditional. Other LogicalExpression operators (||, ??) are
|
|
26
|
-
# left as opaque interpolations.
|
|
26
|
+
# Anything outside these patterns is preserved verbatim as a TODO
|
|
27
|
+
# so the human reviewer sees the original JS at the right spot.
|
|
27
28
|
class Lowering
|
|
28
29
|
# A failure during AST → IR lowering. Carries optional line/column
|
|
29
30
|
# information when the failure can be tied to an AST node.
|
|
@@ -66,13 +67,48 @@ module JsxRosetta
|
|
|
66
67
|
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
67
68
|
].freeze
|
|
68
69
|
|
|
70
|
+
JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
|
|
71
|
+
|
|
72
|
+
# Pre-lowering AST scan: maps a node type to a callable returning the
|
|
73
|
+
# AST nodes that contribute return values. Used by body_returns_jsx?.
|
|
74
|
+
JSX_RETURN_PROBES = {
|
|
75
|
+
"ReturnStatement" => ->(n) { [n[:argument]] },
|
|
76
|
+
"BlockStatement" => ->(n) { n[:body] },
|
|
77
|
+
"IfStatement" => ->(n) { [n[:consequent], n[:alternate]] },
|
|
78
|
+
"TryStatement" => ->(n) { [n[:block]] },
|
|
79
|
+
"ConditionalExpression" => ->(n) { [n[:consequent], n[:alternate]] },
|
|
80
|
+
"LogicalExpression" => ->(n) { [n[:left], n[:right]] }
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
SHAPE_MESSAGES = {
|
|
84
|
+
hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
|
|
85
|
+
"this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
|
|
86
|
+
class_component: "looks like a class component — this version translates only function components " \
|
|
87
|
+
"(rewrite as a function or wait for class-component support)",
|
|
88
|
+
hooks_only: "looks like a custom-hooks module — hooks encode behavior and state, not view markup; " \
|
|
89
|
+
"translate behavior to a Stimulus controller and state to server-rendered ivars",
|
|
90
|
+
columns_data: "looks like a data export (top-level array literal) — not a component; " \
|
|
91
|
+
"data lives in the model or a presenter, not a ViewComponent",
|
|
92
|
+
types_only: "looks like a types/constants module — no functions to translate; " \
|
|
93
|
+
"TypeScript types erase, and Ruby constants belong elsewhere",
|
|
94
|
+
side_effects_only: "looks like a side-effect-only module (top-level calls, no exported functions) — " \
|
|
95
|
+
"register the equivalent setup in a Rails initializer instead",
|
|
96
|
+
utils_only: "looks like a utility module — only function components and JSX-returning helpers translate; " \
|
|
97
|
+
"pure-data helpers don't have a ViewComponent equivalent",
|
|
98
|
+
mixed_exports: "module mixes shapes (utilities + hooks + types + non-JSX helpers) — " \
|
|
99
|
+
"split into separate files so each module has a single shape",
|
|
100
|
+
unknown: nil
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
69
103
|
def initialize(source)
|
|
70
104
|
@source = source
|
|
71
105
|
@prop_names = []
|
|
72
106
|
@local_jsx = {}
|
|
73
107
|
@local_bindings = []
|
|
108
|
+
@local_binding_names = []
|
|
74
109
|
@local_arrows = {}
|
|
75
110
|
@local_polymorphic_tags = {}
|
|
111
|
+
@local_destructures = {}
|
|
76
112
|
@stimulus_methods = []
|
|
77
113
|
@stimulus_seen_names = {}
|
|
78
114
|
@react_hooks = []
|
|
@@ -80,17 +116,69 @@ module JsxRosetta
|
|
|
80
116
|
|
|
81
117
|
def lower_file(file)
|
|
82
118
|
candidates = find_component_functions(file.program)
|
|
83
|
-
raise
|
|
119
|
+
raise no_component_error(file.program) if candidates.empty?
|
|
84
120
|
|
|
85
121
|
name, function = candidates.first
|
|
86
|
-
|
|
122
|
+
module_bindings = capture_module_bindings(file.program, candidates)
|
|
123
|
+
attach_module_bindings(lower_component(name, function), module_bindings)
|
|
87
124
|
end
|
|
88
125
|
|
|
89
126
|
def lower_all_components(file)
|
|
90
127
|
candidates = find_component_functions(file.program)
|
|
91
|
-
raise
|
|
128
|
+
raise no_component_error(file.program) if candidates.empty?
|
|
129
|
+
|
|
130
|
+
module_bindings = capture_module_bindings(file.program, candidates)
|
|
131
|
+
candidates.map do |name, function|
|
|
132
|
+
attach_module_bindings(lower_component(name, function), module_bindings)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Walk the program body for top-level `const`/`let` declarations that
|
|
137
|
+
# aren't component declarations. Capture each as a LocalBinding so
|
|
138
|
+
# backends can emit them as Ruby constants (or as a TODO comment for
|
|
139
|
+
# non-literal initializers) before the class definition. Without
|
|
140
|
+
# this, `const FOO = 400; function X() { return <p>{FOO}</p> }` would
|
|
141
|
+
# silently drop the FOO declaration and emit an unbacked `foo`
|
|
142
|
+
# reference inside the view template.
|
|
143
|
+
def capture_module_bindings(program, candidates)
|
|
144
|
+
component_names = candidates.to_set(&:first)
|
|
145
|
+
bindings = []
|
|
146
|
+
program.body.each do |stmt|
|
|
147
|
+
walk_module_binding(stmt, component_names, bindings)
|
|
148
|
+
end
|
|
149
|
+
bindings
|
|
150
|
+
end
|
|
92
151
|
|
|
93
|
-
|
|
152
|
+
def walk_module_binding(stmt, component_names, bindings)
|
|
153
|
+
case stmt.type
|
|
154
|
+
when "VariableDeclaration"
|
|
155
|
+
stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
|
|
156
|
+
when "ExportNamedDeclaration"
|
|
157
|
+
decl = stmt[:declaration]
|
|
158
|
+
walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def record_module_binding(stmt, declarator, component_names, bindings)
|
|
163
|
+
init = declarator[:init]
|
|
164
|
+
return unless init.is_a?(AST::Node)
|
|
165
|
+
|
|
166
|
+
# Component declarators (`const Foo = () => ...`) are handled by
|
|
167
|
+
# the component pipeline; skip them here so the source doesn't
|
|
168
|
+
# show up twice.
|
|
169
|
+
return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
|
|
170
|
+
component_names.include?(declarator[:id]&.[](:name))
|
|
171
|
+
|
|
172
|
+
name = declarator[:id]&.[](:name)
|
|
173
|
+
return unless name
|
|
174
|
+
|
|
175
|
+
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def attach_module_bindings(component, module_bindings)
|
|
179
|
+
return component if module_bindings.empty?
|
|
180
|
+
|
|
181
|
+
component.with(module_bindings: module_bindings)
|
|
94
182
|
end
|
|
95
183
|
|
|
96
184
|
private
|
|
@@ -99,22 +187,60 @@ module JsxRosetta
|
|
|
99
187
|
LoweringError.new(message, node: node, source: @source)
|
|
100
188
|
end
|
|
101
189
|
|
|
190
|
+
def no_component_error(program)
|
|
191
|
+
shape = ModuleShapeClassifier.classify(program)
|
|
192
|
+
message = SHAPE_MESSAGES[shape]
|
|
193
|
+
suffix = message ? " — #{message}" : ""
|
|
194
|
+
lowering_error("no component function found in module#{suffix}")
|
|
195
|
+
end
|
|
196
|
+
|
|
102
197
|
def find_component_functions(program)
|
|
103
198
|
program.body.flat_map { |stmt| extract_components(stmt) }
|
|
104
199
|
.compact
|
|
105
|
-
.select { |(name,
|
|
200
|
+
.select { |(name, fn)| component_function?(name, fn) }
|
|
106
201
|
end
|
|
107
202
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
203
|
+
# A function is a component if it's PascalCase (the React convention),
|
|
204
|
+
# or if it's a lowercase-named helper whose body returns JSX. The
|
|
205
|
+
# latter catches files like `CellRenderers.tsx` that export
|
|
206
|
+
# `textRender`, `booleanRender`, etc. — JSX-returning by structure
|
|
207
|
+
# but lowercase by convention. `use*` names are excluded — those are
|
|
208
|
+
# hooks, which return data, not view markup.
|
|
209
|
+
def component_function?(name, function)
|
|
112
210
|
return false if name.nil? || name.empty?
|
|
211
|
+
return true if pascal_case?(name)
|
|
212
|
+
return false if hook_name?(name)
|
|
213
|
+
|
|
214
|
+
body_returns_jsx?(function[:body])
|
|
215
|
+
end
|
|
113
216
|
|
|
217
|
+
def pascal_case?(name)
|
|
114
218
|
first = name[0]
|
|
115
219
|
first == first.upcase && first != first.downcase
|
|
116
220
|
end
|
|
117
221
|
|
|
222
|
+
def hook_name?(name)
|
|
223
|
+
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Pre-lowering AST scan: does any return path in this body produce a
|
|
227
|
+
# JSX value? Used only as a heuristic for component_function?, so a
|
|
228
|
+
# false positive is a translation attempt that may TODO out, while
|
|
229
|
+
# a false negative is a missed translation. Recursion follows return
|
|
230
|
+
# paths only — does not descend into nested function expressions.
|
|
231
|
+
def body_returns_jsx?(node)
|
|
232
|
+
return false unless node.is_a?(AST::Node)
|
|
233
|
+
return true if %w[JSXElement JSXFragment].include?(node.type)
|
|
234
|
+
return switch_returns_jsx?(node) if node.type == "SwitchStatement"
|
|
235
|
+
|
|
236
|
+
probe = JSX_RETURN_PROBES[node.type]
|
|
237
|
+
probe ? probe.call(node).any? { |child| body_returns_jsx?(child) } : false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def switch_returns_jsx?(node)
|
|
241
|
+
node[:cases].any? { |c| c[:consequent].any? { |s| body_returns_jsx?(s) } }
|
|
242
|
+
end
|
|
243
|
+
|
|
118
244
|
def extract_components(stmt)
|
|
119
245
|
case stmt.type
|
|
120
246
|
when "FunctionDeclaration"
|
|
@@ -157,8 +283,10 @@ module JsxRosetta
|
|
|
157
283
|
props, rest_prop_name = lower_params(function[:params])
|
|
158
284
|
@prop_names = props.map(&:name)
|
|
159
285
|
@local_bindings = []
|
|
286
|
+
@local_binding_names = []
|
|
160
287
|
@local_arrows = {}
|
|
161
288
|
@local_polymorphic_tags = {}
|
|
289
|
+
@local_destructures = {}
|
|
162
290
|
@stimulus_methods = []
|
|
163
291
|
@stimulus_seen_names = {}
|
|
164
292
|
@react_hooks = []
|
|
@@ -171,6 +299,8 @@ module JsxRosetta
|
|
|
171
299
|
body: body,
|
|
172
300
|
rest_prop_name: rest_prop_name,
|
|
173
301
|
local_bindings: @local_bindings,
|
|
302
|
+
local_binding_names: @local_binding_names.uniq,
|
|
303
|
+
module_bindings: [],
|
|
174
304
|
stimulus_methods: @stimulus_methods,
|
|
175
305
|
react_hooks: @react_hooks
|
|
176
306
|
)
|
|
@@ -208,50 +338,121 @@ module JsxRosetta
|
|
|
208
338
|
end
|
|
209
339
|
|
|
210
340
|
def lower_object_prop(property)
|
|
341
|
+
key = property[:key]
|
|
342
|
+
prop_name = key.type == "StringLiteral" ? key[:value] : key[:name]
|
|
211
343
|
value = property[:value]
|
|
212
|
-
default
|
|
213
|
-
|
|
344
|
+
# Route prop default expressions through the recursive value
|
|
345
|
+
# lowering so object/array literals translate cleanly, instead of
|
|
346
|
+
# producing an opaque Interpolation that the backend would emit as
|
|
347
|
+
# `nil # TODO: ...`. The trailing `#` comment inside a method
|
|
348
|
+
# parameter list swallows the closing `)` and breaks Ruby syntax.
|
|
349
|
+
default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
|
|
350
|
+
Prop.new(name: prop_name, default: default)
|
|
214
351
|
end
|
|
215
352
|
|
|
216
353
|
def lower_function_body(body)
|
|
217
|
-
|
|
218
|
-
when "BlockStatement"
|
|
354
|
+
if body.type == "BlockStatement"
|
|
219
355
|
collect_local_bindings(body[:body])
|
|
220
|
-
|
|
221
|
-
return lower_return_value(return_stmt[:argument]) if return_stmt
|
|
222
|
-
|
|
223
|
-
chained = lower_if_return_chain_from_body(body[:body])
|
|
356
|
+
chained = lower_block_returns(body[:body])
|
|
224
357
|
return chained if chained
|
|
225
358
|
|
|
226
359
|
raise lowering_error("component function has no return statement", node: body)
|
|
227
|
-
when "JSXElement", "JSXFragment"
|
|
228
|
-
@local_jsx = {}
|
|
229
|
-
lower_jsx(body)
|
|
230
|
-
else
|
|
231
|
-
raise lowering_error("unsupported component body: #{body.type}", node: body)
|
|
232
360
|
end
|
|
233
|
-
end
|
|
234
361
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
362
|
+
@local_jsx = {}
|
|
363
|
+
lower_return_value(body)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Dispatch a value in return position. JSX nodes lower via lower_jsx;
|
|
367
|
+
# everything else gets a sensible default — null becomes empty Text,
|
|
368
|
+
# ConditionalExpression / LogicalExpression lower to Conditional,
|
|
369
|
+
# Identifier inlines @local_jsx-bound JSX or emits an Interpolation,
|
|
370
|
+
# literal Strings/Numbers become Text, and any other expression
|
|
371
|
+
# (CallExpression, MemberExpression, BinaryExpression, TemplateLiteral,
|
|
372
|
+
# …) becomes a verbatim Interpolation. This permissive default is
|
|
373
|
+
# what lets lowercase JSX-returning helpers (`textRender`,
|
|
374
|
+
# `moneyRender`) lower cleanly when their guard returns are non-JSX.
|
|
238
375
|
def lower_return_value(node)
|
|
239
376
|
case node.type
|
|
240
377
|
when "ConditionalExpression" then lower_ternary_expression(node)
|
|
241
378
|
when "LogicalExpression" then lower_logical_expression(node)
|
|
242
|
-
|
|
379
|
+
when "NullLiteral" then Text.new(value: "")
|
|
380
|
+
when *JSX_NODE_TYPES then lower_jsx(node)
|
|
381
|
+
when "Identifier" then lower_identifier_return(node)
|
|
382
|
+
when "StringLiteral" then Text.new(value: node[:value])
|
|
383
|
+
when "NumericLiteral" then Text.new(value: node[:value].to_s)
|
|
384
|
+
else Interpolation.new(expression: source_of(node))
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def lower_identifier_return(node)
|
|
389
|
+
bound = @local_jsx[node[:name]]
|
|
390
|
+
bound ? lower_jsx(bound) : Interpolation.new(expression: source_of(node))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Lower a block-statement body into an IR value. Recognized shapes:
|
|
394
|
+
# - first top-level `return X;` (everything after is dead code)
|
|
395
|
+
# - trailing `if/else if/else` chain whose every branch returns
|
|
396
|
+
# - trailing `switch (subject) { case A: return X; default: return Y; }`
|
|
397
|
+
# - trailing `try { return X; } catch { ... }`
|
|
398
|
+
# Any preceding `if (X) return Y;` guard statements (no else) are wrapped
|
|
399
|
+
# around the base value as outer Conditionals. Returns nil when no shape
|
|
400
|
+
# matches; caller raises.
|
|
401
|
+
def lower_block_returns(statements)
|
|
402
|
+
return_idx = statements.index { |s| AST::Node.matches?(s, "ReturnStatement") }
|
|
403
|
+
|
|
404
|
+
if return_idx
|
|
405
|
+
return_arg = statements[return_idx][:argument]
|
|
406
|
+
return nil unless return_arg
|
|
407
|
+
|
|
408
|
+
base = lower_return_value(return_arg)
|
|
409
|
+
preceding = statements[0...return_idx]
|
|
410
|
+
else
|
|
411
|
+
last = statements.last
|
|
412
|
+
return nil unless last.is_a?(AST::Node)
|
|
413
|
+
|
|
414
|
+
base = lower_trailing_return_structure(last)
|
|
415
|
+
return nil unless base
|
|
416
|
+
|
|
417
|
+
preceding = statements[0...-1]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
wrap_return_guards(preceding, base)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def lower_trailing_return_structure(stmt)
|
|
424
|
+
case stmt.type
|
|
425
|
+
when "IfStatement" then lower_if_return_chain(stmt)
|
|
426
|
+
when "SwitchStatement" then lower_switch_return(stmt)
|
|
427
|
+
when "TryStatement" then lower_try_return(stmt)
|
|
243
428
|
end
|
|
244
429
|
end
|
|
245
430
|
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
431
|
+
# Wrap `if (X) return Y;` guard statements (no else) around `base_value`
|
|
432
|
+
# as outer Conditionals. Preceding statements that we can't represent
|
|
433
|
+
# (const declarations, hook calls, multi-stmt guard bodies, if-else
|
|
434
|
+
# structures with multi-stmt branches) are skipped silently — they're
|
|
435
|
+
# either already absorbed by collect_local_bindings (consts/hooks) or
|
|
436
|
+
# they encode side effects we can't preserve. Matches the v0.2.0
|
|
437
|
+
# behavior of dropping unrepresentable preceding statements rather
|
|
438
|
+
# than failing the whole component.
|
|
439
|
+
def wrap_return_guards(preceding, base_value)
|
|
440
|
+
preceding.reverse.reduce(base_value) do |acc, stmt|
|
|
441
|
+
next acc unless guard_if_statement?(stmt)
|
|
442
|
+
|
|
443
|
+
branch_value = lower_return_branch(stmt[:consequent])
|
|
444
|
+
next acc unless branch_value
|
|
253
445
|
|
|
254
|
-
|
|
446
|
+
Conditional.new(
|
|
447
|
+
test: Interpolation.new(expression: source_of(stmt[:test])),
|
|
448
|
+
consequent: branch_value,
|
|
449
|
+
alternate: acc
|
|
450
|
+
)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def guard_if_statement?(stmt)
|
|
455
|
+
AST::Node.matches?(stmt, "IfStatement") && stmt[:alternate].nil?
|
|
255
456
|
end
|
|
256
457
|
|
|
257
458
|
def lower_if_return_chain(if_stmt)
|
|
@@ -273,28 +474,130 @@ module JsxRosetta
|
|
|
273
474
|
)
|
|
274
475
|
end
|
|
275
476
|
|
|
276
|
-
# An if-chain branch lowers to a return value
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
477
|
+
# An if-chain branch lowers to a return value via the same
|
|
478
|
+
# block-handling logic that powers the outer function body:
|
|
479
|
+
# variable declarations are absorbed into @local_bindings as TODOs,
|
|
480
|
+
# leading `if (X) return Y;` guards wrap as Conditionals, and any
|
|
481
|
+
# other preceding statements are silently dropped (since the gem
|
|
482
|
+
# can't preserve their side effects). Without this consistency,
|
|
483
|
+
# cases like `if (m) { const x = ...; return <X/>; } else { ... }`
|
|
484
|
+
# would bail mid-body.
|
|
280
485
|
def lower_return_branch(branch)
|
|
281
486
|
case branch.type
|
|
282
487
|
when "ReturnStatement"
|
|
283
488
|
branch[:argument] && lower_return_value(branch[:argument])
|
|
284
489
|
when "BlockStatement"
|
|
285
|
-
|
|
490
|
+
collect_nested_local_bindings(branch[:body])
|
|
491
|
+
lower_block_returns(branch[:body])
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def collect_nested_local_bindings(stmts)
|
|
496
|
+
stmts.each do |stmt|
|
|
497
|
+
next unless AST::Node.matches?(stmt, "VariableDeclaration")
|
|
498
|
+
|
|
499
|
+
seen = {}
|
|
500
|
+
stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Lower a `switch (subject) { case A: return X; default: return Y; }`
|
|
505
|
+
# to a right-nested chain of IR::Conditional. Each case must end in a
|
|
506
|
+
# returnable value (bare `return X;` or a single-stmt block-return).
|
|
507
|
+
# Fall-through groups (`case A: case B: return X;`) get a single
|
|
508
|
+
# Conditional with an OR-joined test. The default case (or no default)
|
|
509
|
+
# becomes the final alternate. Returns nil when any case has a shape
|
|
510
|
+
# we don't recognize.
|
|
511
|
+
def lower_switch_return(switch_stmt)
|
|
512
|
+
groups = build_switch_case_groups(switch_stmt[:cases])
|
|
513
|
+
return nil unless groups
|
|
286
514
|
|
|
287
|
-
|
|
288
|
-
|
|
515
|
+
subject_src = source_of(switch_stmt[:discriminant])
|
|
516
|
+
default_value, non_default = split_switch_default(groups)
|
|
289
517
|
|
|
290
|
-
|
|
518
|
+
non_default.reverse.reduce(default_value) do |alternate, group|
|
|
519
|
+
test_expr = group[:tests].map { |t| "#{subject_src} === #{source_of(t)}" }.join(" || ")
|
|
520
|
+
Conditional.new(
|
|
521
|
+
test: Interpolation.new(expression: test_expr),
|
|
522
|
+
consequent: group[:value],
|
|
523
|
+
alternate: alternate
|
|
524
|
+
)
|
|
291
525
|
end
|
|
292
526
|
end
|
|
293
527
|
|
|
528
|
+
def build_switch_case_groups(cases)
|
|
529
|
+
groups = []
|
|
530
|
+
pending_tests = []
|
|
531
|
+
pending_default = false
|
|
532
|
+
|
|
533
|
+
cases.each do |case_node|
|
|
534
|
+
if case_node[:test].nil?
|
|
535
|
+
pending_default = true
|
|
536
|
+
else
|
|
537
|
+
pending_tests << case_node[:test]
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
consequent_stmts = case_node[:consequent]
|
|
541
|
+
next if consequent_stmts.empty?
|
|
542
|
+
|
|
543
|
+
value = lower_switch_case_consequent(consequent_stmts)
|
|
544
|
+
return nil unless value
|
|
545
|
+
|
|
546
|
+
groups << {
|
|
547
|
+
tests: pending_default ? [] : pending_tests.dup,
|
|
548
|
+
is_default: pending_default,
|
|
549
|
+
value: value
|
|
550
|
+
}
|
|
551
|
+
pending_tests.clear
|
|
552
|
+
pending_default = false
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
return nil if pending_default || pending_tests.any?
|
|
556
|
+
|
|
557
|
+
groups
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def split_switch_default(groups)
|
|
561
|
+
default_group = groups.find { |g| g[:is_default] }
|
|
562
|
+
default_value = default_group ? default_group[:value] : Text.new(value: "")
|
|
563
|
+
non_default = groups.reject { |g| g[:is_default] }
|
|
564
|
+
[default_value, non_default]
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# A switch case body lowers when it returns from every reachable path.
|
|
568
|
+
# Recognized shapes:
|
|
569
|
+
# case A: return X; (bare return)
|
|
570
|
+
# case A: { return X; } (block-wrapped return)
|
|
571
|
+
# case A: { const y = ...; return X; } (leading vars + return)
|
|
572
|
+
# case A: if (X) return Y; return Z; (guard prefix + return)
|
|
573
|
+
# Trailing `break` statements are ignored.
|
|
574
|
+
def lower_switch_case_consequent(stmts)
|
|
575
|
+
filtered = stmts.reject { |s| AST::Node.matches?(s, "BreakStatement") }
|
|
576
|
+
return nil if filtered.empty?
|
|
577
|
+
|
|
578
|
+
if filtered.size == 1 && AST::Node.matches?(filtered.first, "BlockStatement")
|
|
579
|
+
return lower_switch_case_consequent(filtered.first[:body])
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
collect_nested_local_bindings(filtered)
|
|
583
|
+
lower_block_returns(filtered)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Lower `try { ...; return X; } catch (e) { ... } [finally { ... }]` by
|
|
587
|
+
# treating the try block's body as the function body. Catch/finally
|
|
588
|
+
# handlers are dropped — they typically encode JS-only error semantics
|
|
589
|
+
# that won't translate. Returns nil when the try block has no
|
|
590
|
+
# recognizable return shape.
|
|
591
|
+
def lower_try_return(try_stmt)
|
|
592
|
+
block_body = try_stmt[:block][:body]
|
|
593
|
+
lower_block_returns(block_body)
|
|
594
|
+
end
|
|
595
|
+
|
|
294
596
|
def collect_local_bindings(statements)
|
|
295
597
|
@local_jsx = {}
|
|
296
598
|
@local_arrows = {}
|
|
297
599
|
@local_polymorphic_tags = {}
|
|
600
|
+
@local_destructures = {}
|
|
298
601
|
seen_other_stmts = {}
|
|
299
602
|
|
|
300
603
|
statements.each do |stmt|
|
|
@@ -311,12 +614,27 @@ module JsxRosetta
|
|
|
311
614
|
init = declarator[:init]
|
|
312
615
|
return unless init.is_a?(AST::Node)
|
|
313
616
|
|
|
314
|
-
|
|
315
|
-
|
|
617
|
+
is_hook = hook_call?(init)
|
|
618
|
+
@react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip) if is_hook
|
|
619
|
+
|
|
620
|
+
id_node = declarator[:id]
|
|
621
|
+
if destructure_pattern?(id_node)
|
|
622
|
+
# Capture destructured names so the ExpressionTranslator recognizes
|
|
623
|
+
# them as known-local bindings and emits a `nil` placeholder
|
|
624
|
+
# instead of a bare unresolved reference (which NameErrors at
|
|
625
|
+
# render time). Hook destructures (`const [open, setOpen] = useState(0)`)
|
|
626
|
+
# contribute names but not a separate LocalBinding TODO — the
|
|
627
|
+
# hook's source already shows the binding to the reviewer.
|
|
628
|
+
# Also track member-expression destructures (Gap J) so
|
|
629
|
+
# `const { Content } = Layout` lets `<Content/>` resolve to
|
|
630
|
+
# `Layout::Content`.
|
|
631
|
+
record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
|
|
316
632
|
return
|
|
317
633
|
end
|
|
318
634
|
|
|
319
|
-
|
|
635
|
+
return if is_hook
|
|
636
|
+
|
|
637
|
+
name = id_node&.[](:name)
|
|
320
638
|
return unless name
|
|
321
639
|
|
|
322
640
|
case init.type
|
|
@@ -332,19 +650,83 @@ module JsxRosetta
|
|
|
332
650
|
end
|
|
333
651
|
end
|
|
334
652
|
|
|
653
|
+
def destructure_pattern?(node)
|
|
654
|
+
AST::Node.matches?(node, "ArrayPattern", "ObjectPattern")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def record_destructured_names(stmt, declarator, init:, seen:, is_hook:)
|
|
658
|
+
pattern = declarator[:id]
|
|
659
|
+
names = destructured_names_of(pattern)
|
|
660
|
+
return if names.empty?
|
|
661
|
+
|
|
662
|
+
# Member-expression-style destructuring (Gap J): `const { Content } = Layout`
|
|
663
|
+
# binds `Content` to `Layout.Content`. Record those so JSX use sites
|
|
664
|
+
# resolve to the right component.
|
|
665
|
+
track_member_destructures(pattern, init) if AST::Node.matches?(init, "Identifier")
|
|
666
|
+
|
|
667
|
+
@local_binding_names.concat(names)
|
|
668
|
+
return if is_hook
|
|
669
|
+
|
|
670
|
+
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
671
|
+
names.each { |name| @local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos]) }
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Return the flat list of Identifier names bound by a destructuring
|
|
675
|
+
# pattern. Nested patterns recurse. RestElement / aliased properties
|
|
676
|
+
# are included; defaults (AssignmentPattern) are followed to their
|
|
677
|
+
# left-hand identifier.
|
|
678
|
+
def destructured_names_of(pattern)
|
|
679
|
+
return [] unless pattern.is_a?(AST::Node)
|
|
680
|
+
|
|
681
|
+
case pattern.type
|
|
682
|
+
when "Identifier" then [pattern[:name]]
|
|
683
|
+
when "ArrayPattern"
|
|
684
|
+
pattern[:elements].flat_map { |element| element ? destructured_names_of(element) : [] }
|
|
685
|
+
when "ObjectPattern"
|
|
686
|
+
pattern[:properties].flat_map { |prop| destructured_names_from_property(prop) }
|
|
687
|
+
when "RestElement"
|
|
688
|
+
destructured_names_of(pattern[:argument])
|
|
689
|
+
when "AssignmentPattern"
|
|
690
|
+
destructured_names_of(pattern[:left])
|
|
691
|
+
else
|
|
692
|
+
[]
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def destructured_names_from_property(prop)
|
|
697
|
+
case prop.type
|
|
698
|
+
when "ObjectProperty" then destructured_names_of(prop[:value])
|
|
699
|
+
when "RestElement" then destructured_names_of(prop[:argument])
|
|
700
|
+
else []
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def track_member_destructures(pattern, source_identifier)
|
|
705
|
+
return unless AST::Node.matches?(pattern, "ObjectPattern")
|
|
706
|
+
|
|
707
|
+
source_name = source_identifier[:name]
|
|
708
|
+
pattern[:properties].each do |prop|
|
|
709
|
+
next unless prop.type == "ObjectProperty"
|
|
710
|
+
|
|
711
|
+
value = prop[:value]
|
|
712
|
+
next unless AST::Node.matches?(value, "Identifier")
|
|
713
|
+
|
|
714
|
+
@local_destructures[value[:name]] = source_name
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
335
718
|
def detect_bare_hook_call(stmt)
|
|
336
|
-
expr = stmt
|
|
337
|
-
return unless expr
|
|
338
|
-
return unless hook_call?(expr)
|
|
719
|
+
expr = stmt.child(:expression)
|
|
720
|
+
return unless expr&.of_type?("CallExpression") && hook_call?(expr)
|
|
339
721
|
|
|
340
722
|
@react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
|
|
341
723
|
end
|
|
342
724
|
|
|
343
725
|
def hook_call?(call_expression)
|
|
344
|
-
return false unless call_expression.
|
|
726
|
+
return false unless call_expression.of_type?("CallExpression")
|
|
345
727
|
|
|
346
|
-
callee = call_expression
|
|
347
|
-
callee
|
|
728
|
+
callee = call_expression.child(:callee)
|
|
729
|
+
callee&.of_type?("Identifier") && REACT_HOOKS.include?(callee[:name])
|
|
348
730
|
end
|
|
349
731
|
|
|
350
732
|
# Recognize the asChild-style polymorphic tag pattern:
|
|
@@ -372,6 +754,7 @@ module JsxRosetta
|
|
|
372
754
|
def record_local_other_binding(stmt, name, seen)
|
|
373
755
|
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
374
756
|
@local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
|
|
757
|
+
@local_binding_names << name
|
|
375
758
|
end
|
|
376
759
|
|
|
377
760
|
def lower_jsx(node)
|
|
@@ -387,7 +770,7 @@ module JsxRosetta
|
|
|
387
770
|
|
|
388
771
|
def lower_jsx_element(element)
|
|
389
772
|
tag = element.tag_name
|
|
390
|
-
attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr) }
|
|
773
|
+
attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr, tag: tag) }
|
|
391
774
|
# `key` is a React-only reconciliation hint; never emit it to the DOM
|
|
392
775
|
# or to ViewComponent invocations.
|
|
393
776
|
attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
|
|
@@ -395,6 +778,10 @@ module JsxRosetta
|
|
|
395
778
|
|
|
396
779
|
if (poly = @local_polymorphic_tags[tag])
|
|
397
780
|
lower_polymorphic_tag_use(poly, attributes, children)
|
|
781
|
+
elsif (parent = @local_destructures[tag])
|
|
782
|
+
# Gap J: `const { Content } = Layout; <Content/>` should resolve
|
|
783
|
+
# to `Layout::Content`, not a bare `ContentComponent`.
|
|
784
|
+
ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
|
|
398
785
|
elsif html_element?(tag)
|
|
399
786
|
Element.new(tag: tag, attributes: attributes, children: children)
|
|
400
787
|
else
|
|
@@ -479,11 +866,30 @@ module JsxRosetta
|
|
|
479
866
|
when "ConditionalExpression" then lower_ternary_expression(expression)
|
|
480
867
|
when "Identifier" then lower_identifier_expression(expression)
|
|
481
868
|
when "CallExpression" then lower_call_expression(expression)
|
|
869
|
+
when "ArrowFunctionExpression", "FunctionExpression" then lower_render_prop(expression)
|
|
482
870
|
else
|
|
483
871
|
Interpolation.new(expression: source_of(expression))
|
|
484
872
|
end
|
|
485
873
|
end
|
|
486
874
|
|
|
875
|
+
# Recognize the render-prop / function-as-children pattern:
|
|
876
|
+
# <Form.List>{(fields, helpers) => <div>{fields}</div>}</Form.List>
|
|
877
|
+
# Returns nil (caller falls back to verbatim interpolation) when the
|
|
878
|
+
# arrow has zero or too many params, or when the body doesn't lower
|
|
879
|
+
# cleanly to a JSX child.
|
|
880
|
+
def lower_render_prop(arrow)
|
|
881
|
+
params = arrow[:params]
|
|
882
|
+
return Interpolation.new(expression: source_of(arrow)) if params.size > 4
|
|
883
|
+
unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
884
|
+
return Interpolation.new(expression: source_of(arrow))
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
body = lower_arrow_body(arrow[:body])
|
|
888
|
+
return Interpolation.new(expression: source_of(arrow)) unless body
|
|
889
|
+
|
|
890
|
+
RenderProp.new(params: params.map { |p| p[:name] }, body: body)
|
|
891
|
+
end
|
|
892
|
+
|
|
487
893
|
def lower_jsx_comment(empty_expression)
|
|
488
894
|
comments = empty_expression.raw["innerComments"]
|
|
489
895
|
return nil if comments.nil? || comments.empty?
|
|
@@ -496,32 +902,50 @@ module JsxRosetta
|
|
|
496
902
|
loop_node || Interpolation.new(expression: source_of(expression))
|
|
497
903
|
end
|
|
498
904
|
|
|
499
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
500
905
|
def try_lower_map_loop(call_expression)
|
|
501
|
-
callee = call_expression
|
|
502
|
-
return nil unless callee
|
|
503
|
-
return nil unless callee[:property].is_a?(AST::Node) && callee[:property][:name] == "map"
|
|
906
|
+
callee = call_expression.child(:callee)
|
|
907
|
+
return nil unless callee&.of_type?("MemberExpression")
|
|
504
908
|
|
|
505
|
-
|
|
506
|
-
return nil
|
|
507
|
-
return nil unless args.first.type == "ArrowFunctionExpression"
|
|
909
|
+
property = callee.child(:property)
|
|
910
|
+
return nil unless property && property[:name] == "map"
|
|
508
911
|
|
|
509
|
-
arrow =
|
|
510
|
-
|
|
511
|
-
return nil if params.empty? || params.size > 2
|
|
512
|
-
return nil unless params.all? { |p| p.is_a?(AST::Node) && p.type == "Identifier" }
|
|
912
|
+
arrow = map_loop_arrow(call_expression[:arguments])
|
|
913
|
+
return nil unless arrow
|
|
513
914
|
|
|
915
|
+
params = arrow[:params]
|
|
514
916
|
body = lower_arrow_body(arrow[:body])
|
|
515
917
|
return nil unless body
|
|
516
918
|
|
|
517
919
|
Loop.new(
|
|
518
|
-
iterable:
|
|
920
|
+
iterable: lower_loop_iterable(callee[:object]),
|
|
519
921
|
item_binding: params[0][:name],
|
|
520
922
|
index_binding: params[1] && params[1][:name],
|
|
521
923
|
body: body
|
|
522
924
|
)
|
|
523
925
|
end
|
|
524
|
-
|
|
926
|
+
|
|
927
|
+
# Recognize ArrayExpression/ObjectExpression iterables so a literal-
|
|
928
|
+
# rooted `.map(...)` doesn't bail at translation time. Falls back to
|
|
929
|
+
# the verbatim Interpolation for everything else.
|
|
930
|
+
def lower_loop_iterable(object)
|
|
931
|
+
case object.type
|
|
932
|
+
when "ArrayExpression" then lower_array_literal(object)
|
|
933
|
+
else Interpolation.new(expression: source_of(object))
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def map_loop_arrow(args)
|
|
938
|
+
return nil if args.size != 1
|
|
939
|
+
|
|
940
|
+
arrow = args.first
|
|
941
|
+
return nil unless AST::Node.matches?(arrow, "ArrowFunctionExpression")
|
|
942
|
+
|
|
943
|
+
params = arrow[:params]
|
|
944
|
+
return nil if params.empty? || params.size > 2
|
|
945
|
+
return nil unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
946
|
+
|
|
947
|
+
arrow
|
|
948
|
+
end
|
|
525
949
|
|
|
526
950
|
def lower_arrow_body(body)
|
|
527
951
|
case body.type
|
|
@@ -584,21 +1008,27 @@ module JsxRosetta
|
|
|
584
1008
|
end
|
|
585
1009
|
end
|
|
586
1010
|
|
|
587
|
-
def lower_attribute(attr)
|
|
1011
|
+
def lower_attribute(attr, tag:)
|
|
588
1012
|
case attr
|
|
589
1013
|
when AST::JSXAttribute
|
|
590
|
-
lower_jsx_attribute(attr)
|
|
1014
|
+
lower_jsx_attribute(attr, tag: tag)
|
|
591
1015
|
when AST::JSXSpreadAttribute
|
|
592
1016
|
SpreadAttribute.new(expression: source_of(attr.argument))
|
|
593
1017
|
end
|
|
594
1018
|
end
|
|
595
1019
|
|
|
596
|
-
|
|
1020
|
+
# When the JSX tag is a component (PascalCase or member-expression),
|
|
1021
|
+
# `on*` props are NOT DOM events — they're callback props the receiving
|
|
1022
|
+
# Ruby component decides how to handle. Stimulus action descriptors
|
|
1023
|
+
# only fire on real DOM events, so promoting them would generate
|
|
1024
|
+
# never-firing `data-action="change->foo#h"` markup. Pass through as
|
|
1025
|
+
# a regular component-prop kwarg instead.
|
|
1026
|
+
def lower_jsx_attribute(attr, tag:)
|
|
597
1027
|
name = attr.attribute_name
|
|
598
1028
|
|
|
599
1029
|
return lower_class_name(attr.value) if name == "className"
|
|
600
1030
|
return lower_style_attribute_or_fallback(attr.value) if name == "style"
|
|
601
|
-
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
|
|
1031
|
+
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer) && html_element?(tag)
|
|
602
1032
|
return lower_event_attribute(name, attr.value)
|
|
603
1033
|
end
|
|
604
1034
|
|
|
@@ -613,7 +1043,7 @@ module JsxRosetta
|
|
|
613
1043
|
return nil unless value.is_a?(AST::JSXExpressionContainer)
|
|
614
1044
|
|
|
615
1045
|
expression = value.expression
|
|
616
|
-
return nil unless
|
|
1046
|
+
return nil unless AST::Node.matches?(expression, "ObjectExpression")
|
|
617
1047
|
|
|
618
1048
|
declarations = expression[:properties].map { |prop| lower_style_property(prop) }
|
|
619
1049
|
return nil if declarations.any?(&:nil?)
|
|
@@ -659,10 +1089,10 @@ module JsxRosetta
|
|
|
659
1089
|
end
|
|
660
1090
|
|
|
661
1091
|
def try_lower_class_helper(expression)
|
|
662
|
-
return nil unless
|
|
1092
|
+
return nil unless AST::Node.matches?(expression, "CallExpression")
|
|
663
1093
|
|
|
664
|
-
callee = expression
|
|
665
|
-
return nil unless callee
|
|
1094
|
+
callee = expression.child(:callee)
|
|
1095
|
+
return nil unless callee&.of_type?("Identifier")
|
|
666
1096
|
return nil unless %w[cn clsx classnames].include?(callee[:name])
|
|
667
1097
|
|
|
668
1098
|
segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
|
|
@@ -714,26 +1144,49 @@ module JsxRosetta
|
|
|
714
1144
|
)
|
|
715
1145
|
end
|
|
716
1146
|
|
|
1147
|
+
# Promote a JSX event-handler attribute (`onClick={...}`, `onChange={...}`)
|
|
1148
|
+
# to a Stimulus method binding. Three input shapes are recognized:
|
|
1149
|
+
# - inline arrow / function expression (`onClick={() => doX()}`)
|
|
1150
|
+
# → method body is the arrow's body source.
|
|
1151
|
+
# - identifier referring to a local arrow binding
|
|
1152
|
+
# (`const h = () => doX(); onClick={h}`) → method body is the
|
|
1153
|
+
# bound arrow's body. The local arrow is consumed.
|
|
1154
|
+
# - identifier referring to a prop or external (`onClick={onChange}`)
|
|
1155
|
+
# → synthesizes a method whose body documents the original
|
|
1156
|
+
# reference. Without this branch, prop-handler bindings used to
|
|
1157
|
+
# fall through to an EventBinding that rendered as a broken
|
|
1158
|
+
# `data-action` (a Ruby reference, not a Stimulus action descriptor).
|
|
717
1159
|
def try_promote_to_stimulus(attr_name, event, expression)
|
|
718
|
-
|
|
719
|
-
|
|
1160
|
+
case expression.type
|
|
1161
|
+
when "ArrowFunctionExpression", "FunctionExpression"
|
|
1162
|
+
promote_arrow_to_stimulus(attr_name, event, expression, name_hint: nil)
|
|
1163
|
+
when "Identifier"
|
|
1164
|
+
promote_identifier_event(attr_name, event, expression[:name])
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
720
1167
|
|
|
721
|
-
|
|
1168
|
+
def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
|
|
1169
|
+
base = name_hint || default_stimulus_method_name(attr_name)
|
|
1170
|
+
method_name = stimulus_method_name(base)
|
|
722
1171
|
body_source = source_of(arrow_node[:body])
|
|
723
|
-
@stimulus_methods << StimulusMethod.new(
|
|
1172
|
+
@stimulus_methods << StimulusMethod.new(
|
|
1173
|
+
name: method_name, body_source: body_source, original_name: base
|
|
1174
|
+
)
|
|
724
1175
|
@local_arrows.delete(name_hint) if name_hint
|
|
725
|
-
|
|
726
1176
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
727
1177
|
end
|
|
728
1178
|
|
|
729
|
-
def
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
[expression, nil]
|
|
733
|
-
when "Identifier"
|
|
734
|
-
arrow = @local_arrows[expression[:name]]
|
|
735
|
-
arrow ? [arrow, expression[:name]] : nil
|
|
1179
|
+
def promote_identifier_event(attr_name, event, identifier_name)
|
|
1180
|
+
if (arrow = @local_arrows[identifier_name])
|
|
1181
|
+
return promote_arrow_to_stimulus(attr_name, event, arrow, name_hint: identifier_name)
|
|
736
1182
|
end
|
|
1183
|
+
|
|
1184
|
+
method_name = stimulus_method_name(identifier_name)
|
|
1185
|
+
body_source = "// originally bound to: #{identifier_name}"
|
|
1186
|
+
@stimulus_methods << StimulusMethod.new(
|
|
1187
|
+
name: method_name, body_source: body_source, original_name: identifier_name
|
|
1188
|
+
)
|
|
1189
|
+
StimulusBinding.new(event: event, method_name: method_name)
|
|
737
1190
|
end
|
|
738
1191
|
|
|
739
1192
|
def default_stimulus_method_name(attr_name)
|
|
@@ -753,12 +1206,86 @@ module JsxRosetta
|
|
|
753
1206
|
when nil
|
|
754
1207
|
true
|
|
755
1208
|
when AST::JSXExpressionContainer
|
|
756
|
-
|
|
1209
|
+
lower_value_expression(value.expression)
|
|
757
1210
|
else
|
|
758
1211
|
value.raw["value"]
|
|
759
1212
|
end
|
|
760
1213
|
end
|
|
761
1214
|
|
|
1215
|
+
# Recursively lower an arbitrary JS expression into structured IR
|
|
1216
|
+
# when possible: ObjectExpression → ObjectLiteral, ArrayExpression
|
|
1217
|
+
# → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
|
|
1218
|
+
# Lambda. Everything else falls back to the verbatim Interpolation
|
|
1219
|
+
# so simpler ExpressionTranslator paths still get a crack at it.
|
|
1220
|
+
def lower_value_expression(expression)
|
|
1221
|
+
case expression.type
|
|
1222
|
+
when "ObjectExpression" then lower_object_literal(expression)
|
|
1223
|
+
when "ArrayExpression" then lower_array_literal(expression)
|
|
1224
|
+
when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
|
|
1225
|
+
else
|
|
1226
|
+
Interpolation.new(expression: source_of(expression))
|
|
1227
|
+
end
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def lower_object_literal(object_expression)
|
|
1231
|
+
properties = []
|
|
1232
|
+
object_expression[:properties].each do |prop|
|
|
1233
|
+
# Spreads inside object literals, computed keys, getters/setters,
|
|
1234
|
+
# methods — fall back to a verbatim Interpolation since we can't
|
|
1235
|
+
# represent them as a simple key-value pair.
|
|
1236
|
+
return Interpolation.new(expression: source_of(object_expression)) unless prop.type == "ObjectProperty"
|
|
1237
|
+
return Interpolation.new(expression: source_of(object_expression)) if prop.raw["computed"]
|
|
1238
|
+
|
|
1239
|
+
key_name = object_property_key_name(prop[:key])
|
|
1240
|
+
return Interpolation.new(expression: source_of(object_expression)) if key_name.nil?
|
|
1241
|
+
|
|
1242
|
+
properties << [key_name, lower_value_expression(prop[:value])]
|
|
1243
|
+
end
|
|
1244
|
+
ObjectLiteral.new(properties: properties)
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def object_property_key_name(key_node)
|
|
1248
|
+
case key_node.type
|
|
1249
|
+
when "Identifier" then key_node[:name]
|
|
1250
|
+
when "StringLiteral" then key_node[:value]
|
|
1251
|
+
when "NumericLiteral" then key_node[:value].to_s
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def lower_array_literal(array_expression)
|
|
1256
|
+
elements = array_expression[:elements].map do |el|
|
|
1257
|
+
next nil if el.nil? # `[1, , 3]` holes — Ruby has no equivalent
|
|
1258
|
+
|
|
1259
|
+
lower_value_expression(el)
|
|
1260
|
+
end
|
|
1261
|
+
ArrayLiteral.new(elements: elements)
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def lower_value_lambda(arrow)
|
|
1265
|
+
params = arrow[:params]
|
|
1266
|
+
return Interpolation.new(expression: source_of(arrow)) if params.size > 4
|
|
1267
|
+
unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1268
|
+
return Interpolation.new(expression: source_of(arrow))
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
body = lower_lambda_body(arrow[:body])
|
|
1272
|
+
return Interpolation.new(expression: source_of(arrow)) unless body
|
|
1273
|
+
|
|
1274
|
+
Lambda.new(params: params.map { |p| p[:name] }, body: body)
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
def lower_lambda_body(body)
|
|
1278
|
+
return lower_jsx(body) if %w[JSXElement JSXFragment].include?(body.type)
|
|
1279
|
+
|
|
1280
|
+
return unless body.type == "BlockStatement"
|
|
1281
|
+
|
|
1282
|
+
return_stmt = body[:body].find { |s| s.type == "ReturnStatement" }
|
|
1283
|
+
return nil unless return_stmt && return_stmt[:argument]
|
|
1284
|
+
|
|
1285
|
+
arg = return_stmt[:argument]
|
|
1286
|
+
%w[JSXElement JSXFragment].include?(arg.type) ? lower_jsx(arg) : nil
|
|
1287
|
+
end
|
|
1288
|
+
|
|
762
1289
|
def style_binding_expression(value)
|
|
763
1290
|
case value
|
|
764
1291
|
when nil then "true"
|