jsx_rosetta 0.1.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 +7 -0
- data/CHANGELOG.md +149 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +236 -0
- data/README.md +328 -0
- data/Rakefile +12 -0
- data/exe/jsx_rosetta +6 -0
- data/lib/jsx_rosetta/ast/inflector.rb +23 -0
- data/lib/jsx_rosetta/ast/node.rb +151 -0
- data/lib/jsx_rosetta/ast/types.rb +224 -0
- data/lib/jsx_rosetta/ast/visitor.rb +47 -0
- data/lib/jsx_rosetta/ast.rb +15 -0
- data/lib/jsx_rosetta/backend/base.rb +21 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +41 -0
- data/lib/jsx_rosetta/backend/routes_script.rb +191 -0
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +120 -0
- data/lib/jsx_rosetta/backend/view_component.rb +638 -0
- data/lib/jsx_rosetta/backend.rb +12 -0
- data/lib/jsx_rosetta/cli.rb +182 -0
- data/lib/jsx_rosetta/ir/lowering.rb +727 -0
- data/lib/jsx_rosetta/ir/types.rb +276 -0
- data/lib/jsx_rosetta/ir.rb +16 -0
- data/lib/jsx_rosetta/node_bridge.rb +56 -0
- data/lib/jsx_rosetta/parse_error.rb +19 -0
- data/lib/jsx_rosetta/parser.rb +30 -0
- data/lib/jsx_rosetta/routes.rb +72 -0
- data/lib/jsx_rosetta/version.rb +5 -0
- data/lib/jsx_rosetta.rb +41 -0
- data/node/.gitignore +1 -0
- data/node/package-lock.json +64 -0
- data/node/package.json +16 -0
- data/node/parse.js +77 -0
- metadata +84 -0
data/README.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# jsx_rosetta
|
|
2
|
+
|
|
3
|
+
Translate React/JSX components into Rails 8.1 [ViewComponent](https://viewcomponent.org/)
|
|
4
|
+
classes (or plain Rails view templates) — and lift React Router config into a
|
|
5
|
+
runnable `rails generate controller` script.
|
|
6
|
+
|
|
7
|
+
`jsx_rosetta` parses JSX/TSX via Babel running in a Node sidecar, lowers the
|
|
8
|
+
parsed AST into a framework-agnostic semantic intermediate representation (IR), and emits target output
|
|
9
|
+
through pluggable backends. The pipeline is end-to-end working: a real React
|
|
10
|
+
app's components and routes can be translated, dropped into a fresh Rails 8.1
|
|
11
|
+
app, and rendered with only a handful of human edits at the TODO markers the
|
|
12
|
+
gem itself emits.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
JSX text ──► Babel AST ──► Ruby AST ──► IR ──► backend ──► .rb / .html.erb / _controller.js
|
|
16
|
+
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / RoutesScript)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle add jsx_rosetta
|
|
23
|
+
bundle exec jsx_rosetta install # npm-installs the gem's Node sidecar deps
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires:
|
|
27
|
+
- Ruby ≥ 3.2
|
|
28
|
+
- Node.js ≥ 18 (subprocess used for parsing)
|
|
29
|
+
|
|
30
|
+
The sidecar's `node_modules` is **not** bundled in the gem — `jsx_rosetta install`
|
|
31
|
+
runs `npm install` in the gem's vendored `node/` directory. Set
|
|
32
|
+
`JSX_ROSETTA_NODE` if `node` is in a non-standard location.
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Translate a JSX/TSX component into a ViewComponent (sidecar layout, default)
|
|
38
|
+
jsx_rosetta translate path/to/Button.tsx -o app/components
|
|
39
|
+
# → app/components/button_component.rb
|
|
40
|
+
# app/components/button_component/button_component.html.erb
|
|
41
|
+
# app/components/button_component/button_controller.js (when inline arrow handlers)
|
|
42
|
+
|
|
43
|
+
# Translate a route-tied page as a Rails view template (no Ruby class)
|
|
44
|
+
jsx_rosetta translate path/to/Home.tsx --as=view -o app/views/home
|
|
45
|
+
# → app/views/home/home.html.erb (rename to index.html.erb)
|
|
46
|
+
|
|
47
|
+
# Extract <Routes><Route> entries into a runnable Ruby script
|
|
48
|
+
jsx_rosetta routes path/to/router.tsx -o tmp/generate_controllers.rb
|
|
49
|
+
# Review the script. Uncomment the system() calls you want, then:
|
|
50
|
+
ruby tmp/generate_controllers.rb
|
|
51
|
+
|
|
52
|
+
# Inspect the parsed Babel AST
|
|
53
|
+
jsx_rosetta parse path/to/Button.tsx > button.ast.json
|
|
54
|
+
|
|
55
|
+
# Install the Node sidecar deps
|
|
56
|
+
jsx_rosetta install
|
|
57
|
+
|
|
58
|
+
jsx_rosetta version
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`.tsx` files are auto-detected from the extension; pass `--tsx` to force
|
|
62
|
+
TypeScript parsing on a `.jsx`-named file.
|
|
63
|
+
|
|
64
|
+
## Library API
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
require "jsx_rosetta"
|
|
68
|
+
|
|
69
|
+
source = File.read("Button.tsx")
|
|
70
|
+
|
|
71
|
+
# Just the parsed AST (Babel-shaped, typed Ruby objects)
|
|
72
|
+
ast = JsxRosetta.parse(source)
|
|
73
|
+
ast.walk.find { |n| n.is_a?(JsxRosetta::AST::JSXElement) }.tag_name
|
|
74
|
+
# => "button"
|
|
75
|
+
|
|
76
|
+
# Lowered IR (semantic, backend-agnostic). Returns the first component.
|
|
77
|
+
ir = JsxRosetta.lower(source)
|
|
78
|
+
ir.props.map(&:name) # => ["children", "onClick", "variant"]
|
|
79
|
+
ir.stimulus_methods # event handlers extracted to Stimulus
|
|
80
|
+
ir.react_hooks # useState/useEffect/etc. flagged for the human
|
|
81
|
+
|
|
82
|
+
# All components in a multi-component file
|
|
83
|
+
JsxRosetta::IR.lower_all(JsxRosetta.parse(source), source: source)
|
|
84
|
+
|
|
85
|
+
# End-to-end translation. Returns an array of File value objects.
|
|
86
|
+
files = JsxRosetta.translate(source)
|
|
87
|
+
files.first.path # => "button_component.rb"
|
|
88
|
+
files.first.contents # => "# frozen_string_literal: true\n…"
|
|
89
|
+
|
|
90
|
+
# Same source as a plain Rails view (no .rb class, no sidecar dir)
|
|
91
|
+
JsxRosetta.translate(source, backend: :rails_view)
|
|
92
|
+
|
|
93
|
+
# Extract React Router routes
|
|
94
|
+
route_tree = JsxRosetta::Routes.lower(JsxRosetta.parse(File.read("router.tsx")))
|
|
95
|
+
JsxRosetta::Backend::RoutesScript.new(source_path: "router.tsx").emit(route_tree)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Optional kwargs to `JsxRosetta.translate`:
|
|
99
|
+
- `backend: :view_component | :rails_view` (default `:view_component`)
|
|
100
|
+
- `helpers: nil | Hash | false` — JSX-name → Rails-helper mapping (see below)
|
|
101
|
+
- `layout: :sidecar | :flat` (default `:sidecar`, ignored for `:rails_view`)
|
|
102
|
+
- `typescript: true` — force the TypeScript Babel plugin
|
|
103
|
+
- `source_filename:` — surfaces in parse errors
|
|
104
|
+
|
|
105
|
+
## What translates
|
|
106
|
+
|
|
107
|
+
| JSX construct | Translation |
|
|
108
|
+
| ------------------------------------ | ---------------------------------------------------------- |
|
|
109
|
+
| Function & arrow components | `class XComponent < ::ViewComponent::Base` |
|
|
110
|
+
| Multi-component files | One `.rb` + `.html.erb` pair per exported component |
|
|
111
|
+
| `<button type="x">` | Literal HTML attribute |
|
|
112
|
+
| `<a href={url}>` | `href="<%= @url %>"` when `url` is a prop |
|
|
113
|
+
| `<a href={`/p/${slug}`}>` | `href="/p/<%= @slug %>"` (template literal inlined) |
|
|
114
|
+
| `<a aria-label={x}>` | `aria-label="<%= @x %>"` on Element; `"aria-label" => @x` on Component |
|
|
115
|
+
| `<button type="button" {...rest}>` | `<%= tag.button(type: "button", **@rest) do %>…<% end %>` |
|
|
116
|
+
| `<Button variant="primary" />` | `<%= render ButtonComponent.new(variant: "primary") %>` |
|
|
117
|
+
| `<Tabs.List>` | `<%= render Tabs::ListComponent.new(...) %>` |
|
|
118
|
+
| `<Link href="/x">` | `<%= link_to("/x") do %>…<% end %>` (default helper map) |
|
|
119
|
+
| `<Image src="..." />` | `<%= image_tag("...", alt: "...") %>` (default helper map) |
|
|
120
|
+
| `{children}` (when prop) | `<%= content %>` (ViewComponent default slot) |
|
|
121
|
+
| `{cond && <X />}`, `{cond ? X : Y}` | `<% if %>…<% end %>` / with `<% else %>` branch |
|
|
122
|
+
| `{items.map((item, i) => <X />)}` | `<% @items.each do \|item, i\| %>…<% end %>` |
|
|
123
|
+
| `{post.coverImage}` | `@post.cover_image` (member chains snake-cased) |
|
|
124
|
+
| `{!preview}` | `!@preview` (unary negation) |
|
|
125
|
+
| `className={cn("a", b, { c: cond })}`| `class="a <%= @b %> <%= @cond ? "c" : '' %>"` |
|
|
126
|
+
| `style={{ fontSize: 12 }}` | `style="font-size: 12;"` (kebab-case keys) |
|
|
127
|
+
| `{/* foo */}` | `<%# foo %>` |
|
|
128
|
+
| `{"foo"}`, `{42}` | Plain text (`<%= " " %>` clutter eliminated) |
|
|
129
|
+
| `{true}`, `{null}` | Dropped (matches React runtime) |
|
|
130
|
+
| `<hr />`, `<img />` | Self-closing void elements |
|
|
131
|
+
| `onClick={handler}` (handler is prop)| `data-action="<%= @handler %>"` (Stimulus action descriptor) |
|
|
132
|
+
| `onClick={() => …}` (inline) | Stimulus controller method + `data-action="click->name#methodName"` |
|
|
133
|
+
| `const Comp = asChild ? X : "tag"` | Synthesized `<% if @as_child %>…<% else %>…<% end %>` |
|
|
134
|
+
| Default values (`x = "primary"`) | Translated when literal/identifier/member-chain |
|
|
135
|
+
| Bare prop identifiers | `@snake_case_name` |
|
|
136
|
+
|
|
137
|
+
## What's flagged for human review
|
|
138
|
+
|
|
139
|
+
The gem emits a `<%# TODO: … %>` comment whenever a translation isn't safe to
|
|
140
|
+
auto-perform. Common cases:
|
|
141
|
+
|
|
142
|
+
- **React hooks** (`useState`, `useEffect`, `useRef`, …) → distinct comment
|
|
143
|
+
block at the top of the template, with the Hotwire/Stimulus alternative
|
|
144
|
+
noted and the original JS preserved verbatim.
|
|
145
|
+
- **Local non-JSX `const` bindings** (`const date = parseISO(s)`) → comment
|
|
146
|
+
block at the top with the original JS preserved.
|
|
147
|
+
- **Unrecognized JS expressions** (function calls, subscripts, complex
|
|
148
|
+
ternaries) → inline TODO with verbatim source.
|
|
149
|
+
- **Unresolved identifiers** (imports like `EXAMPLE_PATH`, `CMS_NAME`) →
|
|
150
|
+
inline TODO listing the offending names so the human can wire them up.
|
|
151
|
+
- **`dangerouslySetInnerHTML` and similar opaque attributes** → flagged.
|
|
152
|
+
- **Reserved Rails controller names** (in the routes script) → `# WARNING:`
|
|
153
|
+
line listing collisions.
|
|
154
|
+
|
|
155
|
+
## What's deferred
|
|
156
|
+
|
|
157
|
+
- Translating React state primitives (`useState` / `useEffect` etc.). The
|
|
158
|
+
policy is to flag and route behavior to Stimulus controllers, not to
|
|
159
|
+
recreate React's runtime in Ruby.
|
|
160
|
+
- React Router's data-router form (`createBrowserRouter([...])`). Only the
|
|
161
|
+
declarative `<Routes><Route>` shape is parsed today.
|
|
162
|
+
- Backends other than ViewComponent and RailsView (Phlex, Slim, LiveView).
|
|
163
|
+
- Suspense → Turbo Frame mapping (depends on a data-fetching translation
|
|
164
|
+
story we don't have yet).
|
|
165
|
+
|
|
166
|
+
## ViewComponent emission targets
|
|
167
|
+
|
|
168
|
+
Two backends, picked via `--as` on the CLI or `backend:` in the API:
|
|
169
|
+
|
|
170
|
+
**`:view_component` (default)** — emits a sidecar layout matching
|
|
171
|
+
ViewComponent's `--sidecar` generator convention:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
app/components/
|
|
175
|
+
card_component.rb # at top level
|
|
176
|
+
card_component/
|
|
177
|
+
card_component.html.erb # sidecar template
|
|
178
|
+
card_controller.js # Stimulus, when applicable
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Pass `layout: :flat` to revert to the older flat layout
|
|
182
|
+
(`card_component.rb` + `card_component.html.erb` side-by-side).
|
|
183
|
+
|
|
184
|
+
**`:rails_view`** — for pages tied to a route. Emits one `.html.erb` with
|
|
185
|
+
no Ruby class and no sidecar. Place at `app/views/<controller>/<action>.html.erb`;
|
|
186
|
+
the controller's instance variables become the template's `@x` references.
|
|
187
|
+
|
|
188
|
+
## Helper mappings
|
|
189
|
+
|
|
190
|
+
Capitalized JSX tags that have a direct Rails helper analog skip the
|
|
191
|
+
`<%= render XComponent.new(...) %>` indirection. Defaults:
|
|
192
|
+
|
|
193
|
+
| JSX | Emits |
|
|
194
|
+
| -------- | ---------------------------------------------- |
|
|
195
|
+
| `<Link>` | `<%= link_to(href, **rest) do %>…<% end %>` |
|
|
196
|
+
| `<Image>`| `<%= image_tag(src, alt:, **rest) %>` |
|
|
197
|
+
|
|
198
|
+
Override:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
JsxRosetta.translate(source, helpers: {
|
|
202
|
+
"Link" => { method: :link_to, positional: :href },
|
|
203
|
+
"Image" => { method: :image_tag, positional: :src },
|
|
204
|
+
"Btn" => { method: :button_to, positional: :url }
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
# Disable entirely (everything stays as <%= render XComponent.new(...) %>)
|
|
208
|
+
JsxRosetta.translate(source, helpers: false)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Stimulus integration
|
|
212
|
+
|
|
213
|
+
Inline arrow event handlers (and const-bound arrows referenced from
|
|
214
|
+
`onX={handler}`) extract to a generated `<snake>_controller.js` skeleton:
|
|
215
|
+
|
|
216
|
+
```jsx
|
|
217
|
+
function CopyButton({ text }) {
|
|
218
|
+
const handleClick = () => navigator.clipboard.writeText(text);
|
|
219
|
+
return <button onClick={handleClick}>Copy</button>;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
emits three files:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
# copy_button_component.rb
|
|
227
|
+
class CopyButtonComponent < ::ViewComponent::Base
|
|
228
|
+
def initialize(text:); @text = text; end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```erb
|
|
233
|
+
<%# copy_button_component/copy_button_component.html.erb %>
|
|
234
|
+
<button data-controller="copy-button"
|
|
235
|
+
data-action="click->copy-button#handleClick">
|
|
236
|
+
Copy
|
|
237
|
+
</button>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```javascript
|
|
241
|
+
// copy_button_component/copy_button_controller.js
|
|
242
|
+
import { Controller } from "@hotwired/stimulus";
|
|
243
|
+
|
|
244
|
+
export default class extends Controller {
|
|
245
|
+
// TODO: translate from the original JSX handler:
|
|
246
|
+
// navigator.clipboard.writeText(text)
|
|
247
|
+
handleClick(event) {
|
|
248
|
+
// ...
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
For Rails to find sidecar Stimulus controllers, add to
|
|
254
|
+
`config/importmap.rb`:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
pin_all_from "app/components", under: "components", preload: true
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
and to `app/javascript/application.js`:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { application } from "controllers/application"
|
|
264
|
+
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
|
265
|
+
eagerLoadControllersFrom("controllers", application)
|
|
266
|
+
eagerLoadControllersFrom("components", application)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Routes subcommand
|
|
270
|
+
|
|
271
|
+
`jsx_rosetta routes router.tsx` parses `<Routes><Route>` JSX and emits a
|
|
272
|
+
runnable Ruby script:
|
|
273
|
+
|
|
274
|
+
- Lists each parsed `path → element` mapping.
|
|
275
|
+
- Suggests `system "rails", "generate", "controller", …` invocations
|
|
276
|
+
(commented for review).
|
|
277
|
+
- Consolidates `/xs` + `/xs/:id` pairs into `resources :xs, only: %i[index show]`.
|
|
278
|
+
- Emits `match "*path", to: …, via: :all` for catch-all routes.
|
|
279
|
+
- Warns when a generated controller name collides with a reserved Rails term.
|
|
280
|
+
- Prints a suggested `config/routes.rb` block.
|
|
281
|
+
|
|
282
|
+
The script is meant to be reviewed and edited before `ruby`-running it.
|
|
283
|
+
|
|
284
|
+
## Architecture
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
lib/jsx_rosetta/
|
|
288
|
+
parser.rb # JSX text → AST::File
|
|
289
|
+
node_bridge.rb # subprocess plumbing
|
|
290
|
+
ast/ # typed Ruby classes mirroring Babel
|
|
291
|
+
ir/
|
|
292
|
+
types.rb # IR value classes
|
|
293
|
+
lowering.rb # AST → IR
|
|
294
|
+
routes.rb # AST → IR::RouteTree (React Router)
|
|
295
|
+
backend/
|
|
296
|
+
base.rb
|
|
297
|
+
view_component.rb # IR → .rb + .html.erb (+ _controller.js)
|
|
298
|
+
view_component/expression_translator.rb
|
|
299
|
+
rails_view.rb # IR → .html.erb only
|
|
300
|
+
routes_script.rb # IR::RouteTree → reviewable Ruby script
|
|
301
|
+
cli.rb # exe/jsx_rosetta dispatch
|
|
302
|
+
node/
|
|
303
|
+
parse.js # stdin (JSX request) → stdout (Babel JSON AST)
|
|
304
|
+
package.json # @babel/parser
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The IR sits between parsing and emission so additional backends (Phlex,
|
|
308
|
+
Slim, LiveView, …) can be added without changing the frontend.
|
|
309
|
+
|
|
310
|
+
## Development
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
bin/setup # bundle install + npm install in node/
|
|
314
|
+
bundle exec rspec # run the full test suite
|
|
315
|
+
bundle exec rubocop
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
End-to-end fixtures live in `spec/fixtures/`:
|
|
319
|
+
- `spec/fixtures/jsx/*.{jsx,tsx}` — input JSX
|
|
320
|
+
- `spec/fixtures/expected/*` — hand-written expected output
|
|
321
|
+
|
|
322
|
+
For a worked example of dropping translated output into a real Rails 8.1 app,
|
|
323
|
+
see `CHANGELOG.md` — Phase 9 and Phase 9b describe the multi-route React
|
|
324
|
+
Router → Rails app demo end-to-end.
|
|
325
|
+
|
|
326
|
+
## License
|
|
327
|
+
|
|
328
|
+
MIT.
|
data/Rakefile
ADDED
data/exe/jsx_rosetta
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsxRosetta
|
|
4
|
+
module AST
|
|
5
|
+
# Internal helpers for converting between Babel's camelCase field names
|
|
6
|
+
# and Ruby's snake_case conventions.
|
|
7
|
+
module Inflector
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def underscore(string)
|
|
11
|
+
string
|
|
12
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
13
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
14
|
+
.downcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def camelize(string)
|
|
18
|
+
parts = string.split("_")
|
|
19
|
+
parts[0] + parts[1..].map(&:capitalize).join
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inflector"
|
|
4
|
+
|
|
5
|
+
module JsxRosetta
|
|
6
|
+
module AST
|
|
7
|
+
# Base class for every Babel-shaped AST node. Wraps the raw JSON hash
|
|
8
|
+
# and provides:
|
|
9
|
+
# * Field access via `node[:opening_element]` (snake_case symbols or
|
|
10
|
+
# camelCase strings — both resolve to the same field).
|
|
11
|
+
# * Source location accessors (`loc`, `range`, `start_pos`, `end_pos`).
|
|
12
|
+
# * Tree traversal via `each_child` / `walk`.
|
|
13
|
+
# * Pattern-matching support via `deconstruct_keys`.
|
|
14
|
+
#
|
|
15
|
+
# Specific Babel node types may register subclasses that add named
|
|
16
|
+
# accessors (e.g. JSXElement#opening_element). Unknown types fall
|
|
17
|
+
# through to the generic Node class so the parser doesn't crash on
|
|
18
|
+
# ESNext additions.
|
|
19
|
+
class Node
|
|
20
|
+
TYPE_REGISTRY = {} # rubocop:disable Style/MutableConstant
|
|
21
|
+
|
|
22
|
+
attr_reader :raw
|
|
23
|
+
|
|
24
|
+
def self.register(*type_names)
|
|
25
|
+
type_names.each { |name| TYPE_REGISTRY[name] = self }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.wrap(value)
|
|
29
|
+
case value
|
|
30
|
+
when Hash
|
|
31
|
+
if value.key?("type")
|
|
32
|
+
klass = TYPE_REGISTRY.fetch(value["type"], Node)
|
|
33
|
+
klass.new(value)
|
|
34
|
+
else
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
when Array
|
|
38
|
+
value.map { |element| wrap(element) }
|
|
39
|
+
else
|
|
40
|
+
value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(raw)
|
|
45
|
+
@raw = raw
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def type
|
|
49
|
+
@raw["type"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def loc
|
|
53
|
+
@raw["loc"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def range
|
|
57
|
+
@raw["range"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start_pos
|
|
61
|
+
@raw["start"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def end_pos
|
|
65
|
+
@raw["end"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Field access. Accepts snake_case symbols/strings (translated to
|
|
69
|
+
# camelCase) and camelCase strings (used verbatim).
|
|
70
|
+
def [](key)
|
|
71
|
+
raw_key = key.to_s
|
|
72
|
+
return Node.wrap(@raw[raw_key]) if @raw.key?(raw_key)
|
|
73
|
+
|
|
74
|
+
camel_key = Inflector.camelize(raw_key)
|
|
75
|
+
Node.wrap(@raw[camel_key])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def dig(*keys)
|
|
79
|
+
keys.reduce(self) do |current, key|
|
|
80
|
+
break nil if current.nil?
|
|
81
|
+
|
|
82
|
+
current[key]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def each_child(&block)
|
|
87
|
+
return enum_for(:each_child) unless block
|
|
88
|
+
|
|
89
|
+
@raw.each_value { |value| yield_descendant_nodes(value, &block) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def children
|
|
93
|
+
each_child.to_a
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def walk(&block)
|
|
97
|
+
return enum_for(:walk) unless block
|
|
98
|
+
|
|
99
|
+
yield self
|
|
100
|
+
each_child { |child| child.walk(&block) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Pattern-matching support. Returns a hash with snake_case symbol
|
|
104
|
+
# keys; values are wrapped (Node instances or arrays of Node/raw values).
|
|
105
|
+
def deconstruct_keys(keys)
|
|
106
|
+
if keys.nil?
|
|
107
|
+
@raw.each_with_object({}) do |(k, v), out|
|
|
108
|
+
out[Inflector.underscore(k).to_sym] = Node.wrap(v)
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
keys.each_with_object({}) do |key, out|
|
|
112
|
+
camel_key = Inflector.camelize(key.to_s)
|
|
113
|
+
actual_key = @raw.key?(key.to_s) ? key.to_s : camel_key
|
|
114
|
+
out[key] = Node.wrap(@raw[actual_key]) if @raw.key?(actual_key)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ==(other)
|
|
120
|
+
other.is_a?(Node) && other.raw == @raw
|
|
121
|
+
end
|
|
122
|
+
alias eql? ==
|
|
123
|
+
|
|
124
|
+
def hash
|
|
125
|
+
@raw.hash
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def inspect
|
|
129
|
+
"#<#{self.class.name || "JsxRosetta::AST::Node"} type=#{type.inspect} loc=#{loc_summary}>"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def yield_descendant_nodes(value, &block)
|
|
135
|
+
case value
|
|
136
|
+
when Hash
|
|
137
|
+
yield Node.wrap(value) if value.key?("type")
|
|
138
|
+
when Array
|
|
139
|
+
value.each { |element| yield_descendant_nodes(element, &block) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def loc_summary
|
|
144
|
+
return "?" unless loc
|
|
145
|
+
|
|
146
|
+
start_loc = loc["start"] || {}
|
|
147
|
+
"#{start_loc["line"]}:#{start_loc["column"]}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|