4.8.5 Language Server Integration Architecture
extraA language server wants the project model: which file is part of which package, what that package depends on, where each import resolves, and the exact compiler flags. Bazel knows all of this, but it knows it in target terms — not the language's package terms an LSP server expects. The architectural pattern this item is about is how to bridge those two worlds without baking Bazel into the language server: a layered stack where every layer except a thin Bazel-specific driver is build-system-agnostic.1
The pattern was codified by the Go team for gopls and is deliberately build-system-agnostic, so other languages can reuse the complete architecture.1 The same shape powers Salesforce's VS Code Java extension and is being adapted for Swift through sourcekit-bazel-bsp. The maintainer-facing overview of those integrations lives in 3.5.1 IDE Support. This article is the architectural reference: what each layer is responsible for, where the Bazel-specific seam lies, and why the seam is built on bazel query plus an aspect.
The Five Layers
From the editor down to the build graph:1
- Editor plugin. Thin TypeScript / Vim / Emacs binding. Installs the LSP server, exposes keybindings, and stays out of language semantics. The Go extension for VS Code is intentionally small — a thin binding layer.2
- LSP server. The language brain — parser, type checker, completion, diagnostics. Speaks the Language Server Protocol (JSON-RPC over stdio) to the editor.
goplsis one process per workspace, decoupled from any specific editor.1 - Package-loading library. A language-native API for "given this file or import, give me the package metadata graph." For Go this is
golang.org/x/tools/go/packages, with aLoad(config, patterns)function and aPackagestruct carrying ID, files, and imports.1 This is the layer that hides build-system details from the server. - Build-system driver. A binary that implements the package-loading library's pluggable backend for one build system. For Bazel + Go that's
gopackagesdriver, selected by setting theGOPACKAGESDRIVERenvironment variable.1 The protocol is plain: patterns arrive as command-line arguments, the loader's config arrives as JSON on stdin, and the driver writes a JSON driver response on stdout.1 - Rule providers. The bottom layer is the existing analysis-phase providers the rules already produce —
rules_go'sGoArchivecarries the name, import path, sources, and dependencies the driver needs.1 No special "for IDEs" rule API is required.
The seam that makes this work is layer 3 ↔ layer 4: an unchanged language ecosystem above, a build-specific binary below. Everything Bazel-specific is concentrated in one swap-out component.
What the Driver Actually Does
The driver runs in two phases for each query the LSP fires:1
Map patterns to targets with bazel query. The LSP asks for "the package containing foo.go" or "the package for import google.golang.org/grpc". The driver translates those into Bazel query expressions (e.g., to find the go_library that contains the file, or the target whose importpath matches), runs bazel query once with the union of expressions, and gets back labels.1 Query is the right tool here because it operates on the loaded graph without analyzing or executing actions — fast and cache-friendly.
Extract metadata with an aspect. The driver then runs bazel build against those targets with --aspects pointing at a metadata-collecting aspect that ships in rules_go.1 The aspect propagates along deps, reads the GoArchive provider on each visited target, and declares a small JSON action whose output captures the per-target metadata (label, import path, source list, dep labels). The driver collects those JSON files, fixes up relative paths to absolute paths (analysis-phase outputs are workspace-relative for cacheability), and writes the result to stdout for go/packages to ingest.1
The aspect is doing the same job aspects always do — augmenting the graph with extra metadata or actions without modifying rules — and the 4.8 Aspects chapter introduces the mechanism. The interesting part here is what the aspect emits: not a build artifact, but a projection of provider data shaped for an external consumer.
# Conceptual driver run, shown without the JSON framing.
GOPACKAGESDRIVER=tools/gopackagesdriver.sh \
bazel query 'kind(go_library, rdeps(//..., //path/to:foo.go, 1))' \
| xargs bazel build --aspects=<rules_go gopackagesdriver aspect> \
--output_groups=<driver metadata json>
That command is illustrative — the actual driver hides it — but it shows the three Bazel-specific tools the design relies on: query for the import / file-to-target mapping, aspect for the per-target metadata, and output_groups for asking the aspect's files without building the normal rule outputs (4.8.2 Aspect Implementation Basics).
Why The Aspect Output Looks Familiar
gopls does not trust the file system. It maintains an in-memory snapshot of the workspace — a copy-on-write view that is rebuilt only after the user pauses typing — and recomputes diagnostics with a generic cache keyed by hashes of the inputs. That gopls cache is an in-memory equivalent of Bazel's action cache.1 The invalidation models are analogous, not identical: gopls responds to editor snapshots and package metadata, while Bazel keys actions by declared inputs, tools, command lines, and configuration. The adapter works best when Bazel supplies stable metadata and generated artifacts, so the language server can make deterministic package-loading decisions instead of guessing from the file system.
How Other Languages Apply the Same Shape
The architecture isn't Go-specific. The variations show up at layers 4 and 5, never at layers 1–3.
JVM (Java in VS Code). Salesforce's VS Code Java extension plugs into Red Hat's Java LSP — which is itself a headless Eclipse — and the Bazel-specific piece is a Bazel Java SDK shared between the Bazel Eclipse plugin and the VS Code plugin.3,4 The "driver" here is that extension. It computes a per-package classpath by combining bazel query with an aspect that walks java_library/java_binary/java_test targets and unions their classpaths.3 The same two-phase pattern: query to choose targets, aspect to extract per-target metadata.
C++ via compilation databases. clangd and most C++ tooling want a compile_commands.json — a list of translation units with the exact compiler flags. Bazel has no built-in support, and the community generates that file with one of four mechanisms: build interception, the deprecated extra_actions, bazel aquery, and aspects.5 The aspect approach is the closest fit to the architecture in this article — the aspect reads CppCompileAction's args (or, less accurately, reconstructs flags from CcInfo.compilation_context) and writes per-target JSON that aggregates into the compilation database.5 Implementation depth is in L2.5.1 Compilation Database.
Swift. SourceKit LSP is the language server. The Bazel-specific driver is being built as a Build Server Protocol bridge — sourcekit-bazel-bsp — so the SourceKit side does not need to know about Bazel directly.6 BSP replaces "a driver speaking go/packages JSON" with "a driver speaking BSP JSON-RPC". The four-layer split above the rule providers is the same.
The variations are at the seam binary (build adapter vs BSP server) and at the provider shape (GoArchive vs JVM classpath vs CcInfo vs Swift module info). The above-driver layers — editor plugin, LSP server, language-native loader — are reused per-language across build systems.
When to Build One
For a rule-author audience the practical decision is whether to ship a driver with your ruleset. The cost is concentrated and the payoff is leveraged: one driver per (language, build system) pair, then every editor that already speaks that language's LSP works for Bazel users. In rules_go the driver ships in @io_bazel_rules_go//go/tools/gopackagesdriver, and the rules themselves need nothing special to support gopackagesdriver beyond emitting their normal GoArchive provider.1 The Go-on-Bazel deployment story — wiring GOPACKAGESDRIVER, environment variables, and gopls settings — is covered in L5.6 IDE Integration (gopackagesdriver).
If the language ecosystem already has a stable package-loading abstraction (go/packages, BSP, Red Hat's Java LSP project import API), the driver is small. If not, the work shifts upward and you are effectively also redesigning the language-native loader — at which point you are building a much bigger thing than an aspect.
The LSP-on-Bazel pattern is a five-layer stack: editor plugin, LSP server, language-native package loader, build-system driver, and the analysis-phase rule providers underneath. The driver is the only Bazel-specific piece. It uses bazel query to translate files and import paths into Bazel targets and an aspect to project per-target provider data as JSON for the loader to ingest.
Ship one driver per (language, build system) pair and every editor whose LSP already speaks that language's loader gets Bazel support for free. The Go-side reference implementation to copy is gopackagesdriver.1
Check your understanding · 4 questions
1.In the LSP-on-Bazel architecture (editor plugin -> LSP server -> package-loading library -> build-system driver -> rule providers), which layer is the only Bazel-specific adapter component the IDE integration has to provide?
Select one answer
2.True or false: what the driver does
Choose True or False for each sentence
bazel query to translate filenames and import paths into the Bazel labels that own them.bazel build --aspects=... with an aspect that reads rule providers and emits per-target JSON metadata.lsp_metadata provider for the driver to work.3.Match each language/editor stack to the Bazel-specific component that plays the 'driver' role.
Drag each answer onto the matching prompt, or click an answer and then click a prompt
compile_commands.json4.Which facts make bazel query (not bazel build) the natural fit for the driver's first phase?
Select all that apply
Footnotes
-
Go Editor Support in Bazel Workspaces — full layered architecture, two-phase driver design,
go/packagesAPI, gopls snapshot/cache analogous to Bazel's action cache, and the explicit "steal this design" call-out. ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
Go editor support in Bazel Workspaces — BazelCon 2022 talk version: layer-by-layer walkthrough, thin VS Code Go extension, demo of
gopackagesdriverresolving generated proto code and external dependencies. ↩ -
Eclipse and VS Code IDE Support for Java packages in Bazel — Salesforce's Bazel Java SDK shared between Eclipse plugin and VS Code extension (built on Red Hat's Java LSP). Per-package classpath via
bazel query+ aspect. Partial project imports. ↩1 ↩2 -
Bazel and Java development in VS code — Red Hat Java LSP as headless Eclipse, Bazel extension as the adapter, scaling pain points: aspect-based classpath needs full build, IDE/CLI cache split. ↩
-
The State of Compilation Database in Bazel — four approaches to generating
compile_commands.json. Aspect-based extraction readingCppCompileAction.argsfor accurate per-target compile commands. LLVM compilation database schema. ↩1 ↩2 -
State of Swift and iOS in Bazel —
sourcekit-bazel-bspwiring SourceKit LSP to Bazel via Build Server Protocol as a Cursor/VS Code alternative to Xcode. ↩