Ruby
Article
By Ilya Bodrov-Krukowski

Learn Ruby Metaprogramming for Great Good

By Ilya Bodrov-Krukowski
Rr

Ruby Metaprogramming can be a good thing.

Recently, I was reviewing one of my student’s code. He had a program with many methods that printed out various messages, for example:

class MyClass
  def calculate(a)
    result = a ** 2
    puts "The result is #{result}"
  end
end

class MyOtherClass
  def some_action(a, b)
    puts "The first value is #{a}, the second is #{b}"
  end

  def greet
    puts "Welcome!"
  end
end

I suggested that he store these messages in a separate file to simplify the process working with them. Then, I remembered how I18n translations are stored in Rails and got an idea. Why don’t we create a YAML file (a so-called dictionary) with all the messages and a helper method to fetch them properly, all while supporting additional features like interpolation? This is totally possible with Ruby’s metaprogramming!

That’s how the messages_dictionary gem (created mostly for studying purposes) was born – I even used it in couple of my other projects. So, in this article, we will see Ruby’s metaprogramming in action while writing this gem from scratch.

Basic Gem Structure

Let me quickly cover the files and folders for the gem:

  • Gemfile
  • A gemspec that contains system information. You can check out the gem’s source code on GitHub to see how they look.
  • The Rakefile contains instructions to boot tests written in RSpec.
  • .rspec contains options for RSpec. In this case, for example, I want the tests to run in a random order, the spec_helper.rb file should be required by default, and the output should be colored verbose. Of course, these options can be set when running RSpec from the terminal, as well.
  • .travis.yml contains configuration for the Travis CI service that automatically runs tests for each commit or pull request. This is a really great service, so give it a try if you have never seen it before.
  • README.md contains the gem’s documentation.
  • spec/ contains all the tests written in RSpec. I won’t cover tests in this article, but you may study them on your own.
  • lib/ contains the gem’s main code.

Let’s start work in the lib directory. First of all, create a messages_dictionary.rb file and a messages_dictionary folder. messages_dictionary.rb will require all third-party gems, as well as some other files, and define our module. Sometimes configuration is also placed inside this file, but we won’t do this.

lib/messages_dictionary.rb

require 'yaml'
require 'hashie'

module MessagesDictionary
end

Pretty minimalistic. Note that this gem has two dependencies: YAML and Hashie. YAML will be used for parsing .yml files whereas Hashie provides a bunch of really cool extensions for the basic Array and Hash classes. Open this RubyGems page and note that Hashie is placed under the Dependencies section. This is because inside the gemspec we have the following line:

spec.add_dependency 'hashie', '~> 3.4'

YAML parser is a part of Ruby core, but Hashie is a custom solution, therefore we have to specify it as a dependency.

Now inside the lib/messages_dictionary create a version.rb file:

lib/messages_dictionary/version.rb

module MessagesDictionary
  VERSION = 'GEM_VERSION_HERE'
end

It’s a common practice to define the gem’s version as a constant. Next, inside the gemspec file reference this constant:

spec.version = MessagesDictionary::VERSION

Also note that all the gem’s code is namespaced under the module MessagesDictionary. Namespacing is very important because otherwise, you may introduce naming collisions. Suppose someone wishes to use this gem in their own project, but there is already a VERSION constant defined somewhere (after all, this is a very common name). Placing the gem’s version outside of a module may re-write this constant and introduce bugs that are hard to detect. Therefore, think of a name for your gem and make sure that this name is not yet in use by Googling a bit, then namespace all your code under this name.

Okay, so preparations are done and we can start coding!

Dynamically Defining a Method

First of all, let’s discuss how we want this gem to be used. Of course, before doing anything else it has to be required:

require 'messages_dictionary'

Next, our module has to be included:

class MyClass
  include MessagesDictionary
end

Then there should be a method to tell MessagesDictionary to do its job. Let’s call this method has_messages_dictionary, inspired by Rails’ hassecurepassword:

class MyClass
  include MessagesDictionary
  has_messages_dictionary
end

The next step for the user is to create a .yml file containing messages:

