@trim21/personal-pi-extensions 0.1.460 → 0.1.465

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.460",
3
+ "version": "0.1.465",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/index.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
- * AFT 扩展入口:感知工具(aft_outline / aft_zoom / aft_callgraph / aft_search)
3
- * + aft_refactor / aft_import(workspace-wide 重构与 import 管理,路径级写保护)。
2
+ * AFT 扩展入口:感知工具(aft_outline / aft_zoom / aft_callgraph / aft_search),
3
+ * 全部只读。
4
4
  *
5
- * 感知工具只读,不触碰本仓库自己的 read/write/edit/bash 工具及其安全机制
5
+ * 感知工具不触碰本仓库自己的 read/write/edit/bash 工具及其安全机制
6
6
  * (bwrap 沙箱、write-guard、reads 记账)。aft_search 仅当用户级 aft.jsonc 开启
7
7
  * semantic_search 且配好外部 embedding 后端(semantic.backend 为
8
8
  * openai_compatible / ollama 且有 base_url)时注册;aft 默认的本地 ONNX
9
- * fastembed 后端不使用。aft_refactor / aft_import 的 Rust 命令不支持 preview,
10
- * 写保护退化为路径级审批。
9
+ * fastembed 后端不使用。
11
10
  *
12
11
  * bridge 状态(日志 + 常驻 aft 子进程)的生命周期跟 session 走:session_start
13
12
  * 时用当次 session id 创建(日志落在 tmp/{sessionId}/aft-plugin.log),
@@ -23,8 +22,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
22
 
24
23
  import { createAftState, resolveSessionId, shutdownAftPool } from "./bridge.js";
25
24
  import { loadAftConfig } from "./config.js";
