packages feed

qute-cli-0.1.0: app/qute-symex/Main.hs

-- SPDX-FileCopyrightText: 2025-2026 Sören Tempel <soeren+git@soeren-tempel.net>
--
-- SPDX-License-Identifier: GPL-3.0-only

module Main (main) where

import Control.Monad (when)
import Control.Monad.State.Strict (evalStateT, gets, liftIO)
import Data.Binary (encodeFile)
import Data.KTest (KTest (KTest), KTestObj, fromAssign)
import Data.String (fromString)
import Language.QBE.Backend.Store (Assign)
import Language.QBE.CmdLine qualified as CMD
import Language.QBE.Simulator (execFunc)
import Language.QBE.Simulator.Concolic.State (mkEnv)
import Language.QBE.Simulator.Error (EvalError)
import Language.QBE.Simulator.Explorer
  ( Engine (expLastPath),
    PathResult (pathErr, pathVars),
    defSolver,
    explorePath,
    logSolver,
    newEngine,
  )
import Language.QBE.Types qualified as QBE
import Options.Applicative qualified as OPT
import System.Directory (createDirectoryIfMissing)
import System.Exit (die)
import System.FilePath (addExtension, (</>))
import System.IO (IOMode (WriteMode), hPutStrLn, stderr, withFile)
import Text.Printf (printf)

data Opts = Opts
  { optLog :: Maybe FilePath,
    optSeed :: Maybe Int,
    optTestDir :: Maybe FilePath,
    optErrExit :: Bool,
    optWriteAll :: Bool,
    optVerbose :: Bool,
    optBase :: CMD.BasicArgs
  }

optTestCases :: String
optTestCases = "test-cases"

optsParser :: OPT.Parser Opts
optsParser =
  Opts
    <$> OPT.optional
      ( OPT.strOption
          ( OPT.long "dump-smt2"
              <> OPT.short 'd'
              <> OPT.metavar "FILE"
              <> OPT.help "Output queries as an SMT-LIB file"
          )
      )
    <*> OPT.optional
      ( OPT.option
          OPT.auto
          ( OPT.long "random-seed"
              <> OPT.short 'r'
              <> OPT.help "Initial seed to for the random number generator"
          )
      )
    <*> OPT.optional
      ( OPT.strOption
          ( OPT.long optTestCases
              <> OPT.short 't'
              <> OPT.metavar "FILE"
              <> OPT.help "Directory to write generate test inputs to"
          )
      )
    <*> OPT.switch
      ( OPT.long "exit-on-error"
          <> OPT.short 'e'
          <> OPT.help "Stop exploration after encountering the first error"
      )
    <*> OPT.switch
      ( OPT.long "write-all"
          <> OPT.short 'a'
          <> OPT.help "Write tests for all paths, not just those with errors"
      )
    <*> OPT.switch
      ( OPT.long "verbose"
          <> OPT.short 'v'
          <> OPT.help "Enable more verbose output"
      )
    <*> CMD.basicArgs

------------------------------------------------------------------------

data LogLevel = LogAll | LogErr
  deriving (Show, Eq, Ord)

data KTestConf
  = KTestConf
  { confLevel :: LogLevel,
    confPath :: FilePath,
    confName :: String
  }
  deriving (Show)

mkKTestConf :: LogLevel -> FilePath -> String -> IO KTestConf
mkKTestConf level directory name = do
  createDirectoryIfMissing True directory
  pure $ KTestConf level directory name

writeAssign :: Maybe KTestConf -> LogLevel -> Int -> Assign -> IO ()
writeAssign Nothing _ _ _ = pure ()
writeAssign (Just conf) level pathID assign
  | level >= confLevel conf = writeKTest conf pathID (fromAssign assign)
  | otherwise = pure ()

testCasePath :: KTestConf -> Int -> FilePath
testCasePath (KTestConf {confPath = directory}) n =
  addExtension
    (directory </> ("test" ++ printf "%06d" n))
    ".ktest"

writeKTest :: KTestConf -> Int -> [KTestObj] -> IO ()
writeKTest conf@(KTestConf {confName = name}) pathID =
  writeKTest' pathID . KTest [fromString name]
  where
    writeKTest' :: Int -> KTest -> IO ()
    writeKTest' n ktest = do
      flip encodeFile ktest $
        testCasePath conf n

------------------------------------------------------------------------

showError :: Maybe KTestConf -> Int -> EvalError -> IO ()
showError ktest n err = printErr
  where
    printErr = do
      hPutStrLn stderr $
        "Encountered error on path #"
          ++ show n
          ++ ": "
          ++ show err
          ++ "\n"
          ++ "-> "
          ++ printPath ktest

    printPath :: Maybe KTestConf -> String
    printPath Nothing = "Pass --" ++ optTestCases ++ " to generate test case"
    printPath (Just kt) =
      "Refer to the KTest file in " ++ show (testCasePath kt n)

exploreEntry :: Opts -> Maybe KTestConf -> Engine -> QBE.FuncDef -> IO Int
exploreEntry opts ktest engine entry =
  evalStateT (go 1 $ execFunc entry []) engine
  where
    go n st = do
      when (optVerbose opts) $
        liftIO (hPutStrLn stderr $ "Exploring path " ++ show n ++ "...")
      morePaths <- explorePath st

      lastPath <- gets expLastPath
      logLevel <- case pathErr lastPath of
        Just err -> liftIO $ do
          showError ktest n err
          pure LogErr
        Nothing -> pure LogAll

      liftIO $ do
        writeAssign ktest logLevel n (pathVars lastPath)
        when (optErrExit opts && logLevel == LogErr) $
          die "Exiting due to encountered error"

      if morePaths
        then go (n + 1) st
        else pure n

exploreFile :: Opts -> IO Int
exploreFile opts@Opts {optBase = base} = do
  (prog, func) <- CMD.parseEntryFile $ CMD.optQBEFile base

  let binName = CMD.optQBEFile $ optBase opts
      logLevel = if optWriteAll opts then LogAll else LogErr
  ktest <-
    case optTestDir opts of
      Just dir -> do
        Just <$> mkKTestConf logLevel dir binName
      Nothing -> pure Nothing

  env <- mkEnv prog (CMD.optMemStart base) (CMD.optMemSize base) (optSeed opts)
  case optLog opts of
    Just fn -> withFile fn WriteMode (exploreWithHandle ktest env func)
    Nothing -> do
      engine <- newEngine env <$> defSolver
      exploreEntry opts ktest engine func
  where
    exploreWithHandle ktest env func handle = do
      engine <- newEngine env <$> logSolver handle
      exploreEntry opts ktest engine func

------------------------------------------------------------------------

cmd :: OPT.ParserInfo Opts
cmd =
  OPT.info
    (optsParser OPT.<**> OPT.helper)
    ( OPT.fullDesc
        <> OPT.progDesc "Symbolic execution of programs in the QBE intermediate language"
    )

main :: IO ()
main = do
  numPaths <- OPT.execParser cmd >>= exploreFile
  putStrLn $ "\n---\nAmount of paths: " ++ show numPaths