jsx_rosetta 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/node"
4
+
5
+ module JsxRosetta
6
+ module IR
7
+ # Heuristic classifier that labels a module whose top-level shape
8
+ # isn't a function component. Used by Lowering::no_component_error
9
+ # to produce triage-friendly messages explaining *why* a file didn't
10
+ # translate. Pure function: program in, label symbol out — no
11
+ # mutable state, no relationship to the rest of the lowering
12
+ # pipeline.
13
+ #
14
+ # Labels (in priority order — more specific shapes win):
15
+ # :class_component `class X extends React.Component { ... }`
16
+ # :hoc_wrapped `const X = React.memo(...)` etc.
17
+ # :columns_data top-level array literal export
18
+ # :hooks_only every export is a `use*` hook
19
+ # :utils_only every export is a lowercase non-hook helper
20
+ # :mixed_exports mixes hooks + non-hook lowercase exports
21
+ # :side_effects_only top-level expression statements, no exports
22
+ # :types_only types/constants only, no functions
23
+ # :unknown no signal — caller emits the bare error
24
+ class ModuleShapeClassifier
25
+ EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
26
+ HOC_NAMES = %w[memo forwardRef lazy observer].freeze
27
+
28
+ def self.classify(program)
29
+ new(program).classify
30
+ end
31
+
32
+ def initialize(program)
33
+ @program = program
34
+ end
35
+
36
+ def classify
37
+ ast_shape = classify_ast_shape
38
+ return ast_shape if ast_shape
39
+
40
+ classify_by_export_names(top_level_export_names)
41
+ end
42
+
43
+ private
44
+
45
+ def classify_ast_shape
46
+ return :class_component if @program.body.any? { |stmt| class_component?(stmt) }
47
+ return :hoc_wrapped if @program.body.any? { |stmt| hoc_wrapped_export?(stmt) }
48
+ return :columns_data if @program.body.any? { |stmt| array_literal_export?(stmt) }
49
+
50
+ nil
51
+ end
52
+
53
+ def classify_by_export_names(names)
54
+ export_label = classify_by_export_pattern(names)
55
+ return export_label if export_label
56
+
57
+ classify_non_export_module
58
+ end
59
+
60
+ def classify_by_export_pattern(names)
61
+ any_hooks = names.any? { |n| hook_name?(n) }
62
+ any_helpers = names.any? { |n| /\A[a-z]/.match?(n) && !hook_name?(n) }
63
+ return :mixed_exports if any_hooks && any_helpers
64
+ return :hooks_only if any_hooks
65
+ return :utils_only if any_helpers
66
+
67
+ nil
68
+ end
69
+
70
+ def classify_non_export_module
71
+ return :side_effects_only if @program.body.any? { |s| side_effect_statement?(s) }
72
+ return :types_only if top_level_has_anything?
73
+
74
+ :unknown
75
+ end
76
+
77
+ def hook_name?(name)
78
+ name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
79
+ end
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.
86
+ def class_component?(stmt)
87
+ decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
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
103
+ end
104
+
105
+ # Recognize `export const X = React.memo(...)` (export wrapper) or a
106
+ # top-level `const X = lazy(() => ...)` followed by `export default X`
107
+ # — a VariableDeclaration whose init is a CallExpression to a known HOC.
108
+ def hoc_wrapped_export?(stmt)
109
+ decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
110
+ return false unless AST::Node.matches?(decl, "VariableDeclaration")
111
+
112
+ decl[:declarations].any? do |d|
113
+ init = d[:init]
114
+ AST::Node.matches?(init, "CallExpression") && hoc_callee?(init[:callee])
115
+ end
116
+ end
117
+
118
+ def hoc_callee?(callee)
119
+ return false unless callee.is_a?(AST::Node)
120
+
121
+ case callee.type
122
+ when "Identifier" then HOC_NAMES.include?(callee[:name])
123
+ when "MemberExpression"
124
+ property = callee.child(:property)
125
+ property&.of_type?("Identifier") && HOC_NAMES.include?(property[:name])
126
+ else false
127
+ end
128
+ end
129
+
130
+ def array_literal_export?(stmt)
131
+ return false unless stmt.of_type?(*EXPORT_TYPES)
132
+
133
+ decl = stmt[:declaration]
134
+ return true if AST::Node.matches?(decl, "ArrayExpression")
135
+ return false unless AST::Node.matches?(decl, "VariableDeclaration")
136
+
137
+ decl[:declarations].any? { |d| AST::Node.matches?(d[:init], "ArrayExpression") }
138
+ end
139
+
140
+ def side_effect_statement?(stmt)
141
+ AST::Node.matches?(stmt, "ExpressionStatement")
142
+ end
143
+
144
+ def top_level_has_anything?
145
+ @program.body.any? { |stmt| stmt.is_a?(AST::Node) && !stmt.of_type?("ImportDeclaration") }
146
+ end
147
+
148
+ def top_level_export_names
149
+ @program.body.flat_map { |stmt| extract_top_level_names(stmt) }.compact
150
+ end
151
+
152
+ def extract_top_level_names(stmt)
153
+ case stmt.type
154
+ when "FunctionDeclaration"
155
+ [stmt.child(:id)&.[](:name)]
156
+ when "VariableDeclaration"
157
+ stmt[:declarations].map { |d| AST::Node.matches?(d[:id], "Identifier") ? d[:id][:name] : nil }
158
+ when "ExportNamedDeclaration", "ExportDefaultDeclaration"
159
+ decl = stmt.child(:declaration)
160
+ decl ? extract_top_level_names(decl) : []
161
+ else
162
+ []
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -20,18 +20,58 @@ module JsxRosetta
20
20
  # the component body. Backends typically render these as
