jsx_rosetta 0.2.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: 57170a56efc4593faf87b9ee0e3ac055f862cb058b9185ed57bb9a12d47f9af4
4
- data.tar.gz: bd52ab90f51bc6be003f33b7cc10cda266002fa55386bc079f199fbcc9f3130b
3
+ metadata.gz: 28709ed22159e135331acff12df2c1fadbc2b692c62cd90d40a977192b28a46d
4
+ data.tar.gz: 4c8543a43d7ca8e96ba3c93d92f89fddbb548d4329332487df67683cabffa4e9
5
5
  SHA512:
6
- metadata.gz: 145db314ab8d61384fbebbf8969310664c358c6b561bf1dbf742e97feb71497e58703d300c9d6b1e75159dfee27ee7a0173662a631ab143597b0b6633fff67ff
7
- data.tar.gz: 423f772ec2e33618ad6e6567d20caa9a4c375c8c05a5cb8425b681010ee89ecddda2fd9a6ddde0c2ab0777962c1d9e51cd3ec69f28236cc0e1390cdf25ccd90e
6
+ metadata.gz: c053e3111f0dc98f5ce3549d0c50a06f04f69bdbb13f5206296e807c62174b1e3824c9ac30d327884c20d1dd39d4fa821bec931292ff9802343b7d8c6a1da1ce
7
+ data.tar.gz: d06787398b3c56482c881184f0cdcbe6ce20071c9c6a1d1cc411fad1514a5a4910d9d7ddfd78455107e14f4285fdf4dcf2299f42a48622790bccb9c7d02bfc17
data/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
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
+
3
88
  ## [0.2.0] - 2026-05-10
4
89
 
5
90
  Driven by an empirical probe of v0.1.0 against a 39-file Next.js production
@@ -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,50 +398,114 @@ 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
404
  default = (Interpolation.new(expression: source_of(value[:right])) if value.type == "AssignmentPattern")
213
- Prop.new(name: property[:key][:name], default: default)
405
+ Prop.new(name: prop_name, default: default)
214
406
  end
215
407
 
216
408
  def lower_function_body(body)
217
- case body.type
218
- when "BlockStatement"
409
+ if body.type == "BlockStatement"
219
410
  collect_local_bindings(body[:body])
220
- return_stmt = body[:body].find { |stmt| stmt.type == "ReturnStatement" }
221
- return lower_return_value(return_stmt[:argument]) if return_stmt
222
-
223
- chained = lower_if_return_chain_from_body(body[:body])
411
+ chained = lower_block_returns(body[:body])
224
412
  return chained if chained
225
413
 
226
414
  raise lowering_error("component function has no return statement", node: body)
227
- when "JSXElement", "JSXFragment"
228
- @local_jsx = {}
229
- lower_jsx(body)
230
- else
231
- raise lowering_error("unsupported component body: #{body.type}", node: body)
232
415
  end
233
- end
234
416
 
235
- # Dispatch a value in return position. Distinct from lower_jsx because
236
- # `return cond ? <A/> : <B/>` and `return cond && <A/>` are valid return
237
- # shapes that aren't JSX nodes and need to lower as Conditional.
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.
238
430
  def lower_return_value(node)
239
431
  case node.type
240
432
  when "ConditionalExpression" then lower_ternary_expression(node)
241
433
  when "LogicalExpression" then lower_logical_expression(node)
242
- else lower_jsx(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]
465
+ else
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]
243
473
  end
474
+
475
+ wrap_return_guards(preceding, base)
244
476
  end
245
477
 
246
- # Recognize a body whose only return paths are inside an
247
- # `if/else if/else` chain at the bottom (no unconditional return).
248
- # Lowers the chain to nested IR::Conditional. Returns nil when the
249
- # shape doesn't fit.
250
- def lower_if_return_chain_from_body(statements)
251
- if_stmt = statements.last
252
- return nil unless if_stmt.is_a?(AST::Node) && if_stmt.type == "IfStatement"
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
253
485
 
254
- lower_if_return_chain(if_stmt)
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
255
509
  end
256
510
 
257
511
  def lower_if_return_chain(if_stmt)
@@ -273,24 +527,125 @@ module JsxRosetta
273
527
  )
274
528
  end
275
529
 
276
- # An if-chain branch lowers to a return value only when it is a
277
- # single-statement block ending in `return X;` (or a bare `return X;`
278
- # without braces). Multi-statement branches imply side effects we
279
- # don't preserve, so we bail.
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.
280
538
  def lower_return_branch(branch)
281
539
  case branch.type
282
540
  when "ReturnStatement"
283
541
  branch[:argument] && lower_return_value(branch[:argument])
284
542
  when "BlockStatement"
285
- return nil if branch[:body].size != 1
543
+ collect_nested_local_bindings(branch[:body])
544
+ lower_block_returns(branch[:body])
545
+ end
546
+ end
286
547
 
287
- inner = branch[:body].first
288
- return nil unless inner.type == "ReturnStatement" && inner[:argument]
548
+ def collect_nested_local_bindings(stmts)
549
+ stmts.each do |stmt|
550
+ next unless stmt.is_a?(AST::Node) && stmt.type == "VariableDeclaration"
289
551
 
290
- lower_return_value(inner[:argument])
552
+ seen = {}
553
+ stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen) }
291
554
  end
292
555
  end
293
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
+ )
578
+ end
579
+ end
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
+
294
649
  def collect_local_bindings(statements)
295
650
  @local_jsx = {}
296
651
  @local_arrows = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsxRosetta
4
- VERSION = "0.2.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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean McCleary