packages feed

morley-0.3.0: contract-discover/Main.hs

{-# LANGUAGE DeriveAnyClass #-}

-- | An executable for haskell preprocessor which finds
-- all contract declarations in Haskell modules within the directory where
-- preprocessor is called and exports found contracts in a map.
module Main
  ( main
  ) where

import qualified Unsafe

import Data.Default (Default(..))
import Fmt (Builder, build, fmt, indentF, listF', unlinesF, (+|), (+||), (|+), (||+))
import qualified System.Directory as Dir
import System.Environment (getArgs)
import System.FilePath.Posix (isAbsolute, takeDirectory, (</>))
import Text.Megaparsec (errorBundlePretty, runParser)

import Lorentz.Discover
import Util.IO (hSetTranslit, readFileUtf8, writeFileUtf8)

main :: IO ()
main = do
  hSetTranslit stderr
  args <- getArgs
  case args of
    src : _ : dst : opts -> do
      Config{..} <- parseOpts def (map toText opts)

      let scanRoot
            | isAbsolute cScanRoot = cScanRoot
            | otherwise = takeDirectory src </> cScanRoot
      validRoot <- Dir.doesDirectoryExist scanRoot
      unless validRoot $
        die $ "Specified scan root is not an existing directory: " <> scanRoot
      contracts <- findContracts cDebug scanRoot

      verifyContractDecls contracts
      let output = fmt @Text $ unlinesF
            [ pragmasSection
            , "module " +| cModuleName |+ " (contracts) where"
            , ""
            , importsSection contracts
            , ""
            , contractsMapSection contracts
            ]
      printDebug cDebug . fmt $
        "Generated module:\n" +| indentF 2 (build output) |+ ""

      writeFileUtf8 dst output
    _ -> do
      die "Usage: lorentz-discover src _ dst moduleName"

newtype IsDebug = IsDebug Bool

data Config = Config
  { cModuleName :: Text
  , cDebug :: IsDebug
  , cScanRoot :: FilePath
  }

instance Default Config where
  def = Config
    { cModuleName = "Main"
    , cDebug = IsDebug False
    , cScanRoot = "."
    }

parseOpts :: Config -> [Text] -> IO Config
parseOpts cfg = \case
  [] -> pure cfg
  "--debug" : opts ->
    parseOpts cfg{ cDebug = IsDebug True } opts
  "--generated-module" : opts ->
    case opts of
      [] -> die "Usage: `--generated-module <module name>`"
      modName : os -> parseOpts cfg{ cModuleName = modName } os
  "--root" : opts ->
    case opts of
      [] -> die "Usage: `--root <directory>`"
      root : os -> parseOpts cfg{ cScanRoot = toString root } os
  opt : _ ->
    die $ "Unknown option: " <> show opt

printDebug :: IsDebug -> Text -> IO ()
printDebug (IsDebug isDebug)
  | isDebug = hPutStrLn stderr . ("contract-discover: " <>)
  | otherwise = \_ -> pass

findContracts :: IsDebug -> FilePath -> IO [ExportedContractInfo]
findContracts isDebug path = do
  isDir <- Dir.doesDirectoryExist path
  if isDir
    then do
      dirs <- Dir.listDirectory path
      concatMapM (findContracts isDebug) (map (path </>) dirs)
    else do
      if isHaskellModule path
        then parseHaskellModule isDebug path
        else pure []

parseHaskellModule :: IsDebug -> FilePath -> IO [ExportedContractInfo]
parseHaskellModule isDebug path = do
  file <- readFileUtf8 path
  case runParser haskellExportsParser path file of
    -- Some autogenerated modules may have no export list, we ignore them
    Left err -> do
      printDebug isDebug . toText $ errorBundlePretty err
      return []
    Right res -> do
      printDebug isDebug $ "In module " +|| path ||+ " found contracts "
                         +| listF' (build . ecdName . eciContractDecl) res |+ ""
      return res

-- | Ensures that:
--
-- 1. No two contracts have the same name.
verifyContractDecls :: [ExportedContractInfo] -> IO ()
verifyContractDecls contracts = do
  let vars = map (ecdVar . eciContractDecl) contracts
  let dups = map head . mapMaybe (nonEmpty . Unsafe.init) . group $ sort vars
  case dups of
    [] -> pass
    dup : _ -> die $ "Found multiple contracts with the same name: " <> show dup

pragmasSection :: Builder
pragmasSection =
  mconcat . map (<> "\n") $
  [ "{-# LANGUAGE ImplicitPrelude #-}"
  ]

importsSection :: [ExportedContractInfo] -> Builder
importsSection =
  mconcat . map (<> "\n") .
  (extraImports ++) . map mkImport . ordNub . map eciModuleName
  where
  mkImport m = "import qualified " +| m |+ ""
  extraImports =
    [ "import Data.Text (Text)"
    , "import qualified Data.Map as Map"

    , "import qualified Michelson.Untyped as U"
    , "import Lorentz.Discover"
    ]

contractsMapSection :: [ExportedContractInfo] -> Builder
contractsMapSection contracts =
  "contracts :: Map.Map Text U.Contract\n\
  \contracts = Map.fromList\n  " <> listF' contractPairF contracts
  where
    -- Forms @(contract name, contract variable)@ pair.
    contractPairF ExportedContractInfo{..} =
      let ExportedContractDecl{..} = eciContractDecl
          var = "toUntypedContract " +| eciModuleName |+ "." +| ecdVar |+ ""
      in "\n  (\"" +| ecdName |+ "\", " +| var +| ")"