LaunchKit
← All posts
Video · 8 min read · by Mehdi Farsi · 303 views

An HTML generator in one hook, and the Ruby method lookup path

Watch on YouTube

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.

HTML.li("first li") walking a chain of five boxes: HTML, Module, Object, Kernel, BasicObject, each tagged no li. An arrow from BasicObject leads down to a yellow box reading BasicObject#method_missing raises NoMethodError. A second arrow points up into the first box from a green box reading your method_missing, defined on HTML. Caption: NoMethodError is the end of the chain, not the absence of a method.

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.

Three short ones on the machinery this article walks through:

#ruby #video #metaprogramming

Comments 2 comments

jb

That is truly hideous, why would you ever use that over ERB + legible HTML?

launchkit-codes

Agree with you! But I still think it’s cool to explore for educational purposes. 😄

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.