@trim21/personal-pi-extensions 0.0.299 → 0.0.301
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/aft/ast-edit.ts +6 -7
- package/src/aft/bridge.ts +9 -16
- package/src/claude-code/files.ts +168 -137
- package/src/lib/lsp/adapter.ts +41 -0
- package/src/lib/lsp/adapters/clangd.ts +26 -0
- package/src/lib/lsp/adapters/index.ts +11 -0
- package/src/lib/lsp/adapters/pyright.ts +50 -0
- package/src/lib/lsp/adapters/ruff.ts +27 -0
- package/src/lib/lsp/adapters/typescript.ts +34 -0
- package/src/lib/lsp/bin.ts +88 -0
- package/src/lib/lsp/client.ts +693 -0
- package/src/lib/lsp/diagnostic.ts +35 -0
- package/src/lib/lsp/language.ts +125 -0
- package/src/lib/lsp/launch.ts +22 -0
- package/src/lib/lsp/lsp.ts +289 -0
- package/src/opencode/{read.ts → files.ts} +276 -31
- package/src/opencode/index.ts +13 -14
- package/src/spawn-agent.ts +6 -4
- package/src/opencode/edit.ts +0 -169
- package/src/opencode/write.ts +0 -116
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断报告格式化(移植自 opencode lsp/diagnostic.ts):只报告 ERROR
|
|
3
|
+
* 级诊断,每个文件最多 20 条。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Diagnostic } from "./client.js";
|
|
7
|
+
|
|
8
|
+
const MAX_PER_FILE = 20;
|
|
9
|
+
|
|
10
|
+
const SEVERITY_LABELS: Record<number, string> = {
|
|
11
|
+
1: "ERROR",
|
|
12
|
+
2: "WARN",
|
|
13
|
+
3: "INFO",
|
|
14
|
+
4: "HINT",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function prettyDiagnostic(diagnostic: Diagnostic): string {
|
|
18
|
+
const severity = SEVERITY_LABELS[diagnostic.severity || 1] ?? "ERROR";
|
|
19
|
+
const line = diagnostic.range.start.line + 1;
|
|
20
|
+
const col = diagnostic.range.start.character + 1;
|
|
21
|
+
// 3.18 起 message 可能是 MarkupContent(客户端未声明 markupMessageSupport 时不会出现)
|
|
22
|
+
const message =
|
|
23
|
+
typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value;
|
|
24
|
+
return `${severity} [${line}:${col}] ${message}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 返回空字符串表示没有 ERROR 级诊断。 */
|
|
28
|
+
export function report(file: string, issues: Diagnostic[]): string {
|
|
29
|
+
const errors = issues.filter((item) => item.severity === 1);
|
|
30
|
+
if (errors.length === 0) return "";
|
|
31
|
+
const limited = errors.slice(0, MAX_PER_FILE);
|
|
32
|
+
const more = errors.length - MAX_PER_FILE;
|
|
33
|
+
const suffix = more > 0 ? `\n... and ${more} more` : "";
|
|
34
|
+
return `<diagnostics file="${file}">\n${limited.map((d) => prettyDiagnostic(d)).join("\n")}${suffix}\n</diagnostics>`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件扩展名 → LSP languageId 映射,用于 didOpen 通知。
|
|
3
|
+
* 移植自 opencode packages/opencode/src/lsp/language.ts。
|
|
4
|
+
*/
|
|
5
|
+
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
|
6
|
+
".abap": "abap",
|
|
7
|
+
".bat": "bat",
|
|
8
|
+
".bib": "bibtex",
|
|
9
|
+
".bibtex": "bibtex",
|
|
10
|
+
".clj": "clojure",
|
|
11
|
+
".cljs": "clojure",
|
|
12
|
+
".cljc": "clojure",
|
|
13
|
+
".edn": "clojure",
|
|
14
|
+
".coffee": "coffeescript",
|
|
15
|
+
".c": "c",
|
|
16
|
+
".cpp": "cpp",
|
|
17
|
+
".cxx": "cpp",
|
|
18
|
+
".cc": "cpp",
|
|
19
|
+
".c++": "cpp",
|
|
20
|
+
".cs": "csharp",
|
|
21
|
+
".csx": "csharp",
|
|
22
|
+
".css": "css",
|
|
23
|
+
".d": "d",
|
|
24
|
+
".pas": "pascal",
|
|
25
|
+
".pascal": "pascal",
|
|
26
|
+
".diff": "diff",
|
|
27
|
+
".patch": "diff",
|
|
28
|
+
".dart": "dart",
|
|
29
|
+
".dockerfile": "dockerfile",
|
|
30
|
+
".ex": "elixir",
|
|
31
|
+
".exs": "elixir",
|
|
32
|
+
".erl": "erlang",
|
|
33
|
+
".ets": "typescript",
|
|
34
|
+
".hrl": "erlang",
|
|
35
|
+
".fs": "fsharp",
|
|
36
|
+
".fsi": "fsharp",
|
|
37
|
+
".fsx": "fsharp",
|
|
38
|
+
".fsscript": "fsharp",
|
|
39
|
+
".gitcommit": "git-commit",
|
|
40
|
+
".gitrebase": "git-rebase",
|
|
41
|
+
".go": "go",
|
|
42
|
+
".groovy": "groovy",
|
|
43
|
+
".gleam": "gleam",
|
|
44
|
+
".hbs": "handlebars",
|
|
45
|
+
".handlebars": "handlebars",
|
|
46
|
+
".hs": "haskell",
|
|
47
|
+
".lhs": "haskell",
|
|
48
|
+
".html": "html",
|
|
49
|
+
".htm": "html",
|
|
50
|
+
".ini": "ini",
|
|
51
|
+
".java": "java",
|
|
52
|
+
".jl": "julia",
|
|
53
|
+
".js": "javascript",
|
|
54
|
+
".kt": "kotlin",
|
|
55
|
+
".kts": "kotlin",
|
|
56
|
+
".jsx": "javascriptreact",
|
|
57
|
+
".json": "json",
|
|
58
|
+
".tex": "latex",
|
|
59
|
+
".latex": "latex",
|
|
60
|
+
".less": "less",
|
|
61
|
+
".lua": "lua",
|
|
62
|
+
".makefile": "makefile",
|
|
63
|
+
makefile: "makefile",
|
|
64
|
+
".md": "markdown",
|
|
65
|
+
".markdown": "markdown",
|
|
66
|
+
".m": "objective-c",
|
|
67
|
+
".mm": "objective-cpp",
|
|
68
|
+
".pl": "perl",
|
|
69
|
+
".pm": "perl",
|
|
70
|
+
".pm6": "perl6",
|
|
71
|
+
".php": "php",
|
|
72
|
+
".ps1": "powershell",
|
|
73
|
+
".psm1": "powershell",
|
|
74
|
+
".pug": "jade",
|
|
75
|
+
".jade": "jade",
|
|
76
|
+
".py": "python",
|
|
77
|
+
".r": "r",
|
|
78
|
+
".cshtml": "razor",
|
|
79
|
+
".razor": "razor",
|
|
80
|
+
".rb": "ruby",
|
|
81
|
+
".rake": "ruby",
|
|
82
|
+
".gemspec": "ruby",
|
|
83
|
+
".ru": "ruby",
|
|
84
|
+
".erb": "erb",
|
|
85
|
+
".html.erb": "erb",
|
|
86
|
+
".js.erb": "erb",
|
|
87
|
+
".css.erb": "erb",
|
|
88
|
+
".json.erb": "erb",
|
|
89
|
+
".rs": "rust",
|
|
90
|
+
".scss": "scss",
|
|
91
|
+
".sass": "sass",
|
|
92
|
+
".scala": "scala",
|
|
93
|
+
".shader": "shaderlab",
|
|
94
|
+
".sh": "shellscript",
|
|
95
|
+
".bash": "shellscript",
|
|
96
|
+
".zsh": "shellscript",
|
|
97
|
+
".ksh": "shellscript",
|
|
98
|
+
".sql": "sql",
|
|
99
|
+
".svelte": "svelte",
|
|
100
|
+
".swift": "swift",
|
|
101
|
+
".ts": "typescript",
|
|
102
|
+
".tsx": "typescriptreact",
|
|
103
|
+
".mts": "typescript",
|
|
104
|
+
".cts": "typescript",
|
|
105
|
+
".mtsx": "typescriptreact",
|
|
106
|
+
".ctsx": "typescriptreact",
|
|
107
|
+
".xml": "xml",
|
|
108
|
+
".xsl": "xsl",
|
|
109
|
+
".yaml": "yaml",
|
|
110
|
+
".yml": "yaml",
|
|
111
|
+
".mjs": "javascript",
|
|
112
|
+
".cjs": "javascript",
|
|
113
|
+
".vue": "vue",
|
|
114
|
+
".zig": "zig",
|
|
115
|
+
".zon": "zig",
|
|
116
|
+
".astro": "astro",
|
|
117
|
+
".ml": "ocaml",
|
|
118
|
+
".mli": "ocaml",
|
|
119
|
+
".tf": "terraform",
|
|
120
|
+
".tfvars": "terraform-vars",
|
|
121
|
+
".hcl": "hcl",
|
|
122
|
+
".nix": "nix",
|
|
123
|
+
".typ": "typst",
|
|
124
|
+
".typc": "typst",
|
|
125
|
+
} as const;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP 服务器子进程启动封装:stdin/stdout/stderr 全 pipe 交给 JSON-RPC 层使用。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type ChildProcessWithoutNullStreams, spawn as nodeSpawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
export interface SpawnOptions {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function spawnProcess(
|
|
13
|
+
cmd: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
options: SpawnOptions = {},
|
|
16
|
+
): ChildProcessWithoutNullStreams {
|
|
17
|
+
return nodeSpawn(cmd, args, {
|
|
18
|
+
cwd: options.cwd,
|
|
19
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP 管理器:所有语言服务器连接的注册表与统一入口。
|
|
3
|
+
*
|
|
4
|
+
* - state(client 缓存、broken 集合、spawning 去重)是 createLspService 的
|
|
5
|
+
* 闭包变量,不做成模块级全局;
|
|
6
|
+
* - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
|
|
7
|
+
* (本地逐字段覆盖全局):`enabled` 白名单(缺省全部)、`disabled` 排除
|
|
8
|
+
* (缺省无),以及各超时参数;配置在每个工具的调用 cwd 下惰性读取;
|
|
9
|
+
* - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
|
|
10
|
+
* 集合(服务实例生命周期内不再重试);
|
|
11
|
+
* - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { type Static, Type } from "typebox";
|
|
20
|
+
import { Value } from "typebox/value";
|
|
21
|
+
|
|
22
|
+
import { type LspServerAdapter } from "./adapter.js";
|
|
23
|
+
import { createAdapters } from "./adapters/index.js";
|
|
24
|
+
import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
|
|
25
|
+
import { report } from "./diagnostic.js";
|
|
26
|
+
|
|
27
|
+
/** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
|
|
28
|
+
const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
|
|
29
|
+
|
|
30
|
+
/** lsp.json 的配置项(全局与本地同构)。 */
|
|
31
|
+
const lspConfigSchema = Type.Object({
|
|
32
|
+
/** 只启用列出的服务器 id(缺省 = 全部启用)。 */
|
|
33
|
+
enabled: Type.Optional(Type.Array(Type.String())),
|
|
34
|
+
/** 从启用集中排除的服务器 id(缺省 = 无)。 */
|
|
35
|
+
disabled: Type.Optional(Type.Array(Type.String())),
|
|
36
|
+
/** push 诊断去抖(ms,缺省 150)。 */
|
|
37
|
+
diagnosticsDebounceMs: Type.Optional(timeoutValue),
|
|
38
|
+
/** document 模式诊断等待上限(ms,缺省 5_000)。 */
|
|
39
|
+
diagnosticsDocumentWaitTimeoutMs: Type.Optional(timeoutValue),
|
|
40
|
+
/** full 模式诊断等待上限(ms,缺省 10_000)。 */
|
|
41
|
+
diagnosticsFullWaitTimeoutMs: Type.Optional(timeoutValue),
|
|
42
|
+
/** 单次 pull 诊断请求超时(ms,缺省 3_000)。 */
|
|
43
|
+
diagnosticsRequestTimeoutMs: Type.Optional(timeoutValue),
|
|
44
|
+
/** 服务器 initialize 握手超时(ms,缺省 45_000)。 */
|
|
45
|
+
initializeTimeoutMs: Type.Optional(timeoutValue),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** 配置值;超时字段为原始写法(number 或字符串),换算发生在 timeoutOptions。 */
|
|
49
|
+
export type LspConfig = Static<typeof lspConfigSchema>;
|
|
50
|
+
|
|
51
|
+
/** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由 toMs 过滤)。 */
|
|
52
|
+
function parseTimeoutString(value: string): number {
|
|
53
|
+
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(value.trim());
|
|
54
|
+
if (!match) return NaN;
|
|
55
|
+
const amount = Number(match[1]);
|
|
56
|
+
const unit = match[2] ?? "ms";
|
|
57
|
+
const factors: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
|
|
58
|
+
return amount * (factors[unit] ?? 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toMs(value: number | string | undefined): number | undefined {
|
|
62
|
+
if (value === undefined) return undefined;
|
|
63
|
+
const ms = typeof value === "number" ? value : parseTimeoutString(value);
|
|
64
|
+
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 从配置里取超时字段(缺省 undefined,create 用自身默认值)。 */
|
|
68
|
+
function timeoutOptions(
|
|
69
|
+
config: LspConfig,
|
|
70
|
+
): Pick<
|
|
71
|
+
CreateInput,
|
|
72
|
+
| "diagnosticsDebounceMs"
|
|
73
|
+
| "diagnosticsDocumentWaitTimeoutMs"
|
|
74
|
+
| "diagnosticsFullWaitTimeoutMs"
|
|
75
|
+
| "diagnosticsRequestTimeoutMs"
|
|
76
|
+
| "initializeTimeoutMs"
|
|
77
|
+
> {
|
|
78
|
+
return {
|
|
79
|
+
diagnosticsDebounceMs: toMs(config.diagnosticsDebounceMs),
|
|
80
|
+
diagnosticsDocumentWaitTimeoutMs: toMs(config.diagnosticsDocumentWaitTimeoutMs),
|
|
81
|
+
diagnosticsFullWaitTimeoutMs: toMs(config.diagnosticsFullWaitTimeoutMs),
|
|
82
|
+
diagnosticsRequestTimeoutMs: toMs(config.diagnosticsRequestTimeoutMs),
|
|
83
|
+
initializeTimeoutMs: toMs(config.initializeTimeoutMs),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 读取并解析单个配置文件;文件不存在或解析失败时返回空配置。 */
|
|
88
|
+
async function readConfigFile(filePath: string): Promise<LspConfig> {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await readFile(filePath, "utf8");
|
|
91
|
+
return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
|
|
92
|
+
} catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 合并后的生效配置:全局 `~/.pi/agent/lsp.json` 为基底,本地
|
|
99
|
+
* `<cwd>/.pi/lsp.json` 逐字段覆盖。
|
|
100
|
+
*/
|
|
101
|
+
export async function loadLspConfig(
|
|
102
|
+
cwd: string,
|
|
103
|
+
globalConfigPath: string = join(homedir(), ".pi", "agent", "lsp.json"),
|
|
104
|
+
): Promise<LspConfig> {
|
|
105
|
+
const [globalConfig, localConfig] = await Promise.all([
|
|
106
|
+
readConfigFile(globalConfigPath),
|
|
107
|
+
readConfigFile(join(cwd, ".pi", "lsp.json")),
|
|
108
|
+
]);
|
|
109
|
+
return { ...globalConfig, ...localConfig };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 按配置过滤 adapter 列表。 */
|
|
113
|
+
export function filterAdapters(
|
|
114
|
+
adapters: LspServerAdapter[],
|
|
115
|
+
config: LspConfig,
|
|
116
|
+
): LspServerAdapter[] {
|
|
117
|
+
return adapters.filter((adapter) => {
|
|
118
|
+
if (config.enabled && !config.enabled.includes(adapter.id)) return false;
|
|
119
|
+
if (config.disabled?.includes(adapter.id)) return false;
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface LspState {
|
|
125
|
+
clients: LspClient[];
|
|
126
|
+
adapters: LspServerAdapter[];
|
|
127
|
+
broken: Set<string>;
|
|
128
|
+
spawning: Map<string, Promise<LspClient | undefined>>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface LspService {
|
|
132
|
+
touchFile(file: string, cwd: string, diagnostics?: "document" | "full"): Promise<void>;
|
|
133
|
+
diagnostics(): Promise<Record<string, Diagnostic[]>>;
|
|
134
|
+
lspDiagnosticsForFile(file: string, cwd: string): Promise<string>;
|
|
135
|
+
shutdownAll(): Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 文件必须在工作目录内才启用 LSP(对齐 opencode 的 containsPath)。 */
|
|
139
|
+
function containsPath(file: string, cwd: string): boolean {
|
|
140
|
+
const dir = normalize(cwd);
|
|
141
|
+
const target = normalize(file);
|
|
142
|
+
return target === dir || target.startsWith(dir + sep);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 创建 LSP 服务实例。state 由闭包持有;adapters 可注入(测试传 [] 或
|
|
147
|
+
* mock adapters 即可隔离真实服务器)。globalConfigPath 供测试注入
|
|
148
|
+
* 固定的全局配置路径,避免被本机 ~/.pi/agent/lsp.json 影响。
|
|
149
|
+
*/
|
|
150
|
+
export function createLspService(
|
|
151
|
+
adapters: LspServerAdapter[] = createAdapters(),
|
|
152
|
+
globalConfigPath?: string,
|
|
153
|
+
): LspService {
|
|
154
|
+
const state: LspState = {
|
|
155
|
+
clients: [],
|
|
156
|
+
adapters,
|
|
157
|
+
broken: new Set(),
|
|
158
|
+
spawning: new Map(),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
async function getClients(file: string, cwd: string): Promise<LspClient[]> {
|
|
162
|
+
if (!containsPath(file, cwd)) return [];
|
|
163
|
+
const config = await loadLspConfig(cwd, globalConfigPath);
|
|
164
|
+
const timeout = timeoutOptions(config);
|
|
165
|
+
const adapters = filterAdapters(state.adapters, config);
|
|
166
|
+
const extension = extname(file) || file;
|
|
167
|
+
const result: LspClient[] = [];
|
|
168
|
+
|
|
169
|
+
for (const adapter of adapters) {
|
|
170
|
+
if (adapter.extensions.length > 0 && !adapter.extensions.includes(extension)) continue;
|
|
171
|
+
const root = await adapter.findRoot(file, cwd);
|
|
172
|
+
if (!root) continue;
|
|
173
|
+
const key = root + adapter.id;
|
|
174
|
+
if (state.broken.has(key)) continue;
|
|
175
|
+
|
|
176
|
+
const existing = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
|
|
177
|
+
if (existing) {
|
|
178
|
+
result.push(existing);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const inflight = state.spawning.get(key);
|
|
183
|
+
if (inflight) {
|
|
184
|
+
const client = await inflight;
|
|
185
|
+
if (client) result.push(client);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const task = (async () => {
|
|
190
|
+
try {
|
|
191
|
+
const handle = await adapter.spawn(root, cwd);
|
|
192
|
+
if (!handle) {
|
|
193
|
+
state.broken.add(key);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const client = await create({
|
|
197
|
+
serverID: adapter.id,
|
|
198
|
+
server: handle,
|
|
199
|
+
root,
|
|
200
|
+
directory: cwd,
|
|
201
|
+
...timeout,
|
|
202
|
+
});
|
|
203
|
+
const duplicate = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
|
|
204
|
+
if (duplicate) {
|
|
205
|
+
await client.shutdown();
|
|
206
|
+
return duplicate;
|
|
207
|
+
}
|
|
208
|
+
state.clients.push(client);
|
|
209
|
+
return client;
|
|
210
|
+
} catch {
|
|
211
|
+
state.broken.add(key);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
state.spawning.set(key, task);
|
|
216
|
+
void task.finally(() => {
|
|
217
|
+
if (state.spawning.get(key) === task) state.spawning.delete(key);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const client = await task;
|
|
221
|
+
if (client) result.push(client);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 打开文档让服务器索引 / 产出诊断。diagnostics 传 "document" 时最多等 5s,
|
|
229
|
+
* "full" 最多等 10s;不传则只通知不等待(read 的 warm-up 用)。
|
|
230
|
+
*/
|
|
231
|
+
async function touchFile(
|
|
232
|
+
file: string,
|
|
233
|
+
cwd: string,
|
|
234
|
+
diagnostics?: "document" | "full",
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const clients = await getClients(file, cwd);
|
|
237
|
+
await Promise.all(
|
|
238
|
+
clients.map(async (client) => {
|
|
239
|
+
const after = Date.now();
|
|
240
|
+
const version = await client.notify.open({ path: file });
|
|
241
|
+
if (!diagnostics) return;
|
|
242
|
+
await client.waitForDiagnostics({ path: file, version, mode: diagnostics, after });
|
|
243
|
+
}),
|
|
244
|
+
).catch(() => {
|
|
245
|
+
// 诊断等待失败不影响写操作本身
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 聚合所有 client 的当前诊断(path → diagnostics)。 */
|
|
250
|
+
function diagnostics(): Promise<Record<string, Diagnostic[]>> {
|
|
251
|
+
const results: Record<string, Diagnostic[]> = {};
|
|
252
|
+
for (const client of state.clients) {
|
|
253
|
+
for (const [filePath, diags] of client.diagnostics) {
|
|
254
|
+
(results[filePath] ??= []).push(...diags);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return Promise.resolve(results);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(空串表示无错误)。
|
|
262
|
+
* 内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
|
|
263
|
+
*/
|
|
264
|
+
async function lspDiagnosticsForFile(file: string, cwd: string): Promise<string> {
|
|
265
|
+
await touchFile(file, cwd, "document");
|
|
266
|
+
const all = await diagnostics();
|
|
267
|
+
const normalized = normalize(file);
|
|
268
|
+
return report(normalized, all[normalized] ?? []);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 终止全部服务器进程(session_shutdown 时调用)。 */
|
|
272
|
+
async function shutdownAll(): Promise<void> {
|
|
273
|
+
await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
|
|
274
|
+
// 个别进程退出失败不阻止清理流程
|
|
275
|
+
});
|
|
276
|
+
state.clients = [];
|
|
277
|
+
state.broken.clear();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { touchFile, diagnostics, lspDiagnosticsForFile, shutdownAll };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 注册进程级生命周期:session_shutdown 时清理全部服务器进程。 */
|
|
284
|
+
export function initLsp(pi: ExtensionAPI, service: LspService): void {
|
|
285
|
+
// 测试里的 fake pi 没有事件订阅;生产环境 pi.on 必然存在
|
|
286
|
+
pi.on?.("session_shutdown", () => {
|
|
287
|
+
void service.shutdownAll();
|
|
288
|
+
});
|
|
289
|
+
}
|