jsx_rosetta 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ae2ca5d2ff613e264c7a6027c5a95c57781916a38b52090d6c9d62ba7cafd8c
4
- data.tar.gz: 3e6a2e6f2a54e1dd01002e3e1312d3c1144e3af7e315c55cb3eeee137b858c79
3
+ metadata.gz: 28709ed22159e135331acff12df2c1fadbc2b692c62cd90d40a977192b28a46d
4
+ data.tar.gz: 4c8543a43d7ca8e96ba3c93d92f89fddbb548d4329332487df67683cabffa4e9
5
5
  SHA512:
6
- metadata.gz: 11b3d8dea5bb887a1b55ebccc0119ce5703bf2c39251a0144dda973d01dd05f7e9b58337fb383e87f3c66d1dfac9a465e6cc5a14dca827e0612073401e4f908c
7
- data.tar.gz: 2742e096f9947b4999183e682c0f1f3b405a68894bc9a2bbb4f140fb45f22a5c611c7e5759f88ea9b987bbd98a501f0bbb248638e83b494825881a6ccac60e76
6
+ metadata.gz: c053e3111f0dc98f5ce3549d0c50a06f04f69bdbb13f5206296e807c62174b1e3824c9ac30d327884c20d1dd39d4fa821bec931292ff9802343b7d8c6a1da1ce
7
+ data.tar.gz: d06787398b3c56482c881184f0cdcbe6ce20071c9c6a1d1cc411fad1514a5a4910d9d7ddfd78455107e14f4285fdf4dcf2299f42a48622790bccb9c7d02bfc17
data/CHANGELOG.md CHANGED
@@ -1,5 +1,120 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-05-10
4
+
5
+ Driven by a 929-file stress run against the entire `reserv-web` codebase
6
+ (`reserv-web/src/` + `reserv-web/pages/` + `packages/`). Baseline outcome
7
+ on v0.2.0: 838/929 (90.2%) clean exit, 91 hard failures across 5 distinct
8
+ error categories. This release ships fixes for all five plus a follow-up
9
+ that opens up lowercase JSX-returning helpers as components, lifting the
10
+ corpus to **887/929 (95.5%) clean exit**. The 42 remaining failures are
11
+ non-component modules (utility/hook libraries, AG-Grid column
12
+ descriptors, class-based ErrorBoundary components, side-effect
13
+ initializers); each now reports a classifier-tagged error that explains
14
+ *why* it didn't translate.
15
+
16
+ ### Fixed
17
+
18
+ - **StringLiteral destructure keys no longer crash.**
19
+ `function X({ "data-testid": dataTestId })` previously surfaced as a
20
+ `bundler: failed to load command` after `Inflector.underscore(nil)` —
21
+ the v0.2.0 ObjectPattern fix only handled `Identifier` keys. The
22
+ lowering now reads `:value` from `StringLiteral` keys. Closes 11 files.
23
+ - **Hyphenated prop names emit valid Ruby.** `Inflector.underscore` now
24
+ converts hyphens to underscores, so `data-testid` becomes `data_testid`
25
+ in Ruby identifiers (kwarg, ivar). HTML attribute names continue to
26
+ preserve hyphens — they're rendered from `Attribute.name` directly.
27
+
28
+ ### Added — return-shape lowering
29
+
30
+ - **`return null;`, `return identifier;`, `return call();`** in return
31
+ position. Previously each raised "unexpected JSX node in lowering: …"
32
+ and crashed translation. The return-position dispatcher now accepts:
33
+ - `NullLiteral` → empty `IR::Text` (renders nothing in ERB; valid as a
34
+ Conditional alternate)
35
+ - `Identifier` → `IR::Interpolation`, with inlining when the identifier
36
+ is bound to a JSX local (`const card = <p/>; return card;`)
37
+ - `CallExpression` → `IR::Interpolation` of the verbatim source
38
+ Closes 20 files.
39
+ - **Trailing `switch` and `try` body shapes.** Component bodies whose
40
+ only return path lives inside a trailing `switch (subject) { case A:
41
+ return X; default: return Y; }` or `try { return X; } catch …` now
42
+ lower cleanly. Switch fall-through groups (`case A: case B: return X;`)
43
+ emit a single Conditional with an OR-joined test
44
+ (`subject === A || subject === B`). Cases with multi-statement bodies
45
+ (other than a single block-wrapped return) bail and the gem still
46
+ raises "no return statement". Catch/finally handlers are dropped —
47
+ they typically encode JS-only error semantics. Closes ~5 files.
48
+ - **Leading `if (X) return Y;` guards** wrap around any trailing return
49
+ structure (return / if-chain / switch / try). Previously a guard
50
+ before a trailing if-chain or switch was silently dropped (or caused
51
+ the surrounding structure to bail).
52
+
53
+ ### Added — what counts as a component
54
+
55
+ - **Lowercase-named JSX-returning helpers** (`textRender`,
56
+ `booleanRender`, `cellFor`) now lower as components. The PascalCase
57
+ rule was tightened to "PascalCase OR (lowercase + body returns JSX,
58
+ excluding `use*` hook names)." A pre-lowering AST scan walks the
59
+ function body's return paths (recursing into BlockStatement,
60
+ IfStatement, SwitchStatement, TryStatement, ConditionalExpression,
61
+ LogicalExpression) to detect any reachable JSX value. Closes ~10
62
+ utility-renderer files.
63
+ - **Permissive return-position dispatcher.** Function bodies that
64
+ return arbitrary non-JSX expressions (`return money.formattedValue;`,
65
+ `return computeValue();`, `` return `${name}` ``) now lower cleanly.
66
+ Member access, template literals, binary expressions, and other
67
+ bare-expression returns become `IR::Interpolation`; string and
68
+ numeric literals become `IR::Text`. This is what makes lowercase
69
+ JSX-helpers tractable — their guard returns are usually non-JSX.
70
+ - **Implicit-return arrow bodies of any shape.** Previously
71
+ `const X = () => <div/>` worked but `const X = () => cond ? <a/> : <b/>`
72
+ raised "unsupported component body". The body dispatcher now routes
73
+ any non-block body through the return-position dispatcher.
74
+
75
+ ### Improved
76
+
77
+ - **Module-shape classifier with eight labels and per-shape messages.**
78
+ Every `no component function found in module` error now appends a
79
+ specific label and a concrete suggestion: `:hoc_wrapped` (peel
80
+ `React.memo` / `forwardRef` / `lazy` / `observer`), `:class_component`
81
+ (rewrite as a function), `:hooks_only` (move behavior to Stimulus,
82
+ state to ivars), `:columns_data` (data lives in models or presenters),
83
+ `:types_only` (TypeScript types erase), `:utils_only` (only
84
+ JSX-returning helpers translate), `:mixed_exports` (split the file),
85
+ `:side_effects_only` (use a Rails initializer). Stress-test
86
+ validation: 42 remaining failures, 0 unlabeled.
87
+
88
+ ## [0.2.0] - 2026-05-10
89
+
90
+ Driven by an empirical probe of v0.1.0 against a 39-file Next.js production
91
+ slice (`reserv-web/src/components/rolloverbook`). The slice exposed three
92
+ return-shape gaps and a crash on nested destructure; this release fixes all
93
+ four. Probe outcome: 33/39 → **39/39 emit**.
94
+
95
+ ### Fixed
96
+
97
+ - **Nested-destructured props no longer crash the lowering.**
98
+ `function X({ outer: { inner } })` previously surfaced as
99
+ `Inflector.underscore(nil)` in the backend. The lowering now uses the
100
+ outer key as the prop name. Renamed destructures (`{ outer: inner }`)
101
+ similarly use the source-side key (the prop name the parent passes),
102
+ not the renamed local.
103
+
104
+ ### Added — return-shape lowering
105
+
106
+ - **Top-level conditional / short-circuit returns** —
107
+ `return cond ? <A/> : <B/>` and `return cond && <A/>` now lower to
108
+ IR::Conditional via a new return-position dispatcher. Previously raised
109
+ "unexpected JSX node in lowering: ConditionalExpression".
110
+ - **Multi-branch `if/else if/else` all-return bodies** — components
111
+ whose every return path lives inside an if-chain (no top-level
112
+ unconditional return) lower to a chained IR::Conditional. Branches
113
+ may be braced single-statement blocks (`if (x) { return <A/>; }`) or
114
+ bare returns (`if (x) return <A/>;`). Branches with side-effect
115
+ statements before the return still raise — those imply behavior we
116
+ can't preserve.
117
+
3
118
  ## [Unreleased]
