jsx_rosetta 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -1
- data/ROADMAP.md +92 -0
- data/lib/jsx_rosetta/ast/inflector.rb +15 -0
- data/lib/jsx_rosetta/backend/phlex.rb +372 -110
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +297 -26
- data/lib/jsx_rosetta/backend/view_component.rb +214 -30
- data/lib/jsx_rosetta/ir/lowering.rb +445 -40
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/types.rb +78 -17
- data/lib/jsx_rosetta/version.rb +1 -1
- metadata +2 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "types"
|
|
4
4
|
require_relative "module_shape_classifier"
|
|
5
|
+
require_relative "../ast/inflector"
|
|
5
6
|
|
|
6
7
|
module JsxRosetta
|
|
7
8
|
module IR
|
|
@@ -67,8 +68,53 @@ module JsxRosetta
|
|
|
67
68
|
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
68
69
|
].freeze
|
|
69
70
|
|
|
71
|
+
# Apollo Client hooks. `useQuery` / `useLazyQuery` / `useSubscription`
|
|
72
|
+
# take a GraphQL document as the first argument; `useMutation` returns
|
|
73
|
+
# a `[mutate, { loading, ... }]` tuple. None of these have a direct
|
|
74
|
+
# translation — they encode data fetching, which in Rails lives in
|
|
75
|
+
# the controller/model. Captured here so the backend can emit a
|
|
76
|
+
# per-hook TODO with the operation name preserved when extractable.
|
|
77
|
+
APOLLO_HOOKS = %w[
|
|
78
|
+
useQuery useLazyQuery useMutation useSubscription useApolloClient
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
# Next.js navigation hooks (App Router and Pages Router). Each has a
|
|
82
|
+
# Rails-side analog:
|
|
83
|
+
# useRouter → controller actions / redirect_to
|
|
84
|
+
# usePathname → request.path
|
|
85
|
+
# useSearchParams → params
|
|
86
|
+
# useParams → params (route params)
|
|
87
|
+
# useSelectedLayoutSegment(s) → not directly translatable; usually
|
|
88
|
+
# used to highlight nav links — the Rails view can pattern-match
|
|
89
|
+
# against request.path.
|
|
90
|
+
NEXT_HOOKS = %w[
|
|
91
|
+
useRouter usePathname useSearchParams useParams
|
|
92
|
+
useSelectedLayoutSegment useSelectedLayoutSegments
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
FRAMEWORK_HOOKS_BY_LIBRARY = {
|
|
96
|
+
react: REACT_HOOKS,
|
|
97
|
+
apollo: APOLLO_HOOKS,
|
|
98
|
+
next_js: NEXT_HOOKS
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
70
101
|
JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
|
|
71
102
|
|
|
103
|
+
# Mirrors React's `isUnitlessNumber` table — CSS properties that take
|
|
104
|
+
# a bare number rather than a length. Numeric style values for any
|
|
105
|
+
# property NOT in this set get a `px` suffix appended at lowering time.
|
|
106
|
+
UNITLESS_CSS_PROPERTIES = %w[
|
|
107
|
+
animation-iteration-count aspect-ratio border-image-outset
|
|
108
|
+
border-image-slice border-image-width box-flex box-flex-group
|
|
109
|
+
box-ordinal-group column-count columns flex flex-grow flex-negative
|
|
110
|
+
flex-order flex-positive flex-shrink font-weight grid-area
|
|
111
|
+
grid-column grid-column-end grid-column-span grid-column-start
|
|
112
|
+
grid-row grid-row-end grid-row-span grid-row-start line-clamp
|
|
113
|
+
line-height opacity order orphans scale tab-size widows z-index
|
|
114
|
+
zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
|
|
115
|
+
stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
|
|
116
|
+
].to_set.freeze
|
|
117
|
+
|
|
72
118
|
# Pre-lowering AST scan: maps a node type to a callable returning the
|
|
73
119
|
# AST nodes that contribute return values. Used by body_returns_jsx?.
|
|
74
120
|
JSX_RETURN_PROBES = {
|
|
@@ -112,6 +158,13 @@ module JsxRosetta
|
|
|
112
158
|
@stimulus_methods = []
|
|
113
159
|
@stimulus_seen_names = {}
|
|
114
160
|
@react_hooks = []
|
|
161
|
+
@render_methods = []
|
|
162
|
+
@render_method_seen = {}
|
|
163
|
+
# Class-component non-render members (constructor, lifecycle hooks,
|
|
164
|
+
# custom handlers). Keyed by class name; populated by
|
|
165
|
+
# extract_class_component, drained by lower_component to surface
|
|
166
|
+
# the verbatim sources as a TODO comment block.
|
|
167
|
+
@pending_class_other_members = {}
|
|
115
168
|
end
|
|
116
169
|
|
|
117
170
|
def lower_file(file)
|
|
@@ -210,6 +263,7 @@ module JsxRosetta
|
|
|
210
263
|
return false if name.nil? || name.empty?
|
|
211
264
|
return true if pascal_case?(name)
|
|
212
265
|
return false if hook_name?(name)
|
|
266
|
+
return true if extract_data_factory_array(function)
|
|
213
267
|
|
|
214
268
|
body_returns_jsx?(function[:body])
|
|
215
269
|
end
|
|
@@ -247,6 +301,8 @@ module JsxRosetta
|
|
|
247
301
|
[[stmt[:id]&.[](:name), stmt]]
|
|
248
302
|
when "VariableDeclaration"
|
|
249
303
|
extract_arrow_components(stmt)
|
|
304
|
+
when "ClassDeclaration"
|
|
305
|
+
extract_class_component(stmt)
|
|
250
306
|
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
251
307
|
extract_exported_components(stmt[:declaration])
|
|
252
308
|
else
|
|
@@ -260,10 +316,133 @@ module JsxRosetta
|
|
|
260
316
|
case declaration.type
|
|
261
317
|
when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
|
|
262
318
|
when "VariableDeclaration" then extract_arrow_components(declaration)
|
|
319
|
+
when "ClassDeclaration" then extract_class_component(declaration)
|
|
263
320
|
else []
|
|
264
321
|
end
|
|
265
322
|
end
|
|
266
323
|
|
|
324
|
+
# Recognize a class component by the presence of a `render()` method.
|
|
325
|
+
# We don't require `extends React.Component` because TypeScript codebases
|
|
326
|
+
# often declare the parent via an `extends` of a typed alias. The render
|
|
327
|
+
# method's signature (no args, returns JSX) is the JSX-component signal.
|
|
328
|
+
#
|
|
329
|
+
# The render ClassMethod's `[:params]` is always `[]` and `[:body]` is a
|
|
330
|
+
# BlockStatement — same shape as a function declaration's body, so the
|
|
331
|
+
# rest of the lowering pipeline works unchanged. Other class members
|
|
332
|
+
# (constructor, lifecycle hooks, custom handlers) get stashed on
|
|
333
|
+
# `@pending_class_other_members` keyed by class name, then surfaced as
|
|
334
|
+
# a LocalBinding-style TODO block by `lower_component`.
|
|
335
|
+
def extract_class_component(class_decl)
|
|
336
|
+
name = class_decl[:id]&.[](:name)
|
|
337
|
+
return [] unless name
|
|
338
|
+
|
|
339
|
+
render_method, other_members = partition_class_members(class_decl)
|
|
340
|
+
return [] unless render_method
|
|
341
|
+
|
|
342
|
+
@pending_class_other_members[name] = other_members
|
|
343
|
+
[[name, render_method]]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def partition_class_members(class_decl)
|
|
347
|
+
body = class_decl.child(:body)
|
|
348
|
+
return [nil, []] unless body
|
|
349
|
+
|
|
350
|
+
render_method = nil
|
|
351
|
+
others = []
|
|
352
|
+
body[:body].each do |member|
|
|
353
|
+
if class_render_method?(member)
|
|
354
|
+
render_method = member
|
|
355
|
+
else
|
|
356
|
+
others << member
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
[render_method, others]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def class_render_method?(member)
|
|
363
|
+
return false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
|
|
364
|
+
|
|
365
|
+
key = member.child(:key)
|
|
366
|
+
AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Surface every non-render class member (constructor, lifecycle
|
|
370
|
+
# methods like componentDidMount / componentDidCatch / getDerivedStateFromError,
|
|
371
|
+
# custom event handlers) as a LocalBinding-shaped TODO with the
|
|
372
|
+
# verbatim JS source preserved. The user either translates each to a
|
|
373
|
+
# Ruby method by hand or moves the behavior to Stimulus / controllers.
|
|
374
|
+
def absorb_class_other_members(name)
|
|
375
|
+
members = @pending_class_other_members.delete(name) || []
|
|
376
|
+
members.each do |member|
|
|
377
|
+
source = source_of(member).strip
|
|
378
|
+
member_name = class_member_label(member)
|
|
379
|
+
@local_bindings << LocalBinding.new(name: member_name, source: source)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def class_member_label(member)
|
|
384
|
+
key = member.child(:key)
|
|
385
|
+
return "<class member>" unless key
|
|
386
|
+
|
|
387
|
+
case key.type
|
|
388
|
+
when "Identifier" then key[:name]
|
|
389
|
+
when "StringLiteral" then key[:value]
|
|
390
|
+
else "<class member>"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Pre-scan the render method body for `this.props.X` member access
|
|
395
|
+
# patterns. Each unique X becomes a synthesized IR::Prop entry on the
|
|
396
|
+
# component, so the generated class emits a matching `initialize(x:)`
|
|
397
|
+
# and the translator (which sees `@x`) resolves cleanly. Without this
|
|
398
|
+
# scan, render references would land in `unresolved_identifiers` and
|
|
399
|
+
# the generated initializer would be empty.
|
|
400
|
+
def absorb_class_render_props(render_method)
|
|
401
|
+
body = render_method.child(:body)
|
|
402
|
+
return [] unless body
|
|
403
|
+
|
|
404
|
+
prop_names = []
|
|
405
|
+
scan_this_props(body, prop_names)
|
|
406
|
+
prop_names.uniq.map { |name| Prop.new(name: name, default: nil, alias_name: nil) }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def scan_this_props(node, accumulator)
|
|
410
|
+
return unless node.is_a?(AST::Node)
|
|
411
|
+
|
|
412
|
+
if node.of_type?("MemberExpression") && this_props_access?(node)
|
|
413
|
+
accumulator << node[:property][:name]
|
|
414
|
+
elsif node.of_type?("VariableDeclarator") && this_props_destructure?(node)
|
|
415
|
+
destructured_names_of(node[:id]).each { |name| accumulator << name }
|
|
416
|
+
end
|
|
417
|
+
node.each_child { |child| scan_this_props(child, accumulator) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Match `this.props.X` exactly — `this.props` member access where the
|
|
421
|
+
# property side is also a MemberExpression. We don't follow deeper
|
|
422
|
+
# chains here; only the immediate `.X` after `.props` becomes a prop
|
|
423
|
+
# name. `this.props.foo.bar` still yields prop name `foo`.
|
|
424
|
+
def this_props_access?(member_expr)
|
|
425
|
+
object = member_expr.child(:object)
|
|
426
|
+
return false unless AST::Node.matches?(object, "MemberExpression")
|
|
427
|
+
return false unless AST::Node.matches?(object.child(:object), "ThisExpression")
|
|
428
|
+
|
|
429
|
+
object_prop = object.child(:property)
|
|
430
|
+
AST::Node.matches?(object_prop, "Identifier") && object_prop[:name] == "props"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# `const { foo, bar } = this.props;` — destructure off this.props. Each
|
|
434
|
+
# destructured name becomes a prop. We don't need to walk the rest of
|
|
435
|
+
# the chain because the destructure consumes one level of `.props`.
|
|
436
|
+
def this_props_destructure?(declarator)
|
|
437
|
+
init = declarator[:init]
|
|
438
|
+
return false unless AST::Node.matches?(init, "MemberExpression")
|
|
439
|
+
return false unless AST::Node.matches?(init.child(:object), "ThisExpression")
|
|
440
|
+
return false unless destructure_pattern?(declarator[:id])
|
|
441
|
+
|
|
442
|
+
prop = init.child(:property)
|
|
443
|
+
AST::Node.matches?(prop, "Identifier") && prop[:name] == "props"
|
|
444
|
+
end
|
|
445
|
+
|
|
267
446
|
def extract_arrow_components(variable_declaration)
|
|
268
447
|
variable_declaration[:declarations].filter_map do |declarator|
|
|
269
448
|
init = declarator[:init]
|
|
@@ -280,46 +459,129 @@ module JsxRosetta
|
|
|
280
459
|
raise lowering_error("anonymous component functions are not supported", node: function)
|
|
281
460
|
end
|
|
282
461
|
|
|
462
|
+
reset_per_component_state!
|
|
283
463
|
props, rest_prop_name = lower_params(function[:params])
|
|
284
464
|
@prop_names = props.map(&:name)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
body = lower_function_body(function[:body])
|
|
295
|
-
|
|
465
|
+
absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
|
|
466
|
+
|
|
467
|
+
body, mode = lower_component_body(function)
|
|
468
|
+
# Unconsumed local arrows (`const handleClick = () => ...` that didn't
|
|
469
|
+
# become a Stimulus method or a render-method) are still real bindings
|
|
470
|
+
# in the source. Add their names here so the translator treats use
|
|
471
|
+
# sites as known-unresolvable — `on_click: handle_click` (NameError)
|
|
472
|
+
# becomes `on_click: nil` (file loads, marker visible upstream).
|
|
473
|
+
unconsumed_arrow_names = @local_arrows.keys
|
|
296
474
|
Component.new(
|
|
297
475
|
name: name,
|
|
298
476
|
props: props,
|
|
299
477
|
body: body,
|
|
300
478
|
rest_prop_name: rest_prop_name,
|
|
301
479
|
local_bindings: @local_bindings,
|
|
302
|
-
local_binding_names: @local_binding_names.uniq,
|
|
480
|
+
local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
|
|
303
481
|
module_bindings: [],
|
|
304
482
|
stimulus_methods: @stimulus_methods,
|
|
305
|
-
react_hooks: @react_hooks
|
|
483
|
+
react_hooks: @react_hooks,
|
|
484
|
+
render_methods: @render_methods,
|
|
485
|
+
mode: mode
|
|
306
486
|
)
|
|
307
487
|
end
|
|
308
488
|
|
|
489
|
+
# A "data factory" function — common for AG-Grid / antd column
|
|
490
|
+
# descriptor modules — is a function whose body just returns an array
|
|
491
|
+
# of object literals (`export const createColumns = (...) => [{...},
|
|
492
|
+
# {...}]`). When we recognize this shape, we lower the body via the
|
|
493
|
+
# recursive ObjectLiteral/ArrayLiteral path (Gap H) and let the
|
|
494
|
+
# backend emit a snake_case method that returns the data, instead of
|
|
495
|
+
# a `view_template`. JSX inside object properties still extracts to
|
|
496
|
+
# private methods on the class via the IR::Lambda extraction.
|
|
497
|
+
def lower_component_body(function)
|
|
498
|
+
factory_array = extract_data_factory_array(function)
|
|
499
|
+
return [lower_value_expression(factory_array), :data_factory] if factory_array
|
|
500
|
+
|
|
501
|
+
[lower_function_body(function[:body]), :view]
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def extract_data_factory_array(function)
|
|
505
|
+
body = function[:body]
|
|
506
|
+
return nil unless body.is_a?(AST::Node)
|
|
507
|
+
|
|
508
|
+
# Implicit-return arrow: body IS the ArrayExpression.
|
|
509
|
+
return body if data_factory_candidate_array?(body)
|
|
510
|
+
|
|
511
|
+
return nil unless body.of_type?("BlockStatement")
|
|
512
|
+
|
|
513
|
+
return_stmt = body[:body].last
|
|
514
|
+
return nil unless AST::Node.matches?(return_stmt, "ReturnStatement")
|
|
515
|
+
|
|
516
|
+
arg = return_stmt[:argument]
|
|
517
|
+
data_factory_candidate_array?(arg) ? arg : nil
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def data_factory_candidate_array?(node)
|
|
521
|
+
return false unless AST::Node.matches?(node, "ArrayExpression")
|
|
522
|
+
|
|
523
|
+
# At least one element should be an object literal — otherwise
|
|
524
|
+
# this is probably a primitive list, which doesn't warrant the
|
|
525
|
+
# extra emission machinery and can stay as a regular
|
|
526
|
+
# `body_returns_jsx?` rejection.
|
|
527
|
+
node[:elements].any? { |el| AST::Node.matches?(el, "ObjectExpression") }
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def reset_per_component_state!
|
|
531
|
+
@local_bindings = []
|
|
532
|
+
@local_binding_names = []
|
|
533
|
+
@local_arrows = {}
|
|
534
|
+
@local_polymorphic_tags = {}
|
|
535
|
+
@local_destructures = {}
|
|
536
|
+
@stimulus_methods = []
|
|
537
|
+
@stimulus_seen_names = {}
|
|
538
|
+
@react_hooks = []
|
|
539
|
+
@render_methods = []
|
|
540
|
+
@render_method_seen = {}
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def absorb_class_metadata(name, render_method, props)
|
|
544
|
+
absorb_class_other_members(name)
|
|
545
|
+
props.concat(absorb_class_render_props(render_method))
|
|
546
|
+
@prop_names = props.map(&:name)
|
|
547
|
+
end
|
|
548
|
+
|
|
309
549
|
def lower_params(params)
|
|
310
550
|
return [[], nil] if params.nil? || params.empty?
|
|
311
551
|
|
|
552
|
+
# Multi-positional params — typical for data-factory functions
|
|
553
|
+
# like `createColumns(token, sortedInfo)` and lowercase JSX-helpers
|
|
554
|
+
# like `getAlertIcon(level, status, token, isSnoozed = false)`.
|
|
555
|
+
# Each becomes a Prop with no default. We support Identifier and
|
|
556
|
+
# `AssignmentPattern` (default values get dropped — translating
|
|
557
|
+
# JS defaults to Ruby isn't worth the risk here). Other shapes
|
|
558
|
+
# in a multi-param signature fall through to legacy first-param-
|
|
559
|
+
# only handling so we don't regress files that used to translate.
|
|
560
|
+
if params.size > 1 && params.all? { |p| multi_param_supported?(p) }
|
|
561
|
+
return [params.map { |p| Prop.new(name: multi_param_name(p), default: nil, alias_name: nil) }, nil]
|
|
562
|
+
end
|
|
563
|
+
|
|
312
564
|
first_param = params.first
|
|
313
565
|
case first_param.type
|
|
314
566
|
when "ObjectPattern"
|
|
315
567
|
lower_object_pattern_params(first_param)
|
|
316
568
|
when "Identifier"
|
|
317
|
-
[[Prop.new(name: first_param[:name], default: nil)], nil]
|
|
569
|
+
[[Prop.new(name: first_param[:name], default: nil, alias_name: nil)], nil]
|
|
318
570
|
else
|
|
319
571
|
raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
|
|
320
572
|
end
|
|
321
573
|
end
|
|
322
574
|
|
|
575
|
+
def multi_param_supported?(param)
|
|
576
|
+
return true if AST::Node.matches?(param, "Identifier")
|
|
577
|
+
|
|
578
|
+
AST::Node.matches?(param, "AssignmentPattern") && AST::Node.matches?(param[:left], "Identifier")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def multi_param_name(param)
|
|
582
|
+
param.type == "Identifier" ? param[:name] : param[:left][:name]
|
|
583
|
+
end
|
|
584
|
+
|
|
323
585
|
def lower_object_pattern_params(pattern)
|
|
324
586
|
props = []
|
|
325
587
|
rest_name = nil
|
|
@@ -347,7 +609,20 @@ module JsxRosetta
|
|
|
347
609
|
# `nil # TODO: ...`. The trailing `#` comment inside a method
|
|
348
610
|
# parameter list swallows the closing `)` and breaks Ruby syntax.
|
|
349
611
|
default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
|
|
350
|
-
|
|
612
|
+
alias_name = destructure_alias_for(prop_name, value)
|
|
613
|
+
Prop.new(name: prop_name, default: default, alias_name: alias_name)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# `{ "data-testid": dataTestId }` — extract the alias so use sites of
|
|
617
|
+
# `dataTestId` resolve to the prop's `@data_testid` ivar instead of
|
|
618
|
+
# leaking as a bare snake_case ref. Returns nil for the non-aliased
|
|
619
|
+
# case (`{ loading }` — key.name == value.name).
|
|
620
|
+
def destructure_alias_for(prop_name, value)
|
|
621
|
+
target = value.type == "AssignmentPattern" ? value[:left] : value
|
|
622
|
+
return nil unless AST::Node.matches?(target, "Identifier")
|
|
623
|
+
return nil if target[:name] == prop_name
|
|
624
|
+
|
|
625
|
+
target[:name]
|
|
351
626
|
end
|
|
352
627
|
|
|
353
628
|
def lower_function_body(body)
|
|
@@ -614,29 +889,54 @@ module JsxRosetta
|
|
|
614
889
|
init = declarator[:init]
|
|
615
890
|
return unless init.is_a?(AST::Node)
|
|
616
891
|
|
|
617
|
-
|
|
618
|
-
|
|
892
|
+
# `const { foo, bar } = this.props` — destructured names already
|
|
893
|
+
# got synthesized into `props:` by absorb_class_render_props at
|
|
894
|
+
# class-component setup time. Skip the LocalBinding TODO + the
|
|
895
|
+
# local_binding_names capture so the translator picks up the prop
|
|
896
|
+
# form (`@foo`) instead of emitting a `nil` placeholder.
|
|
897
|
+
return if this_props_destructure?(declarator)
|
|
619
898
|
|
|
620
|
-
|
|
621
|
-
if
|
|
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)
|
|
632
|
-
return
|
|
633
|
-
end
|
|
899
|
+
library = hook_library_for(init)
|
|
900
|
+
record_hook_call(stmt, init, library) if library
|
|
634
901
|
|
|
635
|
-
|
|
902
|
+
id_node = declarator[:id]
|
|
903
|
+
return handle_destructure_binding(stmt, declarator, init, seen, !library.nil?) if destructure_pattern?(id_node)
|
|
904
|
+
return handle_identifier_hook_binding(id_node) if library
|
|
636
905
|
|
|
637
906
|
name = id_node&.[](:name)
|
|
638
907
|
return unless name
|
|
639
908
|
|
|
909
|
+
dispatch_identifier_binding(stmt, init, name, seen)
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def record_hook_call(stmt, call_expression, library)
|
|
913
|
+
@react_hooks << ReactHookCall.new(
|
|
914
|
+
hook: call_expression[:callee][:name],
|
|
915
|
+
source: source_of(stmt).strip,
|
|
916
|
+
library: library,
|
|
917
|
+
operation: apollo_operation_name(call_expression, library)
|
|
918
|
+
)
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# `const [a, b] = ...` or `const { a, b } = ...`. Capture every bound
|
|
922
|
+
# name so the translator recognizes them as known locals. Hook
|
|
923
|
+
# destructures (`const [open, setOpen] = useState(0)`) contribute
|
|
924
|
+
# names but not a separate LocalBinding TODO — the hook's source
|
|
925
|
+
# already shows the binding to the reviewer.
|
|
926
|
+
def handle_destructure_binding(stmt, declarator, init, seen, is_hook)
|
|
927
|
+
record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Identifier-bound hook result (`const handleChange = useCallback(...)`).
|
|
931
|
+
# The hook source is already in @react_hooks; just mark the binding
|
|
932
|
+
# name as known-local so use sites translate to `nil` instead of a
|
|
933
|
+
# bare snake_case ref that NameErrors at render time.
|
|
934
|
+
def handle_identifier_hook_binding(id_node)
|
|
935
|
+
name = id_node&.[](:name)
|
|
936
|
+
@local_binding_names << name if name
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def dispatch_identifier_binding(stmt, init, name, seen)
|
|
640
940
|
case init.type
|
|
641
941
|
when "JSXElement", "JSXFragment"
|
|
642
942
|
@local_jsx[name] = init
|
|
@@ -717,16 +1017,50 @@ module JsxRosetta
|
|
|
717
1017
|
|
|
718
1018
|
def detect_bare_hook_call(stmt)
|
|
719
1019
|
expr = stmt.child(:expression)
|
|
720
|
-
return unless expr&.of_type?("CallExpression")
|
|
1020
|
+
return unless expr&.of_type?("CallExpression")
|
|
721
1021
|
|
|
722
|
-
|
|
1022
|
+
library = hook_library_for(expr)
|
|
1023
|
+
return unless library
|
|
1024
|
+
|
|
1025
|
+
record_hook_call(stmt, expr, library)
|
|
723
1026
|
end
|
|
724
1027
|
|
|
725
1028
|
def hook_call?(call_expression)
|
|
726
|
-
|
|
1029
|
+
!hook_library_for(call_expression).nil?
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
# Resolve a CallExpression to the library whose hook set its callee
|
|
1033
|
+
# belongs to (`:react`, `:apollo`, `:next_js`), or nil when it isn't
|
|
1034
|
+
# a recognized hook invocation. Lookup is by bare-Identifier callee
|
|
1035
|
+
# only — member-expression callees (`Apollo.useQuery`) aren't
|
|
1036
|
+
# recognized; we follow what production code actually writes.
|
|
1037
|
+
def hook_library_for(call_expression)
|
|
1038
|
+
return nil unless call_expression.is_a?(AST::Node) && call_expression.of_type?("CallExpression")
|
|
727
1039
|
|
|
728
1040
|
callee = call_expression.child(:callee)
|
|
729
|
-
callee&.of_type?("Identifier")
|
|
1041
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1042
|
+
|
|
1043
|
+
name = callee[:name]
|
|
1044
|
+
FRAMEWORK_HOOKS_BY_LIBRARY.each do |library, names|
|
|
1045
|
+
return library if names.include?(name)
|
|
1046
|
+
end
|
|
1047
|
+
nil
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
# For Apollo's document-first hooks (`useQuery(GET_USERS, ...)`),
|
|
1051
|
+
# extract the operation name from a bare-Identifier first argument
|
|
1052
|
+
# so the backend can echo it in the TODO. Returns nil for inline
|
|
1053
|
+
# documents (`gql\`...\``), member-expression args, or non-Apollo
|
|
1054
|
+
# hooks — the caller already has the verbatim source in `source`,
|
|
1055
|
+
# which surfaces those cases to the reviewer.
|
|
1056
|
+
def apollo_operation_name(call_expression, library)
|
|
1057
|
+
return nil unless library == :apollo
|
|
1058
|
+
|
|
1059
|
+
args = call_expression[:arguments]
|
|
1060
|
+
first_arg = args.is_a?(Array) ? args.first : nil
|
|
1061
|
+
return nil unless AST::Node.matches?(first_arg, "Identifier")
|
|
1062
|
+
|
|
1063
|
+
first_arg[:name]
|
|
730
1064
|
end
|
|
731
1065
|
|
|
732
1066
|
# Recognize the asChild-style polymorphic tag pattern:
|
|
@@ -899,7 +1233,66 @@ module JsxRosetta
|
|
|
899
1233
|
|
|
900
1234
|
def lower_call_expression(expression)
|
|
901
1235
|
loop_node = try_lower_map_loop(expression)
|
|
902
|
-
loop_node
|
|
1236
|
+
return loop_node if loop_node
|
|
1237
|
+
|
|
1238
|
+
local_call = try_lower_local_arrow_call(expression)
|
|
1239
|
+
return local_call if local_call
|
|
1240
|
+
|
|
1241
|
+
Interpolation.new(expression: source_of(expression))
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
# Recognize `{renderHeader()}` where `renderHeader` is a locally-bound
|
|
1245
|
+
# arrow whose body returns JSX. Extract the arrow as a RenderMethod
|
|
1246
|
+
# on the component and emit a LocalRenderCall at this use site so the
|
|
1247
|
+
# backend can call the generated method instead of dropping the
|
|
1248
|
+
# expression as "[untranslated: renderHeader()]". Args must be simple
|
|
1249
|
+
# identifiers (props, locals) since we don't translate arbitrary
|
|
1250
|
+
# argument expressions here — the backend's ExpressionTranslator
|
|
1251
|
+
# handles them via the Interpolation it sees.
|
|
1252
|
+
def try_lower_local_arrow_call(call_expression)
|
|
1253
|
+
match = local_arrow_call_match(call_expression)
|
|
1254
|
+
return nil unless match
|
|
1255
|
+
|
|
1256
|
+
# Consume the arrow so it doesn't ALSO get promoted to a Stimulus
|
|
1257
|
+
# method if it later appears in event-handler position.
|
|
1258
|
+
@local_arrows.delete(match[:callee_name])
|
|
1259
|
+
|
|
1260
|
+
method_name = unique_render_method_name(match[:callee_name])
|
|
1261
|
+
@render_methods << RenderMethod.new(
|
|
1262
|
+
name: method_name,
|
|
1263
|
+
params: match[:arrow][:params].map { |p| p[:name] },
|
|
1264
|
+
body: match[:body]
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
LocalRenderCall.new(
|
|
1268
|
+
method_name: method_name,
|
|
1269
|
+
args: match[:args].map { |arg| Interpolation.new(expression: source_of(arg)) }
|
|
1270
|
+
)
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
def local_arrow_call_match(call_expression)
|
|
1274
|
+
callee = call_expression.child(:callee)
|
|
1275
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1276
|
+
|
|
1277
|
+
arrow = @local_arrows[callee[:name]]
|
|
1278
|
+
return nil unless arrow
|
|
1279
|
+
return nil unless arrow[:params].all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1280
|
+
|
|
1281
|
+
args = call_expression[:arguments]
|
|
1282
|
+
return nil if args.size != arrow[:params].size
|
|
1283
|
+
return nil unless args.all? { |a| AST::Node.matches?(a, "Identifier", "MemberExpression") }
|
|
1284
|
+
|
|
1285
|
+
body = lower_lambda_body(arrow[:body])
|
|
1286
|
+
return nil unless body
|
|
1287
|
+
|
|
1288
|
+
{ callee_name: callee[:name], arrow: arrow, args: args, body: body }
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
def unique_render_method_name(js_name)
|
|
1292
|
+
snake = AST::Inflector.underscore(js_name)
|
|
1293
|
+
@render_method_seen[snake] ||= 0
|
|
1294
|
+
@render_method_seen[snake] += 1
|
|
1295
|
+
@render_method_seen[snake] == 1 ? snake : "#{snake}_#{@render_method_seen[snake]}"
|
|
903
1296
|
end
|
|
904
1297
|
|
|
905
1298
|
def try_lower_map_loop(call_expression)
|
|
@@ -1061,21 +1454,33 @@ module JsxRosetta
|
|
|
1061
1454
|
end
|
|
1062
1455
|
return nil if property_name.nil?
|
|
1063
1456
|
|
|
1064
|
-
value = lower_style_value(property[:value])
|
|
1457
|
+
value = lower_style_value(property[:value], property_name)
|
|
1065
1458
|
return nil if value.nil?
|
|
1066
1459
|
|
|
1067
1460
|
StyleDeclaration.new(property: property_name, value: value)
|
|
1068
1461
|
end
|
|
1069
1462
|
|
|
1070
|
-
def lower_style_value(value)
|
|
1463
|
+
def lower_style_value(value, property_name)
|
|
1071
1464
|
case value.type
|
|
1072
1465
|
when "StringLiteral" then value[:value]
|
|
1073
|
-
when "NumericLiteral" then value[:value]
|
|
1466
|
+
when "NumericLiteral" then numeric_style_value(value[:value], property_name)
|
|
1074
1467
|
when "Identifier", "MemberExpression"
|
|
1075
1468
|
Interpolation.new(expression: source_of(value))
|
|
1076
1469
|
end
|
|
1077
1470
|
end
|
|
1078
1471
|
|
|
1472
|
+
# Mirrors React's `isUnitlessNumber` table: properties that take bare
|
|
1473
|
+
# numbers (`zIndex: 5`) rather than lengths. Everything else gets a
|
|
1474
|
+
# `px` suffix appended when the JSX source provides a unitless number
|
|
1475
|
+
# — `marginBottom: 16` → `margin-bottom: 16px;`. Without this, the
|
|
1476
|
+
# browser silently ignores the declaration as invalid CSS.
|
|
1477
|
+
def numeric_style_value(number, property_name)
|
|
1478
|
+
return number.to_s if number.zero?
|
|
1479
|
+
return number.to_s if UNITLESS_CSS_PROPERTIES.include?(property_name)
|
|
1480
|
+
|
|
1481
|
+
"#{number}px"
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1079
1484
|
def css_property_from_camel(name)
|
|
1080
1485
|
name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
1081
1486
|
end
|
|
@@ -78,9 +78,28 @@ module JsxRosetta
|
|
|
78
78
|
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Only flag class-component shape when the class has no usable render
|
|
82
|
+
# method. Classes WITH `render()` lower via the
|
|
83
|
+
# ClassDeclaration → ViewComponent path added in v0.5.0, so the
|
|
84
|
+
# classifier should leave them alone and let the regular component
|
|
85
|
+
# finder pick them up.
|
|
81
86
|
def class_component?(stmt)
|
|
82
87
|
decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
|
|
83
|
-
AST::Node.matches?(decl, "ClassDeclaration")
|
|
88
|
+
return false unless AST::Node.matches?(decl, "ClassDeclaration")
|
|
89
|
+
|
|
90
|
+
!class_has_render_method?(decl)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def class_has_render_method?(class_decl)
|
|
94
|
+
body = class_decl.child(:body)
|
|
95
|
+
return false unless body
|
|
96
|
+
|
|
97
|
+
body[:body].any? do |member|
|
|
98
|
+
next false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
|
|
99
|
+
|
|
100
|
+
key = member.child(:key)
|
|
101
|
+
AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
|
|
102
|
+
end
|
|
84
103
|
end
|
|
85
104
|
|
|
86
105
|
# Recognize `export const X = React.memo(...)` (export wrapper) or a
|