Building Elixir cli app

I want to create something fairly simple and play with some of the Elixir concepts. I don’t have much experience with Elixir yet so I’ll try to keep things simple. I started reading through this article:

http://elixirdose.com/post/create_command_line_tools

I’ll stick to it and build up my own implementation with the help of what’s done there.

Let’s mix it up.

Kalins-MacBook-Air:elixir_sandbox kalin$ mix new awesome_cli
* creating README.md
* creating .gitignore
* creating mix.exs
* creating config
* creating config/config.exs
* creating lib
* creating lib/awesome_cli.ex
* creating test
* creating test/test_helper.exs
* creating test/awesome_cli_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

    cd awesome_cli
    mix test

Run "mix help" for more commands.
Kalins-MacBook-Air:elixir_sandbox kalin$ cd awesome_cli/
Kalins-MacBook-Air:awesome_cli kalin$ mix test
Compiling 1 file (.ex)
Generated awesome_cli app
..

Finished in 0.05 seconds
2 tests, 0 failures

Randomized with seed 213787
Kalins-MacBook-Air:awesome_cli kalin$

I’m asked to start up by running the auto-generated tests. So let’s do so.

Kalins-MacBook-Air:elixir_sandbox kalin$ cd awesome_cli/
Kalins-MacBook-Air:awesome_cli kalin$ mix test
Compiling 1 file (.ex)
Generated awesome_cli app
..

Finished in 0.05 seconds
2 tests, 0 failures

Randomized with seed 213787
Kalins-MacBook-Air:awesome_cli kalin$

Fair enough - two successful tests. I’m rather curious to see those. Running mix test with –trace option shows me who they are.

AwesomeCliTest
  * test greets the world (0.00ms)
  * test doc at AwesomeCli.hello/0 (1) (0.00ms)

So, there is one that greets the world and one that test doc. Looking at the test directory I found two files.

test/test_helper.exs

ExUnit.start()

test/awesome_cli_test.exs

defmodule AwesomeCliTest do
  use ExUnit.Case
  doctest AwesomeCli

  test "greets the world" do
    assert AwesomeCli.hello() == :world
  end
end

Nothing fancy about them. The actual app code is in lib.

lib/awesome_cli.ex

defmodule AwesomeCli do
  @moduledoc """
  Documentation for AwesomeCli.
  """

  @doc """
  Hello world.

  ## Examples

      iex> AwesomeCli.hello
      :world

  """
  def hello do
    :world
  end
end

I want to call this “hello” function on my own so I’ll fire the Interactive Elixir and call mix inside. Then I’ll run the function just like it’s done in the test.