4
119
 
5
120
  ### Added — translator (lowering)
@@ -11,6 +11,7 @@ module JsxRosetta
11
11
  string
12
12
  .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
13
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .tr("-", "_")
14
15
  .downcase
15
16
  end
16
17
 
@@ -66,6 +66,41 @@ module JsxRosetta
66
66
  useReducer useImperativeHandle useLayoutEffect useDebugValue
67
67
  ].freeze
68
68
 
69
+ EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
70
+ JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
71
+ HOC_NAMES = %w[memo forwardRef lazy observer].freeze
72
+
73
+ # Pre-lowering AST scan: maps a node type to a callable returning the
74
+ # AST nodes that contribute return values. Used by body_returns_jsx?.
75
+ JSX_RETURN_PROBES = {
76
+ "ReturnStatement" => ->(n) { [n[:argument]] },
77
+ "BlockStatement" => ->(n) { n[:body] },
78
+ "IfStatement" => ->(n) { [n[:consequent], n[:alternate]] },
79
+ "TryStatement" => ->(n) { [n[:block]] },
80
+ "ConditionalExpression" => ->(n) { [n[:consequent], n[:alternate]] },
81
+ "LogicalExpression" => ->(n) { [n[:left], n[:right]] }
82
+ }.freeze
83
+
84
+ SHAPE_MESSAGES = {
85
+ hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
86
+ "this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
87
+ class_component: "looks like a class component — this version translates only function components " \
88
+ "(rewrite as a function or wait for class-component support)",
89
+ hooks_only: "looks like a custom-hooks module — hooks encode behavior and state, not view markup; " \
90
+ "translate behavior to a Stimulus controller and state to server-rendered ivars",
91
+ columns_data: "looks like a data export (top-level array literal) — not a component; " \
92
+ "data lives in the model or a presenter, not a ViewComponent",
93
+ types_only: "looks like a types/constants module — no functions to translate; " \
94
+ "TypeScript types erase, and Ruby constants belong elsewhere",
95
+ side_effects_only: "looks like a side-effect-only module (top-level calls, no exported functions) — " \
96
+ "register the equivalent setup in a Rails initializer instead",
97
+ utils_only: "looks like a utility module — only function components and JSX-returning helpers translate; " \
98
+ "pure-data helpers don't have a ViewComponent equivalent",
99
+ mixed_exports: "module mixes shapes (utilities + hooks + types + non-JSX helpers) — " \
100
+ "split into separate files so each module has a single shape",
101
+ unknown: nil
102
+ }.freeze
103
+
69
104
  def initialize(source)
