packages feed

oglyad-0.1.2.0: src/Oglyad/Source/RhaiEval.hs

-- | runs the version-setting part of an update.rhai against an upstream value
-- we already fetched, so guards like `if v.starts_with("cli-v")` and edits like
-- `v.crop(5)` apply before drift compares it with the spec.
--
-- it understands a small slice of Rhai: let, assignment, if/else, try/catch,
-- terminate(), rpm.version / rpm.global("ver", ...), the string methods update
-- scripts use, andax's regex helpers (find, find_all, sub), template strings,
-- + and indexing. anything else that touches a value we need gives up with
-- 'CantTell', and drift falls back to the raw upstream value
module Oglyad.Source.RhaiEval
  ( Simulated (..),
    simulateVersion,
  )
where

import Control.Applicative ((<|>))
import Data.Char (isAlphaNum, isDigit)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Oglyad.Regex qualified as Re
import Oglyad.Source.Rhai (Token (..), balanced, sourceCall, tokenize)
import Oglyad.Types (UpstreamSource)
import Text.Read (readMaybe)

data Simulated
  = -- | the script would set Version to this
    Sets Text
  | -- | the script finishes (or calls terminate()) without setting Version,
    -- so `anda update` wouldn't touch the spec for this upstream value
    NoUpdate
  | -- | the script does something we don't model
    CantTell
  deriving stock (Eq, Show)

-- | what Version the script would set, given the value its source helper
-- returns. the source is the one 'Oglyad.Source.Rhai.parseUpdateRhai' found.
-- any other helper call evaluates to an unknown value
simulateVersion :: UpstreamSource -> Text -> Text -> Simulated
simulateVersion src upstream script = case tokenize script of
  Just toks
    | balanced toks,
      Just prog <- stmts toks ->
        case exec (src, upstream) emptyEnv prog of
          Right env -> outcome env
          Left (Terminated env) -> outcome env
          -- an error nothing caught fails the whole `anda update` run. we
          -- can't say much about what the spec would look like then
          Left (Thrown _) -> CantTell
          Left GiveUp -> CantTell
  _ -> CantTell
  where
    -- rpm.version wins over rpm.global("ver", ...), since some scripts set the
    -- global to the raw tag and Version to a cleaned-up spelling of it
    outcome env = maybe NoUpdate Sets (envVersion env <|> envGlobal env)

-- * syntax

data Value = VStr Text | VNum Int | VBool Bool | VArr [Value] | VUnit
  deriving stock (Eq, Show)

data Expr
  = Lit Value
  | -- | a backtick string with ${...} to fill in
    Tmpl Text
  | Var Text
  | Src UpstreamSource
  | Call Text [Expr]
  | Method Expr Text [Expr]
  | Index Expr Expr
  | Not Expr
  | Bin Text Expr Expr
  | -- | rpm.changed(). we're asking "what if upstream moved", so it's true
    Changed
  | -- | an expression we couldn't parse. only fatal if something needs it
    Unparsed
  deriving stock (Show)

data Stmt
  = Let Text Expr
  | -- | v.crop(1); and friends, which change v in place
    Mutate Text Text [Expr]
  | -- | True for rpm.version, False for rpm.global("ver", ...)
    SetVersion Bool Expr
  | If Expr [Stmt] [Stmt]
  | Try [Stmt] [Stmt]
  | Terminate
  | -- | rpm.release(), print(...), and other calls that can't change Version
    Ignored
  | -- | an expression on its own, like find(...) used as a guard inside try
    ExprStmt Expr [Token]
  | -- | a statement we don't understand, kept so we can see what it touches
    Opaque [Token]
  | -- | `rpm.f = ...`, which rewrites the whole spec and can change Version
    -- in ways we can't follow
    RewritesSpec
  deriving stock (Show)

-- * parsing

stmts :: [Token] -> Maybe [Stmt]
stmts = \case
  [] -> Just []
  TSym ';' : rest -> stmts rest
  toks -> do
    (s, rest) <- stmt toks
    (s :) <$> stmts rest

stmt :: [Token] -> Maybe (Stmt, [Token])
stmt = \case
  TIdent "if" : rest -> ifStmt rest
  TIdent "try" : TSym '{' : rest -> do
    (body, rest1) <- block rest
    case rest1 of
      TIdent "catch" : rest2 -> do
        let rest3 = case rest2 of
              TSym '(' : TIdent _ : TSym ')' : r -> r
              r -> r
        case rest3 of
          TSym '{' : r -> do
            (handler, rest4) <- block r
            Just (Try body handler, rest4)
          _ -> Nothing
      _ -> Nothing
  toks ->
    let (s, rest) = untilSemi toks
     in Just (simple s, rest)

