LSP Source Code Analysis
Module Overview
The LSP (Language Server Protocol) module gives OpenCode IDE-level code understanding capabilities. Through the standardized LSP protocol, OpenCode can obtain precise diagnostic information (compilation errors, type errors, lint warnings), jump to definitions, find references, get hover information, and browse symbol hierarchies. This enables the AI assistant to make decisions based on precise code semantics rather than simple text matching.
The module is located at packages/opencode/src/lsp/, consisting of 5 files totaling approximately 2900 lines of code. The core entry point LSP namespace adopts the Effect Service architecture, managing global singleton state through Instance.state(). The module includes built-in definitions for 30+ Language Servers (TypeScript, Gopls, Rust Analyzer, Pyright, Clangd, Lua LS, etc.), each with its own project root directory detection logic and spawn implementation.
Core design choices:
- Effect Service pattern: The
LSPnamespace is registered as a global service viaServiceMap.Service, withInstance.state()managing the lifecycle - Intelligent client reuse:
getClients(file)deduplicates by(root, serverID), launching only one server instance per project - Debounced diagnostic collection:
waitForDiagnostics()uses a 150ms debounce + 3-second timeout to avoid blocking the Agent - Auto-download and isolation: Language Server binaries are automatically downloaded to
Global.Path.bin, controlled byFlag.OPENCODE_DISABLE_LSP_DOWNLOAD
Key Files
| File Path | Lines | Responsibility |
|---|---|---|
src/lsp/index.ts | ~559 | Module main entry: Effect Service definition, InstanceState state management, getClients intelligent matching, all public APIs |
src/lsp/client.ts | ~253 | LSP client implementation: vscode-jsonrpc connection management, diagnostic collection, file version tracking, connection shutdown |
src/lsp/server.ts | ~1968 | 30+ Language Server definitions: LSPServer.Info interface, NearestRoot higher-order function, spawn implementation and auto-download |
src/lsp/launch.ts | ~22 | Spawn helper function: wraps Process.spawn, simplifying server launch code |
src/lsp/language.ts | ~120 | LANGUAGE_EXTENSIONS mapping table: file extension (e.g. .tsx) → LSP languageId (e.g. typescriptreact) |
Type System
Range and Diagnostic — Position and Diagnostics
// Text range (line number + character offset)
export const Range = z.object({
start: z.object({ line: z.number(), character: z.number() }),
end: z.object({ line: z.number(), character: z.number() }),
})
// Symbol information (for documentSymbol / workspaceSymbol)
export const Symbol = z.object({
name: z.string(),
kind: z.number(), // LSP SymbolKind enum value
location: z.object({
uri: z.string(),
range: Range,
}),
})
// Server connection status
export const Status = z.object({
id: z.string(), // Unique server identifier
name: z.string(), // Human-readable name
root: z.string(), // Project root directory
status: z.union([
z.literal("connected"),
z.literal("error"),
]),
})
InstanceState — Global State
// LSP module global state structure
{
clients: LSPClient.Info[], // All active client connections
servers: Record<string, LSPServer.Info>, // Registered server definitions (key = serverID)
broken: Set<string>, // Known failed server IDs, to avoid retrying
spawning: Map<string, Promise<LSPClient.Info | undefined>>, // Currently launching clients (deduplication)
}
Key field descriptions:
clients: All active LSP client connections. Each client is bound to a(root, serverID)combinationservers: Server registry merged from built-in definitions and user configuration. Thekeyis the server ID (e.g."typescript","gopls")broken: Set of server IDs that failed to launch. Once added to this set, subsequentgetClients()calls skip them without retryingspawning: In-flight client launch Promises. Concurrent requests for the same(root, serverID)share the same Promise, preventing duplicate launches
LSPServer.Info — Server Definition
// Definition interface for each Language Server in server.ts
interface Info {
id: string // Unique identifier, e.g. "typescript", "gopls", "rust-analyzer"
extensions: string[] // Associated file extensions, e.g. [".ts", ".tsx"]
root: RootFunction // Function to determine project root directory
spawn(root: string): Promise<Handle | undefined> // Launch server process
}
LSPClient.Info — Client Instance
// Core client structure in client.ts
{
serverID: string // Corresponding server ID
root: string // Project root directory
connection: MessageConnection // vscode-jsonrpc message connection
diagnostics: Map<string, Diagnostic[]> // File path → diagnostic list
files: Record<string, number> // File path → version number (incrementing counter)
process: ChildProcess // Server child process
}
Core Flow
getClients — Intelligent File-Client Matching
getClients(file) is the most critical function in the LSP module, responsible for finding or creating the appropriate LSP client for a given file. The complete logic consists of 8 steps:
// index.ts — simplified core logic
async function getClients(file: string): Promise<LSPClient.Info[]> {
const state = await instanceState()
const results: LSPClient.Info[] = []
// Step 1: Check if the file is within the Instance directory
// Files outside the project directory are not processed
if (!file.startsWith(instance.directory)) return results
// Step 2: Extract file extension
const ext = path.extname(file) // e.g. ".ts", ".go"
// Step 3: Iterate all registered servers, check extension match
for (const [serverID, server] of Object.entries(state.servers)) {
if (!server.extensions.includes(ext)) continue
// Step 4: Call server.root(file) to determine project root directory
const root = server.root(file)
if (!root) continue // File does not belong to any project
// Step 5: Check broken set, skip known failed servers
if (state.broken.has(serverID)) continue
// Step 6: Look for existing client (root + serverID match)
const existing = state.clients.find(
c => c.root === root && c.serverID === serverID
)
if (existing) {
results.push(existing)
continue
}
// Step 7: Check spawning Map for concurrent requests
const spawnKey = `${root}:${serverID}`
const spawning = state.spawning.get(spawnKey)
if (spawning) {
const client = await spawning
if (client) results.push(client)
continue
}
// Step 8: Create new client and store in cache
const promise = LSPClient.create({ serverID, server: server.handle, root })
.catch(() => {
state.broken.add(serverID) // Launch failed → add to broken set
return undefined
})
state.spawning.set(spawnKey, promise)
const client = await promise
state.spawning.delete(spawnKey)
if (client) {
state.clients.push(client)
results.push(client)
}
}
return results
}
This logic implements complete client reuse and deduplication:
- Two Language Servers of the same type will never be launched for the same project
- Concurrent
getClientscalls share the same spawn Promise - Failed servers are recorded in the
brokenset and not retried
LSPClient.create — JSON-RPC Connection Establishment
LSPClient.create() is the core function for client creation, establishing the communication channel with the Language Server:
// client.ts — core flow (simplified)
async function create(input: {
serverID: string
server: LSPServer.Handle
root: string
}): Promise<Info> {
// 1. Launch server child process
const handle = await input.server.spawn(input.root)
if (!handle) throw new Error("spawn failed")
// 2. Establish JSON-RPC message connection
const connection = createMessageConnection(
new StreamMessageReader(handle.process.stdout),
new StreamMessageWriter(handle.process.stdin),
)
// 3. Start message listener
connection.listen()
// 4. Send initialize request (45-second timeout)
const initResult = await withTimeout(
connection.sendRequest("initialize", {
rootUri: pathToUri(input.root),
capabilities: {
textDocument: {
synchronization: { didOpen: true, didChange: true, didClose: true },
publishDiagnostics: { relatedInformation: true },
},
workspace: { configuration: true },
},
}),
45_000,
)
// 5. Send initialized notification
connection.sendNotification("initialized", {})
// 6. Create client instance
return {
serverID: input.serverID,
root: input.root,
connection,
process: handle.process,
diagnostics: new Map(),
files: {},
}
}
After the connection is established, the client listens for textDocument/publishDiagnostics notifications and automatically updates the diagnostic map.
Diagnostic Collection Flow
Diagnostic collection is the most critical output of the LSP module, directly impacting the Agent’s ability to fix code. The complete flow:
1. Agent edits file
└─ LSP.touchFile(filePath, content, true)
2. getClients(file) matches appropriate clients
└─ by extension → server → root → reuse/create client
3. Client sends file change notification
├─ First open? → textDocument/didOpen
└─ Already open? → textDocument/didChange (with version number)
4. Language Server analyzes code and pushes diagnostics
└─ textDocument/publishDiagnostics notification
5. Client updates diagnostics Map
└─ TypeScript server special: first diagnostic does not publish event (!exists check)
6. Broadcast to upper layers via Bus
└─ Bus.publish(Event.Diagnostics, { ... })
waitForDiagnostics debounce mechanism:
// client.ts — diagnostic waiting (simplified)
async function waitForDiagnostics(
client: LSPClient.Info,
uri: string,
): Promise<Diagnostic[]> {
const DIAGNOSTICS_DEBOUNCE_MS = 150 // Debounce interval
const DIAGNOSTICS_TIMEOUT_MS = 3000 // Total timeout
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout>
// Subscribe to diagnostic events
const unsub = Bus.subscribe(Event.Diagnostics, (event) => {
// Only care about the current file
if (event.uri !== uri) return
// Reset timer after receiving diagnostics (debounce)
clearTimeout(timer)
timer = setTimeout(() => {
unsub()
resolve(client.diagnostics.get(uri) ?? [])
}, DIAGNOSTICS_DEBOUNCE_MS)
})
// Total timeout protection: force return after 3 seconds
setTimeout(() => {
unsub()
clearTimeout(timer)
resolve(client.diagnostics.get(uri) ?? [])
}, DIAGNOSTICS_TIMEOUT_MS)
})
}
The reason for the 150ms debounce: Language Servers typically send syntactic diagnostics first, followed by semantic diagnostics later. Debouncing ensures the Agent receives the complete diagnostic result rather than an intermediate state. The 3-second total timeout prevents the Agent from waiting indefinitely.
Server Definitions and NearestRoot
server.ts is the largest file in the module (~1968 lines), defining 30+ Language Servers. The core of each server definition consists of two functions: root() and spawn().
NearestRoot higher-order function:
// server.ts — project root directory detection (simplified)
function NearestRoot(
files: string[], // Target file name list, e.g. ["package.json", "tsconfig.json"]
exclude?: string[], // Exclusion patterns, e.g. ["deno.json"]
): (file: string) => string | undefined {
return (file: string) => {
// Search upward from the file's directory until Instance.directory
let dir = path.dirname(file)
while (dir.startsWith(instance.directory)) {
// Check if target files exist
for (const name of files) {
const target = path.join(dir, name)
if (fs.existsSync(target)) {
// Check exclusion patterns
if (exclude?.some(ex => fs.existsSync(path.join(dir, ex)))) {
continue // Matched exclusion rule, skip this directory
}
return dir
}
}
dir = path.dirname(dir)
}
return undefined
}
}
This higher-order function supports monorepo scenarios: files in different subdirectories can match different project roots, thus launching independent Language Server instances. Exclusion patterns handle special cases (e.g., the TypeScript server excludes deno.json to avoid launching the wrong TS server in Deno projects).
Built-in server overview (30+ types):
| Category | Servers | Root Detection Strategy |
|---|---|---|
| Frontend | TypeScript, Deno, Vue, Astro, Svelte | NearestRoot(["package.json", "tsconfig.json"], ["deno.json"]) |
| Systems Languages | Gopls, Rust Analyzer, Clangd, SourceKit (Swift) | NearestRoot(["go.mod"]) / Cargo.toml / compile_commands.json / Package.swift |
| Scripting Languages | Pyright, JDTLS (Java), KotlinLS, Rubocop (Ruby), JuliaLS | NearestRoot(["pyproject.toml", "setup.py"]) / pom.xml / build.gradle etc. |
| Functional Languages | LuaLS, Zls (Zig), Gleam, Ocaml, HLS (Haskell) | NearestRoot([".luarc.json"]) / build.zig / gleam.toml / dune / hie.yaml |
| Infrastructure | TerraformLS, DockerfileLS, YamlLS, Nixd | NearestRoot(["*.tf"]) / Dockerfile / *.yml / flake.nix |
| Data/Query | Prisma, TexLab (LaTeX), SQLS | NearestRoot(["*.prisma"]) / *.tex / *.sql |
| Other | Biome, ESLint, Dart, Clojure, PHPIntelephense, CSharp, FSharp | Respective config file detection |
| Typesetting | Tinymist (Typst) | NearestRoot(["*.typ"]) |
Auto-Download Logic
Some Language Servers (e.g., Biome, Deno, Ty) support automatic downloading. The flow is as follows:
// server.ts — auto-download (simplified)
async function spawnWithDownload(input: {
command: string
args: string[]
url: string // Download URL template
}): Promise<Handle | undefined> {
// Check flag
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) {
// Only try local lookup, no download
return Process.spawn(input.command, input.args)
}
// Try local launch
const local = await Process.spawn(input.command, input.args)
if (local) return local
// Not found locally → auto-download to Global.Path.bin
const binPath = path.join(Global.Path.bin, input.command)
if (!fs.existsSync(binPath)) {
await download(input.url, binPath)
}
return Process.spawn(binPath, input.args)
}
Code Navigation API
The LSP module provides complete code navigation capabilities. All APIs route to matching clients through the run() helper function:
// index.ts — run helper function (simplified)
async function run<T>(
file: string,
fn: (client: LSPClient.Info, uri: string) => Promise<T>,
): Promise<T[]> {
const clients = await getClients(file)
const uri = pathToUri(file)
return Promise.all(clients.map(c => fn(c, uri)))
}
| API | LSP Method | Description |
|---|---|---|
definition(file, position) | textDocument/definition | Jump from reference to definition, returns Location[] |
references(file, position) | textDocument/references | Find all reference locations of a symbol |
hover(file, position) | textDocument/hover | Get type information and documentation for a symbol |
workspaceSymbol(query) | workspace/symbol | Global symbol search, filters kind 5/6/11/12 etc., max 10 results |
documentSymbol(uri) | textDocument/documentSymbol | Document symbol list, returns DocumentSymbol or Symbol |
incomingCalls(file, position) | Two-step request | prepareCallHierarchy → callHierarchy/incomingCalls |
outgoingCalls(file, position) | Two-step request | prepareCallHierarchy → callHierarchy/outgoingCalls |
workspaceSymbol filtering logic:
// index.ts — workspaceSymbol (simplified)
async function workspaceSymbol(query: string): Promise<Symbol[]> {
const allClients = await allClients()
const results: Symbol[] = []
for (const client of allClients) {
const symbols = await client.connection.sendRequest(
"workspace/symbol",
{ query },
)
for (const sym of symbols) {
// Filter to key types: Class(5), Function(6/12), Method(6), Enum(10)
if ([5, 6, 11, 12].includes(sym.kind)) {
results.push(sym)
}
}
}
// Return at most 10 results to avoid context bloat
return results.slice(0, 10)
}
Call Chain Examples
Chain 1: Agent Edits File → Diagnostic Collection → Agent Fix
1. Agent executes EditTool to modify file
└─ SessionProcessor processes tool-result event
2. Agent calls LSP.touchFile(filePath, newContent, true)
├─ getClients(file)
│ ├─ Extract extension: ".ts"
│ ├─ Match server: Typescript (extensions includes ".ts")
│ ├─ root(file) → NearestRoot(["package.json"]) → "/project/root"
│ ├─ Find existing client: root="/project/root", serverID="typescript" → found
│ └─ Return [existingClient]
└─ client.notify.didChange({ uri, version: 3, content: newContent })
3. Language Server analyzes the modified file
└─ Pushes textDocument/publishDiagnostics notification
4. Client updates diagnostics Map
└─ Bus.publish(Event.Diagnostics, { uri, diagnostics: [...] })
5. Agent calls LSP.waitForDiagnostics(file)
├─ Subscribe to Diagnostics event
├─ Received notification → start 150ms debounce timer
├─ No new diagnostics within 150ms → resolve
└─ Return [{ message: "Type 'string' is not assignable to 'number'", range: {...} }]
6. Agent decides whether to continue fixing based on diagnostics
└─ In the next iteration of SessionPrompt.loop(), the Agent sees the diagnostic results and decides on a fix strategy
Chain 2: First File Open → Server Launch → Diagnostics Ready
1. Agent reads "main.go" for the first time
└─ LSP.touchFile("main.go", content, true)
2. getClients("main.go")
├─ Extension: ".go"
├─ Match server: Gopls (extensions includes ".go")
├─ root("main.go") → NearestRoot(["go.mod"]) → "/project/root"
├─ Find existing client: no match (first open)
├─ Check spawning Map: none (first time)
└─ Create new client:
├─ LSPClient.create({ serverID: "gopls", root: "/project/root" })
│ ├─ server.spawn(root) → Process.spawn("gopls", ["serve"])
│ ├─ createMessageConnection(stdout, stdin)
│ ├─ connection.sendRequest("initialize", { rootUri, capabilities })
│ ├─ connection.sendNotification("initialized", {})
│ └─ Return new client
├─ state.clients.push(newClient)
└─ Return [newClient]
3. New client sends textDocument/didOpen
└─ connection.sendNotification("textDocument/didOpen", { textDocument: { uri, languageId: "go", version: 1, text: content } })
4. Gopls analyzes and pushes diagnostics
└─ publishDiagnostics → diagnostics Map updated → Bus broadcast
5. waitForDiagnostics("main.go")
├─ 150ms debounce wait
└─ Return diagnostic results
Chain 3: Multiple Projects Coexisting in a Monorepo
Project structure:
/project
├── frontend/
│ ├── package.json
│ └── app.tsx ← TypeScript file
├── backend/
│ ├── go.mod
│ └── main.go ← Go file
└── shared/
└── utils.ts ← TypeScript file
1. Agent opens "frontend/app.tsx"
├─ getClients → Typescript server
├─ root("frontend/app.tsx") → NearestRoot → "/project/frontend"
└─ Launch TS Client A (root="/project/frontend")
2. Agent opens "shared/utils.ts"
├─ getClients → Typescript server
├─ root("shared/utils.ts") → NearestRoot → "/project" (searches upward to project root's package.json)
└─ Launch TS Client B (root="/project") — different root, different instance
3. Agent opens "backend/main.go"
├─ getClients → Gopls server
├─ root("backend/main.go") → NearestRoot → "/project/backend"
└─ Launch Go Client C (root="/project/backend")
Result: 3 independent LSP clients coexisting without interference
Chain 4: Server Launch Failure → Broken Mark → Subsequent Skip
1. Agent opens "broken.rs"
└─ getClients → Rust Analyzer server
2. LSPClient.create() → server.spawn() fails
├─ catch block captures error
├─ state.broken.add("rust-analyzer")
└─ Return undefined
3. Agent opens "other.rs" again
└─ getClients("other.rs")
├─ Match server: Rust Analyzer
├─ Check broken set: "rust-analyzer" ∈ broken
└─ Skip, no retry → return empty list
4. Agent sees no diagnostic results, continues working normally
Design Tradeoffs
| Decision | Rationale |
|---|---|
| Effect Service instead of plain module | LSP state needs to follow the Instance lifecycle (initialized when a project opens, cleaned up when closed). Instance.state() + Effect.addFinalizer ensures client connections and child processes are properly cleaned up when the project is closed |
| Broken set instead of retry | Language Server launch failures are usually due to environmental issues like missing binaries or port conflicts. Repeated retries only waste time and resources; it is better to mark and skip them directly |
| spawning Map deduplication | Concurrent getClients calls (e.g., Agent editing multiple .ts files simultaneously) must share the same spawn Promise, otherwise multiple identical Language Server processes would be launched |
| 150ms debounced diagnostics | Language Servers typically push syntactic diagnostics first (based on AST), then semantic diagnostics (based on type checking). Debouncing ensures the Agent sees the complete diagnostic set |
| 3-second total timeout | The Agent cannot wait indefinitely due to slow LSP responses. 3 seconds is the balance point between “diagnostics are usually sufficiently complete” and “Agent cannot wait too long” |
| NearestRoot higher-order function | Different servers need to detect different configuration files; the higher-order function abstracts this pattern. Exclusion patterns (e.g., TypeScript excluding deno.json) handle conflicts between language servers |
| 30+ built-in servers | Out-of-the-box support for mainstream development languages is a core selling point of OpenCode. Users can also add custom servers or disable built-in servers via Config.lsp |
| Auto-download instead of error | Automatically downloading missing Language Server binaries (e.g., Biome, Deno) is better than exiting with an error. Flag.OPENCODE_DISABLE_LSP_DOWNLOAD allows users to disable this behavior in restricted environments |
| workspaceSymbol filtered to 10 results | The Agent’s context window is limited; too many symbols dilute useful information. Filtering to key types like Class/Function/Method/Enum and limiting to 10 results balances information density and completeness |
State Management and Lifecycle
InstanceState Initialization
LSP state is created as a lazily-initialized singleton via Instance.state():
// index.ts — state initialization (simplified)
const instanceState = Instance.state(async () => {
const cfg = await Config.get()
// 1. Collect built-in server definitions
const servers: Record<string, LSPServer.Info> = {}
for (const server of builtInServers) {
servers[server.id] = server
}
// 2. Merge user-defined server configuration
if (cfg.lsp) {
for (const [id, custom] of Object.entries(cfg.lsp)) {
if (custom.disabled) {
delete servers[id] // Disable built-in server
continue
}
// Override built-in server's command, extensions, env, initialization, etc.
if (servers[id]) {
servers[id] = mergeServerConfig(servers[id], custom)
} else {
servers[id] = createCustomServer(id, custom)
}
}
}
// 3. Global disable check
if (cfg.lsp === false) {
return { clients: [], servers: {}, broken: new Set(), spawning: new Map() }
}
return { clients: [], servers, broken: new Set(), spawning: new Map() }
})
Effect.addFinalizer Cleanup
// index.ts — lifecycle cleanup (simplified)
Effect.addFinalizer(async () => {
const state = await instanceState()
for (const client of state.clients) {
await LSPClient.shutdown(client)
}
state.clients = []
state.broken.clear()
state.spawning.clear()
})
When an Instance (project) is closed, all LSP client connections are disconnected and child processes are terminated.
LSPClient.shutdown — Connection Shutdown
// client.ts — shutdown flow (simplified)
async function shutdown(client: LSPClient.Info): Promise<void> {
try {
// 1. Send shutdown request (graceful shutdown)
await withTimeout(
client.connection.sendRequest("shutdown"),
5000,
)
// 2. Send exit notification
client.connection.sendNotification("exit")
} catch {
// Timeout or error → force kill process
} finally {
// 3. Close connection
client.connection.dispose()
// 4. Kill child process
client.process.kill()
}
}
Relationships with Other Modules
- Agent: The Agent calls
LSP.touchFile()after editing files and usesLSP.diagnostics()to get the latest diagnostics, helping determine whether code fixes are needed.hover()anddefinition()help the Agent understand symbol semantics - Session / SessionPrompt:
SessionPromptcallsLSP.documentSymbol()andLSP.workspaceSymbol()when constructing user messages to provide the LLM with a code structure overview - Bus: LSP notifies diagnostic state changes via
Bus.publish(Event.Diagnostics)andBusEvent.define("lsp.client.diagnostics"). Upper-layer modules (Agent, UI) subscribe to these events - Config:
Config.lspdefines enabled servers, custom commands, and environment variables;cfg.lsp === falsecan globally disable the entire LSP module - Instance: LSP state is managed via
Instance.state(), following the project instance lifecycle.Instance.directoryis the search boundary forNearestRoot - Global/Flag:
Global.Path.binstores auto-downloaded Language Server binaries;Flag.OPENCODE_DISABLE_LSP_DOWNLOADcontrols whether auto-downloading is allowed - Process: The
spawn()function inlaunch.tswrapsProcess.spawn(), unifying error handling and environment variable injection for child process launching