rzk-0.7.4: src/Language/Rzk/VSCode/Handlers.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# LANGUAGE RecordWildCards #-}
module Language.Rzk.VSCode.Handlers (
typecheckFromConfigFile,
provideCompletions,
formatDocument,
provideSemanticTokens,
handleFilesChanged,
) where
import Control.Exception (SomeException, evaluate, try)
import Control.Lens
import Control.Monad (forM_, when)
import Control.Monad.Except (ExceptT (ExceptT),
MonadError (throwError),
modifyError, runExceptT)
import Control.Monad.IO.Class (MonadIO (..))
import Data.Default.Class
import Data.List (isSuffixOf, sort, (\\))
import Data.Maybe (fromMaybe, isNothing)
import qualified Data.Text as T
import qualified Data.Yaml as Yaml
import Language.LSP.Diagnostics (partitionBySource)
import Language.LSP.Protocol.Lens (HasDetail (detail),
HasDocumentation (documentation),
HasLabel (label),
HasParams (params),
HasTextDocument (textDocument),
HasUri (uri), changes, uri)
import Language.LSP.Protocol.Message
import Language.LSP.Protocol.Types
import Language.LSP.Server
import Language.LSP.VFS (virtualFileText)
import System.FilePath (makeRelative, (</>))
import System.FilePath.Glob (compile, globDir)
import Language.Rzk.Free.Syntax (RzkPosition (RzkPosition),
VarIdent (getVarIdent))
import Language.Rzk.Syntax (Module, VarIdent' (VarIdent),
parseModuleFile,
parseModuleSafe, printTree)
import Language.Rzk.VSCode.Config (ServerConfig (ServerConfig, formatEnabled))
import Language.Rzk.VSCode.Env
import Language.Rzk.VSCode.Logging
import Language.Rzk.VSCode.Tokenize (tokenizeModule)
import Rzk.Format (FormattingEdit (..),
formatTextEdits)
import Rzk.Project.Config (ProjectConfig (include))
import Rzk.TypeCheck
-- | Given a list of file paths, reads them and parses them as Rzk modules,
-- returning the same list of file paths but with the parsed module (or parse error)
parseFiles :: [FilePath] -> IO [(FilePath, Either String Module)]
parseFiles [] = pure []
parseFiles (x:xs) = do
errOrMod <- parseModuleFile x
rest <- parseFiles xs
return $ (x, errOrMod) : rest
-- | Given the list of possible modules returned by `parseFiles`, this segregates the errors
-- from the successfully parsed modules and returns them in separate lists so the errors
-- can be reported and the modules can be typechecked.
collectErrors :: [(FilePath, Either String Module)] -> ([(FilePath, String)], [(FilePath, Module)])
collectErrors [] = ([], [])
collectErrors ((path, result) : paths) =
case result of
Left err -> ((path, err) : errors, [])
Right module_ -> (errors, (path, module_) : modules)
where
(errors, modules) = collectErrors paths
-- | The maximum number of diagnostic messages to send to the client
maxDiagnosticCount :: Int
maxDiagnosticCount = 100
filePathToNormalizedUri :: FilePath -> NormalizedUri
filePathToNormalizedUri = toNormalizedUri . filePathToUri
typecheckFromConfigFile :: LSP ()
typecheckFromConfigFile = do
logInfo "Looking for rzk.yaml"
root <- getRootPath
case root of
Nothing -> do
logWarning "Workspace has no root path, cannot find rzk.yaml"
sendNotification SMethod_WindowShowMessage (ShowMessageParams MessageType_Warning "Cannot find the workspace root")
Just rootPath -> do
let rzkYamlPath = rootPath </> "rzk.yaml"
eitherConfig <- liftIO $ Yaml.decodeFileEither @ProjectConfig rzkYamlPath
case eitherConfig of
Left err -> do
logError ("Invalid or missing rzk.yaml: " ++ Yaml.prettyPrintParseException err)
Right config -> do
logDebug "Starting typechecking"
rawPaths <- liftIO $ globDir (map compile (include config)) rootPath
let paths = concatMap sort rawPaths
typecheckedCachedModules <- getCachedTypecheckedModules
let cachedModules = map (\(path, RzkCachedModule{..}) -> (path, cachedModuleDecls)) typecheckedCachedModules
let cachedPaths = map fst cachedModules
modifiedFiles = paths \\ cachedPaths
logDebug ("Found " ++ show (length cachedPaths) ++ " files in the cache")
logDebug (show (length modifiedFiles) ++ " files have been modified")
(parseErrors, parsedModules) <- liftIO $ collectErrors <$> parseFiles modifiedFiles
tcResults <- liftIO $ try $ evaluate $
defaultTypeCheck (typecheckModulesWithLocationIncremental cachedModules parsedModules)
(typeErrors, _checkedModules) <- case tcResults of
Left (ex :: SomeException) -> do
-- Just a warning to be logged in the "Output" panel and not shown to the user as an error message
-- because exceptions are expected when the file has invalid syntax
logWarning ("Encountered an exception while typechecking:\n" ++ show ex)
return ([], [])
Right (Left err) -> do
logError ("An impossible error happened! Please report a bug:\n" ++ ppTypeErrorInScopedContext' BottomUp err)
return ([err], []) -- sort of impossible
Right (Right (checkedModules, errors)) -> do
-- cache well-typed modules
logInfo (show (length checkedModules) ++ " modules successfully typechecked")
logInfo (show (length errors) ++ " errors found")
let checkedModules' = map (\(path, decls) -> (path, RzkCachedModule decls (filter ((== path) . filepathOfTypeError) errors))) checkedModules
cacheTypecheckedModules checkedModules'
return (errors, checkedModules)
-- Reset all published diags
-- TODO: remove this after properly grouping by path below, after which there can be an empty list of errors
-- TODO: handle clearing diagnostics for files that got removed from the project (rzk.yaml)
forM_ modifiedFiles $ \path -> do
publishDiagnostics 0 (filePathToNormalizedUri path) Nothing (partitionBySource [])
-- Report parse errors to the client
forM_ parseErrors $ \(path, err) -> do
publishDiagnostics maxDiagnosticCount (filePathToNormalizedUri path) Nothing (partitionBySource [diagnosticOfParseError err])
-- TODO: collect all errors for one file in one list
-- Report typechecking errors to the client
forM_ typeErrors $ \err -> do
let errPath = filepathOfTypeError err
errDiagnostic = diagnosticOfTypeError err
publishDiagnostics maxDiagnosticCount (filePathToNormalizedUri errPath) Nothing (partitionBySource [errDiagnostic])
where
filepathOfTypeError :: TypeErrorInScopedContext var -> FilePath
filepathOfTypeError (PlainTypeError err) =
case location (typeErrorContext err) >>= locationFilePath of
Just path -> path
_ -> error "the impossible happened! Please contact Abdelrahman immediately!!!"
filepathOfTypeError (ScopedTypeError _orig err) = filepathOfTypeError err
diagnosticOfTypeError :: TypeErrorInScopedContext VarIdent -> Diagnostic
diagnosticOfTypeError err = Diagnostic
(Range (Position line 0) (Position line 99)) -- 99 to reach end of line and be visible until we actually have information about it
(Just DiagnosticSeverity_Error)
(Just $ InR "type-error") -- diagnostic code
Nothing -- diagonstic description
(Just "rzk") -- A human-readable string describing the source of this diagnostic
(T.pack msg)
Nothing -- tags
(Just []) -- related information
Nothing -- data that is preserved between different calls
where
msg = ppTypeErrorInScopedContext' TopDown err
extractLineNumber :: TypeErrorInScopedContext var -> Maybe Int
extractLineNumber (PlainTypeError e) = do
loc <- location (typeErrorContext e)
lineNo <- locationLine loc
return (lineNo - 1) -- VS Code indexes lines from 0, but locationLine starts with 1
extractLineNumber (ScopedTypeError _ e) = extractLineNumber e
line = fromIntegral $ fromMaybe 0 $ extractLineNumber err
diagnosticOfParseError :: String -> Diagnostic
diagnosticOfParseError err = Diagnostic (Range (Position 0 0) (Position 0 0))
(Just DiagnosticSeverity_Error)
(Just $ InR "parse-error")
Nothing
(Just "rzk")
(T.pack err)
Nothing
(Just [])
Nothing
instance Default T.Text where def = ""
instance Default CompletionItem
instance Default CompletionItemLabelDetails
provideCompletions :: Handler LSP 'Method_TextDocumentCompletion
provideCompletions req res = do
logInfo "Providing text completions"
root <- getRootPath
when (isNothing root) $ logDebug "Not in a workspace. Cannot find root path for relative paths"
let rootDir = fromMaybe "/" root
cachedModules <- getCachedTypecheckedModules
logDebug ("Found " ++ show (length cachedModules) ++ " modules in the cache")
let currentFile = fromMaybe "" $ uriToFilePath $ req ^. params . textDocument . uri
-- Take all the modules up to and including the currently open one
let modules = map ignoreErrors $ takeWhileInc ((/= currentFile) . fst) cachedModules
where
ignoreErrors (path, RzkCachedModule{..}) = (path, cachedModuleDecls)
takeWhileInc _ [] = []
takeWhileInc p (x:xs)
| p x = x : takeWhileInc p xs
| otherwise = [x]
let items = concatMap (declsToItems rootDir) modules
logDebug ("Sending " ++ show (length items) ++ " completion items")
res $ Right $ InL items
where
declsToItems :: FilePath -> (FilePath, [Decl']) -> [CompletionItem]
declsToItems root (path, decls) = map (declToItem root path) decls
declToItem :: FilePath -> FilePath -> Decl' -> CompletionItem
declToItem rootDir path (Decl name type' _ _ _) = def
& label .~ T.pack (printTree $ getVarIdent name)
& detail ?~ T.pack (show type')
& documentation ?~ InR (MarkupContent MarkupKind_Markdown $ T.pack $
"---\nDefined" ++
(if line > 0 then " at line " ++ show line else "")
++ " in *" ++ makeRelative rootDir path ++ "*")
where
(VarIdent pos _) = getVarIdent name
(RzkPosition _path pos') = pos
line = maybe 0 fst pos'
_col = maybe 0 snd pos'
formattingEditToTextEdit :: FormattingEdit -> TextEdit
formattingEditToTextEdit (FormattingEdit startLine startCol endLine endCol newText) =
TextEdit
(Range
(Position (fromIntegral startLine - 1) (fromIntegral startCol - 1))
(Position (fromIntegral endLine - 1) (fromIntegral endCol - 1))
)
(T.pack newText)
formatDocument :: Handler LSP 'Method_TextDocumentFormatting
formatDocument req res = do
let doc = req ^. params . textDocument . uri . to toNormalizedUri
logInfo $ "Formatting document: " <> show doc
ServerConfig {formatEnabled = fmtEnabled} <- getConfig
if fmtEnabled then do
mdoc <- getVirtualFile doc
possibleEdits <- case virtualFileText <$> mdoc of
Nothing -> return (Left "Failed to get file contents")
Just sourceCode -> do
let edits = formatTextEdits (filter (/= '\r') $ T.unpack sourceCode)
return (Right $ map formattingEditToTextEdit edits)
case possibleEdits of
Left err -> res $ Left $ ResponseError (InR ErrorCodes_InternalError) err Nothing
Right edits -> do
res $ Right $ InL edits
else do
logDebug "Formatting is disabled in config"
res $ Right $ InR Null
provideSemanticTokens :: Handler LSP 'Method_TextDocumentSemanticTokensFull
provideSemanticTokens req responder = do
let doc = req ^. params . textDocument . uri . to toNormalizedUri
mdoc <- getVirtualFile doc
possibleTokens <- case virtualFileText <$> mdoc of
Nothing -> return (Left "Failed to get file content")
Just sourceCode -> fmap (fmap tokenizeModule) $ liftIO $
parseModuleSafe (filter (/= '\r') $ T.unpack sourceCode)
case possibleTokens of
Left err -> do
-- Exception occurred when parsing the module
logWarning ("Failed to tokenize file: " ++ err)
Right tokens -> do
let encoded = encodeTokens defaultSemanticTokensLegend $ relativizeTokens tokens
case encoded of
Left _err -> do
-- Failed to encode the tokens
return ()
Right list ->
responder (Right (InL (SemanticTokens Nothing list)))
data IsChanged
= HasChanged
| NotChanged
-- | Detects if the given path has changes in its declaration compared to what's in the cache
isChanged :: RzkTypecheckCache -> FilePath -> LSP IsChanged
isChanged cache path = toIsChanged $ do
let cacheWithoutErrors = map (fmap cachedModuleDecls) cache
errors <- maybeToEitherLSP $ cachedModuleErrors <$> lookup path cache
cachedDecls <- maybeToEitherLSP $ cachedModuleDecls <$> lookup path cache
module' <- toExceptTLifted $ parseModuleFile path
e <- toExceptTLifted $ try @SomeException $ evaluate $
defaultTypeCheck (typecheckModulesWithLocationIncremental (takeWhile ((/= path) . fst) cacheWithoutErrors) [(path, module')])
(checkedModules, errors') <- toExceptT $ return e
decls' <- maybeToEitherLSP $ lookup path checkedModules
return $ if null errors' && null errors && decls' == cachedDecls
then NotChanged
else HasChanged
where
toExceptT = modifyError (const ()) . ExceptT
toExceptTLifted = toExceptT . liftIO
maybeToEitherLSP = \case
Nothing -> throwError ()
Just x -> return x
toIsChanged m = runExceptT m >>= \case
Left _ -> return HasChanged -- in case of error consider the file has changed
Right x -> return x
hasNotChanged :: RzkTypecheckCache -> FilePath -> LSP Bool
hasNotChanged cache path = isChanged cache path >>= \case
HasChanged -> return False
NotChanged -> return True
-- | Monadic 'dropWhile'
dropWhileM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
dropWhileM _ [] = return []
dropWhileM p (x:xs) = do
q <- p x
if q
then dropWhileM p xs
else return (x:xs)
handleFilesChanged :: Handler LSP 'Method_WorkspaceDidChangeWatchedFiles
handleFilesChanged msg = do
let modifiedPaths = msg ^.. params . changes . traverse . uri . to uriToFilePath . _Just
if any ("rzk.yaml" `isSuffixOf`) modifiedPaths
then do
logDebug "rzk.yaml modified. Clearing module cache"
resetCacheForAllFiles
else do
cache <- getCachedTypecheckedModules
actualModified <- dropWhileM (hasNotChanged cache) modifiedPaths
resetCacheForFiles actualModified
typecheckFromConfigFile