hi: "Hello there!"

Finally, in order to display this message, a special method has to be called:

class MyClass
  include MessagesDictionary
  has_messages_dictionary

  def greet
    pretty_output(:hi) # Prints "Hello there!" in the terminal
  end
end

This is somewhat basic functionality, but we will extend it later.

Create a new file called injector.rb inside the lib/messages_dictionary directory. The big question is how to equip a class with an additional method has_messages_dictionary on the fly? Luckily for us, Ruby presents a special hook method called included that runs once a module is included into a class.

lib/messages_dictionary/injector.rb

module MessagesDictionary
  def self.included(klass)
  end
end

included is a class method, so I need to prefix it with self. This method accepts an object representing the class which has included this module. Note that I intentionally called this local variable klass, because class is a reserved word in Ruby.

What do we want to do next? Obviously, define a new method called has_messages_dictionary. However, we can’t use def for that – this has to be done dynamically at runtime. Also, note that the has_messages_dictionary has to be a class method, therefore we have to use the definesingletonmethod. If you wish to learn more about singleton methods, watch my screencast about them. To put it simply, class methods are singleton methods.

There is a small gotcha, however. If I use define_singleton_method like this

module MessagesDictionary
  def self.included(klass)
    define_singleton_method :has_messages_dictionary do |opts = {}|
    end
  end
end

Then this method will be defined inside the MessagesDictionary module but not inside the class! Therefore we have to use yet another method called class_exec that, as you’ve probably guessed, evaluates some code in the context of some class:

lib/messages_dictionary/injector.rb

module MessagesDictionary
  def self.included(klass)
    klass.class_exec do
      define_singleton_method :has_messages_dictionary do |opts = {}|
      end
    end
  end
end

Note that define_singleton_method has a local variable called opts set to an empty hash by default. If we did not have to define this method dynamically, the corresponding code would look more familiar:

def self.has_messages_dictionary(opts = {})
end

This concept of using the included hook and defining some method in the context of another class is pretty common and, for example, is used in Devise.

Opening a File

Next, our code should open a file with messages. Why don’t we expect this file to be named after the class name? For example, if the class if called MyClass then the file should be my_class.yml. The only thing we need to do is convert the class name from camel to snake case. Whereas Rails does have such method, Ruby does not provide it, so let’s just define a separate class for that. The code for the snake_case case method was taken from the Rails ActiveSupport module:

lib/messages_dictionary/utils/snake_case.rb

module MessagesDictionary
  class SpecialString
    attr_accessor :string

    def initialize(string)
      @string = string
    end

    def snake_case
      string.gsub(/::/, '/').
          gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
          gsub(/([a-z\d])([A-Z])/,'\1_\2').
          tr("-", "_").
          downcase
    end
  end
end

Of course, we might have reopened an existing String class, but a user’s application may already have such method defined and we don’t want to redefine it.

Use this new helper class:

injector.rb

# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
  file = "#{SpecialString.new(klass.name).snake_case}.yml"
end

The next step is to load a file and halt the program’s execution if it was not found:

injector.rb

# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
  file = "#{SpecialString.new(klass.name).snake_case}.yml"
  begin
    messages = YAML.load_file(file)
  rescue Errno::ENOENT
    abort "File #{file} does not exist..." # you may raise some custom error instead
  end
end

messages will contain a hash based on the file’s contents but I’d like to offer some flexibility for the users, allowing them to access a message by either providing a symbol or a string as the key. This is called indifferent access and Hashie does support it.

In order to add this feature, define a special class called Dict and add the proper modules there:

lib/messages_dictionary/utils/dict.rb

module MessagesDictionary
  class Dict < Hash
    include Hashie::Extensions::MergeInitializer
    include Hashie::Extensions::IndifferentAccess
  end
end

Now tweak the main file a bit:

injector.rb

# ...
messages = Dict.new(YAML.load_file(file))
# ...

The last step in this section is storing these messages somewhere. Let’s use a class constant for that. Here is the resulting code:

injector.rb

