jsx_rosetta 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 +67,7 @@ module JsxRosetta
66
67
  useReducer useImperativeHandle useLayoutEffect useDebugValue
67
68
  ].freeze
68
69
 
69
- EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
70
70
  JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
71
- HOC_NAMES = %w[memo forwardRef lazy observer].freeze
72
71
 
73
72
  # Pre-lowering AST scan: maps a node type to a callable returning the
74
73
  # AST nodes that contribute return values. Used by body_returns_jsx?.
@@ -106,8 +105,10 @@ module JsxRosetta
106
105
  @prop_names = []
107
106
  @local_jsx = {}
108
107
  @local_bindings = []
108
+ @local_binding_names = []
109
109
  @local_arrows = {}
110
110
  @local_polymorphic_tags = {}
111
+ @local_destructures = {}
111
112
  @stimulus_methods = []
112
113
  @stimulus_seen_names = {}
113
114
  @react_hooks = []
@@ -118,148 +119,79 @@ module JsxRosetta
118
119
  raise no_component_error(file.program) if candidates.empty?
119
120
 
120
121
  name, function = candidates.first
121
- lower_component(name, function)
122
+ module_bindings = capture_module_bindings(file.program, candidates)
123
+ attach_module_bindings(lower_component(name, function), module_bindings)
122
124
  end
123
125
 
124
126
  def lower_all_components(file)
125
127
  candidates = find_component_functions(file.program)
126
128
  raise no_component_error(file.program) if candidates.empty?
127
129
 
128
- 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"
130
+ module_bindings = capture_module_bindings(file.program, candidates)
131
+ candidates.map do |name, function|
132
+ attach_module_bindings(lower_component(name, function), module_bindings)
133
+ end
193
134
  end
194
135
 
195
- def hook_name?(name)
196
- name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
136
+ # Walk the program body for top-level `const`/`let` declarations that
137
+ # aren't component declarations. Capture each as a LocalBinding so
138
+ # backends can emit them as Ruby constants (or as a TODO comment for
139
+ # non-literal initializers) before the class definition. Without
140
+ # this, `const FOO = 400; function X() { return <p>{FOO}</p> }` would
141
+ # silently drop the FOO declaration and emit an unbacked `foo`
142
+ # reference inside the view template.
143
+ def capture_module_bindings(program, candidates)
144
+ component_names = candidates.to_set(&:first)
145
+ bindings = []
146
+ program.body.each do |stmt|
147
+ walk_module_binding(stmt, component_names, bindings)
148
+ end
149
+ bindings
197
150
  end
198
151
 
199
- def class_component?(stmt)
200
- decl = EXPORT_TYPES.include?(stmt.type) ? stmt[:declaration] : stmt
201
- decl.is_a?(AST::Node) && decl.type == "ClassDeclaration"
152
+ def walk_module_binding(stmt, component_names, bindings)
153
+ case stmt.type
154
+ when "VariableDeclaration"
155
+ stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
156
+ when "ExportNamedDeclaration"
157
+ decl = stmt[:declaration]
158
+ walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
159
+ end
202
160
  end
203
161
 
204
- # 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"
162
+ def record_module_binding(stmt, declarator, component_names, bindings)
163
+ init = declarator[:init]
164
+ return unless init.is_a?(AST::Node)
210
165
 
211
- 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
166
+ # Component declarators (`const Foo = () => ...`) are handled by
167
+ # the component pipeline; skip them here so the source doesn't
168
+ # show up twice.
169
+ return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
170
+ component_names.include?(declarator[:id]&.[](:name))
216
171
 
217
- def hoc_callee?(callee)
218
- return false unless callee.is_a?(AST::Node)
172
+ name = declarator[:id]&.[](:name)
173
+ return unless name
219
174
 
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
175
+ bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
227
176
  end
228
177
 
229
- def array_literal_export?(stmt)
230
- return false unless EXPORT_TYPES.include?(stmt.type)
178
+ def attach_module_bindings(component, module_bindings)
179
+ return component if module_bindings.empty?
231
180
 
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" }
181
+ component.with(module_bindings: module_bindings)
237
182
  end
238
183
 
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
184
+ private
246
185
 
247
- def top_level_export_names(program)
248
- program.body.flat_map { |stmt| extract_top_level_names(stmt) }.compact
186
+ def lowering_error(message, node: nil)
187
+ LoweringError.new(message, node: node, source: @source)
249
188
  end
