jsx_rosetta 0.1.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.
@@ -0,0 +1,727 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ module JsxRosetta
6
+ module IR
7
+ # Lowers a parsed AST::File into an IR::Component tree.
8
+ #
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.
21
+ #
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
+ class Lowering
28
+ # A failure during AST → IR lowering. Carries optional line/column
29
+ # information when the failure can be tied to an AST node.
30
+ class LoweringError < JsxRosetta::Error
31
+ attr_reader :line, :column
32
+
33
+ def initialize(message, node: nil, source: nil)
34
+ @line = nil
35
+ @column = nil
36
+
37
+ if node && source && node.start_pos
38
+ @line, @column = compute_line_column(source, node.start_pos)
39
+ message = "#{message} (at line #{@line}, column #{@column})"
40
+ end
41
+
42
+ super(message)
43
+ end
44
+
45
+ private
46
+
47
+ def compute_line_column(source, position)
48
+ prefix = source[0...position] || ""
49
+ line = prefix.count("\n") + 1
50
+ last_newline = prefix.rindex("\n")
51
+ column = last_newline ? position - last_newline - 1 : position
52
+ [line, column + 1]
53
+ end
54
+ end
55
+
56
+ def self.lower(file, source:)
57
+ new(source).lower_file(file)
58
+ end
59
+
60
+ def self.lower_all(file, source:)
61
+ new(source).lower_all_components(file)
62
+ end
63
+
64
+ REACT_HOOKS = %w[
65
+ useState useEffect useRef useContext useMemo useCallback
66
+ useReducer useImperativeHandle useLayoutEffect useDebugValue
67
+ ].freeze
68
+
69
+ def initialize(source)
70
+ @source = source
71
+ @prop_names = []
72
+ @local_jsx = {}
73
+ @local_bindings = []
74
+ @local_arrows = {}
75
+ @local_polymorphic_tags = {}
76
+ @stimulus_methods = []
77
+ @stimulus_seen_names = {}
78
+ @react_hooks = []
79
+ end
80
+
81
+ def lower_file(file)
82
+ candidates = find_component_functions(file.program)
83
+ raise lowering_error("no component function found in module") if candidates.empty?
84
+
85
+ name, function = candidates.first
86
+ lower_component(name, function)
87
+ end
88
+
89
+ def lower_all_components(file)
90
+ candidates = find_component_functions(file.program)
91
+ raise lowering_error("no component function found in module") if candidates.empty?
92
+
93
+ candidates.map { |name, function| lower_component(name, function) }
94
+ end
95
+
96
+ private
97
+
98
+ def lowering_error(message, node: nil)
99
+ LoweringError.new(message, node: node, source: @source)
100
+ end
101
+
102
+ def find_component_functions(program)
103
+ program.body.flat_map { |stmt| extract_components(stmt) }
104
+ .compact
105
+ .select { |(name, _)| component_name?(name) }
106
+ end
107
+
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)
112
+ return false if name.nil? || name.empty?
113
+
114
+ first = name[0]
115
+ first == first.upcase && first != first.downcase
116
+ end
117
+
118
+ def extract_components(stmt)
119
+ case stmt.type
120
+ when "FunctionDeclaration"
121
+ [[stmt[:id]&.[](:name), stmt]]
122
+ when "VariableDeclaration"
123
+ extract_arrow_components(stmt)
124
+ when "ExportNamedDeclaration", "ExportDefaultDeclaration"
125
+ extract_exported_components(stmt[:declaration])
126
+ else
127
+ []
128
+ end
129
+ end
130
+
131
+ def extract_exported_components(declaration)
132
+ return [] unless declaration.is_a?(AST::Node)
133
+
134
+ case declaration.type
135
+ when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
136
+ when "VariableDeclaration" then extract_arrow_components(declaration)
137
+ else []
138
+ end
139
+ end
140
+
141
+ def extract_arrow_components(variable_declaration)
142
+ variable_declaration[:declarations].filter_map do |declarator|
143
+ init = declarator[:init]
144
+ next nil unless init.is_a?(AST::Node)
145
+ next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
146
+
147
+ name = declarator[:id]&.[](:name)
148
+ name ? [name, init] : nil
149
+ end
150
+ end
151
+
152
+ def lower_component(name, function)
153
+ if name.nil? || name.empty?
154
+ raise lowering_error("anonymous component functions are not supported", node: function)
155
+ end
156
+
157
+ props, rest_prop_name = lower_params(function[:params])
158
+ @prop_names = props.map(&:name)
159
+ @local_bindings = []
160
+ @local_arrows = {}
161
+ @local_polymorphic_tags = {}
162
+ @stimulus_methods = []
163
+ @stimulus_seen_names = {}
164
+ @react_hooks = []
165
+
166
+ body = lower_function_body(function[:body])
167
+
168
+ Component.new(
169
+ name: name,
170
+ props: props,
171
+ body: body,
172
+ rest_prop_name: rest_prop_name,
173
+ local_bindings: @local_bindings,
174
+ stimulus_methods: @stimulus_methods,
175
+ react_hooks: @react_hooks
176
+ )
177
+ end
178
+
179
+ def lower_params(params)
180
+ return [[], nil] if params.nil? || params.empty?
181
+
182
+ first_param = params.first
183
+ case first_param.type
184
+ when "ObjectPattern"
185
+ lower_object_pattern_params(first_param)
186
+ when "Identifier"
187
+ [[Prop.new(name: first_param[:name], default: nil)], nil]
188
+ else
189
+ raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
190
+ end
191
+ end
192
+
193
+ def lower_object_pattern_params(pattern)
194
+ props = []
195
+ rest_name = nil
196
+ pattern[:properties].each do |property|
197
+ case property.type
198
+ when "ObjectProperty"
199
+ props << lower_object_prop(property)
200
+ when "RestElement"
201
+ argument = property[:argument]
202
+ rest_name = argument.type == "Identifier" ? argument[:name] : source_of(argument)
203
+ else
204
+ raise lowering_error("unsupported prop pattern: #{property.type}", node: property)
205
+ end
206
+ end
207
+ [props, rest_name]
208
+ end
209
+
210
+ def lower_object_prop(property)
211
+ value = property[:value]
212
+ if value.type == "AssignmentPattern"
213
+ Prop.new(
214
+ name: value[:left][:name],
215
+ default: Interpolation.new(expression: source_of(value[:right]))
216
+ )
217
+ else
218
+ Prop.new(name: value[:name], default: nil)
219
+ end
220
+ end
221
+
222
+ def lower_function_body(body)
223
+ case body.type
224
+ when "BlockStatement"
225
+ collect_local_bindings(body[:body])
226
+ return_stmt = body[:body].find { |stmt| stmt.type == "ReturnStatement" }
227
+ raise lowering_error("component function has no return statement", node: body) unless return_stmt
228
+
229
+ lower_jsx(return_stmt[:argument])
230
+ when "JSXElement", "JSXFragment"
231
+ @local_jsx = {}
232
+ lower_jsx(body)
233
+ else
234
+ raise lowering_error("unsupported component body: #{body.type}", node: body)
235
+ end
236
+ end
237
+
238
+ def collect_local_bindings(statements)
239
+ @local_jsx = {}
240
+ @local_arrows = {}
241
+ @local_polymorphic_tags = {}
242
+ seen_other_stmts = {}
243
+
244
+ statements.each do |stmt|
245
+ case stmt.type
246
+ when "VariableDeclaration"
247
+ stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen_other_stmts) }
248
+ when "ExpressionStatement"
249
+ detect_bare_hook_call(stmt)
250
+ end
251
+ end
252
+ end
253
+
254
+ def classify_local_binding(stmt, declarator, seen)
255
+ init = declarator[:init]
256
+ return unless init.is_a?(AST::Node)
257
+
258
+ if hook_call?(init)
259
+ @react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip)
260
+ return
261
+ end
262
+
263
+ name = declarator[:id]&.[](:name)
264
+ return unless name
265
+
266
+ case init.type
267
+ when "JSXElement", "JSXFragment"
268
+ @local_jsx[name] = init
269
+ when "ArrowFunctionExpression", "FunctionExpression"
270
+ @local_arrows[name] = init
271
+ when "ConditionalExpression"
272
+ poly = lower_polymorphic_tag(init)
273
+ poly ? (@local_polymorphic_tags[name] = poly) : record_local_other_binding(stmt, name, seen)
274
+ else
275
+ record_local_other_binding(stmt, name, seen)
276
+ end
277
+ end
278
+
279
+ def detect_bare_hook_call(stmt)
280
+ expr = stmt[:expression]
281
+ return unless expr.is_a?(AST::Node) && expr.type == "CallExpression"
282
+ return unless hook_call?(expr)
283
+
284
+ @react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
285
+ end
286
+
287
+ def hook_call?(call_expression)
288
+ return false unless call_expression.type == "CallExpression"
289
+
290
+ callee = call_expression[:callee]
291
+ callee.is_a?(AST::Node) && callee.type == "Identifier" && REACT_HOOKS.include?(callee[:name])
292
+ end
293
+
294
+ # Recognize the asChild-style polymorphic tag pattern:
295
+ # const Comp = condition ? <BranchA> : <BranchB>;
296
+ # where each branch is a JSX-renderable thing — a string-literal HTML
297
+ # tag name (`"button"`), an Identifier (`Slot`), or a MemberExpression
298
+ # (`Slot.Root`). Returns nil when the shape isn't recognized so the
299
+ # caller can fall back to the verbatim TODO-comment behavior.
300
+ def lower_polymorphic_tag(conditional)
301
+ true_branch = polymorphic_tag_branch(conditional[:consequent])
302
+ false_branch = polymorphic_tag_branch(conditional[:alternate])
303
+ return nil unless true_branch && false_branch
304
+
305
+ { test: conditional[:test], true_branch: true_branch, false_branch: false_branch }
306
+ end
307
+
308
+ def polymorphic_tag_branch(node)
309
+ case node.type
310
+ when "StringLiteral" then { kind: :element, tag: node[:value] }
311
+ when "Identifier" then { kind: :component, tag: node[:name] }
312
+ when "MemberExpression" then { kind: :component, tag: source_of(node) }
313
+ end
314
+ end
315
+
316
+ def record_local_other_binding(stmt, name, seen)
317
+ seen[stmt.start_pos] ||= source_of(stmt).strip
318
+ @local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
319
+ end
320
+
321
+ def lower_jsx(node)
322
+ case node
323
+ when AST::JSXElement then lower_jsx_element(node)
324
+ when AST::JSXFragment then lower_jsx_fragment(node)
325
+ when AST::JSXText then lower_jsx_text(node)
326
+ when AST::JSXExpressionContainer then lower_jsx_expression(node)
327
+ else
328
+ raise lowering_error("unexpected JSX node in lowering: #{node.type}", node: node)
329
+ end
330
+ end
331
+
332
+ def lower_jsx_element(element)
333
+ tag = element.tag_name
334
+ attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr) }
335
+ # `key` is a React-only reconciliation hint; never emit it to the DOM
336
+ # or to ViewComponent invocations.
337
+ attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
338
+ children = lower_children(element.jsx_children)
339
+
340
+ if (poly = @local_polymorphic_tags[tag])
341
+ lower_polymorphic_tag_use(poly, attributes, children)
342
+ elsif html_element?(tag)
343
+ Element.new(tag: tag, attributes: attributes, children: children)
344
+ else
345
+ ComponentInvocation.new(name: tag, props: attributes, children: children)
346
+ end
347
+ end
348
+
349
+ def lower_polymorphic_tag_use(poly, attributes, children)
350
+ Conditional.new(
351
+ test: Interpolation.new(expression: source_of(poly[:test])),
352
+ consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
353
+ alternate: build_polymorphic_branch(poly[:false_branch], attributes, children)
354
+ )
355
+ end
356
+
357
+ def build_polymorphic_branch(branch, attributes, children)
358
+ case branch[:kind]
359
+ when :element
360
+ Element.new(tag: branch[:tag], attributes: attributes, children: children)
361
+ when :component
362
+ ComponentInvocation.new(name: branch[:tag], props: attributes, children: children)
363
+ end
364
+ end
365
+
366
+ def lower_jsx_fragment(fragment)
367
+ Fragment.new(children: lower_children(fragment.jsx_children))
368
+ end
369
+
370
+ def lower_children(children)
371
+ children.filter_map do |child|
372
+ case child
373
+ when AST::JSXText
374
+ lower_jsx_text(child)
375
+ else
376
+ lower_jsx(child)
377
+ end
378
+ end
379
+ end
380
+
381
+ def lower_jsx_text(node)
382
+ value = normalize_jsx_text(node.value)
383
+ return nil if value.empty?
384
+
385
+ Text.new(value: value)
386
+ end
387
+
388
+ # Apply JSX whitespace rules (matching Babel's cleanJSXElementLiteralChild):
389
+ # - tabs are converted to spaces
390
+ # - leading whitespace on every line except the first is stripped
391
+ # - trailing whitespace on every line except the last is stripped
392
+ # - non-empty lines are joined; each non-final non-empty line gets a
393
+ # trailing space appended
394
+ # - all-whitespace text becomes empty (caller drops it)
395
+ def normalize_jsx_text(value)
396
+ lines = value.split(/\r\n|\n|\r/)
397
+ last_non_empty = nil
398
+ lines.each_with_index { |line, i| last_non_empty = i if line.match?(/[^ \t]/) }
399
+ return "" if last_non_empty.nil?
400
+
401
+ result = String.new
402
+ lines.each_with_index do |line, i|
403
+ trimmed = line.tr("\t", " ")
404
+ trimmed = trimmed.sub(/\A +/, "") unless i.zero?
405
+ trimmed = trimmed.sub(/ +\z/, "") unless i == lines.length - 1
406
+ next if trimmed.empty?
407
+
408
+ trimmed += " " unless i == last_non_empty
409
+ result << trimmed
410
+ end
411
+ result
412
+ end
413
+
414
+ def lower_jsx_expression(node)
415
+ expression = node.expression
416
+ return lower_jsx_comment(expression) if expression.is_a?(AST::JSXEmptyExpression)
417
+
418
+ case expression.type
419
+ when "StringLiteral" then Text.new(value: expression[:value])
420
+ when "NumericLiteral" then Text.new(value: expression[:value].to_s)
421
+ when "BooleanLiteral", "NullLiteral" then nil
422
+ when "LogicalExpression" then lower_logical_expression(expression)
423
+ when "ConditionalExpression" then lower_ternary_expression(expression)
424
+ when "Identifier" then lower_identifier_expression(expression)
425
+ when "CallExpression" then lower_call_expression(expression)
426
+ else
427
+ Interpolation.new(expression: source_of(expression))
428
+ end
429
+ end
430
+
431
+ def lower_jsx_comment(empty_expression)
432
+ comments = empty_expression.raw["innerComments"]
433
+ return nil if comments.nil? || comments.empty?
434
+
435
+ Comment.new(text: comments.map { |c| c["value"] }.join("\n").strip)
436
+ end
437
+
438
+ def lower_call_expression(expression)
439
+ loop_node = try_lower_map_loop(expression)
440
+ loop_node || Interpolation.new(expression: source_of(expression))
441
+ end
442
+
443
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
444
+ def try_lower_map_loop(call_expression)
445
+ callee = call_expression[:callee]
446
+ return nil unless callee.is_a?(AST::Node) && callee.type == "MemberExpression"
447
+ return nil unless callee[:property].is_a?(AST::Node) && callee[:property][:name] == "map"
448
+
449
+ args = call_expression[:arguments]
450
+ return nil if args.size != 1
451
+ return nil unless args.first.type == "ArrowFunctionExpression"
452
+
453
+ arrow = args.first
454
+ params = arrow[:params]
455
+ return nil if params.empty? || params.size > 2
456
+ return nil unless params.all? { |p| p.is_a?(AST::Node) && p.type == "Identifier" }
457
+
458
+ body = lower_arrow_body(arrow[:body])
459
+ return nil unless body
460
+
461
+ Loop.new(
462
+ iterable: Interpolation.new(expression: source_of(callee[:object])),
463
+ item_binding: params[0][:name],
464
+ index_binding: params[1] && params[1][:name],
465
+ body: body
466
+ )
467
+ end
468
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
469
+
470
+ def lower_arrow_body(body)
471
+ case body.type
472
+ when "JSXElement", "JSXFragment"
473
+ lower_jsx(body)
474
+ when "BlockStatement"
475
+ return_stmt = body[:body].find { |s| s.type == "ReturnStatement" }
476
+ return nil unless return_stmt
477
+
478
+ arg = return_stmt[:argument]
479
+ return nil unless %w[JSXElement JSXFragment].include?(arg&.type)
480
+
481
+ lower_jsx(arg)
482
+ end
483
+ end
484
+
485
+ def lower_logical_expression(expr)
486
+ if expr[:operator] == "&&"
487
+ Conditional.new(
488
+ test: Interpolation.new(expression: source_of(expr[:left])),
489
+ consequent: lower_jsx_or_value(expr[:right]),
490
+ alternate: nil
491
+ )
492
+ else
493
+ Interpolation.new(expression: source_of(expr))
494
+ end
495
+ end
496
+
497
+ def lower_ternary_expression(expr)
498
+ alternate_node = expr[:alternate]
499
+ alternate = alternate_node.type == "NullLiteral" ? nil : lower_jsx_or_value(alternate_node)
500
+
501
+ Conditional.new(
502
+ test: Interpolation.new(expression: source_of(expr[:test])),
503
+ consequent: lower_jsx_or_value(expr[:consequent]),
504
+ alternate: alternate
505
+ )
506
+ end
507
+
508
+ def lower_identifier_expression(identifier)
509
+ name = identifier[:name]
510
+ if name == "children" && @prop_names.include?("children")
511
+ Slot.new(name: "children")
512
+ elsif (jsx = @local_jsx[name])
513
+ lower_jsx(jsx)
514
+ else
515
+ Interpolation.new(expression: name)
516
+ end
517
+ end
518
+
519
+ def lower_jsx_or_value(node)
520
+ case node.type
521
+ when "JSXElement", "JSXFragment"
522
+ lower_jsx(node)
523
+ when "Identifier"
524
+ jsx = @local_jsx[node[:name]]
525
+ jsx ? lower_jsx(jsx) : Interpolation.new(expression: source_of(node))
526
+ else
527
+ Interpolation.new(expression: source_of(node))
528
+ end
529
+ end
530
+
531
+ def lower_attribute(attr)
532
+ case attr
533
+ when AST::JSXAttribute
534
+ lower_jsx_attribute(attr)
535
+ when AST::JSXSpreadAttribute
536
+ SpreadAttribute.new(expression: source_of(attr.argument))
537
+ end
538
+ end
539
+
540
+ def lower_jsx_attribute(attr)
541
+ name = attr.attribute_name
542
+
543
+ return lower_class_name(attr.value) if name == "className"
544
+ return lower_style_attribute_or_fallback(attr.value) if name == "style"
545
+ if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
546
+ return lower_event_attribute(name, attr.value)
547
+ end
548
+
549
+ Attribute.new(name: name, value: lower_attribute_value(attr.value))
550
+ end
551
+
552
+ def lower_style_attribute_or_fallback(value)
553
+ lower_style_attribute(value) || Attribute.new(name: "style", value: lower_attribute_value(value))
554
+ end
555
+
556
+ def lower_style_attribute(value)
557
+ return nil unless value.is_a?(AST::JSXExpressionContainer)
558
+
559
+ expression = value.expression
560
+ return nil unless expression.is_a?(AST::Node) && expression.type == "ObjectExpression"
561
+
562
+ declarations = expression[:properties].map { |prop| lower_style_property(prop) }
563
+ return nil if declarations.any?(&:nil?)
564
+
565
+ Style.new(declarations: declarations)
566
+ end
567
+
568
+ def lower_style_property(property)
569
+ return nil unless property.type == "ObjectProperty"
570
+
571
+ property_name =
572
+ case property[:key].type
573
+ when "Identifier" then css_property_from_camel(property[:key][:name])
574
+ when "StringLiteral" then property[:key][:value]
575
+ end
576
+ return nil if property_name.nil?
577
+
578
+ value = lower_style_value(property[:value])
579
+ return nil if value.nil?
580
+
581
+ StyleDeclaration.new(property: property_name, value: value)
582
+ end
583
+
584
+ def lower_style_value(value)
585
+ case value.type
586
+ when "StringLiteral" then value[:value]
587
+ when "NumericLiteral" then value[:value].to_s
588
+ when "Identifier", "MemberExpression"
589
+ Interpolation.new(expression: source_of(value))
590
+ end
591
+ end
592
+
593
+ def css_property_from_camel(name)
594
+ name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
595
+ end
596
+
597
+ def lower_class_name(value)
598
+ if value.is_a?(AST::JSXExpressionContainer)
599
+ decomposed = try_lower_class_helper(value.expression)
600
+ return decomposed if decomposed
601
+ end
602
+ StyleBinding.new(expression: style_binding_expression(value))
603
+ end
604
+
605
+ def try_lower_class_helper(expression)
606
+ return nil unless expression.is_a?(AST::Node) && expression.type == "CallExpression"
607
+
608
+ callee = expression[:callee]
609
+ return nil unless callee.is_a?(AST::Node) && callee.type == "Identifier"
610
+ return nil unless %w[cn clsx classnames].include?(callee[:name])
611
+
612
+ segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
613
+ return nil if segments.any?(&:nil?)
614
+
615
+ ClassList.new(segments: segments)
616
+ end
617
+
618
+ def lower_class_helper_arg(arg)
619
+ case arg.type
620
+ when "StringLiteral" then arg[:value]
621
+ when "Identifier", "MemberExpression" then Interpolation.new(expression: source_of(arg))
622
+ when "ObjectExpression" then lower_class_helper_object(arg)
623
+ end
624
+ end
625
+
626
+ def lower_class_helper_object(object_expression)
627
+ object_expression[:properties].map do |prop|
628
+ break [nil] unless prop.type == "ObjectProperty"
629
+
630
+ class_name =
631
+ case prop[:key].type
632
+ when "StringLiteral" then prop[:key][:value]
633
+ when "Identifier" then prop[:key][:name]
634
+ end
635
+ break [nil] if class_name.nil?
636
+
637
+ ConditionalSegment.new(
638
+ class_name: class_name,
639
+ condition: Interpolation.new(expression: source_of(prop[:value]))
640
+ )
641
+ end
642
+ end
643
+
644
+ def event_attribute?(name)
645
+ name.match?(/\Aon[A-Z]\w*\z/)
646
+ end
647
+
648
+ def lower_event_attribute(name, value)
649
+ event = name.sub(/\Aon/, "").downcase
650
+ expression = value.expression
651
+
652
+ stimulus = try_promote_to_stimulus(name, event, expression)
653
+ return stimulus if stimulus
654
+
655
+ EventBinding.new(
656
+ event: event,
657
+ handler: Interpolation.new(expression: source_of(expression))
658
+ )
659
+ end
660
+
661
+ def try_promote_to_stimulus(attr_name, event, expression)
662
+ arrow_node, name_hint = stimulus_arrow_for(expression)
663
+ return nil unless arrow_node
664
+
665
+ method_name = stimulus_method_name(name_hint || default_stimulus_method_name(attr_name))
666
+ body_source = source_of(arrow_node[:body])
667
+ @stimulus_methods << StimulusMethod.new(name: method_name, body_source: body_source)
668
+ @local_arrows.delete(name_hint) if name_hint
669
+
670
+ StimulusBinding.new(event: event, method_name: method_name)
671
+ end
672
+
673
+ def stimulus_arrow_for(expression)
674
+ case expression.type
675
+ when "ArrowFunctionExpression", "FunctionExpression"
676
+ [expression, nil]
677
+ when "Identifier"
678
+ arrow = @local_arrows[expression[:name]]
679
+ arrow ? [arrow, expression[:name]] : nil
680
+ end
681
+ end
682
+
683
+ def default_stimulus_method_name(attr_name)
684
+ # `onClick` → `clickHandler`
685
+ event = attr_name.sub(/\Aon/, "")
686
+ "#{event[0].downcase}#{event[1..]}Handler"
687
+ end
688
+
689
+ def stimulus_method_name(base)
690
+ @stimulus_seen_names[base] ||= 0
691
+ @stimulus_seen_names[base] += 1
692
+ @stimulus_seen_names[base] == 1 ? base : "#{base}#{@stimulus_seen_names[base]}"
693
+ end
694
+
695
+ def lower_attribute_value(value)
696
+ case value
697
+ when nil
698
+ true
699
+ when AST::JSXExpressionContainer
700
+ Interpolation.new(expression: source_of(value.expression))
701
+ else
702
+ value.raw["value"]
703
+ end
704
+ end
705
+
706
+ def style_binding_expression(value)
707
+ case value
708
+ when nil then "true"
709
+ when AST::JSXExpressionContainer then source_of(value.expression)
710
+ else source_of(value)
711
+ end
712
+ end
713
+
714
+ def html_element?(tag)
715
+ return false if tag.nil? || tag.empty?
716
+ return false if tag.include?(".")
717
+
718
+ first = tag[0]
719
+ first == first.downcase
720
+ end
721
+
722
+ def source_of(node)
723
+ @source[node.start_pos...node.end_pos]
724
+ end
725
+ end
726
+ end
727
+ end