#elixirlang 커맨드 라인 옵션 해석에 사용하는 OptionParser 모듈과 help 옵션 구현 고민

3 minute read

'Options'

POSIX 커맨드라인 컨벤션을 따르는 옵션 구문 해석(parser) 모듈이다. elixir 표준 라이브러리(standard library)에 포함되어 있다. escript로 커맨드 라인 툴을 짤 때, 주로 사용한다. slack 봇인 slab을 만들 때, 채팅창에 입력하는 입력 파싱이 귀찮아서 OptionParser를 사용했다.

OptionParser 기본 사용법

OptionParser.parse(
  ["--load=file_name", "--directory", "/foo", "-q", "--", "--argument1", "argument2"],
  aliases: [q: :quick],
  strict: [load: :string, directory: :string, quick: :boolean])
{[load: "file_name", directory: "/foo", quick: true],
 ["--argument1", "argument2"], []}

파싱(parsing)할 문자열 리스트와 옵션을 인자로 넘기면 튜플을 리턴한다. 파싱에 성공한 옵션인 [load: "file_name", directory: "/foo", quick: true] 가 튜플 첫 번째 요소다. 옵션이 아닌 ["--argument1", "argument2"] 는 튜플 두 번째 요소다. 파싱에 실패한 옵션이 있다면 튜플 세 번째 요소로 들어온다.

help 지원이 없다 - python은 편한데

import argparse

parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument('integers', metavar='N', type=int, nargs='+',
                    help='an integer for the accumulator')
parser.add_argument('--sum', dest='accumulate', action='store_const',
                    const=sum, default=max,
                    help='sum the integers (default: find the max)')

args = parser.parse_args()
print(args.accumulate(args.integers))
$ python prog.py -h
usage: prog.py [-h] [--sum] N [N ...]

Process some integers.

positional arguments:
N           an integer for the accumulator

optional arguments:
-h, --help  show this help message and exit
--sum       sum the integers (default: find the max)

python argparse 모듈은 add_argument 함수 help 파라미터로 도움말을 넣으면 --help 옵션에 해당하는 도움말을 자동으로 출력해준다.

elixir mix는 help 명령을 어떻게 구현했을까?

그렇다면 help를 어떻게 구현했을까? elixir 빌드 툴인 mix에 help 명령이 생각났다. 어떻게 처리하고 출력할까? mix라는 용어도 참 재미있다. elixir(영약)을 만들기 위해 mix(혼합) 한다. 용어가 기가 막히게 들어맞는다.

defmodule Mix.CLI do
  # ..
  defp display_usage do
    Mix.shell().info("""

    Usage: mix [task]

    Examples:

    mix             - Invokes the default task (mix run) in a project
    mix new PATH    - Creates a new Elixir project at the given path
    mix help        - Lists all available tasks
    mix help TASK   - Prints documentation for a given task

    The --help and --version options can be given instead of a task for usage and versioning information.
    """)
  end
  # ..
end
$ mix help

명령을 치면 나오는 도움말은 그냥 텍스트로 출력하게 구현했다. mix help TASK 명령을 입력하면 해당 TASK에 대한 도움말이 나온다. compile에 대한 도움말을 보고 싶으면 mix help compile 명령을 입력하면 된다. 이건 어떻게 구현했을까?

defmodule Mix.Tasks.Help do
  # ...
  def run([task]) do
    # ...
    for doc <- verbose_doc(task) do
      print_doc(task, doc, opts)
    end

    :ok
  end

  defp task_doc(task) do
    module = Mix.Task.get!(task)
    doc = Mix.Task.moduledoc(module) || "There is no documentation for this task"
    {doc, where_is_file(module), nil}
  end
  # ...
end

mix help compile 명령을 실행하면 Mix.Tasks.Help.run(["compile"]) 함수를 실행한다. verbose_doc/1 함수 안에서 task_doc/1 함수를 실행하는데, 함수 구현에 재미있는 코드가 보인다. Mix.Task.moduledoc(module) 바로 이 코드.

defmodule Mix.Task do
  # ...

  @spec moduledoc(task_module) :: String.t() | nil | false
  def moduledoc(module) when is_atom(module) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} -> moduledoc
      {:docs_v1, _, _, _, :none, _, _} -> nil
      _ -> false
    end
  end
end

elixir 모듈에 @moduledoc 속성(attribute)으로 모듈에 대한 설명을 적는다. mix help TASK 명령은 그 설명을 가져와서 출력한다.

defmodule Mix.Tasks.Compile do
  # ...

  @moduledoc """
  The main entry point to compile source files.

  It simply runs the compilers registered in your project and returns
  a tuple with the compilation status and a list of diagnostics.

  Before compiling code, it loads the code in all dependencies and
  perform a series of checks to ensure the project is up to date.

  ## Configuration
  ...

  ## Compilers
  ...

  ## Command line options

  * `--erl-config` - path to an Erlang term file that will be loaded as Mix config
  * `--force` - forces compilation
  * `--list` - lists all enabled compilers
  * `--no-app-loading` - does not load applications (including from deps) before compiling
  * `--no-archives-check` - skips checking of archives
  * `--no-compile` - does not actually compile, only loads code and perform checks
  * `--no-deps-check` - skips checking of dependencies
  * `--no-elixir-version-check` - does not check Elixir version
  * `--no-protocol-consolidation` - skips protocol consolidation
  * `--no-validate-compile-env` - does not validate the application compile environment
  * `--return-errors` - returns error status and diagnostics instead of exiting on error
  * `--warnings-as-errors` - exit with non-zero status if compilation has one or more warnings
  """

즉, mix help compile 명령을 입력하면 Mix.Tasks.Compile 모듈에 @moduledoc 속성으로 정의한 도움말이 출력된다.

커맨드 라인 help 옵션 구현

defmodule MyAwesomeApp.Cli do
  @moduledoc """
  커맨드라인 도움말로도 쓸 모듈 설명
  """

  def main(args) do
    {opts, args} =
      OptionParser.parse!(args,
        strict: [
          # ...
          help: :boolean
        ]
      )

    if opts[:help] do
      print_help()
      System.halt()
    end

    # ...
  end

  defp print_help() do
    IO.puts(@moduledoc)
  end
end

CLI(command line interface) 처리를 책임질 cli 모듈을 만든다. mix task처럼 @moduledoc 속성에 모듈 설명 및 커맨드라인 도움말을 적는다. 외부 모듈 설명을 가져오는 게 아니니 Code.fetch_docs/1 함수를 쓰지 말고 @moduledoc 속성을 바로 사용하면 된다.

help 옵션을 자동으로 생성? 모듈 설명 잘 적고 그걸 help로 출력하면 되잖아? 이런 접근도 재미있다. 하지만 난 규격화된 출력으로 생성해주는 python 방식이 더 편하다.