21
21
  # a TODO comment block since arbitrary JS-to-Ruby
22
22
  # translation isn't attempted.
23
+ # local_binding_names : [String] — flat list of all names bound by the
24
+ # component body (destructure patterns, hook tuples,
25
+ # ordinary const bindings). Backends pass this into the
26
+ # ExpressionTranslator so an identifier reference like
27
+ # `count` resolves to a `nil` placeholder instead of
28
+ # a bare unresolved snake_case identifier that NameErrors
29
+ # at render time. Includes hook destructures (e.g.
30
+ # `open` and `setOpen` from `useState`) even though
31
+ # those names don't appear in `local_bindings`.
23
32
  # stimulus_methods : [StimulusMethod] — event handlers extracted from
24
33
  # inline arrows / const-bound arrows used in onX={...}.
25
34
  # When non-empty, backends should emit a sibling
26
35
  # Stimulus controller file alongside the .rb/.erb pair.
27
- # react_hooks : [ReactHookCall] — calls to React hooks (useState,
28
- # useEffect, useRef, useContext, useMemo, useCallback,
29
- # useReducer, useImperativeHandle, useLayoutEffect).
30
- # Surfaced as a distinct TODO block so the human
31
- # reviewer knows to translate behavior to Stimulus
32
- # and state to server-side rendering.
36
+ # react_hooks : [ReactHookCall] — every recognized hook invocation
37
+ # in the component body, regardless of library.
38
+ # Includes React's built-in hooks (useState, useEffect,
39
+ # useRef, useContext, useMemo, useCallback, useReducer,
40
+ # useImperativeHandle, useLayoutEffect), Apollo hooks
41
+ # (useQuery, useMutation, useLazyQuery, useSubscription,
42
+ # useApolloClient), and Next.js navigation hooks
43
+ # (useRouter, usePathname, useSearchParams, useParams,
44
+ # useSelectedLayoutSegment(s)). Each call carries a
45
+ # `library` tag so backends can group them and emit a
46
+ # library-specific TODO pointing at the right Rails
47
+ # analog (Stimulus/server-render for React; controller
48
+ # fetch for Apollo; request.path/params for Next.js).
49
+ # module_bindings : [LocalBinding] — top-level `const`/`let` declarations
50
+ # outside the component function that aren't themselves
51
+ # components. Captured so backends can either translate
52
+ # to Ruby constants (literal initializers) or surface
53
+ # as a TODO comment block before the class definition.
54
+ # Without this capture, references to module-level
55
+ # constants from inside the JSX silently drop and
56
+ # produce unbacked snake_case references at render time.
57
+ # render_methods : [RenderMethod] — local arrow bindings that return JSX
58
+ # and are invoked from the JSX body (`const renderHeader
59
+ # = () => <div/>; ... {renderHeader()}`). Backends emit
60
+ # each as a private method on the generated class and
61
+ # reference it from a LocalRenderCall at the use site.
62
+ # mode : Symbol — `:view` for a normal Phlex/ViewComponent component
63
+ # whose body is rendered as JSX (the default); `:data_factory`
64
+ # for column-descriptor / option-list modules whose top-level
65
+ # export is a function returning an array of object literals.
66
+ # When `:data_factory`, the backend emits a snake_case method
67
+ # that returns the translated data, instead of `view_template`.
68
+ # JSX inside object properties still extracts to private
69
+ # methods on the class via the IR::Lambda path.
33
70
  Component = Data.define(:name, :props, :body, :rest_prop_name,
34
- :local_bindings, :stimulus_methods, :react_hooks) do
71
+ :local_bindings, :local_binding_names,
72
+ :module_bindings,
73
+ :stimulus_methods, :react_hooks,
74
+ :render_methods, :mode) do
35
75
  include Node
