Posted at
ElixirDay 22

ElixirでJSONを整形+シンタックスハイライトしてみた

本記事はElixir Advent Calendar 2018の22日目です。

Elixirを作って構文解析をやってみたかったので、簡単そうなJSONを解析して


整形 + シンタックスハイライトして標準出力に表示するライブラリを作ってみました


作ったもの

https://github.com/tamanugi/ex_json_coloring

https://hex.pm/packages/ex_json_coloring

スクリーンショット 2018-12-23 19.33.46.png


実装


構文解析の実装

構文解析では文字列の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ずつテストしながら実装を行っていくことで、スムーズに実装を進めていくことができました :sparkles:

少し悩んだところが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にできていないので、

そのへんをきちんとして普通に使えるライブラリのレベルにできたらなーと思っています。

(テストとドキュメントも書かないとだめですね :sweat: )

またこのライブラリを使って jq のようなCLIツールをElixirで書きたいと考えていたりします。

もしご指摘などありましたら、じゃんじゃんもらえると嬉しいです!