@trim21/personal-pi-extensions 0.1.510 → 0.1.512
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 +1 -1
- package/src/lib/lsp/adapter.ts +13 -20
- package/src/lib/lsp/lsp.ts +107 -21
- package/src/lib/lsp/server-config.ts +17 -22
- package/src/skills/lsp-config/SKILL.md +12 -15
package/package.json
CHANGED
package/src/lib/lsp/adapter.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LSP 服务器插件契约:每个语言服务器一个 adapter class,向管理器提供统一接口。
|
|
3
3
|
*
|
|
4
|
-
* -
|
|
4
|
+
* - root 由管理器按 workingDir 计算(缺省调用 cwd);文件必须位于 root 之内
|
|
5
|
+
* 且命中 include(未配置时全匹配)才会由该服务器处理;
|
|
5
6
|
* - spawn 返回 undefined 表示服务器不可用(二进制未安装)。
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { exists, walkUp } from "./bin.js";
|
|
10
|
+
import { resolve } from "node:path";
|
|
12
11
|
|
|
13
12
|
export interface LspServerHandle {
|
|
14
13
|
process: ChildProcessWithoutNullStreams;
|
|
@@ -29,27 +28,21 @@ export interface LspServerAdapter {
|
|
|
29
28
|
readonly kind?: ServerKind;
|
|
30
29
|
/** 关联的文件扩展名(含点,小写);空数组表示匹配所有文件。 */
|
|
31
30
|
readonly extensions: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* 文件 glob(相对 root 或调用 cwd);缺省/空 = 匹配所有文件。
|
|
33
|
+
* 过滤在管理器的 client 匹配阶段完成,adapter 不再持有 findRoot。
|
|
34
|
+
*/
|
|
35
|
+
readonly include?: readonly string[];
|
|
36
|
+
/** 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。 */
|
|
37
|
+
readonly workingDir?: string;
|
|
32
38
|
/** per-server initialize 握手超时(ms);缺省用全局配置 / client 默认。 */
|
|
33
39
|
readonly startupTimeoutMs?: number;
|
|
34
40
|
/** per-server 诊断等待时长(ms);缺省用全局配置 / client 默认。 */
|
|
35
41
|
readonly diagnosticsWaitMs?: number;
|
|
36
|
-
findRoot(file: string, cwd: string): Promise<string | undefined>;
|
|
37
42
|
spawn(root: string, cwd: string): Promise<LspServerHandle | undefined>;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*/
|
|
44
|
-
export function nearestRoot(
|
|
45
|
-
markers: readonly string[],
|
|
46
|
-
file: string,
|
|
47
|
-
cwd: string,
|
|
48
|
-
): Promise<string> {
|
|
49
|
-
for (const dir of walkUp(dirname(file), cwd)) {
|
|
50
|
-
for (const marker of markers) {
|
|
51
|
-
if (exists(join(dir, marker))) return Promise.resolve(dir);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return Promise.resolve(cwd);
|
|
45
|
+
/** 计算服务器 root:workingDir 未配置时即调用 cwd,否则相对 cwd 解析(绝对路径原样)。 */
|
|
46
|
+
export function serverRoot(workingDir: string | undefined, cwd: string): string {
|
|
47
|
+
return workingDir === undefined ? cwd : resolve(cwd, workingDir);
|
|
55
48
|
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 合并结果解析为 `ResolvedLspConfig`(缺省值应用、超时换算为 ms、白名单转
|
|
10
10
|
* Set),消费方不接触"未配置"歧义;
|
|
11
11
|
* 没有内置默认服务器,所有服务器均须在配置里定义;
|
|
12
|
-
*
|
|
12
|
+
* 配置在 session_start 预读并按 cwd 缓存,cwd 变化或 /lsp-reload 时重读;
|
|
13
|
+
* schema 外的未知字段以 warning 上报。enabled 引用不存在的服务器 id
|
|
13
14
|
* 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
|
|
14
15
|
* session 开始预加载时通知,工具调用时校验抛错兜底。disabled 中未注册
|
|
15
16
|
* 的 id 直接忽略;
|
|
@@ -29,7 +30,7 @@ import { type Static, Type } from "typebox";
|
|
|
29
30
|
import { Value } from "typebox/value";
|
|
30
31
|
import type { Hover, WorkspaceEdit } from "vscode-languageserver-types";
|
|
31
32
|
|
|
32
|
-
import { type LspServerAdapter } from "./adapter.js";
|
|
33
|
+
import { type LspServerAdapter, serverRoot } from "./adapter.js";
|
|
33
34
|
import {
|
|
34
35
|
clientDefaults,
|
|
35
36
|
create,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
import { report } from "./diagnostic.js";
|
|
46
47
|
import {
|
|
47
48
|
createAdapters,
|
|
49
|
+
matchesInclude,
|
|
48
50
|
mergeServerRecords,
|
|
49
51
|
type ServerConfig,
|
|
50
52
|
serverConfigSchema,
|
|
@@ -197,11 +199,35 @@ function isMissingFile(error: unknown): boolean {
|
|
|
197
199
|
return (error as { code?: unknown }).code === "ENOENT";
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
/**
|
|
203
|
+
* 收集 schema 之外的未知字段警告(typebox 不校验 additionalProperties,
|
|
204
|
+
* 未知键会静默存活在解析结果里,这里显式报告避免配置写错无感知)。
|
|
205
|
+
*/
|
|
206
|
+
function unknownFieldWarnings(config: LspConfig, filePath: string): string[] {
|
|
207
|
+
const warnings: string[] = [];
|
|
208
|
+
const check = (record: object, known: Record<string, unknown>, scope: string): void => {
|
|
209
|
+
for (const key of Object.keys(record)) {
|
|
210
|
+
if (!(key in known)) warnings.push(`${filePath}${scope}: unknown field "${key}" ignored`);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
check(config, lspConfigSchema.properties, "");
|
|
214
|
+
if (config.watch) check(config.watch, watchConfigSchema.properties, " watch");
|
|
215
|
+
for (const [id, server] of Object.entries(config.servers ?? {})) {
|
|
216
|
+
check(server, serverConfigSchema.properties, ` (server "${id}")`);
|
|
217
|
+
}
|
|
218
|
+
return warnings;
|
|
219
|
+
}
|
|
220
|
+
|
|
200
221
|
/** 读取并解析单个配置文件;文件不存在视为空配置,JSON / typebox 校验错误直接抛出。 */
|
|
201
|
-
async function readConfigFile(
|
|
222
|
+
async function readConfigFile(
|
|
223
|
+
filePath: string,
|
|
224
|
+
onWarning?: (message: string) => void,
|
|
225
|
+
): Promise<LspConfig> {
|
|
202
226
|
try {
|
|
203
227
|
const raw = await readFile(filePath, "utf8");
|
|
204
|
-
|
|
228
|
+
const config = Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
|
|
229
|
+
for (const message of unknownFieldWarnings(config, filePath)) onWarning?.(message);
|
|
230
|
+
return config;
|
|
205
231
|
} catch (error) {
|
|
206
232
|
if (isMissingFile(error)) return {};
|
|
207
233
|
throw error;
|
|
@@ -209,10 +235,12 @@ async function readConfigFile(filePath: string): Promise<LspConfig> {
|
|
|
209
235
|
}
|
|
210
236
|
|
|
211
237
|
/** 同步版 readConfigFile(扩展加载时校验全局配置用)。 */
|
|
212
|
-
function readConfigFileSync(filePath: string): LspConfig {
|
|
238
|
+
function readConfigFileSync(filePath: string, onWarning?: (message: string) => void): LspConfig {
|
|
213
239
|
try {
|
|
214
240
|
const raw = readFileSync(filePath, "utf8");
|
|
215
|
-
|
|
241
|
+
const config = Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
|
|
242
|
+
for (const message of unknownFieldWarnings(config, filePath)) onWarning?.(message);
|
|
243
|
+
return config;
|
|
216
244
|
} catch (error) {
|
|
217
245
|
if (isMissingFile(error)) return {};
|
|
218
246
|
throw error;
|
|
@@ -267,14 +295,15 @@ export function mergeConfig(globalConfig: LspConfig, localConfig: LspConfig): Ls
|
|
|
267
295
|
};
|
|
268
296
|
}
|
|
269
297
|
|
|
270
|
-
/** 读取全局 +
|
|
298
|
+
/** 读取全局 + 本地配置,合并并解析为生效配置;未知字段警告经 onWarning 上报。 */
|
|
271
299
|
export async function loadLspConfig(
|
|
272
300
|
cwd: string,
|
|
273
301
|
globalConfigPath: string = join(homedir(), ".pi", "agent", "lsp.json"),
|
|
302
|
+
onWarning?: (message: string) => void,
|
|
274
303
|
): Promise<ResolvedLspConfig> {
|
|
275
304
|
const [globalConfig, localConfig] = await Promise.all([
|
|
276
|
-
readConfigFile(globalConfigPath),
|
|
277
|
-
readConfigFile(join(cwd, ".pi", "lsp.json")),
|
|
305
|
+
readConfigFile(globalConfigPath, onWarning),
|
|
306
|
+
readConfigFile(join(cwd, ".pi", "lsp.json"), onWarning),
|
|
278
307
|
]);
|
|
279
308
|
return resolveConfig(mergeConfig(globalConfig, localConfig));
|
|
280
309
|
}
|
|
@@ -311,6 +340,10 @@ interface LspState {
|
|
|
311
340
|
cwd: string | undefined;
|
|
312
341
|
/** client → adapter 扩展名集合,fan-out 时按扩展名过滤。 */
|
|
313
342
|
clientExtensions: Map<LspClient, readonly string[]>;
|
|
343
|
+
/** 缓存的生效配置(configCwd 一致时复用;cwd 变化或 reload 后重读)。 */
|
|
344
|
+
config: ResolvedLspConfig | undefined;
|
|
345
|
+
/** config 的加载 cwd。 */
|
|
346
|
+
configCwd: string | undefined;
|
|
314
347
|
}
|
|
315
348
|
|
|
316
349
|
/** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
|
|
@@ -392,9 +425,14 @@ export interface LspService {
|
|
|
392
425
|
start(): void;
|
|
393
426
|
/**
|
|
394
427
|
* 重启指定服务器:关闭其全部 client、清除对应失败记录并解除禁用;
|
|
395
|
-
*
|
|
428
|
+
* 其余服务器不受影响。配置缓存失效,下次工具调用按当前 cwd 重新读盘。
|
|
396
429
|
*/
|
|
397
430
|
reload(serverID: string): Promise<void>;
|
|
431
|
+
/**
|
|
432
|
+
* 重启全部服务器(/lsp-reload 无参):关闭所有 client、清空失败记录并
|
|
433
|
+
* 解除禁用;配置缓存失效,下次工具调用重新读盘。
|
|
434
|
+
*/
|
|
435
|
+
reloadAll(): Promise<void>;
|
|
398
436
|
/** 已知服务器 id(running 或 broken 的去重集合),供命令补全与提示。 */
|
|
399
437
|
serverIDs(): string[];
|
|
400
438
|
/** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
|
|
@@ -426,6 +464,10 @@ export interface LspServiceStartupOptions {
|
|
|
426
464
|
notifyIntervalMs?: number;
|
|
427
465
|
/** 会话级通知:启动失败时主动上报,不依赖触发请求恰好携带 notify。 */
|
|
428
466
|
notify?: ExtensionUIContext["notify"];
|
|
467
|
+
/** session_start 预读的生效配置:直接注入缓存,避免首个工具调用重复读盘。 */
|
|
468
|
+
initialConfig?: ResolvedLspConfig;
|
|
469
|
+
/** initialConfig 对应的 cwd。 */
|
|
470
|
+
initialCwd?: string;
|
|
429
471
|
}
|
|
430
472
|
|
|
431
473
|
export function createLspService(
|
|
@@ -453,10 +495,23 @@ export function createLspService(
|
|
|
453
495
|
servers: new Map(),
|
|
454
496
|
cwd: undefined,
|
|
455
497
|
clientExtensions: new Map(),
|
|
498
|
+
config: startupOptions?.initialConfig,
|
|
499
|
+
configCwd: startupOptions?.initialCwd,
|
|
456
500
|
};
|
|
457
501
|
|
|
458
502
|
let renderStatus: StatusRenderer | undefined;
|
|
459
503
|
|
|
504
|
+
/** 配置缓存:同一 cwd 内复用;cwd 变化或 reload 清缓存后重读。 */
|
|
505
|
+
async function currentConfig(cwd: string): Promise<ResolvedLspConfig> {
|
|
506
|
+
if (state.config && state.configCwd === cwd) return state.config;
|
|
507
|
+
const config = await loadLspConfig(cwd, globalConfigPath, (message) =>
|
|
508
|
+
sessionNotify?.(message, "warning"),
|
|
509
|
+
);
|
|
510
|
+
state.config = config;
|
|
511
|
+
state.configCwd = cwd;
|
|
512
|
+
return config;
|
|
513
|
+
}
|
|
514
|
+
|
|
460
515
|
// ── 工作区文件监听(watcher)───────────────────────────────────────────────
|
|
461
516
|
|
|
462
517
|
let watcher: WorkspaceWatcher | undefined;
|
|
@@ -526,7 +581,7 @@ export function createLspService(
|
|
|
526
581
|
/** 首个 client 建立 / 会话 cwd 变化时(重)建监听器;watch.enabled: false 时不启动。 */
|
|
527
582
|
async function ensureWatcher(cwd: string, notify?: ExtensionUIContext["notify"]): Promise<void> {
|
|
528
583
|
if (state.closing || state.disabled) return;
|
|
529
|
-
const config = await
|
|
584
|
+
const config = await currentConfig(cwd);
|
|
530
585
|
const watch = config.watch;
|
|
531
586
|
if (!watch.enabled) return;
|
|
532
587
|
if (watcher && watcherCwd === cwd) return;
|
|
@@ -612,7 +667,7 @@ export function createLspService(
|
|
|
612
667
|
}
|
|
613
668
|
if (state.closing || state.disabled) return [];
|
|
614
669
|
if (!containsPath(file, cwd)) return [];
|
|
615
|
-
const config = await
|
|
670
|
+
const config = await currentConfig(cwd);
|
|
616
671
|
const timeout = {
|
|
617
672
|
diagnosticsDebounceMs: config.diagnosticsDebounceMs,
|
|
618
673
|
diagnosticsDocumentWaitTimeoutMs: config.diagnosticsDocumentWaitTimeoutMs,
|
|
@@ -633,8 +688,9 @@ export function createLspService(
|
|
|
633
688
|
for (const adapter of active) {
|
|
634
689
|
if (adapterFilter && !adapterFilter(adapter)) continue;
|
|
635
690
|
if (adapter.extensions.length > 0 && !adapter.extensions.includes(extension)) continue;
|
|
636
|
-
const root =
|
|
637
|
-
if (!root) continue;
|
|
691
|
+
const root = serverRoot(adapter.workingDir, cwd);
|
|
692
|
+
if (!containsPath(file, root)) continue;
|
|
693
|
+
if (!matchesInclude(adapter.include ?? [], file, root, cwd)) continue;
|
|
638
694
|
const key = root + adapter.id;
|
|
639
695
|
const failedAt = state.brokenFailAt.get(key);
|
|
640
696
|
if (failedAt !== undefined) {
|
|
@@ -925,6 +981,9 @@ export function createLspService(
|
|
|
925
981
|
|
|
926
982
|
async function reload(serverID: string): Promise<void> {
|
|
927
983
|
state.closing = true;
|
|
984
|
+
// 配置缓存失效:下次工具调用按当前 cwd 重新读盘,让配置修改生效
|
|
985
|
+
state.config = undefined;
|
|
986
|
+
state.configCwd = undefined;
|
|
928
987
|
const targets = state.clients.filter((client) => client.serverID === serverID);
|
|
929
988
|
await Promise.all(targets.map((client) => client.shutdown())).catch(() => {
|
|
930
989
|
// 个别进程退出失败不阻止清理流程
|
|
@@ -943,6 +1002,24 @@ export function createLspService(
|
|
|
943
1002
|
updateStatusText();
|
|
944
1003
|
}
|
|
945
1004
|
|
|
1005
|
+
async function reloadAll(): Promise<void> {
|
|
1006
|
+
state.closing = true;
|
|
1007
|
+
state.config = undefined;
|
|
1008
|
+
state.configCwd = undefined;
|
|
1009
|
+
await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
|
|
1010
|
+
// 个别进程退出失败不阻止清理流程
|
|
1011
|
+
});
|
|
1012
|
+
state.clients = [];
|
|
1013
|
+
state.clientExtensions.clear();
|
|
1014
|
+
state.brokenFailAt.clear();
|
|
1015
|
+
state.brokenNotifiedAt.clear();
|
|
1016
|
+
state.servers.clear();
|
|
1017
|
+
await stopWatcher();
|
|
1018
|
+
state.closing = false;
|
|
1019
|
+
state.disabled = false;
|
|
1020
|
+
updateStatusText();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
946
1023
|
function serverIDs(): string[] {
|
|
947
1024
|
return [...new Set([...state.servers.values()].map((server) => server.serverID))];
|
|
948
1025
|
}
|
|
@@ -958,6 +1035,7 @@ export function createLspService(
|
|
|
958
1035
|
stop,
|
|
959
1036
|
start,
|
|
960
1037
|
reload,
|
|
1038
|
+
reloadAll,
|
|
961
1039
|
serverIDs,
|
|
962
1040
|
attachStatus,
|
|
963
1041
|
refreshStatus: updateStatusText,
|
|
@@ -1036,7 +1114,13 @@ export function createLspManager(
|
|
|
1036
1114
|
service = undefined;
|
|
1037
1115
|
}
|
|
1038
1116
|
try {
|
|
1039
|
-
const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath)
|
|
1117
|
+
const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath, (message) => {
|
|
1118
|
+
try {
|
|
1119
|
+
ctx.ui.notify(message, "warning");
|
|
1120
|
+
} catch {
|
|
1121
|
+
/* 旧会话 ctx 已失效:通知无处可去 */
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1040
1124
|
validateConfig(config, options?.adapters);
|
|
1041
1125
|
if (enabledServerCount(config, options?.adapters) === 0) return;
|
|
1042
1126
|
// 下面两个闭包被 service 长期持有,可能在会话被替换或 reload 后仍触发
|
|
@@ -1053,6 +1137,9 @@ export function createLspManager(
|
|
|
1053
1137
|
/* 旧会话 ctx 已失效或 UI 不可用:通知无处可去 */
|
|
1054
1138
|
}
|
|
1055
1139
|
},
|
|
1140
|
+
// session_start 已读盘的配置直接注入缓存,首个工具调用无需重复读盘
|
|
1141
|
+
initialConfig: config,
|
|
1142
|
+
initialCwd: ctx.cwd,
|
|
1056
1143
|
});
|
|
1057
1144
|
// footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
|
|
1058
1145
|
next.attachStatus((text) => {
|
|
@@ -1109,7 +1196,8 @@ export function createLspManager(
|
|
|
1109
1196
|
});
|
|
1110
1197
|
|
|
1111
1198
|
pi.registerCommand("lsp-reload", {
|
|
1112
|
-
description:
|
|
1199
|
+
description:
|
|
1200
|
+
"Restart LSP servers: /lsp-reload <server-id> for one, no argument reloads config and restarts all",
|
|
1113
1201
|
getArgumentCompletions: (prefix) =>
|
|
1114
1202
|
(service?.serverIDs() ?? [])
|
|
1115
1203
|
.toSorted()
|
|
@@ -1121,12 +1209,10 @@ export function createLspManager(
|
|
|
1121
1209
|
return;
|
|
1122
1210
|
}
|
|
1123
1211
|
const serverID = args.trim();
|
|
1124
|
-
const known = service.serverIDs().toSorted();
|
|
1125
1212
|
if (!serverID) {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
);
|
|
1213
|
+
// 无参:重读配置并重启全部服务器
|
|
1214
|
+
await service.reloadAll();
|
|
1215
|
+
ctx.ui.notify("LSP reloaded: all servers will restart on the next tool call", "info");
|
|
1130
1216
|
return;
|
|
1131
1217
|
}
|
|
1132
1218
|
await service.reload(serverID);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 配置驱动的 LSP 服务器:一份 JSON 配置定义一个语言服务器(bin/args/
|
|
2
|
+
* 配置驱动的 LSP 服务器:一份 JSON 配置定义一个语言服务器(bin/args/workingDir/
|
|
3
3
|
* languageId/超时等),替代为每个语言写一个 adapter class。
|
|
4
4
|
*
|
|
5
5
|
* 配置文件沿用 lsp.json(全局 ~/.pi/agent/lsp.json + 本地 <cwd>/.pi/lsp.json):
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* 覆盖、新增 id、全局其余保留),之后受顶层 enabled/disabled 列表过滤。
|
|
8
8
|
* 没有内置默认服务器:未配置 servers 时不启动任何服务器。
|
|
9
9
|
*
|
|
10
|
+
* 不做向上查找项目根的猜测:root 即调用 cwd(或 per-server workingDir),
|
|
11
|
+
* 文件归属由「文件位于 root 之内」+ include glob 判定。
|
|
12
|
+
*
|
|
10
13
|
* executable 发现统一由用户配置:bin 支持绝对路径 / 项目工作区
|
|
11
14
|
* (node_modules/.bin、.venv/bin、venv/bin)/ PATH,不再内置各语言的
|
|
12
15
|
* 特殊探测逻辑(tsserver 路径、venv python 等)。
|
|
@@ -20,12 +23,7 @@ import { promisify } from "node:util";
|
|
|
20
23
|
import { minimatch } from "minimatch";
|
|
21
24
|
import { type Static, Type } from "typebox";
|
|
22
25
|
|
|
23
|
-
import {
|
|
24
|
-
type LspServerAdapter,
|
|
25
|
-
type LspServerHandle,
|
|
26
|
-
nearestRoot,
|
|
27
|
-
type ServerKind,
|
|
28
|
-
} from "./adapter.js";
|
|
26
|
+
import { type LspServerAdapter, type LspServerHandle, type ServerKind } from "./adapter.js";
|
|
29
27
|
import { exists, findBinaryInWorkspace, which } from "./bin.js";
|
|
30
28
|
import { spawnProcess } from "./launch.js";
|
|
31
29
|
|
|
@@ -34,13 +32,14 @@ export const serverConfigSchema = Type.Object({
|
|
|
34
32
|
include: Type.Optional(Type.Array(Type.String())),
|
|
35
33
|
/** 服务器类型:language(真语言服务器,缺省)或 linter(只实现 LSP 协议的 lint)。 */
|
|
36
34
|
kind: Type.Optional(Type.Union([Type.Literal("language"), Type.Literal("linter")])),
|
|
37
|
-
/**
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。
|
|
37
|
+
* 文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它。
|
|
38
|
+
*/
|
|
39
|
+
workingDir: Type.Optional(Type.String()),
|
|
39
40
|
/** 可执行文件:绝对路径、相对调用 cwd 的路径,或名字(项目工作区优先,PATH 兜底)。 */
|
|
40
41
|
bin: Type.Optional(Type.String()),
|
|
41
42
|
args: Type.Optional(Type.Array(Type.String())),
|
|
42
|
-
/** 启动工作目录,支持 {root} / {cwd} 模板;缺省 {root}。 */
|
|
43
|
-
cwd: Type.Optional(Type.String()),
|
|
44
43
|
/**
|
|
45
44
|
* 追加到子进程的环境变量。string 值支持 {root} / {cwd} 模板与 ${VAR} 引用;
|
|
46
45
|
* {sh: [...]} 在启动时执行命令(argv 直接执行、不经 shell),stdout trim 后作为值,
|
|
@@ -173,7 +172,7 @@ async function resolveBinary(bin: string, root: string, cwd: string): Promise<st
|
|
|
173
172
|
* 支持 `!` 否定模式排除;多 pattern 数组拆开判断(任意 positive 命中且
|
|
174
173
|
* 不被任何 negative 排除),避免库对混合数组的语义差异。
|
|
175
174
|
*/
|
|
176
|
-
function matchesInclude(
|
|
175
|
+
export function matchesInclude(
|
|
177
176
|
patterns: readonly string[],
|
|
178
177
|
file: string,
|
|
179
178
|
root: string,
|
|
@@ -196,11 +195,13 @@ function matchesInclude(
|
|
|
196
195
|
return candidates.some((candidate) => matches(candidate));
|
|
197
196
|
}
|
|
198
197
|
|
|
199
|
-
/** 由配置构建的通用 adapter;include
|
|
198
|
+
/** 由配置构建的通用 adapter;include 过滤在 lsp.ts 的 client 匹配阶段完成,extensions 不设扩展名过滤。 */
|
|
200
199
|
export class ConfigAdapter implements LspServerAdapter {
|
|
201
200
|
readonly id: string;
|
|
202
201
|
readonly kind: ServerKind;
|
|
203
202
|
readonly extensions: readonly string[] = [];
|
|
203
|
+
readonly include: readonly string[];
|
|
204
|
+
readonly workingDir: string | undefined;
|
|
204
205
|
readonly startupTimeoutMs: number | undefined;
|
|
205
206
|
readonly diagnosticsWaitMs: number | undefined;
|
|
206
207
|
readonly config: ServerConfig;
|
|
@@ -209,27 +210,21 @@ export class ConfigAdapter implements LspServerAdapter {
|
|
|
209
210
|
this.id = id;
|
|
210
211
|
this.config = config;
|
|
211
212
|
this.kind = config.kind ?? "language";
|
|
213
|
+
this.include = config.include ?? [];
|
|
214
|
+
this.workingDir = config.workingDir;
|
|
212
215
|
this.startupTimeoutMs = config.startupTimeoutMs;
|
|
213
216
|
this.diagnosticsWaitMs = config.diagnosticsWaitMs;
|
|
214
217
|
}
|
|
215
218
|
|
|
216
|
-
findRoot(file: string, cwd: string): Promise<string | undefined> {
|
|
217
|
-
const root = nearestRoot(this.config.rootMarkers ?? [], file, cwd);
|
|
218
|
-
return root.then((resolved) =>
|
|
219
|
-
matchesInclude(this.config.include ?? [], file, resolved, cwd) ? resolved : undefined,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
219
|
async spawn(root: string, cwd: string): Promise<LspServerHandle | undefined> {
|
|
224
220
|
const bin = this.config.bin;
|
|
225
221
|
if (!bin) return undefined;
|
|
226
222
|
const resolved = await resolveBinary(bin, root, cwd);
|
|
227
223
|
if (!resolved) return undefined;
|
|
228
|
-
const spawnCwd = resolveTemplate(this.config.cwd ?? "{root}", root, cwd);
|
|
229
224
|
const env = await resolveEnv(this.config.env, root, cwd);
|
|
230
225
|
return {
|
|
231
226
|
process: spawnProcess(resolved, this.config.args ?? [], {
|
|
232
|
-
cwd:
|
|
227
|
+
cwd: root,
|
|
233
228
|
env,
|
|
234
229
|
}),
|
|
235
230
|
initialization: interpolateEnvDeep(this.config.initializationOptions, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lsp-config
|
|
3
|
-
description: Use when 编写、检查或排查 LSP 语言服务器配置 —— 项目本地 .pi/lsp.json 或全局 ~/.pi/agent/lsp.json:配置 typescript-language-server / pyright / ruff / gopls / clangd 等服务器的 bin、include、
|
|
3
|
+
description: Use when 编写、检查或排查 LSP 语言服务器配置 —— 项目本地 .pi/lsp.json 或全局 ~/.pi/agent/lsp.json:配置 typescript-language-server / pyright / ruff / gopls / clangd 等服务器的 bin、include、workingDir、initializationOptions、tsserver.path、watch 与诊断超时;或排查服务器起不来(binary not found / provides no tsserver.js)、typescript alias 依赖下 tsserver 找不到、诊断一直为空等问题。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 配置 LSP 服务器(.pi/lsp.json)
|
|
@@ -14,14 +14,14 @@ description: Use when 编写、检查或排查 LSP 语言服务器配置 ——
|
|
|
14
14
|
|
|
15
15
|
合并是**纯函数**(`mergeConfig(global, local)`),规则:
|
|
16
16
|
|
|
17
|
-
| 段 | 合并方式
|
|
18
|
-
| -------------------- |
|
|
19
|
-
| 顶层其余字段 | 本地覆盖全局(浅合并)
|
|
20
|
-
| `servers` | 按服务器 id **整条覆盖**:本地写某个 id 会替换全局同 id 的整条记录,**不是字段级合并**。只改一个字段(如补 `initializationOptions`)也必须把 `include`/`
|
|
21
|
-
| `watch` | 字段级合并:全局为基底、本地逐字段覆盖;`ignore` 两侧**并集去重**(全局在前),本地写 watch 段不会清掉全局 ignore
|
|
22
|
-
| 某段全局本地都未出现 | 保持缺失(调用方用 `...(watch && { watch })` 省略键,不要输出空对象)
|
|
17
|
+
| 段 | 合并方式 |
|
|
18
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| 顶层其余字段 | 本地覆盖全局(浅合并) |
|
|
20
|
+
| `servers` | 按服务器 id **整条覆盖**:本地写某个 id 会替换全局同 id 的整条记录,**不是字段级合并**。只改一个字段(如补 `initializationOptions`)也必须把 `include`/`workingDir`/`bin`/`args`/`languageIdByExtension` 全部带上,否则丢全局字段 |
|
|
21
|
+
| `watch` | 字段级合并:全局为基底、本地逐字段覆盖;`ignore` 两侧**并集去重**(全局在前),本地写 watch 段不会清掉全局 ignore |
|
|
22
|
+
| 某段全局本地都未出现 | 保持缺失(调用方用 `...(watch && { watch })` 省略键,不要输出空对象) |
|
|
23
23
|
|
|
24
|
-
配置经 typebox
|
|
24
|
+
配置经 typebox 校验:字段类型不符、非法时长格式等在读取时**直接抛错**;schema 外的未知字段(含已删除的遗留字段如 `rootMarkers`)**不拒绝**,以 warning notify 逐个上报。`version` 当前为 1。
|
|
25
25
|
|
|
26
26
|
## 顶层字段速查
|
|
27
27
|
|
|
@@ -41,10 +41,9 @@ diagnosticsFullWaitTimeoutMs / diagnosticsRequestTimeoutMs / initializeTimeoutMs
|
|
|
41
41
|
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
42
42
|
| `include` | 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件 |
|
|
43
43
|
| `kind` | `language`(缺省)或 `linter`。rename / inspect 等符号级功能**只面向 `language`**;linter 只参与诊断 |
|
|
44
|
-
| `
|
|
44
|
+
| `workingDir` | 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即调用 cwd。文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它 |
|
|
45
45
|
| `bin` | 可执行文件:绝对路径、相对调用 cwd 的路径、或仅名字(项目工作区优先,PATH 兜底) |
|
|
46
46
|
| `args` | 启动参数数组 |
|
|
47
|
-
| `cwd` | 启动工作目录,支持 `{root}` / `{cwd}` 模板;缺省 `{root}` |
|
|
48
47
|
| `env` | 追加的环境变量:string 值支持 `{root}`/`{cwd}` 与 `${VAR}` 插值;`{ "sh": ["cmd","arg"] }` 启动时执行命令取 stdout(非零退出/空输出 → 启动失败并报错) |
|
|
49
48
|
| `languageIdByExtension` | 扩展名(含点)→ languageId;缺省回退内置映射(见 `src/lib/lsp/language.ts`,覆盖主流语言) |
|
|
50
49
|
| `startupTimeoutMs` / `diagnosticsWaitMs` | 覆盖该服务器的初始化握手 / 写文件后诊断等待(缺省用全局配置与 client 默认) |
|
|
@@ -70,10 +69,8 @@ typescript-language-server **不内置 TypeScript**(零依赖)。启动时
|
|
|
70
69
|
"servers": {
|
|
71
70
|
"typescript": {
|
|
72
71
|
"include": ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
|
|
73
|
-
"rootMarkers": ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"],
|
|
74
72
|
"bin": "typescript-language-server",
|
|
75
73
|
"args": ["--stdio"],
|
|
76
|
-
"cwd": "{root}",
|
|
77
74
|
"languageIdByExtension": {
|
|
78
75
|
".ts": "typescript",
|
|
79
76
|
".tsx": "typescriptreact",
|
|
@@ -94,11 +91,11 @@ typescript-language-server **不内置 TypeScript**(零依赖)。启动时
|
|
|
94
91
|
|
|
95
92
|
- 启动失败(binary 缺失 / initialize 拒绝)会**主动 notify 报错**(server id、root、原因、提示 `/lsp-reload <id>`),不需要等请求方触发
|
|
96
93
|
- 失败进入 60s 冷却:冷却内该服务器被跳过,**冷却过后下次触碰自动重试**——修复配置后不用重启 agent,等冷却过即可,或立即 `/lsp-reload <id>` 重启指定服务器(同时清除失败记录)
|
|
97
|
-
- 命令:`/lsp-reload <id>`(重启单个)、`/lsp-stop`(停全部并禁用)、`/lsp-start`(重新启用)
|
|
94
|
+
- 命令:`/lsp-reload <id>`(重启单个)、`/lsp-reload`(无参:重读配置并重启全部)、`/lsp-stop`(停全部并禁用)、`/lsp-start`(重新启用)
|
|
98
95
|
|
|
99
96
|
## 生效时机
|
|
100
97
|
|
|
101
|
-
- `
|
|
98
|
+
- 配置在 `session_start` 预读并**按 cwd 缓存**:cwd 变化或 `/lsp-reload`(单个或无参)时重读。改配置后用 `/lsp-reload` 让全部或单个服务器换新配置(已启动的进程随之重启)
|
|
102
99
|
- `session_start` 时若 enabled 服务器数为 0 则不创建 service,之后从无到有地启用需要重启 agent
|
|
103
100
|
|
|
104
101
|
## 常见排查
|
|
@@ -108,4 +105,4 @@ typescript-language-server **不内置 TypeScript**(零依赖)。启动时
|
|
|
108
105
|
| notify `failed to start …: binary not found` | `bin` 不在 PATH(测试场景二进制缺失时整组跳过) |
|
|
109
106
|
| `… provides no tsserver.js. No other valid TypeScript installation was found` | workspace 的 `node_modules/typescript` 是 alias stub:配 `initializationOptions.tsserver.path` 指 `.pnpm` 实体,或换标准 typescript 依赖 |
|
|
110
107
|
| 诊断一直为空且无任何报错 | 服务器没匹配到文件(`include`/扩展名)、在 broken 冷却中、或文件在调用 cwd 之外(LSP 只在工作目录内启用) |
|
|
111
|
-
| 读取配置直接抛错 | typebox
|
|
108
|
+
| 读取配置直接抛错 | typebox 严格校验拒绝:字段类型不符 / 非法时长格式(未知字段只是 warning,不拒绝) |
|