packages feed

tramaj-hs-0.3.0.0: src/Tramaj/Parser.hs

-- | Surface syntax to core AST. Everything the surface offers beyond
-- "Tramaj.Ast"'s constructors is desugared here rather than represented:
--
-- * @\@name=expr@ binding lines become nested 'Let's;
-- * string interpolation becomes 'Concat' over the @str@ builtin;
-- * @branch(fallback, p1, v1, ...)@ becomes nested 'Branch';
-- * object shorthand @{foo}@ becomes @{"foo": $foo}@;
-- * escape sequences are resolved into the 'StringLit' they denote.
--
-- The grammar keeps three leader characters from v1: @.@ introduces a
-- document, @$@ reads a binding, @\@@ defines one. What changed is that
-- there is now a single expression grammar -- v1's separate template-phase
-- productions (@node@, @nodeArg@, @childArg@, @templateSpecialForm@,
-- @pathOrCallChild@) are gone, because documents are expressions.
--
-- A fourth character is lexical rather than grammatical: @--@ begins a
-- comment that runs to the end of the line. It is discarded by 'skipSpaces'
-- along with whitespace, so it never reaches the AST and there is nothing to
-- desugar.
--
-- Two conventions are load-bearing and carried over deliberately:
--
-- * A special form's 'try' covers /name recognition only/. Once @import@ or
--   @map@ has matched, its shape is parsed without backtracking, so a
--   malformed one is a hard parse error instead of silently falling through
--   to a meaningless 'Call' that would only fail much later at eval time.
-- * A field-access suffix is parsed with no whitespace skipped before it, so
--   @f().rendered@ is a field access while @f()@ followed by a newline and
--   @.div(...)@ is two separate things.
module Tramaj.Parser
  ( parseProgram
  , parseExpr
  ) where

import Data.Char (chr, isAlphaNum, isDigit, isHexDigit, isLetter)
import Data.List (nub)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Void (Void)
import Numeric (readHex)
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L
import Tramaj.Ast

type P = Parsec Void Text

-- | One argument of an element, before the arguments are bucketed into
-- attributes, the value slot, and children -- and before the
-- attributes-before-children rule is checked, which needs to see them in
-- source order.
data ElementArg
  = EArgAttr Attribute
  | EArgValue Expr
  | EArgChild Expr

-- | One piece of a double-quoted string, before 'desugarString' folds the
-- pieces into core constructors. Not part of the AST: core.md is explicit
-- that interpolation needs no node of its own.
data StringPart
  = SLit Text
  | SInterp Expr

-- Lexing --------------------------------------------------------------

-- | Whitespace and comments -- everything between two tokens that carries no
-- meaning. Run after every token by 'lexeme', and once before the first one,
-- so a comment is legal anywhere a space is.
--
-- A comment is @--@ to the end of the line. Single-line only: there is no
-- block form, so there is no nesting rule to get wrong and no way to leave one
-- unterminated. @--@ inside a string literal is ordinary text, because string
-- bodies are read character by character and never come through here.
--
-- This is also why 'rawIdent' refuses a trailing hyphen: it is what keeps
-- @$x-- note@ from lexing as a name @x--@ instead of @$x@ and a comment.
skipSpaces :: P ()
skipSpaces = L.space space1 (L.skipLineComment "--") empty

lexeme :: P a -> P a
lexeme p = p <* skipSpaces

symbol :: Text -> P Text
symbol s = lexeme (string s)

-- | An identifier with no trailing whitespace consumed -- used where the
-- following character is significant: inside dotted paths and after an
-- element's @.@. Internal hyphens are allowed (kebab-case), so @$my-var@ and
-- @.my-tag@ are one token each.
--
-- /Internal/ is enforced, not merely documented: a hyphen is part of the name
-- only when another name character follows it. Without that, a name would
-- swallow the @--@ of a comment written directly after it, and @$x-- note@
-- would read as the name @x--@.
rawIdent :: P Text
rawIdent = do
  c0 <- satisfy isLetter
  cs <- many identRest
  pure (T.pack (c0 : cs))
  where
    identRest :: P Char
    identRest = identChar <|> try (char '-' <* lookAhead identChar)

    identChar :: P Char
    identChar = satisfy (\c -> isAlphaNum c || c == '_')

identifier :: P Text
identifier = lexeme rawIdent

pathTail :: P (Text, [Text])
pathTail = do
  root <- rawIdent
  rest <- many (char '.' *> rawIdent)
  pure (root, rest)

-- | Zero or more @.field@ segments directly after a closing @)@. Deliberately
-- runs before any whitespace is skipped -- see the module header.
fieldAccessSuffix :: P [Text]
fieldAccessSuffix = many (try (char '.' *> rawIdent))

