LSP 与格式化器
两个默认关闭的子系统:38 个内置语言服务器的根目录探测与按需获取、推拉结合的诊断、诊断如何回到模型,以及 26 个格式化器的启用条件。
LSP 与格式化器解决的是同一件事:模型改完文件后,尽快知道改得对不对、格式合不合规。两者都由编辑类工具调用,也都默认关闭:
if (!cfg.lsp) {
yield* Effect.logInfo("all LSPs are disabled")
} else {(packages/opencode/src/lsp/lsp.ts:151-153;格式化器在 format/index.ts:120 有同样的判断)
官方文档给出的理由是:语言服务器可能不同步、占内存、拖慢 agent,很多项目更适合让 agent 直接跑 lint、typecheck 这类命令行工具。
配置
lsp 与 formatter 形状相同(packages/core/src/v1/config/lsp.ts、formatter.ts):true 启用全部内置项,false 或省略表示全部关闭,对象表示「启用内置项并叠加覆盖」:
{
"lsp": {
"eslint": { "disabled": true },
"my-lang": {
"command": ["my-lang-server", "--stdio"],
"extensions": [".mylang"],
"initialization": { "diagnostics": true }
}
},
"formatter": {
"prettier": { "disabled": true },
"project": { "command": ["./scripts/format", "$FILE"], "extensions": [".foo"] }
}
}自定义语言服务器必须声明 extensions,schema 会校验。环境变量 OPENCODE_DISABLE_LSP_DOWNLOAD 用来禁止自动下载。
38 个内置语言服务器
packages/opencode/src/lsp/server.ts 里每个服务器都实现同一个接口:
export interface Info {
id: string
extensions: string[]
global?: boolean
root: RootFunction
spawn(root: string, ctx: InstanceContext, flags: RuntimeFlags.Info): Promise<Handle | undefined>
}(server.ts:80-86)
root() 回答「这个文件属于哪个工程」。常用的 NearestRoot 从文件所在目录向上找标记文件(锁文件、go.mod、Cargo.toml……),找不到就退回实例目录;StrictNearestRoot 找不到则返回 undefined,表示不启动。还可以带排除标记:TypeScript 服务器遇到 deno.json 就让位给 Deno。个别语言有专门逻辑:Rust 继续向上找带 [workspace] 的 Cargo.toml,Java 依次尝试 Gradle、Maven 的 <module> 链与 Eclipse 工程。
spawn() 回答「二进制从哪来」,大致五种策略:
| 策略 | 例子 |
|---|---|
| 只用 PATH 里现成的 | rust-analyzer、deno、dart、gleam、ocamllsp |
| 从 npm 获取 | typescript-language-server、pyright、vue、svelte、astro、yaml、bash |
| 下载预编译包 | zls、clangd、lua-language-server、texlab、tinymist、terraform-ls |
| 调用语言工具链安装 | go install gopls、gem install rubocop、dotnet tool install C# 与 F# |
| 下载源码现场编译 | ESLint(vscode-eslint + npm run compile)、ElixirLS(mix compile) |
多数下载分支前都有一行 if (flags.disableLspDownload) return。Python 默认用 Pyright,打开 OPENCODE_EXPERIMENTAL_LSP_TY 后换成 ty,filterExperimentalServers 保证两者只留其一。
按需启动与客户端池
LSP.init() 只在实例启动时建好状态,不启动任何服务器。真正的启动发生在 getClients(file):只处理实例目录内的文件,按扩展名筛选服务器、算出 root,以 root + serverID 为键复用客户端;并发请求同一个键时共享同一个启动中的 Promise;启动失败的键记入 broken,本实例内不再重试。
client.ts 用 vscode-jsonrpc 走 stdio,initialize 最多等 45 秒。握手时声明了 workspace/configuration、didChangeWatchedFiles 与动态注册的拉取式诊断;服务器来要配置时,按 section 路径从 initialization 对象里取值。文件更新一律发全文,即使服务器支持增量同步,也只是把范围设成整篇。
推与拉的诊断
诊断有两条来源:服务器推送的 textDocument/publishDiagnostics,以及客户端拉取的 textDocument/diagnostic、workspace/diagnostic(含动态注册的多个 identifier)。两者分开缓存,读取时合并去重。编辑后等待诊断分两档:document 模式最多 5 秒,full 模式 10 秒;推送去抖 150 ms,单次拉取 3 秒超时。拉取的并发策略写在注释里:
LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one batch already produced diagnostics for the current file. Let slower pulls keep merging in the background; do not sequence identifier-by-identifier, and do not add a post-match settle/debounce delay. See PR #23771.
TypeScript 还有个特判:它首次打开文件时推送得很积极,第一次推送直接当作种子,不必再等第二次去抖。
诊断怎样回到模型
edit、write、apply_patch 在写盘和格式化之后调用 lsp.touchFile(file, "document"),再把诊断拼进工具输出:
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}(packages/opencode/src/lsp/diagnostic.ts:20-27)
只报 ERROR 级别,每个文件最多 20 条,警告与提示一律不进上下文,这是在给模型去噪。edit 只报当前文件,write 还会附上其他文件的错误(最多 5 个文件)。read 工具读文件时在后台 touchFile 一下,提前把对应服务器拉起来。
打开 OPENCODE_EXPERIMENTAL_LSP_TOOL 后,模型还能直接使用 lsp 工具,执行跳转定义、查引用、hover、文档与工作区符号、调用层级等 9 种操作(权限键 lsp)。调试可用 opencode debug lsp diagnostics <file>,以及 symbols、document-symbols 两个子命令。
26 个格式化器
packages/opencode/src/format/formatter.ts 里的每个格式化器是 { name, extensions, environment?, enabled(ctx) },enabled() 返回命令数组($FILE 为占位符)或 false。启用条件分几类:
- 二进制存在即可:gofmt、rustfmt、zig、shfmt、ktlint、dart、terraform 等;
- 项目依赖里有:prettier、oxfmt 查
package.json,pint 查composer.json; - 有配置文件:biome 要
biome.json(c),clang-format 要.clang-format,ocamlformat 要.ocamlformat,ruff 要 ruff 配置或依赖清单里出现 ruff; - 互斥与开关:uv 仅在 ruff 未启用时生效;oxfmt 还需打开
OPENCODE_EXPERIMENTAL_OXFMT。
写盘后 Format.file() 找出所有匹配扩展名且已启用的格式化器,依次全部执行,失败只记日志、不影响工具结果;随后编辑工具会恢复原文件的 BOM。探测结果按实例缓存,但只缓存命中的,未命中的下次还会再探测。配置里 ruff 与 uv 被当成同一个后端:
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter.ruff?.disabled || cfg.formatter.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}(packages/opencode/src/format/index.ts:138-144)
V2 设计文档对这两个字段的结论是「保持原样」:单数名字、boolean 或按名字覆盖的对象,自定义语言服务器仍须声明 extensions。
下一篇:插件系统——插件从哪里来、钩子清单、TUI 插件,以及 V2 插件 API 的方向。