jsx_rosetta 0.3.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 +120 -0
- data/README.md +69 -7
- 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 +366 -194
- 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,9 +67,7 @@ module JsxRosetta
|
|
|
66
67
|
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
67
68
|
].freeze
|
|
68
69
|
|
|
69
|
-
EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
|
|
70
70
|
JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
|
|
71
|
-
HOC_NAMES = %w[memo forwardRef lazy observer].freeze
|
|
72
71
|
|
|
73
72
|
# Pre-lowering AST scan: maps a node type to a callable returning the
|
|
74
73
|
# AST nodes that contribute return values. Used by body_returns_jsx?.
|
|
@@ -106,8 +105,10 @@ module JsxRosetta
|
|
|
106
105
|
@prop_names = []
|
|
107
106
|
@local_jsx = {}
|
|
108
107
|
@local_bindings = []
|
|
108
|
+
@local_binding_names = []
|
|
109
109
|
@local_arrows = {}
|
|
110
110
|
@local_polymorphic_tags = {}
|
|
111
|
+
@local_destructures = {}
|
|
111
112
|
@stimulus_methods = []
|
|
112
113
|
@stimulus_seen_names = {}
|
|
113
114
|
@react_hooks = []
|
|
@@ -118,148 +119,79 @@ module JsxRosetta
|
|
|
118
119
|
raise no_component_error(file.program) if candidates.empty?
|
|
119
120
|
|
|
120
121
|
name, function = candidates.first
|
|
121
|
-
|
|
122
|
+
module_bindings = capture_module_bindings(file.program, candidates)
|
|
123
|
+
attach_module_bindings(lower_component(name, function), module_bindings)
|
|
122
124
|
end
|
|
123
125
|
|
|
124
126
|
def lower_all_components(file)
|
|
125
127
|
candidates = find_component_functions(file.program)
|
|
126
128
|
raise no_component_error(file.program) if candidates.empty?
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def lowering_error(message, node: nil)
|
|
134
|
-
LoweringError.new(message, node: node, source: @source)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def no_component_error(program)
|
|
138
|
-
shape = classify_module_shape(program)
|
|
139
|
-
message = SHAPE_MESSAGES[shape]
|
|
140
|
-
suffix = message ? " — #{message}" : ""
|
|
141
|
-
lowering_error("no component function found in module#{suffix}")
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Heuristic classifier that labels a module whose top-level shape isn't
|
|
145
|
-
# a function component. Used only for the error message — does not
|
|
146
|
-
# affect what does or doesn't translate. Order matters: more specific
|
|
147
|
-
# shapes are checked first.
|
|
148
|
-
def classify_module_shape(program)
|
|
149
|
-
ast_shape = classify_ast_shape(program)
|
|
150
|
-
return ast_shape if ast_shape
|
|
151
|
-
|
|
152
|
-
classify_by_export_names(top_level_export_names(program), program)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def classify_ast_shape(program)
|
|
156
|
-
return :class_component if program.body.any? { |stmt| class_component?(stmt) }
|
|
157
|
-
return :hoc_wrapped if program.body.any? { |stmt| hoc_wrapped_export?(stmt) }
|
|
158
|
-
return :columns_data if program.body.any? { |stmt| array_literal_export?(stmt) }
|
|
159
|
-
|
|
160
|
-
nil
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def classify_by_export_names(names, program)
|
|
164
|
-
export_label = classify_by_export_pattern(names)
|
|
165
|
-
return export_label if export_label
|
|
166
|
-
|
|
167
|
-
classify_non_export_module(program)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def classify_by_export_pattern(names)
|
|
171
|
-
any_hooks = names.any? { |n| hook_name?(n) }
|
|
172
|
-
any_helpers = names.any? { |n| /\A[a-z]/.match?(n) && !hook_name?(n) }
|
|
173
|
-
return :mixed_exports if any_hooks && any_helpers
|
|
174
|
-
return :hooks_only if any_hooks
|
|
175
|
-
return :utils_only if any_helpers
|
|
176
|
-
|
|
177
|
-
nil
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# No function-shaped exports. Distinguish:
|
|
181
|
-
# - side-effect-only (top-level calls like `LicenseManager.set(...)`)
|
|
182
|
-
# - types-only (TS types/interfaces and constants)
|
|
183
|
-
# - unknown (nothing top-level to look at)
|
|
184
|
-
def classify_non_export_module(program)
|
|
185
|
-
return :side_effects_only if program.body.any? { |s| side_effect_statement?(s) }
|
|
186
|
-
return :types_only if top_level_has_anything?(program)
|
|
187
|
-
|
|
188
|
-
:unknown
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def side_effect_statement?(stmt)
|
|
192
|
-
stmt.is_a?(AST::Node) && stmt.type == "ExpressionStatement"
|
|
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
|
|
193
134
|
end
|
|
194
135
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
197
150
|
end
|
|
198
151
|
|
|
199
|
-
def
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
202
160
|
end
|
|
203
161
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def hoc_wrapped_export?(stmt)
|
|
208
|
-
decl = EXPORT_TYPES.include?(stmt.type) ? stmt[:declaration] : stmt
|
|
209
|
-
return false unless decl.is_a?(AST::Node) && decl.type == "VariableDeclaration"
|
|
162
|
+
def record_module_binding(stmt, declarator, component_names, bindings)
|
|
163
|
+
init = declarator[:init]
|
|
164
|
+
return unless init.is_a?(AST::Node)
|
|
210
165
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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))
|
|
216
171
|
|
|
217
|
-
|
|
218
|
-
return
|
|
172
|
+
name = declarator[:id]&.[](:name)
|
|
173
|
+
return unless name
|
|
219
174
|
|
|
220
|
-
|
|
221
|
-
when "Identifier" then HOC_NAMES.include?(callee[:name])
|
|
222
|
-
when "MemberExpression"
|
|
223
|
-
property = callee[:property]
|
|
224
|
-
property.is_a?(AST::Node) && property.type == "Identifier" && HOC_NAMES.include?(property[:name])
|
|
225
|
-
else false
|
|
226
|
-
end
|
|
175
|
+
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
227
176
|
end
|
|
228
177
|
|
|
229
|
-
def
|
|
230
|
-
return
|
|
178
|
+
def attach_module_bindings(component, module_bindings)
|
|
179
|
+
return component if module_bindings.empty?
|
|
231
180
|
|
|
232
|
-
|
|
233
|
-
return true if decl.is_a?(AST::Node) && decl.type == "ArrayExpression"
|
|
234
|
-
return false unless decl.is_a?(AST::Node) && decl.type == "VariableDeclaration"
|
|
235
|
-
|
|
236
|
-
decl[:declarations].any? { |d| d[:init].is_a?(AST::Node) && d[:init].type == "ArrayExpression" }
|
|
181
|
+
component.with(module_bindings: module_bindings)
|
|
237
182
|
end
|
|
238
183
|
|
|
239
|
-
|
|
240
|
-
# distinguish "types-only / empty module" from "mixed exports."
|
|
241
|
-
def top_level_has_anything?(program)
|
|
242
|
-
program.body.any? do |stmt|
|
|
243
|
-
stmt.is_a?(AST::Node) && stmt.type != "ImportDeclaration"
|
|
244
|
-
end
|
|
245
|
-
end
|
|
184
|
+
private
|
|
246
185
|
|
|
247
|
-
def
|
|
248
|
-
|
|
186
|
+
def lowering_error(message, node: nil)
|
|
187
|
+
LoweringError.new(message, node: node, source: @source)
|
|
249
188
|
end
|
|
250
189
|
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
stmt[:declarations].map { |d| d[:id].is_a?(AST::Node) && d[:id].type == "Identifier" ? d[:id][:name] : nil }
|
|
257
|
-
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
258
|
-
decl = stmt[:declaration]
|
|
259
|
-
decl.is_a?(AST::Node) ? extract_top_level_names(decl) : []
|
|
260
|
-
else
|
|
261
|
-
[]
|
|
262
|
-
end
|
|
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}")
|
|
263
195
|
end
|
|
264
196
|
|
|
265
197
|
def find_component_functions(program)
|
|
@@ -287,6 +219,10 @@ module JsxRosetta
|
|
|
287
219
|
first == first.upcase && first != first.downcase
|
|
288
220
|
end
|
|
289
221
|
|
|
222
|
+
def hook_name?(name)
|
|
223
|
+
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
224
|
+
end
|
|
225
|
+
|
|
290
226
|
# Pre-lowering AST scan: does any return path in this body produce a
|
|
291
227
|
# JSX value? Used only as a heuristic for component_function?, so a
|
|
292
228
|
# false positive is a translation attempt that may TODO out, while
|
|
@@ -347,8 +283,10 @@ module JsxRosetta
|
|
|
347
283
|
props, rest_prop_name = lower_params(function[:params])
|
|
348
284
|
@prop_names = props.map(&:name)
|
|
349
285
|
@local_bindings = []
|
|
286
|
+
@local_binding_names = []
|
|
350
287
|
@local_arrows = {}
|
|
351
288
|
@local_polymorphic_tags = {}
|
|
289
|
+
@local_destructures = {}
|
|
352
290
|
@stimulus_methods = []
|
|
353
291
|
@stimulus_seen_names = {}
|
|
354
292
|
@react_hooks = []
|
|
@@ -361,6 +299,8 @@ module JsxRosetta
|
|
|
361
299
|
body: body,
|
|
362
300
|
rest_prop_name: rest_prop_name,
|
|
363
301
|
local_bindings: @local_bindings,
|
|
302
|
+
local_binding_names: @local_binding_names.uniq,
|
|
303
|
+
module_bindings: [],
|
|
364
304
|
stimulus_methods: @stimulus_methods,
|
|
365
305
|
react_hooks: @react_hooks
|
|
366
306
|
)
|
|
@@ -401,7 +341,12 @@ module JsxRosetta
|
|
|
401
341
|
key = property[:key]
|
|
402
342
|
prop_name = key.type == "StringLiteral" ? key[:value] : key[:name]
|
|
403
343
|
value = property[:value]
|
|
404
|
-
default
|
|
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")
|
|
405
350
|
Prop.new(name: prop_name, default: default)
|
|
406
351
|
end
|
|
407
352
|
|
|
@@ -454,7 +399,7 @@ module JsxRosetta
|
|
|
454
399
|
# around the base value as outer Conditionals. Returns nil when no shape
|
|
455
400
|
# matches; caller raises.
|
|
456
401
|
def lower_block_returns(statements)
|
|
457
|
-
return_idx = statements.index { |s|
|
|
402
|
+
return_idx = statements.index { |s| AST::Node.matches?(s, "ReturnStatement") }
|
|
458
403
|
|
|
459
404
|
if return_idx
|
|
460
405
|
return_arg = statements[return_idx][:argument]
|
|
@@ -492,20 +437,22 @@ module JsxRosetta
|
|
|
492
437
|
# behavior of dropping unrepresentable preceding statements rather
|
|
493
438
|
# than failing the whole component.
|
|
494
439
|
def wrap_return_guards(preceding, base_value)
|
|
495
|
-
preceding.reverse.
|
|
496
|
-
next unless
|
|
497
|
-
next if stmt[:alternate]
|
|
440
|
+
preceding.reverse.reduce(base_value) do |acc, stmt|
|
|
441
|
+
next acc unless guard_if_statement?(stmt)
|
|
498
442
|
|
|
499
443
|
branch_value = lower_return_branch(stmt[:consequent])
|
|
500
|
-
next unless branch_value
|
|
444
|
+
next acc unless branch_value
|
|
501
445
|
|
|
502
|
-
|
|
446
|
+
Conditional.new(
|
|
503
447
|
test: Interpolation.new(expression: source_of(stmt[:test])),
|
|
504
448
|
consequent: branch_value,
|
|
505
|
-
alternate:
|
|
449
|
+
alternate: acc
|
|
506
450
|
)
|
|
507
451
|
end
|
|
508
|
-
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def guard_if_statement?(stmt)
|
|
455
|
+
AST::Node.matches?(stmt, "IfStatement") && stmt[:alternate].nil?
|
|
509
456
|
end
|
|
510
457
|
|
|
511
458
|
def lower_if_return_chain(if_stmt)
|
|
@@ -547,7 +494,7 @@ module JsxRosetta
|
|
|
547
494
|
|
|
548
495
|
def collect_nested_local_bindings(stmts)
|
|
549
496
|
stmts.each do |stmt|
|
|
550
|
-
next unless
|
|
497
|
+
next unless AST::Node.matches?(stmt, "VariableDeclaration")
|
|
551
498
|
|
|
552
499
|
seen = {}
|
|
553
500
|
stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
|
|
@@ -625,10 +572,10 @@ module JsxRosetta
|
|
|
625
572
|
# case A: if (X) return Y; return Z; (guard prefix + return)
|
|
626
573
|
# Trailing `break` statements are ignored.
|
|
627
574
|
def lower_switch_case_consequent(stmts)
|
|
628
|
-
filtered = stmts.reject { |s|
|
|
575
|
+
filtered = stmts.reject { |s| AST::Node.matches?(s, "BreakStatement") }
|
|
629
576
|
return nil if filtered.empty?
|
|
630
577
|
|
|
631
|
-
if filtered.size == 1 &&
|
|
578
|
+
if filtered.size == 1 && AST::Node.matches?(filtered.first, "BlockStatement")
|
|
632
579
|
return lower_switch_case_consequent(filtered.first[:body])
|
|
633
580
|
end
|
|
634
581
|
|
|
@@ -650,6 +597,7 @@ module JsxRosetta
|
|
|
650
597
|
@local_jsx = {}
|
|
651
598
|
@local_arrows = {}
|
|
652
599
|
@local_polymorphic_tags = {}
|
|
600
|
+
@local_destructures = {}
|
|
653
601
|
seen_other_stmts = {}
|
|
654
602
|
|
|
655
603
|
statements.each do |stmt|
|
|
@@ -666,12 +614,27 @@ module JsxRosetta
|
|
|
666
614
|
init = declarator[:init]
|
|
667
615
|
return unless init.is_a?(AST::Node)
|
|
668
616
|
|
|
669
|
-
|
|
670
|
-
|
|
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)
|
|
671
632
|
return
|
|
672
633
|
end
|
|
673
634
|
|
|
674
|
-
|
|
635
|
+
return if is_hook
|
|
636
|
+
|
|
637
|
+
name = id_node&.[](:name)
|
|
675
638
|
return unless name
|
|
676
639
|
|
|
677
640
|
case init.type
|
|
@@ -687,19 +650,83 @@ module JsxRosetta
|
|
|
687
650
|
end
|
|
688
651
|
end
|
|
689
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
|
+
|
|
690
718
|
def detect_bare_hook_call(stmt)
|
|
691
|
-
expr = stmt
|
|
692
|
-
return unless expr
|
|
693
|
-
return unless hook_call?(expr)
|
|
719
|
+
expr = stmt.child(:expression)
|
|
720
|
+
return unless expr&.of_type?("CallExpression") && hook_call?(expr)
|
|
694
721
|
|
|
695
722
|
@react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
|
|
696
723
|
end
|
|
697
724
|
|
|
698
725
|
def hook_call?(call_expression)
|
|
699
|
-
return false unless call_expression.
|
|
726
|
+
return false unless call_expression.of_type?("CallExpression")
|
|
700
727
|
|
|
701
|
-
callee = call_expression
|
|
702
|
-
callee
|
|
728
|
+
callee = call_expression.child(:callee)
|
|
729
|
+
callee&.of_type?("Identifier") && REACT_HOOKS.include?(callee[:name])
|
|
703
730
|
end
|
|
704
731
|
|
|
705
732
|
# Recognize the asChild-style polymorphic tag pattern:
|
|
@@ -727,6 +754,7 @@ module JsxRosetta
|
|
|
727
754
|
def record_local_other_binding(stmt, name, seen)
|
|
728
755
|
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
729
756
|
@local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
|
|
757
|
+
@local_binding_names << name
|
|
730
758
|
end
|
|
731
759
|
|
|
732
760
|
def lower_jsx(node)
|
|
@@ -742,7 +770,7 @@ module JsxRosetta
|
|
|
742
770
|
|
|
743
771
|
def lower_jsx_element(element)
|
|
744
772
|
tag = element.tag_name
|
|
745
|
-
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) }
|
|
746
774
|
# `key` is a React-only reconciliation hint; never emit it to the DOM
|
|
747
775
|
# or to ViewComponent invocations.
|
|
748
776
|
attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
|
|
@@ -750,6 +778,10 @@ module JsxRosetta
|
|
|
750
778
|
|
|
751
779
|
if (poly = @local_polymorphic_tags[tag])
|
|
752
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)
|
|
753
785
|
elsif html_element?(tag)
|
|
754
786
|
Element.new(tag: tag, attributes: attributes, children: children)
|
|
755
787
|
else
|
|
@@ -834,11 +866,30 @@ module JsxRosetta
|
|
|
834
866
|
when "ConditionalExpression" then lower_ternary_expression(expression)
|
|
835
867
|
when "Identifier" then lower_identifier_expression(expression)
|
|
836
868
|
when "CallExpression" then lower_call_expression(expression)
|
|
869
|
+
when "ArrowFunctionExpression", "FunctionExpression" then lower_render_prop(expression)
|
|
837
870
|
else
|
|
838
871
|
Interpolation.new(expression: source_of(expression))
|
|
839
872
|
end
|
|
840
873
|
end
|
|
841
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
|
+
|
|
842
893
|
def lower_jsx_comment(empty_expression)
|
|
843
894
|
comments = empty_expression.raw["innerComments"]
|
|
844
895
|
return nil if comments.nil? || comments.empty?
|
|
@@ -851,32 +902,50 @@ module JsxRosetta
|
|
|
851
902
|
loop_node || Interpolation.new(expression: source_of(expression))
|
|
852
903
|
end
|
|
853
904
|
|
|
854
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
855
905
|
def try_lower_map_loop(call_expression)
|
|
856
|
-
callee = call_expression
|
|
857
|
-
return nil unless callee
|
|
858
|
-
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")
|
|
859
908
|
|
|
860
|
-
|
|
861
|
-
return nil
|
|
862
|
-
return nil unless args.first.type == "ArrowFunctionExpression"
|
|
909
|
+
property = callee.child(:property)
|
|
910
|
+
return nil unless property && property[:name] == "map"
|
|
863
911
|
|
|
864
|
-
arrow =
|
|
865
|
-
|
|
866
|
-
return nil if params.empty? || params.size > 2
|
|
867
|
-
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
|
|
868
914
|
|
|
915
|
+
params = arrow[:params]
|
|
869
916
|
body = lower_arrow_body(arrow[:body])
|
|
870
917
|
return nil unless body
|
|
871
918
|
|
|
872
919
|
Loop.new(
|
|
873
|
-
iterable:
|
|
920
|
+
iterable: lower_loop_iterable(callee[:object]),
|
|
874
921
|
item_binding: params[0][:name],
|
|
875
922
|
index_binding: params[1] && params[1][:name],
|
|
876
923
|
body: body
|
|
877
924
|
)
|
|
878
925
|
end
|
|
879
|
-
|
|
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
|
|
880
949
|
|
|
881
950
|
def lower_arrow_body(body)
|
|
882
951
|
case body.type
|
|
@@ -939,21 +1008,27 @@ module JsxRosetta
|
|
|
939
1008
|
end
|
|
940
1009
|
end
|
|
941
1010
|
|
|
942
|
-
def lower_attribute(attr)
|
|
1011
|
+
def lower_attribute(attr, tag:)
|
|
943
1012
|
case attr
|
|
944
1013
|
when AST::JSXAttribute
|
|
945
|
-
lower_jsx_attribute(attr)
|
|
1014
|
+
lower_jsx_attribute(attr, tag: tag)
|
|
946
1015
|
when AST::JSXSpreadAttribute
|
|
947
1016
|
SpreadAttribute.new(expression: source_of(attr.argument))
|
|
948
1017
|
end
|
|
949
1018
|
end
|
|
950
1019
|
|
|
951
|
-
|
|
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:)
|
|
952
1027
|
name = attr.attribute_name
|
|
953
1028
|
|
|
954
1029
|
return lower_class_name(attr.value) if name == "className"
|
|
955
1030
|
return lower_style_attribute_or_fallback(attr.value) if name == "style"
|
|
956
|
-
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
|
|
1031
|
+
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer) && html_element?(tag)
|
|
957
1032
|
return lower_event_attribute(name, attr.value)
|
|
958
1033
|
end
|
|
959
1034
|
|
|
@@ -968,7 +1043,7 @@ module JsxRosetta
|
|
|
968
1043
|
return nil unless value.is_a?(AST::JSXExpressionContainer)
|
|
969
1044
|
|
|
970
1045
|
expression = value.expression
|
|
971
|
-
return nil unless
|
|
1046
|
+
return nil unless AST::Node.matches?(expression, "ObjectExpression")
|
|
972
1047
|
|
|
973
1048
|
declarations = expression[:properties].map { |prop| lower_style_property(prop) }
|
|
974
1049
|
return nil if declarations.any?(&:nil?)
|
|
@@ -1014,10 +1089,10 @@ module JsxRosetta
|
|
|
1014
1089
|
end
|
|
1015
1090
|
|
|
1016
1091
|
def try_lower_class_helper(expression)
|
|
1017
|
-
return nil unless
|
|
1092
|
+
return nil unless AST::Node.matches?(expression, "CallExpression")
|
|
1018
1093
|
|
|
1019
|
-
callee = expression
|
|
1020
|
-
return nil unless callee
|
|
1094
|
+
callee = expression.child(:callee)
|
|
1095
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1021
1096
|
return nil unless %w[cn clsx classnames].include?(callee[:name])
|
|
1022
1097
|
|
|
1023
1098
|
segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
|
|
@@ -1069,26 +1144,49 @@ module JsxRosetta
|
|
|
1069
1144
|
)
|
|
1070
1145
|
end
|
|
1071
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).
|
|
1072
1159
|
def try_promote_to_stimulus(attr_name, event, expression)
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
|
1075
1167
|
|
|
1076
|
-
|
|
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)
|
|
1077
1171
|
body_source = source_of(arrow_node[:body])
|
|
1078
|
-
@stimulus_methods << StimulusMethod.new(
|
|
1172
|
+
@stimulus_methods << StimulusMethod.new(
|
|
1173
|
+
name: method_name, body_source: body_source, original_name: base
|
|
1174
|
+
)
|
|
1079
1175
|
@local_arrows.delete(name_hint) if name_hint
|
|
1080
|
-
|
|
1081
1176
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
1082
1177
|
end
|
|
1083
1178
|
|
|
1084
|
-
def
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
[expression, nil]
|
|
1088
|
-
when "Identifier"
|
|
1089
|
-
arrow = @local_arrows[expression[:name]]
|
|
1090
|
-
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)
|
|
1091
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)
|
|
1092
1190
|
end
|
|
1093
1191
|
|
|
1094
1192
|
def default_stimulus_method_name(attr_name)
|
|
@@ -1108,12 +1206,86 @@ module JsxRosetta
|
|
|
1108
1206
|
when nil
|
|
1109
1207
|
true
|
|
1110
1208
|
when AST::JSXExpressionContainer
|
|
1111
|
-
|
|
1209
|
+
lower_value_expression(value.expression)
|
|
1112
1210
|
else
|
|
1113
1211
|
value.raw["value"]
|
|
1114
1212
|
end
|
|
1115
1213
|
end
|
|
1116
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
|
+
|
|
1117
1289
|
def style_binding_expression(value)
|
|
1118
1290
|
case value
|
|
1119
1291
|
when nil then "true"
|