Posted on August 21, 2018 authored by Shinya Yamaguchi

Last Updated December 30, 2019

はじめに

Haskell でファイルやディレクトリを扱うプログラムを書く時によく使うパッケージとして filepath パッケージや directory パッケージがあります。(Haskell入門の「4.4 ファイルシステム」に directory パッケージの話が少し載っています。)

これらのパッケージは結局のところただの文字列操作なので、バグを出さないためにはパッケージ利用者がかなり注意深く使わなければなりません。

例えば、以下のようなパスは型レベルでは同じ文字列 (FilePath) ですが

-- 相対パス pathRel :: FilePath = ./ aaa / bbb / ccc pathRelaaabbbccc -- 絶対パス pathAbs :: FilePath = / home / user / aaa / bbb / ccc pathAbshomeuseraaabbbccc -- ファイルへのパス pathFile :: FilePath = ./ aaa / a . png pathFileaaapng -- ディレクトリへのパス pathDir :: FilePath = ./ aaa pathDiraaa

このように、FilePath 型では相対パスなのか絶対パスなのか型レベルで判断する方法が無かったり、そもそもパスがファイルなのかディレクトリなのかすらわからなかったりします。

今回紹介するのは、型レベルでこれらをちゃんと分類できるようにしている path と path-io パッケージです。

型レベルで 相対パス or 絶対パス と ファイル or ディレクトリ を表現するため、不正な操作はコンパイル時にチェックできるようになります。

また、stack の内部でも利用していたので、実用しても大丈夫だと思います。

パッケージのバージョンは以下のとおりです

path-0.6.1

path-io-1.3.3

まだまだ更新が活発なパッケージなので、path-0.7 では破壊的変更を含む更新があるようです。(CHANGELOG)

path パッケージ

ドキュメントが充実しているので Readme を読めば使い方はすぐにわかると思います。

データ型

Path の型は FilePath を幽霊型 (Phantom type) を使ってラップしているだけです。(幽霊型については ElmでPhantom Type (幽霊型)入門 や で、出たー！幽霊型だー！(Phantom Type) などが日本語のわかりやすい解説だと思います)

newtype Path b t = Path FilePath b t deriving ( Data , Typeable , Generic )

ここで2つの型変数の意味は以下の通りです。

b - 相対パス or 絶対パス

- 相対パス or 絶対パス t - ファイル or ディレクトリ

型変数 b は実際には以下の型のどちらかを取ります。

data Abs deriving ( Typeable ) data Rel deriving ( Typeable )

同様に型変数 t は以下の型を取ります。

data File deriving ( Typeable ) data Dir deriving ( Typeable )

具体的なパスの型は以下の4種類のどれかになります。

Path Abs File -- ファイルへの絶対パス Path Abs Dir -- ディレクトリへの絶対パス Path Rel File -- ファイルへの相対パス Path Rel Dir -- ディレクトリへの相対パス

型を見るだけでどんなパスなのか一目瞭然なので、めっちゃ良いですね。

値の作り方

型については説明したので、次は実際に Path 型の値を作っていきましょう！

パースする方法

Path 型は4種類あるので、パーズする関数も4種類あります。

