kdl-hs-1.2.1: src/KDL/Decoder/Internal/Error.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}
module KDL.Decoder.Internal.Error (
DecodeError (..),
BaseDecodeError,
DecodeErrorKind (..),
Context (..),
ContextItem (..),
renderDecodeError,
) where
import Control.Exception (Exception (..))
import Data.Default (Default (..))
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Text (Text)
import Data.Text qualified as Text
import KDL.Render (
renderIdentifier,
renderValue,
)
import KDL.Types (
Identifier,
Span (..),
Value,
)
import System.FilePath (takeFileName)
import Prelude hiding (span)
data DecodeError = DecodeError
{ filepath :: Maybe FilePath
, errors :: NonEmpty BaseDecodeError
}
deriving (Show, Eq)
instance Exception DecodeError where
displayException = Text.unpack . renderDecodeError
type BaseDecodeError = (Context, DecodeErrorKind)
data Context = Context
{ path :: [ContextItem]
, span :: Maybe Span
, srcLine :: Maybe Text
}
deriving (Show, Eq)
instance Default Context where
def =
Context
{ path = []
, span = Nothing
, srcLine = Nothing
}
data ContextItem
= ContextNode
{ name :: Identifier
, index :: Int
}
| ContextArg
{ index :: Int
, label :: Maybe Text
}
| ContextProp
{ name :: Identifier
}
deriving (Show, Eq, Ord)
data DecodeErrorKind
= DecodeError_Custom Text
| DecodeError_ParseError Text
| DecodeError_ExpectedNode {name :: Text, index :: Int}
| DecodeError_ExpectedArg {index :: Int, label :: Maybe Text, expectedTypes :: [Text]}
| DecodeError_ExpectedProp {name :: Text, expectedTypes :: [Text]}
| DecodeError_MismatchedAnn {givenAnn :: Identifier, validAnns :: [Text]}
| DecodeError_ValueDecodeFail {expectedType :: Text, value :: Value}
| DecodeError_UnexpectedNode {identifier :: Identifier, index :: Int}
| DecodeError_UnexpectedArg {index :: Int, value :: Value}
| DecodeError_UnexpectedProp {identifier :: Identifier, value :: Value}
deriving (Show, Eq)
renderDecodeError :: DecodeError -> Text
renderDecodeError decodeError =
Text.intercalate "\n"
. concatMap renderCtxErrors
. NonEmpty.groupAllWith1 groupKey
$ decodeError.errors
where
-- Group errors with the same contexts together
groupKey (ctx, _) = maybe (Left ctx.path) Right ctx.span
renderCtxErrors = \case
-- Special case parse errors, which shouldn't have a context
(_, DecodeError_ParseError msg) NonEmpty.:| _ -> [msg]
errs ->
let (ctx, _) = NonEmpty.head errs
in renderCtx ctx $ (map (renderError . snd) $ NonEmpty.toList errs)
renderCtx (ctx :: Context) =
case ctx.span of
Nothing -> renderCtxPath ctx.path
Just span -> renderCtxFull span ctx
-- If we don't have the error span, the best we can do is render the context path:
--
-- At: foo.kdl > user #0 > arg #0
-- ├─ error message
-- └─ another error message
renderCtxPath path errors =
let pathDisplay =
Text.intercalate " > " . concat $
[ case decodeError.filepath of
Nothing -> []
Just fp -> [Text.pack $ takeFileName fp]
, if null path then ["(root)"] else map renderCtxPathItem path
]
errors' =
[ (if isLast then "└─ " else "├─ ") <> err
| (err, isLast) <- withIsLast errors
]
in ("At: " <> pathDisplay) : errors'
renderCtxPathItem = \case
ContextNode{..} -> renderIdentifier name <> " #" <> showT index
ContextArg{..} -> renderArg index label
ContextProp{..} -> "prop " <> renderIdentifier name
-- If we have the error span, show a descriptive error message:
--
-- foo.kdl:3:16:
-- • Expected number, got string
-- |
-- 3 | some_child bad-value
-- | ^^^^^^^^^
renderCtxFull (span :: Span) ctx errors =
let spanDisplay =
Text.concat . map (<> ":") $
[ maybe "<input>" Text.pack decodeError.filepath
, showT span.startLine
, showT span.startCol
]
errors' = map (" • " <>) errors
preview =
case ctx.srcLine of
Nothing -> []
Just line ->
let lineNum = showT span.startLine
spaces n = Text.replicate n " "
renderPrefix isSpace = (if isSpace then spaces (Text.length lineNum) else lineNum) <> " │"
spanLength =
if span.startLine == span.endLine
then span.endCol - span.startCol + 1
else Text.length line - span.startCol + 1
in [ renderPrefix True
, renderPrefix False <> " " <> line
, renderPrefix True <> spaces span.startCol <> Text.replicate spanLength "^"
]
in spanDisplay : errors' ++ preview
renderError = \case
DecodeError_Custom msg -> msg
DecodeError_ParseError msg -> msg
DecodeError_ExpectedNode{..}
| index == 0 -> "Expected node: " <> name
| otherwise -> "Expected another node: " <> name
DecodeError_ExpectedArg{..} ->
Text.concat
[ "Expected "
, renderArg index label
, if null expectedTypes
then ""
else " with type: " <> oxfordList "or" expectedTypes
]
DecodeError_ExpectedProp{..} ->
Text.concat
[ "Expected prop '" <> name <> "'"
, if null expectedTypes
then ""
else " with type: " <> oxfordList "or" expectedTypes
]
DecodeError_MismatchedAnn{..} -> "Expected annotation to be one of " <> showT validAnns <> ", got: " <> renderIdentifier givenAnn
DecodeError_ValueDecodeFail{..} -> "Expected " <> expectedType <> ", got: " <> renderValue value
DecodeError_UnexpectedNode{..} -> "Unexpected node: " <> renderIdentifier identifier <> " #" <> showT index
DecodeError_UnexpectedArg{..} -> "Unexpected arg #" <> showT index <> ": " <> renderValue value
DecodeError_UnexpectedProp{..} -> "Unexpected prop: " <> renderIdentifier identifier <> "=" <> renderValue value
renderArg index label = "arg " <> maybe ("#" <> showT index) (\s -> "'" <> s <> "'") label
oxfordList conj = \case
[x] -> x
[x, y] -> Text.unwords [x, conj, y]
xs -> Text.intercalate ", " $ mapLast ((conj <> " ") <>) xs
mapLast f = \case
[] -> []
[x] -> [f x]
x : xs -> x : mapLast f xs
withIsLast = mapLast (True <$) . map (\x -> (x, False))
-- Replace with Text.show after requiring at least text-2.1.2
showT :: (Show a) => a -> Text
showT = Text.pack . show