2024-04-25

Making an LSP server in Go

Resources and documentation about implementing an LSP server in Go are surprisingly sparse, so I decided to write my (first!) blog entry about it, after having to go through the source code of some random dependent on go.lsp.dev/protocol to figure out how to implement a simple LSP server.

The pieces

We’ll be using the go.lsp.dev collection of modules to implement our LSP server. These modules include:

The boilerplate

You’ll most likely want to split up your code into at least two files:

server.go

Mostly boilerplate:

package yourlsp

import (
	"context"
	"io"
	"os"
	"path/filepath"

	"go.lsp.dev/jsonrpc2"
	"go.lsp.dev/protocol"
	"go.uber.org/multierr"
	"go.uber.org/zap"
)



// StartServer starts the language server.
// It reads from stdin and writes to stdout.
func StartServer(logger *zap.Logger) {
	conn := jsonrpc2.NewConn(jsonrpc2.NewStream(&readWriteCloser{
		reader: os.Stdin,
		writer: os.Stdout,
	}))

	handler, ctx, err := NewHandler(
		context.Background(),
		protocol.ServerDispatcher(conn, logger),
		logger,
	)

	if err != nil {
		logger.Sugar().Fatalf("while initializing handler: %w", err)
	}

	conn.Go(ctx, protocol.ServerHandler(
		handler, jsonrpc2.MethodNotFoundHandler,
	))
	<-conn.Done()
}

type readWriteCloser struct {
	reader io.ReadCloser
	writer io.WriteCloser
}

func (r *readWriteCloser) Read(b []byte) (int, error) {
	n, err := r.reader.Read(b)
	return n, err
}

func (r *readWriteCloser) Write(b []byte) (int, error) {
	return r.writer.Write(b)
}

func (r *readWriteCloser) Close() error {
	return multierr.Append(r.reader.Close(), r.writer.Close())
}


cmd/main.go

package main

import (
	"go.uber.org/zap"
	"yourlsp"
)

func main() {
	logger, _ := zap.NewDevelopmentConfig().Build()

	// Start the server
	yourlsp.StartServer(logger)
}

handlers.go

That’s were we get to the interesting stuff.

You first define your own Handler struct that just embeds the protocol.Server struct, so that you can implement all the methods.

package yourlsp

import (
	"context"
	"go.lsp.dev/protocol"
	"go.lsp.dev/uri"
	"go.uber.org/zap"
)

var log *zap.Logger

type Handler struct {
	protocol.Server
}

func NewHandler(ctx context.Context, server protocol.Server, logger *zap.Logger) (Handler, context.Context, error) {
	log = logger
	// Do initialization logic here, including
	// stuff like setting state variables
	// by returning a new context with
	// context.WithValue(context, ...)
	// instead of just context
	return Handler{Server: server}, context, nil
}

Implementing stuff

Actually implementing functionnality for your LSP consists in defining a method on your handler struct that has a particular name, as defined in theprotocol.Server interface

Telling the clients what feature we support: the Initialize method

All LSP servers must implement the Initialize method, that returns information on the LSP server, and most importantly, the features it supports.

The signature of the Initialize method is:

Initialize(ctx context.Context, params *InitializeParams) (result *InitializeResult, err error)

Autocomplete and hover (features of Go’s LSP server, how meta!) will help you a lot when implementing this:

You’ll notice that most of the capabilities receive an interface{}, that’s not very helpful.

This usually means that you can either pass true to say that you support the feature fully, or a struct of options for more intricate support information.

Check with the spec to be sure.

Example

Here’s an example method implementation that signals support for the Go to Definition feature:

func (h Handler) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {
	return &protocol.InitializeResult{
		Capabilities: protocol.ServerCapabilities{
			DefinitionProvider: true, // <-- right there
		},
		ServerInfo: &protocol.ServerInfo{
			Name:    "yourls",
			Version: "0.1.0",
		},
	}, nil
}

Implementing a feature: the Definition example

As with Initialize, hovering over the types of the parameters will help you greatly.

// IMPORTANT: You _can't_ take a pointer to your handler struct as the receiver, 
// your handler will no longer implement protocol.Server if you do that.
func (h Handler) Definition(ctx context.Context, params *protocol.DefinitionParams) ([]protocol.Location, error) {
	// ... do your processing ...
	return []protocol.Location{
		{
			URI: uri.File(...),
			Range: protocol.Range{
				Start: protocol.Position{
					Line:      0,
					Character: 0,
				},
				End: protocol.Position{
					Line:      0,
					Character: 0,
				},
			},
		},
	}, nil
}

Using in IDEs & editors

Neovim

You can put the following in your init.lua:

vim.api.nvim_create_autocmd({'BufEnter', 'BufWinEnter'}, {
	pattern = { "glob pattern of the files you want your LSP to be used on" },
	callback = function(event)
		vim.lsp.start {
			name = "My language",
			cmd = {"mylsp"},
		}
	end
})

Visual Studio Code

VSCode requires writing an entire extension to use an LSP server…

If you want something quick ’n’ dirty, you can use some generic LSP client extension (for example, llllvvuu’s Generic LSP Client).

But to do a proper extension that you can distribute to your user’s, you’ll want to follow the vscode docs on LSP extension development.

The guide assumes that you’ll develop the LSP server in NodeJS too, but you can easily rm -rf the hell out of the server/ directory from their template repository.

I’m using the following architecture for ortfo’s LSP server:

handler.go
server.go
cmd/
	main.go
vscode/
	package.json # contains values from both client's package.json and the root package.json
	src/ # from client/
	tsconfig.json
	...

(curious? see the repository at ortfo/languageserver)

Related works

HyprLS Apr 2024
  • language
  • programming

whoami?

I'm interested in almost anything that is both creative and digital.
Learn more about me