packages feed

kdl-hs-1.0.0: src/KDL/Render.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module KDL.Render (
  render,

  -- * Rendering components
  renderValue,
  renderValueData,
  renderIdentifier,
) where

import Data.Char (isDigit)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import KDL.Types (
  Ann (..),
  AnnExtension (..),
  AnnFormat (..),
  Document,
  Entry (..),
  EntryExtension (..),
  EntryFormat (..),
  Identifier (..),
  IdentifierExtension (..),
  IdentifierFormat (..),
  Node (..),
  NodeExtension (..),
  NodeFormat (..),
  NodeList (..),
  NodeListExtension (..),
  NodeListFormat (..),
  Value (..),
  ValueData (..),
  ValueExtension (..),
  ValueFormat (..),
 )

render :: Document -> Text
render = renderNodeList 0

type IndentLevel = Int

renderNodeList :: IndentLevel -> NodeList -> Text
renderNodeList lvl NodeList{..} =
  Text.concat
    [ maybe (if lvl > 0 then "\n" else "") (.leading) ext.format
    , foldMap (renderNode lvl) nodes
    , maybe (indent (lvl - 1)) (.trailing) ext.format
    ]

renderNode :: IndentLevel -> Node -> Text
renderNode lvl Node{..} =
  Text.concat
    [ maybe (indent lvl) (.leading) ext.format
    , maybe "" renderAnn ann
    , renderIdentifier name
    , foldMap renderEntry entries
    , let def_ = if children == Nothing then "" else " "
       in maybe def_ (.beforeChildren) ext.format
    , case children of
        Nothing -> ""
        Just nodes -> renderChildren lvl nodes
    , maybe "" (.beforeTerminator) ext.format
    , maybe "\n" (.terminator) ext.format
    , maybe "" (.trailing) ext.format
    ]

renderChildren :: IndentLevel -> NodeList -> Text
renderChildren lvl nodeList =
  case nodeList.ext.format of
    -- Special case empty node list to render as "{}"
    Nothing | null nodeList.nodes -> "{}"
    _ -> "{" <> renderNodeList (lvl + 1) nodeList <> "}"

indent :: IndentLevel -> Text
indent lvl = Text.replicate lvl "  "

renderEntry :: Entry -> Text
renderEntry Entry{..} =
  Text.concat
    [ maybe " " (.leading) ext.format
    , case name of
        Nothing -> renderValue value
        Just nameId ->
          Text.concat
            [ renderIdentifier nameId
            , maybe "" (.afterKey) ext.format
            , "="
            , maybe "" (.afterEq) ext.format
            , renderValue value
            ]
    , maybe "" (.trailing) ext.format
    ]

renderAnn :: Ann -> Text
renderAnn Ann{..} =
  Text.concat
    [ maybe "" (.leading) ext.format
    , "("
    , maybe "" (.beforeId) ext.format
    , renderIdentifier identifier
    , maybe "" (.afterId) ext.format
    , ")"
    , maybe "" (.trailing) ext.format
    ]

renderValue :: Value -> Text
renderValue Value{..} =
  Text.concat
    [ maybe "" renderAnn ann
    , fromMaybe (renderValueData data_) (ext.format >>= (.repr))
    ]

renderValueData :: ValueData -> Text
renderValueData = \case
  String s -> renderString s
  Number x -> (Text.pack . show) x
  Bool b -> if b then "#true" else "#false"
  Inf -> "#inf"
  NegInf -> "#-inf"
  NaN -> "#nan"
  Null -> "#null"
 where
  renderString s = if isPlainIdent s then s else "\"" <> Text.concatMap escapeChar s <> "\""
  isPlainIdent s =
    and . map not $
      [ Text.any isDisallowedChar s
      , case fmap Text.uncons <$> Text.uncons s of
          Just (c, _) | isDigit c -> True
          Just (c0, Just (c1, _))
            | c0 `elem` ['.', '-', '+']
            , isDigit c1 ->
                True
          _ -> False
      , s `elem` ["inf", "-inf", "nan", "true", "false", "null"]
      ]
  isDisallowedChar c =
    or
      [ c `elem` disallowedIdentChars
      , any (c `Text.elem`) newlines
      , c `elem` unicodeSpaces
      , isDisallowedUnicode c
      , c == '='
      ]
  disallowedIdentChars = ['\\', '/', '(', ')', '{', '}', '[', ']', ';', '"', '#']
  newlines =
    [ "\x000D\x000A"
    , "\x000D"
    , "\x000A"
    , "\x0085"
    , "\x000B"
    , "\x000C"
    , "\x2028"
    , "\x2029"
    ]
  unicodeSpaces =
    [ '\x0009'
    , '\x0020'
    , '\x00A0'
    , '\x1680'
    , '\x2000'
    , '\x2001'
    , '\x2002'
    , '\x2003'
    , '\x2004'
    , '\x2005'
    , '\x2006'
    , '\x2007'
    , '\x2008'
    , '\x2009'
    , '\x200A'
    , '\x202F'
    , '\x205F'
    , '\x3000'
    ]
  isDisallowedUnicode c =
    or
      [ '\x0000' < c && c <= '\x0008'
      , '\x000E' < c && c <= '\x001F'
      , '\x200E' < c && c <= '\x200F'
      , '\x202A' < c && c <= '\x202E'
      , '\x2066' < c && c <= '\x2069'
      , c == '\xFEFF'
      ]
  escapeChar = \case
    '\\' -> "\\\\"
    '"' -> "\\\""
    '\n' -> "\\n"
    '\r' -> "\\r"
    '\t' -> "\\t"
    '\x08' -> "\\b"
    '\x0C' -> "\\f"
    c -> Text.singleton c

renderIdentifier :: Identifier -> Text
renderIdentifier ident = fromMaybe ident.value (ident.ext.format >>= (.repr))