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
HOC_WRAPPER_NAMES =

Known wrapper-call names that lowering peers through to find the inner component definition. Both bare (‘memo(…)`) and React- namespaced (`React.memo(…)`) forms count. Wrappers we don’t unwrap (e.g. ‘React.lazy` — different shape, no inline function body) stay off this list on purpose; declarations using them won’t be recognized as components, same as pre-unwrap behavior.

%w[memo forwardRef observer connect withRouter withTranslation].to_set.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
SERVER_DATA_HOOK_NAMES =
%w[getServerSideProps getStaticProps].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source, keep_slot: false) ⇒ Lowering

Returns a new instance of Lowering.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/jsx_rosetta/ir/lowering.rb', line 157

def initialize(source, keep_slot: false)
  @source = source
  # When false (default), the shadcn `<Comp asChild>` pattern that
  # routes through Radix's Slot.Root gets its Slot branch dropped at
  # lowering time, leaving only the non-Slot HTML/component branch.
  # When true, preserve the full polymorphic conditional (legacy
  # behavior; useful if the consumer shims Components::Slot::Root).
  @keep_slot = keep_slot
  @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 = {}
  # File-level imports; populated once at lower_file / lower_all_components
  # entry and consulted by JSX lowering to decide whether a member-chain
  # tag like `SeparatorPrimitive.Root` should resolve through the Radix
  # registry into an HTML Element.
  @module_imports = []
  # 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:, keep_slot: false) ⇒ Object



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

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

.lower_all(file, source:, keep_slot: false) ⇒ Object



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

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

Instance Method Details

#attach_module_metadata(component, module_bindings, module_imports, server_data_source = nil) ⇒ Object



528
529
530
531
532
533
534
# File 'lib/jsx_rosetta/ir/lowering.rb', line 528

def (component, module_bindings, module_imports, server_data_source = nil)
  component.with(
    module_bindings: module_bindings,
    module_imports: module_imports,
    server_data_source: server_data_source
  )
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.



261
262
263
264
265
266
267
268
# File 'lib/jsx_rosetta/ir/lowering.rb', line 261

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

#capture_module_imports(program) ⇒ Object

Capture every top-level ‘import` declaration so the translator can recognize use-site references at expression-context. Without this, an import like `import styles from “./X.module.css”` lets every `styles.listContainer` use snake-case to a bare `styles` reference that NameErrors at render time.



541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/jsx_rosetta/ir/lowering.rb', line 541

def capture_module_imports(program)
  imports = []
  program.body.each do |stmt|
    next unless stmt.is_a?(AST::Node) && stmt.type == "ImportDeclaration"

    source = stmt[:source]&.[](:value).to_s
    (stmt[:specifiers] || []).each do |spec|
      name = spec[:local]&.[](:name)
      next unless name

      imports << ModuleImport.new(
        name: name,
        source: source,
        kind: import_specifier_kind(spec),
        imported_name: spec[:imported]&.[](:name)
      )
    end
  end
  imports
end

#capture_server_data_source(program) ⇒ Object

Capture a top-level ‘export function getServerSideProps()` or `export const getServerSideProps = …` (or getStaticProps) so the Phlex backend can surface the body as a TODO comment block. Returns nil when no such export is present (the common case for ordinary components — only Next.js pages have these).



225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/jsx_rosetta/ir/lowering.rb', line 225

def capture_server_data_source(program)
  program.body.each do |stmt|
    next unless stmt.of_type?("ExportNamedDeclaration")

    decl = stmt[:declaration]
    next unless decl.is_a?(AST::Node)

    name = server_data_hook_name(decl)
    return ServerDataSource.new(hook_name: name, source: source_of(stmt)) if name
  end
  nil
end

#cva_call?(node) ⇒ Boolean

Returns:

  • (Boolean)


448
449
450
451
452
453
# File 'lib/jsx_rosetta/ir/lowering.rb', line 448

def cva_call?(node)
  return false unless node.is_a?(AST::Node) && node.type == "CallExpression"

  callee = node[:callee]
  callee.is_a?(AST::Node) && callee.type == "Identifier" && callee[:name] == "cva"
end