ifStmt :: [Token] -> Maybe (Stmt, [Token])
ifStmt toks = do
  let (c, rest) = break (== TSym '{') toks
  rest1 <- case rest of
    TSym '{' : r -> Just r
    _ -> Nothing
  (yes, rest2) <- block rest1
  case rest2 of
    TIdent "else" : TIdent "if" : rest3 -> do
      (s, rest4) <- ifStmt rest3
      Just (If (expr c) yes [s], rest4)
    TIdent "else" : TSym '{' : rest3 -> do
      (no, rest4) <- block rest3
      Just (If (expr c) yes no, rest4)
    _ -> Just (If (expr c) yes [], rest2)

-- | the statements inside braces, given the tokens just after the '{'
block :: [Token] -> Maybe ([Stmt], [Token])
block toks = do
  (inner, rest) <- closing toks
  body <- stmts inner
  Just (body, rest)

simple :: [Token] -> Stmt
simple = \case
  [TIdent "terminate", TSym '(', TSym ')'] -> Terminate
  TIdent "let" : TIdent v : TSym '=' : e -> Let v (expr e)
  TIdent "rpm" : TSym '.' : TIdent "version" : TSym '(' : r
    | Just [a] <- argsOnly r -> SetVersion True a
  TIdent "rpm" : TSym '.' : TIdent f : TSym '(' : TStr key : TSym ',' : r
    | f `elem` ["global", "define"],
      key `elem` ["ver", "version"],
      Just [a] <- argsOnly r ->
        SetVersion False a
  TIdent "rpm" : TSym '.' : TIdent "f" : TSym '=' : t : _ | t /= TSym '=' -> RewritesSpec
  TIdent "rpm" : _ -> Ignored
  TIdent "print" : _ -> Ignored
  TIdent v : TSym '.' : TIdent m : TSym '(' : r
    | m `elem` mutators,
      Just as <- argsOnly r ->
        Mutate v m as
  TIdent v : TSym '=' : e@(t : _) | t /= TSym '=' -> Let v (expr e)
  toks -> case expr toks of
    Unparsed -> Opaque toks
    e -> ExprStmt e toks
  where
    -- the arguments of a call that ends the statement
    argsOnly r = case args r of
      Just (as, []) -> Just as
      _ -> Nothing

-- | string methods that change the string they're called on
mutators :: [Text]
mutators = ["crop", "truncate", "pop", "replace", "trim", "make_lower", "make_upper"]

-- | tokens up to a ';' outside any brackets, and what's after it
untilSemi :: [Token] -> ([Token], [Token])
untilSemi = go (0 :: Int)
  where
    go _ [] = ([], [])
    go d (t : ts) = case t of
      TSym ';' | d == 0 -> ([], ts)
      TSym c
        | c `elem` ("([{" :: String) -> cons t (go (d + 1) ts)
        | c `elem` (")]}" :: String) -> cons t (go (d - 1) ts)
      _ -> cons t (go d ts)
    cons t (a, b) = (t : a, b)

-- | given the tokens after an opening bracket, the tokens inside it and the
-- ones after its closing bracket
closing :: [Token] -> Maybe ([Token], [Token])
closing = go (0 :: Int)
  where
    go _ [] = Nothing
    go d (t : ts) = case t of
      TSym c
        | c `elem` (")]}" :: String) -> if d == 0 then Just ([], ts) else cons t (go (d - 1) ts)
        | c `elem` ("([{" :: String) -> cons t (go (d + 1) ts)
      _ -> cons t (go d ts)
    cons t = fmap (\(a, b) -> (t : a, b))

-- | call arguments, given the tokens after the '('
args :: [Token] -> Maybe ([Expr], [Token])
args toks = do
  (inner, rest) <- closing toks
  Just (if null inner then [] else map expr (splitCommas inner), rest)
  where
    splitCommas ts = case go (0 :: Int) ts of
      (a, []) -> [a]
      (a, rest) -> a : splitCommas rest
    go _ [] = ([], [])
    go d (t : ts) = case t of
      TSym ',' | d == 0 -> ([], ts)
      TSym c
        | c `elem` ("([{" :: String) -> cons t (go (d + 1) ts)
        | c `elem` (")]}" :: String) -> cons t (go (d - 1) ts)
      _ -> cons t (go d ts)
    cons t (a, b) = (t : a, b)