applyFieldAccess :: Expr -> [Text] -> Expr
applyFieldAccess base [] = base
applyFieldAccess base segs = FieldAccess base segs

-- | A double-quoted string with no interpolation: every statically-required
-- position uses this, so what the source says is what the analysis sees.
-- Import names, action events and keys, adaptation prefixes and object keys
-- are all parsed with it.
--
-- A backtick here is an error rather than a literal backtick. Someone writing
-- @import("lib-`$x`")@ means interpolation, and silently handing them a
-- library named @lib-`$x`@ would answer a question they did not ask -- the
-- whole point of the position being static is that it cannot be computed.
staticString :: P Text
staticString = lexeme $ do
  _ <- char '"'
  s <- takeWhileP Nothing (\c -> c /= '"' && c /= '`')
  _ <- char '"' <|> interpolationRefused
  pure s
  where
    interpolationRefused =
      fail "this position must be a literal string, so it cannot contain an interpolation"

-- Strings ---------------------------------------------------------------

-- | A string literal: escape sequences plus backtick interpolation of an
-- arbitrary expression.
stringLit :: P Expr
stringLit = lexeme $ do
  _ <- char '"'
  parts <- many stringPart
  _ <- char '"'
  pure (desugarString parts)
  where
    stringPart :: P StringPart
    stringPart = interpPart <|> (SLit <$> litChunk)

    interpPart :: P StringPart
    interpPart = SInterp <$> (char '`' *> expr <* char '`')

    -- | A run of ordinary characters, with escape sequences resolved as they
    -- are read. Stops at a closing quote or an interpolation's backtick;
    -- either can still be written escaped.
    litChunk :: P Text
    litChunk = T.concat <$> some (escapeSeq <|> plainRun)

    plainRun :: P Text
    plainRun = takeWhile1P Nothing (\c -> c /= '"' && c /= '`' && c /= '\\')

escapeSeq :: P Text
escapeSeq = char '\\' *> (unicodeEscape <|> simpleEscape)
  where
    simpleEscape :: P Text
    simpleEscape = do
      c <- anySingle
      case c of
        'n' -> pure "\n"
        't' -> pure "\t"
        'r' -> pure "\r"
        '\\' -> pure "\\"
        '"' -> pure "\""
        '`' -> pure "`"
        '0' -> pure "\0"
        _ -> fail ("unknown escape sequence: \\" <> [c])

    -- | @\\u{1F600}@ -- braced so it is not limited to four hex digits and
    -- does not need surrogate pairs.
    unicodeEscape :: P Text
    unicodeEscape = do
      _ <- char 'u'
      _ <- char '{'
      digits <- takeWhile1P (Just "hex digit") isHexDigit
      _ <- char '}'
      case readHex (T.unpack digits) of
        [(n, "")] | n <= 0x10FFFF -> pure (T.singleton (chr n))
        _ -> fail ("invalid unicode escape: \\u{" <> T.unpack digits <> "}")

-- | Folds string pieces into core constructors. A string with no
-- interpolation is a plain 'StringLit'; otherwise each interpolated
-- expression is rendered through the @str@ builtin and the pieces are joined
-- with 'Concat', which is exactly what @"a `$x` b"@ means.
desugarString :: [StringPart] -> Expr
desugarString parts = case map partExpr (coalesce parts) of
  [] -> StringLit ""
  (e : es) -> foldl Concat e es
  where
    partExpr (SLit t) = StringLit t
    partExpr (SInterp e) = Call (Path "str" []) [e]

    -- | Adjacent literal chunks (an escape sequence splits one in two) are
    -- merged, so an escape does not leave a stray 'Concat' in the AST.
    coalesce (SLit a : SLit b : rest) = coalesce (SLit (a <> b) : rest)
    coalesce (p : rest) = p : coalesce rest
    coalesce [] = []

-- Literals ---------------------------------------------------------------

-- | @["-"] digits ["." digits] [("e"|"E") ["+"|"-"] digits]@, where a @_@
-- may sit between two digits (reference.md §5, /Number literals/). The
-- fraction and exponent are taken only when complete, so a stray @.@, @e@
-- or @_@ is left for the caller to reject. The underscores are dropped and
-- the exponent's sign normalized before the text reaches 'reads'; an
-- overflow to infinity is refused and a zero is normalized so @-0@ never
-- escapes.
numberLit :: P Expr
numberLit = lexeme $ try $ do
  sign <- optional (char '-')
  intPart <- digits
  fracPart <- optional (try (char '.' *> digits))
  expPart <- optional $ try $ do
    _ <- char 'e' <|> char 'E'
    expSign <- optional (char '+' <|> char '-')
    expDigits <- digits
    pure (if expSign == Just '-' then "-" <> expDigits else expDigits)
  let fullStr =
        maybe "" (const "-") sign
          <> intPart
          <> maybe "" ("." <>) fracPart
          <> maybe "" ("e" <>) expPart
  case reads (T.unpack fullStr) :: [(Double, String)] of
    [(n, "")]
      | isInfinite n -> fail ("number literal out of range: " <> T.unpack fullStr)
      | n == 0 -> pure (NumberLit 0)
      | otherwise -> pure (NumberLit n)
    _ -> fail ("invalid number literal: " <> T.unpack fullStr)
  where
    digits = do
      first <- takeWhile1P (Just "digit") isDigit
      rest <- many (try (char '_' *> takeWhile1P (Just "digit") isDigit))
      pure (T.concat (first : rest))