70
105
  @source = source
71
106
  @prop_names = []
@@ -80,7 +115,7 @@ module JsxRosetta
80
115
 
81
116
  def lower_file(file)
82
117
  candidates = find_component_functions(file.program)
83
- raise lowering_error("no component function found in module") if candidates.empty?
118
+ raise no_component_error(file.program) if candidates.empty?
84
119
 
85
120
  name, function = candidates.first
86
121
  lower_component(name, function)
@@ -88,7 +123,7 @@ module JsxRosetta
88
123
 
89
124
  def lower_all_components(file)
90
125
  candidates = find_component_functions(file.program)
91
- raise lowering_error("no component function found in module") if candidates.empty?
126
+ raise no_component_error(file.program) if candidates.empty?
92
127
 
93
128
  candidates.map { |name, function| lower_component(name, function) }
94
129
  end
@@ -99,22 +134,177 @@ module JsxRosetta
99
134
  LoweringError.new(message, node: node, source: @source)
100
135
  end
101
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"
193
+ end
194
+
195
+ def hook_name?(name)
196
+ name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
197
+ end
198
+
199
+ def class_component?(stmt)
200
+ decl = EXPORT_TYPES.include?(stmt.type) ? stmt[:declaration] : stmt
201
+ decl.is_a?(AST::Node) && decl.type == "ClassDeclaration"
202
+ end
203
+
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"
210
+
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
216
+
217
+ def hoc_callee?(callee)
218
+ return false unless callee.is_a?(AST::Node)
219
+
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
227
+ end
228
+
229
+ def array_literal_export?(stmt)
230
+ return false unless EXPORT_TYPES.include?(stmt.type)
231
+
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" }
237
+ end
238
+
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
246
+
247
+ def top_level_export_names(program)
248
+ program.body.flat_map { |stmt| extract_top_level_names(stmt) }.compact
249
+ end
250
+
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
263
+ end
264
+
102
265
  def find_component_functions(program)
