LSP Integration
Feature Definition
Section titled “Feature Definition”The Language Server Protocol (LSP) gives coding agents access to the same intelligence that powers IDE features: type information on hover, go-to-definition, find-all-references, workspace symbol search, and real-time diagnostics. For a coding agent, LSP integration means the model can verify its edits compile, discover where symbols are defined before modifying them, and find all callers of a function it plans to refactor.
The hard problems are:
- Server lifecycle management — LSP servers are long-running processes per language. The agent must spawn them on demand, feed them the correct workspace root, and shut them down cleanly.
- Multi-server coordination — A monorepo with Python backend, TypeScript frontend, and Rust tooling needs three different LSP servers running simultaneously, each watching a different set of files.
- Diagnostic timing — After an edit, diagnostics don’t arrive instantly. The agent must wait (but not forever) for the server to reanalyze the file before reporting results.
- Context injection — Raw LSP responses (JSON with URIs, ranges, severity codes) must be transformed into a format the LLM can use effectively.
Aider Implementation
Section titled “Aider Implementation”Reference commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b
Aider does not use LSP. There is no LSP client, no server management, and no diagnostic integration anywhere in the codebase.
Instead, Aider uses two alternative approaches for code intelligence:
Tree-Sitter for Repo Mapping
Section titled “Tree-Sitter for Repo Mapping”Aider’s RepoMap (aider/repomap.py) uses tree-sitter to extract definitions and references from source files. Tree-sitter queries (.scm files in aider/queries/) identify name.definition.* and name.reference.* capture groups, which feed into the PageRank-based repo map algorithm. This gives Aider structural awareness of the codebase without needing a running language server.
For languages where tree-sitter queries only provide definitions (like C++), Aider falls back to Pygments lexer tokenization to extract references — a much coarser approach than LSP’s semantic analysis.
Linting via Shell Commands
Section titled “Linting via Shell Commands”Aider’s Linter class (aider/linter.py) runs external lint commands (configurable via --lint-cmd) after edits. These produce diagnostics via stdout/stderr parsing, not LSP. The linter also has a tree-sitter-based fallback that checks for syntax errors by attempting to parse the edited file.
Why No LSP
Section titled “Why No LSP”Aider’s design philosophy prioritizes zero-configuration operation. LSP requires language servers to be installed, configured, and maintained — a significant user burden. Tree-sitter, by contrast, ships as a bundled library with pre-compiled grammars. The trade-off is that Aider gets syntactic intelligence (what identifiers exist and where) but not semantic intelligence (type information, cross-module resolution, or compiler diagnostics).
Codex Implementation
Section titled “Codex Implementation”Reference commit: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476
Codex has no LSP integration. There is no LSP client, no lsp-types crate in the workspace dependencies, and no diagnostic collection mechanism.
The lsp-types crate appears in Cargo.lock as a transitive dependency of starlark_syntax (part of the Buck2/Bazel build infrastructure used internally), but it is never imported or used by any Codex crate.
What Codex Uses Instead
Section titled “What Codex Uses Instead”Codex’s code intelligence is minimal and focused on two areas:
- Tree-sitter for syntax highlighting:
codex-rs/tui/src/render/highlight.rsusestree_sitter_highlightto colorize Bash output in the TUI. This is purely visual — no structural analysis. - Tree-sitter for shell command parsing:
codex-rs/shell-command/src/bash.rsusestree_sitterandtree_sitter_bashto parse shell commands for validation and argument extraction.
Neither of these provides language intelligence in the LSP sense. Codex relies entirely on the LLM’s training knowledge for code understanding, with no external validation of edits beyond the sandbox execution model (run the code and see if it works).
OpenCode Implementation
Section titled “OpenCode Implementation”Reference commit: 7ed449974864361bad2c1f1405769fd2c2fcdf42
OpenCode has the most comprehensive LSP integration of the three tools, with a full client implementation, 30+ built-in server configurations, and tight integration between diagnostics and the AI’s tool execution loop.
Architecture Overview
Section titled “Architecture Overview”The LSP system lives in packages/opencode/src/lsp/ and consists of three main modules:
| Module | File | Purpose |
|---|---|---|
index.ts | lsp/index.ts | State management, server registry, client initialization |
client.ts | lsp/client.ts | JSON-RPC connection, request routing, diagnostic collection |
server.ts | lsp/server.ts | 30+ built-in server definitions with spawn/root/extension configs |
LSP Capabilities
Section titled “LSP Capabilities”OpenCode’s client declares the following capabilities during initialization (client.ts, line 82):
connection.sendRequest("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [{ name: "workspace", uri: pathToFileURL(input.root).href }], initializationOptions: { ...input.server.initialization }, capabilities: { window: { workDoneProgress: true }, workspace: { configuration: true, didChangeWatchedFiles: { dynamicRegistration: true }, }, textDocument: { synchronization: { didOpen: true, didChange: true }, publishDiagnostics: { versionSupport: true }, }, },})The operations exposed to the AI via the LSP tool (tool/lsp.ts + tool/lsp.txt):
goToDefinition—textDocument/definitionfindReferences—textDocument/references(withincludeDeclaration: true)hover—textDocument/hover(type info, documentation)documentSymbol—textDocument/documentSymbol(all symbols in a file)workspaceSymbol—workspace/symbol(cross-workspace symbol search)goToImplementation—textDocument/implementationprepareCallHierarchy—textDocument/prepareCallHierarchyincomingCalls—callHierarchy/incomingCallsoutgoingCalls—callHierarchy/outgoingCalls
Background notifications handled by the client:
textDocument/publishDiagnostics— Collects diagnostics per filetextDocument/didOpen/textDocument/didChange— Sent on file touchworkspace/didChangeConfiguration— Propagates config to serversworkspace/didChangeWatchedFiles— Notifies servers of file changes
Client Initialization Flow
Section titled “Client Initialization Flow”When a file is first accessed (read, edited, or explicitly queried), LSP.getClients(file) (index.ts, line 177) triggers lazy initialization:
- Extension matching: The file’s extension is matched against all registered servers. Multiple servers can match the same extension (e.g., both TypeScript and ESLint serve
.tsfiles). - Root detection: Each server defines a
root()function that walks up the directory tree looking for anchor files (e.g.,package.jsonfor TypeScript,Cargo.tomlfor Rust,go.modfor Go). - Server spawn: The server’s
spawn()function launches the LSP process, returning a child process handle and initialization options. - Connection creation (
client.ts, line 46):
const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout), new StreamMessageWriter(input.server.process.stdin),)- Initialize handshake: Send
initializerequest (45-second timeout), theninitializednotification, thenworkspace/didChangeConfiguration. - Caching: The client is cached per
root + serverIDcombination in the LSP state.
Built-In Server Registry
Section titled “Built-In Server Registry”OpenCode ships with 30+ pre-configured LSP servers (server.ts, 2000+ lines). Selected highlights:
| Server ID | Language | Command | Auto-Download |
|---|---|---|---|
typescript | TS/JS | typescript-language-server --stdio | No |
deno | Deno | deno lsp | No |
vue | Vue | vue-language-server --stdio | Yes (npm) |
eslint | JS/TS linting | VS Code ESLint server | Yes (GitHub) |
pyright | Python | pyright-langserver --stdio | Yes (pip) |
gopls | Go | gopls | Yes (go install) |
rust | Rust | rust-analyzer | No |
clangd | C/C++ | clangd | Yes (GitHub releases) |
astro | Astro | astro-ls --stdio | No |
biome | JS/TS/CSS | biome lsp-proxy --stdio | No |
Each server definition includes:
extensions: File extensions to match (e.g.,[".ts", ".tsx", ".js", ".jsx"])root(): Async function to find workspace root (e.g., walk up looking fortsconfig.jsonorpackage.json)spawn(root): Launches the server process with correct cwd and env varsinitialization: Server-specific init options (e.g., TypeScript SDK path, Python venv path)
Auto-download servers fetch from GitHub releases or package managers when the binary isn’t found locally. This is controlled by the OPENCODE_DISABLE_LSP_DOWNLOAD flag.
Diagnostic Integration
Section titled “Diagnostic Integration”Diagnostics flow through a push model with debouncing:
-
Collection (
client.ts, line 52): The LSP server pushestextDocument/publishDiagnosticsnotifications. The client stores them in a per-fileMap<string, Diagnostic[]>. -
Event bus: Each diagnostic update publishes a
lsp.client.diagnosticsevent with the file path and server ID. -
Post-edit waiting: After a file edit (
tool/edit.ts, line 133), OpenCode calls:
await LSP.touchFile(filePath, true) // true = wait for diagnosticsThe touchFile function sends textDocument/didOpen or textDocument/didChange to the server, then waits up to 3 seconds (with 150ms debounce) for diagnostics to arrive.
- Error filtering and injection (
tool/edit.ts, line 135):
const diagnostics = await LSP.diagnostics()const issues = diagnostics[normalizedFilePath] ?? []const errors = issues.filter((item) => item.severity === 1) // Error onlyif (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) output += `\n\nLSP errors detected in this file, please fix:\n` + `<diagnostics file="${filePath}">\n` + `${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + `</diagnostics>`}Only Error severity (code 1) diagnostics are injected. Warnings, info, and hints are ignored. The format is:
LSP errors detected in this file, please fix:<diagnostics file="src/main.ts">ERROR [10:5] Property 'foo' does not exist on type 'Bar'ERROR [15:12] Cannot find name 'baz'</diagnostics>This text is appended to the tool output, so the AI sees it as part of the edit result and can immediately fix the errors.
The same pattern applies in the write tool (tool/write.ts, line 56) — any file creation or overwrite also triggers diagnostic collection and feedback.
File Touch Without Waiting
Section titled “File Touch Without Waiting”When reading files (tool/read.ts, line 183), OpenCode touches the file non-blocking:
LSP.touchFile(filepath, false) // false = don't waitThis warms up the LSP server so that subsequent operations (hover, definition lookup) are faster, without blocking the read operation.
User Configuration
Section titled “User Configuration”Users override or add servers in opencode.json:
{ "lsp": { "pyright": { "disabled": true }, "custom-python": { "command": ["custom-python-lsp", "--stdio"], "extensions": [".py"], "env": { "PYTHONPATH": "/custom/path" }, "initialization": { "pythonPath": "/usr/bin/python3" } } }}Setting "lsp": false disables all LSP integration entirely. Individual servers can be disabled by name.
Configuration priority (highest to lowest):
- Managed config directory (
/etc/opencodeon Linux; platform-specific equivalent on macOS/Windows) - Inline config (
OPENCODE_CONFIG_CONTENTenv var) .opencodedirectory configs (.opencode/opencode.json{,c}from project/home and optionalOPENCODE_CONFIG_DIR)- Project config (
opencode.json{,c}discovered from cwd up to worktree) - Custom config (
OPENCODE_CONFIGenv var path) - Global config (
~/.config/opencode/opencode.json{,c}) - Remote config (
.well-known/opencode)
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”1. Server Discovery and Installation
Section titled “1. Server Discovery and Installation”OpenCode’s auto-download mechanism (fetching LSP binaries from GitHub releases) is convenient but fragile. Network failures, rate limits, and version mismatches can leave users without language intelligence. The OPENCODE_DISABLE_LSP_DOWNLOAD flag is a necessary escape hatch.
2. Diagnostic Timing
Section titled “2. Diagnostic Timing”The 3-second wait for diagnostics after edits is a compromise. Fast servers (rust-analyzer, clangd) typically respond within 500ms. Slow servers (jdtls for Java, pyright on large codebases) may not finish analysis in 3 seconds, causing false negatives — the agent thinks there are no errors when the server simply hasn’t finished checking.
3. Error-Only Filtering
Section titled “3. Error-Only Filtering”OpenCode only injects Error-severity diagnostics into the AI context. This misses important warnings (unused variables, deprecated APIs, potential null dereferences) that could indicate the edit introduced problems. However, including all severities would flood the context with noise — there’s no good middle ground without a per-language severity policy.
4. Multi-Server File Overlap
Section titled “4. Multi-Server File Overlap”Multiple servers can serve the same file extension. For example, .ts files might be served by both typescript and eslint. OpenCode handles this by creating separate clients for each server, but diagnostic deduplication is left to the consumer — the same error could appear twice from different servers.
5. Workspace Root Detection
Section titled “5. Workspace Root Detection”Each server uses its own root detection heuristic (e.g., TypeScript looks for package.json, Rust looks for Cargo.toml). In monorepos, different servers may detect different roots for the same file, leading to inconsistent behavior. OpenCode’s per-server root() function is correct but can produce surprising results.
6. No Aider/Codex Equivalent
Section titled “6. No Aider/Codex Equivalent”The absence of LSP in Aider and Codex means the AI operates without compiler-level feedback. When the model introduces a type error or references a non-existent symbol, it won’t know until the user runs the code. This is a fundamental capability gap — but it also means zero setup burden.
7. LSP Server Stability
Section titled “7. LSP Server Stability”Language servers can crash, hang, or consume excessive memory. OpenCode tracks broken servers in a Set and skips them on future queries, but doesn’t attempt automatic restart. A crashed server means no diagnostics for that language until the session restarts.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture
Section titled “Architecture”Implement LSP as an optional subsystem that can be disabled entirely. The core agent must function without LSP — it’s an enhancement, not a requirement.
pub struct LspManager { clients: HashMap<(PathBuf, String), LspClient>, // (root, server_id) -> client servers: Vec<LspServerConfig>, // Registered server configs broken: HashSet<String>, // Server IDs that have crashed}
pub struct LspClient { connection: Connection, // lsp-server or tower-lsp based diagnostics: DashMap<PathBuf, Vec<Diagnostic>>, capabilities: ServerCapabilities,}
pub struct LspServerConfig { pub id: String, pub extensions: Vec<String>, pub command: Vec<String>, pub root_markers: Vec<String>, // Files to search for when finding root pub initialization: serde_json::Value,}Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
lsp-types | LSP protocol type definitions |
tower-lsp or lsp-server | JSON-RPC transport layer |
tokio::process | Async server process management |
dashmap | Concurrent diagnostic storage |
Diagnostic Feedback Loop
Section titled “Diagnostic Feedback Loop”Mirror OpenCode’s pattern but with configurable severity filtering:
pub struct DiagnosticConfig { pub min_severity: DiagnosticSeverity, // Default: Error pub max_per_file: usize, // Default: 10 pub wait_timeout: Duration, // Default: 3s pub debounce: Duration, // Default: 150ms}After every file write, call touch_file(path, wait=true). Collect diagnostics at or above min_severity, format them into a <diagnostics> XML block, and append to the tool output.
Server Registry
Section titled “Server Registry”Ship a built-in registry similar to OpenCode’s but defined in a TOML manifest rather than 2000 lines of TypeScript:
[servers.typescript]extensions = [".ts", ".tsx", ".js", ".jsx"]command = ["typescript-language-server", "--stdio"]root_markers = ["tsconfig.json", "package.json"]
[servers.rust]extensions = [".rs"]command = ["rust-analyzer"]root_markers = ["Cargo.toml"]Users override via openoxide.toml or environment variables. Auto-download is out of scope for v1 — users must have servers installed.
LSP Tool
Section titled “LSP Tool”Expose LSP operations as a tool the model can call explicitly:
pub enum LspOperation { GoToDefinition { file: PathBuf, line: u32, character: u32 }, FindReferences { file: PathBuf, line: u32, character: u32 }, Hover { file: PathBuf, line: u32, character: u32 }, DocumentSymbols { file: PathBuf }, WorkspaceSymbol { query: String },}This keeps the model in control — it decides when to query the language server rather than being bombarded with unsolicited information. Diagnostics after edits are the exception: those are always injected automatically.