Skip to content

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 LSP namespace is registered as a global service via ServiceMap.Service, with Instance.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 by Flag.OPENCODE_DISABLE_LSP_DOWNLOAD

Key Files

File PathLinesResponsibility
src/lsp/index.ts~559Module main entry: Effect Service definition, InstanceState state management, getClients intelligent matching, all public APIs
src/lsp/client.ts~253LSP client implementation: vscode-jsonrpc connection management, diagnostic collection, file version tracking, connection shutdown
src/lsp/server.ts~196830+ Language Server definitions: LSPServer.Info interface, NearestRoot higher-order function, spawn implementation and auto-download
src/lsp/launch.ts~22Spawn helper function: wraps Process.spawn, simplifying server launch code
src/lsp/language.ts~120LANGUAGE_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) combination
  • servers: Server registry merged from built-in definitions and user configuration. The key is the server ID (e.g. "typescript", "gopls")
  • broken: Set of server IDs that failed to launch. Once added to this set, subsequent getClients() calls skip them without retrying
  • spawning: 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 getClients calls share the same spawn Promise
  • Failed servers are recorded in the broken set 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):

CategoryServersRoot Detection Strategy
FrontendTypeScript, Deno, Vue, Astro, SvelteNearestRoot(["package.json", "tsconfig.json"], ["deno.json"])
Systems LanguagesGopls, Rust Analyzer, Clangd, SourceKit (Swift)NearestRoot(["go.mod"]) / Cargo.toml / compile_commands.json / Package.swift
Scripting LanguagesPyright, JDTLS (Java), KotlinLS, Rubocop (Ruby), JuliaLSNearestRoot(["pyproject.toml", "setup.py"]) / pom.xml / build.gradle etc.
Functional LanguagesLuaLS, Zls (Zig), Gleam, Ocaml, HLS (Haskell)NearestRoot([".luarc.json"]) / build.zig / gleam.toml / dune / hie.yaml
InfrastructureTerraformLS, DockerfileLS, YamlLS, NixdNearestRoot(["*.tf"]) / Dockerfile / *.yml / flake.nix
Data/QueryPrisma, TexLab (LaTeX), SQLSNearestRoot(["*.prisma"]) / *.tex / *.sql
OtherBiome, ESLint, Dart, Clojure, PHPIntelephense, CSharp, FSharpRespective config file detection
TypesettingTinymist (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)))
}
APILSP MethodDescription
definition(file, position)textDocument/definitionJump from reference to definition, returns Location[]
references(file, position)textDocument/referencesFind all reference locations of a symbol
hover(file, position)textDocument/hoverGet type information and documentation for a symbol
workspaceSymbol(query)workspace/symbolGlobal symbol search, filters kind 5/6/11/12 etc., max 10 results
documentSymbol(uri)textDocument/documentSymbolDocument symbol list, returns DocumentSymbol or Symbol
incomingCalls(file, position)Two-step requestprepareCallHierarchycallHierarchy/incomingCalls
outgoingCalls(file, position)Two-step requestprepareCallHierarchycallHierarchy/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

DecisionRationale
Effect Service instead of plain moduleLSP 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 retryLanguage 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 deduplicationConcurrent 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 diagnosticsLanguage 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 timeoutThe 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 functionDifferent 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 serversOut-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 errorAutomatically 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 resultsThe 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 uses LSP.diagnostics() to get the latest diagnostics, helping determine whether code fixes are needed. hover() and definition() help the Agent understand symbol semantics
  • Session / SessionPrompt: SessionPrompt calls LSP.documentSymbol() and LSP.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) and BusEvent.define("lsp.client.diagnostics"). Upper-layer modules (Agent, UI) subscribe to these events
  • Config: Config.lsp defines enabled servers, custom commands, and environment variables; cfg.lsp === false can globally disable the entire LSP module
  • Instance: LSP state is managed via Instance.state(), following the project instance lifecycle. Instance.directory is the search boundary for NearestRoot
  • Global/Flag: Global.Path.bin stores auto-downloaded Language Server binaries; Flag.OPENCODE_DISABLE_LSP_DOWNLOAD controls whether auto-downloading is allowed
  • Process: The spawn() function in launch.ts wraps Process.spawn(), unifying error handling and environment variable injection for child process launching