Class: JsxRosetta::IR::Lowering

Inherits:
Object
  • Object
show all
Defined in:
lib/jsx_rosetta/ir/lowering.rb

Overview

Lowers a parsed AST::File into an IR::Component tree.

Responsibilities:

- Component discovery — find function/arrow declarations whose
  name and body shape qualify as a function component.
- Module-shape classification — when no component is found,
  produce a triage-friendly error message via SHAPE_MESSAGES.
- Function-body lowering — turn return-bearing block statements,
  if-chains, switch/try statements, and bare expression returns
  into IR values (Conditional, Interpolation, Text, etc.).
- JSX-node lowering — turn JSXElement / JSXFragment / JSXText /
  JSXExpressionContainer trees into IR::Element / Fragment /
  ComponentInvocation / Conditional / Loop / etc.
- Pattern recognition — `cn()` / `clsx()` className helpers,
  `items.map(...)` loops, `cond ? <A/> : <B/>` polymorphic tags,
  React-hook calls, and onX={...} handlers promotable to
  Stimulus methods.

Anything outside these patterns is preserved verbatim as a TODO so the human reviewer sees the original JS at the right spot.

Defined Under Namespace

Classes: LoweringError

Constant Summary collapse

REACT_HOOKS =
%w[
  useState useEffect useRef useContext useMemo useCallback
  useReducer useImperativeHandle useLayoutEffect useDebugValue
].freeze
APOLLO_HOOKS =

Apollo Client hooks. ‘useQuery` / `useLazyQuery` / `useSubscription` take a GraphQL document as the first argument; `useMutation` returns a `[mutate, { loading, … }]` tuple. None of these have a direct translation — they encode data fetching, which in Rails lives in the controller/model. Captured here so the backend can emit a per-hook TODO with the operation name preserved when extractable.

%w[
  useQuery useLazyQuery useMutation useSubscription useApolloClient
].freeze
NEXT_HOOKS =

Next.js navigation hooks (App Router and Pages Router). Each has a Rails-side analog:

useRouter        → controller actions / redirect_to
usePathname      → request.path
useSearchParams  → params
useParams        → params (route params)
useSelectedLayoutSegment(s) → not directly translatable; usually
  used to highlight nav links — the Rails view can pattern-match
  against request.path.
%w[
  useRouter usePathname useSearchParams useParams
  useSelectedLayoutSegment useSelectedLayoutSegments
].freeze
FRAMEWORK_HOOKS_BY_LIBRARY =
{
  react: REACT_HOOKS,
  apollo: APOLLO_HOOKS,
  next_js: NEXT_HOOKS
}.freeze
JSX_NODE_TYPES =
%w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
UNITLESS_CSS_PROPERTIES =

Mirrors React’s ‘isUnitlessNumber` table — CSS properties that take a bare number rather than a length. Numeric style values for any property NOT in this set get a `px` suffix appended at lowering time.

%w[
  animation-iteration-count aspect-ratio border-image-outset
  border-image-slice border-image-width box-flex box-flex-group
  box-ordinal-group column-count columns flex flex-grow flex-negative
  flex-order flex-positive flex-shrink font-weight grid-area
  grid-column grid-column-end grid-column-span grid-column-start
  grid-row grid-row-end grid-row-span grid-row-start line-clamp
  line-height opacity order orphans scale tab-size widows z-index
  zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
  stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
].to_set.freeze
JSX_RETURN_PROBES =

Pre-lowering AST scan: maps a node type to a callable returning the AST nodes that contribute return values. Used by body_returns_jsx?.

{
  "ReturnStatement" => ->(n) { [n[:argument]] },
  "BlockStatement" => ->(n) { n[:body] },
  "IfStatement" => ->(n) { [n[:consequent], n[:alternate]] },
  "TryStatement" => ->(n) { [n[:block]] },
  "ConditionalExpression" => ->(n) { [n[:consequent], n[:alternate]] },
  "LogicalExpression" => ->(n) { [n[:left], n[:right]] }
}.freeze
SHAPE_MESSAGES =
{
  hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
               "this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
  class_component: "looks like a class component — this version translates only function components " \
                   "(rewrite as a function or wait for class-component support)",
  hooks_only: "looks like a custom-hooks module — hooks encode behavior and state, not view markup; " \
              "translate behavior to a Stimulus controller and state to server-rendered ivars",
  columns_data: "looks like a data export (top-level array literal) — not a component; " \
                "data lives in the model or a presenter, not a ViewComponent",
  types_only: "looks like a types/constants module — no functions to translate; " \
              "TypeScript types erase, and Ruby constants belong elsewhere",
  side_effects_only: "looks like a side-effect-only module (top-level calls, no exported functions) — " \
                     "register the equivalent setup in a Rails initializer instead",
  utils_only: "looks like a utility module — only function components and JSX-returning helpers translate; " \
              "pure-data helpers don't have a ViewComponent equivalent",
  mixed_exports: "module mixes shapes (utilities + hooks + types + non-JSX helpers) — " \
                 "split into separate files so each module has a single shape",
  unknown: nil
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source) ⇒ Lowering

