LSP 源码解读
模块概述
LSP(Language Server Protocol)模块让 OpenCode 获得了 IDE 级别的代码理解能力。通过标准化的 LSP 协议,OpenCode 可以获取精确的诊断信息(编译错误、类型错误、lint 警告)、跳转到定义、查找引用、获取悬浮信息、浏览符号层级。这让 AI 助手基于精确的代码语义做出决策,而非简单的文本匹配。
模块位于 packages/opencode/src/lsp/,由 5 个文件组成,总共约 2900 行代码。核心入口 LSP 命名空间采用 Effect Service 架构,通过 Instance.state() 管理全局单例状态。模块内置 30+ 种 Language Server 的定义(TypeScript、Gopls、Rust Analyzer、Pyright、Clangd、Lua LS 等),每种服务器有自己的项目根目录检测逻辑和 spawn 实现。
核心设计选择:
- Effect Service 模式:
LSP命名空间通过ServiceMap.Service注册为全局服务,Instance.state()管理生命周期 - 智能客户端复用:
getClients(file)按(root, serverID)去重,同一项目只启动一个服务器实例 - 去抖诊断收集:
waitForDiagnostics()使用 150ms 去抖 + 3 秒超时,避免 Agent 阻塞 - 自动下载与隔离:Language Server 二进制文件自动下载到
Global.Path.bin,通过Flag.OPENCODE_DISABLE_LSP_DOWNLOAD控制
关键文件
| 文件路径 | 行数 | 职责 |
|---|---|---|
src/lsp/index.ts | ~559 | 模块主入口:Effect Service 定义、InstanceState 状态管理、getClients 智能匹配、所有公共 API |
src/lsp/client.ts | ~253 | LSP 客户端实现:vscode-jsonrpc 连接管理、诊断收集、文件版本追踪、连接关闭 |
src/lsp/server.ts | ~1968 | 30+ 种 Language Server 定义:LSPServer.Info 接口、NearestRoot 高阶函数、spawn 实现与自动下载 |
src/lsp/launch.ts | ~22 | spawn 辅助函数:包装 Process.spawn,简化服务器启动代码 |
src/lsp/language.ts | ~120 | LANGUAGE_EXTENSIONS 映射表:文件扩展名(如 .tsx)→ LSP languageId(如 typescriptreact) |
类型体系
Range 与 Diagnostic — 位置与诊断
// 文本范围(行号 + 字符偏移)
export const Range = z.object({
start: z.object({ line: z.number(), character: z.number() }),
end: z.object({ line: z.number(), character: z.number() }),
})
// 符号信息(用于 documentSymbol / workspaceSymbol)
export const Symbol = z.object({
name: z.string(),
kind: z.number(), // LSP SymbolKind 枚举值
location: z.object({
uri: z.string(),
range: Range,
}),
})
// 服务器连接状态
export const Status = z.object({
id: z.string(), // 服务器唯一标识
name: z.string(), // 人类可读名称
root: z.string(), // 项目根目录
status: z.union([
z.literal("connected"),
z.literal("error"),
]),
})
InstanceState — 全局状态
// LSP 模块的全局状态结构
{
clients: LSPClient.Info[], // 所有活跃的客户端连接
servers: Record<string, LSPServer.Info>, // 已注册的服务器定义(key = serverID)
broken: Set<string>, // 已知失败的服务器 ID,避免重复尝试
spawning: Map<string, Promise<LSPClient.Info | undefined>>, // 正在启动的客户端(去重)
}
关键字段说明:
clients:所有活跃的 LSP 客户端连接。每个客户端绑定到一个(root, serverID)组合servers:从内置定义 + 用户配置合并后的服务器注册表。key是服务器 ID(如"typescript"、"gopls")broken:启动失败的服务器 ID 集合。一旦进入此集合,后续getClients()直接跳过,不再重试spawning:正在启动中的客户端 Promise。同一(root, serverID)的并发请求会共享同一个 Promise,避免重复启动
LSPServer.Info — 服务器定义
// server.ts 中每种 Language Server 的定义接口
interface Info {
id: string // 唯一标识,如 "typescript"、"gopls"、"rust-analyzer"
extensions: string[] // 关联的文件扩展名,如 [".ts", ".tsx"]
root: RootFunction // 确定项目根目录的函数
spawn(root: string): Promise<Handle | undefined> // 启动服务器进程
}
LSPClient.Info — 客户端实例
// client.ts 中客户端的核心结构
{
serverID: string // 对应的服务器 ID
root: string // 项目根目录
connection: MessageConnection // vscode-jsonrpc 消息连接
diagnostics: Map<string, Diagnostic[]> // 文件路径 → 诊断列表
files: Record<string, number> // 文件路径 → 版本号(递增计数器)
process: ChildProcess // 服务器子进程
}
核心流程
getClients — 智能文件-客户端匹配
getClients(file) 是 LSP 模块最核心的函数,负责为给定文件找到或创建合适的 LSP 客户端。完整逻辑分为 8 个步骤:
// index.ts — 简化后的核心逻辑
async function getClients(file: string): Promise<LSPClient.Info[]> {
const state = await instanceState()
const results: LSPClient.Info[] = []
// 步骤 1:检查文件是否在 Instance 目录内
// 不在项目目录内的文件不处理
if (!file.startsWith(instance.directory)) return results
// 步骤 2:提取文件扩展名
const ext = path.extname(file) // 如 ".ts"、".go"
// 步骤 3:遍历所有注册服务器,检查扩展名匹配
for (const [serverID, server] of Object.entries(state.servers)) {
if (!server.extensions.includes(ext)) continue
// 步骤 4:调用 server.root(file) 确定项目根目录
const root = server.root(file)
if (!root) continue // 该文件不属于任何项目
// 步骤 5:检查 broken 集合,跳过已知失败的服务器
if (state.broken.has(serverID)) continue
// 步骤 6:查找已有客户端(root + serverID 匹配)
const existing = state.clients.find(
c => c.root === root && c.serverID === serverID
)
if (existing) {
results.push(existing)
continue
}
// 步骤 7:检查 spawning Map 处理并发请求
const spawnKey = `${root}:${serverID}`
const spawning = state.spawning.get(spawnKey)
if (spawning) {
const client = await spawning
if (client) results.push(client)
continue
}
// 步骤 8:新建客户端并存入缓存
const promise = LSPClient.create({ serverID, server: server.handle, root })
.catch(() => {
state.broken.add(serverID) // 启动失败 → 加入 broken 集合
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
}
这段逻辑实现了完整的客户端复用与去重:
- 同一项目下不会启动两个相同类型的 Language Server
- 并发的
getClients调用共享同一个 spawn Promise - 失败的服务器被记入
broken集合,不再重试
LSPClient.create — JSON-RPC 连接建立
LSPClient.create() 是客户端创建的核心函数,建立与 Language Server 的通信通道:
// client.ts — 核心流程(简化)
async function create(input: {
serverID: string
server: LSPServer.Handle
root: string
}): Promise<Info> {
// 1. 启动服务器子进程
const handle = await input.server.spawn(input.root)
if (!handle) throw new Error("spawn failed")
// 2. 建立 JSON-RPC 消息连接
const connection = createMessageConnection(
new StreamMessageReader(handle.process.stdout),
new StreamMessageWriter(handle.process.stdin),
)
// 3. 启动消息监听
connection.listen()
// 4. 发送 initialize 请求(超时 45 秒)
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. 发送 initialized 通知
connection.sendNotification("initialized", {})
// 6. 创建客户端实例
return {
serverID: input.serverID,
root: input.root,
connection,
process: handle.process,
diagnostics: new Map(),
files: {},
}
}
连接建立后,客户端监听 textDocument/publishDiagnostics 通知并自动更新诊断映射。
诊断收集流程
诊断收集是 LSP 模块最核心的输出,直接影响 Agent 的代码修复能力。完整流程:
1. Agent 编辑文件
└─ LSP.touchFile(filePath, content, true)
2. getClients(file) 匹配合适的客户端
└─ 按扩展名 → 服务器 → root → 复用/新建客户端
3. 客户端发送文件变更通知
├─ 首次打开? → textDocument/didOpen
└─ 已打开? → textDocument/didChange (带版本号)
4. Language Server 分析代码后推送诊断
└─ textDocument/publishDiagnostics 通知
5. 客户端更新 diagnostics Map
└─ TypeScript 服务器特殊:首次诊断不发布事件(!exists 检查)
6. 通过 Bus 广播给上层
└─ Bus.publish(Event.Diagnostics, { ... })
waitForDiagnostics 去抖机制:
// client.ts — 诊断等待(简化)
async function waitForDiagnostics(
client: LSPClient.Info,
uri: string,
): Promise<Diagnostic[]> {
const DIAGNOSTICS_DEBOUNCE_MS = 150 // 去抖间隔
const DIAGNOSTICS_TIMEOUT_MS = 3000 // 总超时
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout>
// 订阅诊断事件
const unsub = Bus.subscribe(Event.Diagnostics, (event) => {
// 只关心当前文件
if (event.uri !== uri) return
// 收到诊断后重置计时器(去抖)
clearTimeout(timer)
timer = setTimeout(() => {
unsub()
resolve(client.diagnostics.get(uri) ?? [])
}, DIAGNOSTICS_DEBOUNCE_MS)
})
// 总超时保护:3 秒后强制返回
setTimeout(() => {
unsub()
clearTimeout(timer)
resolve(client.diagnostics.get(uri) ?? [])
}, DIAGNOSTICS_TIMEOUT_MS)
})
}
150ms 去抖的原因:Language Server 通常先发送语法诊断,稍后发送语义诊断。去抖确保 Agent 拿到的是完整的诊断结果,而非中间状态。3 秒总超时避免 Agent 无限等待。
服务器定义与 NearestRoot
server.ts 是模块中最大的文件(~1968 行),定义了 30+ 种 Language Server。每种服务器的核心是两个函数:root() 和 spawn()。
NearestRoot 高阶函数:
// server.ts — 项目根目录检测(简化)
function NearestRoot(
files: string[], // 目标文件名列表,如 ["package.json", "tsconfig.json"]
exclude?: string[], // 排除模式,如 ["deno.json"]
): (file: string) => string | undefined {
return (file: string) => {
// 从文件所在目录向上搜索直到 Instance.directory
let dir = path.dirname(file)
while (dir.startsWith(instance.directory)) {
// 检查目标文件是否存在
for (const name of files) {
const target = path.join(dir, name)
if (fs.existsSync(target)) {
// 检查排除模式
if (exclude?.some(ex => fs.existsSync(path.join(dir, ex)))) {
continue // 命中排除规则,跳过此目录
}
return dir
}
}
dir = path.dirname(dir)
}
return undefined
}
}
这个高阶函数支持 monorepo 场景:不同子目录下的文件可以匹配到不同的项目根目录,从而启动独立的 Language Server 实例。排除模式用于处理特殊情况(如 TypeScript 服务器排除 deno.json,避免在 Deno 项目中启动错误的 TS 服务器)。
内置服务器概览(30+ 种):
| 类别 | 服务器 | 根目录检测策略 |
|---|---|---|
| 前端 | TypeScript, Deno, Vue, Astro, Svelte | NearestRoot(["package.json", "tsconfig.json"], ["deno.json"]) |
| 系统语言 | Gopls, Rust Analyzer, Clangd, SourceKit (Swift) | NearestRoot(["go.mod"]) / Cargo.toml / compile_commands.json / Package.swift |
| 脚本语言 | Pyright, JDTLS (Java), KotlinLS, Rubocop (Ruby), JuliaLS | NearestRoot(["pyproject.toml", "setup.py"]) / pom.xml / build.gradle 等 |
| 函数式语言 | LuaLS, Zls (Zig), Gleam, Ocaml, HLS (Haskell) | NearestRoot([".luarc.json"]) / build.zig / gleam.toml / dune / hie.yaml |
| 基础设施 | TerraformLS, DockerfileLS, YamlLS, Nixd | NearestRoot(["*.tf"]) / Dockerfile / *.yml / flake.nix |
| 数据/查询 | Prisma, TexLab (LaTeX), SQLS | NearestRoot(["*.prisma"]) / *.tex / *.sql |
| 其他 | Biome, ESLint, Dart, Clojure, PHPIntelephense, CSharp, FSharp | 各自的配置文件检测 |
| 排版 | Tinymist (Typst) | NearestRoot(["*.typ"]) |
自动下载逻辑
部分 Language Server(如 Biome、Deno、Ty)支持自动下载,流程如下:
// server.ts — 自动下载(简化)
async function spawnWithDownload(input: {
command: string
args: string[]
url: string // 下载地址模板
}): Promise<Handle | undefined> {
// 检查标志位
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) {
// 只尝试本地查找,不下载
return Process.spawn(input.command, input.args)
}
// 尝试本地启动
const local = await Process.spawn(input.command, input.args)
if (local) return local
// 本地不存在 → 自动下载到 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)
}
代码导航 API
LSP 模块提供完整的代码导航能力,所有 API 通过 run() 辅助函数路由到匹配的客户端:
// index.ts — run 辅助函数(简化)
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 | 说明 |
|---|---|---|
definition(file, position) | textDocument/definition | 从引用跳转到定义,返回 Location[] |
references(file, position) | textDocument/references | 查找符号的所有引用位置 |
hover(file, position) | textDocument/hover | 获取符号的类型信息和文档 |
workspaceSymbol(query) | workspace/symbol | 全局符号搜索,过滤 kind 5/6/11/12 等,最多 10 条 |
documentSymbol(uri) | textDocument/documentSymbol | 文档内符号列表,返回 DocumentSymbol 或 Symbol |
incomingCalls(file, position) | 两步请求 | prepareCallHierarchy → callHierarchy/incomingCalls |
outgoingCalls(file, position) | 两步请求 | prepareCallHierarchy → callHierarchy/outgoingCalls |
workspaceSymbol 的过滤逻辑:
// index.ts — workspaceSymbol(简化)
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) {
// 过滤到关键类型:Class(5), Function(6/12), Method(6), Enum(10)
if ([5, 6, 11, 12].includes(sym.kind)) {
results.push(sym)
}
}
}
// 最多返回 10 条,避免上下文膨胀
return results.slice(0, 10)
}
调用链示例
链路 1:Agent 编辑文件 → 诊断收集 → Agent 修复
1. Agent 执行 EditTool 修改文件
└─ SessionProcessor 处理 tool-result 事件
2. Agent 调用 LSP.touchFile(filePath, newContent, true)
├─ getClients(file)
│ ├─ 提取扩展名: ".ts"
│ ├─ 匹配服务器: Typescript (extensions 包含 ".ts")
│ ├─ root(file) → NearestRoot(["package.json"]) → "/project/root"
│ ├─ 查找已有客户端: root="/project/root", serverID="typescript" → 命中
│ └─ 返回 [existingClient]
└─ client.notify.didChange({ uri, version: 3, content: newContent })
3. Language Server 分析变更后的文件
└─ 推送 textDocument/publishDiagnostics 通知
4. 客户端更新 diagnostics Map
└─ Bus.publish(Event.Diagnostics, { uri, diagnostics: [...] })
5. Agent 调用 LSP.waitForDiagnostics(file)
├─ 订阅 Diagnostics 事件
├─ 收到通知 → 启动 150ms 去抖计时器
├─ 150ms 内无新诊断 → resolve
└─ 返回 [{ message: "Type 'string' is not assignable to 'number'", range: {...} }]
6. Agent 根据诊断决定是否继续修复
└─ SessionPrompt.loop() 下一次迭代中,Agent 看到诊断结果并决定修复策略
链路 2:首次打开文件 → 服务器启动 → 诊断就绪
1. Agent 首次读取 "main.go"
└─ LSP.touchFile("main.go", content, true)
2. getClients("main.go")
├─ 扩展名: ".go"
├─ 匹配服务器: Gopls (extensions 包含 ".go")
├─ root("main.go") → NearestRoot(["go.mod"]) → "/project/root"
├─ 查找已有客户端: 无匹配(首次打开)
├─ 检查 spawning Map: 无(首次)
└─ 新建客户端:
├─ 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", {})
│ └─ 返回新客户端
├─ state.clients.push(newClient)
└─ 返回 [newClient]
3. 新客户端发送 textDocument/didOpen
└─ connection.sendNotification("textDocument/didOpen", { textDocument: { uri, languageId: "go", version: 1, text: content } })
4. Gopls 分析后推送诊断
└─ publishDiagnostics → diagnostics Map 更新 → Bus 广播
5. waitForDiagnostics("main.go")
├─ 150ms 去抖等待
└─ 返回诊断结果
链路 3:Monorepo 中多个项目共存
项目结构:
/project
├── frontend/
│ ├── package.json
│ └── app.tsx ← TypeScript 文件
├── backend/
│ ├── go.mod
│ └── main.go ← Go 文件
└── shared/
└── utils.ts ← TypeScript 文件
1. Agent 打开 "frontend/app.tsx"
├─ getClients → Typescript 服务器
├─ root("frontend/app.tsx") → NearestRoot → "/project/frontend"
└─ 启动 TS Client A (root="/project/frontend")
2. Agent 打开 "shared/utils.ts"
├─ getClients → Typescript 服务器
├─ root("shared/utils.ts") → NearestRoot → "/project"(向上搜索到项目根的 package.json)
└─ 启动 TS Client B (root="/project") — 不同 root,不同实例
3. Agent 打开 "backend/main.go"
├─ getClients → Gopls 服务器
├─ root("backend/main.go") → NearestRoot → "/project/backend"
└─ 启动 Go Client C (root="/project/backend")
结果:3 个独立的 LSP 客户端并存,互不干扰
链路 4:服务器启动失败 → broken 标记 → 后续跳过
1. Agent 打开 "broken.rs"
└─ getClients → Rust Analyzer 服务器
2. LSPClient.create() → server.spawn() 失败
├─ catch 块捕获错误
├─ state.broken.add("rust-analyzer")
└─ 返回 undefined
3. Agent 再次打开 "other.rs"
└─ getClients("other.rs")
├─ 匹配服务器: Rust Analyzer
├─ 检查 broken 集合: "rust-analyzer" ∈ broken
└─ 跳过,不重试 → 返回空列表
4. Agent 看到无诊断结果,继续正常工作
设计取舍
| 决策 | 理由 |
|---|---|
| Effect Service 而非普通模块 | LSP 状态需要跟随 Instance 生命周期(项目打开时初始化,关闭时清理)。Instance.state() + Effect.addFinalizer 确保客户端连接和子进程在项目关闭时被正确清理 |
| broken 集合而非重试 | Language Server 启动失败通常是因为二进制不存在、端口冲突等环境问题。重复尝试只会浪费时间和资源,不如直接标记跳过 |
| spawning Map 去重 | 并发的 getClients 调用(如 Agent 同时编辑多个 .ts 文件)必须共享同一个 spawn Promise,否则会启动多个相同的 Language Server 进程 |
| 150ms 去抖诊断 | Language Server 通常先推送语法诊断(基于 AST),再推送语义诊断(基于类型检查)。去抖确保 Agent 看到完整的诊断集合 |
| 3 秒总超时 | Agent 不能因为 LSP 响应慢而无限等待。3 秒是”诊断通常足够完整”和”Agent 不能等太久”的平衡点 |
| NearestRoot 高阶函数 | 不同服务器需要检测不同的配置文件,高阶函数抽象了这个模式。排除模式(如 TypeScript 排除 deno.json)处理语言服务器之间的冲突 |
| 30+ 内置服务器 | 主流开发语言开箱即用是 OpenCode 的核心卖点。用户也可以通过 Config.lsp 添加自定义服务器或禁用内置服务器 |
| 自动下载而非报错 | 缺少 Language Server 二进制时自动下载(如 Biome、Deno)优于报错退出。Flag.OPENCODE_DISABLE_LSP_DOWNLOAD 允许用户在受限环境中禁用此行为 |
| workspaceSymbol 过滤到 10 条 | Agent 的上下文窗口有限,过多符号会稀释有效信息。过滤到 Class/Function/Method/Enum 等关键类型并限制 10 条是信息密度和完整性的平衡 |
状态管理与生命周期
InstanceState 初始化
LSP 状态通过 Instance.state() 创建懒加载单例:
// index.ts — 状态初始化(简化)
const instanceState = Instance.state(async () => {
const cfg = await Config.get()
// 1. 收集内置服务器定义
const servers: Record<string, LSPServer.Info> = {}
for (const server of builtInServers) {
servers[server.id] = server
}
// 2. 合并用户自定义服务器配置
if (cfg.lsp) {
for (const [id, custom] of Object.entries(cfg.lsp)) {
if (custom.disabled) {
delete servers[id] // 禁用内置服务器
continue
}
// 覆盖内置服务器的 command、extensions、env、initialization 等
if (servers[id]) {
servers[id] = mergeServerConfig(servers[id], custom)
} else {
servers[id] = createCustomServer(id, custom)
}
}
}
// 3. 全局禁用检查
if (cfg.lsp === false) {
return { clients: [], servers: {}, broken: new Set(), spawning: new Map() }
}
return { clients: [], servers, broken: new Set(), spawning: new Map() }
})
Effect.addFinalizer 清理
// index.ts — 生命周期清理(简化)
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()
})
当 Instance(项目)关闭时,所有 LSP 客户端连接被断开,子进程被终止。
LSPClient.shutdown — 连接关闭
// client.ts — 关闭流程(简化)
async function shutdown(client: LSPClient.Info): Promise<void> {
try {
// 1. 发送 shutdown 请求(优雅关闭)
await withTimeout(
client.connection.sendRequest("shutdown"),
5000,
)
// 2. 发送 exit 通知
client.connection.sendNotification("exit")
} catch {
// 超时或错误 → 强制杀死进程
} finally {
// 3. 关闭连接
client.connection.dispose()
// 4. 杀死子进程
client.process.kill()
}
}
与其他模块的关系
- Agent:Agent 在编辑文件后调用
LSP.touchFile()并通过LSP.diagnostics()获取最新诊断,辅助判断是否需要修复代码。hover()和definition()帮助 Agent 理解符号的语义 - Session / SessionPrompt:
SessionPrompt在构造用户消息时调用LSP.documentSymbol()和LSP.workspaceSymbol()为 LLM 提供代码结构概览 - Bus:LSP 通过
Bus.publish(Event.Diagnostics)和BusEvent.define("lsp.client.diagnostics")通知诊断状态变更,上层模块(Agent、UI)订阅这些事件 - Config:
Config.lsp定义启用的服务器、自定义命令和环境变量;cfg.lsp === false可全局禁用整个 LSP 模块 - Instance:LSP 状态通过
Instance.state()管理,跟随项目实例的生命周期。Instance.directory是NearestRoot搜索的边界 - Global/Flag:
Global.Path.bin存放自动下载的 Language Server 二进制文件;Flag.OPENCODE_DISABLE_LSP_DOWNLOAD控制是否允许自动下载 - Process:
launch.ts中的spawn()函数封装Process.spawn(),统一处理子进程启动的错误处理和环境变量注入