How to Build a Language Server with Go

In my last article, we learned how the Language Server Protocol (LSP) standardizes the communication between code editors and language servers.

This has revolutionized how developers write code, offering features like auto-completion, syntax highlighting, and real-time diagnostics.

Building on that foundation, this article focuses on a more practical aspect – Building a language server using Go.

But why Go?

Because it's simple and I like it.

But you can pretty much use any language you want, as LSP is language agnostic.

The example I'll use today is an emoji LSP, which will map words to emojis.

So, if you write the word "happy," it will give you an option to select 😀.

💡
You can either follow along or get the full code example here.

Let's build an emoji language server

Setting Up the Project

Let's start the project by creating a directory and initializing our go module.

mkdir emoji-lsp
cd emoji-lsp/
go mod init emoji-lsp

While we are here, let's create our main file:

touch main.go

Setting up the Language Server

If we check out the LSP specifications, we have first to set up our minimal mandatory lifecycle methods, which are:

  • Initialize – This is called by the client to initialize the language server. It details the client's capabilities and configuration, and the language server responds with its capabilities.
  • Shutdown – This is called by the client to request the shutdown of the language server. The server should stop processing requests after receiving this call but not close the connection.

We also don't really have to set everything up ourselves. We can use GLSP, which is an LSP SDK for Golang.

As per the documentation, GLSP contains:

  1. All the message structures for easy serialization.
  2. A handler for all client methods.
  3. A ready-to-run JSON-RPC 2.0 server supporting stdio, TCP, WebSockets, and Node.js IPC.

All we have to do then is provide the features for our language server.

Let's now use GLSP to initialize our language server.

Add the following code to your main.go file:

package main

import (
	"github.com/tliron/commonlog"
	"github.com/tliron/glsp"
	protocol "github.com/tliron/glsp/protocol_3_16"
	"github.com/tliron/glsp/server"

	_ "github.com/tliron/commonlog/simple"
)

const lsName = "Emoji Autocomplete Language Server"

var version string = "0.0.1"
var handler protocol.Handler

func main() {
	commonlog.Configure(2, nil)

	handler = protocol.Handler{
		Initialize:             initialize,
		Shutdown:               shutdown,
	}

	server := server.NewServer(&handler, lsName, true)

	server.RunStdio()
}

func initialize(context *glsp.Context, params *protocol.InitializeParams) (any, error) {
	commonlog.NewInfoMessage(0, "Initializing server...")

	capabilities := handler.CreateServerCapabilities()

	capabilities.CompletionProvider = &protocol.CompletionOptions{}

	return protocol.InitializeResult{
		Capabilities: capabilities,
		ServerInfo: &protocol.InitializeResultServerInfo{
			Name:    lsName,
			Version: &version,
		},
	}, nil
}

func shutdown(context *glsp.Context) error {
	return nil
}

As you can see, GLSP gives us a handler struct to map our code to the LSP specification methods.

We currently only map the Initialize and Shutdown methods. But we will add additional functionality to it in the following section.

Finally, run go mod tidy to install the missing packages.

Adding Emoji Text Completion

Let's first create a simple map between our words and emojis.

Create the following file:

mkdir mappers 
touch mappers/emojis.go

Let's add the following code to emojis.go:

package mappers 

var EmojiMapper = map[string]string{ 
  "happy": "😀",
  "sad": "😢",
  "angry": "😠",
  "confused": "😕",
  "excited": "😆",
  "love": "😍",
  "laughing": "😂",
  "crying": "😭",
  "sleepy": "😴",
  "surprised": "😮",
  "sick": "🤒",
  "cool": "😎",
  "nerd": "🤓",
  "worried": "😟",
  "scared": "😨",
  "silly": "🤪",
  "shocked": "😱",
  "sunglasses": "😎",
  "tongue": "😛",
  "thinking": "🤔",
}

Now that we have that, we can implement the method textDocument/completion.

Create the following file:

mkdir handlers
touch handlers/language-features.go

Let's add the following code to langauge-features.go:

package handlers

import (
	"emoji-lsp/mappers"
	"github.com/tliron/glsp"
	protocol "github.com/tliron/glsp/protocol_3_16"

	_ "github.com/tliron/commonlog/simple"
)

func TextDocumentCompletion(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) {
	var completionItems []protocol.CompletionItem

	for word, emoji := range mappers.EmojiMapper {
		emojiCopy := emoji // Create a copy of emoji
		completionItems = append(completionItems, protocol.CompletionItem{
			Label:  word,
            Detail: &emojiCopy,
			InsertText: &emojiCopy,
		})
	}

	return completionItems, nil
}

Every method has its own parameters, and thankfully, GLSP has the types ready. The textDocument/completion method needs to return a list of CompletionItems, and we add our emojis over here.

The Label field is the field that will tell our client what to look for.

So, for example, if I type in ha, it will show me happy, or if I type ang, it will show me angry. It will then put in the emoji, as per the InsertText field.

Now that our function is ready. Let's add it to our handler. Update the main method in the main.go file:

func main() {
	commonlog.Configure(2, nil)

	handler = protocol.Handler{
		Initialize:             initialize,
		Shutdown:               shutdown,
        TextDocumentCompletion: handlers.TextDocumentCompletion,
	}

	server := server.NewServer(&handler, lsName, true)

	server.RunStdio()
}

Let's test our language server

The first thing we have to do is to build our application.

To do that, run the following command:

go build -o bin/emoji-lsp

This will build an executable version of our application inside the bin folder.

The only thing left to do is to tell our language client to use our language server.

For this demonstration, I'll be using Neovim.

I'll first the following file:

touch nvim.lua

I'll use the native LSP methods provided by Neovim to register my LSP. Add the following code to nvim.lua:

vim.lsp.start({
	name = "emoji-lsp",
	cmd = { "./bin/emoji-lsp" },
	root_dir = vim.fn.getcwd(),
})

I source the file by running the following command in Neovim:

:source nvim.lua

If I run the command, :LspInfo I should see my emoji-lsp as one of my clients.

If I type in ha, I should see an option for happy, which maps to the 😀 emoji.

💡
If you want to test this in VSCode, refer to this tutorial.

Next Steps

Congratulations, you've just made your first simple language server.

However, the journey doesn't stop here. You've only just implemented a single method from the LSP specification.

I would challenge you to extend the language server with the following capabilities:

  • Syntax Highlighting and Formatting: Dive deeper into implementing advanced syntax highlighting and code formatting rules specific to the language your server supports.
  • Code Actions and Refactorings: Implement code actions that can suggest fixes or refactorings based on the diagnostics provided by your server.
  • Performance Optimization: Explore ways to optimize the performance of your language server for larger projects, such as implementing incremental synchronization or parallel processing of requests.