2012/12/18

parsec で極める文章編集

正規表現をまったく使えない編集者はひとにぎりだと思いますが、正規表現だと原稿の半角丸括弧を全角に変換する作業とか頭痛いですよね。わたしもいつも困ってました。

というわけで、いまや編集者必須ツールといってもいい parsec を新人編集者にぜひ使ってもらおうということで、 「Haskell Advent Calendar 2012」18日目という場を借りた素人チュートリアル記事です。 Haskeller が書いてるわけではないので、 「その考え方は違う」とか「もっと効率的な書き方がある」といったコメントがもらえるとうれしいです。 ちなみに、わたしの周りに新人編集者はもう何年もいません。まだ見ぬ新人へ向けて書きます。

parsec で最速テキストフィルター

最初に parsec を使おうと思ったときにぶちあたるのは、プログラマ向けの解説しかないことだと思います。 編集者というものは、 CSV や IP アドレスをパースしたり、ましてや関数電卓を作ったりしない。 Ruby で正規表現を使ったテキストフィルターを書くときみたいに parsec を使うにはどうすればいいでしょうか。

外側から考えます。いま作りたいのは、入力ファイルを指定して、何か文字列変換を施した結果を出力するテキストフィルターです。Ruby なら ARGF.gets とかすればいいとこですが、ここは我慢してとりあえずこんな枠組みを書きます。

module Main () where

import System.Environment
import qualified System.IO as IO
import Text.ParserCombinators.Parsec hiding (many, (<|>))
import Control.Applicative

main = do
  args <- getArgs
  inh <- IO.openFile (args !! 0) IO.ReadMode
  body <- IO.hGetContents inh
  IO.putStr $ doSomething body
  IO.hClose inh

doSomething が具体的な変換処理で、ここに「日本語文章に出てくる半角丸括弧を全角に」とか「TeX の数式を抜き出す」とか「コード行に出てくるキーワードにハイライトのタグをつける」とかいった処理をするパーザを parsec で書くわけです。

では doSomething を考えましょう。ここでは doSomething という名前を説明のために使い続けますが、実際にスクリプトを書くときはテキスト変換処理を表す適切な名前をつけてください。

doSomething :: String -> String
doSomething lines = case parse (concat <$> manyTill block eof) "" lines of
  Left  err -> ""
  Right str -> str

テキストフィルターなので doSomething は文字列から文字列への変換を担う関数でないと困ります。 そこで 1行目には String -> String と書いてあります。

case ... of の内側の manyTill block eof が、とっかかりとなる最初のパーサです。 これは、「今はなんだか決めてないけど block という文字列の塊を取ってくるパーザがあるとして、それをファイルの終わり eof まで繰り返し実行する(manyTill)」、という意味です。 繰り返しとってきたその結果は、文字列のリストなので、一つにつなげるために concat <$> と書いています。 このように関数のうしろに <$> と書き、続けてパーザを書くと、「うしろに書いたパーザが返すものに最初の関数を適用したものを返すパーザ」になります。

ここで注意しないといけないのは、パーザは「文字列をパースして得られる文字列」を返してくれるわけではないという点です。 parsec では、パーズして得られる結果は「パーザを実行する専用世界」の中にあり続けます。 その専用世界を Parser と呼ぶことにしていて、 だから parsec におけるパーザは、たとえば文字列を返すものであれば Parser String といいます。文字を返すものなら Parser Char です。何も返さないパーザというのもあって(スペースを読み飛ばす、とかです)、これは Parser () といいます。

この Parser 世界の中にフィルタリングしたい文字列を入れて、パーズした結果をもらいたいわけですが、この世界とのやり取りは決められた出入り口からしかできないようになっています。 someParser というパーザを書いたとして、それで文字列をパーズして結果を「専用世界」の外に引っ張り出してくる方法のひとつが、上記の parse someParser "" lines という書き方です。

こうして手に入る結果は「パースに成功してこんな文字列が手に入った、または失敗した」というちょっと変わった形をしています。そのままでは doSomething の結果としてふさわしくありません(だって doSomething は文字列を返すってことにしたので)。そこで case ... ofRight および Left という識別子を使って、成功の場合も失敗の場合も文字列を返すようにしています。作ってるのがテキストフィルターなので、失敗の場合は空文字列を返しとけばいいでしょう。

ここでようやく block を何にするか決めます。 テキストフィルターの仕様を考えるわけです(というわけで、ここまではテキストフィルターを書くときの定型だと思ってもいいです)。

いま、HTML の <p> タグの内側にある半角丸括弧だけをすべて全角丸括弧に直したいとします。 一方、 <pre> タグの中にある半角丸括弧とかは、コードの断片である可能性が高いので、変換してはいけないとします。数式なんかに出てくる半角丸括弧も全角にしてはいけません。 いま仮に、 <p> タグの中には日本語の本文だけしかないものとしましょう(でないと説明のコードが増えてしまうからです。べつに一定のルールにしたがって出てくるぶんには、その部分だけ処理を飛ばすようにパーザを書けばいいのです。ただし完璧を目指すと泥沼になるので適当な精度で切り上げましょう)。

