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.
@@ -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
- # Phase 2 scope:
10
- # - Single function-declaration component per file.
11
- # - JSX elements with lowercase tags lower to IR::Element; others to
12
- # IR::ComponentInvocation.
13
- # - className attributes lower to IR::StyleBinding; everything else
14
- # to IR::Attribute (event handlers like onClick are passed through
15
- # as Attribute for now and will be re-lowered to EventBinding in
16
- # a later phase).
17
- # - JS expressions are preserved as opaque source text via
18
- # IR::Interpolation. No JS-to-Ruby translation.
19
- # - Pure-whitespace JSXText between elements is dropped (matches
20
- # JSX runtime behavior); other text is preserved verbatim.
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
- # Phase 4a additions:
23
- # - {children} where `children` is a prop lowers to IR::Slot.
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
- EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
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
- HOC_NAMES = %w[memo forwardRef lazy observer].freeze
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
- lower_component(name, function)
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
- candidates.map { |name, function| lower_component(name, function) }
129
- end
130
-
131
- private
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
- def hook_name?(name)
196
- name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
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 class_component?(stmt)
200
- decl = EXPORT_TYPES.include?(stmt.type) ? stmt[:declaration] : stmt
201
- decl.is_a?(AST::Node) && decl.type == "ClassDeclaration"
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
- # Recognize `export const X = React.memo(...)` (export wrapper) or a
205
- # top-level `const X = lazy(() => ...)` followed by `export default X`
206
- # a VariableDeclaration whose init is a CallExpression to a known HOC.
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
- decl[:declarations].any? do |d|
212
- init = d[:init]
213
- init.is_a?(AST::Node) && init.type == "CallExpression" && hoc_callee?(init[:callee])
214
- end
215
- end
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
- def hoc_callee?(callee)
218
- return false unless callee.is_a?(AST::Node)
225
+ name = declarator[:id]&.[](:name)
226
+ return unless name
219
227
 
220
- case callee.type
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 array_literal_export?(stmt)
230
- return false unless EXPORT_TYPES.include?(stmt.type)
231
+ def attach_module_bindings(component, module_bindings)
232
+ return component if module_bindings.empty?
231
233
 
232
- decl = stmt[:declaration]
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
- # Does the program have any top-level non-import statements? Used to
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 top_level_export_names(program)
248
- program.body.flat_map { |stmt| extract_top_level_names(stmt) }.compact
239
+ def lowering_error(message, node: nil)
240
+ LoweringError.new(message, node: node, source: @source)
249
241
  end
250
242
 
251
- def extract_top_level_names(stmt)
252
- case stmt.type
253
- when "FunctionDeclaration"
254
- [stmt[:id]&.[](:name)]
255
- when "VariableDeclaration"
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
- @local_bindings = []
350
- @local_arrows = {}
351
- @local_polymorphic_tags = {}
352
- @stimulus_methods = []
353
- @stimulus_seen_names = {}
354
- @react_hooks = []
355
-
356
- body = lower_function_body(function[:body])
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 = (Interpolation.new(expression: source_of(value[:right])) if value.type == "AssignmentPattern")
405
- Prop.new(name: prop_name, default: default)
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| s.is_a?(AST::Node) && s.type == "ReturnStatement" }
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.each do |stmt|
496
- next unless stmt.is_a?(AST::Node) && stmt.type == "IfStatement"
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
- base_value = Conditional.new(
721
+ Conditional.new(
503
722
  test: Interpolation.new(expression: source_of(stmt[:test])),
504
723
  consequent: branch_value,
505
- alternate: base_value
724
+ alternate: acc
506
725
  )
507
726
  end
508
- base_value
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 stmt.is_a?(AST::Node) && stmt.type == "VariableDeclaration"
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| s.is_a?(AST::Node) && s.type == "BreakStatement" }
850
+ filtered = stmts.reject { |s| AST::Node.matches?(s, "BreakStatement") }
629
851
  return nil if filtered.empty?
630
852
 
631
- if filtered.size == 1 && filtered.first.is_a?(AST::Node) && filtered.first.type == "BlockStatement"
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
- if hook_call?(init)
670
- @react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip)
671
- return
672
- end
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
- name = declarator[:id]&.[](:name)
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[:expression]
692
- return unless expr.is_a?(AST::Node) && expr.type == "CallExpression"
693
- return unless hook_call?(expr)
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
- @react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
1025
+ record_hook_call(stmt, expr, library)
696
1026
  end
697
1027
 
698
1028
  def hook_call?(call_expression)
699
- return false unless call_expression.type == "CallExpression"
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[:callee]
702
- callee.is_a?(AST::Node) && callee.type == "Identifier" && REACT_HOOKS.include?(callee[:name])
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 || Interpolation.new(expression: source_of(expression))
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
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
855
- def try_lower_map_loop(call_expression)
856
- callee = call_expression[:callee]
857
- return nil unless callee.is_a?(AST::Node) && callee.type == "MemberExpression"
858
- return nil unless callee[:property].is_a?(AST::Node) && callee[:property][:name] == "map"
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 != 1
862
- return nil unless args.first.type == "ArrowFunctionExpression"
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
- arrow = args.first
865
- params = arrow[:params]
866
- return nil if params.empty? || params.size > 2
867
- return nil unless params.all? { |p| p.is_a?(AST::Node) && p.type == "Identifier" }
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: Interpolation.new(expression: source_of(callee[:object])),
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
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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
- def lower_jsx_attribute(attr)
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 expression.is_a?(AST::Node) && expression.type == "ObjectExpression"
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].to_s
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 expression.is_a?(AST::Node) && expression.type == "CallExpression"
1497
+ return nil unless AST::Node.matches?(expression, "CallExpression")
1018
1498
 
1019
- callee = expression[:callee]
1020
- return nil unless callee.is_a?(AST::Node) && callee.type == "Identifier"
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
- arrow_node, name_hint = stimulus_arrow_for(expression)
1074
- return nil unless arrow_node
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
- method_name = stimulus_method_name(name_hint || default_stimulus_method_name(attr_name))
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(name: method_name, body_source: body_source)
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 stimulus_arrow_for(expression)
1085
- case expression.type
1086
- when "ArrowFunctionExpression", "FunctionExpression"
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
- Interpolation.new(expression: source_of(value.expression))
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"