26
- import { registerImportTool } from "./imports.js";
27
- import { registerRefactorTool } from "./refactor.js";
28
25
  import {
29
26
  registerCallgraphTool,
30
27
  registerOutlineTool,
@@ -59,8 +56,6 @@ export default function aftReadTools(pi: ExtensionAPI): void {
59
56
  registerOutlineTool(pi, toolCtx);
60
57
  registerZoomTool(pi, toolCtx);
61
58
  registerCallgraphTool(pi, toolCtx);
62
- registerRefactorTool(pi, toolCtx);
63
- registerImportTool(pi, toolCtx);
64
59
  if (cfg.semanticSearch) {
65
60
  if (cfg.semanticRemote) {
66
61
  registerSearchTool(pi, toolCtx);
package/src/aft/tools.ts CHANGED
@@ -7,8 +7,6 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import { stat } from "node:fs/promises";
10
- import { homedir } from "node:os";
11
- import { isAbsolute, join, resolve } from "node:path";
12
10
  import { fileURLToPath } from "node:url";
13
11
 
14
12
  import {
@@ -22,7 +20,7 @@ import {
22
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
21
  import { Type } from "typebox";
24
22
 
25
- import { formatDisplayPath, formatSubtitlePath } from "../lib/path.js";
23
+ import { formatDisplayPath, formatSubtitlePath, resolvePathArg } from "../lib/path.js";
26
24
  import { type ToolPendant } from "../lib/pendant.js";
27
25
  import { type AftState, callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
28
26
 
@@ -41,15 +39,6 @@ const SEARCH_PROMPT = readFileSync(
41
39
  "utf8",
42
40
  ).trim();
43
41
 
44
- /** 解析 `~` 前缀与相对路径(相对 session cwd)。URL 与绝对路径原样返回。 */
45
- export function resolvePathArg(cwd: string, input: string): string {
46
- if (input === "~" || input.startsWith("~/")) {
47
- return join(homedir(), input.slice(1));
48
- }
49
- if (input.startsWith("http://") || input.startsWith("https://")) return input;
50
- return isAbsolute(input) ? input : resolve(cwd, input);
51
- }
52
-
53
42
  export interface AftToolContext {
54
43
  cwd: string;
55
44
  /** 当前 session 的 bridge 状态;session 未初始化时抛错。 */
@@ -14,9 +14,11 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { Type } from "typebox";
16
16
 
17
+ import { RenameNotPossibleError } from "../lib/lsp/client.js";
17
18
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
18
19
  import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
19
- import { formatSubtitlePath } from "../lib/path.js";
20
+ import { canonicalizeEdit, expandWorkspaceEdit, symbolCandidates } from "../lib/lsp/rename.js";
21
+ import { formatSubtitlePath, resolvePathArg } from "../lib/path.js";
20
22
  import type { ToolPendant } from "../lib/pendant.ts";
21
23
  import { guardWriteAccess } from "../lib/write-guard.js";
22
24
  import {
@@ -58,6 +60,10 @@ const WRITE_PROMPT = readFileSync(
58
60
  fileURLToPath(new URL("write.md", import.meta.url)),
59
61
  "utf8",
60
62
  ).trim();
63
+ const LSP_RENAME_PROMPT = readFileSync(
64
+ fileURLToPath(new URL("lsp-rename.md", import.meta.url)),
65
+ "utf8",
66
+ ).trim();
61
67
  const IMAGE_MIMES = new Map<string, string>([
62
68
  [".gif", "image/gif"],
63
69
  [".jpeg", "image/jpeg"],
@@ -672,10 +678,202 @@ export function registerFileTools(
672
678
  };
673
679
  },
674
680
  });
681
+
682
+ pi.registerTool({
683
+ name: "lsp-rename",
684
+ label: "Lsp Rename",
685
+ description: "Rename a code symbol and update all references across the workspace via LSP",
686
+ promptSnippet: "Workspace-wide symbol rename via LSP",
687
+ promptGuidelines: [LSP_RENAME_PROMPT],
688
+ parameters: Type.Object(
689
+ {
690
+ file_path: Type.String({
691
+ description:
692
+ "A file containing the symbol (absolute path, or relative / ~/ path resolved against the session cwd)",
693
+ }),
694
+ line: Type.Integer({
695
+ minimum: 1,
696
+ description:
697
+ "1-based line number where the symbol appears (any occurrence works, not only the definition)",
698
+ }),
699
+ symbol: Type.String({
700
+ minLength: 1,
701
+ description: "The symbol's name exactly as it appears on that line",
702
+ }),
703
+ new_name: Type.String({ minLength: 1, description: "The new name for the symbol" }),
704
+ character: Type.Optional(
705
+ Type.Integer({
706
+ minimum: 1,
707
+ description:
708
+ "1-based character offset of the symbol on that line. Only needed when the tool reports an ambiguity error on this line",
709
+ }),
710
+ ),
711
+ },
712
+ { additionalProperties: false },
713
+ ),
714
+ async execute(_id, params, signal, _onUpdate, ctx) {
715
+ signal?.throwIfAborted();
716
+
717
+ const filePath = resolvePathArg(ctx.cwd, params.file_path);
718
+ let content: string;
719
+ try {
720
+ await stat(filePath);
721
+ content = await readFile(filePath, "utf8");
722
+ } catch (error) {
723
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
724
+ throw new Error(`File does not exist: ${filePath}`, { cause: error });
725
+ }
726
+ throw error;
727
+ }
728
+
729
+ const notify = (message: string, level: "info" | "warning" | "error") =>
730
+ ctx.ui.notify(message, level);
731
+ const options = { notify, signal };
732
+
733
+ // ── 按 symbol 名枚举行内候选,逐候选探测与消歧 ────────────────────────
734
+ const candidates = symbolCandidates(
735
+ content,
736
+ params.line - 1,
737
+ params.symbol,
738
+ params.character === undefined ? undefined : params.character - 1,
739
+ );
740
+ if (candidates.length === 0) {
741
+ throw new Error(
742
+ `Symbol "${params.symbol}" not found on line ${params.line} of ${filePath}. Read the file again and locate the symbol.`,
743
+ );
744
+ }
745
+ const successes: { result: Awaited<ReturnType<typeof service.rename>> }[] = [];
746
+ const notPossibleErrors: string[] = [];
747
+ for (const candidate of candidates) {
748
+ signal?.throwIfAborted();
749
+ try {
750
+ const result = await service.rename({
751
+ file: filePath,
752
+ cwd: ctx.cwd,
753
+ line: candidate.line,
754
+ character: candidate.character,
755
+ newName: params.new_name,
756
+ options,
757
+ });
758
+ successes.push({ result });
759
+ } catch (error) {
760
+ if (error instanceof RenameNotPossibleError) {
761
+ notPossibleErrors.push(error.message);
762
+ continue;
763
+ }
764
+ throw error;
765
+ }
766
+ }
767
+ if (successes.length === 0) {
768
+ throw new RenameNotPossibleError(
769
+ notPossibleErrors.length > 0
770
+ ? notPossibleErrors.join("; ")
771
+ : `no renameable symbol "${params.symbol}" on line ${params.line} of ${filePath}`,
772
+ );
773
+ }
774
+
775
+ // 同一符号的多次出现编辑集合一致;不一致即为不同符号 → 要求补 character
776
+ const groups = new Map<
777
+ string,
778
+ { result: (typeof successes)[number]["result"]; candidates: typeof candidates }
779
+ >();
780
+ for (const [index, candidate] of candidates.entries()) {
781
+ const entry = successes[index];
782
+ if (!entry) continue;
783
+ const key = canonicalizeEdit(entry.result.edit);
784
+ const group = groups.get(key);
785
+ if (group) group.candidates.push(candidate);
786
+ else groups.set(key, { result: entry.result, candidates: [candidate] });
787
+ }
788
+ if (groups.size > 1) {
789
+ const listing = [...groups.values()]
790
+ .map((group) =>
791
+ group.candidates
792
+ .map(
793
+ (candidate) =>
794
+ `- line ${candidate.line + 1}, column ${candidate.character + 1} (rename target: ${group.result.placeholder ?? "unknown"})`,
795
+ )
796
+ .join("\n"),
797
+ )
798
+ .join("\n");
799
+ throw new Error(
800
+ `Ambiguous rename target on line ${params.line} of ${filePath}: several distinct symbols share the name "${params.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
801
+ );
802
+ }
803
+ const firstGroup = [...groups.values()][0];
804
+ if (!firstGroup) throw new Error("LSP rename returned no target");
805
+ const { result } = firstGroup;
806
+
807
+ // ── 内存展开 → 审批 → 写盘 → 诊断 ────────────────────────────────────
808
+ signal?.throwIfAborted();
809
+ const applied = await expandWorkspaceEdit(result.edit, (path) => readFile(path, "utf8"));
810
+ if (applied.length === 0) {
811
+ throw new Error("LSP rename returned no edits");
812
+ }
813
+ for (const fileEdit of applied) {
814
+ await guardWriteAccess(ctx, {
815
+ toolName: "lsp-rename",
816
+ absolutePath: fileEdit.path,
817
+ change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
818
+ });
819
+ }
820
+
821
+ const diffs: string[] = [];
822
+ const pendingReads: { key: string; snapshot: FileSnapshot }[] = [];
823
+ for (const fileEdit of applied) {
824
+ await withFileMutationQueue(fileEdit.path, async () => {
825
+ await writeFile(fileEdit.path, fileEdit.newText, "utf8");
826
+ const key = await readStateKey(fileEdit.path);
827
+ pendingReads.push({ key, snapshot: snapshotOf(fileEdit.newText) });
828
+ });
829
+ diffs.push(
830
+ `### ${fileEdit.path} (${fileEdit.changeCount} edit(s))\n\n\`\`\`diff\n${generateDiffString(fileEdit.oldText, fileEdit.newText).diff}\n\`\`\``,
831
+ );
832
+ }
833
+
834
+ let diagnosticText = "";
835
+ for (const fileEdit of applied) {
836
+ signal?.throwIfAborted();
837
+ const diagnostics = await service.lspDiagnosticsForFile(fileEdit.path, ctx.cwd, {
838
+ notify,
839
+ signal,
840
+ });
841
+ if (diagnostics.text !== "") diagnosticText += `${diagnostics.text}\n`;
842
+ }
843
+
844
+ const reads: Record<string, FileSnapshot> = {};
845
+ for (const { key, snapshot } of pendingReads) {
846
+ reads[key] = snapshot;
847
+ state.reads.set(key, snapshot);
848
+ }
849
+
850
+ const renamed =
851
+ result.placeholder !== undefined && result.placeholder !== params.new_name
852
+ ? `'${result.placeholder}' -> '${params.new_name}'`
853
+ : `to '${params.new_name}'`;
854
+ const summary = `Renamed ${renamed} across ${applied.length} file(s).`;
855
+ const text =
856
+ diagnosticText === ""
857
+ ? summary
858
+ : `${summary}\n\nLSP diagnostics detected in renamed files:\n${diagnosticText.trimEnd()}`;
859
+
860
+ return {
861
+ content: [{ type: "text" as const, text }],
862
+ details: {
863
+ reads,
864
+ pendant: {
865
+ title: "lsp-rename",
866
+ subtitle: `${renamed} · ${applied.length} file(s)`,
867
+ markdown: diffs.join("\n\n"),
868
+ } satisfies ToolPendant,
869
+ },
870
+ };
871
+ },
872
+ });
675
873
  }
676
874
 
677
875
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
678
- const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write"]);
876
+ const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write", "lsp-rename"]);
679
877
 
680
878
  /**
681
879
  * 从当前分支的历史工具结果重建已读记账。先清空再重放,保证 state 只反映
@@ -0,0 +1,9 @@
1
+ ## LSP Rename
2
+
3
+ Rename a code symbol (function, class, variable, ...) and update every reference across the workspace via LSP.
4
+
5
+ - Locate the symbol with `file_path` + `line` (1-based) + `symbol` (the symbol's name exactly as it appears on that line). Any occurrence works — it does not have to be the definition.
6
+ - The tool computes the column itself; do not pass `character` unless asked to disambiguate.
7
+ - When several distinct symbols share the same name on that line, the tool refuses to guess: it reports an ambiguity error listing the candidate columns. Re-run with `character` (1-based) to pick one.
8
+ - Requires an LSP server of kind `language` (per lsp.json) for the file; linter-only servers cannot rename.
9
+ - After the rename, affected files are reported with their edit counts and LSP diagnostics. Renamed files are marked as read — no re-Read needed before further edits.
@@ -20,8 +20,13 @@ export interface LspServerHandle {
20
20
  languageIds?: Record<string, string>;
21
21
  }
22
22
 
23
+ /** 服务器类型:真语言服务器(language,可承载符号级请求)或 LSP 协议 linter。 */
24
+ export type ServerKind = "language" | "linter";
25
+
23
26
  export interface LspServerAdapter {
24
27
  readonly id: string;
28
+ /** 服务器类型;缺省视为 "language"(诊断之外的符号级功能只面向 language)。 */
29
+ readonly kind?: ServerKind;
25
30
  /** 关联的文件扩展名(含点,小写);空数组表示匹配所有文件。 */
26
31
  readonly extensions: readonly string[];
27
32
  /** per-server initialize 握手超时(ms);缺省用全局配置 / client 默认。 */
@@ -16,13 +16,15 @@ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import {
17
17
  createMessageConnection,
18
18
  type MessageConnection,
19
+ ResponseError,
19
20
  StreamMessageReader,
20
21
  StreamMessageWriter,
21
22
  } from "vscode-jsonrpc/node";
22
- import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
23
+ import type { Diagnostic as VSCodeDiagnostic, WorkspaceEdit } from "vscode-languageserver-types";
23
24
 
24
25
  import type { LspServerHandle } from "./adapter.js";
25
26
  import { LANGUAGE_EXTENSIONS } from "./language.js";
27
+ import { editFilePaths } from "./rename.js";
26
28
  import type { FileChange, FileChangeType } from "./watcher.js";
27
29
 
28
30
  // LSP spec 常量
@@ -51,6 +53,65 @@ const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
51
53
 
52
54
  export type Diagnostic = VSCodeDiagnostic;
53
55
 
56
+ /** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
57
+ function samePaths(a: Set<string>, b: Set<string>): boolean {
58
+ if (a.size !== b.size) return false;
59
+ for (const path of a) if (!b.has(path)) return false;
60
+ return true;
61
+ }
62
+
63
+ /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
64
+ const LSP_METHOD_NOT_FOUND = -32601;
65
+
66
+ /**
67
+ * 位置不可 rename 或服务器不具备 rename 能力;与传输失败等意外错误区分,
68
+ * 供调用方在多候选探测时跳过该 client 而不是中断整个操作。
69
+ */
70
+ export class RenameNotPossibleError extends Error {}
71
+
72
+ /**
73
+ * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
74
+ * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
75
+ */
76
+ export class RenameIncompleteError extends Error {
77
+ readonly missing: readonly string[];
78
+ constructor(missing: readonly string[]) {
79
+ super(
80
+ `LSP rename incomplete: textDocument/references found the symbol in ` +
81
+ `${missing.length} file(s) that the rename edit does not cover ` +
82
+ `(${missing.join(", ")}). The server index may still be loading; ` +
83
+ `nothing was modified, retry shortly.`,
84
+ );
85
+ this.missing = missing;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
91
+ * 测试可临时缩小以缩短等待。
92
+ */
93
+ export const renameVerificationTiming = { pollMs: 400, budgetMs: 10_000 };
94
+
95
+ interface PrepareRenameResponse {
96
+ range?: unknown;
97
+ placeholder?: string;
98
+ defaultBehavior?: boolean;
99
+ }
100
+
101
+ /** renameSymbol 的请求与结果(line / character 为 0-based LSP position)。 */
102
+ export interface RenameSymbolRequest {
103
+ path: string;
104
+ line: number;
105
+ character: number;
106
+ newName: string;
107
+ }
108
+
109
+ export interface RenameSymbolResult {
110
+ edit: WorkspaceEdit;
111
+ /** prepareRename 返回的符号当前名;服务器未提供 prepare 时缺省。 */
112
+ placeholder?: string;
113
+ }
114
+
54
115
  export class InitializeError extends Error {
55
116
  readonly serverID: string;
56
117
  constructor(serverID: string, cause: unknown) {
@@ -100,6 +161,7 @@ interface ServerCapabilities {
100
161
  change?: number;
101
162
  };
102
163
  diagnosticProvider?: unknown;
164
+ renameProvider?: boolean | { prepareProvider?: boolean };
103
165
  [key: string]: unknown;
104
166
  }
105
167
 
@@ -137,6 +199,12 @@ export interface LspClient {
137
199
  after?: number;
138
200
  signal?: AbortSignal;
139
201
  }): Promise<void>;
202
+ /**
203
+ * 符号重命名:先把磁盘内容同步给服务器(didOpen/didChange),再按能力决定
204
+ * 是否先发 prepareRename 校验位置,最后发 textDocument/rename 返回 WorkspaceEdit。
205
+ * 位置不在符号上 / 服务器不支持 rename 时抛 RenameNotPossibleError。
206
+ */
207
+ renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult>;
140
208
  shutdown(): Promise<void>;
141
209
  }
142
210
 
@@ -387,6 +455,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
387
455
 
388
456
  const syncKind = getSyncKind(initialized.capabilities);
389
457
  const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
458
+ // prepareProvider 只在静态声明为对象且显式开启时使用;其余情况跳过 prepare
459
+ // 直接 rename(能力可能经动态注册,静态声明缺失不代表服务器不支持)。
460
+ const renameProvider = initialized.capabilities?.renameProvider;
461
+ const hasPrepareProvider =
462
+ typeof renameProvider === "object" && renameProvider.prepareProvider === true;
390
463
 
391
464
  await connection.sendNotification("initialized", {});
392
465
 
@@ -764,6 +837,55 @@ export async function create(input: CreateInput): Promise<LspClient> {
764
837
 
765
838
  // ── 公开 API ────────────────────────────────────────────────────────────────
766
839
 
840
+ const openDocument = async (request: { path: string }): Promise<number> => {
841
+ const resolvedPath = normalize(
842
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
843
+ );
844
+ const text = await readFile(resolvedPath, "utf8");
845
+ const extension = extname(resolvedPath);
846
+ const languageId =
847
+ input.server.languageIds?.[extension] ?? LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
848
+ const uri = pathToFileURL(resolvedPath).href;
849
+
850
+ const document = files[resolvedPath];
851
+ if (document !== undefined) {
852
+ // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
853
+ // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
854
+ pushDiagnostics.delete(resolvedPath);
855
+ pullDiagnostics.delete(resolvedPath);
856
+
857
+ const next = document.version + 1;
858
+ files[resolvedPath] = { version: next, text };
859
+ documentVersions.set(resolvedPath, next);
860
+ await connection.sendNotification("textDocument/didChange", {
861
+ textDocument: { uri, version: next },
862
+ contentChanges:
863
+ syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
864
+ ? [
865
+ {
866
+ range: { start: { line: 0, character: 0 }, end: endPosition(document.text) },
867
+ text,
868
+ },
869
+ ]
870
+ : [{ text }],
871
+ });
872
+ touch(resolvedPath);
873
+ await evictExcess();
874
+ return next;
875
+ }
876
+
877
+ pushDiagnostics.delete(resolvedPath);
878
+ pullDiagnostics.delete(resolvedPath);
879
+ await connection.sendNotification("textDocument/didOpen", {
880
+ textDocument: { uri, languageId, version: 0, text },
881
+ });
882
+ files[resolvedPath] = { version: 0, text };
883
+ documentVersions.set(resolvedPath, 0);
884
+ touch(resolvedPath);
885
+ await evictExcess();
886
+ return 0;
887
+ };
888
+
767
889
  return {
768
890
  root: input.root,
769
891
  get serverID() {
@@ -783,54 +905,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
783
905
  return connection;
784
906
  },
785
907
  notify: {
786
- async open(request: { path: string }): Promise<number> {
787
- const resolvedPath = normalize(
788
- isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
789
- );
790
- const text = await readFile(resolvedPath, "utf8");
791
- const extension = extname(resolvedPath);
792
- const languageId =
793
- input.server.languageIds?.[extension] ?? LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
794
- const uri = pathToFileURL(resolvedPath).href;
795
-
796
- const document = files[resolvedPath];
797
- if (document !== undefined) {
798
- // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
799
- // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
800
- pushDiagnostics.delete(resolvedPath);
801
- pullDiagnostics.delete(resolvedPath);
802
-
803
- const next = document.version + 1;
804
- files[resolvedPath] = { version: next, text };
805
- documentVersions.set(resolvedPath, next);
806
- await connection.sendNotification("textDocument/didChange", {
807
- textDocument: { uri, version: next },
808
- contentChanges:
809
- syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
810
- ? [
811
- {
812
- range: { start: { line: 0, character: 0 }, end: endPosition(document.text) },
813
- text,
814
- },
815
- ]
816
- : [{ text }],
817
- });
818
- touch(resolvedPath);
819
- await evictExcess();
820
- return next;
821
- }
822
-
823
- pushDiagnostics.delete(resolvedPath);
824
- pullDiagnostics.delete(resolvedPath);
825
- await connection.sendNotification("textDocument/didOpen", {
826
- textDocument: { uri, languageId, version: 0, text },
827
- });
828
- files[resolvedPath] = { version: 0, text };
829
- documentVersions.set(resolvedPath, 0);
830
- touch(resolvedPath);
831
- await evictExcess();
832
- return 0;
833
- },
908
+ open: openDocument,
834
909
  async watchedFiles(changes: FileChange[]): Promise<void> {
835
910
  const notified: { uri: string; type: number }[] = [];
836
911
  for (const change of changes) {
@@ -869,6 +944,104 @@ export async function create(input: CreateInput): Promise<LspClient> {
869
944
  });
870
945
  },
871
946
  },
947
+ async renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult> {
948
+ const resolvedPath = normalize(
949
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
950
+ );
951
+ const uri = pathToFileURL(resolvedPath).href;
952
+ // rename 前强制同步磁盘内容,保证服务器基于最新文本计算编辑
953
+ await openDocument({ path: resolvedPath });
954
+ const position = { line: request.line, character: request.character };
955
+ const at = `${resolvedPath}:${request.line + 1}:${request.character + 1}`;
956
+ const notRenameable = () =>
957
+ new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
958
+
959
+ // references 前置 + rename 覆盖校验:LSP 没有标准化的"索引完成"信号,
960
+ // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
961
+ // 尚未入索引的文件。对策分两层:
962
+ // 1. 收敛检测:references 连续两次文件集合一致才认为索引稳定,防止
963
+ // "服务器根本还没发现某文件"时校验形同虚设;
964
+ // 2. 覆盖校验:references 报告的文件必须都被 rename edit 覆盖,
965
+ // 不完整时等待重试,预算耗尽仍不完整抛 RenameIncompleteError——
966
+ // 调用方尚未写盘,整个操作无副作用,可稍后重试。
967
+ // 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器,
968
+ // 与编辑器行为一致。
969
+ const referencesRequest = () =>
970
+ connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
971
+ textDocument: { uri },
972
+ position,
973
+ context: { includeDeclaration: true },
974
+ });
975
+
976
+ const toPaths = (locations: { uri: string }[] | null): Set<string> =>
977
+ new Set(
978
+ (locations ?? []).flatMap((location) =>
979
+ location.uri.startsWith("file:") ? [normalize(fileURLToPath(location.uri))] : [],
980
+ ),
981
+ );
982
+
983
+ const sendRename = async (): Promise<WorkspaceEdit | null> => {
984
+ try {
985
+ return await connection.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
986
+ textDocument: { uri },
987
+ position,
988
+ newName: request.newName,
989
+ });
990
+ } catch (error) {
991
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
992
+ throw notRenameable();
993
+ }
994
+ throw error;
995
+ }
996
+ };
997
+
998
+ let placeholder: string | undefined;
999
+ if (hasPrepareProvider) {
1000
+ let prepared: PrepareRenameResponse | null;
1001
+ try {
1002
+ prepared = await connection.sendRequest<PrepareRenameResponse | null>(
1003
+ "textDocument/prepareRename",
1004
+ { textDocument: { uri }, position },
1005
+ );
1006
+ } catch (error) {
1007
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
1008
+ throw notRenameable();
1009
+ }
1010
+ throw error;
1011
+ }
1012
+ if (!prepared) throw notRenameable();
1013
+ if (typeof prepared.placeholder === "string") placeholder = prepared.placeholder;
1014
+ }
1015
+
1016
+ let locations: { uri: string }[] | null;
1017
+ try {
1018
+ locations = await referencesRequest();
1019
+ } catch (error) {
1020
+ if (!(error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND)) throw error;
1021
+ const edit = await sendRename();
1022
+ if (!edit) throw notRenameable();
1023
+ return placeholder === undefined ? { edit } : { edit, placeholder };
1024
+ }
1025
+
1026
+ const deadline = Date.now() + renameVerificationTiming.budgetMs;
1027
+ let previous: Set<string> | undefined;
1028
+ let current = toPaths(locations);
1029
+ for (;;) {
1030
+ const settled = previous !== undefined && samePaths(previous, current);
1031
+ if (settled || Date.now() >= deadline) {
1032
+ const edit = await sendRename();
1033
+ if (!edit) throw notRenameable();
1034
+ const missing = [...current].filter((path) => !editFilePaths(edit).has(path));
1035
+ if (missing.length === 0) {
1036
+ return placeholder === undefined ? { edit } : { edit, placeholder };
1037
+ }
1038
+ throw new RenameIncompleteError(missing);
1039
+ }
1040
+ previous = current;
1041
+ await sleep(renameVerificationTiming.pollMs);
1042
+ current = toPaths(await referencesRequest());
1043
+ }
1044
+ },
872
1045
  get diagnostics() {
873
1046
  const result = new Map<string, Diagnostic[]>();
874
1047
  for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {