intertwingly

It’s just data

Ruby2JS Gets Prism Support - Enabling Shared Ruby/JavaScript Logic


Ruby2JS now supports Ruby 3.3's Prism parser, opening the door to eliminating duplicate business logic between Ruby servers and JavaScript clients.

Pull request: ruby2js/ruby2js#229


Why This Matters

My recent work on offline-first scoring revealed a fundamental problem: business logic lives in two places.

Server (Ruby): Controllers compute derived values, expand scoring categories, determine display formats.

Client (JavaScript): Hydrators duplicate this logic to reconstruct the same data structures from normalized JSON.

When logic changes, both implementations must be updated in lockstep. This creates maintenance burden and risk of drift. The JSON contract between server and client is implicit—defined only by code on each side.


The Vision: Single Source of Truth

What if model annotations declared serialization contracts, and Ruby2JS transpiled shared logic to JavaScript?

# app/models/heat.rb
class Heat < ApplicationRecord
  spa_serialize do
    attributes :id, :number, :category, :ballroom

    # Pre-computed on server, sent as value
    computed :dance_string, precompute: true

    # Shared logic: block converted to JS via Ruby2JS
    computed :display_category do |heat|
      heat.pro ? 'Professional' : "#{heat.level.initials} - #{heat.age.category}"
    end

    references :dance, :entry, :solo
  end
end

Ruby2JS converts the block to JavaScript:

displayCategory(heat) {
  return heat.pro ? 'Professional' : `${heat.level.initials} - ${heat.age.category}`;
}

Both server and client execute identical logic. No duplication. No drift.


Prism Migration: The Technical Challenge

Ruby2JS was built on the whitequark parser gem. Prism, introduced in Ruby 3.3, produces a different AST structure. Rather than rewrite ~60 AST handlers, I used Prism::Translation::Parser—a compatibility layer that translates Prism's AST into whitequark parser format.

This approach required:

  1. Parser selection logic: Auto-detect Prism if available, fall back to parser gem
  2. Environment override: RUBY2JS_PARSER=prism or RUBY2JS_PARSER=parser
  3. Edge case fixes for subtle differences between parsers

Edge Cases

__FILE__ handling: Prism produces :str nodes with the filename; the parser gem produces :__FILE__ nodes. The node filter now handles both.

Comment association: Filters create new AST nodes that don't exist in the original source. The converter now does location-based lookup to find comments for these synthetic nodes.

Consecutive tilde handling: The jQuery filter uses ~~ for jQuery wrapping. Prism's AST structure required explicit handling of :attr nodes in the recursion.

Class-level comments: The Vue filter generates new class definition nodes. Comments from the original class now propagate to the generated output.

Results

All 1302 tests pass with both parsers. The implementation is fully backwards compatible—existing code continues to work without modification.


Implementation Details

The core changes are in lib/ruby2js.rb:

# Auto-detect: try Prism first, fall back to parser gem
case ENV['RUBY2JS_PARSER']
when 'prism'
  require 'prism'
  require 'parser/current'
  RUBY2JS_PARSER = :prism
when 'parser'
  # ... use parser gem
else
  begin
    require 'prism'
    require 'parser/current'
    RUBY2JS_PARSER = :prism
  rescue LoadError
    # ... fall back to parser gem
  end
end

The Filter::Processor class was refactored from inheriting Parser::AST::Processor to a standalone implementation. This provides explicit control over node traversal and comment handling—both areas where Prism compatibility required adjustments.


What This Enables

With Prism support, Ruby2JS can process blocks defined anywhere in your codebase:

# DSL-captured blocks work
computed :name do |model|
  model.field.upcase
end

# Multi-line complex blocks work
expand_subjects = proc do |subjects, enabled|
  return subjects unless enabled
  subjects.flat_map do |subject|
    # ... complex expansion logic
  end
end

Ruby2JS reads the block's source via source_location, parses it with Prism, and converts to equivalent JavaScript.


The Broader Architecture

This is Stage 1 of a three-stage plan:

Stage 1 (complete): Prism migration with full backwards compatibility

Stage 2 (planned): Annotation DSL + code generator proof of concept

Stage 3 (planned): Full model coverage


Try It

The Prism support is available on the prism-ast branch:

git clone https://github.com/ruby2js/ruby2js
cd ruby2js
git checkout prism-ast
bundle install
bundle exec rake test

Force a specific parser:

RUBY2JS_PARSER=prism bundle exec rake test
RUBY2JS_PARSER=parser bundle exec rake test

Lessons Learned

Translation layers preserve investment. Rather than rewriting handlers for a new AST format, Prism::Translation::Parser let me keep existing code while gaining Prism compatibility.

Edge cases cluster around synthetic nodes. Filters create AST nodes that don't exist in source code. These nodes lack source locations, which breaks assumptions in comment association and location-based lookups.

Test coverage enables confident refactoring. With 1302 passing tests, I could make structural changes knowing regressions would surface immediately.


What's Next

The immediate roadmap for Ruby2JS:

  1. New filters: group_by, sort_by, max_by, min_by transformations
  2. ERB filter: Extract and generalize the converter from my showcase app
  3. Rails helpers filter: dom_id, link_to, path helpers
  4. Online demo update: Consider ruby.wasm or self-hosted options for Prism support in browser

The broader vision: make shared Ruby/JavaScript logic practical and maintainable. Write business logic once in Ruby, let tools generate JavaScript equivalents automatically.

The goal isn't just code generation—it's eliminating an entire class of bugs that arise from maintaining parallel implementations.


Ruby2JS is open source: github.com/ruby2js/ruby2js. The showcase application demonstrating ERB-to-JS conversion is at github.com/rubys/showcase.