Crystalでパーサー書いてみませんか?

  • 9
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

この記事はCrystal Advent Calendar 2015の6日目です。書き始めた段階で23:30とヤバいので駆け足でいきます。

Amorphousというパーサーライブラリを作ったので紹介します。

Amorphousの紹介

Crystalで実装されたパーサーライブラリです。HaskellのParsecやScalaのscala-parser-combinatorsみたいな感じです。細かい説明は省きます。

以下、Amorphousで書いたJSONパーサーの例です。

json.cr
require "colorize"

require "amorphous"

class Highlighter
  STYLE = {
    error:    {:white, :red, :bold},
    punctual: {:white,  nil, :bold},
    literal:  {:red  ,  nil, :bold},
    string:   {:green,  nil,  nil},
    keyword:  {:cyan ,  nil,  nil},
  }

  def highlight(io, str, style)
    if style && (s = STYLE[style]?)
      str = str.colorize
      str = str.fore(s[0].not_nil!) if s[0]
      str = str.back(s[1].not_nil!) if s[1]
      str = str.mode(s[2].not_nil!) if s[2]
    end
    io << str
  end
end

module JSONParser
  alias Result = Nil | Bool | String | Float64 | Array(Result) | Hash(String, Result)


  extend self

  include Amorphous
  include Amorphous::Parsers


  VALUE = Parser(Result).lazy


  # RFC 7159
  # https://tools.ietf.org/html/rfc7159

  # 2. JSON Grammer

  WS = (char(' ') | char('\t') | char('\n') | char('\r')).skip_many

  JSON = WS >> VALUE << WS << eof

  BEGIN_ARRAY = WS >> char('[').highlight(:punctual) << WS
  BEGIN_OBJECT = WS >> char('{').highlight(:punctual) << WS
  END_ARRAY = WS >> char(']').highlight(:punctual) << WS
  END_OBJECT = WS >> char('}').highlight(:punctual) << WS

  NAME_SEPARATOR = WS >> char(':').highlight(:punctual) << WS
  VALUE_SEPARATOR = WS >> char(',').highlight(:punctual) << WS


  # 6. Number

  DIGIT = range('0'..'9')
  DIGIT1_9 = range('1'..'9')
  E = char('e') | char('E')
  MINUS = char('-')
  PLUS = char('+')
  EXP = seq(E, (MINUS | PLUS).opt, DIGIT.some) do |e, op, num|
    "#{e}#{op}#{num}"
  end
  FRAC = seq(char('.'), DIGIT.some) do |pt, num|
    "#{pt}#{num}"
  end
  INT = char('0').map(&.to_s) | seq(DIGIT1_9, DIGIT.many.map(&.join)){ |c, s| "#{c}#{s}" }

  NUMBER = seq(MINUS.opt, INT, FRAC.opt, EXP.opt) do |op, i, f, e|
    "#{op}#{i}#{f}#{e}".to_f as Result
  end.highlight(:literal).name("number")


  # 7. Strings

  HEXDIG = DIGIT | range('a'..'f') | range('A'..'F')
  UNESCAPED = range('\u{20}'..'\u{21}') | range('\u{23}'..'\u{5B}') | range('\u{5D}'..'\u{10FFFF}')
  CHAR = UNESCAPED | (char('\\') >> (
    char('"')  |
    char('\\') |
    char('/')  |
    char('b').map{ '\b' } |
    char('f').map{ '\f' } |
    char('n').map{ '\n' } |
    char('r').map{ '\r' } |
    char('t').map{ '\t' } |
    char('u') >> HEXDIG.count(4).map{ |s| s.join.to_i(16).chr } |
    fail(Char, "Invalid escape sequence"))) |
    fail(Char, "Invalid character")
  STRING = (char('"') >> CHAR.many.map(&.join) << char('"'))
    .highlight(:string).name("string")


  # 4. Objects

  MEMBER = seq(STRING << NAME_SEPARATOR, VALUE)
  OBJECT = (BEGIN_OBJECT >> MEMBER.sep_by(VALUE_SEPARATOR) << END_OBJECT)
    .map do |object|
      Hash(String, Result).new.tap do |result|
        object.each do |name_and_value|
          name, value = name_and_value
          result[name] = value
        end
      end as Result
    end


  # 5. Arrays

  ARRAY = BEGIN_ARRAY >> VALUE.sep_by(VALUE_SEPARATOR).map{ |a| a as Result } << END_ARRAY


  # 3. Values

  TRUE = string("true").map{ true as Result }.highlight(:keyword).name("true")
  FALSE = string("false").map{ false as Result }.highlight(:keyword).name("false")
  NULL = string("null").map{ nil as Result }.highlight(:keyword).name("null")

  VALUE.bind = TRUE | FALSE | NULL | OBJECT | ARRAY | NUMBER | STRING.map{ |s| s as Result }


  def run(source : String)
    JSONParser.run(Source.new MemoryIO.new(source), "<source>")
  end

  def run(source)
    JSON.run source
  end
end

def test(source)
  puts "Source: #{source.inspect}"
  case res = JSONParser.run source
  when Amorphous::Success
    puts "Result: #{res.value.inspect}"
  else
    print "Failure: "
    res.to_s STDOUT, Highlighter.new
  end
  puts "---"
end

test %({})
test %("\\uGGGG")
test %({"hello": "world"})
test %({"ok": "\\google"})
test %({"hey": "siri",})

無駄にコードが長くてすみません。JSONParserモジュールの中身がAmorphousで組み立てられたパーサーになります。心意気で読んでください。

これだけだと、rparsecをCrystalに移植しただけみたいで面白くないので、ちょっと工夫してみました。上のコードを実行すると、次のような出力が表示されるはずです。

スクショ

注目してもらいたいのは、パースエラー出力の際に、エラーのあった行が表示されてエラーの位置がハイライトされているだけでなく、その前の部分も色付けされているところです。このようなエラー出力ができるパーサーはなかなか無いんじゃないでしょうか。

こういうパーサーが役に立つのは、例えば、あまり速度の要求されない設定ファイルの読み込みのときなんかなんじゃないでしょうか。エラーメッセージが華やかな方が便利かと。

懺悔

責任は果たしたので懺悔します。

まずAmorphousですが、まったくドキュメントもテストも書いてないです。なぜなら今日作ったからです。やっつけ仕事はいけませんね。来週くらいまでには整備したいです。せっかくテストするためのライブラリも作ったのに使わずじまいとは‥‥。無念なり。

Advent Calendarを書くこと自体は11月には分かっていたことなんだから、もっと前々から準備しておくべきでした。

記事も適当すぎますね。

そろそろ12:00になりそうなのでこれくらいにしておきます。ありがとうございました。