250
189
 
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
190
+ def no_component_error(program)
191
+ shape = ModuleShapeClassifier.classify(program)
192
+ message = SHAPE_MESSAGES[shape]
193
+ suffix = message ? " — #{message}" : ""
194
+ lowering_error("no component function found in module#{suffix}")
263
195
  end
264
196
 
265
197
  def find_component_functions(program)
@@ -287,6 +219,10 @@ module JsxRosetta
287
219
  first == first.upcase && first != first.downcase
288
220
  end
289
221
 
222
+ def hook_name?(name)
223
+ name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
224
+ end
225
+
290
226
  # Pre-lowering AST scan: does any return path in this body produce a
291
227
  # JSX value? Used only as a heuristic for component_function?, so a
292
228
  # false positive is a translation attempt that may TODO out, while
@@ -347,8 +283,10 @@ module JsxRosetta
347
283
  props, rest_prop_name = lower_params(function[:params])
348
284
  @prop_names = props.map(&:name)
349
285
  @local_bindings = []
286
+ @local_binding_names = []
350
287
  @local_arrows = {}
351
288
  @local_polymorphic_tags = {}
289
+ @local_destructures = {}
352
290
  @stimulus_methods = []
353
291
  @stimulus_seen_names = {}
354
292
  @react_hooks = []
@@ -361,6 +299,8 @@ module JsxRosetta
361
299
  body: body,
362
300
  rest_prop_name: rest_prop_name,
363
301
  local_bindings: @local_bindings,
302
+ local_binding_names: @local_binding_names.uniq,
303
+ module_bindings: [],
364
304
  stimulus_methods: @stimulus_methods,
365
305
  react_hooks: @react_hooks
366
306
  )
@@ -401,7 +341,12 @@ module JsxRosetta
401
341
  key = property[:key]
402
342
  prop_name = key.type == "StringLiteral" ? key[:value] : key[:name]
403
343
  value = property[:value]
404
- default = (Interpolation.new(expression: source_of(value[:right])) if value.type == "AssignmentPattern")
344
+ # Route prop default expressions through the recursive value
345
+ # lowering so object/array literals translate cleanly, instead of
346
+ # producing an opaque Interpolation that the backend would emit as
347
+ # `nil # TODO: ...`. The trailing `#` comment inside a method
348
+ # parameter list swallows the closing `)` and breaks Ruby syntax.
349
+ default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
405
350
  Prop.new(name: prop_name, default: default)
406
351
  end
407
352
 
@@ -454,7 +399,7 @@ module JsxRosetta
454
399
  # around the base value as outer Conditionals. Returns nil when no shape
455
400
  # matches; caller raises.
456
401
  def lower_block_returns(statements)
457
- return_idx = statements.index { |s| s.is_a?(AST::Node) && s.type == "ReturnStatement" }
402
+ return_idx = statements.index { |s| AST::Node.matches?(s, "ReturnStatement") }
458
403
 
459
404
  if return_idx
460
405
  return_arg = statements[return_idx][:argument]
@@ -492,20 +437,22 @@ module JsxRosetta
492
437
  # behavior of dropping unrepresentable preceding statements rather
493
438
  # than failing the whole component.
494
439
  def wrap_return_guards(preceding, base_value)
495
- preceding.reverse.each do |stmt|
496
- next unless stmt.is_a?(AST::Node) && stmt.type == "IfStatement"
497
- next if stmt[:alternate]
440
+ preceding.reverse.reduce(base_value) do |acc, stmt|
441
+ next acc unless guard_if_statement?(stmt)
498
442
 
499
443
  branch_value = lower_return_branch(stmt[:consequent])
500
- next unless branch_value
444
+ next acc unless branch_value
501
445
 
