99designs Tech Blog

Adventures in web development

Ruby Metaprogramming for the Lulz

by Richo Healey.

What if it were possible to call methods with spaces in their name directly from Ruby?

If you’ve seen Gary Bernhardt’s awesome talk where he digs into some of the quirks of Ruby, you’ll know that it’s pretty trivial to get bare words in Ruby:

1
2
3
4
5
6
1.9.3-p286 :001 > def method_missing(*args)
1.9.3-p286 :002?>   args.join(" ")
1.9.3-p286 :003?>   end
 => nil
1.9.3-p286 :004 > ruby has bare words
SystemStackError: stack level too deep

Wait.. What?

Disappointing to say the least. Obviously something is amiss. It turns out this is just a quirk in irb; if you try instead

1
2
3
4
5
6
7
1.9.3-p286 :001 > def self.method_missing(*args)
1.9.3-p286 :002?>   args.join(" ")
1.9.3-p286 :003?>   end
=> nil
1.9.3-p286 :004 > ruby has bare words
=> "ruby has bare words"
1.9.3-p286 :005 >

Cool, so what else can we do with this? It’s trivial to define a method with a space in its name, and calling it isn’t terribly difficult:

1
2
3
4
5
6
7
8
1.9.3-p286 :005 > self.class.send(:define_method, :"i have a space") do
1.9.3-p286 :006 >     puts "I has a space"
1.9.3-p286 :007?>   end
=> #<Proc:0x007ff89c1e0b58@(irb):5 (lambda)>
1.9.3-p286 :008 > send(:"i have a space")
I has a space
=> nil
1.9.3-p286 :009 >

But having created such a monstrosity, how do you call it from the repl? Or for that matter, from an actual Ruby program? This is obviously something you should be doing in production…

1
2
3
4
5
6
7
8
9
10
11
12
13
self.instance_exec do
def method_missing(sym, *args)
  # Splat args if passed in from a parent call
  if args.length == 1 && args[0].is_a?(Array) && args[0][0].class == NameError
    args = args[0]
  end

  method_names, arguments = args.partition { |a| a.class == NameError }
  method([sym.to_s, *method_names.map(&:name)].join(" ")).call(*arguments)
rescue NameError => e
  return [e, *arguments]
end
end

Bam. You may be looking at this baffled (or if you’re reasonably tight with metaprogramming in Ruby, sharpening/setting fire to something with a view to causing me significant bodily harm).

Walking through this, we first of all act on whatever self is; in most cases this will be the local scope. If we didn’t do this, we’d be defining the method on Object, which can cause all kinds of headaches when you’re trying to debug.

Immediately after this, we unpack arguments if they look like they were created by an earlier instance of this method. This is unwieldy, but unfortunately Ruby’s single return values and the recursion we’re employing here make it necessary. We could definitely define a subclass of Array to make the test cleaner and the implementation more robust, but I preferred to keep this as short as possible and use the bare minimum number of Ruby primitives.

Once we’ve unpacked our arguments, we do the real magic. First off, we split our arguments into NameErrors, the container we’re using for our missing method names, and everything else (the legitimate arguments we were called with).

We try to find a method with the current name (as we’ll be building our method name right to left with recursive calls to method_missing), and failing that we pack up our current attempt with our arguments, and return it for the next pass.

There are enough issues with this (if you defined the methods foo bar baz and bar baz, a call to foo bar baz would call foo with bar bazs return) to make it unwieldy. On the other hand; if those bugs are the only thing stopping you from putting this into production, you’ve probably got larger issues.

If this large scale abuse of the language excites you, you might be interested to know that we’re hiring.

At this point you’re probably eager to know.. does it work?

1
2
3
4
5
6
7
8
9
1.9.3-p286 :001 > load "bare_words.rb"
1.9.3-p286 :002 > self.class.send(:define_method, :"i has a space") do |name, greeting|
1.9.3-p286 :003 >     puts "#{greeting}, #{name}!"
1.9.3-p286 :004?>   end
=> #<Proc:0x007fc6b41872c0@(irb):2 (lambda)>
1.9.3-p286 :005 > i has a space "richo", "Hello"
Hello, richo!
=> nil
1.9.3-p286 :006 >

Join the discussion at Hacker News and Reddit.