きっかけは突然に
弊社のインフラエンジニアの方がデプロイ作業の中でこんなようなperl
の処理系をコマンドの中にパイプで渡しているのを発見しました👀
ls | tail -1 | perl -ne 'if (/release command failed/){print 1}else{print 0}'
処理の内容はともかく、パイプの中に言語の処理系が組み込めることに感動しました。perl
は経験のない言語なので、このためだけに新しく言語を習得することはありませんが、自分が愛用しているElixir
でも同じことが出来ないか試してみました。リスト操作はElixir
のEnum
とパイプラインが得意とするところです。
ところが、ElixirのREAL
のiex
では上記のようにパイプの中に組み込んで引数を渡して、文字列として受け取ったコードを評価して実行するということは出来ませんでした。
出会いは突然に
その代わりといっては何ですが、色々と調査を進める内に、文字列として渡されたElixir
のコードを評価して実行することが出来るCode.eval_string
という関数を発見しました。こんな感じで文字列のコードを評価して実行して結果を得ることが出来ます。
iex(1)> code = "[1,2,3,4,5] |> Enum.map(&(&1 + 5)) |> Enum.sum()" "[1,2,3,4,5] |> Enum.map(&(&1 + 5)) |> Enum.sum()" iex(2)> Code.eval_string(code) {40, []}
Code.eval_string
には引数が3つまで渡すことが可能で変数を評価出来るようになっています。今回は詳細は省きますので、ドキュメントを参照して頂ければと思います。
ひらめきは突然に
プログラミングElixirの書籍の中でも紹介されている、コマンドライン引数をパースするサンプル(第13章)のことを唐突に思い出しました。「あれ、これもしかして、コマンドライン引数から文字列を渡せば任意のコードを実行出来るんじゃ...??」と思いつき、試してみました。
完成したコードはこちらです。
コマンドラインの評価は超がつくほどシンプルです。-c
というオプションの後に受け取った値をElixir
のコードとして評価するようにしています。
def parse_args(argv) do parse = OptionParser.parse(argv, switches: [c: :boolean], aliases: [ c: :code ]) case parse do { [ code: code ], args, _ } -> { :code, code, args } _ -> { :error, "", [] } end end
あとはここでパースしたコードをCode.eval_string
に渡すだけです。
defp eval_string({ :code, code, _ }) do { result, _ } = Code.eval_string(code) result end
作成したコードをコマンドとして実行出来るように、escript
を使ってコードをビルドします。githubの方にはすでにビルド済みのcommand_iex
というバイナリファイルをあげてあります。このファイルの名前を変更してみましたが問題なく使えることを確認しました。
# mix.exsにmain関数が記述された対象のモジュールを指定 defp escript_config do [ main_module: CommandIex ] end
mix escript.build
escriptsの詳細 elixirschool.com.
この時点で以下のようなことが可能になりました。ターミナルから文字列で渡したElixir
のコードが評価され実行されました。
$ ./command_iex -c '[1,2,3,4,5] |> Enum.map(&(&1+5)) |> Enum.sum()' 40
パイプに組み込む
次にやりたいのは引数をパイプ経由でコードに渡すことです。先ほどは[1,2,3,4,5]
という配列として扱いたい値を文字列で渡したコードの中に直接、書き込みました。これでは、不便なので引数を動的に渡して、評価するようにしてみます。Code.eval_string
の第2引数に評価したい変数名をキーワードリストで指定します。ここでネックになるのは引数として扱い値の変数名が固定されてしまうということです。マクロ等を使えば、もっと汎用性のあるコードが書けるでしょうが、簡単のため、今回はlst
という変数名で固定化してあります。
# [lst: args]を追加 defp eval_string({ :code, code, args }) do { result, _ } = Code.eval_string(code, [lst: args]) result end
これで以下のようなことが可能になりました。lst
にはargs
のデフォルト値の空配列([]
)が束縛されるため、実行の結果がエラーとならず0になっています。
$ ./command_iex -c 'Enum.map(lst, &(&1+5)) |> Enum.sum()' 0
変数も評価されるようになりました。
コマンドから引数を渡す
色々なことを試しましたが、ここはElxiir
の制御外で、コードの書き方でどうにかなる問題ではありませんでした。色々と調べた結果、xargs
というコマンドを使えばやりたいことが出来そうだったので、試してみました。なお、xargs
とパイプ経由でcommand_iex
に渡せたのはコマンドの実行結果だけでした。
$ ls | xargs ./command_iex -c 'Enum.map(lst, &(&1 <> "_test"))' ["README.md_test", "_build_test", "build.sh_test", "command_iex_test", "lib_test", "mix.exs_test", "test_test"]
なんということでしょう!
ls
コマンドの実行結果の値を引数としてcommand_iex
に渡されて、値がlst
に束縛され、評価され、実行されました。まさに今回やりたかったことはこれです。試しにls
の実行結果から.md
ファイルだけを抽出してみます。
$ ls | xargs ./command_iex -c 'Enum.filter(lst, &(String.contains?(&1, ".md")))' ["README.md"]
やりました!!
PATHを通してどこからでも使えるようにする
このままだと/command_iex
ディレクトリの直下でしか実行出来ないので、どこからでも呼び出せるように自作コマンドとして登録します。私の環境はMacだったので、こちらの記事を参考に自作コマンドciex
(command line iexの略)として./command_iex
を呼び出すようにしました。
これでciex
が呼びされるようになり、どこからでも実行出来るようになりました。
(解決出来ていないエラーが出るので、お分かりの方がいましたら、ぜひPRを...🙇♂️).
$ ciex ** (FunctionClauseError) no function clause matching in CommandIex.eval_string/1 The following arguments were given to CommandIex.eval_string/1: # 1 {:error, "", []} (command_iex 0.1.0) lib/command_iex.ex:15: CommandIex.eval_string/1 (command_iex 0.1.0) lib/command_iex.ex:10: CommandIex.main/1 (elixir 1.11.4) lib/kernel/cli.ex:124: anonymous fn/3 in Kernel.CLI.exec_fun/2
先程のコマンドを実行出来ることを確認しました。
$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md")))' ["README.md"]
文字列として受け取れば、次のパイプにつなげることも出来ます。
$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md"))) |> Enum.at(0)' "README.md"
$ ls | xargs ciex -c 'Enum.filter(lst, &(String.contains?(&1, ".md"))) |> Enum.atgs echo "file: " file: README.md
やりたいことが出来ました!