# LSP 与格式化器

> 两个默认关闭的子系统：38 个内置语言服务器的根目录探测与按需获取、推拉结合的诊断、诊断如何回到模型，以及 26 个格式化器的启用条件。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/lsp-and-format
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

LSP 与格式化器解决的是同一件事：**模型改完文件后，尽快知道改得对不对、格式合不合规**。两者都由编辑类工具调用，也都**默认关闭**：

```ts
        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` 或省略表示全部关闭，对象表示「启用内置项并叠加覆盖」：

```jsonc
{
  "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` 里每个服务器都实现同一个接口：

```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")`，再把诊断拼进工具输出：

```ts
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 被当成同一个后端：

```ts
            // 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`。

下一篇：[插件系统](https://daiw.net/manual/opencode/plugins)——插件从哪里来、钩子清单、TUI 插件，以及 V2 插件 API 的方向。
