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 +4 -4
- data/CHANGELOG.md +85 -0
- data/lib/jsx_rosetta/ast/inflector.rb +1 -0
- data/lib/jsx_rosetta/ir/lowering.rb +395 -40
- data/lib/jsx_rosetta/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28709ed22159e135331acff12df2c1fadbc2b692c62cd90d40a977192b28a46d
|
|
4
|
+
data.tar.gz: 4c8543a43d7ca8e96ba3c93d92f89fddbb548d4329332487df67683cabffa4e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
|
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
|
|
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,
|
|
268
|
+
.select { |(name, fn)| component_function?(name, fn) }
|
|
106
269
|
end
|
|
107
270
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
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:
|
|
405
|
+
Prop.new(name: prop_name, default: default)
|
|
214
406
|
end
|
|
215
407
|
|
|
216
408
|
def lower_function_body(body)
|
|
217
|
-
|
|
218
|
-
when "BlockStatement"
|
|
409
|
+
if body.type == "BlockStatement"
|
|
219
410
|
collect_local_bindings(body[:body])
|
|
220
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
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
|
-
|
|
543
|
+
collect_nested_local_bindings(branch[:body])
|
|
544
|
+
lower_block_returns(branch[:body])
|
|
545
|
+
end
|
|
546
|
+
end
|
|
286
547
|
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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 = {}
|
data/lib/jsx_rosetta/version.rb
CHANGED