Here I’d like to announce and explain the motivations behind my path package.
It was after working on a number of projects at FP Complete that use file paths in various ways. We used the system-filepath package, which was supposed to solve many path problems by being an opaque path type. It occurred to me that the same kind of bugs kept cropping up:
/home//foo
vs /home/foo/
vs
/home/bar/../foo
, etc.).All of these bugs are preventable.
My approach to problems like this is to make a type that encodes the properties I want and then make it impossible to let those invariants be broken, without compromise or backdoors to let the wrong value “slip in”. Once I have a path, I want to be able to trust it fully. This theme will be seen throughout the things I lay out below.
After having to fix bugs due to these in our software, I put my foot down and made:
Here is the type:
newtype Path b t = Path FilePath
deriving (Typeable)
The type variables are:
b
- base, the base location of the path; absolute or
relative.t
- type, whether file or directory.The base types can be filled with these:
data Abs deriving (Typeable)
data Rel deriving (Typeable)
And the type can be filled with these:
data File deriving (Typeable)
data Dir deriving (Typeable)
(Why not use data kinds like data Type = File | Dir
?
Because that imposes an extension overhead of adding
{-# LANGUAGE DataKinds #-}
to every module you might want
to write out a path type in. Given that one cannot construct paths of
types other than these, via the operations in the module, it’s not a
concern for me.)
There is a conversion function to give you back the filepath:
toFilePath :: Path b t -> FilePath
Path l) = l toFilePath (
To get a path value, you need to use one of the four parsers:
parseAbsDir :: MonadThrow m => FilePath -> m (Path Abs Dir)
parseRelDir :: MonadThrow m => FilePath -> m (Path Rel Dir)
parseAbsFile :: MonadThrow m => FilePath -> m (Path Abs File)
parseRelFile :: MonadThrow m => FilePath -> m (Path Rel File)
The following properties apply:
/
on POSIX and \
on Windows...
, ~/
,
/./
, etc./home//foo
-> /home/foo
./
. So /home/foo
parses into the string
/home/foo/
.It was discussed briefly whether we should just have a class for
parsing rather than four separate parsing functions. In my experience so
far, I have had type errors where I wrote something like
x <- parseAbsDir someAbsDirString
because x was then
passed to a place that expected a relative directory. In this way,
overloading the return value would’ve just been accepted. So I don’t
think having a class is a good idea. Being explicit here doesn’t exactly
waste our time, either.
Why are these functions in MonadThrow
? Because it means
I can have it return an Either, or a Maybe, if I’m in pure code, and if
I’m in IO, and I don’t expect parsing to ever fail, I can use it in IO
like this:
do x <- parseRelFile (fromCabalFileName x)
foo x …
That’s really convenient and we take advantage of this at FP Complete a lot.
Equality, ordering and printing are simply re-using the String instances:
instance Eq (Path b t) where
==) (Path x) (Path y) = x == y
(
instance Ord (Path b t) where
compare (Path x) (Path y) = compare x y
instance Show (Path b t) where
show (Path x) = show x
Which gives us for free the following equational properties:
== toFilePath y ≡ x == y -- Eq instance
toFilePath x `compare` toFilePath y ≡ x `compare` y -- Ord instance
toFilePath x == toFilePath y ≡ show x == show y -- Show instance toFilePath x
In other words, the representation and the path you get out at the end are the same. Two paths that are equal will always give you back the same thing.
For when you know what a path will be at compile-time, there are constructors for that:
$(mkAbsDir "/home/chris")
$(mkRelDir "chris")
$(mkAbsFile "/home/chris/x.txt")
$(mkRelFile "chris/x.txt")
These will run at compile-time and underneath use the appropriate parser.
No IsString
instance is provided, because that has no
way to statically determine whether the path is correct, and would
otherwise have to be a partial function.
In practice I have written the wrong path format in a
$(mk… "")
and been thankful it was caught early.
There is path concatenation:
(</>) :: Path b Dir -> Path Rel t -> Path b t
Get the parent directory of a path:
parent :: Path Abs t -> Path Abs Dir
Get the filename of a file path:
filename :: Path b File -> Path Rel File
Get the directory name of a directory path:
dirname :: Path b Dir -> Path Rel Dir
Stripping the parent directory from a path:
stripDir :: MonadThrow m => Path b Dir -> Path b t -> m (Path Rel t)
Let’s review my initial list of complaints and see if they’ve been satisfied.
Paths now distinguish in the type system whether they are relative or absolute. You can’t append two absolute paths, for example:
> $(mkAbsDir "/home/chris") </> $(mkAbsDir "/home/chris")
λ<interactive>:23:31-55:
Couldn't match type ‘Abs’ with ‘Rel’
Paths are now stringently normalized. They have to be a valid path, and they only support single path separators, and all directories are suffixed with a trailing path separator:
> $(mkAbsDir "/home/chris//") == $(mkAbsDir "/./home//chris")
λTrue
> toFilePath $(mkAbsDir "/home/chris//") ==
λ$(mkAbsDir "/./home//chris")
toFilePath True
> ($(mkAbsDir "/home/chris//"),toFilePath $(mkAbsDir "/./home//chris"))
λ"/home/chris/","/home/chris/") (
Because of the stringent normalization, path concatenation, as seen above, is simply string concatenation. This is about as predictable as it can get:
> toFilePath $(mkAbsDir "/home/chris//")
λ"/home/chris/"
> toFilePath $(mkRelDir "foo//bar")
λ"foo/bar/"
> $(mkAbsDir "/home/chris//") </> $(mkRelDir "foo//bar")
λ"/home/chris/foo/bar/"
Now that the path type is encoded in the type system, our
</>
operator prevents improper appending:
> $(mkAbsDir "/home/chris/") </> $(mkRelFile "foo//bar")
λ"/home/chris/foo/bar"
> $(mkAbsFile "/home/chris") </> $(mkRelFile "foo//bar")
λ<interactive>:35:1-26:
Couldn't match type ‘File’ with ‘Dir’
Now I can read the path like:
fooPath :: Path Rel Dir, ... } {
And know that this refers to the directory relative to some other path, meaning I should be careful to consider the current directory when using this in IO, or that I’ll probably need a parent to append to it at some point.
We’ve been using this at FP Complete in a number of packages for some months now, it’s turned out surprisingly sufficient for most of our path work with only one bug found. We weren’t sure initially whether it would just be too much of a pain to use, but really it’s quite acceptable given the advantages. You can see its use all over the stack codebase.
Currently any operations involving I/O can be done by using the existing I/O library:
doesFileExist (toFilePath fp)readFile (toFilePath fp)
etc. This has problems with respect to accidentally running something like:
$(mkRelDir "foo") doesFileExist
But I/O is currently outside the scope of what this package solves. Once you leave the realm of the Path type invariants are back to your responsibility.
As with the original version of this library, we’re currently
building up a set of functions in a Path.IO
module over time that fits our real-world use-cases. It may or may not
appear in the path package eventually. It’ll need cleaning up and
considering what should really be included.
One problem that crops up sometimes is wanting to manipulate paths. Currently the way we do it is via the filepath library and re-parsing the path:
. addExtension "/directory/path" "ext" . toFilePath parseAbsFile
It doesn’t happen too often, in our experience, to the extent this needs to be more convenient.
Sometimes you have user input that contains ../
. The
solution we went with is to have a function like
resolveDir
:
resolveDir :: (MonadIO m, MonadThrow m)
=> Path Abs Dir -> FilePath -> m (Path Abs Dir)
Which will call canonicalizePath
which collapses and
normalizes a path and then we parse with regular old
parseAbsDir
and we’re cooking with gas. This and others
like it might get added to the path
package.
The filepath package is intended as the complimentary package to be used before parsing into a Path value, and/or after printing from a Path value. The package itself contains no type-safety, instead contains a range of cross-platform textual operations. Definitely reach for this library when you want to do more involved manipulations.
The system-filepath package is deprecated in favour of filepath.
The system-canonicalpath
and the canonical-filepath
packages both are a kind of subset of path
. They
canonicalize a string into an opaque path, but neither distinguish
directories from files or absolute/relative. Useful if you just want a
canonical path but doesn’t do anything else.
The directory-tree package contains a sum type of dir/file/etc but doesn’t distinguish in its operations relativity or path type.
Finally, we come to a path library that path
is similar
to: the pathtype library.
There are the same types of Path Abs File
/
Path Rel Dir
, etc.
The points where this library isn’t enough for me are:
There is an IsString
instance, which means people
will use it, and will make mistakes.
Paths are not normalized into a predictable format, leading to me being unsure when equality will succeed. This is the same problem I encountered in system-filepath. The equality function normalizes, but according to what properties I can reason about? I don’t know.
System.Path.Posix> ("/tmp//" :: Path a Dir) == ("/tmp" :: Path a Dir)
True
System.Path.Posix> ("tmp" :: Path a Dir) == ("/tmp" :: Path a Dir)
True
System.Path.Posix> ("/etc/passwd/" :: Path a b) == ("/etc/passwd" :: Path a b)
True
System.Path.Posix> ("/tmp//" :: Path Abs Dir) == ("/tmp/./" :: Path Abs Dir)
False
System.Path.Posix> ("/tmp/../" :: Path Abs Dir) == ("/" :: Path Abs Dir)
False
.
due to that gets weird:System.Path.Posix> fmap getPathString (Right ("." :: Path Rel File))
Right "."
System.Path.Posix> fmap getPathString (mkPathAbsOrRel "")
Right "."
System.Path.Posix> (Right ("." :: Path Rel File)) == (mkPathAbsOrRel "")
False
System.Path.Posix> takeDirectory ("tmp" :: Path Rel Dir)
.
System.Path.Posix> (getPathString ("." :: Path Rel File) ==
"" :: Path Rel File))
getPathString (True
System.Path.Posix> (("." :: Path Rel File) == ("" :: Path Rel File))
False
It has functions like
<.>
/addExtension
which lets you insert
an arbitrary string into a path.
Some functions let you produce nonsense (could be prevented by a stricter type), for example:
System.Path.Posix> takeFileName ("/tmp/" :: Path Abs Dir)
tmp
I’m being a bit picky here, a bit unfair. But the point is really to
show the kind of things I tried to avoid in path
. In
summary, it’s just hard to know where things can go wrong, similar to
what was going on in system-filepath.
The data-filepath is also very similar, I discovered it after writing my own at work and was pleased to see it’s mostly the same. The main differences are:
FilePath
(String) type already deal with well. It does
change the parsing step somewhat, because it parses into segments...
and trailing
.
).The API is a bit awkward to just parse a directory, requires a couple
functions to get it (going via WeakFilePath
), returning
only an Either, and there are no functions like parent
. But
there’s not much to complain about. It’s a fine library, but I didn’t
feel the need to drop my own in favor of it. Check it out and decide for
yourself.
There’s a growing interest in making practical use of well-typed file
path handling. I think everyone’s wanted it for a while, but few people
have really committed to it in practice. Now that I’ve been using
path
for a while, I can’t really go back. It’ll be
interesting to see what new packages crop up in the coming year, I
expect there’ll be more.