-- | @true@\/@false@\/@null@ matched as whole identifiers, so a longer name
-- merely starting with one (@truest@, @nullable@) is not chopped into a
-- literal plus leftovers.
keywordLit :: P Expr
keywordLit = try $ do
  name <- identifier
  case name of
    "true" -> pure (BoolLit True)
    "false" -> pure (BoolLit False)
    "null" -> pure NullLit
    _ -> fail "not a literal keyword"

arrayLit :: P Expr
arrayLit = lexeme $ do
  _ <- symbol "["
  elems <- sepEndBy expr (symbol ",")
  _ <- symbol "]"
  pure (ArrayLit elems)

-- | Object keys may be quoted or bare, and a bare key on its own is shorthand
-- for reading the binding of the same name: @{foo, bar: $baz}@.
objectLit :: P Expr
objectLit = lexeme $ do
  _ <- symbol "{"
  entries <- sepEndBy objEntry (symbol ",")
  _ <- symbol "}"
  pure (ObjectLit entries)
  where
    objEntry :: P (Text, Expr)
    objEntry = try explicitEntry <|> shorthandEntry

    explicitEntry :: P (Text, Expr)
    explicitEntry = do
      k <- objectKey
      _ <- reservedKeyRefused k
      _ <- symbol ":"
      v <- expr
      pure (k, v)

    shorthandEntry :: P (Text, Expr)
    shorthandEntry = do
      k <- identifier
      pure (k, Path k [])

objectKey :: P Text
objectKey = staticString <|> identifier

-- | @"$sym"@ and @"$type"@ are reserved across the value domain (v3-symbols
-- \S5.3, v4-types \S0): the tag a symbolic or typed envelope uses to mark a
-- value that is not an ordinary object. An object literal spelling either as
-- a key is a parse error in every profile, not just the symbolic one, so a
-- program's legality never depends on which profile runs it.
reservedKeyRefused :: Text -> P ()
reservedKeyRefused k
  | k `elem` (["$sym", "$type"] :: [Text]) =
      fail ("\"" <> T.unpack k <> "\" is a reserved key and cannot be used as an object key")
  | otherwise = pure ()

-- Expressions ------------------------------------------------------------

pathExpr :: P Expr
pathExpr = lexeme $ do
  _ <- char '$'
  (root, fields) <- pathTail
  pure (Path root fields)

-- | @?(key)@ (v3-symbols \S1.2): allocates a symbol. The site is a placeholder
-- here -- 'Tramaj.Ast.numberAllocs' assigns the real one once the whole
-- program has been parsed, so it does not depend on this parser's own
-- traversal order. A projection following it, as in @?(key).field@, is an
-- ordinary field-access suffix, the same mechanism a call result uses.
allocExpr :: P Expr
allocExpr = try $ do
  _ <- char '?'
  _ <- symbol "("
  keyExpr <- expr
  _ <- char ')'
  segs <- fieldAccessSuffix
  skipSpaces
  pure (applyFieldAccess (Alloc 0 keyExpr) segs)

-- | @?ctx.a.b@ (\S1.3): the path MUST be rooted at @ctx@ -- unlike an
-- ordinary read, an unsupplied demand allocates at the root rather than
-- failing, so the language needs to tell the two apart before evaluating
-- anything.
demandExpr :: P Expr
demandExpr = lexeme $ try $ do
  _ <- char '?'
  root <- rawIdent
  if root == "ctx"
    then Demand <$> many (char '.' *> rawIdent)
    else fail "a demand must be rooted at ctx, as in ?ctx.path"

-- | @name(args)@ or @$name(args)@ -- the two spellings mean the same thing.
-- The callee may be a dotted path, so a function reached through an import's
-- values (@$lib.vals.fn(1)@) or an import being given more parameters
-- (@$deployment({...})@) is callable directly.
call :: P Expr
call = try $ do
  _ <- optional (char '$')
  (root, fields) <- pathTail
  _ <- symbol "("
  args <- sepEndBy expr (symbol ",")
  _ <- char ')'
  segs <- fieldAccessSuffix
  skipSpaces
  pure (applyFieldAccess (Call (Path root fields) args) segs)

