packages feed

seihou-core-0.4.0.0: src/Seihou/Dhall/Config.hs

module Seihou.Dhall.Config
  ( evalConfigFile,
    evalConfigFileIfExists,
    serializeConfig,
    escapeDhallText,
  )
where

import Control.Exception (SomeException, try)
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Dhall (input, strictText)
import Dhall.Marshal.Decode (Decoder, field, list, record)
import Seihou.Prelude
import System.Directory (doesFileExist)

-- | Evaluate a Dhall config file to a @Map Text Text@.
--
-- Config files are plain Dhall records of text values, e.g.:
--
-- >  { license = "MIT", `project.name` = "my-app" }
--
-- This function reads the file, wraps the content with @toMap@ to convert
-- the record to a list of key-value entries, then decodes via the Dhall
-- library.
evalConfigFile :: FilePath -> IO (Map Text Text)
evalConfigFile path = do
  content <- TIO.readFile path
  let stripped = stripDhallComments content
  if isEmptyConfig stripped
    then pure Map.empty
    else input configMapDecoder ("toMap (" <> content <> ")")

-- | Check whether a stripped Dhall config expression is empty.
isEmptyConfig :: Text -> Bool
isEmptyConfig s = s `elem` ["{=}", "{ = }"] || T.null s

-- | Strip single-line Dhall comments (lines starting with @--@) and
-- whitespace, leaving only the meaningful Dhall expression.
stripDhallComments :: Text -> Text
stripDhallComments =
  T.strip . T.unlines . filter (not . isComment) . T.lines
  where
    isComment line' = "--" `T.isPrefixOf` T.stripStart line'

-- | Like 'evalConfigFile', but returns an empty map if the file does not exist.
-- Dhall parse/evaluation errors still propagate as 'Left'.
evalConfigFileIfExists :: FilePath -> IO (Either Text (Map Text Text))
evalConfigFileIfExists path = do
  exists <- doesFileExist path
  if exists
    then first formatError <$> try (evalConfigFile path)
    else pure (Right Map.empty)
  where
    formatError :: SomeException -> Text
    formatError e = "Error reading config " <> T.pack path <> ": " <> T.pack (show e)

-- | Serialize a @Map Text Text@ to valid Dhall source text.
--
-- Produces a multi-line record with backtick-escaped keys and Dhall text
-- literal values, using trailing-comma style:
--
-- >  { `key1` = "value1"
-- >  , `key2` = "value2"
-- >  }
--
-- An empty map produces @{=}@. Keys are sorted alphabetically for
-- deterministic output.
serializeConfig :: Map Text Text -> Text
serializeConfig m
  | Map.null m = "{=}\n"
  | otherwise =
      let entries = Map.toAscList m
          firstLine (k, v) = "{ `" <> k <> "` = \"" <> escapeDhallText v <> "\""
          restLine (k, v) = ", `" <> k <> "` = \"" <> escapeDhallText v <> "\""
          body = case entries of
            [] -> ""
            (e : es) -> T.unlines (firstLine e : map restLine es <> ["}"])
       in body

-- | Escape special characters inside a Dhall text literal.
--
-- Dhall text literals use double-quotes. Backslash and double-quote
-- must be escaped with a preceding backslash.
escapeDhallText :: Text -> Text
escapeDhallText = T.concatMap escapeChar
  where
    escapeChar '\\' = "\\\\"
    escapeChar '"' = "\\\""
    escapeChar c = T.singleton c

-- | Decoder for a list of @{ mapKey : Text, mapValue : Text }@ entries,
-- which is what @toMap { k = "v" }@ produces.
configMapDecoder :: Decoder (Map Text Text)
configMapDecoder = fmap Map.fromList (list entryDecoder)
  where
    entryDecoder :: Decoder (Text, Text)
    entryDecoder = record ((,) <$> field "mapKey" strictText <*> field "mapValue" strictText)