103
266
  program.body.flat_map { |stmt| extract_components(stmt) }
104
267
  .compact
105
- .select { |(name, _)| component_name?(name) }
268
+ .select { |(name, fn)| component_function?(name, fn) }
106
269
  end
107
270
 
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)
271
+ # A function is a component if it's PascalCase (the React convention),
272
+ # or if it's a lowercase-named helper whose body returns JSX. The
273
+ # latter catches files like `CellRenderers.tsx` that export
274
+ # `textRender`, `booleanRender`, etc. — JSX-returning by structure
275
+ # but lowercase by convention. `use*` names are excluded — those are
276
+ # hooks, which return data, not view markup.
277
+ def component_function?(name, function)
112
278
  return false if name.nil? || name.empty?
279
+ return true if pascal_case?(name)
280
+ return false if hook_name?(name)
281
+
282
+ body_returns_jsx?(function[:body])
283
+ end
113
284
 
285
+ def pascal_case?(name)
114
286
  first = name[0]
115
287
  first == first.upcase && first != first.downcase
116
288
  end
117
289
 
290
+ # Pre-lowering AST scan: does any return path in this body produce a
291
+ # JSX value? Used only as a heuristic for component_function?, so a
292
+ # false positive is a translation attempt that may TODO out, while
293
+ # a false negative is a missed translation. Recursion follows return
294
+ # paths only — does not descend into nested function expressions.
295
+ def body_returns_jsx?(node)
296
+ return false unless node.is_a?(AST::Node)
297
+ return true if %w[JSXElement JSXFragment].include?(node.type)
298
+ return switch_returns_jsx?(node) if node.type == "SwitchStatement"
299
+
300
+ probe = JSX_RETURN_PROBES[node.type]
301
+ probe ? probe.call(node).any? { |child| body_returns_jsx?(child) } : false
302
+ end
303
+
304
+ def switch_returns_jsx?(node)
305
+ node[:cases].any? { |c| c[:consequent].any? { |s| body_returns_jsx?(s) } }
306
+ end
307
+
118
308
  def extract_components(stmt)
119
309
  case stmt.type
120
310
  when "FunctionDeclaration"
@@ -208,33 +398,254 @@ module JsxRosetta
208
398
  end
209
399
 
210
400
  def lower_object_prop(property)
401
+ key = property[:key]
402
+ prop_name = key.type == "StringLiteral" ? key[:value] : key[:name]
211
403
  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
404
+ default = (Interpolation.new(expression: source_of(value[:right])) if value.type == "AssignmentPattern")
405
+ Prop.new(name: prop_name, default: default)
220
406
  end
221
407
 
222
408
  def lower_function_body(body)
223
- case body.type
224
- when "BlockStatement"
409
+ if body.type == "BlockStatement"
225
410
  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
411
+ chained = lower_block_returns(body[:body])
412
+ return chained if chained
228
413
 
229
- lower_jsx(return_stmt[:argument])
230
- when "JSXElement", "JSXFragment"
231
- @local_jsx = {}
232
- lower_jsx(body)
414
+ raise lowering_error("component function has no return statement", node: body)
415
+ end
416
+
417
+ @local_jsx = {}
418
+ lower_return_value(body)
419
+ end
420
+
421
+ # Dispatch a value in return position. JSX nodes lower via lower_jsx;
422
+ # everything else gets a sensible default — null becomes empty Text,
423
+ # ConditionalExpression / LogicalExpression lower to Conditional,
424
+ # Identifier inlines @local_jsx-bound JSX or emits an Interpolation,
425
+ # literal Strings/Numbers become Text, and any other expression
426
+ # (CallExpression, MemberExpression, BinaryExpression, TemplateLiteral,
427
+ # …) becomes a verbatim Interpolation. This permissive default is
428
+ # what lets lowercase JSX-returning helpers (`textRender`,
429
+ # `moneyRender`) lower cleanly when their guard returns are non-JSX.
430
+ def lower_return_value(node)
431
+ case node.type
432
+ when "ConditionalExpression" then lower_ternary_expression(node)
433
+ when "LogicalExpression" then lower_logical_expression(node)
434
+ when "NullLiteral" then Text.new(value: "")
435
+ when *JSX_NODE_TYPES then lower_jsx(node)
436
+ when "Identifier" then lower_identifier_return(node)
437
+ when "StringLiteral" then Text.new(value: node[:value])
438
+ when "NumericLiteral" then Text.new(value: node[:value].to_s)
439
+ else Interpolation.new(expression: source_of(node))
440
+ end
441
+ end
442
+
443
+ def lower_identifier_return(node)
444
+ bound = @local_jsx[node[:name]]
445
+ bound ? lower_jsx(bound) : Interpolation.new(expression: source_of(node))
446
+ end
447
+
448
+ # Lower a block-statement body into an IR value. Recognized shapes:
449
+ # - first top-level `return X;` (everything after is dead code)
450
+ # - trailing `if/else if/else` chain whose every branch returns
451
+ # - trailing `switch (subject) { case A: return X; default: return Y; }`
452
+ # - trailing `try { return X; } catch { ... }`
453
+ # Any preceding `if (X) return Y;` guard statements (no else) are wrapped
454
+ # around the base value as outer Conditionals. Returns nil when no shape
455
+ # matches; caller raises.
456
+ def lower_block_returns(statements)
457
+ return_idx = statements.index { |s| s.is_a?(AST::Node) && s.type == "ReturnStatement" }
458
+
459
+ if return_idx
460
+ return_arg = statements[return_idx][:argument]
461
+ return nil unless return_arg
462
+
463
+ base = lower_return_value(return_arg)
464
+ preceding = statements[0...return_idx]
233
465
  else