lambdaExpr :: P Expr
lambdaExpr = try $ do
  _ <- symbol "("
  params <- sepEndBy pattern (symbol ",")
  _ <- symbol ")"
  _ <- symbol "=>"
  lowerLambda params <$> expr

-- Binding patterns (decisions \S17) -------------------------------------------

-- | A binding pattern: a name, or an object pattern @{a, b: c, d: {e}}@.
-- Object patterns only; defaults (@{a = 1}@), rest (@{...r}@) and array
-- patterns are deliberately not in the grammar, so they are parse errors.
data Pattern
  = PName Text
  | PObject [(Text, Pattern)]

pattern :: P Pattern
pattern = (PName <$> identifier) <|> objectPattern

objectPattern :: P Pattern
objectPattern = do
  _ <- symbol "{"
  fields <- sepEndBy1 patternField (symbol ",")
  _ <- symbol "}"
  let pat = PObject fields
      names = patternNames pat
  if length (nub names) /= length names
    then fail "duplicate name in a binding pattern"
    else pure pat

-- | @name@ reads that field into a binding of the same name; @name: p@ reads
-- it and binds or destructures it as @p@.
patternField :: P (Text, Pattern)
patternField = do
  k <- identifier
  sub <- optional (symbol ":" *> pattern)
  pure (k, fromMaybe (PName k) sub)

patternNames :: Pattern -> [Text]
patternNames (PName n) = [n]
patternNames (PObject fields) = concatMap (patternNames . snd) fields

-- | Lowers @pattern = source@ to plain bindings, in written order. A source
-- that is a path is read directly (@$ctx.item.a@), so errors and static
-- analyses see the reads a hand-written program would make; anything else is
-- bound once to the hidden name @#src@ first. Hidden names start with @#@,
-- which no surface name can, and are dropped from a symbol's @"binding"@ and
-- from a library's @.vals@ ('isHiddenName').
bindPattern :: Pattern -> Expr -> [Stmt]
bindPattern (PName n) source = [SLet n source]
bindPattern (PObject fields) source = case source of
  Path _ _ -> readFields source
  _ -> SLet "#src" source : readFields (Path "#src" [])
  where
    readFields src = concatMap (\(k, p) -> bindPattern p (readField src k)) fields

    readField (Path root segs) k = Path root (segs ++ [k])
    readField other k = FieldAccess other [k]

-- | A pattern parameter becomes a hidden parameter @#argN@; the body is
-- wrapped in the bindings that read the pattern's names out of it.
lowerLambda :: [Pattern] -> Expr -> Expr
lowerLambda params body =
  Lambda (zipWith paramName [0 :: Int ..] params) (stmts (concat (zipWith unpack [0 :: Int ..] params)) body)
  where
    paramName _ (PName n) = n
    paramName i (PObject _) = "#arg" <> T.pack (show i)

    unpack _ (PName _) = []
    unpack i p@(PObject _) = bindPattern p (Path ("#arg" <> T.pack (show i)) [])

-- | Grouping, for readability where 'Concat' chains get long. Not a semantic
-- construct: the parse tree it produces is the same as the inner expression's.
parenExpr :: P Expr
parenExpr = try $ do
  _ <- symbol "("
  e <- expr
  _ <- symbol ")"
  pure e