502
- base_value = Conditional.new(
446
+ Conditional.new(
503
447
  test: Interpolation.new(expression: source_of(stmt[:test])),
504
448
  consequent: branch_value,
505
- alternate: base_value
449
+ alternate: acc
506
450
  )
507
451
  end
508
- base_value
452
+ end
453
+
454
+ def guard_if_statement?(stmt)
455
+ AST::Node.matches?(stmt, "IfStatement") && stmt[:alternate].nil?
509
456
  end
510
457
 
511
458
  def lower_if_return_chain(if_stmt)
@@ -547,7 +494,7 @@ module JsxRosetta
547
494
 
548
495
  def collect_nested_local_bindings(stmts)
549
496
  stmts.each do |stmt|
550
- next unless stmt.is_a?(AST::Node) && stmt.type == "VariableDeclaration"
497
+ next unless AST::Node.matches?(stmt, "VariableDeclaration")
551
498
 
552
499
  seen = {}
553
500
  stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
@@ -625,10 +572,10 @@ module JsxRosetta
625
572
  # case A: if (X) return Y; return Z; (guard prefix + return)
626
573
  # Trailing `break` statements are ignored.
627
574
  def lower_switch_case_consequent(stmts)
628
- filtered = stmts.reject { |s| s.is_a?(AST::Node) && s.type == "BreakStatement" }
575
+ filtered = stmts.reject { |s| AST::Node.matches?(s, "BreakStatement") }
629
576
  return nil if filtered.empty?
630
577
 
631
- if filtered.size == 1 && filtered.first.is_a?(AST::Node) && filtered.first.type == "BlockStatement"
578
+ if filtered.size == 1 && AST::Node.matches?(filtered.first, "BlockStatement")
632
579
  return lower_switch_case_consequent(filtered.first[:body])
633
580
  end
634
581
 
@@ -650,6 +597,7 @@ module JsxRosetta
650
597
  @local_jsx = {}
651
598
  @local_arrows = {}
652
599
  @local_polymorphic_tags = {}
600
+ @local_destructures = {}
653
601
  seen_other_stmts = {}
654
602
 
655
603
  statements.each do |stmt|
@@ -666,12 +614,27 @@ module JsxRosetta
666
614
  init = declarator[:init]
667
615
  return unless init.is_a?(AST::Node)
668
616
 
669
- if hook_call?(init)
670
- @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)
671
632
  return
672
633
  end
673
634
 
674
- name = declarator[:id]&.[](:name)
635
+ return if is_hook
636
+
637
+ name = id_node&.[](:name)
675
638
  return unless name
676
639
 
677
640
  case init.type
@@ -687,19 +650,83 @@ module JsxRosetta
687
650
  end
688
651
  end
689
652
 
653
+ def destructure_pattern?(node)
654
+ AST::Node.matches?(node, "ArrayPattern", "ObjectPattern")
655
+ end
656
+
657
+ def record_destructured_names(stmt, declarator, init:, seen:, is_hook:)
658
+ pattern = declarator[:id]
659
+ names = destructured_names_of(pattern)
660
+ return if names.empty?
661
+
662
+ # Member-expression-style destructuring (Gap J): `const { Content } = Layout`
663
+ # binds `Content` to `Layout.Content`. Record those so JSX use sites
664
+ # resolve to the right component.
665
+ track_member_destructures(pattern, init) if AST::Node.matches?(init, "Identifier")
666
+
667
+ @local_binding_names.concat(names)
668
+ return if is_hook
669
+
670
+ seen[stmt.start_pos] ||= source_of(stmt).strip
671
+ names.each { |name| @local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos]) }
672
+ end
673
+
674
+ # Return the flat list of Identifier names bound by a destructuring
675
+ # pattern. Nested patterns recurse. RestElement / aliased properties
676
+ # are included; defaults (AssignmentPattern) are followed to their
677
+ # left-hand identifier.
678
+ def destructured_names_of(pattern)
679
+ return [] unless pattern.is_a?(AST::Node)
680
+
681
+ case pattern.type
682
+ when "Identifier" then [pattern[:name]]
683
+ when "ArrayPattern"
684
+ pattern[:elements].flat_map { |element| element ? destructured_names_of(element) : [] }
685
+ when "ObjectPattern"
686
+ pattern[:properties].flat_map { |prop| destructured_names_from_property(prop) }
687
+ when "RestElement"
688
+ destructured_names_of(pattern[:argument])
689
+ when "AssignmentPattern"
690
+ destructured_names_of(pattern[:left])
691
+ else
692
+ []
693
+ end
694
+ end
695
+
696
+ def destructured_names_from_property(prop)
697
+ case prop.type
698
+ when "ObjectProperty" then destructured_names_of(prop[:value])
699
+ when "RestElement" then destructured_names_of(prop[:argument])
700
+ else []
701
+ end
702
+ end
703
+
704
+ def track_member_destructures(pattern, source_identifier)
705
+ return unless AST::Node.matches?(pattern, "ObjectPattern")
706
+
707
+ source_name = source_identifier[:name]
708
+ pattern[:properties].each do |prop|
709
+ next unless prop.type == "ObjectProperty"
710
+
711
+ value = prop[:value]
712
+ next unless AST::Node.matches?(value, "Identifier")
713
+
714
+ @local_destructures[value[:name]] = source_name
715
+ end
716
+ end
717
+
690
718
  def detect_bare_hook_call(stmt)
