概要

ちょっとしたパーサを作る用事があったので、ScalaのRegexParsersを試してみる。

とりあえず各メソッドの動きを軽く確認して、CSVをパースできるようになるところまで。一歩ずつ慎重に歩いて行ってみる。

@CretedDate 2012/08/21
@Versions Scala2.9.2

前提知識

ScalaのParsersは噂のParser Combinatorというヤツで、複数のパーサを組み合わせて実行できる。

RegexParsersは名前の通り、正規表現を使ったParser Combinator。

例えばCSVをパースする際は、一行分のデータをsplitする正規表現と、行を分割する正規表現を別々に書いて、組み合わせて実行するみたいなことができる。

RegexParsers(というかその親クラスのParsers)は「~」とか「^^」といった記号を名前とするメソッドをいくつか持っている。

その辺りの意味を確認してからサンプルコードを読まないと、下記のような記号だらけのコードを前にして首を傾げることになるかも。

p1 <~ p2 ~~ { _.toDouble }

覚えておきたいこの手のメソッドとして、以下の子たちがいる。

p1 ~ p2p1に続いてp2が出現し、p1とp2を結果として返す
p1 <~ p2p1に続いてp2が出現し、結果はp1だけを返す
p1 ~> p2p1に続いてp2が出現し、結果はp2だけを返す
p1 ^^ fp1の結果に対して関数fを実行する
p1.*正規表現の「*」と同じ
p1.?正規表現の「?」と同じ
p1 | p2正規表現の「|」と同じ

上記のような感じで条件を組み合わせてパーサを表現できる。

どうでもいい話だけど、こういう風に書くとちょっと顔文字っぽい。

def o = "somthing".r
def b(str: String) = ()

o ^^ b

普通の正規表現を実行してみる

いきなりパーサを書くのはハードルが高そうなので、とりあえず数値だけを許可する正規表現を動かしてみる。

下記は、数値文字列を渡せばそのまま文字列を返し、数値以外の文字列を渡すと例外になる処理になる。

class SimpleRegex1 extends RegexParsers {
  def re = "[0-9]+".r
  def parse(input: String) = parseAll(re, input)
}

val result = new SimpleRegex1().parse("130")
println(result.get)
  //=> 130

数値以外を渡した場合は下記のような例外になる。

val result = new Sample().parse("abc")
println(result.get)
  //=> java.lang.RuntimeException: No result when parsing failed

「~」で複数の条件を連結してみる

2つの条件を繋げてみる。

下記は「アルファベットのあとにドットが続く」という条件を「~」で書いた場合。

class SimpleRegex2 extends RegexParsers {
  // 条件1、アルファベット
  def p1 = "[a-z]+".r
  // 条件2、カンマ
  def p2 = "."
  // p1に続いてp2が発生するという条件
  def re = p1 ~ p2

  def parse( input: String ) = parseAll( re, input )
}

val result = new SimpleRegex2().parse( "abc." )
println( result.get._1 )
  //=> abc
println( result.get._2 )
  //=> .

上記のように、_1にp1の値が、_2にp2の値が入って返ってくる。

「<~」で左側の値だけ返るようにする

上の例ではアルファベットとドット、双方が返ってきた。

「~」ではなく、「<~」を使うと左側のみ、「~>」を使うと右側のみが返ってくる。

class SimpleRegex3 extends RegexParsers {
  def p1 = "[a-z]+".r
  def p2 = "."
  def re = p1 <~ p2
  def parse( input: String ) = parseAll( re, input )
}

val result = new SimpleRegex3().parse( "abc." )
println( result.get )
  //=> abc

p2の部分(ドット)は返らず、p1の部分だけ返ってくる。

「^^」で結果に関数を噛ませる

下記は結果に対してtoUpperCaseを実行している。

class SimpleRegex4 extends RegexParsers {
  def p1 = "[a-z]+".r
  def p2 = "."
  def re = ( p1 <~ p2 ) ^^ ( _.toUpperCase )
  def parse( input: String ) = parseAll( re, input )
}

val result = new SimpleRegex4().parse( """abc.""" )
println( result.get )
  //=> ABC

結果が大文字になって「ABC」で返っている。

「p1 <~ p2」の部分は括弧で囲まなくても動くけど、なんとなく付けてる。

上記の例では「p1 <~ p2」に対して関数を実行しているけど、下記のようにp1に対して書いてしまってもいい。というかこの方がわかりやすいか。

class SimpleRegex4_2 extends RegexParsers {
  def p1 = "[a-z]+".r ^^ (_.toUpperCase)
  def p2 = "."
  def re = p1 <~ p2
  def parse(input: String) = parseAll(re, input)
}

正規表現に合致しない場合はNoneを返してみる

ここまでの実行結果は、ParseResultという型で返ってきている。あと、条件に合致しない場合が例外が返ってきている。

