Building Thor-like CLI in Elixir

Most Ruby programmes are probably familiar with CLI toolkit Thor. In a nutshell it allows building command line interfaces using standard Ruby classes, like this:

class App < Thor
  desc "List things"
  def list
    puts "listing"
  end

  desc "Install something"
  def install(name)
    puts "installing #{name}"
  end
end

 

Here we will go through the minimal implementation in Ruby and then a similar one but this time in Elixir.

RUBY IMPLEMENTATION

class Thor
  def self.inherited(base)
    base.extend(ClassMethods)
  end

Use inherited hook to inject module into class object.

  module ClassMethods
    def desc(text)
      @__desc = text
    end

When desc(text) method is called, save the text in class instance variable @__desc.

    def method_added(method)
      if @__desc
        __actions << [method, @__desc]
        @__desc = nil
      end
    end

    def __actions
      @__actions ||= []
    end
  end

Subscribe to any new method added via method_added hook and if there is something inside @__desc instance variable add new entry to @__actions array.

  def run(args)
    if cmd = args.shift
      send(cmd, *args)
    else
      help
    end
  end

During the execution, if there is an action name given, use send to run method with that name, if not, run the help method …

  def help
    self.class.__actions.each do |action, desc|
      puts "#{action} - #{desc}"
    end
  end
end

… that will read the @__actions array and print its contents.

ELIXIR

The Elixir version is obviously different, but very similar in the approach. Instead of classes inherited and modules included into inheritance chain we use compile-time macros.

defmodule Thor do
  defmacro __using__(_opts) do
    quote do

Instead of self.included(base) hook we are using __using__/1 macro which is executed when compiler sees use Thor line. In that macro we put some code that will be simply put in our App module instead of use Thor.

      import Thor.Builder, only: [desc: 1]

Since there is no ruby-like inheritance, we need to import desc/1 function from Thor.Builder module (defined below).

      Module.register_attribute(__MODULE__, :desc, [])
      Module.register_attribute(__MODULE__, :actions, accumulate: true)

During compilation we can use [module attributes], in this case the first one is to keep the last description (similar to @__desc) and the second one will store all actions (similar to @__actions) hence it is marked with accumulate: true.

      @on_definition  Thor.Builder
      @before_compile Thor.Builder
    end
  end

The last two lines define compile-time hooks:
@on_definition will be called no every function definition
@before_compile will be called right before the module is compiled into bytecode.

  defmodule Builder do
    defmacro desc(text) do
      quote do
        @desc unquote(text)
      end
    end

The mentioned desc/1 function is actually a macro. All it does is replace desc("foo") with @desc "foo"
(We could omit it entirely and write @desc "label" before every action but this way is more flexible and looks the same as the ruby version)

    def __on_definition__(env, :def, name, _args, _guards, _body) do
      if desc = Module.get_attribute(env.module, :desc) do
        Module.put_attribute(env.module, :actions, {name, desc})
        Module.delete_attribute(env.module, :desc)
      end
    end

When the elixir compiler sees a new function definition it will call the __on_definition__ function. Since we are interested only in public function definitions (defs) we match on second function argument. While this code is obviously different from ruby version, it does exactly the same thing, except it is using module attributes instead of class instance variables. The biggest difference is, because :actions attribute was declared with accumulate: true we can simply use put_attribute and the value will be added to the front of the list.

    def __on_definition__(_, _, _, _, _, _) do
    end

And we don’t really care about the rest of definitions (private function and macros)

    defmacro __before_compile__(_env) do
      quote do

When the elixir compiler is done with the App module it calls the __before_compile__/1 macro in Thor.Builder module.

        def run([]) do
          help
        end

        def run([action | args]) do
          apply(__MODULE__, String.to_atom(action), args)
        end

Here we define the same two functions (run and help) as in ruby version. Instead of using if to check for first argument we use pattern matching to match on empty arguments list ([]) or [first | rest]. Then we use apply/3 function to dynamically call a function by name passing: __MODULE__ - current module name, action converted to atom (symbol) and args - rest of arguments (that can be empty).

        def help do
          for {action, desc} <- @actions do
            IO.puts "#{action} - #{desc}"
          end
        end
      end
    end
  end
end

The help function uses [for comprehension] to loop through @actions module attribute and print actions with their descriptions.

With all that in place we can now create out app. As you can see it looks very similar to the Ruby version from beginning of this post.

defmodule App do
  use Thor

  desc "List things"
  def list do
    IO.puts "listing"
  end

  desc "Install something"
  def install(name) do
    IO.puts "installing #{name}"
  end
end

FINAL NOTES

Both these implementation are very basic. For example, they lack any error handling. Both will simply blow up when given invalid command name, although this could be easily handled using Ruby’s respond_to? or Elixir’s function_exported?/3.

BONUS


Looking for comments section?

Send me an email instead to [email protected]