You know Haskell. You know type theory. You know formal methods. You have the best ideas to transform the way people write and understand code. All you need is a simple setup to test your ideas. This post provides exactly that.
Recently, I tried to improve on local code navigation experience. The idea and the algorithm were all well and clear but I didn’t know the first thing about integrating my ideas within an IDE. I knew LSP is the modern standard for it, so it was high time I learnt how to set it up with Haskell.
I distilled a basic setup in this repo, so that you can experiment with your developer productivity ideas right away. I implemented some toy hover functionality for demonstration purposes:
The rest of this post reviews the basics of LSP (skip if you’re already comfortable with it) and discuss the setup so that you can adapt it to your needs.
LSP Overview
LSP is to editors, what LLVM is to compilers. It provides a common ground so that language services can remain agnostic to IDEs and IDEs can remain agnostic to language services. In short, it realises the following diagram:
VSCode +---> +-----+ +---> Haskell services implemented in Haskell
| |
Vim +---> | LSP | +---> OCaml services implemented in TypeScript
| |
Emacs +---> +-----+ +---> Java services implemented in C
The protocol is exhaustively documented here and this starter guide is helpful.
LSP is implemented with a client-server architecture. Each IDE needs a separate client, so clients are best kept thin. The server does the heavy lifting of providing language services and is shared across IDEs.
The client is there to tell the IDE under which conditions, the language services should be in effect (e.g., when the filetype is set to a particular language) and to provide interactions & UI elements (e.g., actions through keyboard shortcuts, commands in a dropdown menu, configurations for services).
The server implements the analysis needed to provide language services, e.g., hovering on a particular element, going to definition or references, and executing bespoke commands.
Clients don’t need to know which language services are provided by servers in advance. For example, when a server declares and implements hover functionality, the IDE picks up on it without the client implementation manifesting hovering. This is neat as you don’t have to update every client every time you add some common functionality.
The setup
I placed the client and server in the same directory for convenience. The overall file structure is as follows:
lsp-lab
├── README.md
├── client
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json -- Extension manifest
│ ├── src
│ │ └── extension.ts -- Extension entry
│ └── tsconfig.json
└── server
├── LICENSE
├── README.md
├── Setup.hs
├── app
│ └── Main.hs -- LSP server entry
├── lsp-lab-server.cabal
├── package.yaml
├── src
│ └── Lib.hs
├── stack.yaml
└── stack.yaml.lock
The client source files are bootstrapped with yo code
and the Haskell files
are bootstrapped with stack new
.
Among these files, we discuss three that contain most of the functionality for the extension.
client/package.json
: declares which UI/UX elements the extension contributes and the event to activate the extension.client/src/extension.ts
: entry point to the extension. It finds and connects to the LSP server.server/app/Main.hs
: entry point to the hover functionality. It interprets the arguments passed through LSP messages, computes what to show on hover, and sends back a response to the client.
package.json
This is the manifest of the extension akin to a .cabal
or package.yaml
file
in Haskell projects. It holds metadata and declares dependencies, but also
determines when the extension should be in effect, and what it contributes to
the IDE, (e.g., a custom command the user can invoke, a configuration box, a
language the IDE should list). See the wide range of things an extension can
contribute.
In our case, we contribute a brand new language with the name LSPLab
and a
file extension .lsp-lab
. This causes files with the given extension to be
recognised as LSPLab
files. When the file doesn’t end with .lsp-lab
, a user
can still select the language from a dropdown menu that includes LSPLab
.
"contributes": {
"languages": [
{
"id": "LSPLab",
"extensions": [
".lsp-lab"
]
}
]
}
Next, we declare when to activate the extension (e.g., when a particular language is set for the open file, when VSCode starts up). See the full list of activation events.
We want our extension to be active when the language is set to or detected as
LSPLab
.
"activationEvents": [
"onLanguage:LSPLab"
]
extension.ts
This file is the entry point to the extension. We need to define activate
and
deactivate
functions. In the case of a language server, these should handle
LSP-server connections.
At a high level, we define ServerOptions
& LanguageClientOptions
, create a
new LanguageClient
object, and start it. We use the vscode-languageclient
library (having declared it in package.json
and run npm install
).
const serverOptions: ServerOptions = {
: {
run: execPath,
command: TransportKind.stdio,
transport: [],
args,
}// Same but for the debug key
;
}
const clientOptions: LanguageClientOptions = {
: [{ scheme: 'file', language: 'LSPLab' }],
documentSelector;
}
= new LanguageClient(
client 'lsp-lab-client',
'LSP Laboratory',
,
serverOptions
clientOptions;
)
.start(); client
To activate the extension, we tell the client how to find and connect to the LSP server. There are a number of ways of accomplishing this task. One can
- execute a shell command to start the server and communicate;
- do IPC calls (if the server is implemented in TypeScript or JavaScript);
- or communicate through a socket.
Above in serverOptions
, we stick to executing a shell command. To do so, the
client must locate the binary for the server. A common solution is for the
extension to contribute a configuration box to specify where the binary is. I
don’t like that solution for experimentation because my Haskell binaries are
deep inside .stack-works
and include version numbers in the path that are
subject to change. Instead, I exploit the directory structure and search for
the binary:
= execSync(
stdout `find ${__dirname}/../../server -iname lsp-lab-server-exe -type file | tail -n1`
;
)= path.normalize(stdout.toString('utf-8').replace(/\n+$/, '')); execPath
Needless to say this is not a setup that you can deploy, but it is convenient for experimentation.
Finally, to deactivate, if there’s a client running, we stop it:
if (client) {
.stop();
client }
Main.hs
We covered everything on the client-side, time to look at the server. The lsp
Haskell library makes it easy and type-safe to create LSP servers.
When the server binary runs it just starts the server according to the given definition and waits for LSP messages.
main :: IO ()
= do
main <- runServer serverDef
exitCode $ ExitFailure exitCode exitWith
The top-level server definition has the simplest values one could provide for
each field (which I copied and pasted from this lsp
library
example)
except for staticHandlers
:
serverDef :: ServerDefinition ()
= ServerDefinition
serverDef = const $ const $ Right ()
{ onConfigurationChange = ()
, defaultConfig = \env _req -> pure $ Right env
, doInitialize = handlers
, staticHandlers = \env -> Iso (runLspT env) liftIO
, interpretHandler = defaultOptions
, options }
The staticHandlers
are set to handlers
which explain how to respond to LSP
requests from clients. Of particular interest to us, we respond to hover queries:
SMethod_TextDocumentHover
requestHandler $ \req responder -> do
let TRequestMessage
_
_
_HoverParams (TextDocumentIdentifier uri) pos _workDone) = req
(let path = fromJust $ uriToFilePath uri
<- liftIO $ TIO.readFile path
contents let Position line col = pos
case findWordAndBoundaries
contentsfromIntegral line)
(fromIntegral col) of
(Just (word, leftCol, rightCol) -> do
let ms = mkMarkdown
$ "length('"
<> word
<> "') = "
<> fromString (show (T.length word))
let range = Range
Position line $ fromIntegral leftCol)
(Position line $ fromIntegral rightCol)
(let rsp = Hover (InL ms) (Just range)
Right $ InL rsp)
responder (Nothing -> responder (Right $ InR Null)]
The request handler for a particular LSP method (in this case
textDocument/hover
)
is a lambda with a parameter req
that gives us the request arguments and a
callback responder
to send back our response.
In this case, the parameters are an identifier for the file, a position in that file, and a parameter to report on progress (which is a cool piece of UX consideration that we ignore here 😛).
The implementation uses the lsp
library to convert the uri
into a path
Haskell understands, reads that file at the path, identify the boundaries of
the word, measure its length, and compose a markdown response to be shown on
text hover.
The implementation itself is not particularly exciting and is omitted, but here
are few points common to all lsp
users:
- positions are zero-based;
- the constructor names are faithful to the spec which makes it easier to search for things;
- there are plenty uses of
InL
andInR
constructors for the type constructor|?
because the specification defines possible inputs and outputs in any given position through unnamed unions. These two constructors handle those uniformly.
Wrap up
We explored a simple setup to experiment with IDE features in VSCode using the
Haskell lsp
library. We discussed the interesting parts of the setup to
facilitate customisation so that you can realise more exciting ideas.
I hope this saves you a couple of hours when you’re getting started.