36
76
  end
37
77
 
@@ -45,22 +85,37 @@ module JsxRosetta
45
85
  include Node
46
86
  end
47
87
 
48
- # A React hook invocation detected in the component body (`useState`,
49
- # `useEffect`, …). Surfaced separately from local_bindings so backends
50
- # can emit a more specific TODO that points at the Stimulus / Hotwire
51
- # / server-render alternative, instead of a generic "translate this JS".
88
+ # A hook invocation detected in the component body. Covers React's
89
+ # built-in hooks plus framework hooks we recognize (Apollo's `useQuery`/
90
+ # `useMutation`/etc., Next.js's `useRouter`/`usePathname`/etc.).
91
+ # Surfaced separately from local_bindings so backends can emit a more
92
+ # specific TODO pointing at the Rails equivalent for each library,
93
+ # instead of a generic "translate this JS".
52
94
  #
53
- # hook : String — hook function name (`"useState"`, `"useEffect"`, …)
54
- # source : String — verbatim JS of the entire statement.
55
- ReactHookCall = Data.define(:hook, :source) do
95
+ # hook : String — hook function name (`"useState"`, `"useQuery"`, …)
96
+ # source : String — verbatim JS of the entire statement.
97
+ # library : Symbol — `:react`, `:apollo`, or `:next_js`. Backends
98
+ # group hooks by library and emit one TODO block per group,
99
+ # since each library maps to a different Rails analog.
100
+ # operation : String | nil — for Apollo hooks called with a bare-Identifier
101
+ # first argument (`useQuery(GET_USERS_QUERY, …)`), the
102
+ # captured operation name. nil when the first argument is
103
+ # not a simple Identifier, or when the hook isn't Apollo.
104
+ # Backends echo it in the TODO so the reviewer can match
105
+ # the operation back to its GraphQL document and to the
106
+ # Rails controller / model fetch it should become.
107
+ ReactHookCall = Data.define(:hook, :source, :library, :operation) do
56
108
  include Node
57
109
  end
58
110
 
59
111
  # A component prop, possibly with a default value.
60
112
  #
61
- # name : String
62
- # default : Interpolation | nil
63
- Prop = Data.define(:name, :default) do
113
+ # name : String — the prop name on the parent (e.g. "data-testid").
114
+ # default : Interpolation | nil
115
+ # alias_name : String | nil — the local binding name inside the body when
116
+ # the destructure renames it (`"data-testid": dataTestId`).
117
+ # Use sites of the alias resolve to the prop's ivar.
118
+ Prop = Data.define(:name, :default, :alias_name) do
64
119
  include Node
