Custom Filters
Filters are the heart of Ruby2JS’s extensibility. They transform the Abstract Syntax Tree (AST) before it’s converted to JavaScript, allowing you to customize how Ruby constructs are translated. This guide explains how to write your own filters.
Table of Contents
- Filter Basics
- Understanding the AST
- Helper Methods
- Writing Filter Methods
- Example: Simple Method Renaming
- Example: Transforming Blocks
- Using Your Filter
- Controlling Method Processing
- Debugging Tips
- Real-World Examples
Filter Basics
A filter is a Ruby module that:
- Lives in the
Ruby2JS::Filternamespace - Includes the
SEXPhelper module - Defines
on_*methods to transform specific AST node types - Optionally registers itself in
DEFAULTSto be included automatically
Here’s the minimal structure:
require 'ruby2js'
module Ruby2JS
module Filter
module MyFilter
include SEXP
def on_send(node)
# Transform :send nodes (method calls)
# Always call super first to let other filters process the node
node = super
# Your transformation logic here
node
end
end
# Optional: auto-include this filter
# DEFAULTS.push MyFilter
end
end
Understanding the AST
Ruby2JS uses the Parser gem to parse Ruby code into an AST. Each node has a type (a symbol) and children (an array of values or other nodes). For a comprehensive reference of all AST node types, see the AST Format documentation.
You can inspect the AST for any Ruby code:
require 'parser/current'
ast = Parser::CurrentRuby.parse('puts "hello"')
puts ast.inspect
# => s(:send, nil, :puts, s(:str, "hello"))
Common node types:
| Node Type | Example Ruby | AST Structure |
|---|---|---|
:send |
foo.bar(x) |
s(:send, receiver, :method, args...) |
:lvar |
x |
s(:lvar, :x) |
:lvasgn |
x = 1 |
s(:lvasgn, :x, value) |
:def |
def foo; end |
s(:def, :foo, args, body) |
:defs |
def self.foo; end |
s(:defs, target, :foo, args, body) |
:class |
class Foo; end |
s(:class, name, parent, body) |
:if |
if x; y; end |
s(:if, cond, then, else) |
:block |
x { \|a\| b } |
s(:block, call, args, body) |
:int |
42 |
s(:int, 42) |
:str |
"hello" |
s(:str, "hello") |
:sym |
:foo |
s(:sym, :foo) |
:array |
[1, 2] |
s(:array, elements...) |
:hash |
{a: 1} |
s(:hash, pairs...) |
:ivar |
@foo |
s(:ivar, :@foo) |
:const |
Foo |
s(:const, nil, :Foo) |
Helper Methods
The SEXP module provides two essential helpers:
s(type, *children) - Create a new node
Creates a brand new AST node:
s(:str, "hello") # => s(:str, "hello")
s(:send, nil, :puts, arg) # => s(:send, nil, :puts, arg)
S(type, *children) - Update the current node
Creates a node that preserves source location info from @ast:
S(:send, nil, :console_log, arg)
Use S() when replacing the current node to maintain source maps.
node.updated(type, children) - Update a specific node
Updates an existing node with new type and/or children:
node.updated(nil, [receiver, :new_method, *args]) # change children only
node.updated(:csend, node.children) # change type only
process(node) - Recursively process a node
Runs a node through all filters:
def on_send(node)
node = super
# Create a new node and process it
new_node = s(:send, nil, :something)
process(new_node)
end
Writing Filter Methods
The on_* Pattern
For each AST node type you want to transform, define an on_<type> method:
def on_send(node) # called for :send nodes (method calls)
def on_def(node) # called for :def nodes (method definitions)
def on_block(node) # called for :block nodes (blocks)
def on_class(node) # called for :class nodes (class definitions)
def on_lvar(node) # called for :lvar nodes (local variables)
Always Call super First
This ensures other filters get a chance to process the node:
def on_send(node)
node = super # Let other filters process first
# Your logic here
node
end
Extracting Node Children
Use destructuring to extract children:
def on_send(node)
node = super
receiver, method, *args = node.children
# receiver: the object (nil for bare method calls)
# method: the method name (a Symbol)
# args: array of argument nodes
node
end
Returning Nodes
Always return a node from your on_* method:
- Return the original
nodeif no transformation is needed - Return a new node created with
s(),S(), ornode.updated()
Example: Simple Method Renaming
This filter renames log calls to console.log:
module Ruby2JS
module Filter
module MyLogger
include SEXP
def on_send(node)
node = super
return node unless node.type == :send
receiver, method, *args = node.children
# Transform: log("msg") => console.log("msg")
if receiver.nil? && method == :log
S(:send, s(:lvar, :console), :log, *args)
else
node
end
end
end
end
end
Example: Transforming Blocks
This filter transforms 3.times { ... } to a for loop:
module Ruby2JS
module Filter
module TimesLoop
include SEXP
def on_block(node)
node = super
return node unless node.type == :block
call, args, body = node.children
return node unless call.type == :send
receiver, method = call.children
if method == :times && receiver&.type == :int
count = receiver.children.first
var = args.children.first&.children&.first || :i
# Create: for (var i = 0; i < count; i++) { body }
s(:for,
s(:lvasgn, var),
s(:erange, s(:int, 0), s(:int, count)),
body
)
else
node
end
end
end
end
end
Using Your Filter
Pass to convert directly
require 'ruby2js'
require_relative 'my_filter'
js = Ruby2JS.convert('log "hello"', filters: [Ruby2JS::Filter::MyLogger])
Add to DEFAULTS
module Ruby2JS
module Filter
module MyLogger
include SEXP
# ... filter code ...
end
DEFAULTS.push MyLogger
end
end
Combine with other filters
Ruby2JS.convert(code, filters: [
Ruby2JS::Filter::Functions,
Ruby2JS::Filter::MyLogger
])
Controlling Method Processing
Filters can opt-in or opt-out of processing specific methods:
Check if a method is excluded
def on_send(node)
node = super
receiver, method, *args = node.children
# Skip if this method was excluded by user configuration
return node if excluded?(method)
# Your transformation
end
Skip certain methods in your filter
SKIP_METHODS = [:initialize, :constructor]
def on_def(node)
node = super
return node if SKIP_METHODS.include?(node.children.first)
# Transform other methods
end
Debugging Tips
Inspect the AST
require 'parser/current'
code = 'your_ruby_code_here'
ast = Parser::CurrentRuby.parse(code)
puts ast.inspect
Add logging to your filter
def on_send(node)
node = super
puts "Processing: #{node.inspect}"
# ... rest of filter
end
Test incrementally
# Test your filter in isolation
require 'ruby2js'
require_relative 'my_filter'
test_cases = [
'log "hello"',
'x.log "test"',
'other_method'
]
test_cases.each do |code|
puts "Input: #{code}"
puts "Output: #{Ruby2JS.convert(code, filters: [Ruby2JS::Filter::MyLogger])}"
puts
end
Real-World Examples
For more complex examples, explore the built-in filters in the Ruby2JS source:
- functions.rb - Comprehensive method transformations
- camelCase.rb - Identifier renaming
- return.rb - Simple AST wrapping
- esm.rb - Module system transformations