wireform-proto-0.2.0.0: src/Proto/CodeGen/Hooks.hs
{- | Attribute-driven codegen hook system.
Protobuf custom options (attributes) can drive additional Haskell code
generation. This module provides hook types for both the text-based
code generator ('CodeGenHooks') and the Template Haskell splice path
('THHooks'), sharing the same context types.
= Three integration points
There are three ways proto files get turned into Haskell code in wireform,
and hooks plug into all of them:
1. __Text-based codegen__ (@wireform-gen@ CLI, 'Proto.Setup', 'Proto.CodeGen.generateModuleText'):
set the 'genHooks' field of 'Proto.CodeGen.GenerateOpts'.
2. __Template Haskell splices__ ('Proto.TH.loadProtoWith'):
set the 'loTHHooks' field of 'Proto.TH.LoadOpts'.
3. __Cabal Setup.hs__ ('Proto.Setup.protoGenPreBuildHook'):
set 'Proto.Setup.pgcHooks' in 'Proto.Setup.ProtoGenConfig'.
= Text-based codegen hooks
Use 'CodeGenHooks' when generating Haskell source as 'Text'. Each hook
receives a typed context and returns @['Text']@ lines to append after
the element's standard generated code.
@
import Proto.CodeGen.Hooks
import Proto.CodeGen ('Proto.CodeGen.GenerateOpts'(..), 'Proto.CodeGen.defaultGenerateOpts')
-- Add a comment after every message that has the (audited) annotation
auditHook :: 'CodeGenHooks'
auditHook = 'onMessageAttribute' "audited" $ \\val ctx ->
case val of
'CBool' True ->
[ "-- | WARNING: " \<> 'mhcHsTypeName' ctx
\<> " is an audited message (proto: " \<> 'mhcFqProtoName' ctx \<> ")"
]
_ -> []
opts :: 'Proto.CodeGen.GenerateOpts'
opts = 'Proto.CodeGen.defaultGenerateOpts' { 'Proto.CodeGen.genHooks' = auditHook }
@
Given this proto:
@
message Transfer {
option (audited) = true;
string from = 1;
string to = 2;
int64 amount = 3;
}
@
The generated module will contain, after @Transfer@'s instances:
@
-- | WARNING: Transfer is an audited message (proto: myapp.Transfer)
@
= Template Haskell hooks
Use 'THHooks' when loading proto files via Template Haskell. Each hook
receives the same context types but returns @'Language.Haskell.TH.Q' ['Language.Haskell.TH.Dec']@
— real TH declarations spliced into the calling module.
@
{\-\# LANGUAGE TemplateHaskell \#-\}
import Proto.TH
import Proto.CodeGen.Hooks
showHook :: 'THHooks'
showHook = mempty
{ 'thOnMessage' = \\ctx -> do
-- For every message, generate a \"describe\" function
let tyStr = 'Data.Text.unpack' ('mhcHsTypeName' ctx)
fnName = 'Language.Haskell.TH.mkName' ("describe" \<> tyStr)
sig \<- 'Language.Haskell.TH.sigD' fnName [t| String |]
body \<- 'Language.Haskell.TH.valD' ('Language.Haskell.TH.varP' fnName)
('Language.Haskell.TH.normalB' ('Language.Haskell.TH.litE' ('Language.Haskell.TH.stringL' ("Proto message: " \<> tyStr)))) []
pure [sig, body]
}
\$(loadProtoWith defaultLoadOpts { loTHHooks = showHook } "person.proto")
-- Now @describePerson :: String@ is available.
@
= Composing hooks
Both 'CodeGenHooks' and 'THHooks' are 'Semigroup' and 'Monoid', so
independent hooks compose with @(\<>)@:
@
allHooks :: 'CodeGenHooks'
allHooks = auditHook \<> loggingHook \<> metricsHook
@
= Attribute-driven constructors
'onMessageAttribute', 'onEnumAttribute', etc. create hooks that only
fire when a specific extension option is present:
@
-- Only fires on messages with option (generate_lens) = true;
lensHook :: 'CodeGenHooks'
lensHook = 'onMessageAttribute' "generate_lens" $ \\val ctx ->
case val of
'CBool' True -> ["makeLenses ''" \<> 'mhcHsTypeName' ctx]
_ -> []
@
The equivalent for TH:
@
thLensHook :: 'THHooks'
thLensHook = 'thOnMessageAttribute' "generate_lens" $ \\val ctx ->
case val of
'CBool' True -> do
-- ... generate TH declarations ...
pure []
_ -> pure []
@
-}
module Proto.CodeGen.Hooks (
-- * Text-based codegen hooks
CodeGenHooks (..),
defaultCodeGenHooks,
-- * Template Haskell codegen hooks
THHooks (..),
defaultTHHooks,
-- * Hook contexts (shared by both hook types)
FileHookCtx (..),
MessageHookCtx (..),
EnumHookCtx (..),
ServiceHookCtx (..),
FieldHookCtx (..),
-- * Wire transform types
OptionValue (..),
WireTransform (..),
-- * Attribute-driven constructors (text codegen)
onMessageAttribute,
onEnumAttribute,
onServiceAttribute,
onFileAttribute,
-- * Attribute-driven constructors (Template Haskell)
thOnMessageAttribute,
thOnEnumAttribute,
thOnFileAttribute,
-- * Querying attributes in hooks
lookupAttribute,
hasAttribute,
attributeAsText,
attributeAsBool,
attributeAsInt,
attributeAsFloat,
attributeAsAggregate,
-- * Extracting options from message elements
messageOptions,
) where
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Language.Haskell.TH (Dec, Q)
import Proto.IDL.AST
import Proto.IDL.Options.Custom (CustomOptionRegistry, emptyCustomOptionRegistry, extractExtensionOptions, registerCustomOption)
-- ---------------------------------------------------------------------------
-- Hook contexts
-- ---------------------------------------------------------------------------
-- | Context passed to file-level hooks.
data FileHookCtx = FileHookCtx
{ fhcProtoFile :: !ProtoFile
, fhcModuleName :: !Text
, fhcFileOptions :: ![OptionDef]
, fhcCustomOptions :: !CustomOptionRegistry
{- ^ Registry of custom option extensions extracted from
@extend google.protobuf.FieldOptions@ blocks in this file.
-}
}
deriving stock (Show)
{- | Context passed to message-level hooks.
Includes the full 'MessageDef' so hooks can inspect fields, nested types,
and message-level options.
-}
data MessageHookCtx = MessageHookCtx
{ mhcMessageDef :: !MessageDef
, mhcScope :: ![Text]
, mhcHsTypeName :: !Text
, mhcFqProtoName :: !Text
, mhcOptions :: ![OptionDef]
}
deriving stock (Show)
-- | Context passed to enum-level hooks.
data EnumHookCtx = EnumHookCtx
{ ehcEnumDef :: !EnumDef
, ehcScope :: ![Text]
, ehcHsTypeName :: !Text
, ehcOptions :: ![OptionDef]
}
deriving stock (Show)
-- | Context passed to service-level hooks.
data ServiceHookCtx = ServiceHookCtx
{ shcServiceDef :: !ServiceDef
, shcScope :: ![Text]
, shcHsTypeName :: !Text
, shcOptions :: ![OptionDef]
}
deriving stock (Show)
-- | Context passed to field-level hooks.
data FieldHookCtx = FieldHookCtx
{ fldFieldDef :: !FieldDef
, fldParentMsg :: !Text
, fldHsFieldName :: !Text
, fldFieldOptions :: ![OptionDef]
}
deriving stock (Show)
-- | Wrapper for option values in wire transform hooks.
data OptionValue
= OVBool !Bool
| OVInt !Integer
| OVFloat !Double
| OVString !Text
deriving stock (Show, Eq)
-- | Wire transform that can override how a field is encoded or decoded.
data WireTransform = WireTransform
{ wtEncodeExpr :: !Text
, wtDecodeExpr :: !Text
}
deriving stock (Show, Eq)
-- ---------------------------------------------------------------------------
-- Hook record
-- ---------------------------------------------------------------------------
{- | Collection of codegen hooks. Each field is a function that receives a
context and returns extra lines of Haskell code to emit at the
corresponding point in the generated module.
Use 'defaultCodeGenHooks' or 'mempty' for no-op hooks, then override
the fields you need. Combine multiple hooks with @(<>)@.
-}
data CodeGenHooks = CodeGenHooks
{ onFileCodeGen :: !(FileHookCtx -> [Text])
, onMessageCodeGen :: !(MessageHookCtx -> [Text])
, onEnumCodeGen :: !(EnumHookCtx -> [Text])
, onServiceCodeGen :: !(ServiceHookCtx -> [Text])
, onFieldCodeGen :: !(FieldHookCtx -> [Text])
, onCustomOption :: !(Text -> OptionValue -> Maybe WireTransform)
}
-- | No-op hooks (produce no extra code).
defaultCodeGenHooks :: CodeGenHooks
defaultCodeGenHooks =
CodeGenHooks
{ onFileCodeGen = const []
, onMessageCodeGen = const []
, onEnumCodeGen = const []
, onServiceCodeGen = const []
, onFieldCodeGen = const []
, onCustomOption = \_ _ -> Nothing
}
instance Semigroup CodeGenHooks where
a <> b =
CodeGenHooks
{ onFileCodeGen = \ctx -> onFileCodeGen a ctx <> onFileCodeGen b ctx
, onMessageCodeGen = \ctx -> onMessageCodeGen a ctx <> onMessageCodeGen b ctx
, onEnumCodeGen = \ctx -> onEnumCodeGen a ctx <> onEnumCodeGen b ctx
, onServiceCodeGen = \ctx -> onServiceCodeGen a ctx <> onServiceCodeGen b ctx
, onFieldCodeGen = \ctx -> onFieldCodeGen a ctx <> onFieldCodeGen b ctx
, onCustomOption = \name val -> case onCustomOption a name val of
Just wt -> Just wt
Nothing -> onCustomOption b name val
}
instance Monoid CodeGenHooks where
mempty = defaultCodeGenHooks
-- ---------------------------------------------------------------------------
-- Template Haskell hooks
-- ---------------------------------------------------------------------------
{- | Hooks for the Template Haskell code generation path.
Each field receives the same context types as 'CodeGenHooks' but returns
@'Q' ['Dec']@ — actual TH declarations that are spliced into the module
alongside the standard generated types and instances.
-}
data THHooks = THHooks
{ thOnFile :: !(FileHookCtx -> Q [Dec])
, thOnMessage :: !(MessageHookCtx -> Q [Dec])
, thOnEnum :: !(EnumHookCtx -> Q [Dec])
, thOnService :: !(ServiceHookCtx -> Q [Dec])
}
-- | No-op TH hooks (produce no extra declarations).
defaultTHHooks :: THHooks
defaultTHHooks =
THHooks
{ thOnFile = const (pure [])
, thOnMessage = const (pure [])
, thOnEnum = const (pure [])
, thOnService = const (pure [])
}
instance Semigroup THHooks where
a <> b =
THHooks
{ thOnFile = \ctx -> (<>) <$> thOnFile a ctx <*> thOnFile b ctx
, thOnMessage = \ctx -> (<>) <$> thOnMessage a ctx <*> thOnMessage b ctx
, thOnEnum = \ctx -> (<>) <$> thOnEnum a ctx <*> thOnEnum b ctx
, thOnService = \ctx -> (<>) <$> thOnService a ctx <*> thOnService b ctx
}
instance Monoid THHooks where
mempty = defaultTHHooks
-- ---------------------------------------------------------------------------
-- Attribute-driven hook constructors (text codegen)
-- ---------------------------------------------------------------------------
{- | Create a hook that fires on messages bearing a specific extension option.
@
onMessageAttribute \"my_annotation\" $ \\val ctx ->
[\"-- annotated: \" <> mhcHsTypeName ctx]
@
-}
onMessageAttribute :: Text -> (Constant -> MessageHookCtx -> [Text]) -> CodeGenHooks
onMessageAttribute attrName f =
mempty
{ onMessageCodeGen = \ctx ->
case lookupAttribute attrName (mhcOptions ctx) of
Just val -> f val ctx
Nothing -> []
}
-- | Create a hook that fires on enums bearing a specific extension option.
onEnumAttribute :: Text -> (Constant -> EnumHookCtx -> [Text]) -> CodeGenHooks
onEnumAttribute attrName f =
mempty
{ onEnumCodeGen = \ctx ->
case lookupAttribute attrName (ehcOptions ctx) of
Just val -> f val ctx
Nothing -> []
}
-- | Create a hook that fires on services bearing a specific extension option.
onServiceAttribute :: Text -> (Constant -> ServiceHookCtx -> [Text]) -> CodeGenHooks
onServiceAttribute attrName f =
mempty
{ onServiceCodeGen = \ctx ->
case lookupAttribute attrName (shcOptions ctx) of
Just val -> f val ctx
Nothing -> []
}
-- | Create a hook that fires when a file-level extension option is present.
onFileAttribute :: Text -> (Constant -> FileHookCtx -> [Text]) -> CodeGenHooks
onFileAttribute attrName f =
mempty
{ onFileCodeGen = \ctx ->
case lookupAttribute attrName (fhcFileOptions ctx) of
Just val -> f val ctx
Nothing -> []
}
-- ---------------------------------------------------------------------------
-- Attribute-driven hook constructors (Template Haskell)
-- ---------------------------------------------------------------------------
-- | Create a TH hook that fires on messages bearing a specific extension option.
thOnMessageAttribute :: Text -> (Constant -> MessageHookCtx -> Q [Dec]) -> THHooks
thOnMessageAttribute attrName f =
mempty
{ thOnMessage = \ctx ->
case lookupAttribute attrName (mhcOptions ctx) of
Just val -> f val ctx
Nothing -> pure []
}
-- | Create a TH hook that fires on enums bearing a specific extension option.
thOnEnumAttribute :: Text -> (Constant -> EnumHookCtx -> Q [Dec]) -> THHooks
thOnEnumAttribute attrName f =
mempty
{ thOnEnum = \ctx ->
case lookupAttribute attrName (ehcOptions ctx) of
Just val -> f val ctx
Nothing -> pure []
}
-- | Create a TH hook that fires when a file-level extension option is present.
thOnFileAttribute :: Text -> (Constant -> FileHookCtx -> Q [Dec]) -> THHooks
thOnFileAttribute attrName f =
mempty
{ thOnFile = \ctx ->
case lookupAttribute attrName (fhcFileOptions ctx) of
Just val -> f val ctx
Nothing -> pure []
}
-- ---------------------------------------------------------------------------
-- Attribute querying
-- ---------------------------------------------------------------------------
{- | Look up an extension (custom) option by name from a list of options.
Searches for options enclosed in parentheses, e.g. @(my_option)@.
-}
lookupAttribute :: Text -> [OptionDef] -> Maybe Constant
lookupAttribute name opts =
case filter (matchesExtension name) opts of
(o : _) -> Just (optValue o)
[] -> Nothing
where
matchesExtension n o = case optNameParts (optName o) of
[ExtensionOption en] -> en == n
_ -> False
-- | Check whether an extension option with the given name exists.
hasAttribute :: Text -> [OptionDef] -> Bool
hasAttribute name opts = case lookupAttribute name opts of
Just _ -> True
Nothing -> False
-- | Extract a 'Text' value from an attribute, if present and string-typed.
attributeAsText :: Text -> [OptionDef] -> Maybe Text
attributeAsText name opts = lookupAttribute name opts >>= extractString
where
extractString (CString s) = Just s
extractString _ = Nothing
-- | Extract a 'Bool' value from an attribute, if present and bool-typed.
attributeAsBool :: Text -> [OptionDef] -> Maybe Bool
attributeAsBool name opts = lookupAttribute name opts >>= extractBool
where
extractBool (CBool b) = Just b
extractBool _ = Nothing
-- | Extract an 'Integer' value from an attribute, if present and int-typed.
attributeAsInt :: Text -> [OptionDef] -> Maybe Integer
attributeAsInt name opts = lookupAttribute name opts >>= extractInt
where
extractInt (CInt n) = Just n
extractInt _ = Nothing
-- | Extract a 'Double' value from an attribute, if present and float-typed.
attributeAsFloat :: Text -> [OptionDef] -> Maybe Double
attributeAsFloat name opts = lookupAttribute name opts >>= extractFloat
where
extractFloat (CFloat n) = Just n
extractFloat _ = Nothing
-- | Extract an aggregate (key-value) value from an attribute.
attributeAsAggregate :: Text -> [OptionDef] -> Maybe [(Text, Constant)]
attributeAsAggregate name opts = lookupAttribute name opts >>= extractAgg
where
extractAgg (CAggregate kvs) = Just kvs
extractAgg _ = Nothing
-- ---------------------------------------------------------------------------
-- Extracting options from message elements
-- ---------------------------------------------------------------------------
{- | Extract all message-level options from a 'MessageDef'.
These are the @option@ statements inside the message body.
-}
messageOptions :: MessageDef -> [OptionDef]
messageOptions msg = mapMaybe extractOpt (msgElements msg)
where
extractOpt (MEOption o) = Just o
extractOpt _ = Nothing