Posted on September 30, 2023 by Mistral Contrastin

Haskell LSP Laboratory

In which we set up the simplest VSCode extension in TypeScript and language server protocol (LSP) server in Haskell to experiment with IDEs.

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:

VSCode hover showing word length

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.

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: {
    command: execPath,
    transport: TransportKind.stdio,
    args: [],
  },
  // Same but for the debug key
};

const clientOptions: LanguageClientOptions = {
  documentSelector: [{ scheme: 'file', language: 'LSPLab' }],
};

client = new LanguageClient(
  'lsp-lab-client',
  'LSP Laboratory',
  serverOptions,
  clientOptions
);

client.start();

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

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:

stdout = execSync(
  `find ${__dirname}/../../server -iname lsp-lab-server-exe -type file | tail -n1`
);
execPath = path.normalize(stdout.toString('utf-8').replace(/\n+$/, ''));

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) {
  client.stop();
}

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 ()
main = do
  exitCode <- runServer serverDef
  exitWith $ ExitFailure exitCode

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 ()
serverDef = ServerDefinition
  { onConfigurationChange = const $ const $ Right ()
  , defaultConfig = ()
  , doInitialize = \env _req -> pure $ Right env
  , staticHandlers = handlers
  , interpretHandler = \env -> Iso (runLspT env) liftIO
  , options = defaultOptions
  }

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:

requestHandler SMethod_TextDocumentHover
$ \req responder -> do
  let TRequestMessage
        _
        _
        _
        (HoverParams (TextDocumentIdentifier uri) pos _workDone) = req
  let path = fromJust $ uriToFilePath uri
  contents <- liftIO $ TIO.readFile path
  let Position line col = pos
  case findWordAndBoundaries
    contents
    (fromIntegral 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)
      responder (Right $ InL rsp)
    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:

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.