@trim21/personal-pi-extensions 0.1.489 → 0.1.491

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.489",
3
+ "version": "0.1.491",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -18,7 +18,7 @@ import { type BashCommand, parseBashCommands } from "./approval-rules.js";
18
18
  */
19
19
  const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
20
20
  string,
21
- number
21
+ number | undefined
22
22
  >;
23
23
 
24
24
  /**
package/src/bwrap/core.ts CHANGED
@@ -105,11 +105,11 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
105
105
  const base = {
106
106
  mode: config.mode,
107
107
  bwrapPath: config.bwrapPath,
108
- writablePaths: config.writablePaths ?? [".", "/tmp"],
108
+ writablePaths: config.writablePaths,
109
109
  extraWritablePaths: config.extraWritablePaths,
110
- denyPaths: config.denyPaths ?? [],
111
- extraArgs: config.extraArgs ?? [],
112
- networkAllowlist: config.networkAllowlist ?? [],
110
+ denyPaths: config.denyPaths,
111
+ extraArgs: config.extraArgs,
112
+ networkAllowlist: config.networkAllowlist,
113
113
  mihomoPath: config.mihomoPath,
114
114
  slirp4netnsPath: config.slirp4netnsPath,
115
115
  approvalRules: config.approvalRules ?? [],
@@ -529,8 +529,8 @@ export function createBwrapBashOperations(
529
529
  }, timeout * 1000)
530
530
  : undefined;
531
531
  const onAbort = () => killChild(child);
532
- child.stdout?.on("data", onData);
533
- child.stderr?.on("data", onData);
532
+ child.stdout.on("data", onData);
533
+ child.stderr.on("data", onData);
534
534
  signal?.addEventListener("abort", onAbort, { once: true });
535
535
  child.once("error", reject);
536
536
  child.once("close", (exitCode) => {
@@ -81,7 +81,9 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
81
81
  const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
82
82
  if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
83
83
  host = match[1];
84
- port = match[2] === undefined ? undefined : parsePort(match[2]);
84
+ // 端口组 (?::(\d+))? 可选:无端口时 match[2] 在运行时是 undefined
85
+ const portPart = match[2] as string | undefined;
86
+ port = portPart === undefined ? undefined : parsePort(portPart);
85
87
  } else {
86
88
  const colon = entry.lastIndexOf(":");
87
89
  if (colon === -1) {
@@ -332,8 +332,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
332
332
  killChild(child.pid);
333
333
  };
334
334
 
335
- child.stdout?.on("data", execOptions.onData);
336
- child.stderr?.on("data", execOptions.onData);
335
+ child.stdout.on("data", execOptions.onData);
336
+ child.stderr.on("data", execOptions.onData);
337
337
  execOptions.signal?.addEventListener("abort", onAbort, { once: true });
338
338
 
339
339
  child.once("error", (error) => {
@@ -73,10 +73,10 @@ function applyCurlySingleQuotes(str: string): string {
73
73
  for (let i = 0; i < chars.length; i++) {
74
74
  if (chars[i] === "'") {
75
75
  // 缩写中的撇号(如 don't)用右花引号,不做开/闭判断
76
- const prev = chars[i - 1];
77
- const next = chars[i + 1];
78
- const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev);
79
- const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
76
+ const prev = i > 0 ? chars[i - 1] : "";
77
+ const next = i + 1 < chars.length ? chars[i + 1] : "";
78
+ const prevIsLetter = /\p{L}/u.test(prev);
79
+ const nextIsLetter = /\p{L}/u.test(next);
80
80
  if (prevIsLetter && nextIsLetter) {
81
81
  result.push(RIGHT_SINGLE_CURLY_QUOTE);
82
82
  } else {
@@ -15,7 +15,8 @@ import {
15
15
  import { Type } from "typebox";
16
16
 
17
17
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
18
- import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
18
+ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
19
+ import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
19
20
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
20
21
  import { formatSubtitlePath } from "../lib/path.js";
21
22
  import type { ToolPendant } from "../lib/pendant.ts";
@@ -228,7 +229,7 @@ function requireCurrentRead(
228
229
  export function registerFileTools(
229
230
  pi: ExtensionAPI,
230
231
  state: ClaudeCodeState,
231
- service: LspService,
232
+ getService: () => LspService,
232
233
  ): void {
233
234
  pi.registerTool({
234
235
  name: "Read",
@@ -351,9 +352,11 @@ export function registerFileTools(
351
352
  const snapshot = { ...snapshotOf(buffer), offset, limit };
352
353
  state.reads.set(key, snapshot);
353
354
  // LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
354
- void service.notifyFile(filePath, ctx.cwd).catch(() => {
355
- // 后台通知失败不影响读取
356
- });
355
+ void getService()
356
+ .notifyFile(filePath, ctx.cwd)
357
+ .catch(() => {
358
+ // 后台通知失败不影响读取
359
+ });
357
360
  return {
358
361
  content: [{ type: "text", text: formatted.text }],
359
362
  details: {
@@ -443,7 +446,7 @@ export function registerFileTools(
443
446
  text: diagnosticText,
444
447
  errorCount,
445
448
  warningCount,
446
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
449
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
447
450
  notify: (message, level) => ctx.ui.notify(message, level),
448
451
  signal,
449
452
  });
@@ -546,7 +549,7 @@ export function registerFileTools(
546
549
  text: diagnosticText,
547
550
  errorCount,
548
551
  warningCount,
549
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
552
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
550
553
  notify: (message, level) => ctx.ui.notify(message, level),
551
554
  });
552
555
  return [
@@ -645,7 +648,7 @@ export function registerFileTools(
645
648
  text: diagnosticText,
646
649
  errorCount,
647
650
  warningCount,
648
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
651
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
649
652
  notify: (message, level) => ctx.ui.notify(message, level),
650
653
  });
651
654
  return [
@@ -674,21 +677,8 @@ export function registerFileTools(
674
677
  },
675
678
  });
676
679
 
677
- // lsp-rename 工具壳与 opencode 共享(lib/lsp/rename-tool.ts);本工具集
678
- // 跟踪 read-before-write 状态,rename 落盘的文件要标记为已读并随 details
679
- // 持久化(FILE_TOOL_NAMES 的 restoreFileReads 依赖 details.reads)。
680
- registerLspRenameTool(pi, service, {
681
- recordReads: async (applied) => {
682
- const reads: Record<string, FileSnapshot> = {};
683
- for (const fileEdit of applied) {
684
- const key = await readStateKey(fileEdit.path);
685
- const snapshot = snapshotOf(fileEdit.newText);
686
- state.reads.set(key, snapshot);
687
- reads[key] = snapshot;
688
- }
689
- return reads;
690
- },
691
- });
680
+ // 只读符号查询工具(find-definition / find-reference / inspect)与 opencode 共享;
681
+ // 它们由 manager onEnabled 回调注册,不在这里注册。
692
682
  }
693
683
 
694
684
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
@@ -720,10 +710,34 @@ function restoreFileReads(
720
710
  * state 归本文件所有:扩展实例内创建,并随 session 事件从历史分支恢复,
721
711
  * 与主进程 index.ts 聚合加载时的行为一致。
722
712
  */