これを、Optionで返し、合致しない場合はNoneを返すようにしてみる。

class SimpleRegex5 extends RegexParsers {
  def re = "てきとー".r
  def parse( input: String ) = parseAll( re, input ) match {
    case Success( result, _ ) => Option( result )
    case _                    => None
  }
}

val result = new SimpleRegex5().parse( """abc""" )
println( result )
  //=> None

数値のみを抽出してみる

なんとなく書き方は分かってきたので、そろそろパーサっぽいことをしてみる。

試しに数値とアルファベットが混ざった文字列の中から、数値のみを取り出してみる。

途中で使われているrepは、繰り返しを意味する。「*」でもいい。

class SimpleRegex6 extends RegexParsers {
  // 条件1、数値
  def alpha = "[a-z]+".r
  // 条件2、アルファベット
  def num = "[0-9]+".r
  // 数値だけ取ってアルファベットは返さないよ、みたいな
  def re = rep( alpha.* ~> num <~ alpha.* )

  def parse( input: String ) = parseAll( re, input )
}

val result = new SimpleRegex6().parse( """abc123def456ghi789""" )
println( result.get )
  //=> List(123, 456, 789)

繰り返し表現を使っているので、結果がListで返ってくる。

数値のみを抽出してみる(連結)

上記の結果を連結して、「123456789」が返るようにしてみる。

class SimpleRegex7 extends RegexParsers {
  def alpha = "[a-z]+".r
  def num = "[0-9]+".r

  // 数値を取ってmkStringで連結
  def re = rep(alpha.* ~> num <~ alpha.*) ^^ (_.mkString)

  def parse(input: String) = parseAll(re, input)
}

val result = new SimpleRegex7().parse( """abc123def456ghi789""" )
println( result )
  //=> 123456789

1行のCSVをパースしてみる

CSVをパースするとかいうパーサっぽいことをさせてみる。

まずは1行だけの入力を分割してみる。split(",")と同じ結果になるようなイメージ。

class OneLineCsv1 extends RegexParsers {
  // カンマじゃない文字とカンマの繰り返し、みたいな
  def fields = ("[^,]*".r <~ ",").*
  def parse(input: String) = parseAll(fields, input)
}

val result = new OneLineCsv1().parse("""abc,def,ghi,""")
println(result.get)
  //=> List(abc, def, ghi)

「カンマじゃない <~ カンマ」という条件を使ってる。ただ、この書き方だと最後の要素の後にもカンマがいないと条件にマッチせずに例外になってしまう。

1行のCSVをパースしてみる(最後の要素を考慮)

行末にカンマがいなかった場合に対応してみる。

文末は\zで判定できるんじゃないかと思ったけど、なんかうまくいかなかったので、とりあえずこんな感じで。

class OneLineCsv2 extends RegexParsers {
  def comma = ","
  def field = "[^,]*".r

  // 1個目の要素があって、その後にカンマと2個目以降の要素が続く、みたいな
  def fields = field ~ ( comma ~> field ).* ^^ { case first ~ other => first :: other }

  def parse( input: String ) = parseAll( fields, input )
}

val result = new OneLineCsv2().parse( "abc,def,ghi" )
println( result.get )
  //=> List(abc, def, ghi)

これで最後にカンマが付かない場合も、要素が1つの場合も、「,abc,,def,」のような空行を含む場合もそれっぽくパースできた。

1行のCSVをパースしてみる(最後の要素を考慮・リベンジ)

上のような処理はrepsepを使えばもっと簡単にできるようだ。

class OneLineCsv3 extends RegexParsers {
  def comma = ","
  def field = "[^,]*".r
  def fields = repsep( field, comma )
  def parse( input: String ) = parseAll( fields, input )
}

val result = new OneLineCsv3().parse( "abc,def,ghi" )
println( result.get )
  //=> List(abc, def, ghi)

単純なCSVをパースしてみる

カンマで区切られて、\nで改行される単純なCSVをパースしてみる。

RegexParsersではskipWhitespace(空白文字をスキップするか)がデフォルトでtrueになっている。CSVでは空白文字(タブや改行含む)が無視されると困るので、falseにしておく。

class CsvParser1 extends RegexParsers {
  override val skipWhitespace = false

  // 一行用の表現
  def fields = repsep( field, comma )
  def field = "[^,\r\n]*".r
  def comma = ","

  // 複数行用の表現
  def lines = repsep( fields, eol )
  def eol = "\n"

  def parse( input: String ) = parseAll( lines, input )
}

val result = new CsvParser1().parse(
"""ab,cd,ef
gh,ij,
kl,mn,op""" )
println( result.get )
  //=> List(List(ab, cd, ef), List(gh, ij, ), List(kl, mn, op))

複数の条件を積み重ねてパーサが書けるところが良い感じ。

単純なCSVをパースしてみる(改行コード対応)