module MessagesDictionary
  def self.included(klass)
    klass.class_exec do
      define_singleton_method :has_messages_dictionary do |opts = {}|
        file = "#{SpecialString.new(klass.name).snake_case}.yml"
        begin
          messages = Dict.new(YAML.load_file(file))
        rescue Errno::ENOENT
          abort "File #{file} does not exist..."
        end
        klass.const_set(:DICTIONARY_CONF, {msgs: messages})
      end
    end
  end
end

const_set dynamically creates a constant named DICTIONARY_CONF for the class. This constant contains a hash with our messages. Later we will store additional options in this constant.

--ADVERTISEMENT--

Support for Options

Our script is starting to gain some shape, but it is way too rigid. Here are some obvious enhancements that need to be introduced:

  • It should be possible to provide a custom messages file
  • Currently, the messages file has to placed into the same directory as the script, which it is not very convenient. Therefore, it should be possible to define a custom path to the file.
  • In some cases, it might be more convenient to pass a hash with messages directly to the script instead of creating a file.
  • It should be possible to store nested messages, just like in Rails’ locale files.
  • By default all messages will be printed out into STDOUT using the puts method, but users may want to change that.

Some of these features might seem complex but, in reality, they are somewhat simple to implement. Let’s start with the custom path and file names.

Providing a Custom Path

We are going to instruct users to provide the :dir and :file options if they want to redefine the default path name. Here is the new version of the script:

injector.rb

# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
  file = "#{SpecialString.new(klass.name).snake_case}.yml"
  begin
    file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml"
    file = File.expand_path(file, opts[:dir]) if opts[:dir]
  rescue Errno::ENOENT
    abort "File #{file} does not exist..."
  end
  klass.const_set(:DICTIONARY_CONF, {msgs: messages})
end

Basically we’ve changed only two lines of code. We either fetch user-provided file name or generate it based on the class name, then use the expand_path method if they supply a directory.

Now the user can provide options like this:

has_messages_dictionary file: 'test.yml', dir: 'my_dir/nested_dir'

Passing a Hash of Messages

This is a simple feature to implement. Just provide support for the messages option:

injector.rb

# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
  if opts[:messages]
    messages = Dict.new(opts[:messages])
  else
    file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml"
    file = File.expand_path(file, opts[:dir]) if opts[:dir]
    begin
      # ...
    end
  end
end

Now messages can be provided in the form of a hash:

has_messages_dictionary messages: {hi: 'hello!'}

Support Nesting and Displaying the Messages

This feature is a bit trickier, but totally feasible with the help of Hashie’s deep_fetch method. It takes one or more arguments representing the hash’s keys (or array’s indexes) and does its best to find the corresponding value. For example, if we have this hash:

user = {
  name: { first: 'Bob', last: 'Smith' }
}

You can say:

user.deep_fetch :name, :first

to fetch “Bob”. This method also accepts a block that will be run when the requested value cannot be found:

user.deep_fetch(:name, :middle) { |key| 'default' }

Before employing this method, however, we need to extend our object with new functionality:

injector.rb

# ...
klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch)})
# ...

Let’s decide how the users should provide a path to the nested value. Why don’t we use Rails’ I18n approach where keys are delimited with .:

pretty_output('some_key.nested_key')

It’s high time to add the actual pretty_output method:

injector.rb

# ...
define_method :pretty_output do |key|
  msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
    raise KeyError, "#{key} cannot be found in the provided file..."
  end
end

Take the key and split it by . resulting in an array. The array’s elements should then be converted to the method’s arguments, which is why we prefix this command with *. Next, fetch the requested value or raise an error if nothing was found.

Outputting the Message

Lastly we’ll display the message while allowing the redefinition of the output location and the method to be used. Let’s call these two new options output (default is STDOUT) and method (default is :puts):

injector.rb

# ...
klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch),
                                   output: opts[:output] || STDOUT,
                                   method: opts[:method] || :puts})
# ...

Next just user these options. As long as we are calling a method dynamically, without knowing its name, use send:

injector.rb

