jsx_rosetta 0.4.0 → 0.6.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 +342 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/ROADMAP.md +92 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +32 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
- data/lib/jsx_rosetta/backend/view_component.rb +261 -31
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +264 -19
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +30 -1
|
@@ -15,7 +15,7 @@ module JsxRosetta
|
|
|
15
15
|
# source JSX includes inline event handlers, a Stimulus controller is
|
|
16
16
|
# still emitted as a sibling file alongside the .html.erb.
|
|
17
17
|
class RailsView < ViewComponent
|
|
18
|
-
def emit(component)
|
|
18
|
+
def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
19
19
|
prop_names = component.props.map(&:name)
|
|
20
20
|
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
21
21
|
translator = ExpressionTranslator.new(
|
|
@@ -22,6 +22,11 @@ module JsxRosetta
|
|
|
22
22
|
# lowering time but not modeled in IR) translate to a `nil`
|
|
23
23
|
# placeholder with an inline `# TODO: local 'name'` marker — the
|
|
24
24
|
# file still loads, but the reviewer sees what to fill in.
|
|
25
|
+
# * Names in `imported_names` (top-level `import` declarations) are
|
|
26
|
+
# treated the same as local bindings — `nil` at leaf position, bail
|
|
27
|
+
# at member-chain root. Without this, `styles.listContainer` from
|
|
28
|
+
# `import styles from "./X.module.css"` snake-cases to a bare
|
|
29
|
+
# `styles` reference that NameErrors at render time.
|
|
25
30
|
# * Anything else translates to the bare snake_case identifier and
|
|
26
31
|
# is recorded as unresolved.
|
|
27
32
|
#
|
|
@@ -29,19 +34,64 @@ module JsxRosetta
|
|
|
29
34
|
# entry shadows lower entries.
|
|
30
35
|
class ExpressionTranslator
|
|
31
36
|
IDENTIFIER = /\A[a-zA-Z_$][a-zA-Z_$0-9]*\z/
|
|
32
|
-
|
|
37
|
+
# Tighter than `\A(['"])(.*)\1\z/m` — the greedy `.*` previously
|
|
38
|
+
# matched expressions like `"X" ? "Y" : "Z"` as a single quoted
|
|
39
|
+
# string. Now the body excludes unescaped quotes of the same kind,
|
|
40
|
+
# so only true string literals match.
|
|
41
|
+
STRING_LITERAL = /\A(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\z/m
|
|
33
42
|
NUMBER_LITERAL = /\A-?\d+(\.\d+)?\z/
|
|
34
43
|
TEMPLATE_LITERAL = /\A`(.*)`\z/m
|
|
35
|
-
TEMPLATE_INTERPOLATION = /\$\{([a-zA-Z_$][a-zA-Z_$0-9]*(
|
|
36
|
-
MEMBER_CHAIN = /\A(?<root>[a-zA-Z_$][a-zA-Z_$0-9]*)(?<rest>(
|
|
44
|
+
TEMPLATE_INTERPOLATION = /\$\{([a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\}/
|
|
45
|
+
MEMBER_CHAIN = /\A(?<root>[a-zA-Z_$][a-zA-Z_$0-9]*)(?<rest>(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)+)\z/
|
|
46
|
+
MEMBER_SEGMENT = /(\??\.)([a-zA-Z_$][a-zA-Z_$0-9]*)/
|
|
47
|
+
# Class-component access patterns. `this.props.X` is the same shape as
|
|
48
|
+
# a function-component prop reference, so it maps to `@x` plus any
|
|
49
|
+
# trailing member chain. `this.state.X` has no Ruby analog (state
|
|
50
|
+
# without a backing data source) so we surface a `nil` placeholder
|
|
51
|
+
# with a TODO marker — the file still loads.
|
|
52
|
+
THIS_PROPS_CHAIN = /\Athis\.props\.(?<rest>[a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\z/
|
|
53
|
+
THIS_STATE_CHAIN = /\Athis\.state\.(?<rest>[a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\z/
|
|
37
54
|
UNARY = /\A(?<op>!+|-|\+)(?<operand>.+)\z/m
|
|
38
55
|
SIMPLE_LITERALS = { "null" => "nil", "undefined" => "nil", "true" => "true", "false" => "false" }.freeze
|
|
39
56
|
|
|
40
|
-
|
|
57
|
+
# Binary operators we translate, grouped by precedence (lowest first).
|
|
58
|
+
# We split on the *lowest*-precedence top-level operator and recurse
|
|
59
|
+
# on each side, mirroring how a recursive-descent parser would treat
|
|
60
|
+
# the source: `a > 0 && b < 5` splits on `&&` first, then each side
|
|
61
|
+
# splits on its relational operator.
|
|
62
|
+
#
|
|
63
|
+
# Arithmetic operators (`+`, `-`, `*`, `/`, `%`) aren't included —
|
|
64
|
+
# `-x` and `+x` are unary at the start of an operand, and string-
|
|
65
|
+
# scanning can't disambiguate without operator-state tracking that
|
|
66
|
+
# mirrors a parser. Real JSX conditions rarely need arithmetic in
|
|
67
|
+
# tests; comparison + logical covers the bulk of them.
|
|
68
|
+
BINARY_PRECEDENCE = [
|
|
69
|
+
%w[|| ??],
|
|
70
|
+
%w[&&],
|
|
71
|
+
%w[=== !== == !=],
|
|
72
|
+
%w[<= >= < >]
|
|
73
|
+
].freeze
|
|
41
74
|
|
|
42
|
-
|
|
75
|
+
QUOTE_CHARS = ['"', "'", "`"].freeze
|
|
76
|
+
OPEN_BRACKETS = ["(", "[", "{"].freeze
|
|
77
|
+
CLOSE_BRACKETS = [")", "]", "}"].freeze
|
|
78
|
+
|
|
79
|
+
# `promoted_locals` lists names that the condition-mode translator
|
|
80
|
+
# rendered as `@ivar` despite being known-but-unresolvable locals or
|
|
81
|
+
# imports (bucket 4 in the resolution rules). The caller is expected
|
|
82
|
+
# to surface this list in a TODO so a reviewer knows which bindings
|
|
83
|
+
# need to become controller-passed props.
|
|
84
|
+
Result = Data.define(:ruby, :unresolved_identifiers, :promoted_locals)
|
|
85
|
+
|
|
86
|
+
# prop_aliases maps a local-binding name (the alias) to the
|
|
87
|
+
# underlying prop name. `"data-testid": dataTestId` records
|
|
88
|
+
# `{ "dataTestId" => "data-testid" }` so the use site of
|
|
89
|
+
# `dataTestId` resolves to the prop's `@data_testid` ivar.
|
|
90
|
+
def initialize(prop_names:, local_binding_names: [], prop_aliases: {}, imported_names: [])
|
|
43
91
|
@prop_names = prop_names.to_set
|
|
44
92
|
@local_binding_names = local_binding_names.to_set
|
|
93
|
+
@prop_aliases = prop_aliases.dup
|
|
94
|
+
@imported_names = imported_names.to_set
|
|
45
95
|
@local_stack = []
|
|
46
96
|
end
|
|
47
97
|
|
|
@@ -53,31 +103,270 @@ module JsxRosetta
|
|
|
53
103
|
end
|
|
54
104
|
|
|
55
105
|
def translate(source)
|
|
106
|
+
do_translate(source, condition_mode: false)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Render-condition entry point. Same recursive translator as
|
|
110
|
+
# `translate`, but bucket-4 hits (known-but-unresolvable locals /
|
|
111
|
+
# imports) emit `@snake_case` instead of returning `nil` (member-
|
|
112
|
+
# chain root, unary/binary operand) or `"nil"` (leaf identifier).
|
|
113
|
+
# The promoted names come back via `Result#promoted_locals` so the
|
|
114
|
+
# caller can surface a TODO naming the bindings that need to become
|
|
115
|
+
# controller-passed props.
|
|
116
|
+
#
|
|
117
|
+
# Only safe here because driving an `if` with a known-but-nil value
|
|
118
|
+
# silently disables the branch — destroying the source's intent.
|
|
119
|
+
# Promoting to an ivar trades silence for a clear render-time error
|
|
120
|
+
# (NameError on @ivar if the user never threads the prop) that the
|
|
121
|
+
# accompanying TODO points the reviewer at.
|
|
122
|
+
def translate_condition(source)
|
|
123
|
+
do_translate(source, condition_mode: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def do_translate(source, condition_mode:)
|
|
56
129
|
source = source.strip
|
|
57
130
|
unresolved = []
|
|
131
|
+
promoted = []
|
|
132
|
+
previous_mode = @condition_mode
|
|
133
|
+
previous_promoted = @promoted_locals
|
|
134
|
+
@condition_mode = condition_mode
|
|
135
|
+
@promoted_locals = promoted
|
|
58
136
|
|
|
59
137
|
ruby = translate_ruby(source, unresolved)
|
|
60
|
-
ruby && Result.new(
|
|
138
|
+
ruby && Result.new(
|
|
139
|
+
ruby: ruby,
|
|
140
|
+
unresolved_identifiers: unresolved.uniq,
|
|
141
|
+
promoted_locals: promoted.uniq
|
|
142
|
+
)
|
|
143
|
+
ensure
|
|
144
|
+
@condition_mode = previous_mode
|
|
145
|
+
@promoted_locals = previous_promoted
|
|
61
146
|
end
|
|
62
147
|
|
|
63
|
-
private
|
|
64
|
-
|
|
65
148
|
def translate_ruby(source, unresolved)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
149
|
+
source = unwrap_outer_parens(source.strip)
|
|
150
|
+
translate_simple_form(source, unresolved) || translate_compound_form(source, unresolved)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Handle the shapes that don't recurse: literals, identifiers, and
|
|
154
|
+
# the class-component `this.props.X` / `this.state.X` accessors.
|
|
155
|
+
# Returns nil when the source needs the compound-form dispatcher.
|
|
156
|
+
def translate_simple_form(source, unresolved)
|
|
157
|
+
return SIMPLE_LITERALS[source] if SIMPLE_LITERALS.key?(source)
|
|
158
|
+
return source if source.match?(NUMBER_LITERAL)
|
|
159
|
+
return reemit_string_literal(source) if source.match?(STRING_LITERAL)
|
|
160
|
+
return translate_this_props_chain(::Regexp.last_match(:rest)) if THIS_PROPS_CHAIN.match(source)
|
|
161
|
+
return translate_this_state_chain(::Regexp.last_match(:rest)) if THIS_STATE_CHAIN.match(source)
|
|
162
|
+
return translate_identifier(source, unresolved) if source.match?(IDENTIFIER)
|
|
163
|
+
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Convert a JS string-literal source (`"foo"` or `'foo'`) into its
|
|
168
|
+
# rubocop-preferred Ruby form: single-quoted when the body has no
|
|
169
|
+
# escapes, embedded quotes of the matching kind, or non-printable
|
|
170
|
+
# characters. Keeps the verbatim source otherwise — JS and Ruby
|
|
171
|
+
# double-quoted escape sequences mostly overlap, so passing through
|
|
172
|
+
# preserves semantics; rewriting `\n` from JS-single-quoted to Ruby
|
|
173
|
+
# would corrupt the literal.
|
|
174
|
+
def reemit_string_literal(source)
|
|
175
|
+
quote = source[0]
|
|
176
|
+
inner = source[1...-1]
|
|
177
|
+
# Backslashes, embedded single quotes, and interpolation markers
|
|
178
|
+
# all complicate the rewrite — keep the original literal as-is.
|
|
179
|
+
# Non-ASCII (emojis, unicode) is fine in single-quoted Ruby
|
|
180
|
+
# strings, so we don't bail for that.
|
|
181
|
+
return source if inner.include?("\\") || inner.include?("'") ||
|
|
182
|
+
inner.include?("\#{") || inner.match?(/[\x00-\x1f\x7f]/)
|
|
183
|
+
return source if quote == "'"
|
|
184
|
+
|
|
185
|
+
"'#{inner}'"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Handle the recursive / multi-segment shapes: member chains,
|
|
189
|
+
# template literals, unary operators, and the binary fallthrough.
|
|
190
|
+
def translate_compound_form(source, unresolved)
|
|
191
|
+
if (m = MEMBER_CHAIN.match(source)) then translate_member_chain(m[:root], m[:rest], unresolved)
|
|
70
192
|
elsif (m = TEMPLATE_LITERAL.match(source)) then translate_template_literal(m[1], unresolved)
|
|
71
|
-
elsif (m = UNARY.match(source))
|
|
72
|
-
|
|
193
|
+
elsif (m = UNARY.match(source)) then translate_unary(m[:op], m[:operand], unresolved)
|
|
194
|
+
else translate_binary(source, unresolved)
|
|
73
195
|
end
|
|
74
196
|
end
|
|
75
197
|
|
|
198
|
+
# `this.props.X` → `@x` (plus any trailing member chain segments,
|
|
199
|
+
# snake_cased and with `?.` → `&.`). The first segment IS the prop;
|
|
200
|
+
# subsequent segments are accessed off the prop's value. We don't
|
|
201
|
+
# add to `unresolved` here — class-component prop synthesis at
|
|
202
|
+
# lowering time has already registered the name.
|
|
203
|
+
def translate_this_props_chain(rest)
|
|
204
|
+
first = rest.split(/\??\./, 2).first
|
|
205
|
+
ivar = "@#{AST::Inflector.underscore(first)}"
|
|
206
|
+
remainder = rest[first.length..]
|
|
207
|
+
ruby_rest = remainder.gsub(MEMBER_SEGMENT) do
|
|
208
|
+
op = ::Regexp.last_match(1) == "?." ? "&." : "."
|
|
209
|
+
"#{op}#{AST::Inflector.underscore(::Regexp.last_match(2))}"
|
|
210
|
+
end
|
|
211
|
+
"#{ivar}#{ruby_rest}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# `this.state.X` has no direct Ruby analog — class-component state
|
|
215
|
+
# mutations don't map cleanly to a Phlex render. Emit `nil` so the
|
|
216
|
+
# file loads; reviewers wire up real state via controller-passed
|
|
217
|
+
# props or Stimulus values.
|
|
218
|
+
def translate_this_state_chain(_rest)
|
|
219
|
+
"nil"
|
|
220
|
+
end
|
|
221
|
+
|
|
76
222
|
def translate_unary(operator, operand, unresolved)
|
|
77
|
-
|
|
223
|
+
operand_clean = operand.strip
|
|
224
|
+
# `!fieldValue` where `fieldValue` is a known-but-unresolved local
|
|
225
|
+
# would translate to `!nil` (always true) and silently flip the
|
|
226
|
+
# condition's truthiness. Bail so the caller emits a TODO with
|
|
227
|
+
# the verbatim source.
|
|
228
|
+
return nil if unresolvable_local?(operand_clean)
|
|
229
|
+
|
|
230
|
+
inner = translate_ruby(operand_clean, unresolved)
|
|
78
231
|
inner && "#{operator}#{inner}"
|
|
79
232
|
end
|
|
80
233
|
|
|
234
|
+
# Walk source left-to-right looking for a top-level binary operator
|
|
235
|
+
# at the lowest precedence level present. Split there and recurse on
|
|
236
|
+
# each side. When two operators of the same precedence appear (e.g.
|
|
237
|
+
# `a || b || c`), the rightmost is chosen — the recursion on `lhs`
|
|
238
|
+
# then keeps splitting, yielding left-associative grouping.
|
|
239
|
+
def translate_binary(source, unresolved)
|
|
240
|
+
BINARY_PRECEDENCE.each do |operators|
|
|
241
|
+
match = find_top_level_operator(source, operators)
|
|
242
|
+
next unless match
|
|
243
|
+
|
|
244
|
+
result = translate_binary_at(source, match, unresolved)
|
|
245
|
+
return result if result
|
|
246
|
+
end
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def translate_binary_at(source, match, unresolved)
|
|
251
|
+
start_idx, end_idx, js_op = match
|
|
252
|
+
lhs = source[0...start_idx].strip
|
|
253
|
+
rhs = source[end_idx..].strip
|
|
254
|
+
return nil if lhs.empty? || rhs.empty?
|
|
255
|
+
# `count > 0` where `count` is a known-but-unresolved local
|
|
256
|
+
# translates to `nil > 0` and NoMethodErrors at render time.
|
|
257
|
+
return nil if unresolvable_local?(lhs) || unresolvable_local?(rhs)
|
|
258
|
+
|
|
259
|
+
lhs_ruby = translate_ruby(lhs, unresolved)
|
|
260
|
+
rhs_ruby = translate_ruby(rhs, unresolved)
|
|
261
|
+
return nil unless lhs_ruby && rhs_ruby
|
|
262
|
+
# `value === null || value === undefined` both translate to
|
|
263
|
+
# `@value.nil?` — collapse idempotent duplication for `||` / `&&`.
|
|
264
|
+
return lhs_ruby if %w[|| &&].include?(js_op) && lhs_ruby == rhs_ruby
|
|
265
|
+
|
|
266
|
+
rewrite_nil_comparison(lhs_ruby, rhs_ruby, js_op) ||
|
|
267
|
+
"#{lhs_ruby} #{ruby_binary_operator(js_op)} #{rhs_ruby}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# `x === null` / `x === undefined` → `x.nil?` (and `!==` → `!x.nil?`).
|
|
271
|
+
# JSX commonly compares values against `null`/`undefined`; emitting
|
|
272
|
+
# the literal `x == nil` form is valid Ruby but trips the
|
|
273
|
+
# Style/NilComparison cop. The `.nil?` form is idiomatic and reads
|
|
274
|
+
# better, so rewrite when either side is literally `nil`.
|
|
275
|
+
def rewrite_nil_comparison(lhs_ruby, rhs_ruby, js_op)
|
|
276
|
+
return nil unless %w[=== !== == !=].include?(js_op)
|
|
277
|
+
|
|
278
|
+
if rhs_ruby == "nil" && lhs_ruby != "nil"
|
|
279
|
+
js_op.start_with?("!") ? "!#{lhs_ruby}.nil?" : "#{lhs_ruby}.nil?"
|
|
280
|
+
elsif lhs_ruby == "nil" && rhs_ruby != "nil"
|
|
281
|
+
js_op.start_with?("!") ? "!#{rhs_ruby}.nil?" : "#{rhs_ruby}.nil?"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def ruby_binary_operator(js_op)
|
|
286
|
+
case js_op
|
|
287
|
+
when "===" then "=="
|
|
288
|
+
when "!==" then "!="
|
|
289
|
+
when "??" then "||"
|
|
290
|
+
else js_op
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Scan `source` for the rightmost occurrence of any operator from
|
|
295
|
+
# `operators` at lexical top level — outside any (), [], {}, or
|
|
296
|
+
# string literal. Returns `[start_index, end_index, operator]` or nil.
|
|
297
|
+
# Operators are tried longest-first at each position so `>=` beats
|
|
298
|
+
# `>` and `===` beats `==`.
|
|
299
|
+
def find_top_level_operator(source, operators)
|
|
300
|
+
sorted_ops = operators.sort_by { |op| -op.length }
|
|
301
|
+
state = { depth: 0, quote: nil, i: 0, last_match: nil }
|
|
302
|
+
while state[:i] < source.length
|
|
303
|
+
if state[:quote]
|
|
304
|
+
advance_quoted(source, state)
|
|
305
|
+
else
|
|
306
|
+
scan_one_position(source, sorted_ops, state)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
state[:last_match]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def advance_quoted(source, state)
|
|
313
|
+
c = source[state[:i]]
|
|
314
|
+
if c == "\\"
|
|
315
|
+
state[:i] += 2
|
|
316
|
+
else
|
|
317
|
+
state[:quote] = nil if c == state[:quote]
|
|
318
|
+
state[:i] += 1
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def scan_one_position(source, sorted_ops, state)
|
|
323
|
+
c = source[state[:i]]
|
|
324
|
+
if QUOTE_CHARS.include?(c)
|
|
325
|
+
state[:quote] = c
|
|
326
|
+
state[:i] += 1
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
state[:depth] += 1 if OPEN_BRACKETS.include?(c)
|
|
330
|
+
state[:depth] -= 1 if CLOSE_BRACKETS.include?(c)
|
|
331
|
+
|
|
332
|
+
matched = state[:depth].zero? && sorted_ops.find { |op| source[state[:i], op.length] == op }
|
|
333
|
+
if matched
|
|
334
|
+
state[:last_match] = [state[:i], state[:i] + matched.length, matched]
|
|
335
|
+
state[:i] += matched.length
|
|
336
|
+
else
|
|
337
|
+
state[:i] += 1
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Strip a single layer of outer parens when they wrap the entire
|
|
342
|
+
# source (`(a > b)` → `a > b`). When the leading `(` closes mid-
|
|
343
|
+
# source — e.g. `(a > b) && c` — leave the source alone since the
|
|
344
|
+
# parens are structurally meaningful. Trims surrounding whitespace.
|
|
345
|
+
def unwrap_outer_parens(source)
|
|
346
|
+
return source unless source.start_with?("(") && source.end_with?(")")
|
|
347
|
+
return source unless outer_parens_balanced?(source)
|
|
348
|
+
|
|
349
|
+
source[1...-1].strip
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def outer_parens_balanced?(source)
|
|
353
|
+
depth = 0
|
|
354
|
+
quote = nil
|
|
355
|
+
source.each_char.with_index do |c, i|
|
|
356
|
+
if quote
|
|
357
|
+
quote = nil if c == quote && source[i - 1] != "\\"
|
|
358
|
+
next
|
|
359
|
+
end
|
|
360
|
+
quote = c if QUOTE_CHARS.include?(c)
|
|
361
|
+
depth += 1 if c == "("
|
|
362
|
+
if c == ")"
|
|
363
|
+
depth -= 1
|
|
364
|
+
return false if depth.zero? && i != source.length - 1
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
true
|
|
368
|
+
end
|
|
369
|
+
|
|
81
370
|
def in_local_scope?(name)
|
|
82
371
|
@local_stack.any? { |scope| scope.include?(name) }
|
|
83
372
|
end
|
|
@@ -86,35 +375,61 @@ module JsxRosetta
|
|
|
86
375
|
snake = AST::Inflector.underscore(name)
|
|
87
376
|
if in_local_scope?(name)
|
|
88
377
|
snake
|
|
378
|
+
elsif @prop_aliases.key?(name)
|
|
379
|
+
"@#{AST::Inflector.underscore(@prop_aliases[name])}"
|
|
89
380
|
elsif @prop_names.include?(name)
|
|
90
381
|
"@#{snake}"
|
|
91
|
-
elsif @local_binding_names.include?(name)
|
|
92
|
-
# We know this binding exists
|
|
93
|
-
# but can't model its value.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
snake
|
|
103
|
-
else
|
|
104
|
-
"nil"
|
|
382
|
+
elsif @local_binding_names.include?(name) || @imported_names.include?(name)
|
|
383
|
+
# We know this binding exists (destructure, hook tuple, top-level
|
|
384
|
+
# import) but can't model its value. In `translate_condition`
|
|
385
|
+
# mode the test is load-bearing — emitting `nil` would silently
|
|
386
|
+
# false-arm the branch — so promote the binding to an `@ivar`
|
|
387
|
+
# and record it for the caller's TODO. In default mode, return
|
|
388
|
+
# `nil` so the file loads (leaf) / bail so the caller emits a
|
|
389
|
+
# TODO (member-chain root).
|
|
390
|
+
if @condition_mode
|
|
391
|
+
@promoted_locals << name
|
|
392
|
+
return "@#{snake}"
|
|
105
393
|
end
|
|
394
|
+
|
|
395
|
+
return nil if member_chain_root
|
|
396
|
+
|
|
397
|
+
"nil"
|
|
106
398
|
else
|
|
107
399
|
unresolved << name
|
|
108
400
|
snake
|
|
109
401
|
end
|
|
110
402
|
end
|
|
111
403
|
|
|
404
|
+
# An identifier we know to be defined locally (destructure, hook
|
|
405
|
+
# tuple) or pulled in via a top-level `import`, but whose value we
|
|
406
|
+
# can't model. The leaf-translates-to-nil path is safe in value
|
|
407
|
+
# positions (attribute kwargs, leaf interpolations) but compound
|
|
408
|
+
# contexts (unary, binary, member chain) must bail so callers emit
|
|
409
|
+
# a TODO instead of silently changing semantics.
|
|
410
|
+
def unresolvable_local?(source)
|
|
411
|
+
return false unless source.match?(IDENTIFIER)
|
|
412
|
+
# In render-condition mode we promote bucket-4 hits to @ivars
|
|
413
|
+
# (see translate_identifier) — so they aren't unresolvable here.
|
|
414
|
+
# Skip the bail so unary/binary translation succeeds.
|
|
415
|
+
return false if @condition_mode
|
|
416
|
+
|
|
417
|
+
(@local_binding_names.include?(source) || @imported_names.include?(source)) &&
|
|
418
|
+
!in_local_scope?(source) &&
|
|
419
|
+
!@prop_names.include?(source)
|
|
420
|
+
end
|
|
421
|
+
|
|
112
422
|
def translate_member_chain(root, rest, unresolved)
|
|
113
423
|
translated_root = translate_identifier(root, unresolved, member_chain_root: true)
|
|
424
|
+
return nil unless translated_root
|
|
425
|
+
|
|
114
426
|
# Underscore each chain segment so JS camelCase identifiers map to
|
|
115
|
-
# Ruby snake_case (`post.coverImage` → `post.cover_image`).
|
|
116
|
-
|
|
117
|
-
|
|
427
|
+
# Ruby snake_case (`post.coverImage` → `post.cover_image`). Map
|
|
428
|
+
# optional-chaining `?.` to Ruby's safe-nav `&.` so a nil receiver
|
|
429
|
+
# short-circuits to nil instead of raising NoMethodError.
|
|
430
|
+
ruby_rest = rest.gsub(MEMBER_SEGMENT) do
|
|
431
|
+
op = ::Regexp.last_match(1) == "?." ? "&." : "."
|
|
432
|
+
"#{op}#{AST::Inflector.underscore(::Regexp.last_match(2))}"
|
|
118
433
|
end
|
|
119
434
|
"#{translated_root}#{ruby_rest}"
|
|
120
435
|
end
|
|
@@ -129,7 +444,16 @@ module JsxRosetta
|
|
|
129
444
|
match = ::Regexp.last_match
|
|
130
445
|
literal = content[last_pos...match.begin(0)]
|
|
131
446
|
parts << escape_ruby_string_literal(literal) unless literal.empty?
|
|
132
|
-
|
|
447
|
+
translated = translate_template_interpolation(match[1], unresolved)
|
|
448
|
+
# An interpolation segment that itself fails translation (nil)
|
|
449
|
+
# or resolves to literal `nil` (known-but-unresolvable local)
|
|
450
|
+
# would emit `\#{}` / `\#{nil}` — empty or semantically empty
|
|
451
|
+
# interpolation that rubocop flags and that loses the source's
|
|
452
|
+
# intent. Bail so the whole template literal falls through to
|
|
453
|
+
# the caller's TODO path with the verbatim JS source visible.
|
|
454
|
+
return nil if translated.nil? || translated == "nil"
|
|
455
|
+
|
|
456
|
+
parts << "\#{#{translated}}"
|
|
133
457
|
last_pos = match.end(0)
|
|
134
458
|
end
|
|
135
459
|
trailing = content[last_pos..]
|