Engineering

Static-ls v1.0 announcement

Written By

Joseph Sumabat

Graphic illustration of code against a grid backdrop
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook

Mercury is pleased to announce the v1.0 release of our own internal language server implementation for Haskell. The goal of static-ls is to provide a high-speed, low-memory language server for enterprise-sized projects.

The Haskell Language Server (HLS) is currently the de facto language server for Haskell projects, but it is effectively unusable on Mercury's large monolithic backend codebase. This is largely because HLS manages an entire compilation session, whereas static-ls offloads this responsibility to other programs, allowing it to run with far fewer resources. Some key differences between HLS and static-ls are:

  • Static-ls can load instantly even on the largest Haskell projects — the size of the project minimally impacts performance, even at over 10,000 modules and 1.2 million lines of code.
  • There is more required initial setup to get static-ls working at both the project- and user-level. It is best used with another application to manage generation of aforementioned static-sources — we recommend using ghciwatch with our hiedb ghc plugin.
  • Static-ls is not as feature- complete as HLS but the provided features have lower latency

Compared to its first inception/alpha release, we’ve added a number of new features, re-architected the codebase, and improved the user experience and stability.

For how to setup your project, see the quick start guide in the project’s readme.

Why a New Language Server?

Our previous announcement for ghciwatch goes into the rationale behind the limitations of the Haskell Language Server for our mono-repo codebase:

While HLS works great for smaller projects, the Mercury backend monolith consists of about 10,000 modules containing about 1.2M lines of Haskell code. For projects that large, HLS’s performance starts to break down — on my 2022 Mac Studio (M1 Ultra, 20 cores, 64GB memory), HLS takes a full 32 minutes to load the project. After it’s loaded everything, making changes to project files will start reloads that take at least 1.5 minutes (changes to modules nested deeper in the dependency graph can take as long as 5 or 10 minutes to reload, if HLS is able to reload at all). HLS is also very memory intensive, consuming 50GB of RAM after loading the project and climbing from there.

Static-ls loads on our codebase practically instantly and is instead bounded by the time it takes for ghciwatch to reload and provide the information it reads. This tends to be very fast:

In contrast, ghciwatch takes 2.7 minutes to load the entire project and about 7 seconds to reload. With Cabal’s --repl-no-load option, modules are only loaded when they’re changed: as a result, ghciwatch can start up in about 6 seconds. The first change to a module will take much longer, depending on how many modules need to be loaded (between one second and two minutes), but subsequent reloads take between 0.2 and 7 seconds.

We chose a re-implementation over forking HLS due to the fact that most of HLS’s functionality is directly tied to recompilation and GHC. Many of HLS’s underlying functions rely directly on observing GHC’s internals through HscEnv, the complete representation of the compilation pipeline. This makes it difficult to decouple language intelligence functionality from a full compiler session.

In comparison, static-ls observes information on disk to answer language intelligence requests. Haskell code is parsed with tree-sitter and lexed with haskell-lexer to form a syntactic representation. GHC generates .hie files with semantic information which are indexed with hiedb. The syntactic location in the current source code is then mapped to the corresponding semantic information to provide LSP functionality. Static-ls is intended to be resilient and make a “best effort” with information that is available on disk. If certain modules do not have up- to- date semantic information static-ls can work with existing source code or stale information to provide more limited functionality.

The original inspiration for static-ls comes from halfsp which directly queried hie files and hiedb for LSP functionality such as type information on hover, go to definition, and find references. Compared to halfsp, static-ls adds support for many more LSP methods, the ability to handle “stale” hie files, and some stability improvements.

Features

Static-ls supports most typical LSP functionality. Features such as go to definition, type on hover, find references, import code actions, and more. Below are some videos of static-ls in use:

Type on hover

Type on hover

Find references

Find references

Go to definition

Go to definition

Static-ls also supports more advanced functionality such as “fly imports” inspired by rust-analyzer allowing the user to import a qualified name via autocomplete and certain code actions.

Gif showing "fly imports" inspired by rust-analyzer

Rename functionality across thousands of symbols

Rename functionality across thousands of symbols

Next Steps and Acknowledgements

In the short term we plan to continue iterating with a focus on adding support for more LSP methods and code actions to bring us closer to feature parity with HLS. We also want to further improve stability, particularly across different versions of GHC.

We have a number of plans for the future, but the main thing we want to focus on is integrating with buck2 in order to generate semantic information.

Finally we would like to acknowledge the amazing work that has been done by the GHC and Haskell Language Server teams particularly around ghcide, .hiefiles, and hiedb that has made all of this possible.

Notes
Written by

Joseph Sumabat

Share
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook