packages feed

seihou-core-0.4.0.0: src/Seihou/Core/Expr.hs

module Seihou.Core.Expr
  ( parseExpr,
    evalExpr,
    exprRefs,
  )
where

import Data.Char (isDigit)
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Prelude

-- | Parse an expression string into an 'Expr' AST.
--
-- Grammar:
--
-- >  expr     = or_expr
-- >  or_expr  = and_expr ("||" and_expr)*
-- >  and_expr = not_expr ("&&" not_expr)*
-- >  not_expr = "!" atom | atom
-- >  atom     = "IsSet" varname
-- >           | "Eq" varname value
-- >           | "(" expr ")"
-- >           | "true" | "false"
-- >  varname  = [a-zA-Z][a-zA-Z0-9._-]*
-- >  value    = quoted_string | bare_word
parseExpr :: Text -> Either Text Expr
parseExpr input =
  let trimmed = T.strip input
   in if T.null trimmed
        then Left "empty expression"
        else case runParser parseOrExpr trimmed of
          Left err -> Left err
          Right (expr, rest)
            | T.null (T.strip rest) -> Right expr
            | otherwise -> Left ("unexpected trailing input: " <> rest)

-- | Evaluate an expression against a map of variable bindings.
evalExpr :: Map VarName VarValue -> Expr -> Bool
evalExpr vars = go
  where
    go (ExprEq name val) = Map.lookup name vars == Just val
    go (ExprAnd l r) = go l && go r
    go (ExprOr l r) = go l || go r
    go (ExprNot e) = not (go e)
    go (ExprIsSet name) = Map.member name vars
    go (ExprLit b) = b

-- | Collect the variables an expression references, paired with the literal
-- each is compared against via 'Eq' (if any).
--
--   * @Eq n v@   → @[(n, Just v)]@
--   * @IsSet n@  → @[(n, Nothing)]@
--   * @And@\/@Or@\/@Not@ recurse into sub-expressions
--   * @Lit@      → @[]@
--
-- Used by authoring-time lint to flag references to undeclared variables and
-- type-inconsistent @Eq@ comparisons. Total over 'Expr'.
exprRefs :: Expr -> [(VarName, Maybe VarValue)]
exprRefs (ExprEq name val) = [(name, Just val)]
exprRefs (ExprIsSet name) = [(name, Nothing)]
exprRefs (ExprAnd l r) = exprRefs l ++ exprRefs r
exprRefs (ExprOr l r) = exprRefs l ++ exprRefs r
exprRefs (ExprNot e) = exprRefs e
exprRefs (ExprLit _) = []

-- Parser internals: a parser consumes text and returns the result plus unconsumed input.
type Parser a = Text -> Either Text (a, Text)

runParser :: Parser a -> Text -> Either Text (a, Text)
runParser = id

parseOrExpr :: Parser Expr
parseOrExpr input = do
  (left, rest) <- parseAndExpr input
  parseOrRest left rest

parseOrRest :: Expr -> Parser Expr
parseOrRest left input =
  let stripped = T.strip input
   in if "||" `T.isPrefixOf` stripped
        then do
          let rest = T.strip (T.drop 2 stripped)
          (right, rest') <- parseAndExpr rest
          parseOrRest (ExprOr left right) rest'
        else Right (left, input)

parseAndExpr :: Parser Expr
parseAndExpr input = do
  (left, rest) <- parseNotExpr input
  parseAndRest left rest

parseAndRest :: Expr -> Parser Expr
parseAndRest left input =
  let stripped = T.strip input
   in if "&&" `T.isPrefixOf` stripped
        then do
          let rest = T.strip (T.drop 2 stripped)
          (right, rest') <- parseNotExpr rest
          parseAndRest (ExprAnd left right) rest'
        else Right (left, input)

parseNotExpr :: Parser Expr
parseNotExpr input =
  let stripped = T.strip input
   in if "!" `T.isPrefixOf` stripped
        then do
          let rest = T.drop 1 stripped
          (e, rest') <- parseNotExpr rest
          Right (ExprNot e, rest')
        else parseAtom stripped

parseAtom :: Parser Expr
parseAtom input =
  let stripped = T.strip input
   in case () of
        _
          | T.null stripped -> Left "unexpected end of input"
          | "true" `T.isPrefixOf` stripped && isAtomEnd (T.drop 4 stripped) ->
              Right (ExprLit True, T.drop 4 stripped)
          | "false" `T.isPrefixOf` stripped && isAtomEnd (T.drop 5 stripped) ->
              Right (ExprLit False, T.drop 5 stripped)
          | "IsSet" `T.isPrefixOf` stripped && isWhitespace (T.drop 5 stripped) -> do
              let rest = T.strip (T.drop 5 stripped)
              (name, rest') <- parseVarName rest
              Right (ExprIsSet (VarName name), rest')
          | "Eq" `T.isPrefixOf` stripped && isWhitespace (T.drop 2 stripped) -> do
              let rest = T.strip (T.drop 2 stripped)
              (name, rest') <- parseVarName rest
              let rest'' = T.strip rest'
              (value, rest''') <- parseValue rest''
              Right (ExprEq (VarName name) value, rest''')
          | T.head stripped == '(' -> do
              let rest = T.strip (T.drop 1 stripped)
              (e, rest') <- parseOrExpr rest
              let rest'' = T.strip rest'
              if not (T.null rest'') && T.head rest'' == ')'
                then Right (e, T.drop 1 rest'')
                else Left "expected closing parenthesis"
          | otherwise -> Left ("unexpected token: " <> T.take 20 stripped)

-- | Check if the character after a keyword is a valid separator (not a varname char).
isAtomEnd :: Text -> Bool
isAtomEnd t = T.null t || not (isVarNameChar (T.head t))

isWhitespace :: Text -> Bool
isWhitespace t = not (T.null t) && T.head t == ' '

parseVarName :: Parser Text
parseVarName input
  | T.null input = Left "expected variable name"
  | not (isVarNameStart (T.head input)) = Left ("expected variable name, got: " <> T.take 10 input)
  | otherwise =
      let (name, rest) = T.span isVarNameChar input
       in Right (name, rest)

isVarNameStart :: Char -> Bool
isVarNameStart c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')

isVarNameChar :: Char -> Bool
isVarNameChar c =
  (c >= 'a' && c <= 'z')
    || (c >= 'A' && c <= 'Z')
    || (c >= '0' && c <= '9')
    || c == '.'
    || c == '_'
    || c == '-'

parseValue :: Parser VarValue
parseValue input
  | T.null input = Left "expected value"
  | T.head input == '"' = parseQuotedString (T.drop 1 input)
  | otherwise = parseBareWord input

parseQuotedString :: Parser VarValue
parseQuotedString input =
  case T.breakOn "\"" input of
    (content, rest)
      | T.null rest -> Left "unterminated quoted string"
      | otherwise -> Right (VText content, T.drop 1 rest)

parseBareWord :: Parser VarValue
parseBareWord input =
  let (word, rest) = T.break (\c -> c == ' ' || c == ')' || c == '&' || c == '|') input
   in if T.null word
        then Left "expected value"
        else Right (classifyBareWord word, rest)
  where
    classifyBareWord "true" = VBool True
    classifyBareWord "false" = VBool False
    classifyBareWord w
      | isIntLiteral w = VInt (read (T.unpack w))
      | otherwise = VText w

-- | Whether a bareword is an integer literal: all digits, with an optional
-- leading minus sign and at least one digit. Such barewords are classified as
-- 'VInt' so an @Eq <int-var> N@ comparison matches a resolved 'VInt' value
-- (there is otherwise no surface syntax that produces an integer literal).
isIntLiteral :: Text -> Bool
isIntLiteral w =
  case T.uncons w of
    Just ('-', rest) -> not (T.null rest) && T.all isDigit rest
    _ -> not (T.null w) && T.all isDigit w