dhall-1.42.0: src/Dhall/Package.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
-- | Create a package.dhall from files and directory contents.
module Dhall.Package
( writePackage
, getPackagePathAndContent
, PackageError(..)
) where
import Control.Exception (Exception, throwIO)
import Control.Monad
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Traversable (for)
import Dhall.Core
( Directory (..)
, Expr (..)
, File (..)
, FilePrefix (..)
, Import (..)
, ImportHashed (..)
, ImportMode (..)
, ImportType (..)
, RecordField (..)
, makeRecordField
)
import Dhall.Map (Map)
import qualified Dhall.Map as Map
import Dhall.Pretty (CharacterSet (..))
import qualified Dhall.Pretty
import Dhall.Util (_ERROR, renderExpression)
import System.Directory
import System.FilePath
-- | Create a package.dhall from files and directory contents.
-- For a description of how the package file is constructed see
-- 'getPackagePathAndContent'.
writePackage :: CharacterSet -> Maybe String -> NonEmpty FilePath -> IO ()
writePackage characterSet outputFn inputs = do
(outputPath, expr) <- getPackagePathAndContent outputFn inputs
renderExpression characterSet True (Just outputPath) expr
-- | Get the path and the Dhall expression for a package file.
--
-- The location of the resulting package file is determined by the first path of the second argument:
--
-- * If it is a directory, it is also the output directory and the package
-- file will be placed there.
--
-- * If it is a file, then the directory that file resides in is the output
-- directory and the package file will be placed there.
--
-- All inputs provided as the second argument must be either in the output
-- directory or below it. They are processed depending on whether
-- the path points to a directory or a file:
--
-- * If the path points to a directory, all files with a @.dhall@ extensions
-- in that directory are included in the package.
--
-- * If the path points to a regular file, it is included in the package
-- unless it is the path of the package file itself.
--
getPackagePathAndContent :: Maybe String -> NonEmpty FilePath -> IO (FilePath, Expr s Import)
getPackagePathAndContent outputFn (path :| paths) = do
outputDir <- do
isDirectory <- doesDirectoryExist path
return $ if isDirectory then path else takeDirectory path
outputDir' <- addTrailingPathSeparator <$> makeAbsolute (normalise outputDir)
-- Check if the supplied @dir@ argument points to a filesystem entry below
-- the output directory and return the path relative to the output directory.
let checkOutputDir dir = do
absoluteDir <- addTrailingPathSeparator <$> makeAbsolute (normalise dir)
let relativeDir = makeRelative outputDir' absoluteDir
unless (isRelative relativeDir) $
throwIO $ AmbiguousOutputDirectory outputDir dir
return relativeDir
resultMap <- go Map.empty checkOutputDir (path:paths)
return (outputDir </> outputFn', RecordLit $ Map.sort resultMap)
where
go :: Map Text (RecordField s Import) -> (FilePath -> IO FilePath) -> [FilePath] -> IO (Map Text (RecordField s Import))
go !acc _checkOutputDir [] = return acc
go !acc checkOutputDir (p:ps) = do
isDirectory <- doesDirectoryExist p
isFile <- doesFileExist p
if | isDirectory -> do
void $ checkOutputDir p
entries <- listDirectory p
let entries' = filter (\entry -> takeExtension entry == ".dhall") entries
go acc checkOutputDir (map (p </>) entries' <> ps)
| isFile -> do
dir <- checkOutputDir $ takeDirectory p
let p' = normalise $ dir </> takeFileName p
let resultMap = if p' == outputFn'
then Map.empty
else filepathToMap outputFn' p'
acc' <- mergeMaps acc resultMap
go acc' checkOutputDir ps
| otherwise -> throwIO $ InvalidPath p
outputFn' = fromMaybe "package.dhall" outputFn
-- | Construct a nested 'Map' from a 'FilePath'.
-- For example, the filepath @some/file/path.dhall@ will result in something
-- similar to the following:
--
-- fromList
-- [ ("some", fromList
-- [ ("file", fromList
-- [ ("path", ./some/file/path.dhall)
-- ])
-- ])
-- ])
--
-- ... where ./some/file/path.dhall is a Dhall import. If the last component
-- equals the value passed in the @outputFn@ argument we produce a slightly
-- different result. Consider for example the Dhall Prelude: We have some
-- sub-packages there like @List/package.dhall@. If we want to construct the
-- top-level @package.dhall@ we want an entry like
--
-- > List = ./List/package.dhall
--
-- in there and not:
--
-- > List = { package = ./List/package.dhall }
--
filepathToMap :: FilePath -> FilePath -> Map Text (RecordField s Import)
filepathToMap outputFn = go [] . splitDirectories
where
go acc [] = go acc ["."]
go !acc [x] =
let import_ = Import
{ importHashed = ImportHashed
{ hash = Nothing
, importType = Local Here File
{ directory = Directory acc
, file = Text.pack x
}
}
, importMode = Code
}
in Map.singleton (Text.pack $ dropExtension x) $ makeRecordField $ Embed import_
go !acc [x, y] | y == outputFn =
let import_ = Import
{ importHashed = ImportHashed
{ hash = Nothing
, importType = Local Here File
{ directory = Directory (Text.pack x : acc)
, file = Text.pack y
}
}
, importMode = Code
}
in Map.singleton (Text.pack x) $ makeRecordField $ Embed import_
go !acc (x:xs) = Map.singleton (Text.pack x) $ makeRecordField $ RecordLit $ go (Text.pack x : acc) xs
-- | Merge two 'Map's constructed with 'filepathToMap'.
-- It will throw an error if the arguments are not compatible with each other, e.g.
-- we cannot merge the following two maps:
--
-- > fromList [ ("file", ./file.dhall) ]
-- > fromList [ ("file", fromList [("nested", ./file/nested.dhall)]) ]
--
mergeMaps :: Map Text (RecordField s Import) -> Map Text (RecordField s Import) -> IO (Map Text (RecordField s Import))
mergeMaps x y = do
let x' = fmap (:| []) x
y' = fmap (:| []) y
z = Map.unionWith (<>) x' y'
for z $ \case
v@RecordField{recordFieldValue = Embed{}} :| [] -> return v
vs | Just rs <- traverse extractRecordLit vs -> makeRecordField . RecordLit . Map.sort <$> foldM mergeMaps Map.empty rs
| otherwise -> throwIO $ IncompatiblePaths $ foldMap extractEmbeds vs
where
extractEmbeds :: RecordField s Import -> [Import]
extractEmbeds RecordField{recordFieldValue = Embed import_} = [import_]
extractEmbeds RecordField{recordFieldValue = RecordLit xs} = foldMap extractEmbeds xs
extractEmbeds _ = mempty
extractRecordLit :: RecordField s Import -> Maybe (Map Text (RecordField s Import))
extractRecordLit RecordField{recordFieldValue = RecordLit xs} = Just xs
extractRecordLit _ = Nothing
-- | Exception thrown when creating a package file.
data PackageError
= AmbiguousOutputDirectory FilePath FilePath
| IncompatiblePaths [Import]
| InvalidPath FilePath
instance Exception PackageError
instance Show PackageError where
show (AmbiguousOutputDirectory dir1 dir2) =
_ERROR <> ": ❰dhall package❱ failed because the inputs make it impossible to\n\
\determine the output directory of the package file. You asked to include files\n\
\from the following directories in the package:\n\
\\n" <> dir1 <>
"\n" <> dir2 <>
"\n\n\
\Although those paths might point to the same location they are not lexically the\n\
\same."
show (IncompatiblePaths imports) =
_ERROR <> ": ❰dhall package❱ failed because some inputs are not compatible with\n\
\each other:\n\
\\n" <> unlines (map (show . Dhall.Pretty.prettyExpr . Embed) imports)
show (InvalidPath fp) =
_ERROR <> ": ❰dhall package❱ failed because the input does not exist or is\n\
\neither a directory nor a regular file:\n\
\\n" <> fp