Returns a new instance of Lowering.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/jsx_rosetta/ir/lowering.rb', line 149

def initialize(source)
  @source = source
  @prop_names = []
  @local_jsx = {}
  @local_bindings = []
  @local_binding_names = []
  @local_arrows = {}
  @local_polymorphic_tags = {}
  @local_destructures = {}
  @stimulus_methods = []
  @stimulus_seen_names = {}
  @react_hooks = []
  @render_methods = []
  @render_method_seen = {}
  # Class-component non-render members (constructor, lifecycle hooks,
  # custom handlers). Keyed by class name; populated by
  # extract_class_component, drained by lower_component to surface
  # the verbatim sources as a TODO comment block.
  @pending_class_other_members = {}
end

Class Method Details

.lower(file, source:) ⇒ Object



58
59
60
# File 'lib/jsx_rosetta/ir/lowering.rb', line 58

def self.lower(file, source:)
  new(source).lower_file(file)
end

.lower_all(file, source:) ⇒ Object



62
63
64
# File 'lib/jsx_rosetta/ir/lowering.rb', line 62

def self.lower_all(file, source:)
  new(source).lower_all_components(file)
end

Instance Method Details

#attach_module_bindings(component, module_bindings) ⇒ Object



231
232
233
234
235
# File 'lib/jsx_rosetta/ir/lowering.rb', line 231

def attach_module_bindings(component, module_bindings)
  return component if module_bindings.empty?

  component.with(module_bindings: module_bindings)
end

#capture_module_bindings(program, candidates) ⇒ Object

Walk the program body for top-level ‘const`/`let` declarations that aren’t component declarations. Capture each as a LocalBinding so backends can emit them as Ruby constants (or as a TODO comment for non-literal initializers) before the class definition. Without this, ‘const FOO = 400; function X() { return <p>FOO</p> }` would silently drop the FOO declaration and emit an unbacked `foo` reference inside the view template.



196
197
198
199
200
201
202
203
# File 'lib/jsx_rosetta/ir/lowering.rb', line 196

def capture_module_bindings(program, candidates)
  component_names = candidates.to_set(&:first)
  bindings = []
  program.body.each do |stmt|
    walk_module_binding(stmt, component_names, bindings)
  end
  bindings
end

#lower_all_components(file) ⇒ Object



179
180
181
182
183
184
185
186
187
# File 'lib/jsx_rosetta/ir/lowering.rb', line 179

def lower_all_components(file)
  candidates = find_component_functions(file.program)
  raise no_component_error(file.program) if candidates.empty?

  module_bindings = capture_module_bindings(file.program, candidates)
  candidates.map do |name, function|
    attach_module_bindings(lower_component(name, function), module_bindings)
  end
end

#lower_file(file) ⇒ Object



170
171
172
173
174
175
176
177
# File 'lib/jsx_rosetta/ir/lowering.rb', line 170

def lower_file(file)
  candidates = find_component_functions(file.program)
  raise no_component_error(file.program) if candidates.empty?

  name, function = candidates.first
  module_bindings = capture_module_bindings(file.program, candidates)
  attach_module_bindings(lower_component(name, function), module_bindings)
end

#record_module_binding(stmt, declarator, component_names, bindings) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/jsx_rosetta/ir/lowering.rb', line 215

def record_module_binding(stmt, declarator, component_names, bindings)
  init = declarator[:init]
  return unless init.is_a?(AST::Node)

  # Component declarators (`const Foo = () => ...`) are handled by
  # the component pipeline; skip them here so the source doesn't
  # show up twice.
  return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
            component_names.include?(declarator[:id]&.[](:name))

  name = declarator[:id]&.[](:name)
  return unless name

  bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
end

#walk_module_binding(stmt, component_names, bindings) ⇒ Object



205
206
207
208
209
210
211
212
213
# File 'lib/jsx_rosetta/ir/lowering.rb', line 205

def walk_module_binding(stmt, component_names, bindings)
  case stmt.type
  when "VariableDeclaration"
    stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
  when "ExportNamedDeclaration"
    decl = stmt[:declaration]
    walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
  end
end