-- | a whole expression. leftover tokens mean we misread it
expr :: [Token] -> Expr
expr toks = case orE toks of
  Just (e, []) -> e
  _ -> Unparsed

type P = [Token] -> Maybe (Expr, [Token])

orE, andE, cmpE, addE, unaryE, primary :: P
orE = binLoop andE [("||", [TSym '|', TSym '|'])]
andE = binLoop cmpE [("&&", [TSym '&', TSym '&'])]
cmpE ts = do
  (l, r) <- addE ts
  case [(op, rest) | (op, syms) <- cmpOps, Just rest <- [stripPrefixList syms r]] of
    (op, rest) : _ -> do
      (x, rest') <- addE rest
      Just (Bin op l x, rest')
    [] -> Just (l, r)
  where
    -- two-character operators first, so "<=" isn't read as "<"
    cmpOps =
      [ ("==", [TSym '=', TSym '=']),
        ("!=", [TSym '!', TSym '=']),
        ("<=", [TSym '<', TSym '=']),
        (">=", [TSym '>', TSym '=']),
        ("<", [TSym '<']),
        (">", [TSym '>'])
      ]
addE = binLoop unaryE [("+", [TSym '+'])]
unaryE = \case
  TSym '!' : r -> do
    (e, r') <- unaryE r
    Just (Not e, r')
  ts -> do
    (p, r) <- primary ts
    postfix p r
primary = \case
  TStr s : r -> Just (Lit (VStr s), r)
  TTmpl s : r -> Just (Tmpl s, r)
  TNum n : r -> (\i -> (Lit (VNum i), r)) <$> readMaybe (T.unpack n)
  TIdent "true" : r -> Just (Lit (VBool True), r)
  TIdent "false" : r -> Just (Lit (VBool False), r)
  TIdent "rpm" : TSym '.' : TIdent "changed" : TSym '(' : TSym ')' : r -> Just (Changed, r)
  TSym '(' : r -> do
    (e, r') <- orE r
    case r' of
      TSym ')' : r'' -> Just (e, r'')
      _ -> Nothing
  ts | Just (s, r) <- sourceCall ts -> Just (Src s, r)
  -- the lexer reads digits as identifiers too
  TIdent n : r | T.all isDigit n -> (\i -> (Lit (VNum i), r)) <$> readMaybe (T.unpack n)
  TSym '-' : TIdent n : r | T.all isDigit n -> (\i -> (Lit (VNum (negate i)), r)) <$> readMaybe (T.unpack n)
  TIdent f : TSym '(' : r -> do
    (as, r') <- args r
    Just (Call f as, r')
  TIdent v : r@(t : _) | t /= TSym ':' -> Just (Var v, r)
  [TIdent v] -> Just (Var v, [])
  _ -> Nothing

-- | method calls, properties and indexing: v.crop(1), v.len, caps[1]
postfix :: Expr -> P
postfix e = \case
  TSym '.' : TIdent m : TSym '(' : r -> do
    (as, r') <- args r
    postfix (Method e m as) r'
  TSym '.' : TIdent m : r -> postfix (Method e m []) r
  TSym '[' : r -> do
    (inner, r') <- closing r
    postfix (Index e (expr inner)) r'
  r -> Just (e, r)

binLoop :: P -> [(Text, [Token])] -> P
binLoop next ops ts = next ts >>= uncurry loop
  where
    loop l r = case [(op, rest) | (op, syms) <- ops, Just rest <- [stripPrefixList syms r]] of
      (op, rest) : _ -> do
        (x, rest') <- next rest
        loop (Bin op l x) rest'
      [] -> Just (l, r)

stripPrefixList :: (Eq a) => [a] -> [a] -> Maybe [a]
stripPrefixList [] ys = Just ys
stripPrefixList (x : xs) (y : ys) | x == y = stripPrefixList xs ys
stripPrefixList _ _ = Nothing

-- * evaluation

data Env = Env
  { -- | Nothing is a value we can't know, like another helper's result
    envVars :: Map Text (Maybe Value),
    envVersion :: Maybe Text,
    envGlobal :: Maybe Text
  }

emptyEnv :: Env
emptyEnv = Env Map.empty Nothing Nothing

data Stop
  = Terminated Env
  | -- | a Rhai error, like find() not matching. try/catch can stop it
    Thrown Env
  | GiveUp

-- | why an expression has no value
data NoValue
  = -- | it depends on something we can't see or don't model
    Unknown
  | -- | evaluating it raises a Rhai error
    Throw

type Eval = Either NoValue Value

type Ctx = (UpstreamSource, Text)

exec :: Ctx -> Env -> [Stmt] -> Either Stop Env
exec _ env [] = Right env
exec ctx env (s : ss) = step s >>= \env' -> exec ctx env' ss
  where
    step = \case
      Let v e -> case eval ctx env e of
        Right val -> Right (bind v (Just val))
        Left Unknown -> Right (bind v Nothing)
        Left Throw -> Left (Thrown env)
      Mutate v m as -> case Map.lookup v (envVars env) of
        Just (Just val) -> case traverse (eval ctx env) as >>= mutate val m of
          Right val' -> Right (bind v (Just val'))
          Left Throw -> Left (Thrown env)
          Left Unknown -> Right (bind v Nothing)
        -- mutating something we can't see keeps it unknown
        _ -> Right env
      SetVersion isMain e -> case eval ctx env e of
        Right (VStr t)
          | isMain -> Right env {envVersion = Just t}
          | otherwise -> Right env {envGlobal = Just t}
        Left Throw -> Left (Thrown env)
        _ -> Left GiveUp
      If c yes no -> case eval ctx env c of
        Right (VBool b) -> exec ctx env (if b then yes else no)
        Left Throw -> Left (Thrown env)
        -- a branch we can't decide only matters if it could change Version
        _ | all inert (yes <> no) -> Right env
        _ -> Left GiveUp
      Try body handler -> case exec ctx env body of
        Left (Thrown env') -> exec ctx env' handler
        r -> r
      Terminate -> Left (Terminated env)
      Ignored -> Right env
      ExprStmt e toks -> case eval ctx env e of
        Right _ -> Right env
        Left Throw -> Left (Thrown env)
        -- it might throw. that only matters if it's looking at our values
        Left Unknown
          | touches toks -> Left GiveUp
          | otherwise -> Right env
      Opaque toks
        | touches toks -> Left GiveUp
        | otherwise -> Right env
      RewritesSpec -> Left GiveUp
    bind v val = env {envVars = Map.insert v val (envVars env)}
    touches toks = any known [v | TIdent v <- toks]
    known v = maybe False isJust (Map.lookup v (envVars env))

-- | can this statement affect what Version ends up as?
inert :: Stmt -> Bool
inert = \case
  Ignored -> True
  If _ yes no -> all inert (yes <> no)
  Try body handler -> all inert (body <> handler)
  _ -> False

eval :: Ctx -> Env -> Expr -> Eval
eval ctx@(src, upstream) env = \case
  Lit v -> Right v
  Tmpl t -> VStr <$> template t
  Var v -> maybe (Left Unknown) Right (Map.lookup v (envVars env) >>= id)
  Src s
    | s == src -> Right (VStr upstream)
    | otherwise -> Left Unknown
  Call "print" _ -> Right VUnit
  Call f as -> traverse (eval ctx env) as >>= call f
  -- in Rhai, x.f(a) is f(x, a) for any function
  Method e m as -> do
    val <- eval ctx env e
    vals <- traverse (eval ctx env) as
    method val m vals <> call m (val : vals)
  Index e i -> do
    val <- eval ctx env e
    idx <- eval ctx env i
    index val idx
  Not e ->
    eval ctx env e >>= \case
      VBool b -> Right (VBool (not b))
      _ -> Left Unknown
  Bin op l r -> do
    a <- eval ctx env l
    b <- eval ctx env r
    binop op a b
  Changed -> Right (VBool True)
  Unparsed -> Left Unknown
  where
    -- only ${name} is filled in. anything fancier inside the braces is unknown
    template t = case T.breakOn "${" t of
      (pre, "") -> Right pre
      (pre, rest) -> case T.breakOn "}" (T.drop 2 rest) of
        (name, after)
          | not (T.null after),
            T.all (\c -> isAlphaNum c || c == '_') name -> do
              val <- eval ctx env (Var name)
              shown <- case val of
                VStr s -> Right s
                VNum n -> Right (T.pack (show n))
                _ -> Left Unknown
              ((pre <> shown) <>) <$> template (T.drop 1 after)
        _ -> Left Unknown

-- | andax functions. regexes the engine can't compile are unknown rather
-- than errors, since that's usually our engine's limit and not the script's
call :: Text -> [Value] -> Eval
call f as = case (f, as) of
  ("find", [VStr r, VStr t, VNum g]) -> do
    re <- regex r
    caps <- slow (Re.captures re t) >>= maybe (Left Throw) Right
    if g < 0 || g >= length caps
      then Left Throw
      else maybe (Left Throw) (Right . VStr) (caps !! g)
  ("find_all", [VStr r, VStr t]) -> do
    re <- regex r
    all' <- slow (Re.capturesAll re t)
    Right (VArr [VArr (map (maybe VUnit VStr) caps) | caps <- all'])
  ("sub", [VStr r, VStr rep, VStr t]) -> do
    re <- regex r
    VStr <$> slow (Re.replaceAll re rep t)
  _ -> Left Unknown
  where
    regex = either (const (Left Unknown)) Right . Re.compile
    -- Rust would have answered, we just ran out of patience
    slow = either (const (Left Unknown)) Right

-- | built-in methods that don't change the value they're called on. the
-- in-place ones return the changed string here, which is how scripts like
-- `gh(...).crop(1)` read
method :: Value -> Text -> [Value] -> Eval
method val m as = case (val, m, as) of
  (VStr s, "pop", []) -> Right (VStr (T.takeEnd 1 s))
  (VStr s, "pop", [VNum n]) -> Right (VStr (T.takeEnd n s))
  (VStr s, "starts_with", [VStr p]) -> Right (VBool (p `T.isPrefixOf` s))
  (VStr s, "ends_with", [VStr p]) -> Right (VBool (p `T.isSuffixOf` s))
  (VStr s, "contains", [VStr p]) -> Right (VBool (p `T.isInfixOf` s))
  (VStr s, "len", []) -> Right (VNum (T.length s))
  (VStr s, "is_empty", []) -> Right (VBool (T.null s))
  (VStr s, "to_lower", []) -> Right (VStr (T.toLower s))
  (VStr s, "to_upper", []) -> Right (VStr (T.toUpper s))
  (VArr xs, "len", []) -> Right (VNum (length xs))
  (VArr xs, "is_empty", []) -> Right (VBool (null xs))
  _ -> mutate val m as

-- | what an in-place string method leaves behind, following Rhai's string
-- api (crop keeps from start, truncate keeps the first n, pop drops the end)
mutate :: Value -> Text -> [Value] -> Eval
mutate (VStr s) m as =
  VStr <$> case (m, as) of
    ("crop", [VNum start]) | start >= 0 -> Right (T.drop start s)
    ("crop", [VNum start, VNum len]) | start >= 0 -> Right (T.take len (T.drop start s))
    ("truncate", [VNum n]) -> Right (T.take n s)
    ("pop", []) -> Right (T.dropEnd 1 s)
    ("pop", [VNum n]) -> Right (T.dropEnd n s)
    ("replace", [VStr a, VStr b]) | not (T.null a) -> Right (T.replace a b s)
    ("trim", []) -> Right (T.strip s)
    ("make_lower", []) -> Right (T.toLower s)
    ("make_upper", []) -> Right (T.toUpper s)
    _ -> Left Unknown
mutate _ _ _ = Left Unknown

-- | arrays and strings, with negative indices counting from the end like
-- Rhai. out of range is a Rhai error
index :: Value -> Value -> Eval
index val (VNum i) = case val of
  VArr xs -> pick xs
  VStr s -> VStr . T.singleton <$> pick (T.unpack s)
  _ -> Left Unknown
  where
    pick :: [a] -> Either NoValue a
    pick xs =
      let n = length xs
          j = if i < 0 then n + i else i
       in if j >= 0 && j < n then Right (xs !! j) else Left Throw
index _ _ = Left Unknown

binop :: Text -> Value -> Value -> Eval
binop op a b = case (op, a, b) of
  ("&&", VBool x, VBool y) -> Right (VBool (x && y))
  ("||", VBool x, VBool y) -> Right (VBool (x || y))
  ("+", VStr x, VStr y) -> Right (VStr (x <> y))
  ("+", VStr x, VNum y) -> Right (VStr (x <> T.pack (show y)))
  ("+", VNum x, VStr y) -> Right (VStr (T.pack (show x) <> y))
  ("+", VNum x, VNum y) -> Right (VNum (x + y))
  ("==", _, _) -> Right (VBool (a == b))
  ("!=", _, _) -> Right (VBool (a /= b))
  (_, VStr x, VStr y) -> maybe (Left Unknown) (Right . VBool) (ordOp (compare x y))
  (_, VNum x, VNum y) -> maybe (Left Unknown) (Right . VBool) (ordOp (compare x y))
  _ -> Left Unknown
  where
    ordOp o = case op of
      "<" -> Just (o == LT)
      "<=" -> Just (o /= GT)
      ">" -> Just (o == GT)
      ">=" -> Just (o /= LT)
      _ -> Nothing