65
120
  end
66
121
 
@@ -250,11 +305,17 @@ module JsxRosetta
250
305
  # Body translation is deferred to the human reviewer; we preserve the
251
306
  # original JS body verbatim.
252
307
  #
253
- # name : String — camelCase Stimulus method name.
254
- # body_source : String verbatim JS body (the entire arrow function or
255
- # the function expression body), preserved as a comment in
256
- # the emitted controller skeleton.
257
- StimulusMethod = Data.define(:name, :body_source) do
308
+ # name : String — camelCase Stimulus method name (uniquified
309
+ # when two handlers collide on the same base name).
310
+ # body_source : String — verbatim JS body (the entire arrow function
311
+ # or the function expression body), preserved as a
312
+ # comment in the emitted controller skeleton.
313
+ # original_name : String — the requested base name before uniquification.
314
+ # Equals `name` when there was no collision. When
315
+ # `name != original_name`, backends emit a collision
316
+ # marker comment in the generated controller JS so the
317
+ # reviewer can see the silent rename.
318
+ StimulusMethod = Data.define(:name, :body_source, :original_name) do
258
319
  include Node
259
320
  end
260
321
 
@@ -272,5 +333,76 @@ module JsxRosetta
272
333
  Loop = Data.define(:iterable, :item_binding, :index_binding, :body) do
273
334
  include Node
274
335
  end
336
+
337
+ # An object-literal value (`{ key: value, ... }`) inside JSX. Lowered
338
+ # from a JSX attribute or expression value whose root is an
339
+ # ObjectExpression. Each property's key is a String; the value can be
340
+ # any IR node (recursive). Backends render as a Ruby Hash literal,
341
+ # snake_casing identifier keys to match Ruby kwarg conventions.
342
+ #
343
+ # properties : [[String key, Node value]] — preserved in source order.
344
+ ObjectLiteral = Data.define(:properties) do
345
+ include Node
346
+ end
347
+
348
+ # An array-literal value (`[a, b, ...]`) inside JSX. Lowered from a
349
+ # JSX attribute or expression value whose root is an ArrayExpression.
350
+ # Each element is an IR node (recursive). Backends render as a Ruby
351
+ # Array literal.
352
+ #
353
+ # elements : [Node]
354
+ ArrayLiteral = Data.define(:elements) do
355
+ include Node
356
+ end
357
+
358
+ # An arrow/function expression appearing as an inline value (not in
359
+ # JSX child or event-handler position). Typical example: a `render`
360
+ # property inside an array-of-config-objects passed to an AG-Grid
361
+ # column descriptor or antd Select option. Backends emit as a Ruby
362
+ # method on the class (deterministically named) and reference it via
363
+ # `method(:name)` in the value position, since lambdas don't carry
364
+ # the Phlex execution context required to call tag.* helpers.
365
+ #
366
+ # params : [String]
367
+ # body : Node
368
+ Lambda = Data.define(:params, :body) do
369
+ include Node
370
+ end
371
+
372
+ # A render-prop child: `<Form.List>{(fields) => <div>{fields}</div>}</Form.List>`.
373
+ # Backends emit this as a Ruby block on the render call, with the params
374
+ # bound as block arguments. Distinct from Loop (which iterates an
375
+ # iterable) and from Slot (which yields without args).
376
+ #
377
+ # params : [String] — param names (camelCase preserved; backends snake_case).
378
+ # body : Node — the lowered IR node produced by the arrow's body.
379
+ RenderProp = Data.define(:params, :body) do
380
+ include Node
381
+ end
382
+
383
+ # A locally-declared JSX-returning arrow that's invoked inside the
384
+ # render body: `const renderHeader = (count) => <h1>{count}</h1>;
385
+ # ... {renderHeader(headerCount)}`. Backends emit one private method
386
+ # per RenderMethod on the generated class and reference it via a
387
+ # LocalRenderCall at each use site.
388
+ #
389
+ # name : String — snake_case method name on the class.
390
+ # params : [String] — arrow param names (camelCase preserved; backends
391
+ # snake_case to form Ruby parameter names).
392
+ # body : Node — the lowered IR node produced by the arrow's body.
393
+ RenderMethod = Data.define(:name, :params, :body) do
394
+ include Node
395
+ end
396
+
397
+ # A call to a locally-declared JSX-returning arrow at its use site.
398
+ # Pairs with a sibling RenderMethod on Component#render_methods.
399
+ #
400
+ # method_name : String — snake_case method name (matches RenderMethod#name).
401
+ # args : [Interpolation] — argument expressions captured verbatim
402
+ # (each Interpolation's expression is translated by the
403
+ # backend's ExpressionTranslator at emission time).
404
+ LocalRenderCall = Data.define(:method_name, :args) do
405
+ include Node
406
+ end
275
407
  end
