はじめに

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

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

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

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

今回紹介するのは、型レベルでこれらをちゃんと分類できるようにしている pathpath-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) などが日本語のわかりやすい解説だと思います)

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

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

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

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

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

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

値の作り方

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

パースする方法

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

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

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

難しいことはあまり気にせず、(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 の等しさは単純に文字列の等価性として定義されています。

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

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

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

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

パスの操作

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

2つのパスの結合

第一引数は 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 関数と同じように利用できます。

> 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 <$> parseRelFile "ab"
"./"

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

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

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

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

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

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

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

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

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

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

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

> 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"

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

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

> 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 を追加して適当な内容で保存し、最後にディレクトリを再帰的にコピーする例です。

実行結果

$ ./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

動いているようです。

まとめ

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

以上です。