Simplifying Rails Block Helpers (With a Side of Rubinius)
We all know that <%= string %>
emits a String in ERB. And <% string %>
runs Ruby code, but does not emit a String. When starting working with Rails, you almost expect the syntax for block helpers to be:
<%= content_tag(:div) do %>
The content
<% end %>
Why doesn't it work that way?
It has to do with how the ERB parser works, looking at each line individually. When it sees <% %>
, it evaluates the code as a line of Ruby. When it sees <%= %>
, it evaluates the inside of the ERB tag, and calls to_s
on it.
This:
<% form_for(@object) do %>
Stuff
<% end %>
gets effectively converted to:
form_for(@object) do
_buf << ("Stuff").to_s
end
On the other hand, this:
<%= form_for(@object) do %>
Stuff
<% end %>
gets converted to:
_buf << (form_for(@object) do).to_s
_buf << ("Stuff").to_s
end
which isn't valid Ruby. So we use the first approach, and then let the helper itself, rather than ERB, be responsible for concatenating to the buffer. Sadly, it leads to significantly more complex helpers.
Let's take a look at the implementation of content_tag
.
def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)
if block_given?
options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash)
content_tag = content_tag_string(name, capture(&block), options, escape)
if block_called_from_erb?(block)
concat(content_tag)
else
content_tag
end
else
content_tag_string(name, content_or_options_with_block, options, escape)
end
end
The important chunk here is the middle, inside of the if block_given?
section. The first few lines just get the actual contents, using the capture
helper to pull out the contents of the block. But then you get this:
if block_called_from_erb?(block)
concat(content_tag)
else
content_tag
end
This is actually a requirement for writing a block helper of any kind in Rails. First, Rails checks to see if the block is being called from ERB. If so, it takes care of concatenating to the buffer. Otherwise, the caller simply wants a String back, so it returns it.
Worse, here's the implementation of block_called_from_erb?
:
BLOCK_CALLED_FROM_ERB = 'defined? __in_erb_template'
# Check whether we're called from an erb template.
# We'd return a string in any other case, but erb <%= ... %>
# can't take an <% end %> later on, so we have to use <% ... %>
# and implicitly concat.
def block_called_from_erb?(block)
block && eval(BLOCK_CALLED_FROM_ERB, block)
end
So every time you use a block helper in Rails, or use a helper which uses a block helper, Rails is forced to eval into the block to determine what the context is.
In Merb, we solved this problem by using this syntax:
<%= form_for(@object) do %>
Stuff
<% end =%>
And while everyone agrees that the opening <%=
is a reasonable change, the closing =%>
is a bit grating. However, it allows us to compile the above code into:
_buf << (form_for(@object) do
_buf << ("Stuff").to_s
end).to_s
That's because we tag the end
with a special ERB tag that allows us to attach a ).to_s
to the end. We use Erubis, which lets us control the compilation process more finely, to hook into this process.
Rails 3 will use Erubis regardless of this problem to implement on-by-default XSS protection, but I needed a solution that didn't require the closing =%>
(ideally).
Evan (lead on Rubinius) hit upon a rather ingenious idea: use Ruby operator precedence to get around the need to know where the end
was. Effectively, compile into the following:
_buf << capture_obj << form_for(@object) do
_buf << ("Stuff").to_s
end
where capture_obj
is:
class CaptureObject
def <<(obj)
@object = obj
self
end
def to_str
@object.to_s
end
def to_s
@object.to_s
end
end
Unfortunately, with one hand Ruby operator precedence giveth, and with one hand it taketh away. In order to test this, I tried using a helper that returned an object, rather than a String (valid in ERB). In ERB, this would call to_s on the object. When I tried to run this code with the CaptureObject
, I got:
template template:1:in `<<': can't convert Object into String (TypeError)
from template template:1:in `template'
from helper_spike.rb:48
Evan and I were both a bit baffled by this (although it retrospect we probably shouldn't have been), and we hit on the idea to try running the code through Rubinius and look at its backtrace:
An exception occurred running helper_spike.rb
Coercion error: #<Object:0x60a>.to_str => String failed:
(No method 'to_str' on an instance of Object.) (TypeError)
Backtrace:
Type.coerce_to at kernel/common/type.rb:22
Kernel(String)#StringValue at kernel/common/kernel.rb:82
String#<< at kernel/common/string.rb:93
MyContext#template at template template:1
main.__script__ at helper_spike.rb:48
By looking at Rubinius' backtrace, we quickly realized that the order of operations was wrong, and to_str
was getting called on the return value from the helper, rather than the CaptureObject
. As I tweeted immediately thereafter, the information available in Rubinius' backtrace is just phenomenal, exposing enough information to really see what's going on. Because the internals of Rubinius are written in Ruby, the Ruby backtrace goes all the way through to the Type.coerce_to
method.
After realizing that, we changed the implementation of CaptureObject
to take the buffer in its initializer, and have it handle concatenating to the buffer. The compiled code now looks like:
capture_obj << form_for(@object) do
_buf << ("Stuff").to_s
end
and the CaptureObject
looks like:
class CaptureObject
def initialize(buf)
@buf = buf
end
def <<(obj)
@buf << obj.to_s
end
end
Now, Ruby's operator precedence will bind the do
to the form_for
, and the return value of form_for
will be to_s
'ed and concatenated to the buffer.
And the best thing is the implementation of content_tag once that's done:
def content_tag(name, content = nil, options = nil, escape = true, &block)
if block_given?
options = content if content.is_a?(Hash)
content = capture(&block)
end
content_tag_string(name, content, options, escape)
end
We can simply return a String and ERB handles the concatenation work. That's the important part: helper writers should be able to think of block helpers the same way they think about traditional helpers. Somewhat less importantly, we'll be able to eliminate eval
ing into untold numbers of blocks at runtime.
This was only an experiment, and the specific details still need to be worked out (how do we do this without breaking untold numbers of existing applications), I'm very happy with this solution, which provides the simplicity and performance enhancement of the Merb solution without the ugly =%>
.