1 {-# LANGUAGE OverloadedStrings #-}
2 {-# LANGUAGE RankNTypes #-}
3 {-# LANGUAGE ScopedTypeVariables #-}
4 {-# LANGUAGE ExistentialQuantification #-}
7 -- Module : Language.Haskell.LSP.Test
8 -- Description : A functional testing framework for LSP servers.
9 -- Maintainer : luke_lau@icloud.com
10 -- Stability : experimental
12 -- A framework for testing
13 -- <https://github.com/Microsoft/language-server-protocol Language Server Protocol servers>
16 module Language.Haskell.LSP.Test
20 , runSessionWithHandles
21 , runSessionWithConfig
24 , SessionException(..)
42 , publishDiagnosticsNotification
57 , waitForDiagnosticsSource
78 import Control.Applicative.Combinators
79 import Control.Concurrent
81 import Control.Monad.IO.Class
82 import Control.Exception
83 import Control.Lens hiding ((.=), List)
84 import qualified Data.Text as T
85 import qualified Data.Text.IO as T
88 import qualified Data.HashMap.Strict as HashMap
89 import qualified Data.Map as Map
91 import Language.Haskell.LSP.Types hiding (id, capabilities, message)
92 import qualified Language.Haskell.LSP.Types as LSP
93 import qualified Language.Haskell.LSP.Types.Capabilities as LSP
94 import Language.Haskell.LSP.Messages
95 import Language.Haskell.LSP.VFS
96 import Language.Haskell.LSP.Test.Compat
97 import Language.Haskell.LSP.Test.Decoding
98 import Language.Haskell.LSP.Test.Exceptions
99 import Language.Haskell.LSP.Test.Parsing
100 import Language.Haskell.LSP.Test.Session
101 import Language.Haskell.LSP.Test.Server
103 import System.Directory
104 import System.FilePath
105 import qualified Yi.Rope as Rope
107 -- | Starts a new session.
108 runSession :: String -- ^ The command to run the server.
109 -> FilePath -- ^ The filepath to the root directory for the session.
110 -> Session a -- ^ The session to run.
112 runSession = runSessionWithConfig def
114 -- | Starts a new sesion with a client with the specified capabilities.
115 runSessionWithConfig :: SessionConfig -- ^ The capabilities the client should have.
116 -> String -- ^ The command to run the server.
117 -> FilePath -- ^ The filepath to the root directory for the session.
118 -> Session a -- ^ The session to run.
120 runSessionWithConfig config serverExe rootDir session = do
121 pid <- getCurrentProcessID
122 absRootDir <- canonicalizePath rootDir
124 let initializeParams = InitializeParams (Just pid)
125 (Just $ T.pack absRootDir)
126 (Just $ filePathToUri absRootDir)
128 (capabilities config)
130 withServer serverExe (logStdErr config) $ \serverIn serverOut _ ->
131 runSessionWithHandles serverIn serverOut listenServer config rootDir $ do
133 -- Wrap the session around initialize and shutdown calls
134 initRspMsg <- sendRequest Initialize initializeParams :: Session InitializeResponse
136 liftIO $ maybe (return ()) (putStrLn . ("Error while initializing: " ++) . show ) (initRspMsg ^. LSP.error)
138 initRspVar <- initRsp <$> ask
139 liftIO $ putMVar initRspVar initRspMsg
141 sendNotification Initialized InitializedParams
143 -- Run the actual test
146 sendNotification Exit ExitParams
150 -- | Listens to the server output, makes sure it matches the record and
151 -- signals any semaphores
152 listenServer :: Handle -> SessionContext -> IO ()
153 listenServer serverOut context = do
154 msgBytes <- getNextMessage serverOut
156 reqMap <- readMVar $ requestMap context
158 let msg = decodeFromServerMsg reqMap msgBytes
159 writeChan (messageChan context) (ServerMessage msg)
161 listenServer serverOut context
163 -- | The current text contents of a document.
164 documentContents :: TextDocumentIdentifier -> Session T.Text
165 documentContents doc = do
167 let file = vfs Map.! (doc ^. uri)
168 return $ Rope.toText $ Language.Haskell.LSP.VFS._text file
170 -- | Parses an ApplyEditRequest, checks that it is for the passed document
171 -- and returns the new content
172 getDocumentEdit :: TextDocumentIdentifier -> Session T.Text
173 getDocumentEdit doc = do
174 req <- message :: Session ApplyWorkspaceEditRequest
176 unless (checkDocumentChanges req || checkChanges req) $
177 liftIO $ throw (IncorrectApplyEditRequest (show req))
181 checkDocumentChanges :: ApplyWorkspaceEditRequest -> Bool
182 checkDocumentChanges req =
183 let changes = req ^. params . edit . documentChanges
184 maybeDocs = fmap (fmap (^. textDocument . uri)) changes
186 Just docs -> (doc ^. uri) `elem` docs
188 checkChanges :: ApplyWorkspaceEditRequest -> Bool
190 let mMap = req ^. params . edit . changes
191 in maybe False (HashMap.member (doc ^. uri)) mMap
193 -- | Sends a request to the server and waits for its response.
195 -- rsp <- sendRequest TextDocumentDocumentSymbol params :: Session DocumentSymbolsResponse
197 -- Note: will skip any messages in between the request and the response.
198 sendRequest :: (ToJSON params, FromJSON a) => ClientMethod -> params -> Session (ResponseMessage a)
199 sendRequest m = sendRequest' m >=> skipManyTill anyMessage . responseForId
201 -- | Send a request to the server and wait for its response,
203 sendRequest_ :: ToJSON params => ClientMethod -> params -> Session ()
204 sendRequest_ p = void . (sendRequest p :: ToJSON params => params -> Session (ResponseMessage Value))
206 -- | Sends a request to the server without waiting on the response.
209 => ClientMethod -- ^ The request method.
210 -> params -- ^ The request parameters.
211 -> Session LspId -- ^ The id of the request that was sent.
212 sendRequest' method params = do
213 id <- curReqId <$> get
214 modify $ \c -> c { curReqId = nextId id }
216 let req = RequestMessage' "2.0" id method params
218 -- Update the request map
219 reqMap <- requestMap <$> ask
220 liftIO $ modifyMVar_ reqMap $
221 \r -> return $ updateRequestMap r id method
227 where nextId (IdInt i) = IdInt (i + 1)
228 nextId (IdString s) = IdString $ T.pack $ show $ read (T.unpack s) + 1
230 -- | A custom type for request message that doesn't
231 -- need a response type, allows us to infer the request
232 -- message type without using proxies.
233 data RequestMessage' a = RequestMessage' T.Text LspId ClientMethod a
235 instance ToJSON a => ToJSON (RequestMessage' a) where
236 toJSON (RequestMessage' rpc id method params) =
237 object ["jsonrpc" .= rpc, "id" .= id, "method" .= method, "params" .= params]
240 sendRequestMessage :: (ToJSON a, ToJSON b) => RequestMessage ClientMethod a b -> Session ()
241 sendRequestMessage req = do
242 -- Update the request map
243 reqMap <- requestMap <$> ask
244 liftIO $ modifyMVar_ reqMap $
245 \r -> return $ updateRequestMap r (req ^. LSP.id) (req ^. method)
249 -- | Sends a notification to the server.
250 sendNotification :: ToJSON a
251 => ClientMethod -- ^ The notification method.
252 -> a -- ^ The notification parameters.
255 -- | Open a virtual file if we send a did open text document notification
256 sendNotification TextDocumentDidOpen params = do
257 let params' = fromJust $ decode $ encode params
258 n :: DidOpenTextDocumentNotification
259 n = NotificationMessage "2.0" TextDocumentDidOpen params'
260 oldVFS <- vfs <$> get
261 newVFS <- liftIO $ openVFS oldVFS n
262 modify (\s -> s { vfs = newVFS })
265 -- | Close a virtual file if we send a close text document notification
266 sendNotification TextDocumentDidClose params = do
267 let params' = fromJust $ decode $ encode params
268 n :: DidCloseTextDocumentNotification
269 n = NotificationMessage "2.0" TextDocumentDidClose params'
270 oldVFS <- vfs <$> get
271 newVFS <- liftIO $ closeVFS oldVFS n
272 modify (\s -> s { vfs = newVFS })
275 sendNotification method params = sendNotification' (NotificationMessage "2.0" method params)
277 sendNotification' :: (ToJSON a, ToJSON b) => NotificationMessage a b -> Session ()
278 sendNotification' = sendMessage
280 sendResponse :: ToJSON a => ResponseMessage a -> Session ()
281 sendResponse = sendMessage
283 -- | Returns the initialize response that was received from the server.
284 -- The initialize requests and responses are not included the session,
285 -- so if you need to test it use this.
286 initializeResponse :: Session InitializeResponse
287 initializeResponse = initRsp <$> ask >>= (liftIO . readMVar)
289 -- | Opens a text document and sends a notification to the client.
290 openDoc :: FilePath -> String -> Session TextDocumentIdentifier
291 openDoc file languageId = do
292 item <- getDocItem file languageId
293 sendNotification TextDocumentDidOpen (DidOpenTextDocumentParams item)
294 TextDocumentIdentifier <$> getDocUri file
296 -- | Reads in a text document as the first version.
297 getDocItem :: FilePath -- ^ The path to the text document to read in.
298 -> String -- ^ The language ID, e.g "haskell" for .hs files.
299 -> Session TextDocumentItem
300 getDocItem file languageId = do
302 let fp = rootDir context </> file
303 contents <- liftIO $ T.readFile fp
304 return $ TextDocumentItem (filePathToUri fp) (T.pack languageId) 0 contents
306 -- | Gets the Uri for the file corrected to the session directory.
307 getDocUri :: FilePath -> Session Uri
310 let fp = rootDir context </> file
311 return $ filePathToUri fp
313 -- | Waits for diagnostics to be published and returns them.
314 waitForDiagnostics :: Session [Diagnostic]
315 waitForDiagnostics = do
316 diagsNot <- skipManyTill anyMessage message :: Session PublishDiagnosticsNotification
317 let (List diags) = diagsNot ^. params . LSP.diagnostics
320 waitForDiagnosticsSource :: String -> Session [Diagnostic]
321 waitForDiagnosticsSource src = do
322 diags <- waitForDiagnostics
323 let res = filter matches diags
325 then waitForDiagnosticsSource src
328 matches :: Diagnostic -> Bool
329 matches d = d ^. source == Just (T.pack src)
331 -- | Expects a 'PublishDiagnosticsNotification' and throws an
332 -- 'UnexpectedDiagnosticsException' if there are any diagnostics
334 noDiagnostics :: Session ()
336 diagsNot <- message :: Session PublishDiagnosticsNotification
337 when (diagsNot ^. params . LSP.diagnostics /= List []) $ liftIO $ throw UnexpectedDiagnostics
339 -- | Returns the symbols in a document.
340 getDocumentSymbols :: TextDocumentIdentifier -> Session [SymbolInformation]
341 getDocumentSymbols doc = do
342 ResponseMessage _ rspLid mRes mErr <- sendRequest TextDocumentDocumentSymbol (DocumentSymbolParams doc)
343 maybe (return ()) (throw . UnexpectedResponseError rspLid) mErr
344 let (Just (List symbols)) = mRes
347 -- | Returns all the code actions in a document by
348 -- querying the code actions at each of the current
349 -- diagnostics' positions.
350 getAllCodeActions :: TextDocumentIdentifier -> Session [CommandOrCodeAction]
351 getAllCodeActions doc = do
352 curDiags <- fromMaybe [] . Map.lookup (doc ^. uri) . curDiagnostics <$> get
353 let ctx = CodeActionContext (List curDiags) Nothing
355 foldM (go ctx) [] curDiags
358 go :: CodeActionContext -> [CommandOrCodeAction] -> Diagnostic -> Session [CommandOrCodeAction]
360 ResponseMessage _ rspLid mRes mErr <- sendRequest TextDocumentCodeAction (CodeActionParams doc (diag ^. range) ctx)
363 Just e -> throw (UnexpectedResponseError rspLid e)
365 let Just (List cmdOrCAs) = mRes
366 in return (acc ++ cmdOrCAs)
368 -- | Executes a command.
369 executeCommand :: Command -> Session ()
370 executeCommand cmd = do
371 let args = decode $ encode $ fromJust $ cmd ^. arguments
372 execParams = ExecuteCommandParams (cmd ^. command) args
373 sendRequest_ WorkspaceExecuteCommand execParams
375 -- | Executes a code action.
376 -- Matching with the specification, if a code action
377 -- contains both an edit and a command, the edit will
379 executeCodeAction :: CodeAction -> Session ()
380 executeCodeAction action = do
381 maybe (return ()) handleEdit $ action ^. edit
382 maybe (return ()) executeCommand $ action ^. command
384 where handleEdit :: WorkspaceEdit -> Session ()
386 -- Its ok to pass in dummy parameters here as they aren't used
387 let req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams e)
388 in updateState (ReqApplyWorkspaceEdit req)
390 -- | Adds the current version to the document, as tracked by the session.
391 getVersionedDoc :: TextDocumentIdentifier -> Session VersionedTextDocumentIdentifier
392 getVersionedDoc (TextDocumentIdentifier uri) = do
395 case fs Map.!? uri of
396 Just (VirtualFile v _) -> Just v
398 return (VersionedTextDocumentIdentifier uri ver)
400 -- | Applys an edit to the document and returns the updated document version.
401 applyEdit :: TextDocumentIdentifier -> TextEdit -> Session VersionedTextDocumentIdentifier
402 applyEdit doc edit = do
404 verDoc <- getVersionedDoc doc
406 caps <- asks (capabilities . config)
408 let supportsDocChanges = fromMaybe False $ do
409 let LSP.ClientCapabilities mWorkspace _ _ = caps
410 LSP.WorkspaceClientCapabilities _ mEdit _ _ _ _ <- mWorkspace
411 LSP.WorkspaceEditClientCapabilities mDocChanges <- mEdit
414 let wEdit = if supportsDocChanges
416 let docEdit = TextDocumentEdit verDoc (List [edit])
417 in WorkspaceEdit Nothing (Just (List [docEdit]))
419 let changes = HashMap.singleton (doc ^. uri) (List [edit])
420 in WorkspaceEdit (Just changes) Nothing
422 let req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams wEdit)
423 updateState (ReqApplyWorkspaceEdit req)
425 -- version may have changed
428 -- | Returns the completions for the position in the document.
429 getCompletions :: TextDocumentIdentifier -> Position -> Session [CompletionItem]
430 getCompletions doc pos = do
431 rsp <- sendRequest TextDocumentCompletion (TextDocumentPositionParams doc pos)
433 case getResponseResult rsp of
434 Completions (List items) -> return items
435 CompletionList (CompletionListType _ (List items)) -> return items
437 -- | Returns the references for the position in the document.
438 getReferences :: TextDocumentIdentifier -- ^ The document to lookup in.
439 -> Position -- ^ The position to lookup.
440 -> Bool -- ^ Whether to include declarations as references.
441 -> Session [Location] -- ^ The locations of the references.
442 getReferences doc pos inclDecl =
443 let ctx = ReferenceContext inclDecl
444 params = ReferenceParams doc pos ctx
445 in getResponseResult <$> sendRequest TextDocumentReferences params
447 -- | Returns the definition(s) for the term at the specified position.
448 getDefinitions :: TextDocumentIdentifier -- ^ The document the term is in.
449 -> Position -- ^ The position the term is at.
450 -> Session [Location] -- ^ The location(s) of the definitions
451 getDefinitions doc pos =
452 let params = TextDocumentPositionParams doc pos
453 in getResponseResult <$> sendRequest TextDocumentDefinition params
455 -- ^ Renames the term at the specified position.
456 rename :: TextDocumentIdentifier -> Position -> String -> Session ()
457 rename doc pos newName = do
458 let params = RenameParams doc pos (T.pack newName)
459 rsp <- sendRequest TextDocumentRename params :: Session RenameResponse
460 let wEdit = getResponseResult rsp
461 req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams wEdit)
462 updateState (ReqApplyWorkspaceEdit req)
464 -- ^ Returns the hover information at the specified position.
465 getHover :: TextDocumentIdentifier -> Position -> Session (Maybe Hover)
466 getHover doc pos = do
467 let params = TextDocumentPositionParams doc pos
468 getResponseResult <$> sendRequest TextDocumentHover params
470 -- | Checks the response for errors and throws an exception if needed.
471 -- Returns the result if successful.
472 getResponseResult :: ResponseMessage a -> a
473 getResponseResult rsp = fromMaybe exc (rsp ^. result)
474 where exc = throw $ UnexpectedResponseError (rsp ^. LSP.id)
475 (fromJust $ rsp ^. LSP.error)