234
- raise lowering_error("unsupported component body: #{body.type}", node: body)
466
+ last = statements.last
467
+ return nil unless last.is_a?(AST::Node)
468
+
469
+ base = lower_trailing_return_structure(last)
470
+ return nil unless base
471
+
472
+ preceding = statements[0...-1]
473
+ end
474
+
475
+ wrap_return_guards(preceding, base)
476
+ end
477
+
478
+ def lower_trailing_return_structure(stmt)
479
+ case stmt.type
480
+ when "IfStatement" then lower_if_return_chain(stmt)
481
+ when "SwitchStatement" then lower_switch_return(stmt)
482
+ when "TryStatement" then lower_try_return(stmt)
483
+ end
484
+ end
485
+
486
+ # Wrap `if (X) return Y;` guard statements (no else) around `base_value`
487
+ # as outer Conditionals. Preceding statements that we can't represent
488
+ # (const declarations, hook calls, multi-stmt guard bodies, if-else
489
+ # structures with multi-stmt branches) are skipped silently — they're
490
+ # either already absorbed by collect_local_bindings (consts/hooks) or
491
+ # they encode side effects we can't preserve. Matches the v0.2.0
492
+ # behavior of dropping unrepresentable preceding statements rather
493
+ # than failing the whole component.
494
+ 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]
498
+
499
+ branch_value = lower_return_branch(stmt[:consequent])
500
+ next unless branch_value
501
+
502
+ base_value = Conditional.new(
503
+ test: Interpolation.new(expression: source_of(stmt[:test])),
504
+ consequent: branch_value,
505
+ alternate: base_value
506
+ )
507
+ end
508
+ base_value
509
+ end
510
+
511
+ def lower_if_return_chain(if_stmt)
512
+ consequent = lower_return_branch(if_stmt[:consequent])
513
+ return nil unless consequent
514
+
515
+ alternate_node = if_stmt[:alternate]
516
+ alternate = case alternate_node&.type
517
+ when nil then nil
518
+ when "IfStatement" then lower_if_return_chain(alternate_node)
519
+ else lower_return_branch(alternate_node)
520
+ end
521
+ return nil if alternate_node && alternate.nil?
522
+
523
+ Conditional.new(
524
+ test: Interpolation.new(expression: source_of(if_stmt[:test])),
525
+ consequent: consequent,
526
+ alternate: alternate
527
+ )
528
+ end
529
+
530
+ # An if-chain branch lowers to a return value via the same
531
+ # block-handling logic that powers the outer function body:
532
+ # variable declarations are absorbed into @local_bindings as TODOs,
533
+ # leading `if (X) return Y;` guards wrap as Conditionals, and any
534
+ # other preceding statements are silently dropped (since the gem
535
+ # can't preserve their side effects). Without this consistency,
536
+ # cases like `if (m) { const x = ...; return <X/>; } else { ... }`
537
+ # would bail mid-body.
538
+ def lower_return_branch(branch)
539
+ case branch.type
540
+ when "ReturnStatement"
541
+ branch[:argument] && lower_return_value(branch[:argument])
542
+ when "BlockStatement"
543
+ collect_nested_local_bindings(branch[:body])
544
+ lower_block_returns(branch[:body])
545
+ end
546
+ end
547
+
548
+ def collect_nested_local_bindings(stmts)
549
+ stmts.each do |stmt|
550
+ next unless stmt.is_a?(AST::Node) && stmt.type == "VariableDeclaration"
551
+
552
+ seen = {}
553
+ stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
554
+ end
555
+ end
556
+
557
+ # Lower a `switch (subject) { case A: return X; default: return Y; }`
558
+ # to a right-nested chain of IR::Conditional. Each case must end in a
559
+ # returnable value (bare `return X;` or a single-stmt block-return).
560
+ # Fall-through groups (`case A: case B: return X;`) get a single
561
+ # Conditional with an OR-joined test. The default case (or no default)
562
+ # becomes the final alternate. Returns nil when any case has a shape
563
+ # we don't recognize.
564
+ def lower_switch_return(switch_stmt)
565
+ groups = build_switch_case_groups(switch_stmt[:cases])
566
+ return nil unless groups
567
+
568
+ subject_src = source_of(switch_stmt[:discriminant])
569
+ default_value, non_default = split_switch_default(groups)
570
+
571
+ non_default.reverse.reduce(default_value) do |alternate, group|
572
+ test_expr = group[:tests].map { |t| "#{subject_src} === #{source_of(t)}" }.join(" || ")
573
+ Conditional.new(
574
+ test: Interpolation.new(expression: test_expr),
575
+ consequent: group[:value],
576
+ alternate: alternate
577
+ )
235
578
  end
