An HTML generator in one hook, and the Ruby method lookup path
Text version
You want to generate HTML from Ruby without reaching for a template, and you want the calls to read like Ruby. Here's the result we're aiming for:
HTML.html(
HTML.head,
HTML.body(
HTML.ul(
HTML.li("first li"),
HTML.li("second li")
)
)
)
# => "<html><head></head><body><ul><li>first li</li><li>second li</li></ul></body></html>"
Not one of html, head, body, ul or li gets defined anywhere. The whole generator is a
single method, and it works because that method sits at the very end of the Ruby method lookup
path.
The draft, one method per tag
The first version of anything is a draft, so start modestly and get a decent result.
module HTML
def self.html(*content) = "<html>#{content.join}</html>"
def self.head(*content) = "<head>#{content.join}</head>"
def self.body(*content) = "<body>#{content.join}</body>"
def self.ul(*content) = "<ul>#{content.join}</ul>"
def self.li(*content) = "<li>#{content.join}</li>"
end
HTML.html(HTML.head, HTML.body(HTML.ul(HTML.li("first li"), HTML.li("second li"))))
# => "<html><head></head><body><ul><li>first li</li><li>second li</li></ul></body></html>"
That's the target output, on the target DSL, in seven lines. It also has one obvious problem: the HTML standard defines well over a hundred elements, and every one of those methods is the same method with a different name in it. Nobody's writing that by hand.
So the real question isn't "how do I generate HTML". It's "how do I get Ruby to define those hundred methods for me".
Where a method call goes when nothing answers it
When you call a method, Ruby looks for it in the context of self first, then walks up the
ancestor chain until something answers. That chain is the class itself, the modules it includes,
its parent class, that parent's modules, and so on up. Read top to bottom, it's the lookup order:
Array.ancestors # => [Array, Enumerable, Object, Kernel, BasicObject]
method(:puts).owner # => Kernel
Object.include?(Kernel) # => true
That second line is the one to carry forward. puts isn't defined on Object at all, and neither
is p. They're on Kernel, which Object includes, which is why they're reachable from
everywhere. Even the bare puts "hi" at the top of a file is a message sent to main, an
ordinary instance of Object, resolved up through the same chain.
So what happens when the chain runs out?
module Plain; end
Plain.nope # NoMethodError: undefined method 'nope' for module Plain
The responsibility gets handed to method_missing, which is itself looked up along that same
chain. BasicObject ships an implementation at the end of it, and that implementation is the one
that raises. So the familiar NoMethodError isn't the absence of a method. It's the return value
of the last method_missing on the chain. Put your own earlier on that chain and the error never
happens.
The whole generator
A module holds behavior and no state, which is the exact shape of a generator that carries nothing
from one call to the next. That's why the hook goes on self: every call in the DSL is a message
to the module object itself, not to an instance of anything.
module HTML
def self.method_missing(tag, *args, &block)
"<#{tag}>#{args.join}</#{tag}>"
end
end
HTML.html(HTML.head, HTML.body(HTML.ul(HTML.li("first li"), HTML.li("second li"))))
# => "<html><head></head><body><ul><li>first li</li><li>second li</li></ul></body></html>"
That's it. Every tag in the DSL, out of one method.
A call is a message: a name, a payload and a block, aimed at a receiver. That triple is exactly
what the hook receives, which is why the first parameter is already the symbol Ruby couldn't
resolve, :html, :head, :body. It's the tag name already, so there's no mapping table to
maintain.
args.join is what makes the nesting work, and it's worth being explicit about why. Ruby
evaluates arguments before it evaluates the call, so the innermost tags are generated first and
the outer call receives strings. Add a trace line to the hook and the order shows up:
method_missing(:head, [])
method_missing(:li, ["first li"])
method_missing(:li, ["second li"])
method_missing(:ul, ["<li>first li</li>", "<li>second li</li>"])
method_missing(:body, ["<ul><li>first li</li><li>second li</li></ul>"])
method_missing(:html, ["<head></head>", "<body><ul>...</ul></body>"])
Six calls, not one of which resolves to a method, each returning a string that the next call up
treats as content. HTML.head with no arguments joins an empty array, so you get <head></head>.
The two tag names Kernel already owns
A hook at the end of the lookup path only fires when the lookup fails. Anything defined earlier on
the chain wins, silently, and HTML is an instance of Module, whose chain isn't empty. Intersect
the HTML element names with everything reachable on that module object and exactly two come back:
Module.ancestors # => [Module, Object, Kernel, BasicObject]
(HTML.methods + HTML.private_methods) & %i[html head body div p ul li select a span]
# => [:p, :select]
Kernel.private_instance_methods & %i[p select] # => [:p, :select]
Kernel.public_instance_methods & %i[p select] # => []
HTML.p("x") # => "<p>x</p>"
HTML.select(HTML.a("x")) # => "<select><a>x</a></select>"
Kernel defines both, and this is where it gets interesting: they're private. A private method
called with an explicit receiver isn't callable, and Ruby routes that failure through
method_missing too. So the hook fires anyway and both tags work, by accident. Drop the hook and
the same call tells you the real reason: Plain.p("x") raises NoMethodError: private method 'p'
called for module Plain, not undefined method.
Where it does bite is inside the module, where there's no explicit receiver to fail on. A bare p
there is Kernel#p. It prints to stdout, returns its argument, and no hook is involved:
module Bare
def self.render = p("hello")
end
Bare.render # prints hello, returns "hello"
The difference between those two tags rendering and them printing to your logs is whether you wrote the receiver.
What method_missing alone doesn't do
respond_to? still says no. The hook answers calls, but it doesn't make the object admit that
it answers them. HTML.respond_to?(:div) returns false, and HTML.method(:div) raises a
NameError, because both of them introspect the ancestor chain and find nothing on it.
The fix is respond_to_missing?, the companion hook Ruby consults for exactly those questions.
Pairing the two also lets the generator declare which names it actually handles, so a typo keeps
raising instead of quietly becoming a tag:
module HTML
TAGS = %i[html head body div p ul li select a span].freeze
def self.method_missing(tag, *args, &block)
return super unless TAGS.include?(tag)
"<#{tag}>#{args.join}</#{tag}>"
end
def self.respond_to_missing?(tag, include_private = false)
TAGS.include?(tag) || super
end
end
HTML.respond_to?(:div) # => true
HTML.method(:div).call("x") # => "<div>x</div>"
HTML.nope("x") # NoMethodError: undefined method 'nope' for module HTML
super is what makes that last line work. Falling through to BasicObject#method_missing is how
you get the standard error back for the names you don't handle, instead of inventing a <nope>
tag out of a typo.
Nothing is escaped. Content gets interpolated raw, so the generator will emit whatever it's handed:
HTML.li("<script>alert(1)</script>")
# => "<li><script>alert(1)</script></li>"
That's fine for literals in your own source. The moment any content comes from a user, it goes
through CGI.escapeHTML first, which is what Rails does for you in ERB and what this DSL doesn't
do yet.
The BasicObject alternative, and its eight methods
The other way to build this is a class inheriting from BasicObject, with method_missing as an
instance method. It's a better container in one specific way: the chain it has to get past is far
shorter, so there's almost nothing left to shadow a tag name.
class HTMLGenerator < BasicObject
def method_missing(tag, *args, &block) = "<#{tag}>#{args.join}</#{tag}>"
end
g = HTMLGenerator.new
g.ul(g.li("first li")) # => "<ul><li>first li</li></ul>"
BasicObject.instance_methods.size # => 8
Object.instance_methods.size # => 51
Eight instance methods against fifty one, and neither p nor select is among the eight. That's
the classic reason BasicObject exists, and it's what gems building DSLs reach for. The cost is
the other half of the same trade: a blank slate doesn't have Kernel either. We keep the module
here because the next steps want methods that live in Kernel more than they want an empty chain.
Related quizzes
Three short ones on the machinery this article walks through:
instance_of?ignores inheritance: the difference between an object's exact class and its ancestor chain.aliasvsalias_methodacross inheritance: what resolves lexically and what resolves onselfat runtime.- Shadowing
Net::HTTPwith your own module: what a constant already holding a class does tomodule Something.
Comments 2 comments
That is truly hideous, why would you ever use that over ERB + legible HTML?
Agree with you! But I still think it’s cool to explore for educational purposes. 😄