algebraic-path-0.1: src/library/AlgebraicPath.hs
module AlgebraicPath
( Path,
-- * Accessors
toFilePath,
toText,
toBasename,
toExtensions,
toSegments,
isAbsolute,
-- * Partial constructors
maybeFromText,
maybeFromFilePath,
-- * Parsers
attoparsecParserOf,
-- * Constructors
root,
dropParent,
dropLastSegment,
addExtension,
dropExtension,
dropExtensions,
deabsolutize,
-- * Idioms
-- | Getting the parent directory
--
-- >>> "a/b" <> ".." :: Path
-- "./a"
-- | Isolating to just the file name
--
-- >>> (dropParent . dropExtensions) "/a/b.c.d" :: Path
-- "./b"
)
where
import AlgebraicPath.Ast.Component qualified as Ast.Component
import AlgebraicPath.Ast.Name qualified as Ast.Name
import AlgebraicPath.Ast.Path qualified as Ast.Path
import AlgebraicPath.Util.List qualified as List
import AlgebraicPath.Util.Prelude hiding (null)
import Data.Attoparsec.Text qualified as Attoparsec
import Data.List qualified as List
import Test.QuickCheck qualified as QuickCheck
import TextBuilder qualified
-- |
-- Composable normalized path with a powerful algebra and consistent behaviour.
--
-- - It has 'toFilePath' and 'maybeFromFilePath' conversions,
-- which let you easily integrate it with any 'FilePath'-oriented libs.
-- - It has an instance of 'IsString',
-- so you can use string literals to define it.
-- - It provides a 'Monoid' instance replacing the `(</>)` operator of other libs with a standard lawful abstraction.
-- - It is automatically normalized, ensuring that various representations of the same path are equal, produce equal hashes and get ordered the same.
-- - It implements [natural sorting](https://en.wikipedia.org/wiki/Natural_sort_order).
-- - It integrates with 'Text' using 'toText' and 'maybeFromText'.
-- - It integrates with \"attoparsec\" via 'attoparsecParserOf'.
--
-- === Normalization
--
-- Internally 'Path' models a normalized form,
-- removing many ambiguities and allowing to achieve the following behaviour.
--
-- The trailing slash gets ignored:
--
-- >>> "a/" :: Path
-- "./a"
--
-- Multislash gets squashed:
--
-- >>> "a//b" :: Path
-- "./a/b"
--
-- Dot-dot gets immediately applied:
--
-- >>> "a/../c" :: Path
-- "./c"
--
-- Same does the single dot:
--
-- >>> "a/./c" :: Path
-- "./a/c"
--
-- Attempts to move out of root in absolute paths are ignored:
--
-- >>> "/../a" :: Path
-- "/a"
--
-- Attempts to move out of the beginning of a relative path on the other hand are preserved:
--
-- >>> "a/../../.." :: Path
-- "../.."
--
-- Dot as the first component of the path is the same as if there's none:
--
-- >>> "./a" :: Path
-- "./a"
--
-- >>> "a" :: Path
-- "./a"
--
-- Empty path is the same as dot:
--
-- >>> "" :: Path
-- "."
--
-- === Composition
--
-- The 'Monoid' instance makes paths composable and provides the following behaviour.
--
-- Empty path is the same as dot:
--
-- >>> mempty :: Path
-- "."
--
-- Appending a relative path nests it:
--
-- >>> "/a/b" <> "c.d" :: Path
-- "/a/b/c.d"
--
-- Appending a path with dot-dot, immediately applies it:
--
-- >>> "/a/b" <> "../c" :: Path
-- "/a/c"
--
-- Appending an absolute path replaces the whole thing:
--
-- >>> "/a/b" <> "/c" :: Path
-- "/c"
--
-- === Consistent equality, order and hashing
--
-- Thanks to 'Path' being in normalized form, the equality is consistent:
--
-- >>> ("./a/b" :: Path) == "a/b"
-- True
--
-- >>> ("./a/b" :: Path) == "a/b/"
-- True
--
-- >>> ("./a/b" :: Path) == "a//b/"
-- True
--
-- >>> ("./a/b" :: Path) == "a/../a/b/"
-- True
--
-- None of the above would hold for 'FilePath'.
--
-- Same logic applies to ordering and hashing.
--
-- === Natural sorting
--
-- The path is sorted [naturally](https://en.wikipedia.org/wiki/Natural_sort_order), meaning that the segments are compared as numbers if they are all digits.
-- This is useful for sorting paths with version numbers:
--
-- >>> import Data.List (sort)
-- >>> sort ["a/1", "a/11", "a/20", "a/2", "a/10"] :: [Path]
-- ["./a/1","./a/2","./a/10","./a/11","./a/20"]
--
-- where with 'FilePath' you would get:
--
-- >>> sort ["a/1", "a/11", "a/20", "a/2", "a/10"] :: [FilePath]
-- ["a/1","a/10","a/11","a/2","a/20"]
data Path
= -- | Absolute path.
AbsNormalizedPath
-- | Ast.Components in reverse order.
[Ast.Name.Name]
| RelNormalizedPath
-- | Preceding go up commands.
Int
-- | Ast.Components in reverse order.
[Ast.Name.Name]
deriving (Eq)
instance Ord Path where
compare = \case
AbsNormalizedPath lNames -> \case
AbsNormalizedPath rNames -> compare lNames rNames
_ -> GT
RelNormalizedPath lMovesUp lNames -> \case
AbsNormalizedPath _ -> LT
RelNormalizedPath rMovesUp rNames -> case compare lMovesUp rMovesUp of
EQ -> compare lNames rNames
res -> res
instance IsString Path where
fromString =
either error id
. Attoparsec.parseOnly (attoparsecParserOf <* Attoparsec.endOfInput)
. fromString
-- | Renders as a string literal.
instance Show Path where
show = show . toText
instance Semigroup Path where
lPath <> rPath = case rPath of
AbsNormalizedPath _ -> rPath
RelNormalizedPath rMovesUp rNames ->
case lPath of
RelNormalizedPath lMovesUp lNames ->
case List.dropPedantically rMovesUp lNames of
Left rMovesUp -> RelNormalizedPath (rMovesUp + lMovesUp) rNames
Right lNames -> RelNormalizedPath lMovesUp (rNames <> lNames)
AbsNormalizedPath lNames ->
AbsNormalizedPath (rNames <> drop rMovesUp lNames)
instance Monoid Path where
mempty =
RelNormalizedPath 0 []
instance QuickCheck.Arbitrary Path where
arbitrary =
QuickCheck.oneof [abs, rel]
where
abs =
AbsNormalizedPath <$> names
rel =
RelNormalizedPath <$> movesUp <*> names
where
movesUp =
QuickCheck.chooseInt (0, 3)
names = do
size <- QuickCheck.chooseInt (0, 20)
QuickCheck.vectorOf size do
QuickCheck.suchThat QuickCheck.arbitrary (not . Ast.Name.null)
shrink = \case
AbsNormalizedPath names ->
AbsNormalizedPath <$> QuickCheck.shrink names
RelNormalizedPath movesUp names ->
QuickCheck.shrink (movesUp, names) <&> \(movesUp, names) ->
RelNormalizedPath movesUp names
instance Hashable Path where
hashWithSalt salt = \case
AbsNormalizedPath names ->
salt
& extendHash @Int 0
& extendHash names
RelNormalizedPath movesUp names ->
salt
& extendHash @Int 1
& extendHash movesUp
& extendHash names
where
extendHash :: (Hashable a) => a -> Int -> Int
extendHash = flip hashWithSalt
-- * Partial constructors
-- | Attempt to parse a path from text.
maybeFromText :: Text -> Maybe Path
maybeFromText =
either (const Nothing) Just . Attoparsec.parseOnly (attoparsecParserOf <* Attoparsec.endOfInput)
-- | Attempt to parse the standard file path which is an alias to 'String'.
maybeFromFilePath :: FilePath -> Maybe Path
maybeFromFilePath = maybeFromText . fromString
-- | Attoparsec parser of 'Path'.
attoparsecParserOf :: Attoparsec.Parser Path
attoparsecParserOf =
fromAst <$> Ast.Path.attoparsecParserOf
-- | Construct from AST.
fromAst :: Ast.Path.Path -> Path
fromAst (Ast.Path.Path root components) =
foldr step finish components 0 []
where
step component next !collectedMovesUp !collectedNames =
case component of
Ast.Component.NameComponent name ->
if collectedMovesUp > 0
then next (pred collectedMovesUp) collectedNames
else
if Ast.Name.null name
then next collectedMovesUp collectedNames
else next collectedMovesUp (name : collectedNames)
Ast.Component.DotComponent ->
next collectedMovesUp collectedNames
Ast.Component.DotDotComponent ->
next (succ collectedMovesUp) collectedNames
finish collectedMovesUp collectedNames =
if root
then AbsNormalizedPath (reverse collectedNames)
else RelNormalizedPath collectedMovesUp (reverse collectedNames)
-- |
-- >>> root
-- "/"
--
-- Prepending it to a relative path will make it absolute.
--
-- >>> root <> "a/b"
-- "/a/b"
root :: Path
root =
AbsNormalizedPath []
-- * Traversers (aka Van Laarhoven lenses)
traverseNames :: (Functor f) => ([Ast.Name.Name] -> f [Ast.Name.Name]) -> Path -> f Path
traverseNames f = \case
AbsNormalizedPath names ->
AbsNormalizedPath <$> f names
RelNormalizedPath movesUp names ->
RelNormalizedPath movesUp <$> f names
traverseLastName :: (Functor f) => (Ast.Name.Name -> f Ast.Name.Name) -> Path -> f Path
traverseLastName =
traverseNames . List.traverseHeadWithDefault Ast.Name.empty
traverseExtensions :: (Functor f) => ([Text] -> f [Text]) -> Path -> f Path
traverseExtensions =
traverseLastName . Ast.Name.traverseExtensions
-- * Mappers
mapNames :: ([Ast.Name.Name] -> [Ast.Name.Name]) -> Path -> Path
mapNames f = \case
AbsNormalizedPath names ->
AbsNormalizedPath (f names)
RelNormalizedPath movesUp names ->
RelNormalizedPath movesUp (f names)
mapHeadName :: (Ast.Name.Name -> Ast.Name.Name) -> Path -> Path
mapHeadName f = mapNames $ \case
head : tail -> f head : tail
[] -> []
mapExtensions :: ([Text] -> [Text]) -> Path -> Path
mapExtensions mapper =
runIdentity . traverseExtensions (Identity . mapper)
-- | Add file extension to the last segment of the path.
--
-- >>> addExtension "tar" "/a/b"
-- "/a/b.tar"
--
-- >>> addExtension "gz" "/a/b.tar"
-- "/a/b.tar.gz"
--
-- >>> addExtension "gitignore" ""
-- "./.gitignore"
--
-- Dots in the prefix are ignored:
--
-- >>> addExtension ".gitignore" ""
-- "./.gitignore"
--
-- In fact dots are interpreted as extension delimiters, meaning that the following two are equivalent:
--
-- >>> addExtension "tar.gz" ""
-- "./.tar.gz"
--
-- >>> (addExtension "gz" . addExtension "tar") ""
-- "./.tar.gz"
addExtension :: Text -> Path -> Path
addExtension ext = mapExtensions (ext :)
-- | Make the path non-absolute.
--
-- >>> deabsolutize "/a/b"
-- "./a/b"
--
-- >>> deabsolutize "a/b"
-- "./a/b"
deabsolutize :: Path -> Path
deabsolutize = \case
AbsNormalizedPath names -> RelNormalizedPath 0 names
relPath -> relPath
-- | Drop path to the parent directory.
--
-- >>> dropParent "/a/b"
-- "./b"
--
-- >>> dropParent "a/b"
-- "./b"
--
-- >>> dropParent "../a/b"
-- "./b"
--
-- >>> dropParent ".."
-- "."
--
-- >>> dropParent "."
-- "."
--
-- >>> dropParent "/"
-- "."
dropParent :: Path -> Path
dropParent = fromNames . toNames
where
fromNames = \case
head : _ -> RelNormalizedPath 0 [head]
_ -> RelNormalizedPath 0 []
-- | Drop the last segment of the path.
--
-- >>> dropLastSegment "./a/b.c.d"
-- "./a"
--
-- >>> dropLastSegment "./a"
-- "."
--
-- >>> dropLastSegment "."
-- "."
--
-- >>> dropLastSegment "/"
-- "/"
--
-- >>> dropLastSegment ".."
-- ".."
dropLastSegment :: Path -> Path
dropLastSegment =
mapNames \case
[] -> []
_ : tail -> tail
-- | Drop last extension if there is one.
--
-- >>> dropExtension "/a/b.c.d"
-- "/a/b.c"
--
-- >>> dropExtension "/a/b.c"
-- "/a/b"
--
-- >>> dropExtension "/a/b"
-- "/a/b"
--
-- >>> dropExtension "/"
-- "/"
dropExtension :: Path -> Path
dropExtension = mapHeadName $ Ast.Name.mapExtensions $ List.drop 1
-- | Drop all extensions.
--
-- >>> dropExtensions "/a/b.c.d"
-- "/a/b"
--
-- >>> dropExtensions "a"
-- "./a"
dropExtensions :: Path -> Path
dropExtensions = mapHeadName $ Ast.Name.mapExtensions $ const []
-- * Accessors
-- | Compile to strict text builder.
toTextBuilder :: Path -> TextBuilder.TextBuilder
toTextBuilder = Ast.Path.toTextBuilder . toAst
-- | Compile to standard file path string.
toFilePath :: Path -> FilePath
toFilePath = toList . toText
-- | Compile to text.
toText :: Path -> Text
toText = TextBuilder.toText . toTextBuilder
toAst :: Path -> Ast.Path.Path
toAst = \case
AbsNormalizedPath names ->
Ast.Path.Path True (fmap Ast.Component.NameComponent names)
RelNormalizedPath movesUp names ->
Ast.Path.Path False
$ if movesUp == 0
then
fmap Ast.Component.NameComponent names
<> pure Ast.Component.DotComponent
else
fmap Ast.Component.NameComponent names
<> replicate movesUp Ast.Component.DotDotComponent
-- |
-- Decompose into individual segments as texts. I.e., the parts separated by slashes.
--
-- >>> toSegments "a/b.c.d"
-- ["a","b.c.d"]
--
-- >>> toSegments "/a/b.c.d"
-- ["a","b.c.d"]
--
-- >>> toSegments "."
-- []
--
-- >>> toSegments "/"
-- []
--
-- If you also want to retain the information on the path being absolute, use this in combination with 'isAbsolute':
--
-- >>> let path = "/a/b.c.d" :: Path
-- >>> toSegments path
-- ["a","b.c.d"]
-- >>> isAbsolute path
-- True
toSegments :: Path -> [Text]
toSegments = fmap Ast.Name.toText . reverse . toNames
toNames :: Path -> [Ast.Name.Name]
toNames = \case
AbsNormalizedPath names -> names
RelNormalizedPath _ names -> names
-- | Get the last segment sans extensions.
--
-- >>> toBasename "/a/b.c.d"
-- "b"
--
-- >>> toBasename "/a/b"
-- "b"
--
-- >>> toBasename "/a/.gitignore"
-- ""
toBasename :: Path -> Text
toBasename path =
case toNames path of
head : _ -> Ast.Name.toBase head
_ -> mempty
-- | Convert to a list of extensions.
--
-- >>> toExtensions "/a/b.c.d"
-- ["c","d"]
--
-- >>> toExtensions "/a/b"
-- []
toExtensions :: Path -> [Text]
toExtensions =
reverse . Ast.Name.toExtensions . List.headOr Ast.Name.empty . toNames
-- | Check if the path is absolute.
--
-- >>> isAbsolute "/a/b"
-- True
--
-- >>> isAbsolute "a/b"
-- False
--
-- >>> isAbsolute "/"
-- True
--
-- >>> isAbsolute "."
-- False
isAbsolute :: Path -> Bool
isAbsolute = \case
AbsNormalizedPath _ -> True
RelNormalizedPath _ _ -> False