この場合の block の仕様はこうです。こいつは文字列を返すパーザにしたいので、 Parser String だと宣言しておきます。

block :: Parser String
block = choice [ try japara
               , otherlines ]

japara :: Parser String
japara = string "<p>" *> (conc <$> manyTill anyChar (try $ string "</p>"))
  where conc = ("<p>"++) . (++"</p>") . replaceParen

otherlines :: Parser String
otherlines = manyTill anyChar $ (try $ string "\n")

ざっくりというと、日本語の本文(japara パーサ)かそれ以外(otherlines パーサ)かで選択(choice 関数)をして、日本語の本文だったら半角括弧を全角に変換します(replaceParen 関数)。 block だけでなく、そこから呼んでる japara だったり otherlines だったりは、すべて文字列を返すパーザです。こんなふうに、基本的なパーザをいろいろ組み合わせて好きなパーザを作るわけです。 string [文字列] とか anyChar なんかも、もちろんパーザで、これらのいわば最小の部品は parsec にあらかじめ用意されています。ほかの部品はここにドキュメントがあるので探してください。

japara、つまり日本語の本文は、「文字列 <p> から </p> までの内側」です。 *> は、右側だけを結果に残すようなパーザを作ってくれます。

otherlines、つまり日本語の本文以外は、「改行までの文字なら何でも」とってくるパーザです。 この定義だと 1行とったら終わってしまうように思いますが、外枠のほうで block を何度も繰り返しとり出すことにしてあるので、これで問題ありません。

<p> タグ内で半角括弧の置換を行う replaceParen を書くには、 最初のほうで定義した doSomething と同じ考え方をします。 doSomething では本文全体から必要なブロックと不要なブロックを切り出すパーザを繰り返し使ったわけですが、今度は置換する要素としない要素に切り刻むパーザを作り、それで各ブロックを処理していきます。 丸括弧は入れ子になってるかもしれないので、丸括弧か否か(parensnoParens)だけでなく、丸括弧内か(inParens)も選択肢になりえます。

replaceParen :: String -> String
replaceParen line = case parse (concat <$> many1 strOrParen) "" line of
  Left err -> ""
  Right str -> str

strOrParen :: Parser String
strOrParen = choice [ try noParens, try inParen, parens ]

inParen :: Parser String
inParen = string "(" *> (wrapDP <$> (manyTill strOrParen (string ")")))
  where wrapDP = ("("++) . (++")") . concat

noParens :: Parser String
noParens = many1 $ noneOf "()"

parens :: Parser String
parens = many1 $ oneOf "()"

以上を ReplaceParen.hs のような名前で保存して以下のように実行すれば <p> タグの中だけ半角丸括弧を全角に置換できます。このブログ記事のソースみたいなのを処理しても、コード片に出てくる半角丸括弧は置換されません。やったね。

$ runghc ReplaceParen.hs input.html > result.html

parsec でテキストフィルターを書くときのまとめ

  1. 全体をブロック要素へと切り刻むパーザを choice で作り
  2. 各ブロックをインライン要素へと切り刻むパーザを choice で作る
  3. どちらも 「case parse [パーザ] "" [パーズする対象] of 」で文字列から文字列への関数にしたてる

書き捨てとはいえ、このような再帰的なパターンになると、正規表現をサポートしてるエディタではつらいし、sed/Ruby/Python/Gauche などでスクリプトを書くにしてもかえってコード力が要求されることが多いように思います。 単純よりちょっと込み入ったテキスト処理になると、Haskell のほうが parsec のおかげで楽に編集補助ツールが書けることもあるはずです。プログラマでないみなさんも『すごい Haskell たのしく学ぼう!』『プログラミング Haskell』だけは読んでおきましょう。再帰に対する理解もあるとなおよいので、『Scheme 手習い』もぜひ読みましょう。これは宣伝です。

Haskell のコードには、ここで出てきたような <$> とか *> のような記号がちょこちょこ出てくるのでとっつきにくいかも知れませんが、『すごい Haskell たのしく学ぼう!』の Kindle 版とか、ちゃんと索引もついてるので、こんな検索しにくそうな記号もばっちり調べられます。これは宣伝であると同時に、電子書籍にも索引あったほうがいいよという、この記事の対象者である新人編集者むけのアドバイスです。

注意

書き捨てのテキストフィルターに完璧は目指さないこと。あくまでも編集作業の補助に使いましょう。 実は上の例でも、たとえば原稿中に <p> 要素の入れ子がないことや、<p> タグがすべて行頭にあることを密かに仮定していて、だから otherlines が簡単に定義できてます。

経験からいうと、 otherlines のような「探してないその他大勢」をすっ飛ばすパーザを書くほうが大変で、行頭とか空白とか特殊文字といった都合のいい条件のない完璧なパーザを目指そうとすると、とたんにスクリプトが巨大になります。この例だと、それこそ XML パーザを書く勢いが必要です。しかし、いまほしいのは書き捨てのテキストフィルターです。妥協が肝心です。ビルドスクリプトに組み込むとかでなければ、一発で完全に処理しようとしないほうが幸せです。

No comments :