723
- export default function claudeCodeFileTools(pi: ExtensionAPI): void {
724
- const service = registerLsp(pi);
713
+ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServiceOptions): void {
725
714
  const state = createClaudeCodeState();
726
715
 
716
+ // LSP 专属工具(lsp-rename / inspect 族)仅在 lsp.json 存在 enabled 服务器时
717
+ // 注册(session_start 校验后);本工具集跟踪 read-before-write 状态,rename
718
+ // 落盘的文件要标记为已读并随 details 持久化(restoreFileReads 依赖 details.reads)。
719
+ const manager = createLspManager(
720
+ pi,
721
+ {
722
+ onEnabled: (pi, service) => {
723
+ registerLspRenameTool(pi, service, {
724
+ recordReads: async (applied) => {
725
+ const reads: Record<string, FileSnapshot> = {};
726
+ for (const fileEdit of applied) {
727
+ const key = await readStateKey(fileEdit.path);
728
+ const snapshot = snapshotOf(fileEdit.newText);
729
+ state.reads.set(key, snapshot);
730
+ reads[key] = snapshot;
731
+ }
732
+ return reads;
733
+ },
734
+ });
735
+ registerLspInspectTools(pi, service);
736
+ },
737
+ },
738
+ options,
739
+ );
740
+
727
741
  // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
728
742
  // 已读记账随之丢失。这里从当前分支的历史工具结果里恢复:digest 是当时的值,
729
743
  // 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
@@ -739,5 +753,6 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
739
753
  restoreFileReads(state, ctx.sessionManager);
740
754
  });