691
- expr = stmt[:expression]
692
- return unless expr.is_a?(AST::Node) && expr.type == "CallExpression"
693
- return unless hook_call?(expr)
719
+ expr = stmt.child(:expression)
720
+ return unless expr&.of_type?("CallExpression") && hook_call?(expr)
694
721
 
695
722
  @react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
696
723
  end
697
724
 
698
725
  def hook_call?(call_expression)
699
- return false unless call_expression.type == "CallExpression"
726
+ return false unless call_expression.of_type?("CallExpression")
700
727
 
701
- callee = call_expression[:callee]
702
- 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])
703
730
  end
704
731
 
705
732
  # Recognize the asChild-style polymorphic tag pattern:
@@ -727,6 +754,7 @@ module JsxRosetta
727
754
  def record_local_other_binding(stmt, name, seen)
728
755
  seen[stmt.start_pos] ||= source_of(stmt).strip
729
756
  @local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
757
+ @local_binding_names << name
730
758
  end
731
759
 
732
760
  def lower_jsx(node)
@@ -742,7 +770,7 @@ module JsxRosetta
742
770
 
743
771
  def lower_jsx_element(element)
744
772
  tag = element.tag_name
745
- attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr) }
773
+ attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr, tag: tag) }
746
774
  # `key` is a React-only reconciliation hint; never emit it to the DOM
747
775
  # or to ViewComponent invocations.
748
776
  attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
@@ -750,6 +778,10 @@ module JsxRosetta
750
778
 
751
779
  if (poly = @local_polymorphic_tags[tag])
752
780
  lower_polymorphic_tag_use(poly, attributes, children)
781
+ elsif (parent = @local_destructures[tag])
782
+ # Gap J: `const { Content } = Layout; <Content/>` should resolve
783
+ # to `Layout::Content`, not a bare `ContentComponent`.
784
+ ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
753
785
  elsif html_element?(tag)
754
786
  Element.new(tag: tag, attributes: attributes, children: children)
755
787
  else
@@ -834,11 +866,30 @@ module JsxRosetta
834
866
  when "ConditionalExpression" then lower_ternary_expression(expression)
835
867
  when "Identifier" then lower_identifier_expression(expression)
836
868
  when "CallExpression" then lower_call_expression(expression)
869
+ when "ArrowFunctionExpression", "FunctionExpression" then lower_render_prop(expression)
837
870
  else
838
871
  Interpolation.new(expression: source_of(expression))
839
872
  end
840
873
  end
841
874
 
875
+ # Recognize the render-prop / function-as-children pattern:
876
+ # <Form.List>{(fields, helpers) => <div>{fields}</div>}</Form.List>
877
+ # Returns nil (caller falls back to verbatim interpolation) when the
878
+ # arrow has zero or too many params, or when the body doesn't lower
879
+ # cleanly to a JSX child.
880
+ def lower_render_prop(arrow)
881
+ params = arrow[:params]
882
+ return Interpolation.new(expression: source_of(arrow)) if params.size > 4
883
+ unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
884
+ return Interpolation.new(expression: source_of(arrow))
885
+ end
886
+
887
+ body = lower_arrow_body(arrow[:body])
888
+ return Interpolation.new(expression: source_of(arrow)) unless body
889
+
890
+ RenderProp.new(params: params.map { |p| p[:name] }, body: body)
891
+ end
892
+
842
893
  def lower_jsx_comment(empty_expression)
843
894
  comments = empty_expression.raw["innerComments"]
844
895
  return nil if comments.nil? || comments.empty?
@@ -851,32 +902,50 @@ module JsxRosetta
851
902
  loop_node || Interpolation.new(expression: source_of(expression))
852
903
  end
853
904
 
854
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
855
905
  def try_lower_map_loop(call_expression)
