packages feed

rzk-0.9.2: src/Language/Rzk/VSCode/Env.hs

{-# LANGUAGE ScopedTypeVariables #-}
module Language.Rzk.VSCode.Env where

import           Control.Concurrent.Async   (Async, async, cancel)
import           Control.Concurrent.STM
import           Control.Exception          (catch)
import           Control.Monad.Reader
import qualified Data.Map.Strict            as Map
import qualified Data.Text                  as T
import           Language.LSP.Server
import           Language.Rzk.Free.Syntax   (VarIdent)
import           Language.Rzk.Syntax        (Module)
import qualified Language.Rzk.VSCode.Config as RzkConfig
import           Language.Rzk.VSCode.Logging
import qualified Language.Rzk.VSCode.ReferenceIndex as RefInd
import           Rzk.TypeCheck              (Decl', TypeErrorInScopedContext)

data RzkCachedModule = RzkCachedModule
  { cachedModuleDecls  :: [Decl']
  , cachedModuleErrors :: [TypeErrorInScopedContext VarIdent]
  }

type RzkTypecheckCache = [(FilePath, RzkCachedModule)]

-- | Where a parse came from, deciding whether it may be reused.
data ParseSource
  = ParsedFromBuffer T.Text
    -- ^ Parsed from the editor buffer with this text; reusable for the
    -- current file while the buffer text is unchanged.
  | ParsedFromDisk
    -- ^ Parsed from the file on disk; trusted until invalidated by a
    -- file-change notification.
  | ParseInvalidated
    -- ^ The file changed; must be re-parsed. The module is kept as the last
    -- good parse, which hover falls back to while the file fails to parse.

-- | A parse result for the reference index. A failed parse is a 'Nothing'
-- module and is not retried until the source changes.
data ParsedModule = ParsedModule
  { parsedSource :: ParseSource
  , parsedModule :: Maybe Module
  }

data ReferenceIndexCache = ReferenceIndexCache
  { indexCacheModules :: Map.Map FilePath ParsedModule
    -- ^ Per-file parse cache. Entries parsed from disk are trusted until a
    -- file-change notification evicts them ('resetCacheForFiles'); the entry
    -- for the file being edited is revalidated against the editor buffer.
  , indexCacheResult  :: Maybe ([FilePath], RefInd.ReferenceIndex)
    -- ^ The index last built, with the file set it was built from.
  }

emptyReferenceIndexCache :: ReferenceIndexCache
emptyReferenceIndexCache = ReferenceIndexCache Map.empty Nothing

data RzkEnv = RzkEnv
  { rzkEnvTypecheckCache      :: TVar RzkTypecheckCache
  , rzkEnvReferenceIndexCache :: TVar ReferenceIndexCache
  , rzkEnvTypecheckWorker     :: TVar (Maybe (Async ()))
    -- ^ The thread running the current project typecheck, if any.
  }

defaultRzkEnv :: IO RzkEnv
defaultRzkEnv = do
  typecheckCache <- newTVarIO []
  referenceIndexCache <- newTVarIO emptyReferenceIndexCache
  typecheckWorker <- newTVarIO Nothing
  return RzkEnv
    { rzkEnvTypecheckCache = typecheckCache
    , rzkEnvReferenceIndexCache = referenceIndexCache
    , rzkEnvTypecheckWorker = typecheckWorker
    }

type LSP = LspT RzkConfig.ServerConfig (ReaderT RzkEnv IO)

-- | Run the given action (a project typecheck) on a fresh worker thread,
-- cancelling the previous worker first. Cancellation waits for the old
-- worker to stop, so it can no longer write to the caches once the new
-- one starts. Typechecking runs on a worker so that the handler dispatch
-- thread stays responsive: 'lsp' dispatches messages sequentially, so a
-- multi-second re-check run directly in a notification handler would
-- block every later request (e.g. formatting on save).
spawnTypecheckWorker :: LSP () -> LSP ()
spawnTypecheckWorker action = do
  lspEnv <- getLspEnv
  rzkEnv <- lift ask
  let run act = runReaderT (runLspT lspEnv act) rzkEnv
  liftIO $ do
    oldWorker <- atomically $ swapTVar (rzkEnvTypecheckWorker rzkEnv) Nothing
    mapM_ cancel oldWorker
    worker <- async $
      run action `catch` \(_ :: ProgressCancelledException) ->
        run (logInfo (T.pack "Typechecking was cancelled by the client"))
    atomically $ writeTVar (rzkEnvTypecheckWorker rzkEnv) (Just worker)

cacheTypecheckedModules :: RzkTypecheckCache -> LSP ()
cacheTypecheckedModules cache = lift $ do
  typecheckCache <- asks rzkEnvTypecheckCache
  referenceIndexCache <- asks rzkEnvReferenceIndexCache
  liftIO $ atomically $ do
    writeTVar typecheckCache cache
    -- Drop the built index (the declaration set may have changed), but keep
    -- the parse entries: files changed on disk are already evicted per path
    -- by 'resetCacheForFiles' on file-change notifications, and a kept entry
    -- may hold the last good parse of a file that currently has a syntax
    -- error, which hover falls back to.
    modifyTVar referenceIndexCache $ \c -> c { indexCacheResult = Nothing }

resetCacheForAllFiles :: LSP ()
resetCacheForAllFiles = cacheTypecheckedModules []

resetCacheForFiles :: [FilePath] -> LSP ()
resetCacheForFiles paths = lift $ do
  typecheckCache <- asks rzkEnvTypecheckCache
  referenceIndexCache <- asks rzkEnvReferenceIndexCache
  liftIO $ atomically $ do
    modifyTVar typecheckCache (takeWhile ((`notElem` paths) . fst))
    modifyTVar referenceIndexCache $ \c -> ReferenceIndexCache
      { indexCacheModules =
          foldr (Map.adjust (\pm -> pm { parsedSource = ParseInvalidated }))
                (indexCacheModules c) paths
      , indexCacheResult  = Nothing
      }

getCachedTypecheckedModules :: LSP RzkTypecheckCache
getCachedTypecheckedModules = lift $ do
  typecheckCache <- asks rzkEnvTypecheckCache
  liftIO $ readTVarIO typecheckCache

cacheReferenceIndex :: ReferenceIndexCache -> LSP ()
cacheReferenceIndex cache = lift $ do
  referenceIndexCache <- asks rzkEnvReferenceIndexCache
  liftIO $ atomically $ writeTVar referenceIndexCache cache

getCachedReferenceIndex :: LSP ReferenceIndexCache
getCachedReferenceIndex = lift $ do
  referenceIndexCache <- asks rzkEnvReferenceIndexCache
  liftIO $ readTVarIO referenceIndexCache