jsx_rosetta 0.3.0 → 0.5.1
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 +334 -0
- data/README.md +69 -7
- data/ROADMAP.md +92 -0
- data/lib/jsx_rosetta/ast/inflector.rb +15 -0
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +1018 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +338 -25
- data/lib/jsx_rosetta/backend/view_component.rb +361 -77
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +790 -213
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +167 -0
- data/lib/jsx_rosetta/ir/types.rb +154 -22
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +4 -1
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "types"
|
|
4
|
+
require_relative "module_shape_classifier"
|
|
5
|
+
require_relative "../ast/inflector"
|
|
4
6
|
|
|
5
7
|
module JsxRosetta
|
|
6
8
|
module IR
|
|
7
9
|
# Lowers a parsed AST::File into an IR::Component tree.
|
|
8
10
|
#
|
|
9
|
-
#
|
|
10
|
-
# -
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# -
|
|
18
|
-
# IR::
|
|
19
|
-
#
|
|
20
|
-
#
|
|
11
|
+
# Responsibilities:
|
|
12
|
+
# - Component discovery — find function/arrow declarations whose
|
|
13
|
+
# name and body shape qualify as a function component.
|
|
14
|
+
# - Module-shape classification — when no component is found,
|
|
15
|
+
# produce a triage-friendly error message via SHAPE_MESSAGES.
|
|
16
|
+
# - Function-body lowering — turn return-bearing block statements,
|
|
17
|
+
# if-chains, switch/try statements, and bare expression returns
|
|
18
|
+
# into IR values (Conditional, Interpolation, Text, etc.).
|
|
19
|
+
# - JSX-node lowering — turn JSXElement / JSXFragment / JSXText /
|
|
20
|
+
# JSXExpressionContainer trees into IR::Element / Fragment /
|
|
21
|
+
# ComponentInvocation / Conditional / Loop / etc.
|
|
22
|
+
# - Pattern recognition — `cn()` / `clsx()` className helpers,
|
|
23
|
+
# `items.map(...)` loops, `cond ? <A/> : <B/>` polymorphic tags,
|
|
24
|
+
# React-hook calls, and onX={...} handlers promotable to
|
|
25
|
+
# Stimulus methods.
|
|
21
26
|
#
|
|
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.
|
|
27
|
+
# Anything outside these patterns is preserved verbatim as a TODO
|
|
28
|
+
# so the human reviewer sees the original JS at the right spot.
|
|
27
29
|
class Lowering
|
|
28
30
|
# A failure during AST → IR lowering. Carries optional line/column
|
|
29
31
|
# information when the failure can be tied to an AST node.
|
|
@@ -66,9 +68,52 @@ module JsxRosetta
|
|
|
66
68
|
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
67
69
|
].freeze
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
# Apollo Client hooks. `useQuery` / `useLazyQuery` / `useSubscription`
|
|
72
|
+
# take a GraphQL document as the first argument; `useMutation` returns
|
|
73
|
+
# a `[mutate, { loading, ... }]` tuple. None of these have a direct
|
|
74
|
+
# translation — they encode data fetching, which in Rails lives in
|
|
75
|
+
# the controller/model. Captured here so the backend can emit a
|
|
76
|
+
# per-hook TODO with the operation name preserved when extractable.
|
|
77
|
+
APOLLO_HOOKS = %w[
|
|
78
|
+
useQuery useLazyQuery useMutation useSubscription useApolloClient
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
# Next.js navigation hooks (App Router and Pages Router). Each has a
|
|
82
|
+
# Rails-side analog:
|
|
83
|
+
# useRouter → controller actions / redirect_to
|
|
84
|
+
# usePathname → request.path
|
|
85
|
+
# useSearchParams → params
|
|
86
|
+
# useParams → params (route params)
|
|
87
|
+
# useSelectedLayoutSegment(s) → not directly translatable; usually
|
|
88
|
+
# used to highlight nav links — the Rails view can pattern-match
|
|
89
|
+
# against request.path.
|
|
90
|
+
NEXT_HOOKS = %w[
|
|
91
|
+
useRouter usePathname useSearchParams useParams
|
|
92
|
+
useSelectedLayoutSegment useSelectedLayoutSegments
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
FRAMEWORK_HOOKS_BY_LIBRARY = {
|
|
96
|
+
react: REACT_HOOKS,
|
|
97
|
+
apollo: APOLLO_HOOKS,
|
|
98
|
+
next_js: NEXT_HOOKS
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
70
101
|
JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
|
|
71
|
-
|
|
102
|
+
|
|
103
|
+
# Mirrors React's `isUnitlessNumber` table — CSS properties that take
|
|
104
|
+
# a bare number rather than a length. Numeric style values for any
|
|
105
|
+
# property NOT in this set get a `px` suffix appended at lowering time.
|
|
106
|
+
UNITLESS_CSS_PROPERTIES = %w[
|
|
107
|
+
animation-iteration-count aspect-ratio border-image-outset
|
|
108
|
+
border-image-slice border-image-width box-flex box-flex-group
|
|
109
|
+
box-ordinal-group column-count columns flex flex-grow flex-negative
|
|
110
|
+
flex-order flex-positive flex-shrink font-weight grid-area
|
|
111
|
+
grid-column grid-column-end grid-column-span grid-column-start
|
|
112
|
+
grid-row grid-row-end grid-row-span grid-row-start line-clamp
|
|
113
|
+
line-height opacity order orphans scale tab-size widows z-index
|
|
114
|
+
zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
|
|
115
|
+
stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
|
|
116
|
+
].to_set.freeze
|
|
72
117
|
|
|
73
118
|
# Pre-lowering AST scan: maps a node type to a callable returning the
|
|
74
119
|
# AST nodes that contribute return values. Used by body_returns_jsx?.
|
|
@@ -106,11 +151,20 @@ module JsxRosetta
|
|
|
106
151
|
@prop_names = []
|
|
107
152
|
@local_jsx = {}
|
|
108
153
|
@local_bindings = []
|
|
154
|
+
@local_binding_names = []
|
|
109
155
|
@local_arrows = {}
|
|
110
156
|
@local_polymorphic_tags = {}
|
|
157
|
+
@local_destructures = {}
|
|
111
158
|
@stimulus_methods = []
|
|
112
159
|
@stimulus_seen_names = {}
|
|
113
160
|
@react_hooks = []
|
|
161
|
+
@render_methods = []
|
|
162
|
+
@render_method_seen = {}
|
|
163
|
+
# Class-component non-render members (constructor, lifecycle hooks,
|
|
164
|
+
# custom handlers). Keyed by class name; populated by
|
|
165
|
+
# extract_class_component, drained by lower_component to surface
|
|
166
|
+
# the verbatim sources as a TODO comment block.
|
|
167
|
+
@pending_class_other_members = {}
|
|
114
168
|
end
|
|
115
169
|
|
|
116
170
|
def lower_file(file)
|
|
@@ -118,148 +172,79 @@ module JsxRosetta
|
|
|
118
172
|
raise no_component_error(file.program) if candidates.empty?
|
|
119
173
|
|
|
120
174
|
name, function = candidates.first
|
|
121
|
-
|
|
175
|
+
module_bindings = capture_module_bindings(file.program, candidates)
|
|
176
|
+
attach_module_bindings(lower_component(name, function), module_bindings)
|
|
122
177
|
end
|
|
123
178
|
|
|
124
179
|
def lower_all_components(file)
|
|
125
180
|
candidates = find_component_functions(file.program)
|
|
126
181
|
raise no_component_error(file.program) if candidates.empty?
|
|
127
182
|
|
|
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"
|
|
183
|
+
module_bindings = capture_module_bindings(file.program, candidates)
|
|
184
|
+
candidates.map do |name, function|
|
|
185
|
+
attach_module_bindings(lower_component(name, function), module_bindings)
|
|
186
|
+
end
|
|
193
187
|
end
|
|
194
188
|
|
|
195
|
-
|
|
196
|
-
|
|
189
|
+
# Walk the program body for top-level `const`/`let` declarations that
|
|
190
|
+
# aren't component declarations. Capture each as a LocalBinding so
|
|
191
|
+
# backends can emit them as Ruby constants (or as a TODO comment for
|
|
192
|
+
# non-literal initializers) before the class definition. Without
|
|
193
|
+
# this, `const FOO = 400; function X() { return <p>{FOO}</p> }` would
|
|
194
|
+
# silently drop the FOO declaration and emit an unbacked `foo`
|
|
195
|
+
# reference inside the view template.
|
|
196
|
+
def capture_module_bindings(program, candidates)
|
|
197
|
+
component_names = candidates.to_set(&:first)
|
|
198
|
+
bindings = []
|
|
199
|
+
program.body.each do |stmt|
|
|
200
|
+
walk_module_binding(stmt, component_names, bindings)
|
|
201
|
+
end
|
|
202
|
+
bindings
|
|
197
203
|
end
|
|
198
204
|
|
|
199
|
-
def
|
|
200
|
-
|
|
201
|
-
|
|
205
|
+
def walk_module_binding(stmt, component_names, bindings)
|
|
206
|
+
case stmt.type
|
|
207
|
+
when "VariableDeclaration"
|
|
208
|
+
stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
|
|
209
|
+
when "ExportNamedDeclaration"
|
|
210
|
+
decl = stmt[:declaration]
|
|
211
|
+
walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
|
|
212
|
+
end
|
|
202
213
|
end
|
|
203
214
|
|
|
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"
|
|
215
|
+
def record_module_binding(stmt, declarator, component_names, bindings)
|
|
216
|
+
init = declarator[:init]
|
|
217
|
+
return unless init.is_a?(AST::Node)
|
|
210
218
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
# Component declarators (`const Foo = () => ...`) are handled by
|
|
220
|
+
# the component pipeline; skip them here so the source doesn't
|
|
221
|
+
# show up twice.
|
|
222
|
+
return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
|
|
223
|
+
component_names.include?(declarator[:id]&.[](:name))
|
|
216
224
|
|
|
217
|
-
|
|
218
|
-
return
|
|
225
|
+
name = declarator[:id]&.[](:name)
|
|
226
|
+
return unless name
|
|
219
227
|
|
|
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
|
|
228
|
+
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
227
229
|
end
|
|
228
230
|
|
|
229
|
-
def
|
|
230
|
-
return
|
|
231
|
+
def attach_module_bindings(component, module_bindings)
|
|
232
|
+
return component if module_bindings.empty?
|
|
231
233
|
|
|
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" }
|
|
234
|
+
component.with(module_bindings: module_bindings)
|
|
237
235
|
end
|
|
238
236
|
|
|
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
|
|
237
|
+
private
|
|
246
238
|
|
|
247
|
-
def
|
|
248
|
-
|
|
239
|
+
def lowering_error(message, node: nil)
|
|
240
|
+
LoweringError.new(message, node: node, source: @source)
|
|
249
241
|
end
|
|
250
242
|
|
|
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
|
|
243
|
+
def no_component_error(program)
|
|
244
|
+
shape = ModuleShapeClassifier.classify(program)
|
|
245
|
+
message = SHAPE_MESSAGES[shape]
|
|
246
|
+
suffix = message ? " — #{message}" : ""
|
|
247
|
+
lowering_error("no component function found in module#{suffix}")
|
|
263
248
|
end
|
|
264
249
|
|
|
265
250
|
def find_component_functions(program)
|
|
@@ -278,6 +263,7 @@ module JsxRosetta
|
|
|
278
263
|
return false if name.nil? || name.empty?
|
|
279
264
|
return true if pascal_case?(name)
|
|
280
265
|
return false if hook_name?(name)
|
|
266
|
+
return true if extract_data_factory_array(function)
|
|
281
267
|
|
|
282
268
|
body_returns_jsx?(function[:body])
|
|
283
269
|
end
|
|
@@ -287,6 +273,10 @@ module JsxRosetta
|
|
|
287
273
|
first == first.upcase && first != first.downcase
|
|
288
274
|
end
|
|
289
275
|
|
|
276
|
+
def hook_name?(name)
|
|
277
|
+
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
278
|
+
end
|
|
279
|
+
|
|
290
280
|
# Pre-lowering AST scan: does any return path in this body produce a
|
|
291
281
|
# JSX value? Used only as a heuristic for component_function?, so a
|
|
292
282
|
# false positive is a translation attempt that may TODO out, while
|
|
@@ -311,6 +301,8 @@ module JsxRosetta
|
|
|
311
301
|
[[stmt[:id]&.[](:name), stmt]]
|
|
312
302
|
when "VariableDeclaration"
|
|
313
303
|
extract_arrow_components(stmt)
|
|
304
|
+
when "ClassDeclaration"
|
|
305
|
+
extract_class_component(stmt)
|
|
314
306
|
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
315
307
|
extract_exported_components(stmt[:declaration])
|
|
316
308
|
else
|
|
@@ -324,10 +316,133 @@ module JsxRosetta
|
|
|
324
316
|
case declaration.type
|
|
325
317
|
when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
|
|
326
318
|
when "VariableDeclaration" then extract_arrow_components(declaration)
|
|
319
|
+
when "ClassDeclaration" then extract_class_component(declaration)
|
|
327
320
|
else []
|
|
328
321
|
end
|
|
329
322
|
end
|
|
330
323
|
|
|
324
|
+
# Recognize a class component by the presence of a `render()` method.
|
|
325
|
+
# We don't require `extends React.Component` because TypeScript codebases
|
|
326
|
+
# often declare the parent via an `extends` of a typed alias. The render
|
|
327
|
+
# method's signature (no args, returns JSX) is the JSX-component signal.
|
|
328
|
+
#
|
|
329
|
+
# The render ClassMethod's `[:params]` is always `[]` and `[:body]` is a
|
|
330
|
+
# BlockStatement — same shape as a function declaration's body, so the
|
|
331
|
+
# rest of the lowering pipeline works unchanged. Other class members
|
|
332
|
+
# (constructor, lifecycle hooks, custom handlers) get stashed on
|
|
333
|
+
# `@pending_class_other_members` keyed by class name, then surfaced as
|
|
334
|
+
# a LocalBinding-style TODO block by `lower_component`.
|
|
335
|
+
def extract_class_component(class_decl)
|
|
336
|
+
name = class_decl[:id]&.[](:name)
|
|
337
|
+
return [] unless name
|
|
338
|
+
|
|
339
|
+
render_method, other_members = partition_class_members(class_decl)
|
|
340
|
+
return [] unless render_method
|
|
341
|
+
|
|
342
|
+
@pending_class_other_members[name] = other_members
|
|
343
|
+
[[name, render_method]]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def partition_class_members(class_decl)
|
|
347
|
+
body = class_decl.child(:body)
|
|
348
|
+
return [nil, []] unless body
|
|
349
|
+
|
|
350
|
+
render_method = nil
|
|
351
|
+
others = []
|
|
352
|
+
body[:body].each do |member|
|
|
353
|
+
if class_render_method?(member)
|
|
354
|
+
render_method = member
|
|
355
|
+
else
|
|
356
|
+
others << member
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
[render_method, others]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def class_render_method?(member)
|
|
363
|
+
return false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
|
|
364
|
+
|
|
365
|
+
key = member.child(:key)
|
|
366
|
+
AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Surface every non-render class member (constructor, lifecycle
|
|
370
|
+
# methods like componentDidMount / componentDidCatch / getDerivedStateFromError,
|
|
371
|
+
# custom event handlers) as a LocalBinding-shaped TODO with the
|
|
372
|
+
# verbatim JS source preserved. The user either translates each to a
|
|
373
|
+
# Ruby method by hand or moves the behavior to Stimulus / controllers.
|
|
374
|
+
def absorb_class_other_members(name)
|
|
375
|
+
members = @pending_class_other_members.delete(name) || []
|
|
376
|
+
members.each do |member|
|
|
377
|
+
source = source_of(member).strip
|
|
378
|
+
member_name = class_member_label(member)
|
|
379
|
+
@local_bindings << LocalBinding.new(name: member_name, source: source)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def class_member_label(member)
|
|
384
|
+
key = member.child(:key)
|
|
385
|
+
return "<class member>" unless key
|
|
386
|
+
|
|
387
|
+
case key.type
|
|
388
|
+
when "Identifier" then key[:name]
|
|
389
|
+
when "StringLiteral" then key[:value]
|
|
390
|
+
else "<class member>"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Pre-scan the render method body for `this.props.X` member access
|
|
395
|
+
# patterns. Each unique X becomes a synthesized IR::Prop entry on the
|
|
396
|
+
# component, so the generated class emits a matching `initialize(x:)`
|
|
397
|
+
# and the translator (which sees `@x`) resolves cleanly. Without this
|
|
398
|
+
# scan, render references would land in `unresolved_identifiers` and
|
|
399
|
+
# the generated initializer would be empty.
|
|
400
|
+
def absorb_class_render_props(render_method)
|
|
401
|
+
body = render_method.child(:body)
|
|
402
|
+
return [] unless body
|
|
403
|
+
|
|
404
|
+
prop_names = []
|
|
405
|
+
scan_this_props(body, prop_names)
|
|
406
|
+
prop_names.uniq.map { |name| Prop.new(name: name, default: nil, alias_name: nil) }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def scan_this_props(node, accumulator)
|
|
410
|
+
return unless node.is_a?(AST::Node)
|
|
411
|
+
|
|
412
|
+
if node.of_type?("MemberExpression") && this_props_access?(node)
|
|
413
|
+
accumulator << node[:property][:name]
|
|
414
|
+
elsif node.of_type?("VariableDeclarator") && this_props_destructure?(node)
|
|
415
|
+
destructured_names_of(node[:id]).each { |name| accumulator << name }
|
|
416
|
+
end
|
|
417
|
+
node.each_child { |child| scan_this_props(child, accumulator) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Match `this.props.X` exactly — `this.props` member access where the
|
|
421
|
+
# property side is also a MemberExpression. We don't follow deeper
|
|
422
|
+
# chains here; only the immediate `.X` after `.props` becomes a prop
|
|
423
|
+
# name. `this.props.foo.bar` still yields prop name `foo`.
|
|
424
|
+
def this_props_access?(member_expr)
|
|
425
|
+
object = member_expr.child(:object)
|
|
426
|
+
return false unless AST::Node.matches?(object, "MemberExpression")
|
|
427
|
+
return false unless AST::Node.matches?(object.child(:object), "ThisExpression")
|
|
428
|
+
|
|
429
|
+
object_prop = object.child(:property)
|
|
430
|
+
AST::Node.matches?(object_prop, "Identifier") && object_prop[:name] == "props"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# `const { foo, bar } = this.props;` — destructure off this.props. Each
|
|
434
|
+
# destructured name becomes a prop. We don't need to walk the rest of
|
|
435
|
+
# the chain because the destructure consumes one level of `.props`.
|
|
436
|
+
def this_props_destructure?(declarator)
|
|
437
|
+
init = declarator[:init]
|
|
438
|
+
return false unless AST::Node.matches?(init, "MemberExpression")
|
|
439
|
+
return false unless AST::Node.matches?(init.child(:object), "ThisExpression")
|
|
440
|
+
return false unless destructure_pattern?(declarator[:id])
|
|
441
|
+
|
|
442
|
+
prop = init.child(:property)
|
|
443
|
+
AST::Node.matches?(prop, "Identifier") && prop[:name] == "props"
|
|
444
|
+
end
|
|
445
|
+
|
|
331
446
|
def extract_arrow_components(variable_declaration)
|
|
332
447
|
variable_declaration[:declarations].filter_map do |declarator|
|
|
333
448
|
init = declarator[:init]
|
|
@@ -344,42 +459,129 @@ module JsxRosetta
|
|
|
344
459
|
raise lowering_error("anonymous component functions are not supported", node: function)
|
|
345
460
|
end
|
|
346
461
|
|
|
462
|
+
reset_per_component_state!
|
|
347
463
|
props, rest_prop_name = lower_params(function[:params])
|
|
348
464
|
@prop_names = props.map(&:name)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
465
|
+
absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
|
|
466
|
+
|
|
467
|
+
body, mode = lower_component_body(function)
|
|
468
|
+
# Unconsumed local arrows (`const handleClick = () => ...` that didn't
|
|
469
|
+
# become a Stimulus method or a render-method) are still real bindings
|
|
470
|
+
# in the source. Add their names here so the translator treats use
|
|
471
|
+
# sites as known-unresolvable — `on_click: handle_click` (NameError)
|
|
472
|
+
# becomes `on_click: nil` (file loads, marker visible upstream).
|
|
473
|
+
unconsumed_arrow_names = @local_arrows.keys
|
|
358
474
|
Component.new(
|
|
359
475
|
name: name,
|
|
360
476
|
props: props,
|
|
361
477
|
body: body,
|
|
362
478
|
rest_prop_name: rest_prop_name,
|
|
363
479
|
local_bindings: @local_bindings,
|
|
480
|
+
local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
|
|
481
|
+
module_bindings: [],
|
|
364
482
|
stimulus_methods: @stimulus_methods,
|
|
365
|
-
react_hooks: @react_hooks
|
|
483
|
+
react_hooks: @react_hooks,
|
|
484
|
+
render_methods: @render_methods,
|
|
485
|
+
mode: mode
|
|
366
486
|
)
|
|
367
487
|
end
|
|
368
488
|
|
|
489
|
+
# A "data factory" function — common for AG-Grid / antd column
|
|
490
|
+
# descriptor modules — is a function whose body just returns an array
|
|
491
|
+
# of object literals (`export const createColumns = (...) => [{...},
|
|
492
|
+
# {...}]`). When we recognize this shape, we lower the body via the
|
|
493
|
+
# recursive ObjectLiteral/ArrayLiteral path (Gap H) and let the
|
|
494
|
+
# backend emit a snake_case method that returns the data, instead of
|
|
495
|
+
# a `view_template`. JSX inside object properties still extracts to
|
|
496
|
+
# private methods on the class via the IR::Lambda extraction.
|
|
497
|
+
def lower_component_body(function)
|
|
498
|
+
factory_array = extract_data_factory_array(function)
|
|
499
|
+
return [lower_value_expression(factory_array), :data_factory] if factory_array
|
|
500
|
+
|
|
501
|
+
[lower_function_body(function[:body]), :view]
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def extract_data_factory_array(function)
|
|
505
|
+
body = function[:body]
|
|
506
|
+
return nil unless body.is_a?(AST::Node)
|
|
507
|
+
|
|
508
|
+
# Implicit-return arrow: body IS the ArrayExpression.
|
|
509
|
+
return body if data_factory_candidate_array?(body)
|
|
510
|
+
|
|
511
|
+
return nil unless body.of_type?("BlockStatement")
|
|
512
|
+
|
|
513
|
+
return_stmt = body[:body].last
|
|
514
|
+
return nil unless AST::Node.matches?(return_stmt, "ReturnStatement")
|
|
515
|
+
|
|
516
|
+
arg = return_stmt[:argument]
|
|
517
|
+
data_factory_candidate_array?(arg) ? arg : nil
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def data_factory_candidate_array?(node)
|
|
521
|
+
return false unless AST::Node.matches?(node, "ArrayExpression")
|
|
522
|
+
|
|
523
|
+
# At least one element should be an object literal — otherwise
|
|
524
|
+
# this is probably a primitive list, which doesn't warrant the
|
|
525
|
+
# extra emission machinery and can stay as a regular
|
|
526
|
+
# `body_returns_jsx?` rejection.
|
|
527
|
+
node[:elements].any? { |el| AST::Node.matches?(el, "ObjectExpression") }
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def reset_per_component_state!
|
|
531
|
+
@local_bindings = []
|
|
532
|
+
@local_binding_names = []
|
|
533
|
+
@local_arrows = {}
|
|
534
|
+
@local_polymorphic_tags = {}
|
|
535
|
+
@local_destructures = {}
|
|
536
|
+
@stimulus_methods = []
|
|
537
|
+
@stimulus_seen_names = {}
|
|
538
|
+
@react_hooks = []
|
|
539
|
+
@render_methods = []
|
|
540
|
+
@render_method_seen = {}
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def absorb_class_metadata(name, render_method, props)
|
|
544
|
+
absorb_class_other_members(name)
|
|
545
|
+
props.concat(absorb_class_render_props(render_method))
|
|
546
|
+
@prop_names = props.map(&:name)
|
|
547
|
+
end
|
|
548
|
+
|
|
369
549
|
def lower_params(params)
|
|
370
550
|
return [[], nil] if params.nil? || params.empty?
|
|
371
551
|
|
|
552
|
+
# Multi-positional params — typical for data-factory functions
|
|
553
|
+
# like `createColumns(token, sortedInfo)` and lowercase JSX-helpers
|
|
554
|
+
# like `getAlertIcon(level, status, token, isSnoozed = false)`.
|
|
555
|
+
# Each becomes a Prop with no default. We support Identifier and
|
|
556
|
+
# `AssignmentPattern` (default values get dropped — translating
|
|
557
|
+
# JS defaults to Ruby isn't worth the risk here). Other shapes
|
|
558
|
+
# in a multi-param signature fall through to legacy first-param-
|
|
559
|
+
# only handling so we don't regress files that used to translate.
|
|
560
|
+
if params.size > 1 && params.all? { |p| multi_param_supported?(p) }
|
|
561
|
+
return [params.map { |p| Prop.new(name: multi_param_name(p), default: nil, alias_name: nil) }, nil]
|
|
562
|
+
end
|
|
563
|
+
|
|
372
564
|
first_param = params.first
|
|
373
565
|
case first_param.type
|
|
374
566
|
when "ObjectPattern"
|
|
375
567
|
lower_object_pattern_params(first_param)
|
|
376
568
|
when "Identifier"
|
|
377
|
-
[[Prop.new(name: first_param[:name], default: nil)], nil]
|
|
569
|
+
[[Prop.new(name: first_param[:name], default: nil, alias_name: nil)], nil]
|
|
378
570
|
else
|
|
379
571
|
raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
|
|
380
572
|
end
|
|
381
573
|
end
|
|
382
574
|
|
|
575
|
+
def multi_param_supported?(param)
|
|
576
|
+
return true if AST::Node.matches?(param, "Identifier")
|
|
577
|
+
|
|
578
|
+
AST::Node.matches?(param, "AssignmentPattern") && AST::Node.matches?(param[:left], "Identifier")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def multi_param_name(param)
|
|
582
|
+
param.type == "Identifier" ? param[:name] : param[:left][:name]
|
|
583
|
+
end
|
|
584
|
+
|
|
383
585
|
def lower_object_pattern_params(pattern)
|
|
384
586
|
props = []
|
|
385
587
|
rest_name = nil
|
|
@@ -401,8 +603,26 @@ module JsxRosetta
|
|
|
401
603
|
key = property[:key]
|
|
402
604
|
prop_name = key.type == "StringLiteral" ? key[:value] : key[:name]
|
|
403
605
|
value = property[:value]
|
|
404
|
-
default
|
|
405
|
-
|
|
606
|
+
# Route prop default expressions through the recursive value
|
|
607
|
+
# lowering so object/array literals translate cleanly, instead of
|
|
608
|
+
# producing an opaque Interpolation that the backend would emit as
|
|
609
|
+
# `nil # TODO: ...`. The trailing `#` comment inside a method
|
|
610
|
+
# parameter list swallows the closing `)` and breaks Ruby syntax.
|
|
611
|
+
default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
|
|
612
|
+
alias_name = destructure_alias_for(prop_name, value)
|
|
613
|
+
Prop.new(name: prop_name, default: default, alias_name: alias_name)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# `{ "data-testid": dataTestId }` — extract the alias so use sites of
|
|
617
|
+
# `dataTestId` resolve to the prop's `@data_testid` ivar instead of
|
|
618
|
+
# leaking as a bare snake_case ref. Returns nil for the non-aliased
|
|
619
|
+
# case (`{ loading }` — key.name == value.name).
|
|
620
|
+
def destructure_alias_for(prop_name, value)
|
|
621
|
+
target = value.type == "AssignmentPattern" ? value[:left] : value
|
|
622
|
+
return nil unless AST::Node.matches?(target, "Identifier")
|
|
623
|
+
return nil if target[:name] == prop_name
|
|
624
|
+
|
|
625
|
+
target[:name]
|
|
406
626
|
end
|
|
407
627
|
|
|
408
628
|
def lower_function_body(body)
|
|
@@ -454,7 +674,7 @@ module JsxRosetta
|
|
|
454
674
|
# around the base value as outer Conditionals. Returns nil when no shape
|
|
455
675
|
# matches; caller raises.
|
|
456
676
|
def lower_block_returns(statements)
|
|
457
|
-
return_idx = statements.index { |s|
|
|
677
|
+
return_idx = statements.index { |s| AST::Node.matches?(s, "ReturnStatement") }
|
|
458
678
|
|
|
459
679
|
if return_idx
|
|
460
680
|
return_arg = statements[return_idx][:argument]
|
|
@@ -492,20 +712,22 @@ module JsxRosetta
|
|
|
492
712
|
# behavior of dropping unrepresentable preceding statements rather
|
|
493
713
|
# than failing the whole component.
|
|
494
714
|
def wrap_return_guards(preceding, base_value)
|
|
495
|
-
preceding.reverse.
|
|
496
|
-
next unless
|
|
497
|
-
next if stmt[:alternate]
|
|
715
|
+
preceding.reverse.reduce(base_value) do |acc, stmt|
|
|
716
|
+
next acc unless guard_if_statement?(stmt)
|
|
498
717
|
|
|
499
718
|
branch_value = lower_return_branch(stmt[:consequent])
|
|
500
|
-
next unless branch_value
|
|
719
|
+
next acc unless branch_value
|
|
501
720
|
|
|
502
|
-
|
|
721
|
+
Conditional.new(
|
|
503
722
|
test: Interpolation.new(expression: source_of(stmt[:test])),
|
|
504
723
|
consequent: branch_value,
|
|
505
|
-
alternate:
|
|
724
|
+
alternate: acc
|
|
506
725
|
)
|
|
507
726
|
end
|
|
508
|
-
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def guard_if_statement?(stmt)
|
|
730
|
+
AST::Node.matches?(stmt, "IfStatement") && stmt[:alternate].nil?
|
|
509
731
|
end
|
|
510
732
|
|
|
511
733
|
def lower_if_return_chain(if_stmt)
|
|
@@ -547,7 +769,7 @@ module JsxRosetta
|
|
|
547
769
|
|
|
548
770
|
def collect_nested_local_bindings(stmts)
|
|
549
771
|
stmts.each do |stmt|
|
|
550
|
-
next unless
|
|
772
|
+
next unless AST::Node.matches?(stmt, "VariableDeclaration")
|
|
551
773
|
|
|
552
774
|
seen = {}
|
|
553
775
|
stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
|
|
@@ -625,10 +847,10 @@ module JsxRosetta
|
|
|
625
847
|
# case A: if (X) return Y; return Z; (guard prefix + return)
|
|
626
848
|
# Trailing `break` statements are ignored.
|
|
627
849
|
def lower_switch_case_consequent(stmts)
|
|
628
|
-
filtered = stmts.reject { |s|
|
|
850
|
+
filtered = stmts.reject { |s| AST::Node.matches?(s, "BreakStatement") }
|
|
629
851
|
return nil if filtered.empty?
|
|
630
852
|
|
|
631
|
-
if filtered.size == 1 &&
|
|
853
|
+
if filtered.size == 1 && AST::Node.matches?(filtered.first, "BlockStatement")
|
|
632
854
|
return lower_switch_case_consequent(filtered.first[:body])
|
|
633
855
|
end
|
|
634
856
|
|
|
@@ -650,6 +872,7 @@ module JsxRosetta
|
|
|
650
872
|
@local_jsx = {}
|
|
651
873
|
@local_arrows = {}
|
|
652
874
|
@local_polymorphic_tags = {}
|
|
875
|
+
@local_destructures = {}
|
|
653
876
|
seen_other_stmts = {}
|
|
654
877
|
|
|
655
878
|
statements.each do |stmt|
|
|
@@ -666,14 +889,54 @@ module JsxRosetta
|
|
|
666
889
|
init = declarator[:init]
|
|
667
890
|
return unless init.is_a?(AST::Node)
|
|
668
891
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
892
|
+
# `const { foo, bar } = this.props` — destructured names already
|
|
893
|
+
# got synthesized into `props:` by absorb_class_render_props at
|
|
894
|
+
# class-component setup time. Skip the LocalBinding TODO + the
|
|
895
|
+
# local_binding_names capture so the translator picks up the prop
|
|
896
|
+
# form (`@foo`) instead of emitting a `nil` placeholder.
|
|
897
|
+
return if this_props_destructure?(declarator)
|
|
673
898
|
|
|
674
|
-
|
|
899
|
+
library = hook_library_for(init)
|
|
900
|
+
record_hook_call(stmt, init, library) if library
|
|
901
|
+
|
|
902
|
+
id_node = declarator[:id]
|
|
903
|
+
return handle_destructure_binding(stmt, declarator, init, seen, !library.nil?) if destructure_pattern?(id_node)
|
|
904
|
+
return handle_identifier_hook_binding(id_node) if library
|
|
905
|
+
|
|
906
|
+
name = id_node&.[](:name)
|
|
675
907
|
return unless name
|
|
676
908
|
|
|
909
|
+
dispatch_identifier_binding(stmt, init, name, seen)
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def record_hook_call(stmt, call_expression, library)
|
|
913
|
+
@react_hooks << ReactHookCall.new(
|
|
914
|
+
hook: call_expression[:callee][:name],
|
|
915
|
+
source: source_of(stmt).strip,
|
|
916
|
+
library: library,
|
|
917
|
+
operation: apollo_operation_name(call_expression, library)
|
|
918
|
+
)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# `const [a, b] = ...` or `const { a, b } = ...`. Capture every bound
|
|
922
|
+
# name so the translator recognizes them as known locals. Hook
|
|
923
|
+
# destructures (`const [open, setOpen] = useState(0)`) contribute
|
|
924
|
+
# names but not a separate LocalBinding TODO — the hook's source
|
|
925
|
+
# already shows the binding to the reviewer.
|
|
926
|
+
def handle_destructure_binding(stmt, declarator, init, seen, is_hook)
|
|
927
|
+
record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Identifier-bound hook result (`const handleChange = useCallback(...)`).
|
|
931
|
+
# The hook source is already in @react_hooks; just mark the binding
|
|
932
|
+
# name as known-local so use sites translate to `nil` instead of a
|
|
933
|
+
# bare snake_case ref that NameErrors at render time.
|
|
934
|
+
def handle_identifier_hook_binding(id_node)
|
|
935
|
+
name = id_node&.[](:name)
|
|
936
|
+
@local_binding_names << name if name
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def dispatch_identifier_binding(stmt, init, name, seen)
|
|
677
940
|
case init.type
|
|
678
941
|
when "JSXElement", "JSXFragment"
|
|
679
942
|
@local_jsx[name] = init
|
|
@@ -687,19 +950,117 @@ module JsxRosetta
|
|
|
687
950
|
end
|
|
688
951
|
end
|
|
689
952
|
|
|
953
|
+
def destructure_pattern?(node)
|
|
954
|
+
AST::Node.matches?(node, "ArrayPattern", "ObjectPattern")
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def record_destructured_names(stmt, declarator, init:, seen:, is_hook:)
|
|
958
|
+
pattern = declarator[:id]
|
|
959
|
+
names = destructured_names_of(pattern)
|
|
960
|
+
return if names.empty?
|
|
961
|
+
|
|
962
|
+
# Member-expression-style destructuring (Gap J): `const { Content } = Layout`
|
|
963
|
+
# binds `Content` to `Layout.Content`. Record those so JSX use sites
|
|
964
|
+
# resolve to the right component.
|
|
965
|
+
track_member_destructures(pattern, init) if AST::Node.matches?(init, "Identifier")
|
|
966
|
+
|
|
967
|
+
@local_binding_names.concat(names)
|
|
968
|
+
return if is_hook
|
|
969
|
+
|
|
970
|
+
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
971
|
+
names.each { |name| @local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos]) }
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# Return the flat list of Identifier names bound by a destructuring
|
|
975
|
+
# pattern. Nested patterns recurse. RestElement / aliased properties
|
|
976
|
+
# are included; defaults (AssignmentPattern) are followed to their
|
|
977
|
+
# left-hand identifier.
|
|
978
|
+
def destructured_names_of(pattern)
|
|
979
|
+
return [] unless pattern.is_a?(AST::Node)
|
|
980
|
+
|
|
981
|
+
case pattern.type
|
|
982
|
+
when "Identifier" then [pattern[:name]]
|
|
983
|
+
when "ArrayPattern"
|
|
984
|
+
pattern[:elements].flat_map { |element| element ? destructured_names_of(element) : [] }
|
|
985
|
+
when "ObjectPattern"
|
|
986
|
+
pattern[:properties].flat_map { |prop| destructured_names_from_property(prop) }
|
|
987
|
+
when "RestElement"
|
|
988
|
+
destructured_names_of(pattern[:argument])
|
|
989
|
+
when "AssignmentPattern"
|
|
990
|
+
destructured_names_of(pattern[:left])
|
|
991
|
+
else
|
|
992
|
+
[]
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
def destructured_names_from_property(prop)
|
|
997
|
+
case prop.type
|
|
998
|
+
when "ObjectProperty" then destructured_names_of(prop[:value])
|
|
999
|
+
when "RestElement" then destructured_names_of(prop[:argument])
|
|
1000
|
+
else []
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def track_member_destructures(pattern, source_identifier)
|
|
1005
|
+
return unless AST::Node.matches?(pattern, "ObjectPattern")
|
|
1006
|
+
|
|
1007
|
+
source_name = source_identifier[:name]
|
|
1008
|
+
pattern[:properties].each do |prop|
|
|
1009
|
+
next unless prop.type == "ObjectProperty"
|
|
1010
|
+
|
|
1011
|
+
value = prop[:value]
|
|
1012
|
+
next unless AST::Node.matches?(value, "Identifier")
|
|
1013
|
+
|
|
1014
|
+
@local_destructures[value[:name]] = source_name
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
690
1018
|
def detect_bare_hook_call(stmt)
|
|
691
|
-
expr = stmt
|
|
692
|
-
return unless expr
|
|
693
|
-
|
|
1019
|
+
expr = stmt.child(:expression)
|
|
1020
|
+
return unless expr&.of_type?("CallExpression")
|
|
1021
|
+
|
|
1022
|
+
library = hook_library_for(expr)
|
|
1023
|
+
return unless library
|
|
694
1024
|
|
|
695
|
-
|
|
1025
|
+
record_hook_call(stmt, expr, library)
|
|
696
1026
|
end
|
|
697
1027
|
|
|
698
1028
|
def hook_call?(call_expression)
|
|
699
|
-
|
|
1029
|
+
!hook_library_for(call_expression).nil?
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
# Resolve a CallExpression to the library whose hook set its callee
|
|
1033
|
+
# belongs to (`:react`, `:apollo`, `:next_js`), or nil when it isn't
|
|
1034
|
+
# a recognized hook invocation. Lookup is by bare-Identifier callee
|
|
1035
|
+
# only — member-expression callees (`Apollo.useQuery`) aren't
|
|
1036
|
+
# recognized; we follow what production code actually writes.
|
|
1037
|
+
def hook_library_for(call_expression)
|
|
1038
|
+
return nil unless call_expression.is_a?(AST::Node) && call_expression.of_type?("CallExpression")
|
|
700
1039
|
|
|
701
|
-
callee = call_expression
|
|
702
|
-
callee
|
|
1040
|
+
callee = call_expression.child(:callee)
|
|
1041
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1042
|
+
|
|
1043
|
+
name = callee[:name]
|
|
1044
|
+
FRAMEWORK_HOOKS_BY_LIBRARY.each do |library, names|
|
|
1045
|
+
return library if names.include?(name)
|
|
1046
|
+
end
|
|
1047
|
+
nil
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
# For Apollo's document-first hooks (`useQuery(GET_USERS, ...)`),
|
|
1051
|
+
# extract the operation name from a bare-Identifier first argument
|
|
1052
|
+
# so the backend can echo it in the TODO. Returns nil for inline
|
|
1053
|
+
# documents (`gql\`...\``), member-expression args, or non-Apollo
|
|
1054
|
+
# hooks — the caller already has the verbatim source in `source`,
|
|
1055
|
+
# which surfaces those cases to the reviewer.
|
|
1056
|
+
def apollo_operation_name(call_expression, library)
|
|
1057
|
+
return nil unless library == :apollo
|
|
1058
|
+
|
|
1059
|
+
args = call_expression[:arguments]
|
|
1060
|
+
first_arg = args.is_a?(Array) ? args.first : nil
|
|
1061
|
+
return nil unless AST::Node.matches?(first_arg, "Identifier")
|
|
1062
|
+
|
|
1063
|
+
first_arg[:name]
|
|
703
1064
|
end
|
|
704
1065
|
|
|
705
1066
|
# Recognize the asChild-style polymorphic tag pattern:
|
|
@@ -727,6 +1088,7 @@ module JsxRosetta
|
|
|
727
1088
|
def record_local_other_binding(stmt, name, seen)
|
|
728
1089
|
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
729
1090
|
@local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
|
|
1091
|
+
@local_binding_names << name
|
|
730
1092
|
end
|
|
731
1093
|
|
|
732
1094
|
def lower_jsx(node)
|
|
@@ -742,7 +1104,7 @@ module JsxRosetta
|
|
|
742
1104
|
|
|
743
1105
|
def lower_jsx_element(element)
|
|
744
1106
|
tag = element.tag_name
|
|
745
|
-
attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr) }
|
|
1107
|
+
attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr, tag: tag) }
|
|
746
1108
|
# `key` is a React-only reconciliation hint; never emit it to the DOM
|
|
747
1109
|
# or to ViewComponent invocations.
|
|
748
1110
|
attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
|
|
@@ -750,6 +1112,10 @@ module JsxRosetta
|
|
|
750
1112
|
|
|
751
1113
|
if (poly = @local_polymorphic_tags[tag])
|
|
752
1114
|
lower_polymorphic_tag_use(poly, attributes, children)
|
|
1115
|
+
elsif (parent = @local_destructures[tag])
|
|
1116
|
+
# Gap J: `const { Content } = Layout; <Content/>` should resolve
|
|
1117
|
+
# to `Layout::Content`, not a bare `ContentComponent`.
|
|
1118
|
+
ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
|
|
753
1119
|
elsif html_element?(tag)
|
|
754
1120
|
Element.new(tag: tag, attributes: attributes, children: children)
|
|
755
1121
|
else
|
|
@@ -834,11 +1200,30 @@ module JsxRosetta
|
|
|
834
1200
|
when "ConditionalExpression" then lower_ternary_expression(expression)
|
|
835
1201
|
when "Identifier" then lower_identifier_expression(expression)
|
|
836
1202
|
when "CallExpression" then lower_call_expression(expression)
|
|
1203
|
+
when "ArrowFunctionExpression", "FunctionExpression" then lower_render_prop(expression)
|
|
837
1204
|
else
|
|
838
1205
|
Interpolation.new(expression: source_of(expression))
|
|
839
1206
|
end
|
|
840
1207
|
end
|
|
841
1208
|
|
|
1209
|
+
# Recognize the render-prop / function-as-children pattern:
|
|
1210
|
+
# <Form.List>{(fields, helpers) => <div>{fields}</div>}</Form.List>
|
|
1211
|
+
# Returns nil (caller falls back to verbatim interpolation) when the
|
|
1212
|
+
# arrow has zero or too many params, or when the body doesn't lower
|
|
1213
|
+
# cleanly to a JSX child.
|
|
1214
|
+
def lower_render_prop(arrow)
|
|
1215
|
+
params = arrow[:params]
|
|
1216
|
+
return Interpolation.new(expression: source_of(arrow)) if params.size > 4
|
|
1217
|
+
unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1218
|
+
return Interpolation.new(expression: source_of(arrow))
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
body = lower_arrow_body(arrow[:body])
|
|
1222
|
+
return Interpolation.new(expression: source_of(arrow)) unless body
|
|
1223
|
+
|
|
1224
|
+
RenderProp.new(params: params.map { |p| p[:name] }, body: body)
|
|
1225
|
+
end
|
|
1226
|
+
|
|
842
1227
|
def lower_jsx_comment(empty_expression)
|
|
843
1228
|
comments = empty_expression.raw["innerComments"]
|
|
844
1229
|
return nil if comments.nil? || comments.empty?
|
|
@@ -848,35 +1233,112 @@ module JsxRosetta
|
|
|
848
1233
|
|
|
849
1234
|
def lower_call_expression(expression)
|
|
850
1235
|
loop_node = try_lower_map_loop(expression)
|
|
851
|
-
loop_node
|
|
1236
|
+
return loop_node if loop_node
|
|
1237
|
+
|
|
1238
|
+
local_call = try_lower_local_arrow_call(expression)
|
|
1239
|
+
return local_call if local_call
|
|
1240
|
+
|
|
1241
|
+
Interpolation.new(expression: source_of(expression))
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
# Recognize `{renderHeader()}` where `renderHeader` is a locally-bound
|
|
1245
|
+
# arrow whose body returns JSX. Extract the arrow as a RenderMethod
|
|
1246
|
+
# on the component and emit a LocalRenderCall at this use site so the
|
|
1247
|
+
# backend can call the generated method instead of dropping the
|
|
1248
|
+
# expression as "[untranslated: renderHeader()]". Args must be simple
|
|
1249
|
+
# identifiers (props, locals) since we don't translate arbitrary
|
|
1250
|
+
# argument expressions here — the backend's ExpressionTranslator
|
|
1251
|
+
# handles them via the Interpolation it sees.
|
|
1252
|
+
def try_lower_local_arrow_call(call_expression)
|
|
1253
|
+
match = local_arrow_call_match(call_expression)
|
|
1254
|
+
return nil unless match
|
|
1255
|
+
|
|
1256
|
+
# Consume the arrow so it doesn't ALSO get promoted to a Stimulus
|
|
1257
|
+
# method if it later appears in event-handler position.
|
|
1258
|
+
@local_arrows.delete(match[:callee_name])
|
|
1259
|
+
|
|
1260
|
+
method_name = unique_render_method_name(match[:callee_name])
|
|
1261
|
+
@render_methods << RenderMethod.new(
|
|
1262
|
+
name: method_name,
|
|
1263
|
+
params: match[:arrow][:params].map { |p| p[:name] },
|
|
1264
|
+
body: match[:body]
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
LocalRenderCall.new(
|
|
1268
|
+
method_name: method_name,
|
|
1269
|
+
args: match[:args].map { |arg| Interpolation.new(expression: source_of(arg)) }
|
|
1270
|
+
)
|
|
852
1271
|
end
|
|
853
1272
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1273
|
+
def local_arrow_call_match(call_expression)
|
|
1274
|
+
callee = call_expression.child(:callee)
|
|
1275
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1276
|
+
|
|
1277
|
+
arrow = @local_arrows[callee[:name]]
|
|
1278
|
+
return nil unless arrow
|
|
1279
|
+
return nil unless arrow[:params].all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
859
1280
|
|
|
860
1281
|
args = call_expression[:arguments]
|
|
861
|
-
return nil if args.size !=
|
|
862
|
-
return nil unless args.
|
|
1282
|
+
return nil if args.size != arrow[:params].size
|
|
1283
|
+
return nil unless args.all? { |a| AST::Node.matches?(a, "Identifier", "MemberExpression") }
|
|
863
1284
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1285
|
+
body = lower_lambda_body(arrow[:body])
|
|
1286
|
+
return nil unless body
|
|
1287
|
+
|
|
1288
|
+
{ callee_name: callee[:name], arrow: arrow, args: args, body: body }
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
def unique_render_method_name(js_name)
|
|
1292
|
+
snake = AST::Inflector.underscore(js_name)
|
|
1293
|
+
@render_method_seen[snake] ||= 0
|
|
1294
|
+
@render_method_seen[snake] += 1
|
|
1295
|
+
@render_method_seen[snake] == 1 ? snake : "#{snake}_#{@render_method_seen[snake]}"
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
def try_lower_map_loop(call_expression)
|
|
1299
|
+
callee = call_expression.child(:callee)
|
|
1300
|
+
return nil unless callee&.of_type?("MemberExpression")
|
|
1301
|
+
|
|
1302
|
+
property = callee.child(:property)
|
|
1303
|
+
return nil unless property && property[:name] == "map"
|
|
1304
|
+
|
|
1305
|
+
arrow = map_loop_arrow(call_expression[:arguments])
|
|
1306
|
+
return nil unless arrow
|
|
868
1307
|
|
|
1308
|
+
params = arrow[:params]
|
|
869
1309
|
body = lower_arrow_body(arrow[:body])
|
|
870
1310
|
return nil unless body
|
|
871
1311
|
|
|
872
1312
|
Loop.new(
|
|
873
|
-
iterable:
|
|
1313
|
+
iterable: lower_loop_iterable(callee[:object]),
|
|
874
1314
|
item_binding: params[0][:name],
|
|
875
1315
|
index_binding: params[1] && params[1][:name],
|
|
876
1316
|
body: body
|
|
877
1317
|
)
|
|
878
1318
|
end
|
|
879
|
-
|
|
1319
|
+
|
|
1320
|
+
# Recognize ArrayExpression/ObjectExpression iterables so a literal-
|
|
1321
|
+
# rooted `.map(...)` doesn't bail at translation time. Falls back to
|
|
1322
|
+
# the verbatim Interpolation for everything else.
|
|
1323
|
+
def lower_loop_iterable(object)
|
|
1324
|
+
case object.type
|
|
1325
|
+
when "ArrayExpression" then lower_array_literal(object)
|
|
1326
|
+
else Interpolation.new(expression: source_of(object))
|
|
1327
|
+
end
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
def map_loop_arrow(args)
|
|
1331
|
+
return nil if args.size != 1
|
|
1332
|
+
|
|
1333
|
+
arrow = args.first
|
|
1334
|
+
return nil unless AST::Node.matches?(arrow, "ArrowFunctionExpression")
|
|
1335
|
+
|
|
1336
|
+
params = arrow[:params]
|
|
1337
|
+
return nil if params.empty? || params.size > 2
|
|
1338
|
+
return nil unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1339
|
+
|
|
1340
|
+
arrow
|
|
1341
|
+
end
|
|
880
1342
|
|
|
881
1343
|
def lower_arrow_body(body)
|
|
882
1344
|
case body.type
|
|
@@ -939,21 +1401,27 @@ module JsxRosetta
|
|
|
939
1401
|
end
|
|
940
1402
|
end
|
|
941
1403
|
|
|
942
|
-
def lower_attribute(attr)
|
|
1404
|
+
def lower_attribute(attr, tag:)
|
|
943
1405
|
case attr
|
|
944
1406
|
when AST::JSXAttribute
|
|
945
|
-
lower_jsx_attribute(attr)
|
|
1407
|
+
lower_jsx_attribute(attr, tag: tag)
|
|
946
1408
|
when AST::JSXSpreadAttribute
|
|
947
1409
|
SpreadAttribute.new(expression: source_of(attr.argument))
|
|
948
1410
|
end
|
|
949
1411
|
end
|
|
950
1412
|
|
|
951
|
-
|
|
1413
|
+
# When the JSX tag is a component (PascalCase or member-expression),
|
|
1414
|
+
# `on*` props are NOT DOM events — they're callback props the receiving
|
|
1415
|
+
# Ruby component decides how to handle. Stimulus action descriptors
|
|
1416
|
+
# only fire on real DOM events, so promoting them would generate
|
|
1417
|
+
# never-firing `data-action="change->foo#h"` markup. Pass through as
|
|
1418
|
+
# a regular component-prop kwarg instead.
|
|
1419
|
+
def lower_jsx_attribute(attr, tag:)
|
|
952
1420
|
name = attr.attribute_name
|
|
953
1421
|
|
|
954
1422
|
return lower_class_name(attr.value) if name == "className"
|
|
955
1423
|
return lower_style_attribute_or_fallback(attr.value) if name == "style"
|
|
956
|
-
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
|
|
1424
|
+
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer) && html_element?(tag)
|
|
957
1425
|
return lower_event_attribute(name, attr.value)
|
|
958
1426
|
end
|
|
959
1427
|
|
|
@@ -968,7 +1436,7 @@ module JsxRosetta
|
|
|
968
1436
|
return nil unless value.is_a?(AST::JSXExpressionContainer)
|
|
969
1437
|
|
|
970
1438
|
expression = value.expression
|
|
971
|
-
return nil unless
|
|
1439
|
+
return nil unless AST::Node.matches?(expression, "ObjectExpression")
|
|
972
1440
|
|
|
973
1441
|
declarations = expression[:properties].map { |prop| lower_style_property(prop) }
|
|
974
1442
|
return nil if declarations.any?(&:nil?)
|
|
@@ -986,21 +1454,33 @@ module JsxRosetta
|
|
|
986
1454
|
end
|
|
987
1455
|
return nil if property_name.nil?
|
|
988
1456
|
|
|
989
|
-
value = lower_style_value(property[:value])
|
|
1457
|
+
value = lower_style_value(property[:value], property_name)
|
|
990
1458
|
return nil if value.nil?
|
|
991
1459
|
|
|
992
1460
|
StyleDeclaration.new(property: property_name, value: value)
|
|
993
1461
|
end
|
|
994
1462
|
|
|
995
|
-
def lower_style_value(value)
|
|
1463
|
+
def lower_style_value(value, property_name)
|
|
996
1464
|
case value.type
|
|
997
1465
|
when "StringLiteral" then value[:value]
|
|
998
|
-
when "NumericLiteral" then value[:value]
|
|
1466
|
+
when "NumericLiteral" then numeric_style_value(value[:value], property_name)
|
|
999
1467
|
when "Identifier", "MemberExpression"
|
|
1000
1468
|
Interpolation.new(expression: source_of(value))
|
|
1001
1469
|
end
|
|
1002
1470
|
end
|
|
1003
1471
|
|
|
1472
|
+
# Mirrors React's `isUnitlessNumber` table: properties that take bare
|
|
1473
|
+
# numbers (`zIndex: 5`) rather than lengths. Everything else gets a
|
|
1474
|
+
# `px` suffix appended when the JSX source provides a unitless number
|
|
1475
|
+
# — `marginBottom: 16` → `margin-bottom: 16px;`. Without this, the
|
|
1476
|
+
# browser silently ignores the declaration as invalid CSS.
|
|
1477
|
+
def numeric_style_value(number, property_name)
|
|
1478
|
+
return number.to_s if number.zero?
|
|
1479
|
+
return number.to_s if UNITLESS_CSS_PROPERTIES.include?(property_name)
|
|
1480
|
+
|
|
1481
|
+
"#{number}px"
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1004
1484
|
def css_property_from_camel(name)
|
|
1005
1485
|
name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
1006
1486
|
end
|
|
@@ -1014,10 +1494,10 @@ module JsxRosetta
|
|
|
1014
1494
|
end
|
|
1015
1495
|
|
|
1016
1496
|
def try_lower_class_helper(expression)
|
|
1017
|
-
return nil unless
|
|
1497
|
+
return nil unless AST::Node.matches?(expression, "CallExpression")
|
|
1018
1498
|
|
|
1019
|
-
callee = expression
|
|
1020
|
-
return nil unless callee
|
|
1499
|
+
callee = expression.child(:callee)
|
|
1500
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1021
1501
|
return nil unless %w[cn clsx classnames].include?(callee[:name])
|
|
1022
1502
|
|
|
1023
1503
|
segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
|
|
@@ -1069,26 +1549,49 @@ module JsxRosetta
|
|
|
1069
1549
|
)
|
|
1070
1550
|
end
|
|
1071
1551
|
|
|
1552
|
+
# Promote a JSX event-handler attribute (`onClick={...}`, `onChange={...}`)
|
|
1553
|
+
# to a Stimulus method binding. Three input shapes are recognized:
|
|
1554
|
+
# - inline arrow / function expression (`onClick={() => doX()}`)
|
|
1555
|
+
# → method body is the arrow's body source.
|
|
1556
|
+
# - identifier referring to a local arrow binding
|
|
1557
|
+
# (`const h = () => doX(); onClick={h}`) → method body is the
|
|
1558
|
+
# bound arrow's body. The local arrow is consumed.
|
|
1559
|
+
# - identifier referring to a prop or external (`onClick={onChange}`)
|
|
1560
|
+
# → synthesizes a method whose body documents the original
|
|
1561
|
+
# reference. Without this branch, prop-handler bindings used to
|
|
1562
|
+
# fall through to an EventBinding that rendered as a broken
|
|
1563
|
+
# `data-action` (a Ruby reference, not a Stimulus action descriptor).
|
|
1072
1564
|
def try_promote_to_stimulus(attr_name, event, expression)
|
|
1073
|
-
|
|
1074
|
-
|
|
1565
|
+
case expression.type
|
|
1566
|
+
when "ArrowFunctionExpression", "FunctionExpression"
|
|
1567
|
+
promote_arrow_to_stimulus(attr_name, event, expression, name_hint: nil)
|
|
1568
|
+
when "Identifier"
|
|
1569
|
+
promote_identifier_event(attr_name, event, expression[:name])
|
|
1570
|
+
end
|
|
1571
|
+
end
|
|
1075
1572
|
|
|
1076
|
-
|
|
1573
|
+
def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
|
|
1574
|
+
base = name_hint || default_stimulus_method_name(attr_name)
|
|
1575
|
+
method_name = stimulus_method_name(base)
|
|
1077
1576
|
body_source = source_of(arrow_node[:body])
|
|
1078
|
-
@stimulus_methods << StimulusMethod.new(
|
|
1577
|
+
@stimulus_methods << StimulusMethod.new(
|
|
1578
|
+
name: method_name, body_source: body_source, original_name: base
|
|
1579
|
+
)
|
|
1079
1580
|
@local_arrows.delete(name_hint) if name_hint
|
|
1080
|
-
|
|
1081
1581
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
1082
1582
|
end
|
|
1083
1583
|
|
|
1084
|
-
def
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
[expression, nil]
|
|
1088
|
-
when "Identifier"
|
|
1089
|
-
arrow = @local_arrows[expression[:name]]
|
|
1090
|
-
arrow ? [arrow, expression[:name]] : nil
|
|
1584
|
+
def promote_identifier_event(attr_name, event, identifier_name)
|
|
1585
|
+
if (arrow = @local_arrows[identifier_name])
|
|
1586
|
+
return promote_arrow_to_stimulus(attr_name, event, arrow, name_hint: identifier_name)
|
|
1091
1587
|
end
|
|
1588
|
+
|
|
1589
|
+
method_name = stimulus_method_name(identifier_name)
|
|
1590
|
+
body_source = "// originally bound to: #{identifier_name}"
|
|
1591
|
+
@stimulus_methods << StimulusMethod.new(
|
|
1592
|
+
name: method_name, body_source: body_source, original_name: identifier_name
|
|
1593
|
+
)
|
|
1594
|
+
StimulusBinding.new(event: event, method_name: method_name)
|
|
1092
1595
|
end
|
|
1093
1596
|
|
|
1094
1597
|
def default_stimulus_method_name(attr_name)
|
|
@@ -1108,12 +1611,86 @@ module JsxRosetta
|
|
|
1108
1611
|
when nil
|
|
1109
1612
|
true
|
|
1110
1613
|
when AST::JSXExpressionContainer
|
|
1111
|
-
|
|
1614
|
+
lower_value_expression(value.expression)
|
|
1112
1615
|
else
|
|
1113
1616
|
value.raw["value"]
|
|
1114
1617
|
end
|
|
1115
1618
|
end
|
|
1116
1619
|
|
|
1620
|
+
# Recursively lower an arbitrary JS expression into structured IR
|
|
1621
|
+
# when possible: ObjectExpression → ObjectLiteral, ArrayExpression
|
|
1622
|
+
# → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
|
|
1623
|
+
# Lambda. Everything else falls back to the verbatim Interpolation
|
|
1624
|
+
# so simpler ExpressionTranslator paths still get a crack at it.
|
|
1625
|
+
def lower_value_expression(expression)
|
|
1626
|
+
case expression.type
|
|
1627
|
+
when "ObjectExpression" then lower_object_literal(expression)
|
|
1628
|
+
when "ArrayExpression" then lower_array_literal(expression)
|
|
1629
|
+
when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
|
|
1630
|
+
else
|
|
1631
|
+
Interpolation.new(expression: source_of(expression))
|
|
1632
|
+
end
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
def lower_object_literal(object_expression)
|
|
1636
|
+
properties = []
|
|
1637
|
+
object_expression[:properties].each do |prop|
|
|
1638
|
+
# Spreads inside object literals, computed keys, getters/setters,
|
|
1639
|
+
# methods — fall back to a verbatim Interpolation since we can't
|
|
1640
|
+
# represent them as a simple key-value pair.
|
|
1641
|
+
return Interpolation.new(expression: source_of(object_expression)) unless prop.type == "ObjectProperty"
|
|
1642
|
+
return Interpolation.new(expression: source_of(object_expression)) if prop.raw["computed"]
|
|
1643
|
+
|
|
1644
|
+
key_name = object_property_key_name(prop[:key])
|
|
1645
|
+
return Interpolation.new(expression: source_of(object_expression)) if key_name.nil?
|
|
1646
|
+
|
|
1647
|
+
properties << [key_name, lower_value_expression(prop[:value])]
|
|
1648
|
+
end
|
|
1649
|
+
ObjectLiteral.new(properties: properties)
|
|
1650
|
+
end
|
|
1651
|
+
|
|
1652
|
+
def object_property_key_name(key_node)
|
|
1653
|
+
case key_node.type
|
|
1654
|
+
when "Identifier" then key_node[:name]
|
|
1655
|
+
when "StringLiteral" then key_node[:value]
|
|
1656
|
+
when "NumericLiteral" then key_node[:value].to_s
|
|
1657
|
+
end
|
|
1658
|
+
end
|
|
1659
|
+
|
|
1660
|
+
def lower_array_literal(array_expression)
|
|
1661
|
+
elements = array_expression[:elements].map do |el|
|
|
1662
|
+
next nil if el.nil? # `[1, , 3]` holes — Ruby has no equivalent
|
|
1663
|
+
|
|
1664
|
+
lower_value_expression(el)
|
|
1665
|
+
end
|
|
1666
|
+
ArrayLiteral.new(elements: elements)
|
|
1667
|
+
end
|
|
1668
|
+
|
|
1669
|
+
def lower_value_lambda(arrow)
|
|
1670
|
+
params = arrow[:params]
|
|
1671
|
+
return Interpolation.new(expression: source_of(arrow)) if params.size > 4
|
|
1672
|
+
unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1673
|
+
return Interpolation.new(expression: source_of(arrow))
|
|
1674
|
+
end
|
|
1675
|
+
|
|
1676
|
+
body = lower_lambda_body(arrow[:body])
|
|
1677
|
+
return Interpolation.new(expression: source_of(arrow)) unless body
|
|
1678
|
+
|
|
1679
|
+
Lambda.new(params: params.map { |p| p[:name] }, body: body)
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
def lower_lambda_body(body)
|
|
1683
|
+
return lower_jsx(body) if %w[JSXElement JSXFragment].include?(body.type)
|
|
1684
|
+
|
|
1685
|
+
return unless body.type == "BlockStatement"
|
|
1686
|
+
|
|
1687
|
+
return_stmt = body[:body].find { |s| s.type == "ReturnStatement" }
|
|
1688
|
+
return nil unless return_stmt && return_stmt[:argument]
|
|
1689
|
+
|
|
1690
|
+
arg = return_stmt[:argument]
|
|
1691
|
+
%w[JSXElement JSXFragment].include?(arg.type) ? lower_jsx(arg) : nil
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1117
1694
|
def style_binding_expression(value)
|
|
1118
1695
|
case value
|
|
1119
1696
|
when nil then "true"
|