1   Introduction

In this post, we are going to explore several approaches to Elixir scripting. Some are simpler; and some are more complex. Some do not use the Elixir build tool Mix; and some do.

More information -- Here is another blog with more information about scripting under Elixir -- https://webonrails.com/2015/11/03/creating-command-line-programs-in-elixir/

2   Scripting without Mix

As described in the Elixir docs at https://elixir-lang.org/getting-started/introduction.html#running-scripts, running a script under Elixir can be as simple as the following:

Write the script:

# test01.exs

IO.puts("Hello")

Run the script:

$ elixir test01.exs

And, under Linux/UNIX, if we want a "shebang" or hashbang:

#!/usr/bin/env elixir
# test01.exs

IO.puts("Hello")

And we change the access/mode of the file to executable, then, we can run it as follows:

$ ./test01.exs

There are several additional capabilities that we'd like our script to have:

  1. Access to command line arguments
  2. The ability to use external modules

2.1   Access to command line arguments

System.argv/0 gives us access to any command line arguments.

And, OptionParser.parse helps us process the arguments so that we can capture both the command line options and any additional, positional arguments.

Here is an example:

#!/usr/bin/env elixir
# test01.exs

defmodule Test13.CLI do
  @moduledoc """
  synopsis:
    Prints args, possibly multiple times, possibly upper cased.
  usage:
    $ test01 {options} arg1 arg2 ...
  options:
    --upcase      Convert args to upper case.
    --count=n     Print n times.
  """

  def main([help_opt]) when help_opt == "-h" or help_opt == "--help" do
    IO.puts(@moduledoc)
  end
  def main(args) do
    {opts, cmd_and_args, errors} = parse_args(args)
    case errors do
      [] ->
        process_args(opts, cmd_and_args)
      _ ->
        IO.puts("Bad option:")
        IO.inspect(errors)
        IO.puts(@moduledoc)
    end
  end

  defp parse_args(args) do
    {opts, cmd_and_args, errors} =
      args
      |> OptionParser.parse(strict:
        [upcase: :boolean, help: :boolean, count: :integer])
    {opts, cmd_and_args, errors}
  end

  defp process_args(opts, args) do
    count = Keyword.get(opts, :count, 1)
    convertfn = if Keyword.has_key?(opts, :upcase) do
      fn (arg) -> String.upcase(arg) end
    else
      fn (arg) -> arg end
    end
    Stream.iterate(0, &(&1 + 1))
    |> Stream.take(count)
    |> Enum.each(fn (idx) ->
      if idx > 0 do
        IO.puts("-----------------")
      end
      Stream.with_index(args)
      |> Enum.each(fn ({arg, index}) ->
        arg1 = convertfn.(arg)
        IO.puts("arg #{index + 1}. #{arg1}") end)
    end)
  end

end

Test13.CLI.main(System.argv)

Notes:

  • Notice that at the bottom of this script, we call the function that starts it, and we pass the (un-parsed command line arguments).

2.2   The ability to use external modules

In order to use an external module and call a function in it, we'll have to do the following:

  1. Get the source code for the module.
  2. Compile it.
  3. Access it from our Elixir script.

Retrieving and compiling -- I use Mix, the Elixir build tool. So, as an example, it's easy for me to include the following in my mix.exs file:

defp deps do
  [
    {:jason, ">0.0.0"}
  ]
end

I can then compile it and build the ebin files with:

$ mix deps.get
$ mix deps.compile

Next we need to enable Elixir (actually the underlying Erlang system) to find these modules. We can do that in one of the following ways:

  • Use the "-pa" or "-pz" command line option to elixir:

    $ elixir -pz /path/to/my/ebin test01.exs arg1 arg2 ...
    

    In my case, the /path/to/my/ebin is a path to a sub-directory of the _build directory under my Mix project. Note that (on Linux) I can use a symbolic link to make it easier to access that directory.

  • Set the ERL_LIBS environment variable to include the needed ebin directory. Example:

    export ERL_LIBS=/path/to/my/ebin
    
  • Specify locations (directories) from which to load compiled code in your Erlang configuration file: ~/.erlang. For example:

    code:add_pathsz([
        "/home/yourname/a1/Erlang/Elixir/Test/test21/_build/dev/lib/sweet_xml/ebin/",
        "/home/yourname/a1/Erlang/Elixir/Test/test21/_build/dev/lib/jason/ebin/",
        "/home/yourname/a1/Erlang/Elixir/Test/test21/_build/dev/lib/test21/ebin/"
    ]).
    

