Show HN: Create a full language server in Go with 3.17 spec support
Linux & Servers

Show HN: Create a full language server in Go with 3.17 spec support

📅 March 29, 2026 ⏱ 5 min read 📡 hackernews

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

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:

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 →

Related DevToolKit Tools