Gossip
March 8, 2026 · View on GitHub
A best-in-class Go framework for building Language Server Protocol (LSP) servers.
Gossip gives you everything you need to build a production-quality LSP server in Go: code-generated protocol types, composable middleware, built-in document management, native tree-sitter integration with incremental diagnostics, config hot-reload, multi-root workspace support, and full cross-editor transport support.
Features
- Zero-to-server in 15 lines — functional handler registration, auto-detected capabilities
- LSP 3.18 types — code-generated from the official meta model
- Built-in document store — thread-safe, auto-wired to didOpen/didChange/didClose, with UTF-16 position handling
- Native tree-sitter — parser-per-document lifecycle, automatic incremental re-parsing, query API
- Incremental diagnostics — declarative checks and imperative analyzers with smart caching and merging
- Multi-root workspaces — native support for
workspace/didChangeWorkspaceFolderswith per-folder config resolution - Config hot-reload — TOML config files with fsnotify watching, atomic swap, change callbacks
- Composable middleware — logging, panic recovery, tracing, telemetry (same pattern as
net/http) - 6 transports — stdio, TCP, Unix sockets, named pipes, WebSocket, Node.js IPC
- CLI flag parsing — single binary works with every editor via
--stdio,--tcp,--socket, etc. - First-class testing — in-memory client, test harness, LSP-specific assertions
- Extensible — custom method handlers, break-glass accessors, bring your own tree-sitter grammars
Quick Start
package main
import (
"log"
"github.com/LukasParke/gossip"
"github.com/LukasParke/gossip/protocol"
)
func main() {
s := gossip.NewServer("my-lang", "0.1.0")
s.OnHover(func(ctx *gossip.Context, p *protocol.HoverParams) (*protocol.Hover, error) {
doc := ctx.Documents.Get(p.TextDocument.URI)
word := doc.WordAt(p.Position)
return &protocol.Hover{
Contents: protocol.MarkupContent{Kind: protocol.Markdown, Value: "**" + word + "**"},
}, nil
})
if err := gossip.Serve(s, gossip.WithStdio()); err != nil {
log.Fatal(err)
}
}
Architecture
gossip/
├── jsonrpc/ JSON-RPC 2.0 codec and bidirectional connection
├── transport/ stdio, TCP, Unix socket, named pipe, WebSocket, Node.js IPC
├── protocol/ LSP 3.18 types (handwritten + code-generated)
├── document/ Thread-safe document store with position utilities
├── treesitter/ Native tree-sitter integration with incremental diagnostics
├── lspclient/ Child LSP client, diagnostic aggregator (multi-source merge + debounce)
├── config/ TOML config system with hot-reload
├── middleware/ Composable middleware (logging, recovery, tracing, telemetry)
├── gossiptest/ Testing utilities
└── examples/ Minimal, configurable, and full-featured example servers
Handler Registration
Register handlers for the LSP methods you want to support. Gossip automatically detects capabilities from which handlers are registered — no manual wiring needed.
s := gossip.NewServer("my-lang", "0.1.0")
// Language features
s.OnHover(myHoverHandler)
s.OnCompletion(myCompletionHandler)
s.OnDefinition(myDefinitionHandler)
s.OnDeclaration(myDeclarationHandler)
s.OnTypeDefinition(myTypeDefinitionHandler)
s.OnImplementation(myImplementationHandler)
s.OnReferences(myReferencesHandler)
s.OnDocumentSymbol(mySymbolHandler)
s.OnCodeAction(myCodeActionHandler)
s.OnCodeLens(myCodeLensHandler)
s.OnFormatting(myFormattingHandler)
s.OnRangeFormatting(myRangeFormattingHandler)
s.OnRename(myRenameHandler)
s.OnPrepareRename(myPrepareRenameHandler)
s.OnSignatureHelp(mySignatureHelpHandler)
s.OnDocumentHighlight(myDocumentHighlightHandler)
s.OnDocumentLink(myDocumentLinkHandler)
s.OnFoldingRange(myFoldingRangeHandler)
s.OnSelectionRange(mySelectionRangeHandler)
s.OnInlayHint(myInlayHintHandler)
s.OnSemanticTokens(mySemanticTokensHandler)
s.OnWorkspaceSymbol(myWorkspaceSymbolHandler)
s.OnExecuteCommand(myExecuteCommandHandler)
// Text document sync notifications
s.OnDidOpen(myDidOpenHandler)
s.OnDidChange(myDidChangeHandler)
s.OnDidClose(myDidCloseHandler)
s.OnDidSave(myDidSaveHandler)
// Workspace notifications
s.OnDidChangeWatchedFiles(myWatchedFilesHandler)
s.OnDidChangeWorkspaceFolders(myFoldersHandler)
Tree-sitter Integration
Native tree-sitter support gives you incremental parsing for free. Trees are automatically maintained and re-parsed as documents change. Bring your own grammar packages — any tree-sitter grammar with Go bindings works.
import (
"github.com/LukasParke/gossip"
"github.com/LukasParke/gossip/treesitter"
tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
s := gossip.NewServer("my-lang", "0.1.0",
gossip.WithTreeSitter(treesitter.Config{
Languages: map[string]*tree_sitter.Language{
".go": tree_sitter_go.GetLanguage(),
},
}),
)
s.OnHover(func(ctx *gossip.Context, p *protocol.HoverParams) (*protocol.Hover, error) {
doc := ctx.Documents.Get(p.TextDocument.URI)
tree := gossip.TreeFor(doc)
if tree == nil {
return nil, nil
}
node := tree.NodeAt(p.Position)
// Use node.Kind(), tree.NodeText(node), tree.QueryCaptures(), etc.
})
Flexible Language Matching
Use LanguageMatcher to match files by extension, filename, glob pattern, or LSP language ID:
gossip.WithTreeSitter(treesitter.Config{
Matchers: []treesitter.LanguageMatcher{
{
Language: yamlLang,
Extensions: []string{".yml", ".yaml"},
Filenames: []string{".gitlab-ci.yml"},
LanguageID: "yaml",
},
{
Language: dockerLang,
Filenames: []string{"Dockerfile", "Containerfile"},
Pattern: "Dockerfile.*",
},
},
})
Matching priority: exact filename > LSP languageID > glob pattern > file extension.
Incremental Diagnostics
Gossip's diagnostic engine leverages tree-sitter's incremental parsing to run only the checks affected by each edit. Two APIs are provided:
Declarative Checks
Pattern-based rules that the framework automatically scopes and caches:
s.Check(treesitter.Check{
ID: "no-todo",
Pattern: `(comment) @c`,
Severity: protocol.DiagnosticSeverityWarning,
Run: func(node *tree_sitter.Node, text string) *string {
if strings.Contains(text, "TODO") {
msg := "Resolve TODO comment"
return &msg
}
return nil
},
})
Imperative Analyzers
Full-control analyzers with lifecycle hooks, change-aware scoping, and previous-result merging:
s.Analyze(treesitter.Analyzer{
ID: "complexity",
InterestKinds: []string{"function_declaration", "method_declaration"},
Run: func(actx *treesitter.AnalysisContext) []protocol.Diagnostic {
// actx.Scope tells you what changed: Full, Incremental, or Skip
// actx.AffectedNodes() returns only the nodes in changed ranges
// actx.MergePrevious(fresh) intelligently merges with cached results
// ...
},
})
The DiagnosticEngine orchestrates everything: it determines which checks/analyzers are affected by each TreeDiff, runs them with scoped query cursors, caches results per-check, and publishes merged diagnostics to the client automatically.
Child LSP Client (lspclient)
The lspclient package lets your server delegate syntax-level work to child language servers (e.g., yaml-language-server or vscode-json-language-server) and merge their diagnostics with your own.
DiagnosticAggregator
The DiagnosticAggregator collects diagnostics from multiple named sources for each URI, debounces, and publishes a merged set to the client:
agg := lspclient.NewDiagnosticAggregator(client.PublishDiagnostics, 80*time.Millisecond)
// Your server's diagnostics
agg.Set(uri, "my-server", myDiags)
// Child LSP diagnostics
agg.Set(uri, "yaml-ls", yamlDiags)
// On document close, clear all sources and publish empty diagnostics
agg.Clear(uri)
The aggregator is safe for concurrent use. Clear cancels any pending debounce timer and immediately publishes an empty diagnostic set so the client does not show stale results.
Child LSP Client
lspclient.Client manages the lifecycle of a child language server process. It handles spawning, initialization, document synchronization, and shutdown:
child := lspclient.NewClient(lspclient.ClientOptions{
Command: "yaml-language-server",
Args: []string{"--stdio"},
Logger: logger,
})
child.OnDiagnostics(func(uri protocol.DocumentURI, diags []protocol.Diagnostic) {
aggregator.Set(uri, "yaml-ls", diags)
})
child.Start(ctx)
defer child.Stop()
Document Lifecycle Guarantees
Gossip serializes didOpen, didChange, and didClose notifications using an internal mutex (docSyncMu). This prevents races when a client reclassifies a document's language (sending didClose + didOpen in quick succession) and ensures that OnInitialized callbacks complete before any document notifications are processed. This guarantee is critical for servers that wire up diagnostic aggregators or load rulesets during initialization.
Multi-Root Workspace Support
Gossip natively supports multi-root workspaces. The server automatically tracks workspace folders and handles workspace/didChangeWorkspaceFolders notifications from the client.
s.OnHover(func(ctx *gossip.Context, p *protocol.HoverParams) (*protocol.Hover, error) {
// Determine which workspace folder owns this document
folder := ctx.FolderFor(p.TextDocument.URI)
// Access all workspace folders
folders := ctx.WorkspaceFolders()
// Access the primary workspace root
root := ctx.WorkspaceRoot()
// ...
})
For per-folder configuration, use ctx.FolderFor(uri) to resolve which workspace root a file belongs to, then load the appropriate config.
Config with Hot-Reload
Define your config as a Go struct. Gossip handles TOML parsing, file watching, and atomic reload.
type MyConfig struct {
MaxCompletions int `toml:"max_completions"`
LintRules []string `toml:"lint_rules"`
}
s := gossip.NewServer("my-lang", "0.1.0",
gossip.WithConfig[MyConfig](".my-lang.toml", MyConfig{
MaxCompletions: 50,
}),
)
gossip.OnConfigChange(s, func(ctx *gossip.Context, old, new_ *MyConfig) {
ctx.Client.LogMessage(ctx, protocol.Info, "Config reloaded")
})
s.OnCompletion(func(ctx *gossip.Context, p *protocol.CompletionParams) (*protocol.CompletionList, error) {
cfg := gossip.Config[MyConfig](ctx)
// use cfg.MaxCompletions
})
Custom Method Handlers
Register handlers for custom or non-standard LSP methods using raw JSON params:
// Custom request (expects a response)
s.HandleRequest("$/myCustomMethod", func(ctx *gossip.Context, params json.RawMessage) (interface{}, error) {
var p MyCustomParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
return MyCustomResult{OK: true}, nil
})
// Custom notification (fire-and-forget)
s.HandleNotification("$/myCustomNotification", func(ctx *gossip.Context, params json.RawMessage) {
// handle notification
})
Client Proxy
The ClientProxy provides server-initiated communication back to the client:
s.OnCodeAction(func(ctx *gossip.Context, p *protocol.CodeActionParams) ([]protocol.CodeAction, error) {
// Send messages to the client
ctx.Client.ShowMessage(ctx, protocol.Info, "Processing...")
// Request workspace edits
ctx.Client.ApplyEdit(ctx, &protocol.ApplyWorkspaceEditParams{
Label: "My Edit",
Edit: workspaceEdit,
})
// Read client configuration
ctx.Client.Configuration(ctx, configItems)
// Register/unregister dynamic capabilities
ctx.Client.RegisterCapability(ctx, registrations)
// Trigger refreshes
ctx.Client.RefreshDiagnostics(ctx)
ctx.Client.RefreshInlayHints(ctx)
ctx.Client.RefreshSemanticTokens(ctx)
// ...
})
Break-Glass Accessors
When you need direct access to framework internals:
// Access the tree-sitter manager (parsers, trees, registry)
tsManager := s.TreeSitter()
// Access the diagnostic engine (checks, analyzers, cache)
engine := s.DiagnosticEngine()
// Access the document store directly
docs := s.Documents()
// Access the underlying JSON-RPC connection
conn := s.Conn()
// Access the server's logger
logger := s.Logger()
These are also available from handler context:
s.OnHover(func(ctx *gossip.Context, p *protocol.HoverParams) (*protocol.Hover, error) {
logger := ctx.Logger()
server := ctx.Server()
caps := ctx.ClientCapabilities()
initOpts := ctx.InitOptions()
// ...
})
Middleware
Gossip uses the same middleware pattern as Go's net/http. Middleware applies to both requests and notifications.
s := gossip.NewServer("my-lang", "0.1.0",
gossip.WithMiddleware(
middleware.Logging(slog.Default()),
middleware.Recovery(),
middleware.Tracing(),
middleware.Telemetry(metrics),
),
)
Custom middleware:
func RateLimit(rps int) middleware.Middleware {
return func(next middleware.Handler) middleware.Handler {
return func(ctx context.Context, method string, params jsonrpc.RawMessage) (interface{}, error) {
// your rate limiting logic
return next(ctx, method, params)
}
}
}
Cross-Editor Transport
A single binary supports every editor via CLI flags:
my-lang-lsp # default: stdio
my-lang-lsp --stdio # explicit stdio
my-lang-lsp --tcp :9257 # TCP listener
my-lang-lsp --socket /tmp/my.sock # Unix domain socket
my-lang-lsp --pipe \\.\pipe\my-lang # Windows named pipe
my-lang-lsp --ws :9258 # WebSocket
my-lang-lsp --node-ipc # Node.js IPC (VS Code)
Use gossip.FromArgs() to enable automatic transport selection:
gossip.Serve(s, gossip.FromArgs())
Or select programmatically:
gossip.Serve(s, gossip.WithStdio())
gossip.Serve(s, gossip.WithTCP(":9257"))
gossip.Serve(s, gossip.WithSocket("/tmp/my.sock"))
gossip.Serve(s, gossip.WithWebSocket(":9258"))
gossip.Serve(s, gossip.WithNodeIPC())
Testing
The gossiptest package provides an in-memory LSP client for testing:
func TestHover(t *testing.T) {
s := gossip.NewServer("test", "0.1.0")
s.OnHover(myHoverHandler)
c := gossiptest.NewClient(t, s)
c.Open("file:///test.txt", "hello world")
hover, err := c.Hover("file:///test.txt", gossiptest.Pos(0, 2))
if err != nil {
t.Fatal(err)
}
gossiptest.AssertHoverContains(t, hover, "hello")
}
Tree-sitter test helpers are also available:
func TestDiagnostics(t *testing.T) {
s := gossip.NewServer("test", "0.1.0",
gossip.WithTreeSitter(treesitter.Config{...}),
)
s.Check(myCheck)
c := gossiptest.NewClient(t, s)
c.Open("file:///test.json", `{"key": }`)
diags := gossiptest.WaitForDiagnostics(t, c, "file:///test.json")
// assert on diags...
gossiptest.ChangeIncremental(t, c, "file:///test.json", editRange, `"value"`)
}
Editor Integration
VS Code
In your extension's activate():
const serverOptions: ServerOptions = {
command: "my-lang-lsp",
args: ["--stdio"],
};
Neovim
vim.lsp.start({
name = "my-lang",
cmd = { "my-lang-lsp", "--stdio" },
-- Or connect to a running server:
-- cmd = vim.lsp.rpc.connect("127.0.0.1", 9257),
})
Emacs (Eglot)
(add-to-list 'eglot-server-programs
'(my-lang-mode . ("my-lang-lsp" "--stdio")))
Helix
In languages.toml:
[[language]]
name = "my-lang"
language-servers = ["my-lang-lsp"]
[language-server.my-lang-lsp]
command = "my-lang-lsp"
args = ["--stdio"]
Zed
In Zed settings:
{
"lsp": {
"my-lang-lsp": {
"binary": { "path": "my-lang-lsp", "arguments": ["--stdio"] }
}
}
}
Benchmarks
Performance is tracked on every push to main. Benchmarks cover initial parse, incremental edits, query scoping, analyzer skip logic, merge performance, and end-to-end diagnostic cycles across Go, Python, JSON, and YAML.
View the benchmark dashboard for historical trends.
On pull requests, benchmarks are compared against main using benchstat to catch performance regressions before they are merged.
Dependencies
| Package | Purpose |
|---|---|
github.com/tree-sitter/go-tree-sitter | Tree-sitter Go bindings |
github.com/BurntSushi/toml | TOML config parsing |
github.com/fsnotify/fsnotify | File watching for config hot-reload |
golang.org/x/net | WebSocket transport |
The core framework (JSON-RPC + stdio) has zero external dependencies.
License
MIT
This repo was 100% created by AI