#extract_cva_axis_options(axis_value_node) ⇒ Object



482
483
484
485
486
487
488
489
490
# File 'lib/jsx_rosetta/ir/lowering.rb', line 482

def extract_cva_axis_options(axis_value_node)
  return {} unless object_expression?(axis_value_node)

  axis_value_node[:properties].each_with_object({}) do |opt, hash|
    opt_name = property_key(opt)
    opt_value = extract_cva_string(opt[:value])
    hash[opt_name] = opt_value if opt_name && opt_value
  end
end

#extract_cva_compound_source(options_node) ⇒ Object



507
508
509
510
511
512
# File 'lib/jsx_rosetta/ir/lowering.rb', line 507

def extract_cva_compound_source(options_node)
  prop = find_object_property(options_node, "compoundVariants")
  return nil unless prop

  source_of(prop[:value]).strip
end

#extract_cva_default_variants(options_node) ⇒ Object



496
497
498
499
500
501
502
503
504
505
# File 'lib/jsx_rosetta/ir/lowering.rb', line 496

def extract_cva_default_variants(options_node)
  prop = find_object_property(options_node, "defaultVariants")
  return {} unless prop && prop[:value].is_a?(AST::Node) && prop[:value].type == "ObjectExpression"

  prop[:value][:properties].each_with_object({}) do |p, hash|
    key = property_key(p)
    val = extract_cva_string(p[:value])
    hash[key] = val if key && val
  end
end

#extract_cva_string(node) ⇒ Object



455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/jsx_rosetta/ir/lowering.rb', line 455

def extract_cva_string(node)
  return nil unless node.is_a?(AST::Node)

  case node.type
  when "StringLiteral"
    node[:value]
  when "TemplateLiteral"
    # Only handle templates with no interpolations — they're effectively
    # a string literal (shadcn's cva bases sometimes use a template for
    # multi-line readability).
    return nil unless (node[:expressions] || []).empty?

    (node[:quasis] || []).map { |q| q[:value][:cooked] }.join
  end
end

#extract_cva_variants(options_node) ⇒ Object



471
472
473
474
475
476
477
478
479
480
# File 'lib/jsx_rosetta/ir/lowering.rb', line 471

def extract_cva_variants(options_node)
  prop = find_object_property(options_node, "variants")
  return {} unless object_expression?(prop&.[](:value))

  prop[:value][:properties].each_with_object({}) do |axis, hash|
    axis_name = property_key(axis)
    options = extract_cva_axis_options(axis[:value])
    hash[axis_name] = options if axis_name && !options.empty?
  end
end

#find_object_property(obj_node, name) ⇒ Object



514
515
516
# File 'lib/jsx_rosetta/ir/lowering.rb', line 514

def find_object_property(obj_node, name)
  (obj_node[:properties] || []).find { |p| property_key(p) == name }
end

#import_specifier_kind(spec) ⇒ Object



562
563
564
565
566
567
568
# File 'lib/jsx_rosetta/ir/lowering.rb', line 562

def import_specifier_kind(spec)
  case spec.type
  when "ImportDefaultSpecifier" then :default
  when "ImportNamespaceSpecifier" then :namespace
  else :named
  end
end

#literal_value(node) ⇒ Object

Returns the Ruby-literal-friendly value for ‘node`, or the sentinel `:not_literal` when the node isn’t translatable. Sentinel rather than ‘nil` so a JS `null` (which legitimately maps to Ruby `nil`) is distinguishable from “couldn’t parse this.”



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/jsx_rosetta/ir/lowering.rb', line 352

def literal_value(node)
  return :__not_literal__ unless node.is_a?(AST::Node)

  case node.type
  when "StringLiteral", "NumericLiteral", "BooleanLiteral" then node[:value]
  when "NullLiteral" then nil
  when "TemplateLiteral" then literal_value_from_template(node)
  when "ArrayExpression" then literal_value_from_array(node)
  when "ObjectExpression" then literal_value_from_object(node)
  when "UnaryExpression" then literal_value_from_unary(node)
  when "TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion"
    literal_value(node[:expression])
  else :__not_literal__
  end
end

