@trim21/personal-pi-extensions 0.1.460 → 0.1.463

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.463",
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,10 +16,11 @@ 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";
@@ -51,6 +52,35 @@ const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
51
52
 
52
53
  export type Diagnostic = VSCodeDiagnostic;
53
54
 
55
+ /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
56
+ const LSP_METHOD_NOT_FOUND = -32601;
57
+
58
+ /**
59
+ * 位置不可 rename 或服务器不具备 rename 能力;与传输失败等意外错误区分,
60
+ * 供调用方在多候选探测时跳过该 client 而不是中断整个操作。
61
+ */
62
+ export class RenameNotPossibleError extends Error {}
63
+
64
+ interface PrepareRenameResponse {
65
+ range?: unknown;
66
+ placeholder?: string;
67
+ defaultBehavior?: boolean;
68
+ }
69
+
70
+ /** renameSymbol 的请求与结果(line / character 为 0-based LSP position)。 */
71
+ export interface RenameSymbolRequest {
72
+ path: string;
73
+ line: number;
74
+ character: number;
75
+ newName: string;
76
+ }
77
+
78
+ export interface RenameSymbolResult {
79
+ edit: WorkspaceEdit;
80
+ /** prepareRename 返回的符号当前名;服务器未提供 prepare 时缺省。 */
81
+ placeholder?: string;
82
+ }
83
+
54
84
  export class InitializeError extends Error {
55
85
  readonly serverID: string;
56
86
  constructor(serverID: string, cause: unknown) {
@@ -100,6 +130,7 @@ interface ServerCapabilities {
100
130
  change?: number;
101
131
  };
102
132
  diagnosticProvider?: unknown;
133
+ renameProvider?: boolean | { prepareProvider?: boolean };
103
134
  [key: string]: unknown;
104
135
  }
105
136
 
@@ -137,6 +168,12 @@ export interface LspClient {
137
168
  after?: number;
138
169
  signal?: AbortSignal;
139
170
  }): Promise<void>;
171
+ /**
172
+ * 符号重命名:先把磁盘内容同步给服务器(didOpen/didChange),再按能力决定
173
+ * 是否先发 prepareRename 校验位置,最后发 textDocument/rename 返回 WorkspaceEdit。
174
+ * 位置不在符号上 / 服务器不支持 rename 时抛 RenameNotPossibleError。
175
+ */
176
+ renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult>;
140
177
  shutdown(): Promise<void>;
141
178
  }
142
179
 
@@ -387,6 +424,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
387
424
 
388
425
  const syncKind = getSyncKind(initialized.capabilities);
389
426
  const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
427
+ // prepareProvider 只在静态声明为对象且显式开启时使用;其余情况跳过 prepare
428
+ // 直接 rename(能力可能经动态注册,静态声明缺失不代表服务器不支持)。
429
+ const renameProvider = initialized.capabilities?.renameProvider;
430
+ const hasPrepareProvider =
431
+ typeof renameProvider === "object" && renameProvider.prepareProvider === true;
390
432
 
391
433
  await connection.sendNotification("initialized", {});
392
434
 
