dojang-0.2.1: src/Dojang/Syntax/Manifest/Writer.hs
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoFieldSelectors #-}
module Dojang.Syntax.Manifest.Writer
( WriteError (..)
, writeManifest
, writeManifestFile
) where
import Control.Monad.Except (MonadError (throwError))
import Data.Bifunctor (Bifunctor (second))
import Data.List (group, partition, sort)
import Data.List.NonEmpty (NonEmpty ((:|)), length, toList)
import Data.Maybe (fromMaybe, listToMaybe)
import GHC.IsList (IsList (fromList))
import System.IO.Unsafe (unsafePerformIO)
import Prelude hiding (all, any, writeFile)
import Data.CaseInsensitive (CI (original))
import Data.HashMap.Strict (toList)
import Data.Map.Strict (toList)
import Data.Text (Text, pack, unpack)
import Data.Text.Encoding (encodeUtf8)
import System.OsPath (OsPath, decodeFS)
import TextShow (FromStringShow (FromStringShow), TextShow (showt))
import Toml.Pretty (prettyTomlOrdered)
import Toml.ToValue (ToTable (toTable))
import qualified Data.Map.Strict
import Dojang.MonadFileSystem
( FileType (Directory)
, MonadFileSystem (writeFile)
)
import Dojang.Syntax.EnvironmentPredicate.Writer (writeEnvironmentPredicate)
import Dojang.Syntax.Manifest.Internal
( EnvironmentPredicate' (..)
, FileRoute'
, FileRouteMap'
, FlatOrNonEmptyStrings (..)
, Hooks' (..)
, IgnoreMap'
, Manifest' (Manifest')
, MonikerMap'
, always
)
import qualified Dojang.Syntax.Manifest.Internal as Internal
import Dojang.Types.EnvironmentPredicate
( EnvironmentPredicate (..)
, normalizePredicate
)
import Dojang.Types.FilePathExpression (FilePathExpression, toPathText)
import Dojang.Types.FileRoute (FileRoute (..))
import Dojang.Types.FileRouteMap (FileRouteMap)
import Dojang.Types.Hook (Hook (..), HookMap, HookType (..))
import Dojang.Types.Manifest (Manifest (..))
import Dojang.Types.MonikerMap (MonikerMap)
import Dojang.Types.MonikerName (MonikerName)
schema :: Text
schema = "https://schema.dojang.dev/2023-11/manifest.schema.json"
-- | An error that can occur while encoding a 'Manifest'.
data WriteError
= -- | A route contains an environment predicate that cannot be represented
-- by any moniker in the manifest.
UnrepresentableRoutePredicate
OsPath
-- ^ The source path of the route.
EnvironmentPredicate
-- ^ The predicate that cannot be represented.
| -- | Multiple route predicates resolve to the same moniker and therefore
-- cannot be represented as distinct entries in the manifest.
DuplicateRouteMoniker
OsPath
-- ^ The source path of the route.
MonikerName
-- ^ The moniker shared by multiple route predicates.
deriving (Eq)
instance Show WriteError where
show (UnrepresentableRoutePredicate path predicate) =
"Route predicate "
<> show predicate
<> " for path "
<> show (decodePath path)
<> " cannot be represented by any moniker."
show (DuplicateRouteMoniker path moniker) =
"Multiple route predicates for path "
<> show (decodePath path)
<> " resolve to moniker "
<> show moniker
<> "."
-- | Encodes a 'Manifest' into a TOML document.
writeManifest
:: Manifest
-- ^ The 'Manifest' to encode.
-> Either WriteError Text
-- ^ The encoded TOML document, or an error if the manifest contains route
-- predicates that the TOML format cannot represent without data loss.
writeManifest manifest = do
manifest' <- mapManifest' manifest
let tbl = toTable manifest'
pure $
"#:schema "
<> schema
<> "\n\n"
<> (showt $ FromStringShow $ prettyTomlOrdered order tbl)
where
order :: [String] -> String -> Either Int String
order [] field = case field of
"dirs" -> Left 1
"files" -> Left 2
"ignores" -> Left 3
"monikers" -> Left 4
_ -> Right field
order _ field = Right field
-- | Writes a 'Manifest' file to the given path. Throws an 'IOError' if the
-- manifest cannot be encoded or an error occurs while writing the file.
writeManifestFile
:: (MonadFileSystem m)
=> Manifest
-- ^ The 'Manifest' to write.
-> OsPath
-- ^ The path to write the 'Manifest' to.
-> m ()
writeManifestFile manifest filePath =
case writeManifest manifest of
Left err -> throwError $ userError $ show err
Right toml -> writeFile filePath $ encodeUtf8 toml
mapManifest' :: Manifest -> Either WriteError Manifest'
mapManifest' manifest = do
(dirs, files) <- mapFiles manifest.fileRoutes manifest.monikers
pure $ Manifest' monikers' dirs files ignores hooks'
where
monikers' :: MonikerMap'
monikers' = mapMonikers' manifest.monikers
ignores :: IgnoreMap'
ignores =
fromList
[ (decodePath path, pattern)
| (path, pattern) <- Data.Map.Strict.toList manifest.ignorePatterns
]
hooks' :: Maybe Hooks'
hooks' =
if Data.Map.Strict.null manifest.hooks
then Nothing
else Just $ mapHooks' manifest.hooks
mapFiles
:: FileRouteMap
-> MonikerMap
-> Either WriteError (FileRouteMap', FileRouteMap')
mapFiles fileRouteMap monikers = do
dirs' <- traverse mapRoute dirs
files' <- traverse mapRoute files
pure (fromList dirs', fromList files')
where
mapRoute :: (OsPath, FileRoute) -> Either WriteError (FilePath, FileRoute')
mapRoute (path, route) = do
route' <- mapFileRoute' path route monikers
pure (decodePath path, route')
dirs :: [(OsPath, FileRoute)]
files :: [(OsPath, FileRoute)]
(dirs, files) =
partition
((== Directory) . fileType . snd)
$ Data.Map.Strict.toList fileRouteMap
decodePath :: OsPath -> FilePath
decodePath = unsafePerformIO . decodeFS
mapFileRoute'
:: OsPath -> FileRoute -> MonikerMap -> Either WriteError FileRoute'
mapFileRoute' routePath fileRoute monikers = do
mappedPredicates <- traverse mapPredicate fileRoute.predicates
case duplicateMoniker mappedPredicates of
Nothing -> Right $ fromList mappedPredicates
Just moniker -> Left $ DuplicateRouteMoniker routePath moniker
where
duplicateMoniker :: [(MonikerName, Text)] -> Maybe MonikerName
duplicateMoniker entries =
listToMaybe
[ moniker
| moniker : _ : _ <- group $ sort $ fst <$> entries
]
mapPredicate
:: (EnvironmentPredicate, Maybe FilePathExpression)
-> Either WriteError (MonikerName, Text)
mapPredicate (predicate, filePath) =
case normalizePredicate predicate of
Moniker name -> Right (name, path)
normalizedPredicate -> case lookBack monikers normalizedPredicate of
Just name -> Right (name, path)
Nothing ->
Left $ UnrepresentableRoutePredicate routePath normalizedPredicate
where
path = maybe "" toPathText filePath
lookBack :: MonikerMap -> EnvironmentPredicate -> Maybe MonikerName
lookBack monikers predicate =
listToMaybe $
sort
[ n
| (n, p) <- Data.HashMap.Strict.toList monikers
, normalizePredicate p == normalizedPred
]
where
normalizedPred :: EnvironmentPredicate
normalizedPred = normalizePredicate predicate
mapMonikers' :: MonikerMap -> MonikerMap'
mapMonikers' monikers =
fromList $
second (mapEnvironmentPredicate' . normalizePredicate)
<$> Data.HashMap.Strict.toList monikers
mapEnvironmentPredicate' :: EnvironmentPredicate -> EnvironmentPredicate'
mapEnvironmentPredicate' Always = always
mapEnvironmentPredicate' (Moniker n) = always{all = Just $ fromList [n]}
mapEnvironmentPredicate' (OperatingSystem os) =
always{os = Just $ Flat $ unpack $ original os.identifier}
mapEnvironmentPredicate' (Architecture arch) =
always{arch = Just $ Flat $ unpack $ original arch.identifier}
mapEnvironmentPredicate' (KernelName kernel) =
always{kernel = Just $ Flat $ unpack $ original kernel}
mapEnvironmentPredicate' (KernelRelease kernelRelease) =
always{kernelRelease = Just $ Flat $ unpack $ original kernelRelease}
mapEnvironmentPredicate' (And predicates') =
fromMaybe
always{when = Just $ writeEnvironmentPredicate $ And predicates'}
(xor' $ mapEnvironmentPredicate' <$> predicates')
where
xor' :: NonEmpty EnvironmentPredicate' -> Maybe EnvironmentPredicate'
xor' (predicate :| []) = Just predicate
xor' (a :| predicates'') =
let r : est = predicates''
in case xor' (r :| est) of
Nothing -> Nothing
Just b -> do
os' <- xorMaybe a.os b.os
arch' <- xorMaybe a.arch b.arch
kernel' <- xorMaybe a.kernel b.kernel
kernelRelease' <- xorMaybe a.kernelRelease b.kernelRelease
all' <- xorMaybe a.all b.all
any' <- xorMaybe a.any b.any
when' <- xorMaybe a.when b.when
return
EnvironmentPredicate'
{ os = os'
, arch = arch'
, kernel = kernel'
, kernelRelease = kernelRelease'
, all = all'
, any = any'
, when = when'
}
xorMaybe :: Maybe a -> Maybe a -> Maybe (Maybe a)
xorMaybe (Just _) (Just _) = Nothing
xorMaybe (Just a) Nothing = Just $ Just a
xorMaybe Nothing (Just b) = Just $ Just b
xorMaybe Nothing Nothing = Just Nothing
mapEnvironmentPredicate' (Or predicates')
| Prelude.length oses == length' =
always{os = Just $ NonEmpty $ toNonEmpty oses}
| Prelude.length arches == length' =
always{arch = Just $ NonEmpty $ toNonEmpty arches}
| Prelude.length kernels == length' =
always{kernel = Just $ NonEmpty $ toNonEmpty kernels}
| Prelude.length kernelReleases == length' =
always{kernelRelease = Just $ NonEmpty $ toNonEmpty kernelReleases}
| Prelude.length monikers' == length' =
always{any = Just $ toNonEmpty monikers'}
| otherwise =
always{when = Just $ writeEnvironmentPredicate $ Or predicates'}
where
length' :: Int
length' = Data.List.NonEmpty.length predicates'
oses :: [String]
oses =
[ unpack $ original os'.identifier
| OperatingSystem os' <- Data.List.NonEmpty.toList predicates'
]
arches :: [String]
arches =
[ unpack $ original arch'.identifier
| Architecture arch' <- Data.List.NonEmpty.toList predicates'
]
kernels :: [String]
kernels =
[ unpack $ original kernel'
| KernelName kernel' <- Data.List.NonEmpty.toList predicates'
]
kernelReleases :: [String]
kernelReleases =
[ unpack $ original kernelRelease'
| KernelRelease kernelRelease' <- Data.List.NonEmpty.toList predicates'
]
monikers' :: [MonikerName]
monikers' = [n | Moniker n <- Data.List.NonEmpty.toList predicates']
toNonEmpty :: [a] -> NonEmpty a
toNonEmpty (x : xs) = x :| xs
toNonEmpty [] = error "toNonEmpty: empty list"
mapEnvironmentPredicate' pred' =
always{when = Just $ writeEnvironmentPredicate pred'}
mapHooks' :: HookMap -> Hooks'
mapHooks' hookMap =
Hooks'
{ preApply = mapHookList PreApply
, preFirstApply = mapHookList PreFirstApply
, postFirstApply = mapHookList PostFirstApply
, postApply = mapHookList PostApply
}
where
mapHookList :: HookType -> Maybe [Internal.Hook']
mapHookList hookType =
case Data.Map.Strict.lookup hookType hookMap of
Nothing -> Nothing
Just [] -> Nothing
Just hooks' -> Just $ mapHook <$> hooks'
mapHook :: Hook -> Internal.Hook'
mapHook hook =
Internal.Hook'
{ Internal.command = decodePath' hook.command
, Internal.args = if null hook.args then Nothing else Just hook.args
, Internal.moniker = Nothing -- moniker is already resolved into condition
, Internal.condition =
if hook.condition == Always
then Nothing
else Just $ writeEnvironmentPredicate hook.condition
, Internal.workingDirectory = decodePath <$> hook.workingDirectory
, Internal.ignoreFailure = if hook.ignoreFailure then Just True else Nothing
}
decodePath' :: OsPath -> Text
decodePath' = unsafePerformIO . fmap pack . decodeFS