Implementing the Language Server Protocol
Tags: devops, productivity, typescript, tutorial
Published: 2022-07-06 17:32:59

## How we got here
Before the end of 2021, my work scheduled a hackathon for my organization. With the requirement being that it be useful for us or our players. I decided to attempt to develop a [language server](https://microsoft.github.io/language-server-protocol/implementors/servers/) to help engineers get their services into our production stack. 

Our stack is pretty [complex](https://technology.riotgames.com/news/running-online-services-riot-part-i) and takes a lot of learning. Although the language one interacts with is yaml, it's really a DSL and requires a lot of support. The goal of this tool was to help cut down the amount of assistance someone would need who is new / generally unfamiliar with the stack.

I found it pretty difficult to get up and running, so I wanted to share my work and progress for any other developer productivity enthusiasts!

## What is a Language Server
A language server is a standard developed by Microsoft that defines a language server and language client; which handle the language syntax parsing, linting, code hints (etc) and the actual interfacing with the developer respectively. Two language server clients are Visual Studio Code, and recently Neovim on version 0.6 (written entirely in [Lua!](https://github.com/neovim/neovim/tree/master/runtime/lua/vim/lsp)). Some language servers include `yamlls`, `sourcegraph-go` and `rust-analyzer`. The language server protocol then defines the [glue](https://microsoft.github.io/language-server-protocol/specifications/specification-current/) between these two components. The glue happens over JSON-rcp.

For our custom LSP, we'll start by forking the [yaml-language-server maintained by redhat](https://github.com/redhat-developer/yaml-language-server).


## Getting started
The first thing you'll need to do is enable the LSP for your editor of choice. I'm very deep into the vim camp, so I did neovim. This is the typical setup code needed to run the yamlls

```
local nvim_lsp = require('lspconfig')
local servers = { 
  'yamlls',  
}
for _, lsp in ipairs(servers) do
  nvim_lsp[lsp].setup {
    on_attach = on_attach,
    flags = {
      debounce_text_changes = 150,
    }
  }
end
```

Which just tells the neovim LSP to setup the client to talk to the language server. The yaml-language-server runs a binary on your local computer. The default is with the `--stdio` flag, which would accept standard input and validate: `yaml-language-server --stdio`. If you run `:LspInfo` from vim, you'll see this info

```
1 client(s) attached to this buffer: yamlls

  Client: yamlls (id 1)
  	root:      /Users/ischweer/dev/shards
  	filetypes: yaml
  	cmd:       yaml-language-server --stdio
```

There tends to be a lot of config you can set as well, you could also run the language server separately with an `--rpc-port` and `--rpc-host` option if you chose to run the language server in one window, and test it in the other. 

## A small demo of what I built

You're ready to start developing. In my case, I forked the yaml-language-server, and started adding some addition logic to fetch application specs, their configuration, and WAN setup from our central services. A service at Riot has this sort of kube-esque definition, just with more business DSL ontop

```
application-instance:
  name: some.app
  network:
    inbound:
      - name: another.app
        location: usw2
```

Pulling all these down over http is fairly straight forward. Once we have all the yamls, they are cached

```typescript
  async downloadApps() {
    // we want to go through all the apps in the env, and get
    // the yaml'd app definitions
    await this.getDiscoverous();

    // now we can get the lol-services 710e env
    const url = `https://${this.gandalf_host}/api/v1/environments/lol-services/${env.LATEST_VERSION}`;
    const resp: XHRResponse = await xhr({ url: url, headers: { authorization: `Bearer ${this.gandalf_token}` } });
    console.log('Successfully grab 710e instance');

    const cache_builder: Array<Promise<boolean>> = [];

    for (const appMetadata of environmentInstance['environment']['applications']) {
      cache_builder.push(this.downloadApp(appMetadata));
    }

    await Promise.all(cache_builder);
  }

  async downloadApp(app: { name: string; version: string }): Promise<any> {
    const url = `https://${this.gandalf_host}/api/v1/applications/${app.name}/${app.version}`;
    const resp = await xhr({ url: url, headers: { authorization: `Bearer ${this.gandalf_token}` } });
    const _d = new YAML.Document();
    _d.contents = JSON.parse(resp.responseText);

    return new Promise(() => {
      fsp
        .writeFile(`${homedir()}/.cache/gandalf/${app.name}.yaml`, _d.toString(), { flag: 'wx' })
        .then()
        .catch((err) => {
          console.log(`Did not write file because it exists already ${app.name}.yaml`);
        });
    });
  }
```

In order to fully understand this yaml parsing, you'll need to read the subsequent blog post on the riot eng blog :) however, once it's downloaded, we have now have some cached files that help us answer questions like "Is service X actually specified talk to service Y, or is it a typo?". That looks like this in the typescript

```typescript
    for (const defined_outbound of appSpec.outbounds || []) {
      let found = false;
      for (const _instanced_outbound of app_instance_outbounds.items || []) {
        const instanced_outbound = _instanced_outbound as YAMLMap;
        if (instanced_outbound.get('service') == defined_outbound) {
          found = true;
          break;
        }
      }
      if (!found) {
        errors.push({
          message: `Missing required outbound for ${defined_outbound}`,
          location: { start: app_instance_outbounds.range[0], end: app_instance_outbounds.range[2], toLineEnd: true },
          severity: 1,
          source: 'Gandalf',
          code: ErrorCode.Undefined,
        } as YAMLDocDiagnostic);
      }
    }
```

With that in place, we can see nice little errors in our editor when services are expected to talk to each other, but not specified to do so.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/04jyblpfle3vb6usrxa6.png)

## Actually  doing the work

LSP is all over RPC, and you can see all the events specified above. Everything is async, and the client needs to be able to handle json serialized text for everything. You need to learn your editors internals to fully comprehend how the LSP is working :').

For neovim, always check the lsp logs: `tail -n 10000  ~/.cache/nvim/lsp.log | bat`. They will be extremely valueable, as most times you'll have serialization errors or unknown property issues. I found adding the following "always log" approach helped when debugging (though it slows the editor down)

```typescript
console.error = (arg) => {
  if (arg === null) {
    connection.console.info(arg);
  } else {
    connection.console.error(arg);
  }
};

```

this will let you write more normal typescript code, read the resulting errors in the log, and then have a bit of a tighter feedback loop.