Class: JsxRosetta::IR::Lowering
- Inherits:
-
Object
- Object
- JsxRosetta::IR::Lowering
- 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
- #attach_module_bindings(component, module_bindings) ⇒ Object
-
#capture_module_bindings(program, candidates) ⇒ Object
Walk the program body for top-level ‘const`/`let` declarations that aren’t component declarations.
-
#initialize(source) ⇒ Lowering
constructor
A new instance of Lowering.
- #lower_all_components(file) ⇒ Object
- #lower_file(file) ⇒ Object
- #record_module_binding(stmt, declarator, component_names, bindings) ⇒ Object
- #walk_module_binding(stmt, component_names, bindings) ⇒ Object
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 |