856
- callee = call_expression[: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"
906
+ callee = call_expression.child(:callee)
907
+ return nil unless callee&.of_type?("MemberExpression")
859
908
 
860
- args = call_expression[:arguments]
861
- return nil if args.size != 1
862
- return nil unless args.first.type == "ArrowFunctionExpression"
909
+ property = callee.child(:property)
910
+ return nil unless property && property[:name] == "map"
863
911
 
864
- arrow = 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" }
912
+ arrow = map_loop_arrow(call_expression[:arguments])
913
+ return nil unless arrow
868
914
 
915
+ params = arrow[:params]
869
916
  body = lower_arrow_body(arrow[:body])
870
917
  return nil unless body
871
918
 
872
919
  Loop.new(
873
- iterable: Interpolation.new(expression: source_of(callee[:object])),
920
+ iterable: lower_loop_iterable(callee[:object]),
874
921
  item_binding: params[0][:name],
875
922
  index_binding: params[1] && params[1][:name],
876
923
  body: body
877
924
  )
878
925
  end
879
- # 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
880
949
 
881
950
  def lower_arrow_body(body)
882
951
  case body.type
@@ -939,21 +1008,27 @@ module JsxRosetta
939
1008
  end
940
1009
  end
941
1010
 
942
- def lower_attribute(attr)
1011
+ def lower_attribute(attr, tag:)
943
1012
  case attr
944
1013
  when AST::JSXAttribute
945
- lower_jsx_attribute(attr)
1014
+ lower_jsx_attribute(attr, tag: tag)
946
1015
  when AST::JSXSpreadAttribute
947
1016
  SpreadAttribute.new(expression: source_of(attr.argument))
948
1017
  end
949
1018
  end
950
1019
 
951
- 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:)
952
1027
  name = attr.attribute_name
953
1028
 
954
1029
  return lower_class_name(attr.value) if name == "className"
955
1030
  return lower_style_attribute_or_fallback(attr.value) if name == "style"
956
- if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
1031
+ if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer) && html_element?(tag)
957
1032
  return lower_event_attribute(name, attr.value)
958
1033
  end
959
1034
 
@@ -968,7 +1043,7 @@ module JsxRosetta
968
1043
  return nil unless value.is_a?(AST::JSXExpressionContainer)
969
1044
 
970
1045
  expression = value.expression
971
- return nil unless expression.is_a?(AST::Node) && expression.type == "ObjectExpression"
1046
+ return nil unless AST::Node.matches?(expression, "ObjectExpression")
972
1047
 
973
1048
  declarations = expression[:properties].map { |prop| lower_style_property(prop) }
974
1049
  return nil if declarations.any?(&:nil?)
@@ -1014,10 +1089,10 @@ module JsxRosetta
1014
1089
  end
1015
1090
 
1016
1091
  def try_lower_class_helper(expression)
1017
- return nil unless expression.is_a?(AST::Node) && expression.type == "CallExpression"
1092
+ return nil unless AST::Node.matches?(expression, "CallExpression")
1018
1093
 
1019
- callee = expression[:callee]
1020
- return nil unless callee.is_a?(AST::Node) && callee.type == "Identifier"
1094
+ callee = expression.child(:callee)
1095
+ return nil unless callee&.of_type?("Identifier")
1021
1096
  return nil unless %w[cn clsx classnames].include?(callee[:name])
1022
1097
 
1023
1098
  segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
@@ -1069,26 +1144,49 @@ module JsxRosetta
1069
1144
  )
1070
1145
  end
1071
1146
 
1147
+ # Promote a JSX event-handler attribute (`onClick={...}`, `onChange={...}`)
1148
+ # to a Stimulus method binding. Three input shapes are recognized:
1149
+ # - inline arrow / function expression (`onClick={() => doX()}`)
1150
+ # → method body is the arrow's body source.
1151
+ # - identifier referring to a local arrow binding
1152
+ # (`const h = () => doX(); onClick={h}`) → method body is the
1153
+ # bound arrow's body. The local arrow is consumed.
1154
+ # - identifier referring to a prop or external (`onClick={onChange}`)
1155
+ # → synthesizes a method whose body documents the original
1156
+ # reference. Without this branch, prop-handler bindings used to
1157
+ # fall through to an EventBinding that rendered as a broken
1158
+ # `data-action` (a Ruby reference, not a Stimulus action descriptor).
1072
1159
  def try_promote_to_stimulus(attr_name, event, expression)
1073
- arrow_node, name_hint = stimulus_arrow_for(expression)
1074
- 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
1075
1167
 
1076
- 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)
1077
1171
  body_source = source_of(arrow_node[:body])
1078
- @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
+ )
1079
1175
  @local_arrows.delete(name_hint) if name_hint
1080
-
1081
1176
  StimulusBinding.new(event: event, method_name: method_name)
1082
1177
  end
1083
1178
 
1084
- def 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
1179
+ def promote_identifier_event(attr_name, event, identifier_name)
1180
+ if (arrow = @local_arrows[identifier_name])
1181
+ return promote_arrow_to_stimulus(attr_name, event, arrow, name_hint: identifier_name)
1091
1182
  end