741
755
 
742
- registerFileTools(pi, state, service);
756
+ // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
757
+ registerFileTools(pi, state, () => manager.mustLazyGetService());
743
758
  }
@@ -34,7 +34,7 @@ function extractGlobBaseDirectory(pattern: string): {
34
34
  } {
35
35
  const globChars = /[*?[{]/;
36
36
  const match = globChars.exec(pattern);
37
- if (!match || match.index === undefined) {
37
+ if (!match) {
38
38
  // 无 glob 特殊字符:字面路径,目录部分作为 baseDir
39
39
  return { baseDir: dirname(pattern), relativePattern: basename(pattern) };
40
40
  }
@@ -122,12 +122,12 @@ export function runGh(
122
122
  timeoutId = setTimeout(() => killProcess("timeout"), timeout);
123
123
  }
124
124
 
125
- proc.stdout?.on("data", (data: Buffer) => {
125
+ proc.stdout.on("data", (data: Buffer) => {
126
126
  const text = data.toString();
127
127
  stdout += text;
128
128
  combined.push(text);
129
129
  });
130
- proc.stderr?.on("data", (data: Buffer) => {
130
+ proc.stderr.on("data", (data: Buffer) => {
131
131
  const text = data.toString();
132
132
  stderr += text;
133
133
  combined.push(text);
@@ -793,7 +793,7 @@ export async function renderStepLog(
793
793
  const totalLines = clean.split("\n").length;
794
794
  let logToShow = clean;
795
795
  let appliedOffset = false;
796
- if (offset !== undefined && offset !== null && offset > 1) {
796
+ if (offset !== undefined && offset > 1) {
797
797
  if (offset > totalLines) {
798
798
  return {
799
799
  content: [
@@ -867,7 +867,7 @@ export async function renderJobLogs(
867
867
  ): Promise<CiLogsResult> {
868
868
  const { job, offset, limit, full } = params;
869
869
 
870
- if (!jobs || jobs.length === 0) {
870
+ if (jobs.length === 0) {
871
871
  return {
872
872
  content: [{ type: "text", text: `No jobs found for run ${params.runId}` }],
873
873
  details: {},
@@ -923,7 +923,7 @@ export async function renderJobLogs(
923
923
 
924
924
  // Apply offset on the cleaned text, then truncate.
925
925
  let logToShow = clean;
926
- if (offset !== undefined && offset !== null && offset > 1) {
926
+ if (offset !== undefined && offset > 1) {
927
927
  if (offset > totalLines) {
928
928
  steps.push({ name: s.name });
929
929
  continue;
@@ -1037,9 +1037,12 @@ export async function writeLogFile(
1037
1037
 
1038
1038
  let content: string;
1039
1039
  let what: string;
1040
- if (step !== undefined && step !== null) {
1040
+ if (step === undefined) {
1041
+ content = stripAnsi(rawLog);
1042
+ what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1043
+ } else {
1041
1044
  const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
1042
- if (!found) {
1045
+ if (found === undefined) {
1043
1046
  return {
1044
1047
  content: [
1045
1048
  {
@@ -1064,9 +1067,6 @@ export async function writeLogFile(
1064
1067
  }
1065
1068
  content = cleanStepOutput(stepLog);
1066
1069
  what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
1067
- } else {
1068
- content = stripAnsi(rawLog);
1069
- what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1070
1070
  }
1071
1071
 
1072
1072
  const target = resolve(cwd ?? process.cwd(), outputFile);
@@ -1480,7 +1480,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1480
1480
  getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
1481
1481
 
1482
1482
  // ── Write the complete log to a file ───────────────────────────────
1483
- if (output_file !== undefined && output_file !== null && output_file !== "") {
1483
+ if (output_file !== undefined && output_file !== "") {
1484
1484
  const result = await writeLogFile(
1485
1485
  { runId: String(run_id), job, step, outputFile: output_file },
1486
1486
  jobs,
@@ -1492,7 +1492,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1492
1492
  }
1493
1493
 
1494
1494
  // ── Fetch a specific step's logs (requires `job`) ─────────────────
1495
- if (step !== undefined && step !== null) {
1495
+ if (step !== undefined) {
1496
1496
  onUpdate?.({
1497
1497
  content: [{ type: "text", text: `Fetching job list...` }],
1498
1498
  details: {},
package/src/lib/cli.ts CHANGED
@@ -238,7 +238,7 @@ export function parseCommand<TFlags extends TObject>(
238
238
  } else if (info.kind === "boolean") {
239
239
  rawFlags[info.key] = true;
240
240
  } else {
241
- const next = tokens[i + 1];
241
+ const next = tokens.at(i + 1);
242
242
  if (next !== undefined && !looksLikeFlag(next)) {
243
243
  rawFlags[info.key] = next;
244
244
  i++;
@@ -266,7 +266,7 @@ export function parseCommand<TFlags extends TObject>(
266
266
  } else if (j + 1 < rest.length) {
267
267
  rawFlags[info.key] = rest.slice(j + 1);
268
268
  } else {
269
- const next = tokens[i + 1];
269
+ const next = tokens.at(i + 1);
270
270
  if (next !== undefined && !looksLikeFlag(next)) {
271
271
  rawFlags[info.key] = next;
272
272
  i++;
@@ -327,10 +327,7 @@ export function parseCommand<TFlags extends TObject>(
327
327
  parsed = Value.Convert(spec.flags, Value.Clone(parsed));
328
328
  if (!Value.Check(spec.flags, parsed)) {
329
329
  const [first] = [...Value.Errors(spec.flags, parsed)];
330
- return errorResult(
331
- spec,
332
- `Invalid arguments: ${first?.message ?? "value does not match flags"}`,
333
- );
330
+ return errorResult(spec, `Invalid arguments: ${first.message}`);
334
331
  }
335
332
  } catch {
336
333
  return errorResult(spec, "Invalid arguments");
@@ -20,7 +20,14 @@ import {
20
20
  StreamMessageReader,
21
21
  StreamMessageWriter,
22
22
  } from "vscode-jsonrpc/node";
23
- import type { Diagnostic as VSCodeDiagnostic, WorkspaceEdit } from "vscode-languageserver-types";
23
+ import type {
24
+ Diagnostic as VSCodeDiagnostic,
25
+ Hover,
26
+ Location as LspLocation,
27
+ LocationLink,
28
+ Range,
29
+ WorkspaceEdit,
30
+ } from "vscode-languageserver-types";
24
31
 
25
32
  import type { LspServerHandle } from "./adapter.js";
26
33
  import { LANGUAGE_EXTENSIONS } from "./language.js";
@@ -72,6 +79,20 @@ const LSP_CONTENT_MODIFIED = -32801;
72
79
  */
73
80
  export class RenameNotPossibleError extends Error {}
74
81
 
82
+ /**
83
+ * 服务器不支持某 LSP 方法(MethodNotFound)。与传输失败区分,供服务层跳过
84
+ * 该服务器尝试下一个,而不是把整个操作当作失败。
85
+ */
86
+ export class LspMethodNotSupportedError extends Error {
87
+ readonly serverID: string;
88
+ readonly method: string;
89
+ constructor(serverID: string, method: string) {
90
+ super(`LSP server "${serverID}" does not support ${method}`);
91
+ this.serverID = serverID;
92
+ this.method = method;
93
+ }
94
+ }
95
+
75
96
  /**
76
97
  * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
77
98
  * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
@@ -148,6 +169,50 @@ export interface RenameSymbolResult {
148
169
  placeholder?: string;
149
170
  }
150
171
 
172
+ /** definition / references / hover 请求的输入(line / character 为 0-based LSP position)。 */
173
+ export interface InspectPositionRequest {
174
+ path: string;
175
+ line: number;
176
+ character: number;
177
+ }
178
+
179
+ /** definition / references 归一化后的位置(0-based;1-based 格式化由工具层负责)。 */
180
+ export interface InspectLocation {
181
+ path: string;
182
+ line: number;
183
+ character: number;
184
+ }
185
+
186
+ type DefinitionResult = LspLocation | LspLocation[] | LocationLink | LocationLink[] | null;
187
+
188
+ /** Location / LocationLink → path + 0-based 坐标;非 file: URI 是服务器的意外行为,跳过。 */
189
+ function toInspectLocations(result: DefinitionResult): InspectLocation[] {
190
+ if (result === null) return [];
191
+ const items = Array.isArray(result) ? result : [result];
192
+ const locations: InspectLocation[] = [];
193
+ for (const item of items) {
194
+ if ("targetUri" in item) {
195
+ if (!item.targetUri.startsWith("file:")) continue;
196
+ const range =
197
+ (item as Omit<LocationLink, "targetSelectionRange"> & { targetSelectionRange?: Range })
198
+ .targetSelectionRange ?? item.targetRange;
199
+ locations.push({
200
+ path: normalize(fileURLToPath(item.targetUri)),
201
+ line: range.start.line,
202
+ character: range.start.character,
203
+ });
204
+ } else {
205
+ if (!item.uri.startsWith("file:")) continue;
206
+ locations.push({
207
+ path: normalize(fileURLToPath(item.uri)),
208
+ line: item.range.start.line,
209
+ character: item.range.start.character,
210
+ });
211
+ }
212
+ }
213
+ return locations;
214
+ }
215
+
151
216
  export class InitializeError extends Error {
152
217
  readonly serverID: string;
153
218
  constructor(serverID: string, cause: unknown) {
@@ -241,6 +306,21 @@ export interface LspClient {
241
306
  * 位置不在符号上 / 服务器不支持 rename 时抛 RenameNotPossibleError。
242
307
  */
243
308
  renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult>;
309
+ /**
310
+ * textDocument/definition:归一化后的定义位置列表(Location / LocationLink
311
+ * 统一转 path + 0-based 坐标;无结果返回空数组)。
312
+ */
313
+ definition(request: InspectPositionRequest): Promise<InspectLocation[]>;
314
+ /**
315
+ * textDocument/references:归一化后的引用位置列表(是否含声明处由服务器
316
+ * 按 includeDeclaration 决定,此处固定包含,对齐 rename 覆盖校验口径)。
317
+ */
318
+ references(request: InspectPositionRequest): Promise<InspectLocation[]>;
319
+ /**
320
+ * textDocument/hover:服务器返回的 contents 原样透传,不做内容归一化;
321
+ * 服务器无信息时返回 null(合法应答,非错误)。
322
+ */
323
+ hover(request: InspectPositionRequest): Promise<Hover | null>;
244
324
  shutdown(): Promise<void>;
245
325
  }
246
326
 
@@ -347,7 +427,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
347
427
  new StreamMessageReader(input.server.process.stdout),
348
428
  new StreamMessageWriter(input.server.process.stdin),
349
429
  );
350
- input.server.process.stderr?.resume();
430
+ input.server.process.stderr.resume();
351
431
  /** 连接或服务器进程已关闭;pull 重试循环以此终止,避免无界等待。 */
352
432
  let connectionClosed = false;
353
433
  input.server.process.once("exit", () => {
@@ -504,7 +584,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
504
584
  await connection.sendNotification("workspace/didChangeConfiguration", { settings });
505
585
  }
506
586
 
507
- const files: Record<string, { version: number; text: string }> = {};
587
+ const files: Record<string, { version: number; text: string } | undefined> = {};
508
588
 
509
589
  // ── 驻留 LRU ────────────────────────────────────────────────────────────────
510
590
 
@@ -746,16 +826,16 @@ export async function create(input: CreateInput): Promise<LspClient> {
746
826
  if (timeout <= 0) return Promise.resolve(false);
747
827
  return new Promise<boolean>((resolve) => {
748
828
  let finished = false;
829
+ const timer = setTimeout(() => finish(false), timeout);
749
830
  const finish = (result: boolean) => {
750
831
  if (finished) return;
751
832
  finished = true;
752
- if (timer) clearTimeout(timer);
833
+ clearTimeout(timer);
753
834
  registrationListeners.delete(listener);
754
835
  resolve(result);
755
836
  };
756
837
  const listener = () => finish(true);
757
838
  registrationListeners.add(listener);
758
- const timer = setTimeout(() => finish(false), timeout);
759
839
  });
760
840
  }
761
841
 
@@ -769,12 +849,14 @@ export async function create(input: CreateInput): Promise<LspClient> {
769
849
  return new Promise<boolean>((resolve) => {
770
850
  let finished = false;
771
851
  let debounceTimer: ReturnType<typeof setTimeout> | undefined;
852
+ const timeoutTimer = setTimeout(() => finish(false), request.timeout);
853
+ const unsub = () => diagnosticListeners.delete(listener);
772
854
  const finish = (result: boolean) => {
773
855
  if (finished) return;
774
856
  finished = true;
775
857
  if (debounceTimer) clearTimeout(debounceTimer);
776
- if (timeoutTimer) clearTimeout(timeoutTimer);
777
- unsub?.();
858
+ clearTimeout(timeoutTimer);
859
+ unsub();
778
860
  resolve(result);
779
861
  };
780
862
  const schedule = () => {
@@ -789,13 +871,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
789
871
  );
790
872
  };
791
873
 
792
- const timeoutTimer = setTimeout(() => finish(false), request.timeout);
793
874
  const listener = (event: { path: string; serverID: string }) => {
794
875
  if (event.path !== request.path || event.serverID !== input.serverID) return;
795
876
  schedule();
796
877
  };
797
878
  diagnosticListeners.add(listener);
798
- const unsub = () => diagnosticListeners.delete(listener);
799
879
  schedule();
800
880
  });
801
881
  }
@@ -922,6 +1002,31 @@ export async function create(input: CreateInput): Promise<LspClient> {
922
1002
  return 0;
923
1003
  };
924
1004
 
1005
+ // ── 只读符号查询(definition / references / hover)──────────────────────────
1006
+
1007
+ /** 请求前先同步磁盘内容(didOpen/didChange),保证服务器基于最新文本应答。 */
1008
+ const preparePositionRequest = async (request: InspectPositionRequest) => {
1009
+ const resolvedPath = normalize(
1010
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
1011
+ );
1012
+ await openDocument({ path: resolvedPath });
1013
+ return {
1014
+ uri: pathToFileURL(resolvedPath).href,
1015
+ position: { line: request.line, character: request.character },
1016
+ };
1017
+ };
1018
+
1019
+ const sendInspectRequest = async <T>(method: string, message: object): Promise<T> => {
1020
+ try {
1021
+ return await retryOnContentModified(() => connection.sendRequest<T>(method, message));
1022
+ } catch (error) {
1023
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
1024
+ throw new LspMethodNotSupportedError(input.serverID, method);
1025
+ }
1026
+ throw error;
1027
+ }
1028
+ };
1029
+
925
1030
  return {
926
1031
  root: input.root,
927
1032
  get serverID() {
@@ -1101,6 +1206,31 @@ export async function create(input: CreateInput): Promise<LspClient> {
1101
1206
  current = toPaths(await referencesRequest());
1102
1207
  }
1103
1208
  },
1209
+ async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
1210
+ const { uri, position } = await preparePositionRequest(request);
1211
+ return toInspectLocations(
1212
+ await sendInspectRequest<DefinitionResult>("textDocument/definition", {
1213
+ textDocument: { uri },
1214
+ position,
1215
+ }),
1216
+ );
1217
+ },
1218
+ async references(request: InspectPositionRequest): Promise<InspectLocation[]> {
1219
+ const { uri, position } = await preparePositionRequest(request);
1220
+ const locations = await sendInspectRequest<LspLocation[] | null>("textDocument/references", {
1221
+ textDocument: { uri },
1222
+ position,
1223
+ context: { includeDeclaration: true },
1224
+ });
1225
+ return toInspectLocations(locations);
1226
+ },
1227
+ async hover(request: InspectPositionRequest): Promise<Hover | null> {
1228
+ const { uri, position } = await preparePositionRequest(request);
1229
+ return await sendInspectRequest<Hover | null>("textDocument/hover", {
1230
+ textDocument: { uri },
1231
+ position,
1232
+ });
1233
+ },
1104
1234
  get diagnostics() {
1105
1235
  const result = new Map<string, Diagnostic[]>();
1106
1236
  for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {