4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

本記事は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で書きたいと考えていたりします。

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

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?