packages feed

wireform-yaml-0.1.0.0: src/YAML/Derive.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}

{- | Annotation-driven Template Haskell deriver for YAML
'YAML.Class.ToYAML' / 'YAML.Class.FromYAML' instances.

Encoding shape:

* 'TypeShapeNewtype' — pass-through to the inner field's instance.
* 'TypeShapeRecord'  — YAML mapping with text-string keys.
* 'TypeShapeEnum'    — YAML scalar 'YV.YString' carrying the
  (possibly renamed) constructor name.
* 'TypeShapeSum'     — YAML mapping
  @{ tag: \"Ctor\", contents: ... }@; nullary constructors omit
  the @contents@ key entirely (mirroring the TOML / EDN derivers).

Modifiers honoured: 'rename', 'renameStyle', 'renameWith', 'skip',
'defaults', 'optional', 'coerced'.
-}
module YAML.Derive (
  deriveYAML,
  deriveToYAML,
  deriveFromYAML,
) where

import Data.Coerce (Coercible, coerce)
import Data.Foldable (foldlM)
import Data.List (nub)
import Data.Text qualified as Text
import Data.Vector qualified as V
import Language.Haskell.TH
import Wireform.Derive.Backend
import Wireform.Derive.ModifierInfo
import Wireform.Derive.TypeInfo
import YAML.Class qualified as Y
import YAML.Value qualified as YV


-- ---------------------------------------------------------------------------
-- Public entry points
-- ---------------------------------------------------------------------------

deriveYAML :: Name -> Q [Dec]
deriveYAML nm = (++) <$> deriveToYAML nm <*> deriveFromYAML nm


deriveToYAML :: Name -> Q [Dec]
deriveToYAML nm = do
  ti <- reifyTypeInfo nm
  context <- instanceContext ''Y.ToYAML ti
  body <- toYAMLBody ti
  let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
      decl =
        InstanceD
          Nothing
          context
          (AppT (ConT ''Y.ToYAML) typ)
          [FunD 'Y.toYAML [Clause [] (NormalB body) []]]
  pure [decl]


deriveFromYAML :: Name -> Q [Dec]
deriveFromYAML nm = do
  ti <- reifyTypeInfo nm
  context <- instanceContext ''Y.FromYAML ti
  body <- fromYAMLBody ti
  let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
      decl =
        InstanceD
          Nothing
          context
          (AppT (ConT ''Y.FromYAML) typ)
          [FunD 'Y.fromYAML [Clause [] (NormalB body) []]]
  pure [decl]


-- Constrain the variables used by serialized fields. An applied type variable
-- (f a) needs a constraint on the application, rather than the higher-kinded f.
instanceContext :: Name -> TypeInfo -> Q Cxt
instanceContext cls ti = do
  constraints <- case typeInfoShape ti of
    TypeShapeNewtype constructor -> pure (concatMap ordinary (conInfoFields constructor))
    TypeShapeRecord constructor -> concat <$> traverse fieldConstraints (conInfoFields constructor)
    TypeShapeEnum _ -> pure []
    TypeShapeSum constructors -> concat <$> traverse (\constructor -> if conInfoIsRecord constructor then concat <$> traverse fieldConstraints (conInfoFields constructor) else pure (concatMap ordinary (conInfoFields constructor))) constructors
  pure (nub (typeInfoContext ti <> constraints))
  where
    ordinary = (AppT (ConT cls) <$>) . variables . fieldInfoType
    fieldConstraints field@(FieldInfo selector typ) = case selector of
      Nothing -> pure (ordinary field)
      Just name -> do
        modifier <- reifyModifierInfoFor backendYAML name
        pure $
          if miSkip modifier
            then []
            else case miCoerce modifier of
              Nothing -> ordinary field
              Just target
                | null (variables typ) -> []
                | otherwise ->
                    let wireType = if miRequired modifier == Just False then AppT (ConT ''Maybe) (ConT target) else ConT target
                    in [AppT (AppT (ConT ''Coercible) typ) wireType]
    variables typ
      | variableHead typ = [typ]
    variables (AppT left right) = variables left <> variables right
    variables (SigT typ _) = variables typ
    variables (ParensT typ) = variables typ
    variables _ = []
    variableHead (VarT _) = True
    variableHead (AppT typ _) = variableHead typ
    variableHead _ = False


-- ---------------------------------------------------------------------------
-- ToYAML
-- ---------------------------------------------------------------------------

toYAMLBody :: TypeInfo -> Q Exp
toYAMLBody ti = case typeInfoShape ti of
  TypeShapeNewtype c -> toYAMLNewtype c
  TypeShapeRecord c -> toYAMLRecord c
  TypeShapeEnum cs -> toYAMLEnum cs
  TypeShapeSum cs -> toYAMLSum cs


toYAMLNewtype :: ConInfo -> Q Exp
toYAMLNewtype c = case conInfoFields c of
  [FieldInfo (Just sel) _] -> do
    x <- newName "x"
    lamE [varP x] [|Y.toYAML ($(varE sel) $(varE x))|]
  [FieldInfo Nothing _] -> do
    x <- newName "x"
    lamE [conP (conInfoName c) [varP x]] [|Y.toYAML $(varE x)|]
  _ -> fail "YAML.Derive: newtype must have exactly one field"


toYAMLRecord :: ConInfo -> Q Exp
toYAMLRecord c = do
  x <- newName "x"
  pairs <- recordToYAMLPairs (varE x) c
  lamE [varP x] [|YV.YMap (V.fromList $(pure pairs))|]


recordToYAMLPairs :: Q Exp -> ConInfo -> Q Exp
recordToYAMLPairs varExp c = do
  pairExpss <- mapM (toYAMLField varExp) (conInfoFields c)
  pure (ListE (concat pairExpss))


toYAMLField :: Q Exp -> FieldInfo -> Q [Exp]
toYAMLField varExp (FieldInfo mSel _) = do
  selName <- requireSelector mSel
  mi <- reifyModifierInfoFor backendYAML selName
  if miSkip mi
    then pure []
    else do
      let selBase = Text.pack (nameBase selName)
      keyExp <- renderWireKey mi selBase
      let getter = appE (varE selName) varExp
          encoded = case miCoerce mi of
            Nothing -> [|Y.toYAML $getter|]
            Just target -> [|Y.toYAML $(sigE [|coerce $getter|] (pure (if miRequired mi == Just False then AppT (ConT ''Maybe) (ConT target) else ConT target)))|]
      pair <- [|(YV.YString $(pure keyExp), $encoded)|]
      pure [pair]


toYAMLEnum :: [ConInfo] -> Q Exp
toYAMLEnum cs = do
  v <- newName "v"
  matches <- mapM enumToYAMLMatch cs
  body <- caseE (varE v) (map pure matches)
  lamE [varP v] (pure body)


enumToYAMLMatch :: ConInfo -> Q Match
enumToYAMLMatch c = do
  mi <- reifyModifierInfoFor backendYAML (conInfoName c)
  keyExp <- renderWireKey mi (Text.pack (nameBase (conInfoName c)))
  body <- [|YV.YString $(pure keyExp)|]
  pure (Match (ConP (conInfoName c) [] []) (NormalB body) [])


toYAMLSum :: [ConInfo] -> Q Exp
toYAMLSum cs = do
  v <- newName "v"
  matches <- mapM sumCtorToYAML cs
  body <- caseE (varE v) (map pure matches)
  lamE [varP v] (pure body)


sumCtorToYAML :: ConInfo -> Q Match
sumCtorToYAML c
  | conInfoIsRecord c
  , not (null (conInfoFields c)) = do
      modifier <- reifyModifierInfoFor backendYAML (conInfoName c)
      tag <- renderWireKey modifier (Text.pack (nameBase (conInfoName c)))
      record <- newName "record"
      pairs <- recordToYAMLPairs (varE record) c
      body <- [|YV.YMap (V.fromList [(YV.YString "tag", YV.YString $(pure tag)), (YV.YString "contents", YV.YMap (V.fromList $(pure pairs)))])|]
      let pattern_ = AsP record (ConP (conInfoName c) [] (replicate (length (conInfoFields c)) WildP))
      pure (Match pattern_ (NormalB body) [])
  | otherwise = sumCtorToYAMLPositional c


sumCtorToYAMLPositional :: ConInfo -> Q Match
sumCtorToYAMLPositional c = do
  mi <- reifyModifierInfoFor backendYAML (conInfoName c)
  keyExp <- renderWireKey mi (Text.pack (nameBase (conInfoName c)))
  fieldNames <- mapM (\_ -> newName "f") (conInfoFields c)
  let pat = ConP (conInfoName c) [] (map VarP fieldNames)
  body <- case fieldNames of
    [] ->
      [|
        YV.YMap
          ( V.fromList
              [ (YV.YString (Text.pack "tag"), YV.YString $(pure keyExp))
              ]
          )
        |]
    [n] ->
      [|
        YV.YMap
          ( V.fromList
              [ (YV.YString (Text.pack "tag"), YV.YString $(pure keyExp))
              , (YV.YString (Text.pack "contents"), Y.toYAML $(varE n))
              ]
          )
        |]
    ns ->
      [|
        YV.YMap
          ( V.fromList
              [ (YV.YString (Text.pack "tag"), YV.YString $(pure keyExp))
              ,
                ( YV.YString (Text.pack "contents")
                , YV.YSeq
                    ( V.fromList
                        $(pure (ListE (map (AppE (VarE 'Y.toYAML) . VarE) ns)))
                    )
                )
              ]
          )
        |]
  pure (Match pat (NormalB body) [])


-- ---------------------------------------------------------------------------
-- FromYAML
-- ---------------------------------------------------------------------------

fromYAMLBody :: TypeInfo -> Q Exp
fromYAMLBody ti = case typeInfoShape ti of
  TypeShapeNewtype c -> fromYAMLNewtype c
  TypeShapeRecord c -> fromYAMLRecord c
  TypeShapeEnum cs -> fromYAMLEnum cs
  TypeShapeSum cs -> fromYAMLSum cs


fromYAMLNewtype :: ConInfo -> Q Exp
fromYAMLNewtype c = case conInfoFields c of
  [FieldInfo _ _] -> [|fmap $(conE (conInfoName c)) . Y.fromYAML|]
  _ -> fail "YAML.Derive: newtype must have exactly one field"


fromYAMLRecord :: ConInfo -> Q Exp
fromYAMLRecord c = do
  v <- newName "v"
  kvs <- newName "kvs"
  bodyE <- recordParser kvs c
  let unwrapped = AppE (VarE 'YV.unwrap) (VarE v)
  lamE
    [varP v]
    ( caseE
        (pure unwrapped)
        [ match
            (conP 'YV.YMap [varP kvs])
            (normalB [|Y.validateFieldNames $(varE kvs) >> $(pure bodyE)|])
            []
        , match
            wildP
            ( normalB
                [|Left "YAML.Derive: expected YMap for record type"|]
            )
            []
        ]
    )


recordParser :: Name -> ConInfo -> Q Exp
recordParser kvs c = do
  let conName = conInfoName c
      fields = conInfoFields c
  case fields of
    [] -> [|Right $(conE conName)|]
    (f0 : fs) -> do
      e0 <- fieldParser kvs f0
      hd <- [|$(conE conName) <$> $(pure e0)|]
      foldlM
        ( \acc f -> do
            ef <- fieldParser kvs f
            [|$(pure acc) <*> $(pure ef)|]
        )
        hd
        fs


fieldParser :: Name -> FieldInfo -> Q Exp
fieldParser kvs (FieldInfo mSel _) = do
  selName <- requireSelector mSel
  mi <- reifyModifierInfoFor backendYAML selName
  if miSkip mi
    then case miDefaults mi of
      Just defNm -> [|Right $(varE defNm)|]
      Nothing ->
        [|
          Left
            ( "YAML.Derive: missing 'defaults' for skipped field "
                ++ $(litE (stringL (nameBase selName)))
            )
          |]
    else do
      let selBase = Text.pack (nameBase selName)
      keyExp <- renderWireKey mi selBase
      let isOptional = miRequired mi == Just False
          targetType target = if isOptional then AppT (ConT ''Maybe) (ConT target) else ConT target
      missing <- case miDefaults mi of
        Just def -> [|Right $(varE def)|]
        Nothing | isOptional -> [|Right Nothing|]
        Nothing -> [|Left ("YAML.Derive: missing field " ++ Text.unpack $(pure keyExp))|]
      input <- newName "input"
      present <- case miCoerce mi of
        Nothing -> [|Y.fromYAML $(varE input)|]
        Just target -> [|coerce <$> $(sigE [|Y.fromYAML $(varE input)|] (pure (AppT (AppT (ConT ''Either) (ConT ''String)) (targetType target))))|]
      [|
        case lookupYAMLField $(pure keyExp) $(varE kvs) of
          Nothing -> $(pure missing)
          Just $(varP input) -> $(pure present)
        |]


-- | Linear scan for a key in a 'YV.YMap'.
lookupYAMLField
  :: Text.Text
  -> V.Vector (YV.Value, YV.Value)
  -> Maybe YV.Value
lookupYAMLField nm kvs = V.foldr step Nothing kvs
  where
    step (k, val) acc = case YV.unwrap k of
      YV.YString s | s == nm -> Just val
      _ -> acc


fromYAMLEnum :: [ConInfo] -> Q Exp
fromYAMLEnum cs = do
  v <- newName "v"
  s <- newName "s"
  branches <- mapM (enumDispatch s) cs
  let fallback =
        ( NormalG (ConE 'True)
        , AppE
            ( AppE
                (VarE 'mappend)
                (LitE (StringL "YAML.Derive: unknown enum value "))
            )
            (AppE (VarE 'show) (VarE s))
        )
      multi =
        MultiIfE
          ( map (\(g, e) -> (g, AppE (ConE 'Right) e)) branches
              ++ [(NormalG (ConE 'True), AppE (ConE 'Left) (snd fallback))]
          )
      unwrapped = AppE (VarE 'YV.unwrap) (VarE v)
  lamE
    [varP v]
    ( caseE
        (pure unwrapped)
        [ match
            (conP 'YV.YString [varP s])
            (normalB (pure multi))
            []
        , match
            wildP
            (normalB [|Left "YAML.Derive: enum expected YString"|])
            []
        ]
    )


enumDispatch :: Name -> ConInfo -> Q (Guard, Exp)
enumDispatch sVar c = do
  mi <- reifyModifierInfoFor backendYAML (conInfoName c)
  keyExp <- renderWireKey mi (Text.pack (nameBase (conInfoName c)))
  let guardExp = InfixE (Just (VarE sVar)) (VarE '(==)) (Just keyExp)
      bodyExp = ConE (conInfoName c)
  pure (NormalG guardExp, bodyExp)


fromYAMLSum :: [ConInfo] -> Q Exp
fromYAMLSum cs = do
  v <- newName "v"
  kvs <- newName "kvs"
  tagVar <- newName "tag"
  cVar <- newName "c"
  branches <- mapM (sumDispatch tagVar cVar) cs
  let fallback =
        ( NormalG (ConE 'True)
        , AppE
            (ConE 'Left)
            ( AppE
                ( AppE
                    (VarE 'mappend)
                    (LitE (StringL "YAML.Derive: unknown sum tag "))
                )
                (AppE (VarE 'show) (VarE tagVar))
            )
        )
      multi = MultiIfE (branches ++ [fallback])
      unwrapped = AppE (VarE 'YV.unwrap) (VarE v)
  lamE
    [varP v]
    ( caseE
        (pure unwrapped)
        [ match
            (conP 'YV.YMap [varP kvs])
            ( normalB
                [|
                  do
                    Y.validateFieldNames $(varE kvs)
                    $(varP tagVar) <-
                      case lookupYAMLField (Text.pack "tag") $(varE kvs) of
                        Just t -> case YV.unwrap t of
                          YV.YString s -> Right s
                          _ -> Left "YAML.Derive: sum 'tag' not a string"
                        _ -> Left "YAML.Derive: sum missing 'tag'"
                    let $(varP cVar) =
                          lookupYAMLField (Text.pack "contents") $(varE kvs)
                    $(pure multi)
                  |]
            )
            []
        , match
            wildP
            (normalB [|Left "YAML.Derive: sum expected YMap"|])
            []
        ]
    )


sumDispatch :: Name -> Name -> ConInfo -> Q (Guard, Exp)
sumDispatch tagVar cVar c = do
  mi <- reifyModifierInfoFor backendYAML (conInfoName c)
  keyExp <- renderWireKey mi (Text.pack (nameBase (conInfoName c)))
  let guardExp = InfixE (Just (VarE tagVar)) (VarE '(==)) (Just keyExp)
  body <-
    if conInfoIsRecord c && not (null (conInfoFields c))
      then recordSumParser cVar c
      else case conInfoFields c of
        [] ->
          let conNameStr = nameBase (conInfoName c)
          in [|
               case $(varE cVar) of
                 Nothing -> Right $(conE (conInfoName c))
                 Just _ ->
                   Left
                     ( "YAML.Derive: "
                         ++ conNameStr
                         ++ " is nullary; unexpected 'contents'"
                     )
               |]
        [_one] ->
          let conNameStr = nameBase (conInfoName c)
          in [|
               case $(varE cVar) of
                 Just inner -> fmap $(conE (conInfoName c)) (Y.fromYAML inner)
                 Nothing ->
                   Left
                     ( "YAML.Derive: "
                         ++ conNameStr
                         ++ " missing 'contents'"
                     )
               |]
        many -> sumNAry cVar (conInfoName c) (length many)
  pure (NormalG guardExp, body)


recordSumParser :: Name -> ConInfo -> Q Exp
recordSumParser contents constructor = do
  fields <- newName "fields"
  parser <- recordParser fields constructor
  [|
    do
      $(varP fields) <- case $(varE contents) of
        Nothing -> Right V.empty
        Just inner -> case YV.unwrap inner of
          YV.YMap entries -> Right entries
          _ -> Left "YAML.Derive: record constructor contents must be a mapping"
      Y.validateFieldNames $(varE fields)
      $(pure parser)
    |]


sumNAry :: Name -> Name -> Int -> Q Exp
sumNAry cVar conName arity = do
  arr <- newName "arr"
  let parseI :: Int -> Q Exp
      parseI i = [|Y.fromYAML ($(varE arr) V.! $(litE (integerL (fromIntegral i))))|]
  hd <- do
    e0 <- parseI 0
    [|$(conE conName) <$> $(pure e0)|]
  body <-
    foldlM
      ( \acc i -> do
          ei <- parseI i
          [|$(pure acc) <*> $(pure ei)|]
      )
      hd
      [1 .. arity - 1]
  let conNameStr = nameBase conName
      arityStr = show arity
  [|
    case $(varE cVar) of
      Just inner -> case YV.unwrap inner of
        YV.YSeq $(varP arr)
          | V.length $(varE arr) == $(litE (integerL (fromIntegral arity))) ->
              $(pure body)
          | otherwise ->
              Left
                ( "YAML.Derive: "
                    ++ conNameStr
                    ++ " expected "
                    ++ arityStr
                    ++ " contents, got "
                    ++ show (V.length $(varE arr))
                )
        _ ->
          Left
            ( "YAML.Derive: "
                ++ conNameStr
                ++ " expected YSeq contents"
            )
      Nothing ->
        Left
          ( "YAML.Derive: "
              ++ conNameStr
              ++ " missing 'contents'"
          )
    |]


-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

requireSelector :: Maybe Name -> Q Name
requireSelector (Just n) = pure n
requireSelector Nothing =
  fail "YAML.Derive: cannot derive YAML for non-record positional field"


applyTypeArgs :: Type -> [Type] -> Type
applyTypeArgs = foldl AppT