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
JSX_NODE_TYPES =
%w[JSXElement JSXFragment JSXText JSXExpressionContainer].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.



103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/jsx_rosetta/ir/lowering.rb', line 103

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 = []
end

Class Method Details

.lower(file, source:) ⇒ Object



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

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

.lower_all(file, source:) ⇒ Object



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

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

Instance Method Details

#attach_module_bindings(component, module_bindings) ⇒ Object



178
179
180
181
182
# File 'lib/jsx_rosetta/ir/lowering.rb', line 178

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.



143
144
145
146
147
148
149
150
# File 'lib/jsx_rosetta/ir/lowering.rb', line 143

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



126
127
128
129
130
131
132
133
134
# File 'lib/jsx_rosetta/ir/lowering.rb', line 126

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



117
118
119
120
121
122
123
124
# File 'lib/jsx_rosetta/ir/lowering.rb', line 117

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



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/jsx_rosetta/ir/lowering.rb', line 162

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



152
153
154
155
156
157
158
159
160
# File 'lib/jsx_rosetta/ir/lowering.rb', line 152

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