parseAbsDir :: MonadThrow m => FilePath -> m ( Path Abs Dir ) m ( parseRelDir :: MonadThrow m => FilePath -> m ( Path Rel Dir ) m ( parseAbsFile :: MonadThrow m => FilePath -> m ( Path Abs File ) m ( parseRelFile :: MonadThrow m => FilePath -> m ( Path Rel File ) m (

MonadThrow m がついていますが、この m は IO だと思えば以下の型と同じですし

parseAbsDir :: FilePath -> IO ( Path Abs Dir ) parseRelDir :: FilePath -> IO ( Path Rel Dir ) parseAbsFile :: FilePath -> IO ( Path Abs File ) parseRelFile :: FilePath -> IO ( Path Rel File )

Maybe であれば、以下の型と同じです。

parseAbsDir :: FilePath -> Maybe ( Path Abs Dir ) parseRelDir :: FilePath -> Maybe ( Path Rel Dir ) parseAbsFile :: FilePath -> Maybe ( Path Abs File ) parseRelFile :: FilePath -> Maybe ( Path Rel File )

難しいことはあまり気にせず、(MonadThrow 型クラスのインスタンスになっている) 色んなモナドで使えるんだなと思えば良いと思います。

実際に ghci を使って動作を確認してみましょう！

$ stack repl --package path > import Path # 型のチェック > :t parseAbsDir "/" parseAbsDir "/" :: MonadThrow m => m (Path Abs Dir) > :t parseAbsDir "./" parseAbsDir "./" :: MonadThrow m => m (Path Abs Dir) # IO モナドの文脈 > parseAbsDir "/" "/" > parseAbsDir "./" *** Exception: InvalidAbsDir "./" # Maybe モナドの文脈 > parseAbsDir "/" :: Maybe (Path Abs Dir) Just "/" > parseAbsDir "./" :: Maybe (Path Abs Dir) Nothing # 以下のような "../" を含むパスはパーズできない > parseAbsDir "./../a/b/" *** Exception: InvalidAbsDir "./../a/b/" > parseRelDir "./../a/b/" *** Exception: InvalidAbsDir "./../a/b/"

これで文字列から Path 型に変換する方法がわかりましたね！結構簡単です。

Template Haskell & QuasiQuotes

コンパイル時にすでにファイルパスが決まっている時はテンプレートHaskellや準クォートを使うこともできます。

これで不正なパスはコンパイル時エラーとなるため、かなり安全ですね。

Path から FilePath への変換

Path 型の値を FilePath に変換するためには toFilePath 関数を利用します。

> toFilePath <$> parseRelDir "./a/b" "a/b/" > toFilePath <$> parseRelDir "./a/b/" "a/b/" > toFilePath <$> parseRelDir "./a////b//////" "a/b/"

こんな感じで期待している文字列に変換されているか確かめることができます。

パスの等価性

2つの Path の等しさは単純に文字列の等価性として定義されています。

instance Eq ( Path b t) where b t) ( == ) ( Path x) ( Path y) = x == y ) (x) (y)

実際にいくつか試してみます。

> (==) <$> parseRelDir "./a/b" <*> parseRelDir "./a/b" True > (==) <$> parseRelDir "./a/b" <*> parseRelDir "./a/b/c" False > (==) <$> parseRelDir "./a/b" <*> parseRelDir "./a/b/" True

パスの操作

関数と実行結果のみを紹介していきます。

2つのパスの結合

(</>) :: Path b Dir -> Path Rel t -> Path b t b t

第一引数は Dir で第二引数は Rel が指定されている点に注意してください。そのため、第一引数にファイルへのパスを与えようとするとコンパイルエラーになります。

> (</>) <$> parseRelDir "a/b/c" <*> parseRelFile "a.png" "a/b/c/a.png" > (</>) <$> parseRelDir "a/b/c" <*> parseRelDir "d" "a/b/c/d/"

パスの先頭部分から、ディレクトリパスを除去

Data.List の stripPrefix 関数と同じように利用できます。

stripProperPrefix :: MonadThrow m => Path b Dir -> Path b t -> m ( Path Rel t) b tm (t)

> join $ stripProperPrefix <$> parseAbsDir "/usr/local/bin/" <*> parseAbsFile "/usr/local/bin/stack" "stack" > join $ stripProperPrefix <$> parseAbsDir "/local/bin/" <*> parseAbsFile "/usr/local/bin/stack" *** Exception: NotAProperPrefix "/local/bin/" "/usr/local/bin/stack"

パスから親ディレクトリパスを取得

parent :: Path b t -> Path b Dir b t

> parent <$> parseRelFile "ab" "./" > parent <$> parseRelFile "./a/b/c/d" "a/b/c/"

ディレクトリパスから、相対ディレクトリパスを取得

dirname :: Path b Dir -> Path Rel Dir

> dirname <$> parseAbsDir "/a/b/c/d" "d/" > dirname <$> parseRelDir "./a/b/c/d" "d/"

ファイルパスから相対ファイルパスを取得

filename :: Path b File -> Path Rel File

> filename <$> parseAbsFile "/a/b/c/d.png" "d.png" > filename <$> parseRelFile "./a/b/c/d.png" "d.png"

ファイルパスから拡張子を取得

fileExtension :: Path b File -> String

> fileExtension <$> parseAbsFile "/a/b/c.png" ".png" > fileExtension <$> parseRelFile "a/b/c.png" ".png"

ファイルパスに拡張子を追加

addFileExtension :: MonadThrow m => String -> Path b File -> m ( Path b File ) m ( -- 演算子バージョンとして (<.>) が定義されている (<.>) :: MonadThrow m => Path b File -> String -> m ( Path b File ) m (

> join $ addFileExtension "hs" <$> parseAbsFile "/a/b/c" "/a/b/c.hs" > join $ addFileExtension ".hs" <$> parseAbsFile "/a/b/c" "/a/b/c.hs" > join $ addFileExtension ".hs" <$> parseRelFile "a/b/c" "a/b/c.hs" > join $ addFileExtension ".hs" <$> parseRelFile "a/b/c.rs" "a/b/c.rs.hs" > join $ (<.> ".hs") <$> parseRelFile "a/b/c.rs" "a/b/c.rs.hs"

既に拡張子があっても、追加する点に注意。

ファイルパスに拡張子を追加 (既に拡張子がある場合は置き換える)

setFileExtension :: MonadThrow m => String -> Path b File -> m ( Path b File ) m ( -- 演算子バージョンとして (-<.>) が定義されている (-<.>) :: MonadThrow m => Path b File -> String -> m ( Path b File ) m (

> join $ setFileExtension "hs" <$> parseAbsFile "/a/b/c" "/a/b/c.hs" > join $ setFileExtension ".hs" <$> parseAbsFile "/a/b/c" "/a/b/c.hs" > join $ setFileExtension ".hs" <$> parseRelFile "a/b/c" "a/b/c.hs" > join $ setFileExtension ".hs" <$> parseRelFile "a/b/c.rs" "a/b/c.hs" > join $ (-<.> ".hs") <$> parseRelFile "a/b/c.rs" "a/b/c.hs"

path-io

ここまでで Path 型の定義や値の作り方、操作する関数などを見てきました。

しかしながら、これだけでは実際にファイルを作ったり削除したりすることはできません。文字列に変換して directory パッケージを利用することもできますが、やはり Path 型のまま使いたいですよね。

そのためには path-io パッケージを利用すると良いです。内部的には directory パッケージを再利用していますが、 Path 型で使えるようにラップしてくれています。(また、便利な関数もいくつか追加されています)

サンプルプログラム

例えばこんな感じで使えます。以下の例はコマンドライン引数から受け取った文字列に拡張子 .hs を追加して適当な内容で保存し、最後にディレクトリを再帰的にコピーする例です。

#!/usr/bin/env stack -- stack script --resolver lts-12.7 {-# LANGUAGE TemplateHaskell #-} import Path import Path.IO import Control.Monad (when) (when) import System.Environment (getArgs) (getArgs) main :: IO () () = do main <- getArgs argsgetArgs length args == 1 ) $ do when (args let src = $ (mkRelDir "./src" ) src(mkRelDir = $ (mkRelDir "./.backup" ) dest(mkRelDir -- 安全にディレクトリを作成 mapM_ ensureDir [src, dest] ensureDir [src, dest] <- parseRelFile $ head args rawNameparseRelFileargs <- (src </> rawName) -<.> "hs" fn(srcrawName) writeFile (toFilePath fn) "main :: IO ()

main = undefined

" (toFilePath fn) -- ディレクトリを再帰的にコピー copyDirRecur' src dest

実行結果

$ ./Sample.hs aaa $ tree -a . . ├── .backup │ └── aaa.hs ├── Sample.hs └── src └── aaa.hs 2 directories, 3 files $ cat src/aaa.hs main :: IO () main = undefined $ cat .backup/aaa.hs main :: IO () main = undefined

動いているようです。

まとめ

filepath や directory パッケージでは文字列の操作となってしまうため、コンパイル時に不正な利用方法をチェックできない

や パッケージでは文字列の操作となってしまうため、コンパイル時に不正な利用方法をチェックできない path や path-io は幽霊型を使って不正な利用をコンパイル時にチェックする

や は幽霊型を使って不正な利用をコンパイル時にチェックする 実際に stack でも利用されているパッケージ

以上です。