#literal_value_from_array(node) ⇒ Object



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'lib/jsx_rosetta/ir/lowering.rb', line 377

def literal_value_from_array(node)
  elements = node[:elements] || []
  result = []
  elements.each do |elem|
    # Holes in array literals (`[1, , 3]`) come through as nil; map to
    # Ruby `nil` to preserve length. Spread elements bail — we can't
    # statically expand the spread target.
    if elem.nil?
      result << nil
      next
    end
    return :__not_literal__ if elem.is_a?(AST::Node) && elem.type == "SpreadElement"

    value = literal_value(elem)
    return :__not_literal__ if value == :__not_literal__

    result << value
  end
  result
end

#literal_value_from_object(node) ⇒ Object



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/jsx_rosetta/ir/lowering.rb', line 398

def literal_value_from_object(node)
  properties = node[:properties] || []
  result = {}
  properties.each do |prop|
    return :__not_literal__ unless prop.is_a?(AST::Node) && prop.type == "ObjectProperty"
    return :__not_literal__ if prop[:computed]
    return :__not_literal__ if prop[:shorthand] && prop[:value].is_a?(AST::Node) &&
                               prop[:value].type == "Identifier"

    key = property_key(prop)
    return :__not_literal__ unless key.is_a?(String)

    value = literal_value(prop[:value])
    return :__not_literal__ if value == :__not_literal__

    result[key] = value
  end
  result
end

#literal_value_from_template(node) ⇒ Object



368
369
370
371
372
373
374
375
# File 'lib/jsx_rosetta/ir/lowering.rb', line 368

def literal_value_from_template(node)
  return :__not_literal__ unless (node[:expressions] || []).empty?

  # `quasi[:value]` is a plain Hash with String keys ("cooked" / "raw"),
  # not an AST::Node — Babel's AST wraps Hashes only when they carry a
  # "type" field. Use the String key directly.
  (node[:quasis] || []).map { |q| q[:value]["cooked"] }.join
end

#literal_value_from_unary(node) ⇒ Object



418
419
420
421
422
423
424
425
# File 'lib/jsx_rosetta/ir/lowering.rb', line 418

def literal_value_from_unary(node)
  return :__not_literal__ unless %w[- +].include?(node[:operator])

  inner = literal_value(node[:argument])
  return :__not_literal__ unless inner.is_a?(Numeric)

  node[:operator] == "-" ? -inner : inner
end

#lower_all_components(file) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/jsx_rosetta/ir/lowering.rb', line 201

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)
  @module_imports = capture_module_imports(file.program)
  @server_data_source = capture_server_data_source(file.program)
  candidates.each_with_index.map do |(name, function), idx|
    # Only the first sibling carries the server_data_source — a page
    # file has at most one such export, and attaching it to every
    # sibling would duplicate the TODO block across N files.
    sds = idx.zero? ? @server_data_source : nil
    (lower_component(name, function),
                           @module_bindings, @module_imports, sds)
  end
end

#lower_file(file) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
# File 'lib/jsx_rosetta/ir/lowering.rb', line 189

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)
  @module_imports = capture_module_imports(file.program)
  @server_data_source = capture_server_data_source(file.program)
  (lower_component(name, function),
                         @module_bindings, @module_imports, @server_data_source)
end

#object_expression?(node) ⇒ Boolean

Returns:

  • (Boolean)


492
493
494
# File 'lib/jsx_rosetta/ir/lowering.rb', line 492

def object_expression?(node)
  node.is_a?(AST::Node) && node.type == "ObjectExpression"
end

#parse_cva_binding(init, name) ⇒ Object

Returns a CvaBinding when ‘init` is a `cva(base, options)` call we know how to parse, or nil to fall through to LocalBinding.



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/jsx_rosetta/ir/lowering.rb', line 429

def parse_cva_binding(init, name)
  return nil unless cva_call?(init)

  args = init[:arguments] || []
  base_class = extract_cva_string(args[0])
  return nil unless base_class

  options = args[1]
  return nil unless options.is_a?(AST::Node) && options.type == "ObjectExpression"

  CvaBinding.new(
    name: name,
    base_class: base_class,
    variants: extract_cva_variants(options),
    default_variants: extract_cva_default_variants(options),
    compound_source: extract_cva_compound_source(options)
  )
end

#parse_module_constant(init, name) ⇒ Object

Returns an IR::ModuleConstant when ‘init` reduces to a Ruby-literal- friendly value, or nil otherwise. Anything that depends on runtime state (call expressions, identifier references, function expressions, JSX) bails out so the existing LocalBinding TODO path still surfaces the original JS source.