1183
+
1184
+ method_name = stimulus_method_name(identifier_name)
1185
+ body_source = "// originally bound to: #{identifier_name}"
1186
+ @stimulus_methods << StimulusMethod.new(
1187
+ name: method_name, body_source: body_source, original_name: identifier_name
1188
+ )
1189
+ StimulusBinding.new(event: event, method_name: method_name)
1092
1190
  end
1093
1191
 
1094
1192
  def default_stimulus_method_name(attr_name)
@@ -1108,12 +1206,86 @@ module JsxRosetta
1108
1206
  when nil
1109
1207
  true
1110
1208
  when AST::JSXExpressionContainer
1111
- Interpolation.new(expression: source_of(value.expression))
1209
+ lower_value_expression(value.expression)
1112
1210
  else
1113
1211
  value.raw["value"]
1114
1212
  end
1115
1213
  end
1116
1214
 
1215
+ # Recursively lower an arbitrary JS expression into structured IR
1216
+ # when possible: ObjectExpression → ObjectLiteral, ArrayExpression
1217
+ # → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
1218
+ # Lambda. Everything else falls back to the verbatim Interpolation
1219
+ # so simpler ExpressionTranslator paths still get a crack at it.
1220
+ def lower_value_expression(expression)
1221
+ case expression.type
1222
+ when "ObjectExpression" then lower_object_literal(expression)
1223
+ when "ArrayExpression" then lower_array_literal(expression)
1224
+ when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
1225
+ else
1226
+ Interpolation.new(expression: source_of(expression))
1227
+ end
1228
+ end
1229
+
1230
+ def lower_object_literal(object_expression)
1231
+ properties = []
1232
+ object_expression[:properties].each do |prop|
1233
+ # Spreads inside object literals, computed keys, getters/setters,
1234
+ # methods — fall back to a verbatim Interpolation since we can't
1235
+ # represent them as a simple key-value pair.
1236
+ return Interpolation.new(expression: source_of(object_expression)) unless prop.type == "ObjectProperty"
1237
+ return Interpolation.new(expression: source_of(object_expression)) if prop.raw["computed"]
1238
+
1239
+ key_name = object_property_key_name(prop[:key])
1240
+ return Interpolation.new(expression: source_of(object_expression)) if key_name.nil?
1241
+
1242
+ properties << [key_name, lower_value_expression(prop[:value])]
1243
+ end
1244
+ ObjectLiteral.new(properties: properties)
1245
+ end
1246
+
1247
+ def object_property_key_name(key_node)
1248
+ case key_node.type
1249
+ when "Identifier" then key_node[:name]
1250
+ when "StringLiteral" then key_node[:value]
1251
+ when "NumericLiteral" then key_node[:value].to_s
1252
+ end
1253
+ end
1254
+
1255
+ def lower_array_literal(array_expression)
1256
+ elements = array_expression[:elements].map do |el|
1257
+ next nil if el.nil? # `[1, , 3]` holes — Ruby has no equivalent
1258
+
1259
+ lower_value_expression(el)
1260
+ end
1261
+ ArrayLiteral.new(elements: elements)
1262
+ end
1263
+
1264
+ def lower_value_lambda(arrow)
1265
+ params = arrow[:params]
1266
+ return Interpolation.new(expression: source_of(arrow)) if params.size > 4
1267
+ unless params.all? { |p| AST::Node.matches?(p, "Identifier") }
1268
+ return Interpolation.new(expression: source_of(arrow))
1269
+ end
1270
+
1271
+ body = lower_lambda_body(arrow[:body])
1272
+ return Interpolation.new(expression: source_of(arrow)) unless body
1273
+
1274
+ Lambda.new(params: params.map { |p| p[:name] }, body: body)
1275
+ end
1276
+
1277
+ def lower_lambda_body(body)
1278
+ return lower_jsx(body) if %w[JSXElement JSXFragment].include?(body.type)
1279
+
1280
+ return unless body.type == "BlockStatement"
1281
+
1282
+ return_stmt = body[:body].find { |s| s.type == "ReturnStatement" }
1283
+ return nil unless return_stmt && return_stmt[:argument]
1284
+
1285
+ arg = return_stmt[:argument]
1286
+ %w[JSXElement JSXFragment].include?(arg.type) ? lower_jsx(arg) : nil
1287
+ end
1288
+
1117
1289
  def style_binding_expression(value)
1118
1290
  case value
1119
1291
  when nil then "true"