packages feed

keiro-dsl-0.8.0.0: src/Keiro/Dsl/Parser/Core.hs

{-# LANGUAGE ImportQualifiedPost #-}
-- EP-4 integration: structural keywords never used as identifiers, so a
-- list like @accept A B C@ stops at the next block keyword.
-- EP-5 pgmq structural keywords.
-- EP-6 workflow/operation: reserved so the multi-word result-type parse and
-- node boundaries don't swallow the next block keyword.
-- 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.
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
{-# LANGUAGE ImportQualifiedPost #-}

-- | Parser primitives shared by every internal grammar concern.
module Keiro.Dsl.Parser.Core
  ( ContextualParseFailure (..),
    P,
    firstContextualFailure,
    bundleFailureSpan,
    bundleExpected,
    bundleMessage,
    contextualFailureAt,
    requireLanguageFeatureAt,
    sc,
    lexeme,
    symbol,
    keyword,
    identChar,
    asciiLetter,
    asciiUpper,
    asciiDigit,
    asciiAlphaNum,
    failAt,
    boundedDecimal,
    checkedDecimal,
    reservedWords,
    ident,
    wireWord,
    patchIdWord,
    getLoc,
    withOwnedSpan,
    spanOf,
    locatedValue,
    optionalLanguageFeature,
    pField,
    pVersion,
    pWindow,
    decimalText,
    signedDecimalText,
    integerLiteral,
    stringLit,
    brackets,
    braces,
    parens,
    pModulePrefix,
  )
where

import Data.Char (isAlpha, isAlphaNum, isAscii, isDigit, isSpace, isUpper)
import Data.List.NonEmpty qualified as NE
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Frontend.Internal
import Keiro.Dsl.Grammar
import Keiro.Dsl.LanguageVersion
import Keiro.Dsl.Source
import Text.Megaparsec
import Text.Megaparsec.Char (char, digitChar, letterChar, space1)
import Text.Megaparsec.Char.Lexer qualified as L
import Prelude hiding (span)

data ContextualParseFailure = ContextualParseFailure
  { contextualFailureCode :: !SourceLanguageErrorCode,
    contextualFailureFeature :: !(Maybe LanguageFeature),
    contextualFailureSpan :: !SourceSpan
  }
  deriving stock (Eq, Ord, Show)

instance ShowErrorComponent ContextualParseFailure where
  showErrorComponent contextual = T.unpack (sourceLanguageErrorCodeText (contextualFailureCode contextual))

type P = Parsec ContextualParseFailure Text

firstContextualFailure :: ParseErrorBundle Text ContextualParseFailure -> Maybe ContextualParseFailure
firstContextualFailure bundle =
  case [ contextual
       | FancyError _ fancy <- NE.toList (bundleErrors bundle),
         ErrorCustom contextual <- Set.toList fancy
       ] of
    contextual : _ -> Just contextual
    [] -> Nothing

bundleFailureSpan :: ParseErrorBundle Text e -> SourceSpan
bundleFailureSpan bundle =
  SourceSpan
    { source = sourceName position,
      start = point,
      end = point
    }
  where
    offset = errorOffset (NE.head (bundleErrors bundle))
    reachedState = reachOffsetNoLine offset (bundlePosState bundle)
    position = pstateSourcePos reachedState
    point = sourcePointAt offset position

bundleExpected :: ParseErrorBundle Text e -> [Text]
bundleExpected bundle =
  case NE.head (bundleErrors bundle) of
    TrivialError _ _ expected -> map (T.pack . show) (Set.toList expected)
    FancyError {} -> []

bundleMessage :: ParseErrorBundle Text ContextualParseFailure -> Text
bundleMessage = T.pack . parseErrorTextPretty . NE.head . bundleErrors

contextualFailureAt :: SourceSpan -> SourceLanguageErrorCode -> P a
contextualFailureAt span code =
  customFailure ContextualParseFailure {contextualFailureCode = code, contextualFailureFeature = Nothing, contextualFailureSpan = span}

requireLanguageFeatureAt :: FrontendContext -> LanguageFeature -> SourceSpan -> P ()
requireLanguageFeatureAt context feature span
  | frontendSupportsFeature context feature = pure ()
  | otherwise =
      customFailure
        ContextualParseFailure
          { contextualFailureCode = LanguageFeatureRequiresVersion,
            contextualFailureFeature = Just feature,
            contextualFailureSpan = span
          }

-- | 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",
    "mapped",
    "ex",
    "aggregate",
    "regs",
    "states",
    "command",
    "event",
    "wire",
    "projection",
    "snapshot",
    "category",
    "guard",
    "write",
    "emit",
    "goto",
    "fields",
    "status-map",
    "true",
    "false",
    "retiring",
    "deprecated",
    "upcast",
    "from",
    "HOLE",
    "process",
    "router",
    "dispatch-each",
    "resolve",
    "read-model",
    "dispatch",
    "intake",
    "contract",
    "topic",
    "accept",
    "bind",
    "dedupe",
    "persist",
    "decode",
    "disposition",
    "publisher",
    "map",
    "workqueue",
    "queue",
    "payload",
    "retry",
    "fanout",
    "dedup",
    "enqueue",
    "seenIn",
    "workflow",
    "operation",
    "consistency",
    "body",
    "step",
    "await",
    "sleep",
    "child",
    "patch",
    "continueAsNew",
    "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

-- | Attach the exact syntax owned by a production. Existing token parsers
-- consume following trivia, so the consumed slice is scanned to exclude final
-- whitespace and @#@ comments from the half-open end point.
withOwnedSpan :: P a -> P (Located a)
withOwnedSpan parser = do
  startOffset <- getOffset
  startPosition <- getSourcePos
  startState <- getParserState
  inputBefore <- getInput
  value <- parser
  inputAfter <- getInput
  let consumedLength = T.length inputBefore - T.length inputAfter
      ownedLength = ownedSyntaxLength (T.take consumedLength inputBefore)
      endOffset = startOffset + ownedLength
      endPosition = pstateSourcePos (reachOffsetNoLine endOffset (statePosState startState))
      span =
        SourceSpan
          { source = sourceName startPosition,
            start = sourcePointAt startOffset startPosition,
            end = sourcePointAt endOffset endPosition
          }
  pure Located {span, value}

sourcePointAt :: Int -> SourcePos -> SourcePoint
sourcePointAt offset position =
  SourcePoint
    { offset,
      line = unPos (sourceLine position),
      column = unPos (sourceColumn position)
    }

spanOf :: Located a -> SourceSpan
spanOf Located {span} = span

locatedValue :: Located a -> a
locatedValue Located {value} = value

data TriviaScan
  = InSyntax
  | InString !Bool
  | InComment

ownedSyntaxLength :: Text -> Int
ownedSyntaxLength = go 0 InSyntax 0 . T.unpack
  where
    go _ _ lastOwned [] = lastOwned
    go index mode lastOwned (character : rest) =
      case mode of
        InComment
          | character == '\n' || character == '\r' -> go (index + 1) InSyntax lastOwned rest
          | otherwise -> go (index + 1) InComment lastOwned rest
        InString escaped
          | escaped -> go (index + 1) (InString False) (index + 1) rest
          | character == '\\' -> go (index + 1) (InString True) (index + 1) rest
          | character == '"' -> go (index + 1) InSyntax (index + 1) rest
          | otherwise -> go (index + 1) (InString False) (index + 1) rest
        InSyntax
          | character == '#' -> go (index + 1) InComment lastOwned rest
          | character == '"' -> go (index + 1) (InString False) (index + 1) rest
          | isSpace character -> go (index + 1) InSyntax lastOwned rest
          | otherwise -> go (index + 1) InSyntax (index + 1) rest

optionalLanguageFeature :: FrontendContext -> LanguageFeature -> Text -> P a -> P (Maybe a)
optionalLanguageFeature context feature marker parser
  | frontendSupportsFeature context feature = optional parser
  | otherwise = reject <|> pure Nothing
  where
    reject = do
      locatedMarker <- withOwnedSpan (try (keyword marker))
      requireLanguageFeatureAt context feature (spanOf locatedMarker)
      fail "unreachable enabled feature in predecessor grammar"

pField :: P Field
pField = do
  n <- ident
  mty <- optional (symbol ":" *> ident)
  pure Field {fieldName = n, fieldType = mty}

pVersion :: P Int
pVersion = do
  offset <- getOffset
  value <- lexeme (try (char 'v' *> (L.decimal :: P Integer) <* notFollowedBy identChar))
  checkedDecimal offset value

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
  fractional <- optional (char '.' *> some digitChar)
  pure (T.pack (maybe "" pure sign <> digits <> maybe "" ('.' :) fractional))

integerLiteral :: P Integer
integerLiteral = lexeme (L.signed (pure ()) L.decimal)

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 "]")

braces :: P a -> P a
braces = between (symbol "{") (symbol "}")

parens :: P a -> P a
parens = between (symbol "(") (symbol ")")

-- | A dotted module prefix with one or more PascalCase segments.
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))