packages feed

oglyad-0.1.1.0: src/Oglyad/Source/Rhai.hs

-- | figures out where an update.rhai gets its version from, without running it.
--
-- evaluating Rhai would mean reimplementing andax, so instead we:
--
--   1. lex the script into tokens with megaparsec (strings, identifiers,
--      punctuation, comments dropped)
--   2. find what's passed to `rpm.version(...)` or `rpm.global("ver", ...)`
--   3. if that's a variable, follow its `let` binding
--   4. if it's (or starts with) a known andax helper like gh("owner/repo"),
--      that's the source
--
-- versions that follow another distro or package (bodhi, madoguchi, nvidia)
-- become 'Derived'. anything fancier, like custom get_json scraping, ends up as
-- 'Unknown', and the unparsed-rhai check lists those
module Oglyad.Source.Rhai
  ( parseUpdateRhai,
    Token (..),
    tokenize,
  )
where

import Data.Char (isAlphaNum, isDigit, isSpace)
import Data.List (nub)
import Data.Maybe (listToMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Void (Void)
import Oglyad.Types (CrateField (..), UpstreamSource (..))
import Text.Megaparsec hiding (Token)
import Text.Megaparsec.Char (char, space1)
import Text.Megaparsec.Char.Lexer qualified as L

data Token
  = TIdent Text
  | TStr Text
  | TNum Text
  | TSym Char
  deriving stock (Eq, Show)

type Parser = Parsec Void Text

parseUpdateRhai :: Text -> UpstreamSource
parseUpdateRhai src = case tokenize src of
  Nothing -> Unknown (T.strip src)
  Just toks -> case resolveVersion toks of
    Just s -> s
    Nothing
      | hasCommitCall toks -> CommitTracked
      -- no rpm.version we could follow, but the script only ever calls one
      -- source helper, so that's almost certainly it
      | [s] <- nub (allSources toks) -> s
      | otherwise -> Unknown (T.strip src)

-- * lexing

tokenize :: Text -> Maybe [Token]
tokenize = either (const Nothing) Just . parse (sc *> many tokenP <* eof) "update.rhai"

sc :: Parser ()
sc = L.space space1 (L.skipLineComment "//") (L.skipBlockComment "/*" "*/")

tokenP :: Parser Token
tokenP =
  L.lexeme sc $
    choice
      [ TStr <$> quoted '"',
        TStr <$> raw, -- template strings, ${} left as-is
        TStr <$> quoted '\'', -- char literals
        TIdent <$> takeWhile1P (Just "identifier") (\c -> isAlphaNum c || c == '_'),
        TNum <$> takeWhile1P (Just "number") isDigit,
        TSym <$> satisfy (not . isSpace)
      ]
  where
    -- backtick strings are raw in Rhai, so regexes like `[\d.]+` stay intact
    raw = char '`' *> takeWhileP Nothing (/= '`') <* char '`'
    -- in quoted strings a backslash escapes whatever follows it. we only care
    -- about the text being roughly right, so unknown escapes are kept as-is
    quoted q = T.pack <$> (char q *> manyTill (escaped <|> anySingle) (char q))
    escaped = char '\\' *> fmap unescape anySingle
    unescape = \case
      'n' -> '\n'
      't' -> '\t'
      c -> c

-- * analysis

-- | follow rpm.version(...) / rpm.global("ver", ...) back to a source helper
resolveVersion :: [Token] -> Maybe UpstreamSource
resolveVersion toks = listToMaybe (mapMaybe (resolveExpr toks 5) (versionExprs toks))

-- | every expression handed to something that sets the package version
versionExprs :: [Token] -> [[Token]]
versionExprs = \case
  [] -> []
  TIdent "rpm" : TSym '.' : TIdent "version" : TSym '(' : rest ->
    argsOf rest <> versionExprs rest
  TIdent "rpm" : TSym '.' : TIdent f : TSym '(' : TStr key : TSym ',' : rest
    | f `elem` ["global", "define"],
      key `elem` ["ver", "version"] ->
        argsOf' rest <> versionExprs rest
  _ : rest -> versionExprs rest
  where
    -- rpm.version(EXPR)
    argsOf rest = [takeBalanced rest]
    -- rpm.global("ver", EXPR), we're already past the comma
    argsOf' rest = [takeBalanced rest]

-- | tokens up to the ')' that closes the call we're inside
takeBalanced :: [Token] -> [Token]
takeBalanced = go (0 :: Int)
  where
    go _ [] = []
    go depth (t : ts) = case t of
      TSym c
        | c `elem` ("([{" :: String) -> t : go (depth + 1) ts
        | c `elem` (")]}" :: String) -> if depth == 0 then [] else t : go (depth - 1) ts
        | c == ',' && depth == 0 -> []
      _ -> t : go depth ts

-- | what does this expression get its value from? fuel stops let-cycles
resolveExpr :: [Token] -> Int -> [Token] -> Maybe UpstreamSource
resolveExpr toks fuel expr
  | fuel <= 0 = Nothing
  | otherwise = case expr of
      -- a helper call, possibly with a method chain after it
      _ | Just (s, _) <- sourceCall expr -> Just s
      -- a helper from one of the shared andax/*.rhai modules, e.g.
      -- bump::madoguchi(...) or nvidia::nvidia_driver_version()
      TIdent m : TSym ':' : TSym ':' : TIdent f : TSym '(' : _ -> Just (Derived (m <> "::" <> f))
      TIdent f : TSym '(' : _ | f `elem` derivedHelpers -> Just (Derived f)
      -- some other call like find(`regex`, tag, 1): the version comes from
      -- whichever argument we can trace back to a source
      TIdent _ : TSym '(' : rest ->
        listToMaybe (mapMaybe (resolveExpr toks (fuel - 1)) (splitArgs rest))
      -- a variable, possibly with methods: `v` or `v.crop(1)`
      TIdent v : _ -> do
        bound <- letBinding v toks
        resolveExpr toks (fuel - 1) bound
      _ -> Nothing

-- | split the arguments of a call we're inside, up to its closing ')'
splitArgs :: [Token] -> [[Token]]
splitArgs toks = case takeBalanced toks of
  [] -> []
  arg ->
    arg : case drop (length arg) toks of
      TSym ',' : rest -> splitArgs rest
      _ -> []

-- | the right-hand side of `let v = ...;` (first binding wins)
letBinding :: Text -> [Token] -> Maybe [Token]
letBinding v = \case
  [] -> Nothing
  TIdent "let" : TIdent v' : TSym '=' : rest | v == v' -> Just (takeWhile (/= TSym ';') rest)
  _ : rest -> letBinding v rest

-- | recognize an andax source helper at the start of a token list
sourceCall :: [Token] -> Maybe (UpstreamSource, [Token])
sourceCall = \case
  TIdent f : TSym '(' : TStr a : TSym ',' : TStr b : TSym ')' : rest
    | Just s <- twoArg f a b -> Just (s, rest)
  TIdent f : TSym '(' : TStr a : TSym ')' : rest
    | Just s <- oneArg f a -> Just (s, rest)
  _ -> Nothing
  where
    oneArg f a = case f of
      "gh" -> Just (GitHub a)
      "gh_tag" -> Just (GitHubTag a)
      "gitlab" -> Just (GitLab "gitlab.com" a)
      "gitlab_tag" -> Just (GitLabTag "gitlab.com" a)
      "codeberg" -> Just (Gitea "codeberg.org" a)
      "sourcehut" -> Just (GitTags ("https://git.sr.ht/" <> a))
      "tangled" -> Just (GitTags ("https://tangled.org/" <> a))
      "codeberg_tag" -> Just (GiteaTag "codeberg.org" a)
      "pypi" -> Just (PyPI a)
      "crates" -> Just (Crates MaxStable a)
      "crates_max" -> Just (Crates Max a)
      "crates_newest" -> Just (Crates Newest a)
      "npm" -> Just (Npm a)
      "hackage" -> Just (Hackage a)
      "gems" -> Just (Gems a)
      "hex" -> Just (Hex a)
      "cran" -> Just (Cran a)
      "opam" -> Just (Opam a)
      _ -> Nothing
    twoArg f a b = case f of
      "gitlab" -> Just (GitLab a b)
      "gitlab_tag" -> Just (GitLabTag a b)
      "gitea" -> Just (Gitea a b)
      "gitea_tag" -> Just (GiteaTag a b)
      "forgejo" -> Just (Gitea a b)
      "forgejo_tag" -> Just (GiteaTag a b)
      "ansible_galaxy" -> Just (AnsibleGalaxy a b)
      _ -> Nothing

-- | helpers whose version follows something other than an upstream release
derivedHelpers :: [Text]
derivedHelpers =
  [ "bodhi",
    "bodhi_vr",
    "madoguchi",
    "alma",
    "nvidia_driver_version",
    "nvidia_legacy_version",
    "nvidia_component_version"
  ]

allSources :: [Token] -> [UpstreamSource]
allSources = \case
  [] -> []
  ts@(_ : rest) -> maybe id ((:) . fst) (sourceCall ts) (allSources rest)

-- | does the script call any *_commit helper (gh_commit, gitlab_commit, ...)?
hasCommitCall :: [Token] -> Bool
hasCommitCall = \case
  TIdent f : TSym '(' : _ | "_commit" `T.isSuffixOf` f -> True
  _ : rest -> hasCommitCall rest
  [] -> False