337
338
339
340
341
342
343
344
345
346
# File 'lib/jsx_rosetta/ir/lowering.rb', line 337

def parse_module_constant(init, name)
  value = literal_value(init)
  return nil if value == :__not_literal__

  ModuleConstant.new(
    name: name,
    constant_name: AST::Inflector.underscore(name).upcase,
    value: value
  )
end

#property_key(prop) ⇒ Object



518
519
520
521
522
523
524
525
526
# File 'lib/jsx_rosetta/ir/lowering.rb', line 518

def property_key(prop)
  return nil unless prop.is_a?(AST::Node) && prop[:key].is_a?(AST::Node)

  key = prop[:key]
  case key.type
  when "Identifier" then key[:name]
  when "StringLiteral" then key[:value]
  end
end

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



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/jsx_rosetta/ir/lowering.rb', line 297

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

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

  # Component declarators (`const Foo = () => ...` and the
  # HOC-wrapped `const Foo = memo(() => ...)` form) are handled by
  # the component pipeline; skip them here so the source doesn't
  # surface twice (once as a TODO, once as the class).
  return if component_names.include?(name)

  # shadcn-style `const fooVariants = cva(base, { variants, ... })` gets
  # recognized at lowering and stored as a CvaBinding — the backend
  # turns it into real Ruby constants and the use-site call collapses
  # to a string interpolation. Falls through to the generic LocalBinding
  # path when the cva shape doesn't match exactly.
  if (cva = parse_cva_binding(init, name))
    bindings << cva
    return
  end

  # Literal-shaped `const FOO = "x"` / `const COLUMNS = [...]` /
  # `const TAGS = {...}` lowers to a real Ruby constant emitted above
  # the class. Anything richer (call expressions, identifier refs,
  # JSX) bails to the LocalBinding TODO-block fallback below.
  if (constant = parse_module_constant(init, name))
    bindings << constant
    return
  end

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

#record_module_function_binding(stmt, component_names, bindings) ⇒ Object

Top-level ‘function onError(){}` helpers — non-component, non-hook functions declared in the same file as the component. Without capture, a `<Button onClick=onError>` use site translates to `on_click: on_error` which NameErrors at render time because nothing binds `on_error`. Recording the name here threads it into the translator’s bailout set so the reference becomes ‘on_click: nil` plus a visible TODO.



289
290
291
292
293
294
295
# File 'lib/jsx_rosetta/ir/lowering.rb', line 289

def record_module_function_binding(stmt, component_names, bindings)
  name = stmt[:id]&.[](:name)
  return unless name
  return if component_names.include?(name)

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

#server_data_hook_name(decl) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/jsx_rosetta/ir/lowering.rb', line 238

def server_data_hook_name(decl)
  case decl.type
  when "FunctionDeclaration"
    id = decl[:id]
    id&.[](:name) if id.is_a?(AST::Node) && SERVER_DATA_HOOK_NAMES.include?(id[:name])
  when "VariableDeclaration"
    first = decl[:declarations].first
    return nil unless first.is_a?(AST::Node)

    id = first[:id]
    return nil unless id.is_a?(AST::Node) && id.of_type?("Identifier")

    id[:name] if SERVER_DATA_HOOK_NAMES.include?(id[:name])
  end
end

#walk_module_binding(stmt, component_names, bindings) ⇒ Object



270
271
272
273
274
275
276
277
278
279
280
# File 'lib/jsx_rosetta/ir/lowering.rb', line 270

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 "FunctionDeclaration"
    record_module_function_binding(stmt, component_names, bindings)
  when "ExportNamedDeclaration", "ExportDefaultDeclaration"
    decl = stmt[:declaration]
    walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
  end
end