For information about the use of command line flags "-pa" and "-pz" and for information about ERL_LIBS, see: http://erlang.org/doc/man/erl.html. Note that when you use environment variable ERL_LIBS, subdirectories are also searched. For infomation about code:add_pathsz/1 and other functions in the code module for this purpose see https://erlang.org/doc/man/code.html.

If you decide to use the ERL_LIBS environment variable, then, on Linux, you can set it with something like the following:

# replace
$ export ERL_LIBS=/path/to/my/libs
# append
$ export ERL_LIBS=$ERL_LIBS:/path/to/my/libs

Or, for one time use, set the environment variable and run your script as follows:

$ ERL_LIBS=/the/path/to/my/libs elixir my_script.exs

In our script, we need to make the module available using the require directive. Here is an example:

#!/usr/bin/env elixir

require Jason

elixir_data = [11, 22, 33, 44]
{:ok, jason_data} = Jason.encode(elixir_data)
IO.write("jason_data: ")
IO.inspect(jason_data)
{:ok, elixir_data2} = Jason.decode(jason_data)
IO.write("elixir_data2: ")
IO.inspect(elixir_data2)

3   Scripting with Mix

Another strategy is to use Mix to build or generate your script for you. This provides the following features:

  • Elixir is embedded in the "script".

  • Because Elixir is embedded into the script, you will not need Elixir on a machine on which you run the script. However, you will need Erlang.

  • Because Elixir is embedded into the script, the script is quite large. It's greater than a megabyte on my system.

  • The resulting script is actually an Erlang script and is run with escript rather than elixir. In fact, a hash-bang line is inserted at the top of this file, so on Linux systems, you can run it with something like:

    $ ./my_script arg1 arg2 ...
    

For more help with this do: $ mix help escript.build which inside your Mix project directory.

Create a Mix project -- If you do not already have one, create a project with Mix. Example:

$ mix new test14

Configuration -- Add an escript clause to the project definition in your mix.exs file. Example:

def project do
[
  app: :test14,
  version: "0.1.0",
  elixir: "~> 1.9-rc",
  start_permanent: Mix.env() == :prod,
  deps: deps(),
  escript: [
    main_module: Test14.CLI,
    comment: "A sample escript",
  ]
]
end

Write the code --

  1. Create an additional file in the ./lib directory of your Mix project.
  2. In that file, define the module that you specified as the main_module in mix.exs.
  3. In that module, define a main function. This function will receive one argument, specifically, the list of command line arguments.

Here is an example:

defmodule Test14.CLI do
  @moduledoc """
  synopsis:
    Prints args, possibly multiple times.
  usage:
    $ test10 {options} arg1 arg2 ...
  options:
    --verbose     Add more info.
    --count=n     Print n times.
  """

  def main([]) do
    IO.puts(@moduledoc)
  end
  def main([help_opt]) when help_opt == "-h" or help_opt == "--help" do
    IO.puts(@moduledoc)
  end
  def main(args) do
    {opts, positional_args, errors} =
      args
      |> parse_args
    case errors do
      [] ->
        process_args(opts, positional_args)
        show_jason(positional_args)
      _ ->
        IO.puts("Bad option:")
        IO.inspect(errors)
        IO.puts(@moduledoc)
    end
  end

  defp show_jason(args) do
    {:ok, content} = Jason.encode(args)
    IO.puts("json content: #{content}")
  end

  defp parse_args(args) do
    {opts, cmd_and_args, errors} =
      args
      |> OptionParser.parse(strict:
        [verbose: :boolean, count: :integer])
    {opts, cmd_and_args, errors}
  end

  defp process_args(opts, args) do
    count = Keyword.get(opts, :count, 1)
    printfn = if not(Keyword.has_key?(opts, :verbose)) do
      fn (arg) -> IO.puts(arg) end
    else
      fn (arg) ->
        IO.write("Message: ")
        IO.puts(arg)
      end
    end
    Stream.iterate(0, &(&1 + 1))
    |> Stream.take(count)
    |> Enum.each(fn (_counter) ->
      Enum.with_index(args)
      |> Enum.each(fn ({arg, idx}) ->
        printfn.("#{idx}. #{arg}")
      end)
    end)
  end

end

Use Mix to generate the script -- You can build it with the following:

$ mix escript.build

Run the script -- In our case, examples would be the following:

$ ./test14 --count=4 --verbose aaa bbb ccc
$ escript ./test14 --count=2 alpha beta

Published

Last Updated

Category

elixir

Tags

Contact