改行コードに\rが来ても\nが来ても大丈夫なようにしてみる。

class CsvParser2 extends RegexParsers {
  override val skipWhitespace = false

  def fields = repsep( field, comma )
  def field = "[^,\r\n]*".r
  def comma = ","

  def lines = repsep( fields, eol )
  // 複数の改行コードに対応
  def eol = "\r\n" | "\n" | "\r"

  def parse( input: String ) = parseAll( lines, input )
}

「|」を使って\r\nでも\nでも\rでも良いようにしている。

「|」は前に書いた方が先に評価される。\r\nを先に書いておかないと、単体の\rと\nが優先して評価されて、2行分の改行として扱われてしまう。

CSVをパースしてみる(ダブルコーテーション対応その1)

CSVはカラムをダブルコーテーションで囲ったり囲わなかったりする。

とりあえず1行に限定してダブルクオートで囲う表現を書いてみる。

class CsvParser3 extends RegexParsers {
  override val skipWhitespace = false

  // ダブルクオートで囲まれた文字列的な
  def field = dblquote ~> text <~ dblquote
  def dblquote = "\""
  def text = "[^,\"\r\n]*".r

  // 上記条件の繰り返し
  def fields = repsep( field, "," )

  def parse( input: String ) = parseAll( fields, input )
}

val result = new CsvParser3().parse( """"abc","def","ghi"""" )
println( result.get )
  //=> List(abc, def, ghi)

CSVをパースしてみる(ダブルコーテーション対応その2)

1行分はパースできたから、それを使って複数行に対応してみる。

class CsvParser4 extends RegexParsers {
  override val skipWhitespace = false

  // 一行の表現
  def fields = repsep( field, "," )
  def field = dblquote ~> text <~ dblquote
  def dblquote = "\""
  def text = "[^,\"\r\n]*".r

  // 複数行の表現
  def lines = repsep( fields, eol )
  def eol = "\r\n" | "\n" | "\r"

  def parse( input: String ) = parseAll( lines, input )
}


val result = new CsvParser4().parse( """"abc","def","ghi"
"jkl","mno","pqr"
"stu","vwx","yz"""" )
println( result.get )
  //=> List(List(abc, def, ghi), List(jkl, mno, pqr), List(stu, vwx, yz))

CSVをパースしてみる(ダブルコーテーション対応その3)

ダブルコーテーションは付けられることもあれば付けられないこともある。

どちらの表現でも大丈夫なようにする。

class CsvParser5 extends RegexParsers {
  override val skipWhitespace = false

  // ダブルクオート無しフィールド
  def normalField = "[^,\"\r\n]*".r

  // ダブルクオート有りフィールド
  def quoteField = dblquote ~> normalField <~ dblquote
  def dblquote = "\""

  // 一行の表現(ダブルクオート有りまたは無しのカンマ区切り)
  def fields = repsep( quoteField | normalField, "," )

  // 複数行の表現
  def lines = repsep( fields | fields, eol )
  def eol = "\r\n" | "\n" | "\r"

  def parse( input: String ) = parseAll( lines, input )
}

val result = new CsvParser5().parse( """abc,def,"ghi"
"jkl","mno",pqr
"stu",vwx,yz""" )
println( result.get )
  //=> List(List(abc, def, ghi), List(jkl, mno, pqr), List(stu, vwx, yz))

ただ、これだとまだダブルコーテーションのエスケープとか、ダブルコーテーション内にカンマや改行がある場合に対応できてない。

CSVをパースしてみる(ダブルコーテーション対応その4)

エスケープとか、ダブルコーテーション内のカンマとかに対応してみる。

class CsvParser6 extends RegexParsers {
  override val skipWhitespace = false

  // ダブルクオート無しフィールド
  def normalField = "[^,\r\n]*".r

  // ダブルクオート有りフィールド
  def quoteField = dblquote ~> ( ( "[^\"]".r | escDblquote ).* ^^ ( x => x.mkString ) ) <~ dblquote
  def dblquote = "\""

  // ダブルクオートのエスケープ(2つ連続で続く場合。値を返す際は1つに変換)
  def escDblquote = "\"\"" ^^ ( x => "\"" )

  // 一行の表現
  def fields = repsep( quoteField | normalField, "," )

  // 複数行の表現
  def lines = repsep( fields | fields, eol )
  def eol = "\r\n" | "\n" | "\r"

  def parse( input: String ) = parseAll( lines, input )
}

val result = new CsvParser6().parse( """abc,de"f,"gh
i"
"jk,l","m""no",pqr
"stu",vwx,yz""" )
println( result.get )
  //=> List(List(abc, de"f, gh
  //=>           i), List(jk,l, m"no, pqr), List(stu, vwx, yz))

まだ抜けはあるような気もするけど、だいぶそれっぽくなってきた。