termonad-4.6.0.0: src/Termonad/Preferences/File.hs
{-# LANGUAGE CPP #-}
-- | Description : Read and write to the Preferences file
-- Copyright : (c) Dennis Gosnell, 2023
-- License : BSD3
-- Stability : experimental
-- Portability : POSIX
--
-- This module contains functions for reading and writing to the preferences file.
--
-- The preferences file is generally stored in
-- @~/.config/termonad/termonad.yaml@. It stores run-time preferences that
-- have been set through the Preferences dialog. Preferences are loaded on
-- app startup, but only if the @termonad.hs@ configuration file doesn't exist.
module Termonad.Preferences.File where
import Termonad.Prelude
import Control.Monad.Trans.Except (ExceptT(..), runExceptT, throwE, withExceptT)
import Data.Aeson (Result(..), fromJSON)
#if MIN_VERSION_aeson(2, 0, 0)
import qualified Data.Aeson.KeyMap as KeyMap
#endif
import qualified Data.ByteString as ByteString
import qualified Data.HashMap.Strict as HashMap
import Data.Text (pack)
import Data.Yaml (ParseException, ToJSON (toJSON), decodeFileEither, encode, prettyPrintParseException)
import Data.Yaml.Aeson (Value(..))
import System.Directory
( XdgDirectory(XdgConfig)
, createDirectoryIfMissing
, doesFileExist
, getXdgDirectory
)
import System.FilePath ((</>))
import Termonad.Types
( ConfigOptions
, TMConfig(TMConfig, hooks, options)
, defaultConfigHooks
, defaultConfigOptions
)
-- $setup
--
-- >>> import Data.Aeson(object, (.=))
-- | Get the path to the preferences file @~\/.config\/termonad\/termonad.yaml@.
getPreferencesFile :: IO FilePath
getPreferencesFile = do
-- Get the termonad config directory
confDir <- getXdgDirectory XdgConfig "termonad"
createDirectoryIfMissing True confDir
pure $ confDir </> "termonad.yaml"
-- | Read the configuration for the preferences file
-- @~\/.config\/termonad\/termonad.yaml@. This file stores only the 'options' of
-- 'TMConfig' so 'hooks' are initialized with 'defaultConfigHooks'. If the
-- file doesn't exist, create it with the default values.
--
-- Any options that do not exist will get initialized with values from
-- 'defaultConfigOptions'.
tmConfigFromPreferencesFile :: IO TMConfig
tmConfigFromPreferencesFile = do
confFile <- getPreferencesFile
-- If there is no preferences file we create it with the default values
exists <- doesFileExist confFile
unless exists $ writePreferencesFile confFile defaultConfigOptions
-- Read the configuration file
eitherOptions <- readFileWithDefaults confFile
options <-
case eitherOptions of
Left err -> do
hPutStrLn stderr $ "Error parsing file " <> pack confFile <> ": " <> err
pure defaultConfigOptions
Right options -> pure options
pure TMConfig { options = options, hooks = defaultConfigHooks }
-- | Read the 'ConfigOptions' out of a configuration file.
--
-- Merge the raw 'ConfigOptions' with 'defaultConfigOptions'. This makes sure
-- that old versions of the configuration file will still be able to be read
-- even if new options are added to 'ConfigOptions' in new versions of
-- Termonad.
readFileWithDefaults :: FilePath -> IO (Either Text ConfigOptions)
readFileWithDefaults file = runExceptT $ do
-- Read the configuration file as a JSON object
optsFromFile :: Value <-
withExceptT parseExceptionToText . ExceptT $ decodeFileEither file
let optsDefault :: Value = toJSON defaultConfigOptions
-- Then merge it with the default options in JSON before converting it to
-- a 'ConfigOptions'
resultToExcept . fromJSON $ mergeObjVals optsFromFile optsDefault
where
parseExceptionToText :: ParseException -> Text
parseExceptionToText = pack . prettyPrintParseException
resultToExcept :: Result a -> ExceptT Text IO a
resultToExcept (Success v) = pure v
resultToExcept (Error str) = throwE (pack str)
-- | Merge 'Value's recursively.
--
-- This merges 'Value's recursively in 'Object' values, taking values that
-- have been explicitly over the defaults. The defaults are only used if
-- there is no value that has been explicitly set.
--
-- For 'Array', 'String', 'Number', 'Bool', and 'Null', take the first 'Value'
-- (the one that has been explicitly set in the user's config file):
--
-- >>> mergeObjVals (Array [Number 1, Number 2]) (Array [String "hello"])
-- Array [Number 1.0,Number 2.0]
-- >>> mergeObjVals (String "hello") (String "bye")
-- String "hello"
-- >>> mergeObjVals (Number 1) (Number 2)
-- Number 1.0
-- >>> mergeObjVals (Bool True) (Bool False)
-- Bool True
-- >>> mergeObjVals Null Null
-- Null
--
-- Note that 'Value's in 'Array's are not recursed into:
--
-- >>> let obj1 = object ["hello" .= Number 2]
-- >>> let obj2 = object ["hello" .= String "bye"]
-- >>> mergeObjVals (Array [obj1]) (Array [obj2])
-- Array [Object (fromList [("hello",Number 2.0)])]
--
-- 'Object's are recursed into. Unique keys from both Maps will be used.
-- Keys that are in both Maps will be merged according to the rules above:
--
-- >>> let object1 = object ["hello" .= Number 1, "bye" .= Number 100]
-- >>> let object2 = object ["hello" .= Number 2, "goat" .= String "chicken"]
-- >>> mergeObjVals object1 object2
-- Object (fromList [("bye",Number 100.0),("goat",String "chicken"),("hello",Number 1.0)])
--
-- 'Value's of different types will use the second 'Value':
--
-- >>> mergeObjVals Null (String "bye")
-- String "bye"
-- >>> mergeObjVals (Bool True) (Number 2)
-- Number 2.0
-- >>> mergeObjVals (Object mempty) (Bool False)
-- Bool False
mergeObjVals
:: Value
-- ^ Value that has been set explicitly in the User's configuration
-- file.
-> Value
-- ^ Default value that will be used if no explicitly set value.
-> Value
-- ^ Merged values.
mergeObjVals optsFromFile optsDefault =
case (optsFromFile, optsDefault) of
-- Both the options from the file and the default options are an Object
-- here. Recursively merge the keys and values.
(Object optsFromFileKeyMap, Object optsDefaultKeyMap) ->
let
#if MIN_VERSION_aeson(2, 0, 0)
hashMapFromKeyMap = KeyMap.toHashMap
keyMapFromHashMap = KeyMap.fromHashMap
#else
hashMapFromKeyMap = id
keyMapFromHashMap = id
#endif
optsFromFileHashMap = hashMapFromKeyMap optsFromFileKeyMap
optsDefaultHashMap = hashMapFromKeyMap optsDefaultKeyMap
optsResultHashMap = HashMap.unionWith mergeObjVals
optsFromFileHashMap
optsDefaultHashMap
optsResultKeyMap = keyMapFromHashMap optsResultHashMap
in Object optsResultKeyMap
-- Both the value from the file and the default value are the same type.
-- Use the value from the file.
--
-- XXX: This will end up causing readFileWithDefaults to fail if the value
-- from the file is old and can no longer properly be decoded into a value
-- expected by ConfigOptions.
(Array fromFile, Array _) -> Array fromFile
(String fromFile, String _) -> String fromFile
(Number fromFile, Number _) -> Number fromFile
(Bool fromFile, Bool _) -> Bool fromFile
(Null, Null) -> Null
-- The value from the file and the default value are different types. Just
-- use the default value.
(_, defVal) -> defVal
writePreferencesFile :: FilePath -> ConfigOptions -> IO ()
writePreferencesFile confFile options = do
let yaml = encode options
yamlWithComment =
"# DO NOT EDIT THIS FILE BY HAND!\n" <>
"#\n" <>
"# This file is generated automatically by the Preferences dialog\n" <>
"# in Termonad. Please open the Preferences dialog if you wish to\n" <>
"# modify this file.\n" <>
"#\n" <>
"# The settings in this file will be ignored if you have a\n" <>
"# termonad.hs file in this same directory.\n\n" <>
yaml
ByteString.writeFile confFile yamlWithComment
-- | Save the configuration to the preferences file
-- @~\/.config\/termonad\/termonad.yaml@
saveToPreferencesFile :: TMConfig -> IO ()
saveToPreferencesFile TMConfig { options = options } = do
confFile <- getPreferencesFile
writePreferencesFile confFile options