@@ -764,6 +806,55 @@ export async function create(input: CreateInput): Promise<LspClient> {
764
806
 
765
807
  // ── 公开 API ────────────────────────────────────────────────────────────────
766
808
 
809
+ const openDocument = async (request: { path: string }): Promise<number> => {
810
+ const resolvedPath = normalize(
811
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
812
+ );
813
+ const text = await readFile(resolvedPath, "utf8");
814
+ const extension = extname(resolvedPath);
815
+ const languageId =
816
+ input.server.languageIds?.[extension] ?? LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
817
+ const uri = pathToFileURL(resolvedPath).href;
818
+
819
+ const document = files[resolvedPath];
820
+ if (document !== undefined) {
821
+ // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
822
+ // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
823
+ pushDiagnostics.delete(resolvedPath);
824
+ pullDiagnostics.delete(resolvedPath);
825
+
826
+ const next = document.version + 1;
827
+ files[resolvedPath] = { version: next, text };
828
+ documentVersions.set(resolvedPath, next);
829
+ await connection.sendNotification("textDocument/didChange", {
830
+ textDocument: { uri, version: next },
831
+ contentChanges:
832
+ syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
833
+ ? [
834
+ {
835
+ range: { start: { line: 0, character: 0 }, end: endPosition(document.text) },
836
+ text,
837
+ },
838
+ ]
839
+ : [{ text }],
840
+ });
841
+ touch(resolvedPath);
842
+ await evictExcess();
843
+ return next;
844
+ }
845
+
846
+ pushDiagnostics.delete(resolvedPath);
847
+ pullDiagnostics.delete(resolvedPath);
848
+ await connection.sendNotification("textDocument/didOpen", {
849
+ textDocument: { uri, languageId, version: 0, text },
850
+ });
851
+ files[resolvedPath] = { version: 0, text };
852
+ documentVersions.set(resolvedPath, 0);
853
+ touch(resolvedPath);
854
+ await evictExcess();
855
+ return 0;
856
+ };
857
+
767
858
  return {
768
859
  root: input.root,
769
860
  get serverID() {
@@ -783,54 +874,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
783
874
  return connection;
784
875
  },
785
876
  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
- },
877
+ open: openDocument,
834
878
  async watchedFiles(changes: FileChange[]): Promise<void> {
835
879
  const notified: { uri: string; type: number }[] = [];
836
880
  for (const change of changes) {
@@ -869,6 +913,75 @@ export async function create(input: CreateInput): Promise<LspClient> {
869
913
  });
870
914
  },
871
915
  },
916
+ async renameSymbol(request: RenameSymbolRequest): Promise<RenameSymbolResult> {
917
+ const resolvedPath = normalize(
918
+ isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
919
+ );
920
+ const uri = pathToFileURL(resolvedPath).href;
921
+ // rename 前强制同步磁盘内容,保证服务器基于最新文本计算编辑
922
+ await openDocument({ path: resolvedPath });
923
+ const position = { line: request.line, character: request.character };
924
+ const at = `${resolvedPath}:${request.line + 1}:${request.character + 1}`;
925
+ const notRenameable = () =>
926
+ new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
927
+
928
+ // references 前置作为同步点:服务器必须完成项目加载才能回答引用位置,
929
+ // 其响应到达后紧随的 rename 必然基于完整索引(LSP 没有标准化的
930
+ // "索引完成"信号,这是协议内唯一的同步手段)。references 请求本身
931
+ // 的结果不用于校验 rename 编辑——信任服务器,与编辑器行为一致;
932
+ // 服务器不支持 references(MethodNotFound)时直接跳过。
933
+ const awaitReferences = async (): Promise<void> => {
934
+ try {
935
+ await connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
936
+ textDocument: { uri },
937
+ position,
938
+ context: { includeDeclaration: true },
939
+ });
940
+ } catch (error) {
941
+ if (!(error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND)) {
942
+ throw error;
943
+ }
944
+ }
945
+ };
946
+
947
+ const sendRename = async (): Promise<WorkspaceEdit | null> => {
948
+ try {
949
+ return await connection.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
950
+ textDocument: { uri },
951
+ position,
952
+ newName: request.newName,
953
+ });
954
+ } catch (error) {
955
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
956
+ throw notRenameable();
957
+ }
958
+ throw error;
959
+ }
960
+ };
961
+
962
+ let placeholder: string | undefined;
963
+ if (hasPrepareProvider) {
964
+ let prepared: PrepareRenameResponse | null;
965
+ try {
966
+ prepared = await connection.sendRequest<PrepareRenameResponse | null>(
967
+ "textDocument/prepareRename",
968
+ { textDocument: { uri }, position },
969
+ );
970
+ } catch (error) {
971
+ if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
972
+ throw notRenameable();
973
+ }
974
+ throw error;
975
+ }
976
+ if (!prepared) throw notRenameable();
977
+ if (typeof prepared.placeholder === "string") placeholder = prepared.placeholder;
978
+ }
979
+
980
+ await awaitReferences();
981
+ const edit = await sendRename();
982
+ if (!edit) throw notRenameable();
983
+ return placeholder === undefined ? { edit } : { edit, placeholder };
984
+ },
872
985
  get diagnostics() {
873
986
  const result = new Map<string, Diagnostic[]>();
874
987
  for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {