keiro-dsl-0.2.0.0: src/Keiro/Dsl/Parser.hs
{- | The megaparsec parser for the keiro DSL. Turns @.keiro@ text into the typed
'Spec' AST. The notation is keyword-driven: newlines and @#@-comments are
whitespace, structure comes from keywords (@aggregate@, @regs@, @states@,
@command@, @event@, @wire@, @projection@) and the transition arrow
@Src -- Command --> clauses@. Guards and write right-hand sides are parsed as
a typed 'Expr' (never an opaque string) so the validator can scope-check them.
-}
module Keiro.Dsl.Parser (
ParseError,
parseSpec,
parseSpecText,
)
where
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
import Data.Char (isAlpha, isAlphaNum, isAscii, isDigit, isUpper)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Void (Void)
import Keiro.Dsl.Grammar
import Text.Megaparsec hiding (ParseError)
import Text.Megaparsec.Char (char, digitChar, letterChar, space1)
import Text.Megaparsec.Char.Lexer qualified as L
-- | A rendered, line-numbered parse error, ready to print to the user.
type ParseError = Text
type P = Parsec Void Text
{- | Parse a @.keiro@ source. The 'FilePath' is used only as the source name in
diagnostics (megaparsec's line/column reporting); it need not exist on disk.
This is the canonical signature shared across all keiro-dsl plans.
-}
parseSpec :: FilePath -> Text -> Either ParseError Spec
parseSpec src input =
case runParser (sc *> pSpec <* eof) src input of
Left bundle -> Left (T.pack (errorBundlePretty bundle))
Right spec -> Right spec
-- | Convenience wrapper for callers without a source name (tests, stdin).
parseSpecText :: Text -> Either ParseError Spec
parseSpecText = parseSpec "<input>"
--------------------------------------------------------------------------------
-- Lexer
--------------------------------------------------------------------------------
-- | Space consumer: spaces, newlines, and @#@ line comments are all whitespace.
sc :: P ()
sc = L.space space1 (L.skipLineComment "#") empty
lexeme :: P a -> P a
lexeme = L.lexeme sc
symbol :: Text -> P Text
symbol = L.symbol sc
{- | A reserved keyword: the literal word not followed by an identifier
character (so @goto@ matches @goto@ but not @gotoX@).
-}
keyword :: Text -> P ()
keyword w = (lexeme . try) (string' w *> notFollowedBy (identChar <|> (char '-' *> identChar)))
where
string' = chunk
identChar :: P Char
identChar = asciiAlphaNum <|> char '_'
asciiLetter :: P Char
asciiLetter = satisfy (\c -> isAscii c && isAlpha c)
asciiUpper :: P Char
asciiUpper = satisfy (\c -> isAscii c && isUpper c)
asciiDigit :: P Char
asciiDigit = satisfy (\c -> isAscii c && isDigit c)
asciiAlphaNum :: P Char
asciiAlphaNum = satisfy (\c -> isAscii c && isAlphaNum c)
-- | Fail with the diagnostic caret placed at a previously captured offset.
failAt :: Int -> String -> P a
failAt offset message = region (setErrorOffset offset) (fail message)
{- | Parse a decimal as an unbounded Integer, then reject values that cannot be
represented as Int. Parsing L.decimal directly at Int silently wraps.
-}
boundedDecimal :: P Int
boundedDecimal = do
offset <- getOffset
value <- lexeme (L.decimal :: P Integer)
checkedDecimal offset value
checkedDecimal :: Int -> Integer -> P Int
checkedDecimal offset value
| value > fromIntegral (maxBound :: Int) =
failAt
offset
( "decimal literal "
<> show value
<> " is out of range (maximum "
<> show (maxBound :: Int)
<> ")"
)
| otherwise = pure (fromIntegral value)
{- | Words that may not be used as bare identifiers, because they introduce a
different construct and would otherwise be swallowed (e.g. @aggregate@ ending
one node and beginning the next).
-}
reservedWords :: [Text]
reservedWords =
[ "context"
, "module"
, "layout"
, "prefixed"
, "collocated"
, "id"
, "enum"
, "rule"
, "ex"
, "aggregate"
, "regs"
, "states"
, "command"
, "event"
, "wire"
, "projection"
, "snapshot"
, "category"
, "guard"
, "write"
, "emit"
, "goto"
, "fields"
, "status-map"
, "true"
, "false"
, "deprecated"
, "upcast"
, "from"
, "HOLE"
, "process"
, "router"
, "dispatch-each"
, "resolve"
, "read-model"
, "dispatch"
, -- EP-4 integration: structural keywords never used as identifiers, so a
-- list like @accept A B C@ stops at the next block keyword.
"intake"
, "contract"
, "topic"
, "accept"
, "bind"
, "dedupe"
, "persist"
, "decode"
, "disposition"
, "publisher"
, "map"
, -- EP-5 pgmq structural keywords.
"workqueue"
, "queue"
, "payload"
, "retry"
, "fanout"
, "dedup"
, "enqueue"
, "seenIn"
, -- EP-6 workflow/operation: reserved so the multi-word result-type parse and
-- node boundaries don't swallow the next block keyword.
"workflow"
, "operation"
, "consistency"
, "body"
, "step"
, "await"
, "sleep"
, "child"
, "patch"
, "continueAsNew"
, -- EP-107 read-model structural words. Clause labels such as table and
-- schema remain usable identifiers because their block parser consumes
-- them with symbol-style matching.
"readmodel"
, "columns"
, "feed"
, "scope"
, "shape"
]
{- | A CamelCase / snake_case identifier (no dashes): type names, register
names, command\/event\/state names, enum constructors, projection keys.
-}
ident :: P Name
ident = (lexeme . try) $ do
c <- asciiLetter <|> char '_'
cs <- many identChar
let w = T.pack (c : cs)
if w `elem` reservedWords
then fail ("unexpected reserved word " <> T.unpack w)
else pure w
{- | A wire-spelling token, which may contain dashes (@partial-divert@,
@hospital-capacity@). Used for the context name, id prefixes, enum wire
spellings, and status-map values.
-}
wireWord :: P Text
wireWord = lexeme $ do
c <- asciiLetter <|> asciiDigit
cs <- many (identChar <|> char '-')
pure (T.pack (c : cs))
{- | Patch ids use wire-word spelling, but admit @:@ so the validator can emit
the domain-specific 'WorkflowPatchIdInvalid' diagnostic at the owning item.
-}
patchIdWord :: P Text
patchIdWord = lexeme $ do
c <- asciiLetter <|> asciiDigit
cs <- many (identChar <|> char '-' <|> char ':')
pure (T.pack (c : cs))
getLoc :: P Loc
getLoc = (Loc . unPos . sourceLine) <$> getSourcePos
--------------------------------------------------------------------------------
-- Top level
--------------------------------------------------------------------------------
data TopItem
= TIId IdDecl
| TIEnum EnumDecl
| TIRule RuleDecl
| TINode Node
pSpec :: P Spec
pSpec = do
keyword "context"
ctx <- wireWord
mroot <- optional pModuleClause
mlayout <- optional pLayoutClause
items <- many pTopItem
pure
Spec
{ specContext = ctx
, specModuleRoot = mroot
, specLayout = mlayout
, specIds = [d | TIId d <- items]
, specEnums = [d | TIEnum d <- items]
, specRules = [d | TIRule d <- items]
, specNodes = [n | TINode n <- items]
}
-- | @module Acme.Services@ — the optional namespace-prefix clause.
pModuleClause :: P Text
pModuleClause = keyword "module" *> pModulePrefix
-- | @layout (prefixed|collocated)@ — the optional placement-style clause.
pLayoutClause :: P Placement
pLayoutClause =
keyword "layout"
*> choice
[ GeneratedPrefix <$ keyword "prefixed"
, CollocatedLeaf <$ keyword "collocated"
]
{- | A dotted module prefix: one-or-more PascalCase segments joined by dots,
e.g. @Acme@ or @Acme.Services@.
-}
pModulePrefix :: P Text
pModulePrefix = lexeme $ do
seg0 <- pSeg
segs <- many (char '.' *> pSeg)
pure (T.intercalate "." (seg0 : segs))
where
pSeg = do
c <- asciiUpper
cs <- many identChar
pure (T.pack (c : cs))
pTopItem :: P TopItem
pTopItem =
choice
[ TIId <$> pIdDecl
, TIEnum <$> pEnumDecl
, TIRule <$> pRuleDecl
, TINode . NRouter <$> pRouter
, TINode . NProcess <$> pProcess
, TINode . NContract <$> pContract
, TINode . NIntake <$> pIntake
, TINode . NEmit <$> pEmit
, TINode . NPublisher <$> pPublisher
, TINode . NWorkqueue <$> pWorkqueue
, TINode . NPgmqDispatch <$> pPgmqDispatch
, TINode . NReadModel <$> pReadModel
, TINode . NWorkflow <$> pWorkflow
, TINode . NOperation <$> pOperation
, TINode . NAggregate <$> pAggregate
]
pIdDecl :: P IdDecl
pIdDecl = do
loc <- getLoc
keyword "id"
name <- ident
_ <- symbol "prefix"
_ <- symbol "="
pfx <- wireWord
pure IdDecl{idName = name, idPrefix = pfx, idLoc = loc}
pEnumDecl :: P EnumDecl
pEnumDecl = do
loc <- getLoc
keyword "enum"
name <- ident
ctors <- braces (many pEnumCtor)
pure EnumDecl{enumName = name, enumCtors = ctors, enumLoc = loc}
where
pEnumCtor = do
c <- ident
_ <- symbol "="
w <- wireWord
pure (c, w)
pRuleDecl :: P RuleDecl
pRuleDecl = do
loc <- getLoc
keyword "rule"
name <- ident
_ <- symbol ":"
dom <- ident
_ <- symbol "->"
cod <- ident
keyword "ex"
cases <- sepBy1 pCase (symbol ";")
pure
RuleDecl
{ ruleName = name
, ruleDomain = dom
, ruleCodomain = cod
, ruleCases = cases
, ruleLoc = loc
}
where
pCase = do
c <- ident
_ <- symbol "=>"
e <- pExpr
pure (c, e)
--------------------------------------------------------------------------------
-- Aggregate node
--------------------------------------------------------------------------------
data BodyItem
= BICommand Command
| BIEvent Event
| BIWire WireSpec
| BIProjection ProjectionSpec
| BISnapshot SnapshotSpec
| BITransition Transition
pAggregate :: P Aggregate
pAggregate = do
loc <- getLoc
keyword "aggregate"
name <- ident
regs <- pRegsBlock
states <- pStatesLine
positionedItems <- many ((,) <$> getOffset <*> pBodyItem)
let items = map snd positionedItems
wireOffsets = [offset | (offset, BIWire _) <- positionedItems]
projectionOffsets = [offset | (offset, BIProjection _) <- positionedItems]
snapshotOffsets = [offset | (offset, BISnapshot _) <- positionedItems]
case wireOffsets of
_ : duplicateOffset : _ ->
failAt duplicateOffset ("duplicate wire block in aggregate " <> T.unpack name <> " (only one is allowed)")
_ -> pure ()
case projectionOffsets of
_ : duplicateOffset : _ ->
failAt duplicateOffset ("duplicate projection block in aggregate " <> T.unpack name <> " (only one is allowed)")
_ -> pure ()
case snapshotOffsets of
_ : duplicateOffset : _ ->
failAt duplicateOffset ("duplicate snapshot block in aggregate " <> T.unpack name <> " (only one is allowed)")
_ -> pure ()
pure
Aggregate
{ aggName = name
, aggRegs = regs
, aggStates = states
, aggCommands = [c | BICommand c <- items]
, aggEvents = [e | BIEvent e <- items]
, aggTransitions = [t | BITransition t <- items]
, aggWire = listToMaybe [w | BIWire w <- items]
, aggProjection = listToMaybe [p | BIProjection p <- items]
, aggSnapshot = listToMaybe [s | BISnapshot s <- items]
, aggLoc = loc
}
where
listToMaybe xs = case xs of (x : _) -> Just x; [] -> Nothing
pRegsBlock :: P [RegDecl]
pRegsBlock = do
keyword "regs"
many pRegDecl
pRegDecl :: P RegDecl
pRegDecl = do
loc <- getLoc
name <- ident
ty <- ident
_ <- symbol "="
initial <- (RegInitText <$> stringLit) <|> (RegInitBare <$> (ident <|> signedDecimalText))
pure RegDecl{regName = name, regType = ty, regInitial = initial, regLoc = loc}
pStatesLine :: P [StateDecl]
pStatesLine = do
keyword "states"
many pStateDecl
where
-- A state decl is an identifier with an optional terminal @!@. The
-- @notFollowedBy@ lookahead stops the list before a transition whose source
-- state would otherwise be swallowed as an extra state, e.g. when a
-- transition directly follows the @states@ line with no command\/event
-- between them. The @try@ backtracks so the identifier is left for
-- 'pTransition'.
pStateDecl = try $ do
loc <- getLoc
n <- ident
term <- option False (True <$ symbol "!")
notFollowedBy (symbol "--")
pure StateDecl{stName = n, stTerminal = term, stLoc = loc}
pBodyItem :: P BodyItem
pBodyItem =
choice
[ BICommand <$> pCommand
, BIEvent <$> pEvent
, BIWire <$> pWire
, BIProjection <$> pProjection
, BISnapshot <$> pSnapshot
, BITransition <$> pTransition
]
pSnapshot :: P SnapshotSpec
pSnapshot = do
loc <- getLoc
keyword "snapshot"
policy <-
choice
[ SnapEvery <$> (keyword "every" *> boundedDecimal)
, SnapOnTerminal <$ symbol "on-terminal"
]
_ <- symbol "state-codec"
_ <- symbol "version" *> symbol "="
version <- boundedDecimal
_ <- symbol "shape-hash" *> symbol "="
hash <- stringLit
pure SnapshotSpec{snapPolicy = policy, snapCodecVersion = version, snapShapeHash = hash, snapLoc = loc}
pCommand :: P Command
pCommand = do
loc <- getLoc
keyword "command"
name <- ident
fs <- braces (many pField)
pure Command{cmdName = name, cmdFields = fs, cmdLoc = loc}
pField :: P Field
pField = do
n <- ident
mty <- optional (symbol ":" *> ident)
pure Field{fieldName = n, fieldType = mty}
pEvent :: P Event
pEvent = do
loc <- getLoc
dep <- option False (True <$ keyword "deprecated")
keyword "event"
name <- ident
ver <- option 1 pVersion
body <-
choice
[ EventFromCommand <$> (symbol "=" *> keyword "fields" *> parens ident)
, EventFields <$> braces (many pField)
]
up <- optional pUpcast
pure
Event
{ evName = name
, evBody = body
, evVersion = ver
, evUpcastFrom = up
, evDeprecated = dep
, evLoc = loc
}
where
pUpcast = do
keyword "upcast"
keyword "from"
m <- pVersion
_ <- symbol "="
keyword "HOLE"
pure (m, Hole)
{- | A @vN@ schema-version token (e.g. @v2@). Fails (backtracking) on anything
that is not @v@ immediately followed by digits.
-}
pVersion :: P Int
pVersion = do
offset <- getOffset
value <- lexeme (try (char 'v' *> (L.decimal :: P Integer) <* notFollowedBy identChar))
checkedDecimal offset value
pWire :: P WireSpec
pWire = do
keyword "wire"
_ <- symbol "kind"
_ <- symbol "="
k <- wireWord
_ <- symbol "fields"
_ <- symbol "="
f <- wireWord
_ <- symbol "schemaVersion"
_ <- symbol "="
v <- boundedDecimal
pure WireSpec{wireKind = k, wireFields = f, wireSchemaVersion = v}
pProjection :: P ProjectionSpec
pProjection = do
loc <- getLoc
keyword "projection"
table <- ident
cons <- optional (symbol "consistency" *> symbol "=" *> pConsistency)
_ <- symbol "key"
_ <- symbol "="
k <- ident
sm <- optional pStatusMap
pure
ProjectionSpec
{ projTable = table
, projConsistency = cons
, projKey = k
, projStatusMap = sm
, projLoc = loc
}
where
pConsistency =
choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]
pStatusMap :: P Mapping
pStatusMap = do
keyword "status-map"
partial <- option False (True <$ keyword "partial")
pairs <- braces (many pPair)
pure Mapping{mapPairs = pairs, mapPartial = partial}
where
pPair = do
l <- ident
_ <- symbol "=>"
r <- wireWord
pure (l, r)
--------------------------------------------------------------------------------
-- Integration contract (EP-4)
--------------------------------------------------------------------------------
pContract :: P ContractNode
pContract = do
loc <- getLoc
keyword "contract"
nm <- ident
_ <- symbol "{"
keyword "schemaVersion"
sv <- boundedDecimal
keyword "discriminator"
disc <- ident
topics <- many pTopic
events <- many pContractEvent
_ <- symbol "}"
pure
ContractNode
{ ctrName = nm
, ctrSchemaVersion = sv
, ctrDiscriminator = disc
, ctrTopics = topics
, ctrEvents = events
, ctrLoc = loc
}
where
pTopic = do
keyword "topic"
alias <- ident
t <- stringLit
pure (alias, t)
pContractEvent = do
keyword "event"
nm <- ident
keyword "on"
topicAlias <- ident
fs <- braces (many pContractField)
pure ContractEvent{ceName = nm, ceTopic = topicAlias, ceFields = fs}
pContractField = do
n <- ident
_ <- symbol ":"
ty <- pContractType
_ <- optional (symbol ";")
pure ContractField{cfName = n, cfType = ty}
pContractType =
choice
[ CTypeId <$> (keyword "typeid" *> stringLit)
, CText <$ keyword "text"
, CInt <$ keyword "int"
]
pIntake :: P IntakeNode
pIntake = do
loc <- getLoc
keyword "intake"
nm <- ident
_ <- symbol "{"
keyword "contract"
ctr <- ident
keyword "topic"
tp <- ident
keyword "accept"
acc <- some ident
binds <- many pBindRow
keyword "dedupe"
keyword "key"
dk <- ident
keyword "policy"
dp <- ident
persistence <-
option InkPersistFull $
keyword "persist"
*> symbol "="
*> choice
[ InkPersistFull <$ keyword "full-envelope"
, InkPersistDedupeOnly <$ keyword "dedupe-only"
]
dec <- pDecode
disp <- pDisposition
_ <- symbol "}"
pure
IntakeNode
{ inkName = nm
, inkContract = ctr
, inkTopic = tp
, inkAccept = acc
, inkBinds = binds
, inkDedupeKey = dk
, inkDedupePolicy = dp
, inkPersist = persistence
, inkDecode = dec
, inkDisposition = disp
, inkLoc = loc
}
where
pBindRow = do
keyword "bind"
f <- ident
keyword "from"
src <- pWireSource
req <- option False (True <$ keyword "required")
xc <- option False (True <$ (keyword "cross-check" *> keyword "body"))
pure BindRow{brField = f, brSource = src, brRequired = req, brCrossCheck = xc}
pWireSource =
choice
[ SrcHeader <$> (keyword "header" *> stringLit)
, SrcKafkaKey <$ keyword "kafka-key"
, SrcKafkaCursor <$ keyword "kafka-cursor"
, SrcBody <$ keyword "body"
]
pDecode = do
keyword "decode"
_ <- symbol "{"
keyword "envelope"
env <- pEnvelopePolicy
keyword "body"
strict <- (True <$ keyword "strict") <|> (False <$ keyword "lenient")
keyword "schemaVersion"
_ <- symbol "=="
v <- boundedDecimal
_ <- symbol "}"
pure DecodeSpec{decEnvelope = env, decBodyStrict = strict, decBodySchemaVersion = v}
pEnvelopePolicy = do
a <- wireWord
b <- wireWord
pure (a <> " " <> b)
pDisposition = do
keyword "disposition"
rows <- braces (many pDispositionRow)
pure rows
pDispositionRow = do
loc <- getLoc
o <- ident
_ <- symbol "=>"
act <- pInboxAction
pure DispositionRow{drOutcome = o, drAction = act, drLoc = loc}
pInboxAction =
choice
[ IAckOk <$ keyword "ackOk"
, IRetry <$> (keyword "retry" *> pWindow)
, IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)
]
pEmit :: P EmitNode
pEmit = do
loc <- getLoc
keyword "emit"
nm <- ident
_ <- symbol "{"
keyword "contract"
ctr <- ident
keyword "topic"
tp <- ident
keyword "source"
src <- stringLit
keyword "key"
k <- ident
keyword "map"
disc <- ident
(rows, skip) <- braces pMapRows
keyword "messageId"
mid <- pDerive
keyword "idempotencyKey"
idk <- pDerive
_ <- symbol "}"
pure
EmitNode
{ emName = nm
, emContract = ctr
, emTopic = tp
, emSource = src
, emKey = k
, emDiscriminant = disc
, emMap = rows
, emSkip = skip
, emMessageId = mid
, emIdempotencyKey = idk
, emLoc = loc
}
where
pMapRows = do
rows <- many pMapRow
skip <- option False (True <$ try (symbol "_" *> symbol "=>" *> keyword "skip"))
pure (rows, skip)
pMapRow = try $ do
loc <- getLoc
v <- stringLit
_ <- symbol "=>"
ev <- ident
pure EmitMapRow{emrValue = v, emrEvent = ev, emrLoc = loc}
pDerive = do
keyword "derive"
pfx <- optional stringLit
keyword "hole"
pure DeriveSpec{dsPrefix = pfx}
pPublisher :: P PublisherNode
pPublisher = do
loc <- getLoc
keyword "publisher"
nm <- ident
_ <- symbol "{"
keyword "emit"
em <- ident
keyword "ordering"
ord <- ident
keyword "maxAttempts"
ma <- boundedDecimal
keyword "backoff"
bk <- ident
bw <- pWindow
bm <- optional (keyword "max" *> symbol "=" *> pWindow)
multiplier <- optional (keyword "multiplier" *> symbol "=" *> decimalText)
keyword "outboxId"
keyword "stable"
keyword "from"
obf <- ident
_ <- symbol "}"
pure
PublisherNode
{ pubName = nm
, pubEmit = em
, pubOrdering = ord
, pubMaxAttempts = ma
, pubBackoff = BackoffSpec{boKind = bk, boWindow = bw, boMax = bm, boMultiplier = multiplier}
, pubOutboxField = obf
, pubLoc = loc
}
pWorkqueue :: P WorkqueueNode
pWorkqueue = do
loc <- getLoc
keyword "workqueue"
nm <- ident
_ <- symbol "{"
keyword "queue"
_ <- symbol "logical" *> symbol "="
logical <- stringLit
keyword "derive"
_ <- symbol "physical" *> symbol "="
phys <- stringLit
_ <- symbol "dlq" *> symbol "="
dlqName <- stringLit
_ <- symbol "table" *> symbol "="
tbl <- stringLit
ordering <- option WqUnordered pOrdering
groupKey <- optional pGroupKey
provision <- option WqStandard pProvision
keyword "payload"
pn <- ident
fields <- braces (many pWqField)
keyword "retry"
_ <- symbol "maxRetries" *> symbol "="
mr <- boundedDecimal
_ <- symbol "delay" *> symbol "="
dl <- pWindow
_ <- symbol "dlq" *> symbol "="
dlqOn <- (True <$ keyword "on") <|> (False <$ keyword "off")
keyword "disposition"
disp <- braces (many pWqDispRow)
_ <- symbol "}"
pure
WorkqueueNode
{ wqName = nm
, wqLogical = logical
, wqPhysical = phys
, wqDlq = dlqName
, wqTable = tbl
, wqOrdering = ordering
, wqGroupKey = groupKey
, wqProvision = provision
, wqPayloadName = pn
, wqPayload = fields
, wqMaxRetries = mr
, wqDelay = dl
, wqDlqOn = dlqOn
, wqDisposition = disp
, wqLoc = loc
}
where
pOrdering = do
_ <- symbol "ordering"
choice
[ WqUnordered <$ symbol "unordered"
, WqFifoThroughput <$ symbol "fifo-throughput"
, WqFifoRoundRobin <$ symbol "fifo-roundrobin"
]
pGroupKey = do
_ <- symbol "group" *> symbol "key" *> symbol "from"
field <- ident
_ <- symbol "via"
via <- ident
fixture <- optional (symbol "fixture" *> stringLit)
pure WqGroupKey{gkField = field, gkVia = via, gkFixture = fixture}
pProvision = do
_ <- symbol "provision"
choice
[ WqStandard <$ symbol "standard"
, WqUnlogged <$ symbol "unlogged"
, do
_ <- symbol "partitioned" *> symbol "("
_ <- symbol "interval" *> symbol "="
interval <- stringLit
_ <- symbol "," *> symbol "retention" *> symbol "="
retention <- stringLit
_ <- symbol ")"
pure (WqPartitioned interval retention)
]
pWqField = do
n <- ident
_ <- symbol "->"
w <- stringLit
ty <- ident
req <- option False (True <$ keyword "required")
pure WqField{wqfName = n, wqfWire = w, wqfType = ty, wqfRequired = req}
pWqDispRow = do
loc <- getLoc
o <- ident
_ <- symbol "->"
act <- choice [IAckOk <$ keyword "ackOk", IRetry <$> (keyword "retry" *> pWindow), IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)]
pure WqDispRow{wqdOutcome = o, wqdAction = act, wqdLoc = loc}
pReadModel :: P ReadModelNode
pReadModel = do
loc <- getLoc
keyword "readmodel"
name <- ident
_ <- symbol "{"
_ <- symbol "table" *> symbol "="
table <- stringLit
_ <- symbol "schema" *> symbol "="
schema <- stringLit
_ <- symbol "columns"
columns <- braces (many pColumn)
_ <- symbol "version" *> symbol "="
version <- boundedDecimal
_ <- symbol "shape" *> symbol "="
shape <- stringLit
_ <- symbol "consistency" *> symbol "="
consistency <- pConsistency
scope <- optional (symbol "scope" *> symbol "=" *> pScope)
_ <- symbol "feed" *> symbol "="
feed <- pFeed
subscription <- optional (symbol "subscription" *> symbol "=" *> stringLit)
_ <- symbol "}"
pure
ReadModelNode
{ rmName = name
, rmTable = table
, rmSchema = schema
, rmColumns = columns
, rmVersion = version
, rmShape = shape
, rmConsistency = consistency
, rmScope = scope
, rmFeed = feed
, rmSubscription = subscription
, rmLoc = loc
}
where
pColumn =
RmColumn
<$> wireWord
<*> ident
<*> option False (True <$ keyword "required")
pConsistency = choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]
pScope =
choice
[ RmEntireLog <$ keyword "entire-log"
, RmCategory <$> (keyword "category" *> stringLit)
]
pFeed = choice [RmInline <$ keyword "inline", RmSubscription <$ keyword "subscription"]
pPgmqDispatch :: P PgmqDispatchNode
pPgmqDispatch = do
loc <- getLoc
keyword "dispatch"
nm <- ident
_ <- symbol "{"
keyword "source"
_ <- symbol "readModel" *> symbol "="
srm <- ident
_ <- symbol "key" *> symbol "="
sk <- ident
keyword "fanout"
_ <- symbol "body" *> symbol "="
fb <- ident
keyword "dedup"
_ <- symbol "key" *> symbol "="
dk <- ident
_ <- keyword "seenIn" *> symbol "readModel" *> symbol "="
drm <- ident
_ <- symbol "field" *> symbol "="
drmf <- ident
_ <- keyword "seenIn" *> symbol "queue" *> symbol "="
dq <- ident
_ <- symbol "field" *> symbol "="
dqf <- ident
keyword "enqueue"
_ <- symbol "to" *> symbol "="
enq <- ident
_ <- symbol "}"
pure
PgmqDispatchNode
{ pdName = nm
, pdSourceReadModel = srm
, pdSourceKey = sk
, pdFanoutBody = fb
, pdDedupKey = dk
, pdDedupReadModel = drm
, pdDedupReadModelField = drmf
, pdDedupQueue = dq
, pdDedupQueueField = dqf
, pdEnqueueTo = enq
, pdLoc = loc
}
pWorkflow :: P WorkflowNode
pWorkflow = do
loc <- getLoc
keyword "workflow"
wid <- ident
keyword "name"
nm <- stringLit
keyword "in"
inTy <- ident
inFields <- option [] (braces (many pField))
keyword "out"
outTy <- ident
keyword "id"
keyword "from"
keyword "input"
idField <- optional (symbol "." *> ident)
keyword "via"
idVia <- ident
keyword "body"
body <- many pWfBodyItem
pure
WorkflowNode
{ wfId = wid
, wfStable = nm
, wfInput = inTy
, wfInputFields = inFields
, wfOutput = outTy
, wfIdField = idField
, wfIdVia = idVia
, wfBody = body
, wfLoc = loc
}
where
pWfBodyItem =
choice
[ do
loc <- getLoc
WfStep <$> (keyword "step" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc
, do
loc <- getLoc
WfAwait <$> (keyword "await" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc
, do
loc <- getLoc
WfSleep <$> (keyword "sleep" *> wireWord) <*> (keyword "after" *> ident) <*> pure loc
, do
loc <- getLoc
WfChild
<$> (keyword "child" *> wireWord)
<*> (keyword "id" *> keyword "input" *> keyword "via" *> ident)
<*> (symbol "->" *> ident)
<*> pure loc
, do
loc <- getLoc
WfPatch
<$> (keyword "patch" *> patchIdWord)
<*> braces (many pWfBodyItem)
<*> pure loc
, do
loc <- getLoc
WfContinueAsNew <$> (keyword "continueAsNew" *> ident) <*> pure loc
]
pOperation :: P OperationNode
pOperation = do
loc <- getLoc
keyword "operation"
nm <- ident
shape <-
choice
[ pCommandOp
, pQueryOp
, pSignalOp
, pRunOp
]
pure OperationNode{opName = nm, opShape = shape, opLoc = loc}
where
pCommandOp = do
keyword "command"
keyword "on"
agg <- ident
_ <- keyword "stream" *> keyword "from"
sf <- ident
keyword "via"
sv <- ident
proj <- option [] (keyword "project" *> brackets (many ident))
pure (CommandOp agg sf sv proj)
pQueryOp = do
keyword "query"
rm <- ident
keyword "input"
inp <- ident
keyword "result"
res <- pTypeExpr
cons <- option "Strong" (keyword "consistency" *> ident)
pure (QueryOp rm inp res cons)
pSignalOp = do
keyword "signal"
lbl <- wireWord
keyword "of"
wf <- ident
_ <- keyword "key" *> keyword "from"
kf <- ident
keyword "via"
kv <- ident
keyword "value"
val <- ident
pure (SignalOp lbl wf kf kv val)
pRunOp = do
keyword "run"
wf <- ident
keyword "input"
inp <- ident
_ <- keyword "outcome" *> symbol "->"
oc <- ident
pure (RunOp wf inp oc)
-- A result type expression, possibly multi-word like @Maybe TransferDecision@.
pTypeExpr = do
ws <- some ident
pure (T.unwords ws)
--------------------------------------------------------------------------------
-- Process manager + durable timer (EP-3)
--------------------------------------------------------------------------------
pProcess :: P ProcessNode
pProcess = do
loc <- getLoc
keyword "process"
pid <- ident
keyword "name"
nm <- stringLit
inp <- pInputDecl
corr <- pCorrelate
saga <- pSaga
keyword "target"
tgt <- ident
projs <- keyword "projections" *> brackets (many ident)
handle <- pHandle
_ <- optional pDispatchIdLine
rejected <- pPolicyLine "rejected"
poison <- pPolicyLine "poison"
timer <- pTimerNode
pure
ProcessNode
{ procId = pid
, procName = nm
, procInput = inp
, procCorrelate = corr
, procSaga = saga
, procTarget = tgt
, procProjections = projs
, procHandle = handle
, procRejected = rejected
, procPoison = poison
, procTimer = timer
, procLoc = loc
}
pRouter :: P RouterNode
pRouter = do
loc <- getLoc
keyword "router"
rid <- ident
keyword "name"
nm <- stringLit
inp <- pInputDecl
key <- pRouterKey
resolved <- pResolveDecl
keyword "target"
target <- ident
projections <- keyword "projections" *> brackets (many ident)
dispatch <- pRouterDispatch
pRouterDispatchIdLine
rejected <- pPolicyLine "rejected"
poison <- pPolicyLine "poison"
pure
RouterNode
{ rtId = rid
, rtName = nm
, rtInput = inp
, rtKey = key
, rtResolve = resolved
, rtTarget = target
, rtProjections = projections
, rtDispatch = dispatch
, rtRejected = rejected
, rtPoison = poison
, rtLoc = loc
}
pRouterKey :: P CorrelateDecl
pRouterKey = do
keyword "key"
_ <- keyword "input" *> symbol "."
field <- ident
keyword "via"
via <- ident
pure CorrelateDecl{corrField = field, corrVia = via}
pResolveDecl :: P ResolveDecl
pResolveDecl = do
loc <- getLoc
keyword "resolve"
keyword "stable"
keyword "via"
source <- choice [ResolveReadModel <$> (keyword "read-model" *> ident), ResolveHole <$ keyword "hole"]
keyword "row"
row <- braces (many ident)
pure ResolveDecl{rvSource = source, rvRow = row, rvLoc = loc}
pRouterDispatch :: P RouterDispatchNode
pRouterDispatch = do
loc <- getLoc
keyword "dispatch-each"
command <- ident
fields <- braces (many pFieldBinding)
disposition <-
DispatchDisposition
<$> (keyword "on-appended" *> pDisp)
<*> (symbol ";" *> keyword "on-duplicate" *> pDisp)
<*> (symbol ";" *> keyword "on-failed" *> pDisp)
pure RouterDispatchNode{rdCommand = command, rdFields = fields, rdDisposition = disposition, rdLoc = loc}
pRouterDispatchIdLine :: P ()
pRouterDispatchIdLine = do
keyword "dispatch-id"
_ <- symbol "strategy" *> symbol "=" *> keyword "uuidv5"
_ <- symbol "from" *> symbol "=" *> parens fixedInputs
pure ()
where
fixedInputs = do
keyword "name"
_ <- symbol ","
keyword "key"
_ <- symbol ","
keyword "sourceEventId"
_ <- symbol ","
keyword "targetStreamName"
_ <- symbol ","
keyword "occurrence"
pPolicyLine :: Text -> P PolicyChoice
pPolicyLine clause = keyword clause *> symbol "=>" *> pPolicyChoice
pPolicyChoice :: P PolicyChoice
pPolicyChoice =
choice
[ PolHalt <$ keyword "halt"
, PolDeadLetter <$ keyword "deadLetter"
, PolSkip <$ keyword "skip"
]
pInputDecl :: P InputDecl
pInputDecl = do
keyword "input"
nm <- ident
fs <- braces (many pField)
pure InputDecl{inName = nm, inFields = fs}
pCorrelate :: P CorrelateDecl
pCorrelate = do
keyword "correlate"
_ <- keyword "input" *> symbol "."
f <- ident
keyword "via"
v <- ident
pure CorrelateDecl{corrField = f, corrVia = v}
pSaga :: P SagaRef
pSaga = do
keyword "saga"
agg <- ident
keyword "category"
categoryName <- stringLit
pure SagaRef{sagaAgg = agg, sagaCategory = categoryName}
pHandle :: P HandleNode
pHandle = do
keyword "on"
onName <- ident
adv <- pAdvance
disps <- many pDispatch
keyword "schedule"
sched <- ident
pure HandleNode{hOn = onName, hAdvance = adv, hDispatch = disps, hSchedule = sched}
pAdvance :: P AdvanceNode
pAdvance = do
keyword "advance"
cmd <- ident
fs <- braces (many pFieldBinding)
pure AdvanceNode{advCommand = cmd, advFields = fs}
pDispatch :: P DispatchNode
pDispatch = do
loc <- getLoc
keyword "dispatch"
tgt <- ident
_ <- symbol "@"
key <- dottedRef
cmd <- ident
fs <- braces (many pFieldBinding)
disp <-
DispatchDisposition
<$> (keyword "on-appended" *> pDisp)
<*> (symbol ";" *> keyword "on-duplicate" *> pDisp)
<*> (symbol ";" *> keyword "on-failed" *> pDisp)
pure DispatchNode{dispTarget = tgt, dispKey = key, dispCommand = cmd, dispFields = fs, dispDisposition = disp, dispLoc = loc}
pDisp :: P Disp
pDisp =
choice
[ DAckOk <$ keyword "AckOk"
, DRetry <$ keyword "Retry"
, DDeadLetter <$> (keyword "DeadLetter" *> stringLit)
]
-- The dispatch-id line is a fixed, runtime-owned strategy; parse and discard.
pDispatchIdLine :: P ()
pDispatchIdLine = do
keyword "dispatch-id"
_ <- symbol "strategy" *> symbol "=" *> ident
_ <- symbol "from" *> symbol "=" *> parens (sepBy dottedRef (symbol ","))
pure ()
pTimerNode :: P TimerNode
pTimerNode = do
loc <- getLoc
keyword "timer"
nm <- ident
tid <- keyword "id" *> pIdExpr
fat <- keyword "fireAt" *> pFireAt
pay <- keyword "payload" *> braces (many pFieldBinding)
fire <- pFire
_ <- keyword "decode" *> keyword "unknown-status" *> symbol "=>"
unk <- ident
keyword "max-attempts"
ma <- boundedDecimal
keyword "dead-letter"
dl <- stringLit
pure
TimerNode
{ tmName = nm
, tmId = tid
, tmFireAt = fat
, tmPayload = pay
, tmFire = fire
, tmDecodeUnknown = unk
, tmMaxAttempts = ma
, tmDeadLetter = dl
, tmLoc = loc
}
pIdExpr :: P IdExpr
pIdExpr = do
keyword "uuidv5"
pfx <- stringLit
_ <- symbol "<>"
_ <- ident -- correlationId (fixed)
pure IdExpr{ideStrategy = UuidV5Id, idePrefix = pfx}
pFireAt :: P FireAtExpr
pFireAt = do
_ <- keyword "input" *> symbol "."
f <- ident
_ <- symbol "+"
w <- pWindow
pure FireAtExpr{faField = f, faWindow = w}
pWindow :: P Text
pWindow = lexeme $ do
ds <- some digitChar
u <- choice [char 's', char 'm', char 'h'] <?> "time unit: s, m, or h"
notFollowedBy letterChar <?> "time unit: s, m, or h"
pure (T.pack (ds <> [u]))
decimalText :: P Text
decimalText = lexeme $ do
whole <- some digitChar
fractional <- optional (char '.' *> some digitChar)
pure (T.pack (whole <> maybe "" ('.' :) fractional))
signedDecimalText :: P Text
signedDecimalText = lexeme $ do
sign <- optional (char '-')
digits <- some digitChar
pure (T.pack (maybe "" pure sign <> digits))
pFire :: P FireNode
pFire = do
keyword "fire"
keyword "dispatch"
tgt <- ident
_ <- symbol "@"
key <- dottedRef
cmd <- ident
fs <- braces (many pFieldBinding)
fid <- keyword "fired-event-id" *> pIdExpr
disp <-
FireDisposition
<$> (keyword "on-ok" *> pFireOutcome)
<*> (symbol ";" *> keyword "on-reject" *> pFireOutcome)
<*> (symbol ";" *> keyword "on-ambiguous" *> pFireOutcome)
<*> (symbol ";" *> keyword "on-error" *> pFireOutcome)
<*> (symbol ";" *> keyword "not-mine" *> pFireOutcome)
pure FireNode{fireTarget = tgt, fireKey = key, fireCommand = cmd, fireFields = fs, fireFiredEventId = fid, fireDisposition = disp}
pFireOutcome :: P FireOutcome
pFireOutcome = choice [OFired <$ keyword "Fired", ORetry <$ keyword "Retry"]
pFieldBinding :: P FieldBinding
pFieldBinding = do
n <- ident
v <- optional (symbol "=" *> pBindingValue)
pure FieldBinding{fbName = n, fbValue = v}
-- | A binding value: a quoted string (kept quoted) or a dotted reference.
pBindingValue :: P Text
pBindingValue = choice [quoted, dottedRef]
where
quoted = do
s <- stringLit
pure ("\"" <> s <> "\"")
{- | A dotted/plain reference token like @input.hospitalId@, @timer.id@,
@correlationId@.
-}
dottedRef :: P Text
dottedRef = lexeme $ do
c <- asciiLetter
cs <- many (asciiAlphaNum <|> char '_' <|> char '.')
pure (T.pack (c : cs))
{- | A double-quoted string literal, returning raw (unescaped) inner text.
The surface syntax supports a closed escape set so unknown escapes remain
available for backward-compatible extensions.
-}
stringLit :: P Text
stringLit = lexeme $ do
_ <- char '"'
s <- many strChar
_ <- char '"'
pure (T.pack s)
where
strChar =
choice
[ char '\\' *> escapeCode
, char '\n' *> fail "unescaped newline in string literal (write \\n)"
, anySingleBut '"'
]
escapeCode =
choice
[ '"' <$ char '"'
, '\\' <$ char '\\'
, '\n' <$ char 'n'
, '\t' <$ char 't'
, '\r' <$ char 'r'
, anySingle >>= \c -> fail ("unknown escape sequence \\" <> [c] <> " in string literal")
]
brackets :: P a -> P a
brackets = between (symbol "[") (symbol "]")
--------------------------------------------------------------------------------
-- Transitions
--------------------------------------------------------------------------------
data Clause
= CGuard Expr
| CWrite Name Expr
| CEmit Name
| CGoto Name
pTransition :: P Transition
pTransition = do
startOffset <- getOffset
loc <- getLoc
src <- ident
_ <- symbol "--"
cmd <- ident
_ <- symbol "-->"
positionedClauses <- many ((,) <$> getOffset <*> (pClause <* optional (symbol ";")))
let clauses = map snd positionedClauses
gotos = [(offset, target) | (offset, CGoto target) <- positionedClauses]
transitionName = T.unpack src <> " -- " <> T.unpack cmd
gt <- case gotos of
[] -> failAt startOffset ("transition " <> transitionName <> " is missing a goto clause")
[(_, target)] -> pure target
(_, firstTarget) : (duplicateOffset, _) : _ ->
failAt
duplicateOffset
("duplicate goto clause (transition " <> transitionName <> " already declared goto " <> T.unpack firstTarget <> ")")
let guards = [e | CGuard e <- clauses]
pure
Transition
{ tSource = src
, tCommand = cmd
, tGuard = case guards of [] -> Nothing; es -> Just (foldr1 EAnd es)
, tWrites = [(r, e) | CWrite r e <- clauses]
, tEmits = [n | CEmit n <- clauses]
, tGoto = gt
, tLoc = loc
}
pClause :: P Clause
pClause =
choice
[ CGuard <$> (keyword "guard" *> pExpr)
, (\r e -> CWrite r e) <$> (keyword "write" *> ident) <*> (symbol ":=" *> pExpr)
, try $ do
keyword "emit"
eventName <- ident
notFollowedBy (symbol "{")
pure (CEmit eventName)
, CGoto <$> (keyword "goto" *> ident)
]
--------------------------------------------------------------------------------
-- Expr sublanguage
--------------------------------------------------------------------------------
pExpr :: P Expr
pExpr = makeExprParser pTerm operatorTable
pTerm :: P Expr
pTerm =
choice
[ parens pExpr
, EAtom . ABool <$> (True <$ keyword "true" <|> False <$ keyword "false")
, EAtom . AName <$> ident
]
{- | Highest precedence first: relational comparisons bind tighter than @&&@,
which binds tighter than @||@.
-}
operatorTable :: [[Operator P Expr]]
operatorTable =
[
[ InfixN (ECmp OpLe <$ op "<=")
, InfixN (ECmp OpGe <$ op ">=")
, InfixN (ECmp OpEq <$ op "==")
, InfixN (ECmp OpNeq <$ op "!=")
, InfixN (ECmp OpLt <$ op "<")
, InfixN (ECmp OpGt <$ op ">")
]
, [InfixL (EAnd <$ op "&&")]
, [InfixL (EOr <$ op "||")]
]
where
op s = symbol s
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
braces :: P a -> P a
braces = between (symbol "{") (symbol "}")
parens :: P a -> P a
parens = between (symbol "(") (symbol ")")