-- | The forms whose evaluation the language defines itself, rather than
-- leaving to a builtin: the array primitives (whose function argument needs a
-- fresh binding per element), 'Branch' (which must not evaluate the arm it
-- does not select), imports, and action adaptation.
--
-- The 'try' covers name recognition only -- see the module header.
specialForm :: P Expr
specialForm = do
  name <- try $ do
    _ <- optional (char '$')
    n <- identifier
    if n `elem` (["map", "filter", "scan", "fold", "branch", "import", "adapt-actions", "constraint"] :: [Text])
      then pure n
      else fail "not a special form"
  base <- case name of
    "map" -> binaryShape Map
    "filter" -> binaryShape Filter
    "scan" -> ternaryShape Scan
    "fold" -> ternaryShape Fold
    "branch" -> branchShape
    "import" -> importShape
    "adapt-actions" -> adaptActionsShape
    "constraint" -> constraintShape
    _ -> fail "unreachable: name already checked against the recognized special-form set"
  segs <- fieldAccessSuffix
  skipSpaces
  pure (applyFieldAccess base segs)
  where
    binaryShape :: (Expr -> Expr -> Expr) -> P Expr
    binaryShape ctor = do
      _ <- symbol "("
      a <- expr
      _ <- symbol ","
      b <- expr
      _ <- char ')'
      pure (ctor a b)

    ternaryShape :: (Expr -> Expr -> Expr -> Expr) -> P Expr
    ternaryShape ctor = do
      _ <- symbol "("
      a <- expr
      _ <- symbol ","
      b <- expr
      _ <- symbol ","
      c <- expr
      _ <- char ')'
      pure (ctor a b c)

    -- | @branch(fallback, p1, v1, p2, v2, ...)@ reads "if p1 then v1, else if
    -- p2 then v2, ..., else fallback" and lowers to nested 'Branch', so the
    -- laziness is the core constructor's rather than a rule of its own.
    branchShape :: P Expr
    branchShape = do
      _ <- symbol "("
      fallback <- expr
      arms <- many (try (symbol "," *> arm))
      _ <- optional (symbol ",")
      _ <- char ')'
      pure (foldr (\(p, v) acc -> Branch p v acc) fallback arms)

    arm :: P (Expr, Expr)
    arm = do
      p <- expr
      _ <- symbol ","
      v <- expr
      pure (p, v)

    -- | @constraint(name, arg1, arg2, ...)@ (v3-symbols \S2.1): a static
    -- string name, like an action's event and key, followed by any number of
    -- ordinary expressions -- zero included, since the language fixes no
    -- signature for any name.
    constraintShape :: P Expr
    constraintShape = do
      _ <- symbol "("
      name <- staticString
      args <- many (try (symbol "," *> expr))
      _ <- optional (symbol ",")
      _ <- char ')'
      pure (Constrain name args)

    -- | @import("name", {param: expr, other: ctx(path)})@. The name is a
    -- static literal, and the parameters are a dedicated production rather
    -- than an ordinary object expression, because @ctx(...)@ means something
    -- only here.
    importShape :: P Expr
    importShape = do
      _ <- symbol "("
      name <- staticString
      _ <- symbol ","
      params <- importParams
      _ <- char ')'
      pure (Import name params)

    importParams :: P [(Text, ParamValue)]
    importParams = do
      _ <- symbol "{"
      entries <- sepEndBy paramEntry (symbol ",")
      _ <- symbol "}"
      pure entries

    paramEntry :: P (Text, ParamValue)
    paramEntry = try explicitParam <|> shorthandParam

    explicitParam :: P (Text, ParamValue)
    explicitParam = do
      k <- objectKey
      _ <- symbol ":"
      v <- paramValue
      pure (k, v)

    shorthandParam :: P (Text, ParamValue)
    shorthandParam = do
      k <- identifier
      pure (k, PExpr (Path k []))

    -- | @ctx(spec.replicas)@ reads this program's own @$ctx.spec.replicas@,
    -- and means exactly that. The separate form exists so the path lands in a
    -- static position the analyses can read; see "Tramaj.Ast"'s 'ParamValue'.
    paramValue :: P ParamValue
    paramValue = (PType <$> markedTypeExpr) <|> fromContext <|> (PExpr <$> expr)

    fromContext :: P ParamValue
    fromContext = do
      _ <- try $ do
        n <- identifier
        _ <- lookAhead (char '(')
        if n == ("ctx" :: Text) then pure n else fail "not a ctx(...) parameter"
      _ <- symbol "("
      (root, fields) <- pathTail
      skipSpaces
      _ <- char ')'
      skipSpaces
      pure (PFromContext (root : fields))

    -- | @adapt-actions(node, prefix("ns:"))@, optionally with a closure for
    -- the event type and payload.
    adaptActionsShape :: P Expr
    adaptActionsShape = do
      _ <- symbol "("
      target <- expr
      _ <- symbol ","
      adaptation <- adaptationShape
      fn <- optional (try (symbol "," *> expr))
      _ <- optional (symbol ",")
      _ <- char ')'
      pure (AdaptActions target adaptation fn)

    -- | Two forms only, never an arbitrary rewriting function: this is what
    -- keeps the set of action keys a program can emit enumerable without
    -- evaluating it.
    adaptationShape :: P ActionAdaptation
    adaptationShape = do
      name <- identifier
      case name of
        "identity" -> pure Identity
        "prefix" -> do
          _ <- symbol "("
          p <- staticString
          _ <- char ')'
          skipSpaces
          pure (Prefix p)
        _ -> fail "an action adaptation must be identity or prefix(\"...\")"

-- Types ---------------------------------------------------------------------

-- | The five value-domain shapes v4-types \S1 reserves as type primitives.
-- Fixed and closed, so recognized here rather than left for a later
-- resolution pass to classify.
primNames :: [Text]
primNames = ["string", "number", "bool", "null", "document"]

-- | A type expression (v4-types \S1), in the position a full 'TypeExpr' may
-- appear: a declaration's right-hand side, a record field's type, an array's
-- element type. 'typeUnion' and 'typePrimOrRef' are included here but
-- deliberately excluded from 'typeExprPayload', which is what a union arm's
-- own payload parses with -- see that function for why.
typeExpr :: P TypeExpr
typeExpr = typeVar <|> typeUnion <|> typeArray <|> typeRecord <|> typePrimOrRef