# ...
define_method :pretty_output do |key|
  msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
    raise KeyError, "#{key} cannot be found in the provided file..."
  end

  klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end

The gem is nearly finished, there are just a few more items.

Interpolation

Interpolating values into a string is a very common practice, so, of course, our program should support it. We only need to pick some special symbols to mark an interpolation placeholder. I’ll go for Handlebars-style {{ and }} but of course you may choose anything else:

show_result: "The result is {{result}}. Another value is {{value}}"

The values will then be passed as a hash:

pretty_output(:show_result, result: 2, value: 50)

This means that the pretty_output method should accept one more argument:

injector.rb

# ...
define_method :pretty_output do |key, values = {}|
end

In order to replace a placeholder with an actual value we will stick with the gsub!:

injector.rb

# ...
define_method :pretty_output do |key, values = {}|
  msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
    raise KeyError, "#{key} cannot be found in the provided file..."
  end

  values.each do |k, v|
    msg.gsub!(Regexp.new('\{\{' + k.to_s + '\}\}'), v.to_s)
  end

  klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end

Custom Transformations and Finalizing the Gem

The last feature to implement is the ability to provide custom transformations for the messages. By that, I mean user-defined operations that should be applied to the fetched string. For example, sometimes you might want to just fetch the string without displaying it anywhere. Other times you may wish to capitalize it or strip out some symbols, etc.

Therefore I would like our pretty_output method to accept an optional block with some custom code:

injector.rb

# ...
define_method :pretty_output do |key, values = {}, &block|
end

Simply run the code if the block is provided, otherwise, perform the default operation:

injector.rb

# ...
define_method :pretty_output do |key, values = {}, &block|
  # ...
  block ?
      block.call(msg) :
      klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end

By removing the & symbol from the block’s name we turn it into a procedure. Next, simply use the call method to run this procedure and pass our message to it.

The transformations can now be provided like this:

pretty_output(:welcome) do |msg|
  msg.upcase!
  msg # => Returns "WELCOME", does not print anything
end

Sometimes it might be better to provide transformation logic for the whole class, instead of passing a block individually:

injector.rb

# ...
define_singleton_method :has_messages_dictionary do |opts = {}|
  # ...
  klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch),
                                     output: opts[:output] || STDOUT,
                                     method: opts[:method] || :puts,
                                     transform: opts[:transform]})
end

define_method :pretty_output do |key, values = {}, &block|
  # ...
  transform = klass::DICTIONARY_CONF[:transform] || block
  transform ?
      transform.call(msg) :
      klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg)
end

You may say block || klass::DICTIONARY_CONF[:transform] instead to make the block passed for an individual method more prioritized.

We are done with the gem’s features, so let’s finalize it now. pretty_output is an instance method, but we probably don’t want it to be called from outside of the class. Therefore, let’s make it private:

injector.rb

# ...
define_method :pretty_output do |key, values = {}, &block|
  # ...
end
private :pretty_output

The name pretty_output is nice, but a bit too long, so let’s provide an alias for it:

injector.rb

# ...
private :pretty_output
alias_method :pou, :pretty_output

Now displaying a message is as simple as saying

pou(:welcome)

The very last step is to require all the files in the proper order:

lib/messages_dictionary.rb

require 'yaml'
require 'hashie'

require_relative 'messages_dictionary/utils/snake_case'
require_relative 'messages_dictionary/utils/dict'
require_relative 'messages_dictionary/injector'

module MessagesDictionary
end

Conclusion

In this article we’ve seen how Ruby’s metaprogramming can be used in the real world and wrote a MessagesDictionary gem that allows us to easily fetch and work with strings. Hopefully, you now feel a bit more confident about using methods like included, define_singleton_method, send, as well as working with blocks.

The final result is available on GitHub along with thorough documentation. You can also find RSpec tests there. Feel free to suggest your own enhancement for this gem, after all, it was created for studying purposes. As always, I thank you for staying with me and see you soon!

Recommended
Sponsors
The most important and interesting stories in tech. Straight to your inbox, daily. Get Versioning.
Login or Create Account to Comment
Login Create Account