Parsecを使ってCSVを読み込むライブラリを作ってみました

Parsecを使ってCSVを読み込むライブラリを作ってみました。ParsecはGHCに標準添付なんで気軽に使えてよいです。なおCSVの仕様はRFC4180を参考にしていますが、厳密にはRFC4180には準拠していません。差分は以下の通りです。

  • 改行文字は\nのみ許可しています。が、Windowsではファイルはテキストモードで開かれるのでCRLFはLFに変換されます。あまり問題にはならないかもしれません。
  • 8ビット目が立っている文字も許容しています。

ファイル: CSV.hs

module CSV (parseCSV, parseCSVFromFile) where

import Text.ParserCombinators.Parsec

parseCSV :: String -> [[String]]
parseCSV s =
  case parse parseCSV' "" s of
     Right v -> removeLastEmptyCVSRecord v
     Left  e -> fail $ show e

parseCSVFromFile :: FilePath -> IO [[String]]
parseCSVFromFile s = do
  ret <- parseFromFile parseCSV' s
  case ret of
     Right v -> return $ removeLastEmptyCVSRecord v
     Left  e -> fail $ show e

parseCSV' :: Parser [[String]]
parseCSV' = file
  where
    file :: Parser [[String]]
    file = record `sepBy` char '\n'

    record :: Parser [String]
    record = field `sepBy` char ','

    field :: Parser String
    field = escaped <|> nonEscaped

    escaped :: Parser String
    escaped = do
      char '"'
      v <- many escapedChar
      char '"'
      return v

    escapedChar :: Parser Char
    escapedChar = try (char '"' >> char '"' >> return '"') <|> noneOf "\""

    nonEscaped :: Parser String
    nonEscaped = many $ noneOf ",\"\n"

removeLastEmptyCVSRecord :: [[String]] -> [[String]]
removeLastEmptyCVSRecord a
  | isEmptyCVS      = []
  | isEndNewLineCVS = reverse $ tail $ reverse a
  | otherwise       = a
  where
    isEmptyCVS :: Bool
    isEmptyCVS = (length a == 1) && (null $ head a)

    isEndNewLineCVS :: Bool
    isEndNewLineCVS = last a == [""]

ファイル: test.hs

module Main (main) where

import System.Environment (getArgs, getProgName)
import CSV

main :: IO ()
main = do
  prog <- getProgName
  args <- getArgs
  if length args == 0
    then putStrLn $ usage prog
    else mapM_ (\path -> mapM_ print =<< parseCSVFromFile path) args
  where
    usage p =  p ++ " files..." 

ファイル: Makefile

TARGET  = test
SOURCES = test.hs CSV.hs

HC      = ghc
HCFLAGS = -W -fno-warn-unused-matches --make

all: $(TARGET)

$(TARGET): $(SOURCES)
	$(HC) $(HCFLAGS) $< -o $@

clean:
	rm -f $(TARGET) *.o *.hi *.exe

コンパイル手順は以下の通りです。

$ make
ghc -W -fno-warn-unused-matches --make test.hs -o test
Chasing modules from: test.hs
Compiling CSV              ( ./CSV.hs, ./CSV.o )
Compiling Main             ( test.hs, test.o )
Linking ...
$

実行例1。以下のファイルの場合。

$ cat data.txt
aaa, bbb ,ccc,"ddd""ddd
ddd",
foo,bar,baz,quux
$ ./test.exe data.txt
["aaa"," bbb ","ccc","ddd\"ddd\nddd",""]
["foo","bar","baz","quux"]
$

実行例2。空ファイルの場合。空のリストが返るので何も表示されません。

$ ./test.exe data2.txt
$

上の実装では、関数removeLastEmptyCVSRecordを使ってCSVファイルが改行文字で終わる場合の処理を行っています。具体的には、空のCSVを読み込ませた時には[]を返して、CSVが改行で終わる場合には最後の[""]を削除しています。reverseが続くので非効率ぽいです。うーむ、何かいい解決策はありませんかね? (関数fileのrecord `sepBy` char '\n'をrecord `sepEndBy` char '\n'にしても関数nonEscapedが'\n'を消費できなくて、次の空のnonEscapedで処理されてしまいます…)
参考: