15cb2a164d4600530cbc6a626f8fedbfee7890f9
[lsp-test.git] / src / Language / Haskell / LSP / Test.hs
1 {-# LANGUAGE OverloadedStrings #-}
2 {-# LANGUAGE RankNTypes #-}
3 {-# LANGUAGE ScopedTypeVariables #-}
4 {-# LANGUAGE ExistentialQuantification #-}
5
6 {-|
7 Module      : Language.Haskell.LSP.Test
8 Description : A functional testing framework for LSP servers.
9 Maintainer  : luke_lau@icloud.com
10 Stability   : experimental
11 Portability : POSIX
12
13 Provides the framework to start functionally testing
14 <https://github.com/Microsoft/language-server-protocol Language Server Protocol servers>.
15 You should import "Language.Haskell.LSP.Types" alongside this.
16 -}
17 module Language.Haskell.LSP.Test
18   (
19   -- * Sessions
20     Session
21   , runSession
22   -- ** Config
23   , runSessionWithConfig
24   , SessionConfig(..)
25   , defaultConfig
26   , C.fullCaps
27   -- ** Exceptions
28   , module Language.Haskell.LSP.Test.Exceptions
29   , withTimeout
30   -- * Sending
31   , request
32   , request_
33   , sendRequest
34   , sendNotification
35   , sendResponse
36   -- * Receving
37   , module Language.Haskell.LSP.Test.Parsing
38   -- * Utilities
39   -- | Quick helper functions for common tasks.
40   -- ** Initialization
41   , initializeResponse
42   -- ** Documents
43   , openDoc
44   , openDoc'
45   , closeDoc
46   , changeDoc
47   , documentContents
48   , getDocumentEdit
49   , getDocUri
50   , getVersionedDoc
51   -- ** Symbols
52   , getDocumentSymbols
53   -- ** Diagnostics
54   , waitForDiagnostics
55   , waitForDiagnosticsSource
56   , noDiagnostics
57   , getCurrentDiagnostics
58   -- ** Commands
59   , executeCommand
60   -- ** Code Actions
61   , getCodeActions
62   , getAllCodeActions
63   , executeCodeAction
64   -- ** Completions
65   , getCompletions
66   -- ** References
67   , getReferences
68   -- ** Definitions
69   , getDefinitions
70   , getTypeDefinitions
71   -- ** Renaming
72   , rename
73   -- ** Hover
74   , getHover
75   -- ** Highlights
76   , getHighlights
77   -- ** Formatting
78   , formatDoc
79   , formatRange
80   -- ** Edits
81   , applyEdit
82   ) where
83
84 import Control.Applicative.Combinators
85 import Control.Concurrent
86 import Control.Monad
87 import Control.Monad.IO.Class
88 import Control.Exception
89 import Control.Lens hiding ((.=), List)
90 import qualified Data.Text as T
91 import qualified Data.Text.IO as T
92 import Data.Aeson
93 import Data.Default
94 import qualified Data.HashMap.Strict as HashMap
95 import qualified Data.Map as Map
96 import Data.Maybe
97 import Language.Haskell.LSP.Types
98 import Language.Haskell.LSP.Types.Lens hiding
99   (id, capabilities, message, executeCommand, applyEdit, rename)
100 import qualified Language.Haskell.LSP.Types.Lens as LSP
101 import qualified Language.Haskell.LSP.Types.Capabilities as C
102 import Language.Haskell.LSP.Messages
103 import Language.Haskell.LSP.VFS
104 import Language.Haskell.LSP.Test.Compat
105 import Language.Haskell.LSP.Test.Decoding
106 import Language.Haskell.LSP.Test.Exceptions
107 import Language.Haskell.LSP.Test.Parsing
108 import Language.Haskell.LSP.Test.Session
109 import Language.Haskell.LSP.Test.Server
110 import System.IO
111 import System.Directory
112 import System.FilePath
113 import qualified Data.Rope.UTF16 as Rope
114
115 -- | Starts a new session.
116 --
117 -- > runSession "hie" fullCaps "path/to/root/dir" $ do
118 -- >   doc <- openDoc "Desktop/simple.hs" "haskell"
119 -- >   diags <- waitForDiagnostics
120 -- >   let pos = Position 12 5
121 -- >       params = TextDocumentPositionParams doc
122 -- >   hover <- request TextDocumentHover params
123 runSession :: String -- ^ The command to run the server.
124            -> C.ClientCapabilities -- ^ The capabilities that the client should declare.
125            -> FilePath -- ^ The filepath to the root directory for the session.
126            -> Session a -- ^ The session to run.
127            -> IO a
128 runSession = runSessionWithConfig def
129
130 -- | Starts a new sesion with a custom configuration.
131 runSessionWithConfig :: SessionConfig -- ^ Configuration options for the session.
132                      -> String -- ^ The command to run the server.
133                      -> C.ClientCapabilities -- ^ The capabilities that the client should declare.
134                      -> FilePath -- ^ The filepath to the root directory for the session.
135                      -> Session a -- ^ The session to run.
136                      -> IO a
137 runSessionWithConfig config serverExe caps rootDir session = do
138   pid <- getCurrentProcessID
139   absRootDir <- canonicalizePath rootDir
140
141   let initializeParams = InitializeParams (Just pid)
142                                           (Just $ T.pack absRootDir)
143                                           (Just $ filePathToUri absRootDir)
144                                           Nothing
145                                           caps
146                                           (Just TraceOff)
147                                           Nothing
148   withServer serverExe (logStdErr config) $ \serverIn serverOut _ ->
149     runSessionWithHandles serverIn serverOut listenServer config caps rootDir $ do
150
151       -- Wrap the session around initialize and shutdown calls
152       initRspMsg <- request Initialize initializeParams :: Session InitializeResponse
153
154       liftIO $ maybe (return ()) (putStrLn . ("Error while initializing: " ++) . show ) (initRspMsg ^. LSP.error)
155
156       initRspVar <- initRsp <$> ask
157       liftIO $ putMVar initRspVar initRspMsg
158
159       sendNotification Initialized InitializedParams
160
161       case lspConfig config of
162         Just cfg -> sendNotification WorkspaceDidChangeConfiguration (DidChangeConfigurationParams cfg)
163         Nothing -> return ()
164
165       -- Run the actual test
166       result <- session
167
168       sendNotification Exit ExitParams
169
170       return result
171   where
172   -- | Listens to the server output, makes sure it matches the record and
173   -- signals any semaphores
174   listenServer :: Handle -> SessionContext -> IO ()
175   listenServer serverOut context = do
176     msgBytes <- getNextMessage serverOut
177
178     reqMap <- readMVar $ requestMap context
179
180     let msg = decodeFromServerMsg reqMap msgBytes
181     writeChan (messageChan context) (ServerMessage msg)
182
183     listenServer serverOut context
184
185 -- | The current text contents of a document.
186 documentContents :: TextDocumentIdentifier -> Session T.Text
187 documentContents doc = do
188   vfs <- vfs <$> get
189   let file = vfs Map.! (doc ^. uri)
190   return $ Rope.toText $ Language.Haskell.LSP.VFS._text file
191
192 -- | Parses an ApplyEditRequest, checks that it is for the passed document
193 -- and returns the new content
194 getDocumentEdit :: TextDocumentIdentifier -> Session T.Text
195 getDocumentEdit doc = do
196   req <- message :: Session ApplyWorkspaceEditRequest
197
198   unless (checkDocumentChanges req || checkChanges req) $
199     liftIO $ throw (IncorrectApplyEditRequest (show req))
200
201   documentContents doc
202   where
203     checkDocumentChanges :: ApplyWorkspaceEditRequest -> Bool
204     checkDocumentChanges req =
205       let changes = req ^. params . edit . documentChanges
206           maybeDocs = fmap (fmap (^. textDocument . uri)) changes
207       in case maybeDocs of
208         Just docs -> (doc ^. uri) `elem` docs
209         Nothing -> False
210     checkChanges :: ApplyWorkspaceEditRequest -> Bool
211     checkChanges req =
212       let mMap = req ^. params . edit . changes
213         in maybe False (HashMap.member (doc ^. uri)) mMap
214
215 -- | Sends a request to the server and waits for its response.
216 -- Will skip any messages in between the request and the response
217 -- @
218 -- rsp <- request TextDocumentDocumentSymbol params :: Session DocumentSymbolsResponse
219 -- @
220 -- Note: will skip any messages in between the request and the response.
221 request :: (ToJSON params, FromJSON a) => ClientMethod -> params -> Session (ResponseMessage a)
222 request m = sendRequest m >=> skipManyTill anyMessage . responseForId
223
224 -- | The same as 'sendRequest', but discard the response.
225 request_ :: ToJSON params => ClientMethod -> params -> Session ()
226 request_ p = void . (request p :: ToJSON params => params -> Session (ResponseMessage Value))
227
228 -- | Sends a request to the server. Unlike 'request', this doesn't wait for the response.
229 sendRequest
230   :: ToJSON params
231   => ClientMethod -- ^ The request method.
232   -> params -- ^ The request parameters.
233   -> Session LspId -- ^ The id of the request that was sent.
234 sendRequest method params = do
235   id <- curReqId <$> get
236   modify $ \c -> c { curReqId = nextId id }
237
238   let req = RequestMessage' "2.0" id method params
239
240   -- Update the request map
241   reqMap <- requestMap <$> ask
242   liftIO $ modifyMVar_ reqMap $
243     \r -> return $ updateRequestMap r id method
244
245   sendMessage req
246
247   return id
248
249   where nextId (IdInt i) = IdInt (i + 1)
250         nextId (IdString s) = IdString $ T.pack $ show $ read (T.unpack s) + 1
251
252 -- | A custom type for request message that doesn't
253 -- need a response type, allows us to infer the request
254 -- message type without using proxies.
255 data RequestMessage' a = RequestMessage' T.Text LspId ClientMethod a
256
257 instance ToJSON a => ToJSON (RequestMessage' a) where
258   toJSON (RequestMessage' rpc id method params) =
259     object ["jsonrpc" .= rpc, "id" .= id, "method" .= method, "params" .= params]
260
261
262 -- | Sends a notification to the server.
263 sendNotification :: ToJSON a
264                  => ClientMethod -- ^ The notification method.
265                  -> a -- ^ The notification parameters.
266                  -> Session ()
267
268 -- Open a virtual file if we send a did open text document notification
269 sendNotification TextDocumentDidOpen params = do
270   let params' = fromJust $ decode $ encode params
271       n :: DidOpenTextDocumentNotification
272       n = NotificationMessage "2.0" TextDocumentDidOpen params'
273   oldVFS <- vfs <$> get
274   newVFS <- liftIO $ openVFS oldVFS n
275   modify (\s -> s { vfs = newVFS })
276   sendMessage n
277
278 -- Close a virtual file if we send a close text document notification
279 sendNotification TextDocumentDidClose params = do
280   let params' = fromJust $ decode $ encode params
281       n :: DidCloseTextDocumentNotification
282       n = NotificationMessage "2.0" TextDocumentDidClose params'
283   oldVFS <- vfs <$> get
284   newVFS <- liftIO $ closeVFS oldVFS n
285   modify (\s -> s { vfs = newVFS })
286   sendMessage n
287
288 sendNotification TextDocumentDidChange params = do
289     let params' = fromJust $ decode $ encode params
290         n :: DidChangeTextDocumentNotification
291         n = NotificationMessage "2.0" TextDocumentDidChange params'
292     oldVFS <- vfs <$> get
293     newVFS <- liftIO $ changeFromClientVFS oldVFS n
294     modify (\s -> s { vfs = newVFS })
295     sendMessage n
296
297 sendNotification method params = sendMessage (NotificationMessage "2.0" method params)
298
299 -- | Sends a response to the server.
300 sendResponse :: ToJSON a => ResponseMessage a -> Session ()
301 sendResponse = sendMessage
302
303 -- | Returns the initialize response that was received from the server.
304 -- The initialize requests and responses are not included the session,
305 -- so if you need to test it use this.
306 initializeResponse :: Session InitializeResponse
307 initializeResponse = initRsp <$> ask >>= (liftIO . readMVar)
308
309 -- | Opens a text document and sends a notification to the client.
310 openDoc :: FilePath -> String -> Session TextDocumentIdentifier
311 openDoc file languageId = do
312   context <- ask
313   let fp = rootDir context </> file
314   contents <- liftIO $ T.readFile fp
315   openDoc' file languageId contents
316
317 -- | This is a variant of `openDoc` that takes the file content as an argument.
318 openDoc' :: FilePath -> String -> T.Text -> Session TextDocumentIdentifier
319 openDoc' file languageId contents = do
320   context <- ask
321   let fp = rootDir context </> file
322       uri = filePathToUri fp
323       item = TextDocumentItem uri (T.pack languageId) 0 contents
324   sendNotification TextDocumentDidOpen (DidOpenTextDocumentParams item)
325   pure $ TextDocumentIdentifier uri
326
327 -- | Closes a text document and sends a notification to the client.
328 closeDoc :: TextDocumentIdentifier -> Session ()
329 closeDoc docId = do
330   let params = DidCloseTextDocumentParams (TextDocumentIdentifier (docId ^. uri))
331   sendNotification TextDocumentDidClose params
332
333 -- | Changes a text document and sends a notification to the client
334 changeDoc :: TextDocumentIdentifier -> [TextDocumentContentChangeEvent] -> Session ()
335 changeDoc docId changes = do
336   verDoc <- getVersionedDoc docId
337   let params = DidChangeTextDocumentParams (verDoc & version . non 0 +~ 1) (List changes)
338   sendNotification TextDocumentDidChange params
339
340 -- | Gets the Uri for the file corrected to the session directory.
341 getDocUri :: FilePath -> Session Uri
342 getDocUri file = do
343   context <- ask
344   let fp = rootDir context </> file
345   return $ filePathToUri fp
346
347 -- | Waits for diagnostics to be published and returns them.
348 waitForDiagnostics :: Session [Diagnostic]
349 waitForDiagnostics = do
350   diagsNot <- skipManyTill anyMessage message :: Session PublishDiagnosticsNotification
351   let (List diags) = diagsNot ^. params . LSP.diagnostics
352   return diags
353
354 -- | The same as 'waitForDiagnostics', but will only match a specific
355 -- 'Language.Haskell.LSP.Types._source'.
356 waitForDiagnosticsSource :: String -> Session [Diagnostic]
357 waitForDiagnosticsSource src = do
358   diags <- waitForDiagnostics
359   let res = filter matches diags
360   if null res
361     then waitForDiagnosticsSource src
362     else return res
363   where
364     matches :: Diagnostic -> Bool
365     matches d = d ^. source == Just (T.pack src)
366
367 -- | Expects a 'PublishDiagnosticsNotification' and throws an
368 -- 'UnexpectedDiagnostics' exception if there are any diagnostics
369 -- returned.
370 noDiagnostics :: Session ()
371 noDiagnostics = do
372   diagsNot <- message :: Session PublishDiagnosticsNotification
373   when (diagsNot ^. params . LSP.diagnostics /= List []) $ liftIO $ throw UnexpectedDiagnostics
374
375 -- | Returns the symbols in a document.
376 getDocumentSymbols :: TextDocumentIdentifier -> Session (Either [DocumentSymbol] [SymbolInformation])
377 getDocumentSymbols doc = do
378   ResponseMessage _ rspLid mRes mErr <- request TextDocumentDocumentSymbol (DocumentSymbolParams doc) :: Session DocumentSymbolsResponse
379   maybe (return ()) (throw . UnexpectedResponseError rspLid) mErr
380   case mRes of
381     Just (DSDocumentSymbols (List xs)) -> return (Left xs)
382     Just (DSSymbolInformation (List xs)) -> return (Right xs)
383     Nothing -> Prelude.error "No result and no error in DocumentSymbolsResponse"
384
385 -- | Returns the code actions in the specified range.
386 getCodeActions :: TextDocumentIdentifier -> Range -> Session [CAResult]
387 getCodeActions doc range = do
388   ctx <- getCodeActionContext doc
389   rsp <- request TextDocumentCodeAction (CodeActionParams doc range ctx)
390
391   case rsp ^. result of
392     Just (List xs) -> return xs
393     _ -> throw (UnexpectedResponseError (rsp ^. LSP.id) (fromJust $ rsp ^. LSP.error))
394
395 -- | Returns all the code actions in a document by
396 -- querying the code actions at each of the current
397 -- diagnostics' positions.
398 getAllCodeActions :: TextDocumentIdentifier -> Session [CAResult]
399 getAllCodeActions doc = do
400   ctx <- getCodeActionContext doc
401
402   foldM (go ctx) [] =<< getCurrentDiagnostics doc
403
404   where
405     go :: CodeActionContext -> [CAResult] -> Diagnostic -> Session [CAResult]
406     go ctx acc diag = do
407       ResponseMessage _ rspLid mRes mErr <- request TextDocumentCodeAction (CodeActionParams doc (diag ^. range) ctx)
408
409       case mErr of
410         Just e -> throw (UnexpectedResponseError rspLid e)
411         Nothing ->
412           let Just (List cmdOrCAs) = mRes
413             in return (acc ++ cmdOrCAs)
414
415 getCodeActionContext :: TextDocumentIdentifier -> Session CodeActionContext
416 getCodeActionContext doc = do
417   curDiags <- getCurrentDiagnostics doc
418   return $ CodeActionContext (List curDiags) Nothing
419
420 -- | Returns the current diagnostics that have been sent to the client.
421 -- Note that this does not wait for more to come in.
422 getCurrentDiagnostics :: TextDocumentIdentifier -> Session [Diagnostic]
423 getCurrentDiagnostics doc = fromMaybe [] . Map.lookup (doc ^. uri) . curDiagnostics <$> get
424
425 -- | Executes a command.
426 executeCommand :: Command -> Session ()
427 executeCommand cmd = do
428   let args = decode $ encode $ fromJust $ cmd ^. arguments
429       execParams = ExecuteCommandParams (cmd ^. command) args
430   request_ WorkspaceExecuteCommand execParams
431
432 -- | Executes a code action.
433 -- Matching with the specification, if a code action
434 -- contains both an edit and a command, the edit will
435 -- be applied first.
436 executeCodeAction :: CodeAction -> Session ()
437 executeCodeAction action = do
438   maybe (return ()) handleEdit $ action ^. edit
439   maybe (return ()) executeCommand $ action ^. command
440
441   where handleEdit :: WorkspaceEdit -> Session ()
442         handleEdit e =
443           -- Its ok to pass in dummy parameters here as they aren't used
444           let req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams e)
445             in updateState (ReqApplyWorkspaceEdit req)
446
447 -- | Adds the current version to the document, as tracked by the session.
448 getVersionedDoc :: TextDocumentIdentifier -> Session VersionedTextDocumentIdentifier
449 getVersionedDoc (TextDocumentIdentifier uri) = do
450   fs <- vfs <$> get
451   let ver =
452         case fs Map.!? uri of
453           Just (VirtualFile v _ _) -> Just v
454           _ -> Nothing
455   return (VersionedTextDocumentIdentifier uri ver)
456
457 -- | Applys an edit to the document and returns the updated document version.
458 applyEdit :: TextDocumentIdentifier -> TextEdit -> Session VersionedTextDocumentIdentifier
459 applyEdit doc edit = do
460
461   verDoc <- getVersionedDoc doc
462
463   caps <- asks sessionCapabilities
464
465   let supportsDocChanges = fromMaybe False $ do
466         let mWorkspace = C._workspace caps
467         C.WorkspaceClientCapabilities _ mEdit _ _ _ _ _ _ <- mWorkspace
468         C.WorkspaceEditClientCapabilities mDocChanges <- mEdit
469         mDocChanges
470
471   let wEdit = if supportsDocChanges
472       then
473         let docEdit = TextDocumentEdit verDoc (List [edit])
474         in WorkspaceEdit Nothing (Just (List [docEdit]))
475       else
476         let changes = HashMap.singleton (doc ^. uri) (List [edit])
477         in WorkspaceEdit (Just changes) Nothing
478
479   let req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams wEdit)
480   updateState (ReqApplyWorkspaceEdit req)
481
482   -- version may have changed
483   getVersionedDoc doc
484
485 -- | Returns the completions for the position in the document.
486 getCompletions :: TextDocumentIdentifier -> Position -> Session [CompletionItem]
487 getCompletions doc pos = do
488   rsp <- request TextDocumentCompletion (TextDocumentPositionParams doc pos)
489
490   case getResponseResult rsp of
491     Completions (List items) -> return items
492     CompletionList (CompletionListType _ (List items)) -> return items
493
494 -- | Returns the references for the position in the document.
495 getReferences :: TextDocumentIdentifier -- ^ The document to lookup in.
496               -> Position -- ^ The position to lookup.
497               -> Bool -- ^ Whether to include declarations as references.
498               -> Session [Location] -- ^ The locations of the references.
499 getReferences doc pos inclDecl =
500   let ctx = ReferenceContext inclDecl
501       params = ReferenceParams doc pos ctx
502   in getResponseResult <$> request TextDocumentReferences params
503
504 -- | Returns the definition(s) for the term at the specified position.
505 getDefinitions :: TextDocumentIdentifier -- ^ The document the term is in.
506                -> Position -- ^ The position the term is at.
507                -> Session [Location] -- ^ The location(s) of the definitions
508 getDefinitions doc pos =
509   let params = TextDocumentPositionParams doc pos
510   in getResponseResult <$> request TextDocumentDefinition params
511
512 -- | Returns the type definition(s) for the term at the specified position.
513 getTypeDefinitions :: TextDocumentIdentifier -- ^ The document the term is in.
514                -> Position -- ^ The position the term is at.
515                -> Session [Location] -- ^ The location(s) of the definitions
516 getTypeDefinitions doc pos =
517   let params = TextDocumentPositionParams doc pos
518   in getResponseResult <$> request TextDocumentTypeDefinition params
519
520 -- | Renames the term at the specified position.
521 rename :: TextDocumentIdentifier -> Position -> String -> Session ()
522 rename doc pos newName = do
523   let params = RenameParams doc pos (T.pack newName)
524   rsp <- request TextDocumentRename params :: Session RenameResponse
525   let wEdit = getResponseResult rsp
526       req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams wEdit)
527   updateState (ReqApplyWorkspaceEdit req)
528
529 -- | Returns the hover information at the specified position.
530 getHover :: TextDocumentIdentifier -> Position -> Session (Maybe Hover)
531 getHover doc pos =
532   let params = TextDocumentPositionParams doc pos
533   in getResponseResult <$> request TextDocumentHover params
534
535 -- | Returns the highlighted occurences of the term at the specified position
536 getHighlights :: TextDocumentIdentifier -> Position -> Session [DocumentHighlight]
537 getHighlights doc pos =
538   let params = TextDocumentPositionParams doc pos
539   in getResponseResult <$> request TextDocumentDocumentHighlight params
540
541 -- | Checks the response for errors and throws an exception if needed.
542 -- Returns the result if successful.
543 getResponseResult :: ResponseMessage a -> a
544 getResponseResult rsp = fromMaybe exc (rsp ^. result)
545   where exc = throw $ UnexpectedResponseError (rsp ^. LSP.id)
546                                               (fromJust $ rsp ^. LSP.error)
547
548 -- | Applies formatting to the specified document.
549 formatDoc :: TextDocumentIdentifier -> FormattingOptions -> Session ()
550 formatDoc doc opts = do
551   let params = DocumentFormattingParams doc opts
552   edits <- getResponseResult <$> request TextDocumentFormatting params
553   applyTextEdits doc edits
554
555 -- | Applies formatting to the specified range in a document.
556 formatRange :: TextDocumentIdentifier -> FormattingOptions -> Range -> Session ()
557 formatRange doc opts range = do
558   let params = DocumentRangeFormattingParams doc range opts
559   edits <- getResponseResult <$> request TextDocumentRangeFormatting params
560   applyTextEdits doc edits
561
562 applyTextEdits :: TextDocumentIdentifier -> List TextEdit -> Session ()
563 applyTextEdits doc edits =
564   let wEdit = WorkspaceEdit (Just (HashMap.singleton (doc ^. uri) edits)) Nothing
565       req = RequestMessage "" (IdInt 0) WorkspaceApplyEdit (ApplyWorkspaceEditParams wEdit)
566   in updateState (ReqApplyWorkspaceEdit req)