4.8.5 Language Server Integration Architecture

extra

A 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.

LSP-on-Bazel: five layers, one Bazel-specific seam
The build-system adapter speaks Bazel. The editor and language server keep their normal language-aware roles.
EDITOR PLUGIN
Thin editor binding
Owns language-aware editor features and LSP server setup, but no Bazel graph logic
VS Code Go vim-go Emacs lsp-mode
LSP SERVER
The language brain
Owns parser, type checker, completion, diagnostics
gopls Red Hat Java LSP SourceKit LSP
PACKAGE-LOADING LIBRARY
Language-native package loader
For Go, owns the file/import → package metadata API
go/packages BSP client JDT project import
BUILD-SYSTEM DRIVER
The Bazel-specific seam
Maps files to targets, then asks Bazel analysis for language metadata
Bazel-specific
gopackagesdriver Bazel Java SDK sourcekit-bazel-bsp
RULE PROVIDERS
Analysis-phase provider data
Rule providers supply analysis facts. Each language integration chooses its own projection.
GoArchive · rules_go JavaInfo · rules_java CcInfo · rules_cc
The Bazel adapter translates the build graph for a language tool. The concrete protocol and provider projection differ across Go, JVM, and Swift integrations.

The Five Layers

From the editor down to the build graph:1

  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
  2. LSP server. The language brain — parser, type checker, completion, diagnostics. Speaks the Language Server Protocol (JSON-RPC over stdio) to the editor. gopls is one process per workspace, decoupled from any specific editor.1
  3. 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 a Load(config, patterns) function and a Package struct carrying ID, files, and imports.1 This is the layer that hides build-system details from the server.
  4. 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 the GOPACKAGESDRIVER environment 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
  5. Rule providers. The bottom layer is the existing analysis-phase providers the rules already produce — rules_go's GoArchive carries 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.

key takeaway

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

The driver uses bazel query to translate filenames and import paths into the Bazel labels that own them.
The driver uses bazel build --aspects=... with an aspect that reads rule providers and emits per-target JSON metadata.
The driver invokes the language's own compiler/type-checker so the LSP server doesn't have to.
Each Bazel rule needs to add a special 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

Answers
VS Code Go + gopls
VS Code Java + Red Hat Java LSP (a headless Eclipse)
Cursor / VS Code Swift + SourceKit LSP
clangd consuming compile_commands.json

4.Which facts make bazel query (not bazel build) the natural fit for the driver's first phase?

Select all that apply

0 of 4 answered

Footnotes

  1. Go Editor Support in Bazel Workspaces — full layered architecture, two-phase driver design, go/packages API, 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

  2. Go editor support in Bazel Workspaces — BazelCon 2022 talk version: layer-by-layer walkthrough, thin VS Code Go extension, demo of gopackagesdriver resolving generated proto code and external dependencies.

  3. 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

  4. 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.

  5. The State of Compilation Database in Bazel — four approaches to generating compile_commands.json. Aspect-based extraction reading CppCompileAction.args for accurate per-target compile commands. LLVM compilation database schema. 1 2

  6. State of Swift and iOS in Bazelsourcekit-bazel-bsp wiring SourceKit LSP to Bazel via Build Server Protocol as a Cursor/VS Code alternative to Xcode.