236
579
  end
237
580
 
581
+ def build_switch_case_groups(cases)
582
+ groups = []
583
+ pending_tests = []
584
+ pending_default = false
585
+
586
+ cases.each do |case_node|
587
+ if case_node[:test].nil?
588
+ pending_default = true
589
+ else
590
+ pending_tests << case_node[:test]
591
+ end
592
+
593
+ consequent_stmts = case_node[:consequent]
594
+ next if consequent_stmts.empty?
595
+
596
+ value = lower_switch_case_consequent(consequent_stmts)
597
+ return nil unless value
598
+
599
+ groups << {
600
+ tests: pending_default ? [] : pending_tests.dup,
601
+ is_default: pending_default,
602
+ value: value
603
+ }
604
+ pending_tests.clear
605
+ pending_default = false
606
+ end
607
+
608
+ return nil if pending_default || pending_tests.any?
609
+
610
+ groups
611
+ end
612
+
613
+ def split_switch_default(groups)
614
+ default_group = groups.find { |g| g[:is_default] }
615
+ default_value = default_group ? default_group[:value] : Text.new(value: "")
616
+ non_default = groups.reject { |g| g[:is_default] }
617
+ [default_value, non_default]
618
+ end
619
+
620
+ # A switch case body lowers when it returns from every reachable path.
621
+ # Recognized shapes:
622
+ # case A: return X; (bare return)
623
+ # case A: { return X; } (block-wrapped return)
624
+ # case A: { const y = ...; return X; } (leading vars + return)
625
+ # case A: if (X) return Y; return Z; (guard prefix + return)
626
+ # Trailing `break` statements are ignored.
627
+ def lower_switch_case_consequent(stmts)
628
+ filtered = stmts.reject { |s| s.is_a?(AST::Node) && s.type == "BreakStatement" }
629
+ return nil if filtered.empty?
630
+
631
+ if filtered.size == 1 && filtered.first.is_a?(AST::Node) && filtered.first.type == "BlockStatement"
632
+ return lower_switch_case_consequent(filtered.first[:body])
633
+ end
634
+
635
+ collect_nested_local_bindings(filtered)
636
+ lower_block_returns(filtered)
637
+ end
638
+
639
+ # Lower `try { ...; return X; } catch (e) { ... } [finally { ... }]` by
640
+ # treating the try block's body as the function body. Catch/finally
641
+ # handlers are dropped — they typically encode JS-only error semantics
642
+ # that won't translate. Returns nil when the try block has no
643
+ # recognizable return shape.
644
+ def lower_try_return(try_stmt)
645
+ block_body = try_stmt[:block][:body]
646
+ lower_block_returns(block_body)
647
+ end
648
+
238
649
  def collect_local_bindings(statements)
239
650
  @local_jsx = {}
240
651
  @local_arrows = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsxRosetta
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean McCleary