本記事はElixir Advent Calendar 2018の22日目です。
Elixirを作って構文解析をやってみたかったので、簡単そうなJSONを解析して
整形 + シンタックスハイライトして標準出力に表示するライブラリを作ってみました
作ったもの
https://github.com/tamanugi/ex_json_coloring
https://hex.pm/packages/ex_json_coloring
実装
構文解析の実装
構文解析では文字列のJSONを以下のようなトークンに変換することに決め、
実装を進めていきました。
# {"key": "value"}
[
%ExJsonColoring.Token{type: :brace, value: "{"},
%ExJsonColoring.Token{type: :key_string, value: "key"},
%ExJsonColoring.Token{type: :colon, value: ":"},
%ExJsonColoring.Token{type: :string, value: "value"},
%ExJsonColoring.Token{type: :brace, value: "}"},
]
実装の際にはJSONのBNFに従って、進めていきました。
# array
defp value("[" <> rest, acc, state_stack) do
token = %Token{type: :square_bracket, value: "["}
value(rest, [token | acc], [:array | state_stack])
end
defp value("]" <> rest, acc, [:array | tail ]) do
token = %Token{type: :square_bracket, value: "]"}
value(rest, [token | acc], tail)
end
defp value("," <> rest, acc, [:array | _] = state_stack) do
token = %Token{type: :comma, value: ","}
value(rest, [token | acc], state_stack)
end
# object
defp value("{" <> rest, acc, state_stack) do
token = %Token{type: :brace, value: "{"}
key_string(rest, [token | acc], [:object | state_stack])
end
defp value(":" <> rest, acc, state_stack) do
token = %Token{type: :colon, value: ":"}
value(rest, [token | acc], state_stack)
end
defp value("}" <> rest, acc, [:object | tail]) do
token = %Token{type: :brace, value: "}"}
value(rest, [token | acc], tail)
end
defp value("," <> rest, acc, [:object | _] = state_stack) do
token = %Token{type: :comma, value: ","}
key_string(rest, [token | acc], state_stack)
end
defp key_string(rest, acc, state_stack) do
{rest, str_val} = string_start(rest |> skip_ws)
token = %Token{type: :key_string, value: str_val}
value(rest, [token | acc], state_stack)
end
# string
defp value(ws, acc, _) when ws in '\s\n\t\r', do: {"", acc}
defp value(~s(") <> _ = string, acc, state_stack) do
{rest, str_val} = string_start(string)
token = %Token{type: :string, value: str_val}
value(rest, [token | acc], state_stack)
end
defp string_start(~s(") <> rest) do
string(rest, "")
end
defp string(~s(") <> rest, acc), do: {rest, acc}
defp string(<<char>> <> rest, acc) do
string(rest, acc <> <<char>>)
end
# number
defp value(<<char>> <> rest, acc, state_stack) when char in '-0123456789' do
{rest_, number_val} = number(rest, [char])
token = %Token{type: :number, value: number_val |> List.to_string}
value(rest_, [token | acc], state_stack)
end
defp number(<<char>> <> rest, acc) when char in '0123456789.' do
number(rest, acc ++ [char])
end
defp number(rest, acc), do: {rest, acc}
# boolean
defp value("true" <> rest, acc, state_stack) do
token = %Token{type: :boolean, value: "true"}
value(rest, [token | acc], state_stack)
end
defp value("false" <> rest, acc, state_stack) do
token = %Token{type: :boolean, value: "false"}
value(rest, [token | acc], state_stack)
end
# null
defp value("null" <> rest, acc, state_stack) do
token = %Token{type: :null, value: "null"}
value(rest, [token | acc], state_stack)
end
# ws
defp value(<<char>> <> rest, acc, state_stack) when char in '\s\n\t\r' do
value(rest, acc, state_stack)
end
defp skip_ws(<<char>> <> rest) when char in '\s\n\t\r' do
skip_ws(rest)
end
defp skip_ws(string) do
string
end
最初の一文字目を使ったパターンマッチングやガード節などはElixirではおなじみのJSON ParserライブラリのPoisonを参考にさせてもらいました。
Doctestを使って1stepずつテストしながら実装を行っていくことで、スムーズに実装を進めていくことができました
少し悩んだところがBNFではオブジェクトのキーも普通の文字列なのですが、
ハイライトするにあたってはオブジェクトのキーとしての文字列なのか、値としての文字列なのか
区別する必要があるところでした。
現在の解析している対象(オブジェクトor配列)をスタックとして持っておき、
オブジェクト解析中に,
の後ろにある文字列をオブジェクトキーとするようにしました。
出力(整形, ハイライト)の実装
出力とフォーマットは簡単で、構文解析器にかけたトークンのリストを一つずつ見ていき、
色とインデント, 改行するしないを設定していくだけです。
Elixirでは標準出力の色付けに IO.ANSI
モジュールが使えますが、
色の指定が面倒そうだったのでBunt というライブラリを使うことにしました
defp format(:brace, value, indent_lv) do
case value do
"{" ->
{[color(:brace), "{", "\n", indent(indent_lv + 1)], indent_lv + 1}
"}" ->
{["\n", indent(indent_lv - 1), color(:brace), "}"], indent_lv - 1}
end
end
defp format(:square_bracket, value, indent_lv) do
case value do
"[" ->
{[color(:square_bracket), "[", "\n", indent(indent_lv + 1)], indent_lv + 1}
"]" ->
{["\n", indent(indent_lv - 1), color(:square_bracket), "]"], indent_lv - 1}
end
end
defp format(:comma, ",", indent_lv) do
{[color(:comma), ",", "\n", indent(indent_lv)], indent_lv}
end
defp format(:colon, ":", indent_lv) do
{[":", " "], indent_lv}
end
defp format(type, value, indent_lv) when type in [:string, :key_string] do
{[color(type), "\"#{value}\""], indent_lv}
end
defp format(type, value, indent_lv) do
{[color(type), value], indent_lv}
end
defp indent(0), do: ""
defp indent(indent_lv) do
for _ <- 1..(indent_lv * 2), do: " "
end
defp color(:string), do: :green
defp color(:key_string), do: :fuchsia
defp color(:boolean), do: :blue
defp color(:number), do: :lightyellow
defp color(:null), do: :lightgray
defp color(_), do: Bunt.ANSI.reset
感想とか
昔応用情報を勉強したときには BNF のありがたみなどが全然わからなかったのですが、
構文解析器を実装するにあたって、BNFに従ってひとつずつ実装していくだけでよかったので
すごい楽でした。
エスケープ文字の実装などを行っておらず、またコードもあまりDRYにできていないので、
そのへんをきちんとして普通に使えるライブラリのレベルにできたらなーと思っています。
(テストとドキュメントも書かないとだめですね )
またこのライブラリを使って jq
のようなCLIツールをElixirで書きたいと考えていたりします。
もしご指摘などありましたら、じゃんじゃんもらえると嬉しいです!