Building a Language Server in Go: Complete Guide to LSP 3.17 Implementation
Modern code editors like VS Code, Neovim, and others rely on Language Server Protocol (LSP) to provide intelligent features like autocomplete, error checking, and go-to-definition. Building your own language server in Go gives you full control over developer tooling for custom languages, DSLs, or enhanced support for existing ones.
Why This Matters
Language servers are the backbone of modern development environments. Instead of each editor implementing language support separately, LSP creates a standardized protocol where one server can support multiple editors. The 3.17 specification adds crucial features like inlay hints, semantic tokens, and improved diagnostics that make developer experiences significantly better.
For DevOps teams managing infrastructure-as-code, custom configuration languages, or internal DSLs, a dedicated language server can dramatically improve productivity and reduce configuration errors.
How To Build Your Language Server
Step 1: Set Up the Project Structure
mkdir my-language-server
cd my-language-server
go mod init github.com/yourorg/my-language-server
# Create basic structure
mkdir -p {cmd,internal/lsp,internal/parser,pkg/protocol}
touch cmd/main.go internal/lsp/server.go internal/parser/parser.go
Step 2: Implement JSON-RPC Transport
LSP communicates over JSON-RPC. Here's the foundation:
// internal/lsp/transport.go
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
type Transport struct {
reader *bufio.Reader
writer io.Writer
}
func NewTransport(reader io.Reader, writer io.Writer) *Transport {
return &Transport{
reader: bufio.NewReader(reader),
writer: writer,
}
}
func (t *Transport) ReadMessage() ([]byte, error) {
// Read Content-Length header
header, err := t.reader.ReadString('\n')
if err != nil {
return nil, err
}
contentLength := 0
if strings.HasPrefix(header, "Content-Length: ") {
length := strings.TrimSpace(header[16:])
contentLength, err = strconv.Atoi(length)
if err != nil {
return nil, err
}
}
// Skip empty line
t.reader.ReadString('\n')
// Read message body
message := make([]byte, contentLength)
_, err = io.ReadFull(t.reader, message)
return message, err
}
func (t *Transport) WriteMessage(message []byte) error {
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(message))
_, err := t.writer.Write([]byte(header))
if err != nil {
return err
}
_, err = t.writer.Write(message)
return err
}
Step 3: Define LSP 3.17 Protocol Structures
// pkg/protocol/types.go
package protocol
type InitializeParams struct {
ProcessID int `json:"processId"`
RootPath string `json:"rootPath,omitempty"`
RootURI string `json:"rootUri"`
Capabilities ClientCapabilities `json:"capabilities"`
}
type ServerCapabilities struct {
TextDocumentSync int `json:"textDocumentSync"`
HoverProvider bool `json:"hoverProvider"`
CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"`
SignatureHelp *SignatureHelpOptions `json:"signatureHelpProvider,omitempty"`
DefinitionProvider bool `json:"definitionProvider"`
DiagnosticProvider *DiagnosticOptions `json:"diagnosticProvider,omitempty"`
InlayHintProvider bool `json:"inlayHintProvider"` // LSP 3.17
SemanticTokens *SemanticTokensOptions `json:"semanticTokensProvider,omitempty"` // LSP 3.17
}
type CompletionOptions struct {
TriggerCharacters []string `json:"triggerCharacters,omitempty"`
ResolveProvider bool `json:"resolveProvider,omitempty"`
}
// LSP 3.17 Inlay Hints
type InlayHint struct {
Position Position `json:"position"`
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Tooltip string `json:"tooltip,omitempty"`
}
Step 4: Implement Core Server Logic
// internal/lsp/server.go
package lsp
import (
"context"
"encoding/json"
"log"
"myserver/pkg/protocol"
)
type Server struct {
transport *Transport
documents map[string]string // URI -> content
}
func NewServer(transport *Transport) *Server {
return &Server{
transport: transport,
documents: make(map[string]string),
}
}
func (s *Server) Start(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
message, err := s.transport.ReadMessage()
if err != nil {
return err
}
go s.handleMessage(message)
}
}
}
func (s *Server) handleMessage(data []byte) {
var request protocol.Request
if err := json.Unmarshal(data, &request); err != nil {
log.Printf("Failed to unmarshal request: %v", err)
return
}
switch request.Method {
case "initialize":
s.handleInitialize(request)
case "textDocument/didOpen":
s.handleDidOpen(request)
case "textDocument/hover":
s.handleHover(request)
case "textDocument/completion":
s.handleCompletion(request)
case "textDocument/inlayHint": // LSP 3.17
s.handleInlayHint(request)
}
}
func (s *Server) handleInitialize(request protocol.Request) {
capabilities := protocol.ServerCapabilities{
TextDocumentSync: 1, // Full sync
HoverProvider: true,
CompletionProvider: &protocol.CompletionOptions{
TriggerCharacters: []string{".", ":"},
},
DefinitionProvider: true,
InlayHintProvider: true, // Enable LSP 3.17 inlay hints
}
response := protocol.Response{
ID: request.ID,
Result: map[string]interface{}{
"capabilities": capabilities,
},
}
s.sendResponse(response)
}
Step 5: Add Language-Specific Features
func (s *Server) handleCompletion(request protocol.Request) {
// Parse completion params
var params protocol.CompletionParams
json.Unmarshal(request.Params, ¶ms)
// Get document content
content := s.documents[params.TextDocument.URI]
// Implement your completion logic here
items := []protocol.CompletionItem{
{
Label: "myFunction",
Kind: 3, // Function
Detail: "Custom function from my language",
InsertText: "myFunction(${1:param})",
},
}
response := protocol.Response{
ID: request.ID,
Result: items,
}
s.sendResponse(response)
}
// LSP 3.17 Inlay Hints implementation
func (s *Server) handleInlayHint(request protocol.Request) {
var params protocol.InlayHintParams
json.Unmarshal(request.Params, ¶ms)
hints := []protocol.InlayHint{
{
Position: protocol.Position{Line: 5, Character: 20},
Label: ": string",
Kind: 1, // Type hint
Tooltip: "Inferred type annotation",
},
}
response := protocol.Response{
ID: request.ID,
Result: hints,
}
s.sendResponse(response)
}
Best Practices
- **Implement incremental sync** — For large files, use `TextDocumentSyncKind.Incremental` instead of full document sync to improve performance
- **Cache parsing results** — Parse documents once and cache ASTs, invalidating only when content changes
- **Use worker pools** — Handle LSP requests concurrently but ensure document state consistency with proper locking
- **Implement proper error handling** — Always send valid JSON-RPC responses, even for errors, to prevent client confusion
- **Support workspace folders** — Modern editors work with multiple root folders; design your server to handle this from the start
Common Pitfalls
Blocking the main loop — Never perform expensive operations in the message handler. Use goroutines for parsing and analysis to keep the transport responsive.
Ignoring LSP specification versions — Different editors support different LSP versions. Always check client capabilities before using newer features like semantic tokens.
Memory leaks from document storage — Implement proper cleanup when documents are closed or deleted. Use weak references or periodic cleanup for large codebases.
Incorrect position calculations — LSP uses UTF-16 code units for positions, not byte offsets or rune counts. This catches many implementers off guard.
Related DevToolKit Tools
Building language servers involves significant JSON manipulation and API testing. These DevToolKit utilities can streamline your development:
- **[JSON Formatter](/tools/json-formatter)** — Essential for debugging LSP JSON-RPC messages and validating protocol compliance
- **[API Request Builder](/tools/api-tester)** — Test your language server's HTTP endpoints if you're building web-based tooling around it
- **[Regex Tester](/tools/regex-tester)** — Perfect for testing tokenization patterns and syntax highlighting rules in your parser
- **[Git Command Generator](/tools/git-command)** — Streamline version control workflow when managing language server releases and collaboration
Language servers represent a significant investment in developer tooling, but the payoff in improved productivity and code quality makes them worthwhile for any serious language or DSL. The LSP 3.17 specification provides robust foundations for modern IDE experiences that your developers will appreciate.
Try These Tools for Free
54 developer & DevOps tools — right in your browser. No sign-up needed.
Explore All Tools →