みどりねこ日記

よくわからないけど、頑張りますよ。

パーサへの誘い

Magnusさんのご厚意により、邦訳してもよいことになりましたので掲載させて頂きます。
もとの記事はこちらのAdventures in parsingです。


私はParsecが使いこなせるようになりたいと常日頃から思っていました。何度か試してみたのですが、そのいずれもどこかで躓いて失敗し、そういった期間が非常に長かったので、何度試したかは忘れてしまいました。私の~/devo/test/haskellのなかに書きちらされたコードたちが私の失敗の数々を物語っています。

さて、私の一番最初の試みとして、/proc/< pid >/mapsの中身をパースすることを考えました。
mapsのマニュアルを見てみましょう。

どうやら、

address perms offset dev inode pathname
08048000-08056000 r-xp 00000000 03:0c 64593 /usr/sbin/gpm

という構造になっているようです。

まず、いくつかのデータ型を用意し、つなげていきます。最初にアドレスの範囲を表します。

data Address = Address { start :: Integer, end :: Integer }
	deriving (Show)

パーミッションにsまたはpが入っているものはAccessと呼ぶことにします。

data Access = Shared | Private
	deriving (Show)

rwxのようなパーミッションは簡単にブール型として表しましょう。

data Perms = Perms {
	read :: Bool,
	write :: Bool,
	executable :: Bool,
	access :: Access
}
deriving (Show)

デバイスは素直に。

data Device = Device { major :: Integer, minor :: Integer }
	deriving (Show)

最後に、これらをメモリ空間を表すひとつのデータ型に固めます。

data MemRegion = MemRegion {
	address :: Address,
	perms :: Perms,
	offset :: Integer,
	device :: Device,
	inode :: Integer,
	pathname :: String
}
deriving (Show)

すべてのデータ型はShowのインスタンスとしている(つまり最低でもshowは使える)ので、それらの出力は簡単になっています。

さて、Parsecを使っていきます。トップダウンかボトムアップの方法で開発ができますが、私は後者を選ぶことにしました。しかし、mapsファイルの中の一行がシンプルな形式なので、最終的にどういった関数になるのかが容易に想像できますね。私がボトムアップですると決めたのは、データ型のおかげで、どのように一行を切り離すかが簡単にわかるからです。まず、アドレスの範囲をパースしてみます。

parseAddress = let hexStr2Int = Prelude.read . ("0x" ++)
	in do
		start <- many1 hexDigit
		char '-'
		end <- many1 hexDigit
		return $ Address (hexStr2Int start) (hexStr2Int end)

アドレス自体が16進数表記で表されており、少なくとも1文字以上の長さがあるので、many1 hexDigitを使っています。count 8 hexDigitのように、アドレスの長さが8文字であるかをチェックするのが安全であるとは思いますが(少なくとも32bitマシンでは)、私は試していません。
16進数からIntegerに変える方法は2つあります。一つは上でしたようにPrelude.readが0xから始まる文字列を16進数として読みこんでくれることを利用する方法です。もう一つは fst . (!! 0) . readHexを使ってする方法です。
マニュアルページによると、アドレスはダッシュ'-'で区切られているようなので、char '-'で処理しています。

この関数をテストするのは簡単です。ghciを使ってロードし、parseを使用します。

*Main> parse parseAddress "" "0-1"
Right (Address { start = 0, end = 1 } )
*Main> parse parseAddress "hhh" "01234567-89abcdef"
Right (Address { start = 19088743, end = 2309737967 } )

うまくいっていますね。

次に、パーミッションをパースします。これは素直にかけるのでコメントは必要ないかもしれません。

parsePerms = let
	cA a = case a of
		'p' -> Private
		's' -> Shared
	in do
		r <- anyChar
		w <- anyChar
		x <- anyChar
		a <- anyChar
		return $ Perms (r == 'r') (w == 'w') (x == 'x') (cA a)

デバイス情報も上記のアドレスのものと同じ書き方で実装できますが、今回はダッシュではなくコロン':'で分けられています。

parseDevice = let
	hexStr2Int = Prelude.read . ("0x" ++)
	in do
		maj <- many1 digit
		char ':'
		min <- many1 digit
		return $ Device (hexStr2Int maj) (hexStr2Int min)

次に、MemRegionインスタンスを作り出すためにこれらをつなげましょう。

parseRegion = let
	hexStr2Int = Prelude.read . ("0x" ++)
	parsePath = (many1 $ char ' ') >> (many1 $ anyChar)
	in do
		addr <- parseAddress
		char ' '
		perm <- parsePerms
		char ' '
		offset <- many1 hexDigit
		char ' '
		dev <- parseDevice
		char ' '
		inode <- many1 digit
		char ' '
		path <- parsePath <|> string ""
		return $ MemRegion addr perm (hexStr2Int offset) dev (Prelude.read inode) path

問題は、パス名のない行なんかもあることです。こんな感じで。

address perms offset dev inode pathname
09058000-0805b000 rwxp 00000000 00:00 0

inodeのあとにスペースがあるようなので、parseRegionの中のchar ' 'は残しています。もしパスをパースしようとして失敗したら、空の文字列をパースすることで解決しました。それがparsePath <|> string ""の意味です。パスネームはどうやらスペースの数によって整えられているようですが、面倒なのでスペースをすべて消費しています。パスネームとしてどの文字が使用できるのか把握していないので、文字があったらとにかく消費していくことにしています。

ここで、つくったこの関数で遊ぶために、pidによる指定で特定のプロセスのmapsファイルをパースし、その結果をMemRegionインスタンスのリストで返す関数を考えました。

getMemRegions pid = let
	fp = "/proc" </> show pid </> "maps"
	doParseLine' = parse parseRegion "parseRegion"
	doParseLine l = case (doParseLine' l) of
		Left _ -> "Failed to parse line"
		Right x -> x
	in do
		mapContent <- liftM lines $ readFile fp
		return $ map doParseLine mapContent

上のコードでは、行をIOモナドからうけとり、Parserモナドにそれを渡し、再びIOモナドに返しています。試してみましょう。

*Main> getMemRegions 1

これで実行できますが、ものすごい数で出力されたりするのでtakeを使って制限したりするとよいでしょう。ですので、最後の行は、

return $ map doParseLine (take 4 mapContent)

となります。
さて、これで最初のコマンドライン引数をpidとして受け取るプログラムを書いてみましょう。

main = do
	pid <- liftM (Prelude.read . (!! 0)) getArgs
	regs <- getMemRegions pid
	mapM_ (putStrLn . show) regs

これでパーサは完成です。

ちなみに、以下のモジュールをインクルードする必要があります。

import Control.Monad
import System
import System.FilePath
import Text.ParserCombinators.Parsec