276
408
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsxRosetta
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/jsx_rosetta.rb CHANGED
@@ -14,18 +14,19 @@ module JsxRosetta
14
14
  IR.lower(ast, source: source)
15
15
  end
16
16
 
17
- def self.translate(source, backend: :view_component, helpers: nil, layout: :sidecar,
18
- typescript: false, source_filename: nil)
17
+ def self.translate(source, backend: :view_component, backend_options: {},
18
+ typescript: false, source_filename: nil, **legacy_options)
19
19
  ast = parse(source, typescript: typescript, source_filename: source_filename)
20
20
  components = IR.lower_all(ast, source: source)
21
- backend_instance = backend_for(backend, helpers: helpers, layout: layout)
21
+ backend_instance = backend_for(backend, **legacy_options, **backend_options)
22
22
  components.flat_map { |component| backend_instance.emit(component) }
23
23
  end
24
24
 
25
- def self.backend_for(name, helpers: nil, layout: :sidecar)
25
+ def self.backend_for(name, **options)
26
26
  case name
27
- when :view_component then Backend::ViewComponent.new(helpers: helpers, layout: layout)
28
- when :rails_view then Backend::RailsView.new(helpers: helpers, layout: layout)
27
+ when :view_component then Backend::ViewComponent.new(**options.slice(:helpers, :layout))
28
+ when :rails_view then Backend::RailsView.new(**options.slice(:helpers, :layout))
29
+ when :phlex then Backend::Phlex.new(**options.slice(:suffix, :namespace))
29
30
  else
30
31
  raise Error, "unknown backend: #{name.inspect}"
31
32
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jsx_rosetta
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean McCleary
@@ -28,6 +28,7 @@ files:
28
28
  - LICENSE.txt
29
29
  - PLAN.md
30
30
  - README.md
31
+ - ROADMAP.md
31
32
  - Rakefile
32
33
  - exe/jsx_rosetta
33
34
  - lib/jsx_rosetta.rb
@@ -38,6 +39,7 @@ files:
38
39
  - lib/jsx_rosetta/ast/visitor.rb
39
40
  - lib/jsx_rosetta/backend.rb
40
41
  - lib/jsx_rosetta/backend/base.rb
42
+ - lib/jsx_rosetta/backend/phlex.rb
41
43
  - lib/jsx_rosetta/backend/rails_view.rb
42
44
  - lib/jsx_rosetta/backend/routes_script.rb
43
45
  - lib/jsx_rosetta/backend/view_component.rb
@@ -45,6 +47,7 @@ files:
45
47
  - lib/jsx_rosetta/cli.rb
46
48
  - lib/jsx_rosetta/ir.rb
47
49
  - lib/jsx_rosetta/ir/lowering.rb
50
+ - lib/jsx_rosetta/ir/module_shape_classifier.rb
48
51
  - lib/jsx_rosetta/ir/types.rb
49
52
  - lib/jsx_rosetta/node_bridge.rb
50
53
  - lib/jsx_rosetta/parse_error.rb