Kalins-MacBook-Air:awesome_cli kalin$ iex -S mix
Erlang/OTP 20 [erts-9.1.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Compiling 1 file (.ex)
Generated awesome_cli app
Interactive Elixir (1.5.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> AwesomeCli.hello
:world
iex(2)> 

The great thing about iex is that I can recompile my code without getting out of it. Let’s add another simple function.

def hello_again do
    :again
end

Now in order to run this function all I have to do is recompile AwesomeCli using the “r” command.

iex(6)> r AwesomeCli 
warning: redefining module AwesomeCli (current version defined in memory)
  lib/awesome_cli.ex:1

{:reloaded, AwesomeCli, [AwesomeCli]}
iex(7)> AwesomeCli.hello_again
:again
iex(8)> 

And there - I have the hello_again function I just wrote. Now let’s move on and add some actual cli functions.

Now it’s time to do some cleaning. I’ll remove the hello function and the auto-generated test. And soon enough here’s what I have:

lib/awesome_cli.ex

defmodule AwesomeCli do
  @moduledoc """
  Documentation for AwesomeCli.
  """

end

test/awesome_cli_test.exs

defmodule AwesomeCliTest do
  use ExUnit.Case
  doctest AwesomeCli

end

That looks like a good starting point. Now let’s add two more functions.

lib/awesome_cli.ex

defmodule AwesomeCli do
  @moduledoc """
  Documentation for AwesomeCli.
  """

  def start(_type, _args) do
    Supervisor.start_link([], strategy: :one_for_one)
  end

  def main(args) do
    IO.puts "Hello, world!"
  end

end

A supervisor is a process which supervises other processes, which we refer to as child processes.

More on the use of this supervisor comes later but now we just define it. That’s something you can find in the documentation over here. Now let’s fire the REPL and check our main function.

iex(1)> AwesomeCli.main("myarg")
Hello, world!
:ok
iex(2)>

Now our function takes an argument but it doesn’t do anything with it. Before we continue with cli functions let’s make an executable.

Kalins-MacBook-Air:awesome_cli kalin$ mix escript.build
** (Mix) Could not generate escript, please set :main_module in your project configuration (under :escript option) to a module that implements main/1
Kalins-MacBook-Air:awesome_cli kalin$

That failed but the nice thing about Elixir is that it keeps telling what we should do next. So let’s follow the advice and add :main_module to the configuration. We will add this piece of code to mix.exs in the root directory:

def escript do
  [main_module: AwesomeCli]
end

Here’s how mix.exs will look like afterwards:

mix.exs

defmodule AwesomeCli.Mixfile do
  use Mix.Project

  def project do
    [
      app: :awesome_cli,
      version: "0.1.0",
      elixir: "~> 1.5",
      start_permanent: Mix.env == :prod,
      escript: escript,
      deps: deps()
    ]
  end

  def escript do
    [main_module: AwesomeCli]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      # {:dep_from_hexpm, "~> 0.3.0"},
      # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
    ]
  end
end

Notice also the “escript: escript” that we added to the project definition. Now we can build.

Kalins-MacBook-Air:awesome_cli kalin$ mix escript.build
warning: variable "escript" does not exist and is being expanded to "escript()", please use parentheses to remove the ambiguity or change the variable name
  mix.exs:10

Compiling 1 file (.ex)
warning: variable "args" is unused
  lib/awesome_cli.ex:10

Generated awesome_cli app
Generated escript awesome_cli with MIX_ENV=dev
Kalins-MacBook-Air:awesome_cli kalin$ ./awesome_cli 
Hello, world!
Kalins-MacBook-Air:awesome_cli kalin$

Now let’s update lib/awesome_cli.ex with the following:

def main(args) do
  args |> parse_args
end

def parse_args(args) do
  {[name: name], _, _} = OptionParser.parse(args)
  IO.puts "Hello, #{name}! You're awesome!!"
end

Rebuild with “mix escript.build” and you’ll get the first cli option available for use.

Kalins-MacBook-Air:awesome_cli kalin$ ./awesome_cli --name "Kalin"
Hello, Kalin! You're awesome!!
Kalins-MacBook-Air:awesome_cli kalin$

Now let’s introduce a quick fix. If you’re following along you probably noticed this warning:

warning: variable "escript" does not exist and is being expanded to "escript()", please use parentheses to remove the ambiguity or change the variable name
  mix.exs:10

Let’s fix this real quick by adding () in mix.exs after escript just like we were adviced to do. I really love Elixir for helping me in such a way. Great, now that the warning is gone let’s keep updating our lib/awesome_cli.ex.

def parse_args(args) do
  options = OptionParser.parse(args)

  case options do
    {[name: name], _, _} -> IO.puts "Hello, #{name}! You're awesome!!"
    {[help: true], _, _} -> IO.puts "This is help message"

  end
end
Kalins-MacBook-Air:awesome_cli kalin$ mix escript.build
Kalins-MacBook-Air:awesome_cli kalin$ ./awesome_cli --help
This is help message
Kalins-MacBook-Air:awesome_cli kalin$

It’s all great but you might have noticed what’s happening when we run our cli app with no arguments.

Kalins-MacBook-Air:awesome_cli kalin$ ./awesome_cli 
** (CaseClauseError) no case clause matching: {[], [], []}
    (awesome_cli) lib/awesome_cli.ex:17: AwesomeCli.parse_args/1
    (elixir) lib/kernel/cli.ex:90: anonymous fn/3 in Kernel.CLI.exec_fun/2
Kalins-MacBook-Air:awesome_cli kalin$

Ouch… Let’s introduce some more updates.

lib/awesome_cli.ex

defmodule AwesomeCli do
  @moduledoc """
  Documentation for AwesomeCli.
  """

  def start(_type, _args) do
    Supervisor.start_link([], strategy: :one_for_one)
  end

  def main(args) do
    args |> parse_args |> do_process
  end

  def parse_args(args) do
    options = OptionParser.parse(args)

    case options do
      {[name: name], _, _} -> [name]
      {[help: true], _, _} -> :help
      _ -> :help

    end
  end

  def do_process([name]) do
    IO.puts "Hello, #{name}! You're awesome!!"
  end

  def do_process(:help) do
    IO.puts """
      Usage:
      ./awesome_cli --name [your name]

      Options:
      --help  Show this help message.

      Description:
      Prints out an awesome message.
    """

    System.halt(0)
  end
  
end
Kalins-MacBook-Air:awesome_cli kalin$ mix escript.build
Compiling 1 file (.ex)
Generated escript awesome_cli with MIX_ENV=dev
Kalins-MacBook-Air:awesome_cli kalin$ ./awesome_cli 
  Usage:
  ./awesome_cli --name [your name]

  Options:
  --help  Show this help message.

  Description:
  Prints out an awesome message.

Kalins-MacBook-Air:awesome_cli kalin$

Alright. Now it works but the first time I saw this code I found it hard to read. If you’re like me by now you’re probably confused. Elixir is supposed to be very helpful so let’s ask the REPL what’s going on.

Looking at the main function we see the following:

args > parse_args > do_process

That’s standard piping just like in the Linux shell. So, we start with some arguments, then we parse them, then we do process.

Let’s test the parse_args first. We will use myargs although we know that it will not match it.

iex(1)> AwesomeCli.parse_args(["myarg"])
:help
iex(2)>

It replies with :help because there is no match. Let’s see if we put “–name” instead.

iex(2)> AwesomeCli.parse_args(["--name"])
[true]
iex(3)>

Nice, so it responds to “–name”. If we provide second argument we’ll see it returned.

iex(3)> AwesomeCli.parse_args(["--name", "Kalin"])
["Kalin"]
iex(4)>

Why? Let’s bring back the implementation.

def parse_args(args) do
  options = OptionParser.parse(args)

  case options do
    {[name: name], _, _} -> [name]
    {[help: true], _, _} -> :help
    _ -> :help

  end
end

And here’s the answer:

{[name: name], _, _} -> [name]

When –name gets matched the function returns the name. Let’s test the do_process function.

iex(1)> AwesomeCli.do_process(["Kalin"])          
Hello, Kalin! You're awesome!!
:ok
iex(2)>

And finally let’s call the help.

Kalins-MacBook-Air:awesome_cli kalin$ iex -S mix
Erlang/OTP 20 [erts-9.1.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.5.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> AwesomeCli.do_process(:help)
  Usage:
  ./awesome_cli --name [your name]

  Options:
  --help  Show this help message.

  Description:
  Prints out an awesome message.

Kalins-MacBook-Air:awesome_cli kalin$

I hope you enjoyed this tutorial.

Categories:

Updated: