jsx_rosetta 0.2.0 → 0.4.0

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