魔法陣を描きながらmakeを叩くCLIをElixirで作ってみました
Elixirのライブラリ作ってみたいなぁ \
- 日常的に使えるものだと愛着湧くなぁ \
- 最近Earthlyというmakeに関連した技術が良かったなぁ、 \
- (Elixir...魔法の薬、、、魔法、、、 + make...)
= makeを叩く時に魔方陣が表示されるCLIでも作ってみよう!!
という、安直な動機から、こちらのCLIを作成致しました。
実装に関して、ライブラリの紹介を兼ねて、設計諸々もご紹介させていただけたらとおもいます。
参考になりましたら幸いです。
CLIの作り方に関してはこちらの記事でご紹介していますので、「Elixir盛り上げちゃうぞ、CLI作っちゃうぞ」 という方はぜひ見てやってください。
magical_makeの実装解説
以下要件
- make commandの資産をそのまま使うことが出来る
- 魔法陣のアスキーアートをイイカンジに描画することが出来る
- 叩くmake command自体のアスキーアートを出力することが出来る
- 魔法陣のアスキーアートやmake commandは色付けして出力することが出来る
- CLIを用いて上記操作を行うことが出来る
全体像
以下、各要件とそれぞれに該当する関数の一覧です。
MagicalMake.Circle | MagicalMake.CLI | MagicalMake.Font | MagicalMake.Make | MagicalMake.SystemCommand | MagicalMake.Painter | |
---|---|---|---|---|---|---|
make command | - | - | - | exec_make | exec | - |
魔法陣のアスキーアート | draw_circle | - | - | - | - | draw |
make commandのアスキーアート | - | - | - | draw_make | - | draw |
色付け | - | - | create_decoration | - | - | - |
CLI | - | main | - | - | - | - |
さっぱりしてますね、execだったりexecuteだったりは多少気の迷いが感じられます。
MagicalMake.executeの流れ
MagicalMake.execute
が全体の処理をまとめたmoduleとなっています。
この処理を追っていけば実装の内容や各module責務や設計が把握出来ます。
def execute(make_command) do
make_check!() # ① makeが実行可能な事を確認
font_decoration = create_decoration() # ② 色付け情報の作成
draw_circle(@interval, font_decoration) # ③ 魔法陣の描画
|> draw_make(make_command, @final_interval) # ④ 魔法陣の前面にmakeのアスキーアートを描画
exec_make(make_command) # ⑤ make commandの実行
:ok
end
こちらの順番に沿ってそれぞれ関数の解説を以下記載します。
MagicalMake.Fontの解説
MagicalMake.executeでのこちらの部分です
def execute(make_command) do
...
font_decoration = create_decoration() # ② 色付け情報の作成
...
end
実装はこちら
defmodule MagicalMake.Font do
@esc "\e"
@colors [
black: "#{@esc}[30m",
red: "#{@esc}[31m",
green: "#{@esc}[32m",
yellow: "#{@esc}[33m",
blue: "#{@esc}[34m",
magenta: "#{@esc}[35m",
cyan: "#{@esc}[36m"
]
def create_decoration() do
@colors |> Keyword.values() |> Enum.random()
end
end
create_decoration/0
予めstdoutへの色付け情報をKeywordListとして保持しておき、それらをランダムで取得するだけというシンプルな実装です。
今後色味の指定が出来るようにしたり、それらを用いてトリッキーな絵柄を作ったりと、拡張する可能性が高いmoduleとなっております。@escはわざわざmodule変数に分けなくても良かったかも。。。
MagicalMake.Circleの解説
MagicalMake.executeでのこちらの部分です
def execute(make_command) do
...
draw_circle(@interval, font_decoration) # ③ 魔法陣の描画
...
end
実装はこちら
defmodule MagicalMake.Circle do
@last_word "M"
@gsub_words [
".",
";",
"i",
"I",
@last_word
]
@assets_path "priv/assets/circles/"
@circle_texts [
elem(File.read(@assets_path <> "1.txt"), 1),
elem(File.read(@assets_path <> "2.txt"), 1),
elem(File.read(@assets_path <> "3.txt"), 1),
elem(File.read(@assets_path <> "4.txt"), 1),
elem(File.read(@assets_path <> "5.txt"), 1)
]
def draw_circle(interval, font_decoration) do
circle_txt = @circle_texts |> Enum.random()
Enum.each(@gsub_words ++ [], fn word ->
(font_decoration <> String.replace(circle_txt, ~r/\S/, word))
|> MagicalMake.Painter.draw(interval)
end)
font_decoration <> String.replace(circle_txt, ~r/\S/, @last_word)
end
end
draw_circle/2
事前に用意しておいたアスキーアートを priv/assets/circles/
に配置しておき、module変数として定義しておきます。
なぜ先んじて読み込んでおく必要があるかというのは、こちらでまとめておりますので、興味がある方はご閲覧ください。
それらをランダムに取得して、@gsub_words
の順に MagicalMake.Painter.draw
を用いて徐々に描画していきます。
文字列を@gsub_words
の順に置き換えることにより、アスキーアートのデータに依存せず、表示を安定させています。
また、最後に描画される文字列を返却して、後述のmakeのアスキーアートの背面に指定できるような設計となっております。
アスキーアート自体の種類に関しては @circle_texts
、アニメーションに関しては @gsub_words
で担保しているため、変更のしやすいmoduleとなってます。
ロジック的にはシンプルですので、これらを他言語で実装することも容易いかと思います。
MagicalMake.Makeの解説
MagicalMake.executeでのこちらの部分です
def execute(make_command) do
make_check!() # ① makeが実行可能な事を確認
...
|> draw_make(make_command, @final_interval) # ④ 魔法陣の前面にmakeのアスキーアートを描画
exec_make(make_command) # ⑤ make commandの実行
end
実装はこちら
すこし長くなってしまったため、private functionに関しては割愛させていただきます。
defmodule MagicalMake.Make do
@make "make"
@asc_word "※"
@pin_word "@"
@make_y_range 1..8
@make_x_range 0..100
@circle_x_center 75
@ciercle_y_make_start 17
@font elem(Chisel.Font.load("priv/assets/5x8.bdf"), 1)
def make_check! do
System.find_executable(@make) ||
raise MagicalMake.MakefileMissing,
message: "Makefile does not exist. Must exist in current directory."
:ok
end
def exec_make(command) do
MagicalMake.SystemCommand.exec(
@make,
[command |> to_string()],
into: IO.stream(:stdio, :line)
)
end
def draw_make(circle_text, make_command, interval) do
create_make_art(circle_text, make_command)
|> MagicalMake.Painter.draw(interval)
end
defp create_make_art(circle_text, make_command) do
make_texts = create_make_texts(make_command)
circle_text
|> String.split("\n")
|> Enum.with_index(fn text, index ->
if index >= @ciercle_y_make_start && index < @ciercle_y_make_start + Enum.count(make_texts) do
make_texts |> Enum.at(index - @ciercle_y_make_start)
else
text
end
end)
|> Enum.join("\n")
end
defp create_make_texts(make_command) do
...
make_texts = ... # Chisel.Renderer.reduce_draw_textを用いてmake commandをアスキーアートに変換
space_count = @circle_x_center - max_x_length(make_texts) |> div(2)
spaces = Enum.map(0..(space_count - 1), fn _ -> " " end)
# 魔法陣の真ん中に表示されるようにmake commandアスキーアートの開始位置を整えていく
fin_make_texts = make_texts |> Enum.map(fn words -> spaces ++ words end)
# 境界線を含むmake commandアスキーアートを作成
(create_partition() ++ fin_make_texts ++ create_partition())
end
defp create_partition() do
# 魔法陣とmake commandのアスキーアートの境界線を作成
end
defp max_x_length(make_text) do
# make commandのアスキーアートでの最大の長さをゴニョゴニョして取得する
...
end
end
make_check!/0
System.find_executable を用いてmake comamndを叩くことができるかの確認。不可だった場合 MagicalMake.MakefileMissing
の例外を発火します。
exec_make/1
MagicalMake.SystemCommand
に対して指定の引数でmake commandの発火を促します。
draw_make/3
make commandをChiselを用いてアスキーアートに変換。
それらを魔法陣の真ん中に描画出来るよう加工、背面が魔法陣、前面がmake commandのアスキーアートとして、指定インターバルで描画します。
create_make_art
に関しては割愛しつつも流れ自体にコメントを記述しておきました。
@circle_x_center 75, @ciercle_y_make_start 17
といった変数の計算を動的ではなく実装してしまったため、事前に用意してあるアスキーアートへの依存度が高くなってしまっており、この部分に関しては、後に改善したいポイントです。
この辺りのロジックが一番複雑度が高い部分になってるかと思います。
MagicalMake.Painter, MagicalMake.SystemCommandの補足
MagicalMake.PainterとMagicalMake.SystemCommandの補足をさせていただきます。
MagicalMake.Painter
defmodule MagicalMake.Painter do
def draw(text, interval) do
IO.puts(text)
Process.sleep(interval)
refresh!()
:ok
end
def refresh! do
:font_reset |> sys_cmd()
:clear |> sys_cmd()
:ok
end
end
draw
描画に関してはシンプルで、必要な以下処理を順次行うのみの実装です。
-
IO.puts
での表示 - sleepによるintervalの実現
- refresh!()による画面クリアとfont情報のreset
refresh!
SystemCommand.sys_cmd()で定義されている画面クリアとfont情報のresetを呼び出しを行っております。
MagicalMake.SystemCommand
defmodule MagicalMake.SystemCommand do
@commands [
clear: %{
command: "printf",
args: ["'\e[2J\e[3J\e[H'"],
opts: [into: IO.stream(:stdio, :line)]
},
font_reset: %{
command: "tput",
args: ["sgr0"],
opts: [into: IO.stream(:stdio, :line)]
}
]
def sys_cmd(command) do
command_map = Keyword.fetch!(@commands, command)
exec(
command_map.command,
command_map.args,
command_map.opts
)
end
def exec(command, args, opts) do
System.cmd(
command,
args,
opts
)
:ok
end
end
sys_cmd
MagicalMake内で利用されるコマンドを事前に変数に入れておき、それらをatomを用いて呼び出すだけのシンプルなfunctionです。
exec
System.cmdのwrapper functionです。わざわざ分けずともいいくらいの実装になってます。
おわりに
今回Elixirを用いたCLIの作成では、初めてなことが多く、とても勉強になりました。
magical_make自体は副作用も少ない軽量なCLIです。
是非、入れて遊んでみていただきたいです。make commandを楽しく表示出来るだけですが、ちょっとだけ気分が変わります。
hexへの登録に辺り、module docの記述はspec, unittest, actionsの整備等も経験する事ができたので、ライブラリの作成はElixirコトハジメにはベストなのでは?と感じました。
幸いなことに、アイディアはまだありますので、今後またElixir開発に役立つようなものをなにか作ってみたいと思っております。
2022はガッツリElixirをしていきたいと思います。
引き続きよろしくおねがいします。