-- | Everything a union arm's payload may be. Deliberately narrower than
-- 'typeExpr': a nested union has no bracketing in the surface grammar, and a
-- bare name (a 'TPrim' or 'TName') is excluded because nothing marks where a
-- nullary arm ends -- @| Dev | Staging@ must parse as two nullary arms, not
-- @Dev@ with a payload named @Staging@, and the same trap would swallow a
-- following statement's root expression whenever it starts with a bare
-- identifier (@true@, or a special form written without its leading @$@).
-- Every constructor kept here starts with a token -- @%@, @[@, @{@ -- that
-- cannot otherwise begin whatever follows a union declaration, so no such
-- ambiguity exists for them.
typeExprPayload :: P TypeExpr
typeExprPayload = typeVar <|> typeArray <|> typeRecord

-- | @%ctx.a.b@ (v4-types \S1): a type hole. The path MUST be rooted at @ctx@,
-- mirroring 'demandExpr' on the value side.
typeVar :: P TypeExpr
typeVar = lexeme $ try $ do
  _ <- char '%'
  root <- rawIdent
  if root == "ctx"
    then TVar <$> many (char '.' *> rawIdent)
    else fail "a type hole must be rooted at ctx, as in %ctx.path"

-- | A @%@-marked type argument, in the two positions v4-types \S2 and \S5
-- both use it: an import parameter's value, and a @!type-constraint@
-- argument. Unlike 'typeVar', the leading @%@ here does not require what
-- follows to be @ctx@ -- @%Json@ (\S2's supply) and @%ctx.payload@ (\S6's
-- forward) are both legal, told apart only after the @%@ itself is seen, so
-- this cannot simply be @char \'%\' *> typeExpr@: that would need a second
-- @%@ before the @ctx@ case. 'typeUnion'\/'typeArray'\/'typeRecord'\/
-- 'typePrimOrRef' need no such adjustment, since none of them themselves
-- start with @%@.
markedTypeExpr :: P TypeExpr
markedTypeExpr = try $ do
  _ <- char '%'
  ctxForward <|> typeUnion <|> typeArray <|> typeRecord <|> typePrimOrRef
  where
    ctxForward :: P TypeExpr
    ctxForward = lexeme $ try $ do
      root <- rawIdent
      if root == ("ctx" :: Text)
        then TVar <$> many (char '.' *> rawIdent)
        else fail "a %-marked value must be ctx.path or a type expression"

typeArray :: P TypeExpr
typeArray = do
  _ <- symbol "["
  t <- typeExpr
  _ <- symbol "]"
  pure (TArray t)

typeRecord :: P TypeExpr
typeRecord = do
  _ <- symbol "{"
  fields <- sepEndBy typeField (symbol ",")
  _ <- symbol "}"
  pure (TRecord fields)
  where
    -- | Unlike an ordinary object literal's 'objectKey', a field name here is
    -- always a bare 'identifier', never a quoted string (v4-types \S1.1's
    -- examples never show one either). This is what keeps a canonical id
    -- (v4-types \S3, "Tramaj.Types") injective: the id grammar uses @:@,
    -- @,@, @[@, @]@ and @|@ as its own delimiters, and an identifier can
    -- never contain any of them, so inserting a field name raw can never be
    -- confused with the surrounding structure. A quoted key could.
    typeField :: P (Text, TypeExpr)
    typeField = do
      k <- identifier
      _ <- symbol ":"
      t <- typeExpr
      pure (k, t)

-- | @| A T | B U | C@ (v4-types \S1.1): one or more arms, each a name with an
-- optional payload -- a nullary arm is an enum case, not a payload-carrying
-- one with an empty payload.
typeUnion :: P TypeExpr
typeUnion = TUnion <$> some (symbol "|" *> unionArm)
  where
    unionArm :: P (Text, Maybe TypeExpr)
    unionArm = do
      name <- identifier
      payload <- optional typeExprPayload
      pure (name, payload)

-- | A bare name (a primitive keyword or a declaration reference) or a
-- library-qualified one, @$lib.types.Name@ (v4-types \S1.1's @\@m :
-- $msg.types.Envelope@). Which of 'TName' and 'TLibRef' applies is a purely
-- syntactic distinction here; resolving either to a primitive, a
-- declaration, or 'UnresolvedType' is a later pass's job (v4-types \S9, not
-- yet implemented).
typePrimOrRef :: P TypeExpr
typePrimOrRef = libRef <|> nameOrPrim
  where
    libRef :: P TypeExpr
    libRef = lexeme $ try $ do
      _ <- char '$'
      libName <- rawIdent
      _ <- char '.'
      _ <- string "types"
      _ <- char '.'
      typeName <- rawIdent
      pure (TLibRef libName typeName)

    nameOrPrim :: P TypeExpr
    nameOrPrim = do
      name <- identifier
      pure (if name `elem` primNames then TPrim name else TName name)

-- | One argument to @!type-constraint@ (v4-types \S5): a @%@-marked type
-- expression, or a literal scalar -- reusing the same primitive literal
-- parsers 'numberLit'\/'keywordLit' use, unwrapped to the scalar the
-- argument actually carries, since it is never wrapped as an evaluable
-- 'Expr' here. A string scalar is 'staticString', not 'stringLit': like a
-- constraint's own name, this position is never computed.
typeConstraintArg :: P TypeConstraintArg
typeConstraintArg = (TCType <$> markedTypeExpr) <|> scalarArg
  where
    scalarArg :: P TypeConstraintArg
    scalarArg =
      (TCScalarStr <$> staticString)
        <|> (asScalar <$> numberLit)
        <|> (asScalar <$> keywordLit)

    asScalar :: Expr -> TypeConstraintArg
    asScalar (NumberLit n) = TCScalarNum n
    asScalar (BoolLit b) = TCScalarBool b
    asScalar NullLit = TCScalarNull
    asScalar (StringLit s) = TCScalarStr s
    asScalar _ = TCScalarNull -- unreachable: 'numberLit'/'keywordLit' only ever produce the cases above

-- | @!type-constraint(name, args...)@ (v4-types \S5, roadmap Phase 12): tried
-- before the general @!expr@ 'emission', since both share the @!@ leader and
-- @type-constraint(...)@ would otherwise parse as an ordinary call to an
-- unbound name.
typeEmission :: P Stmt
typeEmission = try $ do
  _ <- char '!'
  kw <- identifier
  if kw /= ("type-constraint" :: Text) then fail "not a !type-constraint" else pure ()
  _ <- symbol "("
  name <- staticString
  args <- many (try (symbol "," *> typeConstraintArg))
  _ <- optional (symbol ",")
  _ <- char ')'
  skipSpaces
  pure (STypeEmit name args)

-- | @type Name = TypeExpr@ (v4-types \S1.1): the fifth statement leader.
-- Unlike @\@@\/@!@\/@.\@$@ it is a whole keyword rather than a single
-- character, so it is recognized by parsing a full identifier and checking
-- it -- the same device 'keywordLit' uses -- which is what keeps @typeface =
-- ...@ from being chopped into the keyword @type@ plus leftovers.
typeDeclStmt :: P Stmt
typeDeclStmt = try $ do
  kw <- identifier
  if kw /= "type" then fail "not a type declaration" else pure ()
  name <- identifier
  _ <- symbol "="
  STypeDecl name <$> typeExpr

-- Documents ---------------------------------------------------------------

-- | @.tag(...)@ is an element; @.(...)@ is a fragment -- a tagless element,
-- introducing siblings with no wrapper.
documentExpr :: P Expr
documentExpr = try $ do
  _ <- char '.'
  choice [fragmentShape, elementShape]
  where
    fragmentShape :: P Expr
    fragmentShape = do
      _ <- symbol "("
      children <- sepEndBy expr (symbol ",")
      _ <- symbol ")"
      pure (Fragment children)

    elementShape :: P Expr
    elementShape = do
      tag <- rawIdent
      skipSpaces
      _ <- symbol "("
      args <- sepEndBy elementArg (symbol ",")
      _ <- symbol ")"
      buildElement tag args

-- | Buckets an element's arguments and enforces the one ordering rule:
-- everything in attribute position comes before any child.
buildElement :: Text -> [ElementArg] -> P Expr
buildElement tag args = do
  ensureAttrsBeforeChildren
  val <- singleValueSlot
  pure (Element tag [a | EArgAttr a <- args] val [c | EArgChild c <- args])
  where
    ensureAttrsBeforeChildren :: P ()
    ensureAttrsBeforeChildren
      | fst (foldl step (True, False) args) = pure ()
      | otherwise = fail "attributes, action(...) and value(...) must all come before an element's children"
      where
        step (ok, seenChild) arg = case arg of
          EArgChild _ -> (ok, True)
          _ -> (ok && not seenChild, seenChild)

    singleValueSlot :: P Expr
    singleValueSlot = case [v | EArgValue v <- args] of
      [] -> pure NullLit
      [v] -> pure v
      _ -> fail "an element can have at most one value(...)"

elementArg :: P ElementArg
elementArg =
  attributePositionArg
    <|> (EArgAttr <$> try namedArg)
    <|> (EArgChild <$> expr)

-- | @action(...)@ and @value(...)@, the two forms that mean something only in
-- an element's argument list.
--
-- As with 'specialForm', the 'try' covers name recognition only: once
-- @action@ has been seen applied to arguments, a malformed one is a parse
-- error. Letting it backtrack would leave @action("on-click", $computed, {})@
-- parsing happily as a call to an unbound function named @action@, and the
-- static-key restriction would be enforced by nothing at all.
--
-- Recognition needs the following @(@, so @action@ and @value@ remain usable
-- as ordinary attribute names: @value: 1@ is an attribute, @value(1)@ is the
-- value slot.
attributePositionArg :: P ElementArg
attributePositionArg = do
  name <- try $ do
    n <- identifier
    _ <- lookAhead (char '(')
    if n `elem` (["action", "value"] :: [Text])
      then pure n
      else fail "not an action(...) or value(...) form"
  case name of
    "action" -> EArgAttr <$> actionShape
    _ -> EArgValue <$> valueShape

-- | @action("on-click", "save", payloadExpr)@. Both the event and the key are
-- static literals; only the payload is computed. The host still owns the
-- event vocabulary -- what is fixed is the position, not the words allowed in
-- it.
actionShape :: P Attribute
actionShape = do
  _ <- symbol "("
  event <- staticString
  _ <- symbol ","
  key <- staticString
  _ <- symbol ","
  payload <- expr
  _ <- symbol ")"
  pure (ActionAttr event key payload)

-- | @value(expr)@ fills the element's value slot -- see "Tramaj.Node" for
-- what a host does with it.
valueShape :: P Expr
valueShape = do
  _ <- symbol "("
  v <- expr
  _ <- symbol ")"
  pure v

namedArg :: P Attribute
namedArg = do
  name <- objectKey
  _ <- symbol ":"
  Attr name <$> expr

-- Precedence ---------------------------------------------------------------

-- | @a \<\> b@, left-associative and the lowest precedence in the language --
-- the only infix operator there is.
expr :: P Expr
expr = do
  first <- operand
  rest <- many (try (symbol "<>" *> operand))
  pure (foldl Concat first rest)

-- | Alternatives are ordered so that a longer form is tried before a prefix of
-- it: keyword literals before paths and calls, special forms before ordinary
-- calls, lambdas before parenthesized expressions.
operand :: P Expr
operand =
  keywordLit
    <|> lambdaExpr
    <|> parenExpr
    <|> specialForm
    <|> call
    <|> pathExpr
    <|> allocExpr
    <|> demandExpr
    <|> documentExpr
    <|> stringLit
    <|> numberLit
    <|> arrayLit
    <|> objectLit

-- Programs -----------------------------------------------------------------

-- | @\@name=expr@ or @\@name : T = expr@ (v4-types \S7), one per line. @\@@
-- leads a binding definition, mirroring @$@ leading a binding read; the
-- optional @: T@ is what tells 'SLet' and 'SAnnotate' apart.
binding :: P [Stmt]
binding = try $ do
  _ <- char '@'
  pat <- pattern
  case pat of
    -- No annotation on a pattern (decisions \S17): a @:@ here is a parse error.
    PObject _ -> do
      _ <- symbol "="
      bindPattern pat <$> expr
    PName name -> do
      annot <- optional (try (symbol ":" *> typeExpr))
      _ <- symbol "="
      e <- expr
      pure [maybe (SLet name e) (\t -> SAnnotate name t e) annot]

-- | @!expr@ (v3-symbols \S2.2): the fourth statement leader, joining @.@,
-- @$@ and @\@@. A statement position only -- it may not appear inside an
-- expression, so there is no operand form for it.
emission :: P Expr
emission = try $ do
  _ <- char '!'
  expr

-- | One statement of the surface grammar: a binding (plain or annotated), an
-- emission (value or type), or a type declaration, in the order the source
-- wrote them -- what 'Ast.stmts' rebuilds into the core chain. 'typeEmission'
-- is tried ahead of 'emission' since both share the @!@ leader.
statement :: P [Stmt]
statement = binding <|> (pure <$> typeEmission) <|> (pure . SEmit <$> emission) <|> (pure <$> typeDeclStmt)

-- | A program is a sequence of statements and a root expression. Which kind
-- of program it is follows from the root's own form -- a document root is
-- exactly one written as a document -- so there is no mode to declare and no
-- separate entry point to pick.
parseProgram :: Text -> Either (ParseErrorBundle Text Void) Program
parseProgram = runParser program ""
  where
    program :: P Program
    program = do
      skipSpaces
      statements <- many statement
      root <- expr
      skipSpaces
      eof
      let programBody = numberAllocs (stmts (concat statements) root)
      pure $ case root of
        Element {} -> DocumentProgram programBody
        Fragment {} -> DocumentProgram programBody
        _ -> ExpressionProgram programBody

parseExpr :: Text -> Either (ParseErrorBundle Text Void) Expr
parseExpr = runParser (numberAllocs <$> (skipSpaces *> expr <* eof)) ""