A fast, spec-faithful implementation of the Language Server Protocol 3.18 for Go — types generated from the official LSP meta-model, a union-aware JSON codec that skips reflection on the hot path, and a ready-to-wire client/server RPC layer.
import "go.lsp.dev/protocol"- Generated from
metaModel.json. Every request, notification, and structure is generated from the official LSP meta-model, so the type surface tracks the specification rather than a hand-maintained subset. The generator lives ininternal/genlsp. - Sealed-interface unions. Protocol
"or"types are modeled as sealed Go interfaces — each arm is a distinct concrete type implementing a private marker method. Callers discriminate arms with an ordinary type switch, and the compiler keeps the arm set closed. - Reflection-free codec.
MarshalandUnmarshaldispatch through generated byte-level encoders and decoders; struct values, named slice wrappers, and union values all (de)serialize without enteringreflect.AppendMarshallets callers amortize the output allocation across messages. - Batteries-included RPC.
NewServerandNewClientwire the union-aware codec onto ago.lsp.dev/jsonrpc2connection and return typedServer/Clientdispatchers.
go get go.lsp.dev/protocol@latestRequires Go 1.26+.
Implement the methods you care about by embedding UnimplementedServer, then let
NewServer serve it over any jsonrpc2.Stream (here, an in-memory pipe):
package main
import (
"context"
"go.lsp.dev/jsonrpc2"
"go.lsp.dev/protocol"
)
// langServer overrides only the methods it implements; UnimplementedServer
// supplies a "method not found" default for the rest of the LSP surface.
type langServer struct {
protocol.UnimplementedServer
}
func (langServer) Hover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
return &protocol.Hover{
Contents: protocol.String("hello from the server"),
}, nil
}
func serve(ctx context.Context, stream jsonrpc2.Stream) (jsonrpc2.Conn, protocol.Client) {
// NewServer serves langServer and returns the connection plus a typed
// Client dispatcher for server -> client requests.
ctx, conn, client := protocol.NewServer(ctx, langServer{}, stream)
_ = ctx
return conn, client
}The mirror-image helper NewClient
serves a Client and hands back a typed Server dispatcher, so the same model
drives both ends of the connection.
Where the LSP says a value is A | B, this package exposes a sealed interface.
Construct a value with the concrete arm and read it back with a type switch:
// Hover.Contents (HoverContents) is one of:
// String | *MarkupContent | *MarkedStringWithLanguage | MarkedStringSlice
hover := &protocol.Hover{Contents: protocol.String("plain text")}
switch c := hover.Contents.(type) {
case protocol.String:
// a bare string arm
case *protocol.MarkupContent:
// structured markup
default:
_ = c
}Because the arm set is closed by an unexported marker method, no value outside the package can satisfy the interface — the type switch is exhaustive by construction.
Generated byte-walkers make one GC-managed copy of the input before decoding it. To avoid per-field allocations on hot paths, unescaped decoded strings and raw JSON value fields may alias that single owned copy. Two consequences:
- After
Unmarshal(or the LSP codec) returns, you may freely reuse or mutate the original[]byte— the decoded value no longer references it. - Retaining a small decoded string or raw value can keep the entire owned
message copy alive. When a decoded value must outlive a much larger input
message, detach it first with
Clone.
Generated URI fields use go.lsp.dev/uri.URI directly. Construct new
values with uri.Parse, uri.File, or uri.From.
protocol.URI remains as a package-local named type for compatibility and for
the sealed union arms (such as RelativePatternBaseURI) that require a local
marker-method receiver. Prefer go.lsp.dev/uri.URI in ordinary code; convert
explicitly with protocol.URI(u) only when assigning a URI string to such an
arm.
The codec is the project's hot path, and it is benchmarked and gated in CI. A
relative-regression gate (.github/workflows/bench.yaml) benchmarks the base
ref and the PR head on the same runner and fails on a >5% sec/op geomean
regression. The figures below are from the Phase-3 authoritative re-baseline
(Intel Xeon 8481C, linux/amd64), comparing the optimized codec against the
reflection-based baseline:
| Benchmark | sec/op change |
|---|---|
Encode/initialize_request |
−77.93% |
Encode/publish_diagnostics |
−15.98% |
Decode/semantic_tokens |
−27.92% |
Decode/completion_array |
−23.80% |
Decode/completion_list |
−13.24% |
Run them yourself:
go test -run='^$' -bench='^(BenchmarkDecode|BenchmarkEncode)$' -benchmem -count=6 .make test # run the test suite with race detection
make coverage # run tests and emit coverage
make lint # goimports-rereviser + gofumpt + golangci-lint
make generate # regenerate the package from metaModel.json, then format
make help # list all targetsGenerated files carry a .gen.go suffix; edit the generator under
internal/genlsp and run make generate rather than
hand-editing them.
| Module | Role |
|---|---|
go.lsp.dev/jsonrpc2 |
JSON-RPC 2.0 transport for the RPC layer |
go.lsp.dev/uri |
RFC 3986 / file:// URI handling |
go-json-experiment/json |
JSON engine (the github.com/go-json-experiment/json prototype) |
BSD 3-Clause. See LICENSE.