@mrclrchtr/supi-lsp 1.16.0 → 2.0.0

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.
Files changed (77) hide show
  1. package/README.md +44 -56
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/package.json +6 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/abort-utils.ts +31 -0
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/api.ts +8 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +10 -4
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config.ts +1 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/index.ts +6 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +9 -2
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +109 -4
  12. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +5 -1
  13. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  15. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +3 -2
  16. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +7 -0
  17. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +56 -2
  18. package/node_modules/@mrclrchtr/supi-code-runtime/src/evidence-badge.ts +40 -0
  19. package/node_modules/@mrclrchtr/supi-code-runtime/src/index.ts +5 -0
  20. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +76 -3
  21. package/node_modules/@mrclrchtr/supi-code-runtime/src/workspace/runtime.ts +37 -2
  22. package/node_modules/@mrclrchtr/supi-core/package.json +6 -1
  23. package/node_modules/@mrclrchtr/supi-core/src/abort-utils.ts +31 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/api.ts +8 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +10 -4
  26. package/node_modules/@mrclrchtr/supi-core/src/config.ts +1 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +1 -1
  28. package/node_modules/@mrclrchtr/supi-core/src/index.ts +6 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +9 -2
  31. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +109 -4
  32. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +5 -1
  33. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  34. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  35. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.diagnostic.d.ts +21 -2
  36. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.diagnostic.js +1 -0
  37. package/node_modules/vscode-languageserver-protocol/lib/common/protocol.inlineValue.d.ts +2 -1
  38. package/node_modules/vscode-languageserver-protocol/package.json +1 -1
  39. package/package.json +4 -31
  40. package/src/api.ts +27 -0
  41. package/src/client/client-refresh.ts +5 -0
  42. package/src/client/client.ts +246 -1
  43. package/src/client/transport.ts +4 -0
  44. package/src/config/capabilities.ts +3 -0
  45. package/src/config/config.ts +4 -2
  46. package/src/config/lsp-settings.ts +102 -0
  47. package/src/config/server-actions.ts +59 -0
  48. package/src/config/server-config.ts +4 -0
  49. package/src/index.ts +11 -0
  50. package/src/manager/capability-index.ts +1 -1
  51. package/src/manager/manager-project-info.ts +2 -1
  52. package/src/manager/manager.ts +115 -26
  53. package/src/provider/lsp-semantic-provider.ts +177 -134
  54. package/src/provider/refactor-planning.ts +214 -0
  55. package/src/session/runtime-controller.ts +320 -0
  56. package/src/session/runtime-registration.ts +26 -5
  57. package/src/session/service-registry.ts +81 -4
  58. package/src/summary.ts +2 -0
  59. package/src/extension.ts +0 -1
  60. package/src/format.ts +0 -373
  61. package/src/handlers/diagnostic-injection.ts +0 -182
  62. package/src/handlers/session-lifecycle.ts +0 -149
  63. package/src/handlers/status-command.ts +0 -38
  64. package/src/handlers/workspace-recovery.ts +0 -92
  65. package/src/lsp.ts +0 -37
  66. package/src/session/lsp-state.ts +0 -93
  67. package/src/session/settings-registration.ts +0 -296
  68. package/src/session/tree-persist.ts +0 -75
  69. package/src/tool/guidance.ts +0 -69
  70. package/src/tool/names.ts +0 -27
  71. package/src/tool/overrides.ts +0 -204
  72. package/src/tool/register-tools.ts +0 -69
  73. package/src/tool/service-actions.ts +0 -399
  74. package/src/tool/tool-specs.ts +0 -248
  75. package/src/ui/renderer.ts +0 -120
  76. package/src/ui/ui.ts +0 -360
  77. package/src/workspace-change.ts +0 -40
@@ -55,6 +55,10 @@ import type {
55
55
  ServerStatus,
56
56
  } from "./manager-types.ts";
57
57
  import { recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl } from "./manager-workspace-recovery.ts";
58
+ import {
59
+ findWorkspaceSymbolWarmTargets,
60
+ getWorkspaceSymbolWarmPosition,
61
+ } from "./manager-workspace-symbol.ts";
58
62
 
59
63
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
60
64
 
@@ -74,6 +78,10 @@ export class LspManager {
74
78
  private excludePatterns: string[] = [];
75
79
  /** Project roots already warmed for workspace-symbol queries. */
76
80
  private warmedWorkspaceSymbolProjects = new Set<string>();
81
+ /** Project roots whose semantic state was warmed with a readiness probe. */
82
+ private warmedSemanticProjects = new Set<string>();
83
+ /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
84
+ private pendingWarmProbes = new Map<string, Promise<void>>();
77
85
  constructor(
78
86
  private readonly config: LspConfig,
79
87
  private readonly cwd: string,
@@ -174,6 +182,8 @@ export class LspManager {
174
182
  this.clients.delete(key);
175
183
  this.unavailable.set(key, "runtime-error");
176
184
  this.clearWarmedWorkspaceSymbolProjects(existing.name, existing.root);
185
+ this.clearWarmedSemanticProjects(existing.name, existing.root);
186
+ this.clearPendingWarmProbes(existing.name, existing.root);
177
187
  return null;
178
188
  }
179
189
 
@@ -213,6 +223,8 @@ export class LspManager {
213
223
  // Spawn new client
214
224
  const client = new LspClient(serverName, serverConfig, root);
215
225
  this.clearWarmedWorkspaceSymbolProjects(serverName, root);
226
+ this.clearWarmedSemanticProjects(serverName, root);
227
+ this.clearPendingWarmProbes(serverName, root);
216
228
  this.clients.set(key, client);
217
229
  rememberKnownRoot(this.knownRoots, serverName, root);
218
230
  try {
@@ -276,6 +288,8 @@ export class LspManager {
276
288
  this.clients.delete(key);
277
289
  this.unavailable.delete(key);
278
290
  this.clearWarmedWorkspaceSymbolProjects(client.name, client.root);
291
+ this.clearWarmedSemanticProjects(client.name, client.root);
292
+ this.clearPendingWarmProbes(client.name, client.root);
279
293
 
280
294
  const replacement = new LspClient(client.name, serverConfig, client.root);
281
295
  this.clients.set(key, replacement);
@@ -336,6 +350,36 @@ export class LspManager {
336
350
  a.status.localeCompare(b.status),
337
351
  );
338
352
  }
353
+
354
+ /** Wait until the client that owns a file is query-ready, then run a light warm-up probe. */
355
+ async waitUntilFileReady(filePath: string): Promise<LspClient | null> {
356
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
357
+ const client = await this.getClientForFile(resolvedPath);
358
+ if (!client) return null;
359
+ await client.getReady();
360
+ await this.warmSemanticProject(client, resolvedPath, true);
361
+ return client;
362
+ }
363
+
364
+ /** Wait until all started clients are query-ready, then warm one project file per client/root. */
365
+ async waitUntilWorkspaceReady(): Promise<void> {
366
+ const activeClients = Array.from(this.clients.values()).filter(
367
+ (client) => client.status === "running",
368
+ );
369
+ await Promise.all(activeClients.map((client) => client.getReady()));
370
+
371
+ for (const client of activeClients) {
372
+ const serverConfig = this.config.servers[client.name];
373
+ if (!serverConfig) continue;
374
+ const target = findWorkspaceSymbolWarmTargets(
375
+ client.root,
376
+ serverConfig.rootMarkers,
377
+ serverConfig.fileTypes,
378
+ )[0];
379
+ if (!target) continue;
380
+ await this.warmSemanticProject(client, target.file);
381
+ }
382
+ }
339
383
  async syncFileAndGetDiagnostics(
340
384
  filePath: string,
341
385
  maxSeverity: number = 1,
@@ -422,6 +466,8 @@ export class LspManager {
422
466
  this.unavailable.clear();
423
467
  this.knownRoots.clear();
424
468
  this.warmedWorkspaceSymbolProjects.clear();
469
+ this.warmedSemanticProjects.clear();
470
+ this.pendingWarmProbes.clear();
425
471
  }
426
472
  /** Get status of all servers. */
427
473
  getStatus(): ManagerStatus {
@@ -580,13 +626,8 @@ export class LspManager {
580
626
  query,
581
627
  );
582
628
  if (warmed.results) return warmed.results;
583
- if (!warmed.warmedAny) return initial.results;
584
629
 
585
- return this.retryWorkspaceSymbolAfterWarmup(
586
- helper.collectWorkspaceSymbols,
587
- query,
588
- initial.results,
589
- );
630
+ return initial.results;
590
631
  }
591
632
  async ensureFileOpen(filePath: string): Promise<LspClient | null> {
592
633
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
@@ -666,28 +707,58 @@ export class LspManager {
666
707
  return { warmedAny, results: null };
667
708
  }
668
709
 
669
- private async retryWorkspaceSymbolAfterWarmup(
670
- collect: (
671
- clients: Iterable<LspClient>,
672
- query: string,
673
- ) => Promise<{ results: (SymbolInformation | WorkspaceSymbol)[]; hasSupport: boolean }>,
674
- query: string,
675
- fallbackResults: (SymbolInformation | WorkspaceSymbol)[],
676
- ): Promise<(SymbolInformation | WorkspaceSymbol)[]> {
677
- const attempts = 5;
678
- const delayMs = 50;
679
-
680
- for (let attempt = 0; attempt < attempts; attempt++) {
681
- if (attempt > 0) {
682
- await new Promise((resolve) => setTimeout(resolve, delayMs));
683
- }
684
- const retried = await collect(this.clients.values(), query);
685
- if (!retried.hasSupport || retried.results.length > 0) {
686
- return retried.hasSupport ? retried.results : fallbackResults;
687
- }
710
+ private async warmSemanticProject(
711
+ client: LspClient,
712
+ filePath: string,
713
+ preferExactFile: boolean = false,
714
+ ): Promise<void> {
715
+ const serverConfig = this.config.servers[client.name];
716
+ if (!serverConfig) return;
717
+
718
+ const resolvedFile = resolveSessionPath(this.cwd, filePath);
719
+ const projectRoot = preferExactFile
720
+ ? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
721
+ knownRoots: this.knownRoots,
722
+ cwd: this.cwd,
723
+ })
724
+ : client.root;
725
+ const projectKey = this.workspaceSymbolProjectKey(client.name, projectRoot);
726
+ if (this.warmedSemanticProjects.has(projectKey)) return;
727
+
728
+ const pending = this.pendingWarmProbes.get(projectKey);
729
+ if (pending) {
730
+ await pending;
731
+ return;
688
732
  }
689
733
 
690
- return fallbackResults;
734
+ const probe = this.performWarmProbe(client, resolvedFile, projectKey);
735
+ this.pendingWarmProbes.set(projectKey, probe);
736
+ try {
737
+ await probe;
738
+ } finally {
739
+ this.pendingWarmProbes.delete(projectKey);
740
+ }
741
+ }
742
+
743
+ private async performWarmProbe(
744
+ _client: LspClient,
745
+ resolvedFile: string,
746
+ projectKey: string,
747
+ ): Promise<void> {
748
+ const openedClient = await this.ensureFileOpen(resolvedFile);
749
+ if (!openedClient) return;
750
+
751
+ this.warmedSemanticProjects.add(projectKey);
752
+
753
+ try {
754
+ const symbols = await openedClient.documentSymbols(resolvedFile);
755
+ const hoverPosition = getWorkspaceSymbolWarmPosition(symbols);
756
+ if (hoverPosition) {
757
+ await openedClient.hover(resolvedFile, hoverPosition);
758
+ }
759
+ } catch {
760
+ // Best-effort warm-up only.
761
+ }
691
762
  }
692
763
 
693
764
  private hasOpenFileInProject(client: LspClient, projectRoot: string): boolean {
@@ -706,4 +777,22 @@ export class LspManager {
706
777
  }
707
778
  }
708
779
  }
780
+
781
+ private clearWarmedSemanticProjects(serverName: string, root: string): void {
782
+ const prefix = `${serverName}:${path.resolve(root)}`;
783
+ for (const key of Array.from(this.warmedSemanticProjects)) {
784
+ if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
785
+ this.warmedSemanticProjects.delete(key);
786
+ }
787
+ }
788
+ }
789
+
790
+ private clearPendingWarmProbes(serverName: string, root: string): void {
791
+ const prefix = `${serverName}:${path.resolve(root)}`;
792
+ for (const key of Array.from(this.pendingWarmProbes.keys())) {
793
+ if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
794
+ this.pendingWarmProbes.delete(key);
795
+ }
796
+ }
797
+ }
709
798
  }
@@ -1,23 +1,35 @@
1
1
  // LSP semantic provider adapter — wraps SessionLspService into the shared
2
2
  // SemanticProvider contract from supi-code-runtime.
3
3
 
4
- import type {
5
- CodeLocation,
6
- CodePosition,
7
- CodeSymbol,
8
- RefactorResult,
9
- SemanticProvider,
4
+ import {
5
+ type CodeLocation,
6
+ type CodePosition,
7
+ type CodeSymbol,
8
+ normalizeRefactorOperation,
9
+ type RefactorRequest,
10
+ type RefactorResult,
11
+ type SemanticProvider,
12
+ type SourceRange,
10
13
  } from "@mrclrchtr/supi-code-runtime/api";
11
14
  import type {
15
+ CodeAction,
12
16
  DocumentSymbol,
17
+ Hover,
13
18
  Location,
14
19
  LocationLink,
20
+ MarkupContent,
15
21
  SymbolInformation,
16
- TextDocumentEdit,
17
- TextEdit,
18
- WorkspaceEdit,
19
22
  } from "../config/types.ts";
20
23
  import type { SessionLspService } from "../session/service-registry.ts";
24
+ import {
25
+ collectCodeActionResults,
26
+ isDeleteDeadCodeCodeAction,
27
+ isExtractFunctionCodeAction,
28
+ isExtractVariableCodeAction,
29
+ isUpdateImportsCodeAction,
30
+ runFilteredCodeActionRefactor,
31
+ runRenameRefactor,
32
+ } from "./refactor-planning.ts";
21
33
 
22
34
  /**
23
35
  * Create a SemanticProvider backed by a SessionLspService.
@@ -25,6 +37,27 @@ import type { SessionLspService } from "../session/service-registry.ts";
25
37
  */
26
38
  export function createLspSemanticProvider(lsp: SessionLspService): SemanticProvider {
27
39
  return {
40
+ async definition(filePath: string, position: CodePosition): Promise<CodeLocation[] | null> {
41
+ const result = await lsp.definition(filePath, position);
42
+ if (!result) return null;
43
+ const normalized = Array.isArray(result) ? result : [result];
44
+ const mapped: CodeLocation[] = [];
45
+ for (const item of normalized) {
46
+ const loc = toCodeLocation(item);
47
+ if (loc) mapped.push(loc);
48
+ }
49
+ return mapped;
50
+ },
51
+
52
+ async hover(
53
+ filePath: string,
54
+ position: CodePosition,
55
+ ): Promise<{ contents: string; range?: SourceRange } | null> {
56
+ const result = await lsp.hover(filePath, position);
57
+ if (!result) return null;
58
+ return convertLspHover(result);
59
+ },
60
+
28
61
  async references(filePath: string, position: CodePosition): Promise<CodeLocation[] | null> {
29
62
  const refResult = await lsp.references(filePath, position);
30
63
  if (!refResult) return null;
@@ -60,135 +93,101 @@ export function createLspSemanticProvider(lsp: SessionLspService): SemanticProvi
60
93
  return results.map((sym) => toCodeSymbol(sym as SymbolInformation));
61
94
  },
62
95
 
96
+ async refactor(request: RefactorRequest): Promise<RefactorResult> {
97
+ // Normalize defensively at the provider boundary as well as in
98
+ // code-intelligence. RefactorRequest still permits the legacy `rename`
99
+ // alias, and the provider may be invoked directly outside the public tool.
100
+ const operation = normalizeRefactorOperation(request.operation);
101
+
102
+ switch (operation) {
103
+ case "rename_symbol":
104
+ if (!request.newName) {
105
+ return {
106
+ kind: "unavailable",
107
+ reason: 'Refactor operation "rename_symbol" requires `newName`.',
108
+ };
109
+ }
110
+ return runRenameRefactor(lsp, request.file, request.position, request.newName);
111
+ case "extract_function":
112
+ return runExtractRefactor(lsp, request, "extract_function", isExtractFunctionCodeAction);
113
+ case "extract_variable":
114
+ return runExtractRefactor(lsp, request, "extract_variable", isExtractVariableCodeAction);
115
+ case "update_imports":
116
+ return runFilteredCodeActionRefactor({
117
+ lsp,
118
+ file: request.file,
119
+ position: request.position,
120
+ operation: "update_imports",
121
+ matches: isUpdateImportsCodeAction,
122
+ });
123
+ case "delete_dead_code":
124
+ return runFilteredCodeActionRefactor({
125
+ lsp,
126
+ file: request.file,
127
+ position: request.position,
128
+ operation: "delete_dead_code",
129
+ matches: isDeleteDeadCodeCodeAction,
130
+ });
131
+ case "rename_file":
132
+ case "move_file":
133
+ // TODO(TNDM-D9FEHR): Replace this explicit unavailable result once
134
+ // shared file/resource edits and rollback semantics exist.
135
+ return {
136
+ kind: "unavailable",
137
+ reason: `Refactor operation "${operation}" is not supported yet. File/resource operations are deferred.`,
138
+ };
139
+ }
140
+
141
+ return {
142
+ kind: "unavailable",
143
+ reason: `Refactor operation "${operation}" is not supported by the active semantic provider.`,
144
+ };
145
+ },
146
+
63
147
  async rename(file: string, position: CodePosition, newName: string): Promise<RefactorResult> {
64
- const edit = await lsp.rename(file, position, newName);
65
- return convertLspWorkspaceEdit(edit);
148
+ return runRenameRefactor(lsp, file, position, newName);
66
149
  },
67
150
 
68
151
  async codeActions(file: string, position: CodePosition): Promise<RefactorResult[]> {
69
152
  const actions = await lsp.codeActions(file, position);
70
153
  if (!actions) return [];
154
+ return collectCodeActionResults(actions);
155
+ },
71
156
 
72
- const results: RefactorResult[] = [];
73
- for (const action of actions) {
74
- const edit = action.edit;
75
- if (!edit) {
76
- results.push({
77
- kind: "unavailable",
78
- reason: `Code action "${action.title}" has no edit`,
79
- });
80
- continue;
81
- }
82
- const converted = convertLspWorkspaceEdit(edit);
83
- if (converted.kind === "precise") {
84
- results.push(converted);
85
- } else {
86
- results.push({
87
- kind: "unavailable",
88
- reason: `Code action "${action.title}" could not produce precise edits`,
89
- });
90
- }
91
- }
92
- return results;
157
+ async codeActionTitles(
158
+ file: string,
159
+ position: CodePosition,
160
+ ): Promise<Array<{ title: string; kind?: string }> | null> {
161
+ const actions = await lsp.codeActions(file, position);
162
+ if (!actions) return null;
163
+ return actions
164
+ .filter((a) => a.title)
165
+ .map((a) => ({ title: a.title, kind: a.kind ?? undefined }));
93
166
  },
94
167
  };
95
168
  }
96
169
 
97
- // ── LSP WorkspaceEdit converter ─────────────────────────────────────
98
-
99
- /**
100
- * Convert an LSP WorkspaceEdit to the shared RefactorResult type.
101
- *
102
- * LSP WorkspaceEdit can use:
103
- * - `documentChanges` (preferred, with TextDocumentEdit)
104
- * - `changes` (legacy, URI → TextEdit[] map)
105
- *
106
- * Returns `unavailable` when both are missing or both produce zero edits.
107
- */
108
- function resolveFileFromUri(uri: string): string {
109
- if (!uri.startsWith("file://")) return uri;
110
- try {
111
- return decodeURIComponent(uri.slice(7));
112
- } catch {
113
- return uri;
114
- }
115
- }
116
-
117
- function collectDocumentChangeEdits(
118
- docChanges: NonNullable<WorkspaceEdit["documentChanges"]>,
119
- ): Array<{
120
- file: string;
121
- range: { start: { line: number; character: number }; end: { line: number; character: number } };
122
- newText: string;
123
- }> {
124
- const out: Array<{
125
- file: string;
126
- range: { start: { line: number; character: number }; end: { line: number; character: number } };
127
- newText: string;
128
- }> = [];
129
- for (const change of docChanges) {
130
- const tdEdit = change as TextDocumentEdit;
131
- if (!tdEdit.textDocument || !tdEdit.edits) continue;
132
- const file = resolveFileFromUri(tdEdit.textDocument.uri);
133
- for (const singleEdit of tdEdit.edits) {
134
- const te = singleEdit as TextEdit;
135
- out.push({
136
- file,
137
- range: {
138
- start: { line: te.range.start.line, character: te.range.start.character },
139
- end: { line: te.range.end.line, character: te.range.end.character },
140
- },
141
- newText: te.newText,
142
- });
143
- }
144
- }
145
- return out;
146
- }
147
-
148
- function collectChangesEdits(changes: NonNullable<WorkspaceEdit["changes"]>): Array<{
149
- file: string;
150
- range: { start: { line: number; character: number }; end: { line: number; character: number } };
151
- newText: string;
152
- }> {
153
- const out: Array<{
154
- file: string;
155
- range: { start: { line: number; character: number }; end: { line: number; character: number } };
156
- newText: string;
157
- }> = [];
158
- for (const [uri, textEdits] of Object.entries(changes)) {
159
- if (!textEdits || textEdits.length === 0) continue;
160
- const file = resolveFileFromUri(uri);
161
- for (const te of textEdits) {
162
- out.push({
163
- file,
164
- range: {
165
- start: { line: te.range.start.line, character: te.range.start.character },
166
- end: { line: te.range.end.line, character: te.range.end.character },
167
- },
168
- newText: te.newText,
169
- });
170
- }
171
- }
172
- return out;
173
- }
174
-
175
- function convertLspWorkspaceEdit(edit: WorkspaceEdit | null): RefactorResult {
176
- if (!edit) {
177
- return { kind: "unavailable", reason: "LSP server returned no edit" };
178
- }
179
-
180
- let fileEdits = edit.documentChanges?.length
181
- ? collectDocumentChangeEdits(edit.documentChanges)
182
- : [];
183
- if (fileEdits.length === 0 && edit.changes) {
184
- fileEdits = collectChangesEdits(edit.changes);
185
- }
186
-
187
- if (fileEdits.length === 0) {
188
- return { kind: "unavailable", reason: "Workspace edit contains no file edits" };
170
+ function runExtractRefactor(
171
+ lsp: SessionLspService,
172
+ request: RefactorRequest,
173
+ operation: "extract_function" | "extract_variable",
174
+ matches: (action: CodeAction) => boolean,
175
+ ): Promise<RefactorResult> | RefactorResult {
176
+ if (!request.range) {
177
+ return {
178
+ kind: "unavailable",
179
+ reason: `Refactor operation "${operation}" requires \`range\`.`,
180
+ };
189
181
  }
190
182
 
191
- return { kind: "precise", edits: { edits: fileEdits } };
183
+ return runFilteredCodeActionRefactor({
184
+ lsp,
185
+ file: request.file,
186
+ position: request.position,
187
+ range: request.range,
188
+ operation,
189
+ matches,
190
+ });
192
191
  }
193
192
 
194
193
  // ── Type conversion helpers ───────────────────────────────────────────
@@ -257,20 +256,27 @@ function flattenDocumentSymbols(
257
256
  const result: CodeSymbol[] = [];
258
257
 
259
258
  for (const sym of symbols) {
260
- // DocumentSymbol has selectionRange; SymbolInformation has location
259
+ // DocumentSymbol.range = full defining node (declaration anchor);
260
+ // .selectionRange = identifier token (name anchor). SymbolInformation has
261
+ // only location.range (declaration) and no selectionRange.
261
262
  const ds = sym as DocumentSymbol;
262
263
  const si = sym as SymbolInformation;
263
- const start = ds.selectionRange?.start ?? si.location?.range?.start;
264
- if (!start) continue;
264
+ const declStart = ds.range?.start ?? si.location?.range?.start;
265
+ if (!declStart) continue;
266
+ const nameStart = ds.selectionRange?.start;
265
267
 
266
- result.push({
268
+ const symbol: CodeSymbol = {
267
269
  name: sym.name,
268
270
  kind: symbolKindName(sym.kind),
269
271
  file: filePath,
270
- line: start.line + 1,
271
- character: start.character + 1,
272
+ declarationAnchor: { line: declStart.line + 1, character: declStart.character + 1 },
272
273
  container,
273
- });
274
+ };
275
+ if (nameStart) {
276
+ symbol.nameAnchor = { line: nameStart.line + 1, character: nameStart.character + 1 };
277
+ }
278
+
279
+ result.push(symbol);
274
280
 
275
281
  if (Array.isArray(ds.children) && ds.children.length > 0) {
276
282
  result.push(...flattenDocumentSymbols(ds.children, filePath, sym.name));
@@ -287,8 +293,45 @@ function toCodeSymbol(sym: SymbolInformation): CodeSymbol {
287
293
  name: sym.name,
288
294
  kind: symbolKindName(sym.kind),
289
295
  file: uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri,
290
- line: start ? start.line + 1 : 0,
291
- character: start ? start.character + 1 : 0,
296
+ // SymbolInformation has no selectionRange nameAnchor is derived later
297
+ // by the orchestration layer's refine, or left absent.
298
+ declarationAnchor: {
299
+ line: start ? start.line + 1 : 0,
300
+ character: start ? start.character + 1 : 0,
301
+ },
292
302
  container: sym.containerName ?? null,
293
303
  };
294
304
  }
305
+
306
+ // ── Hover conversion helpers ─────────────────────────────────────────
307
+
308
+ /**
309
+ * Convert an LSP Hover result into a simplified runtime shape.
310
+ * Extracts text from MarkupContent, MarkedString[], or plain string,
311
+ * and converts the optional LSP Range to a SourceRange.
312
+ */
313
+ function convertLspHover(hover: Hover): { contents: string; range?: SourceRange } {
314
+ const contents = extractHoverText(hover.contents);
315
+ const result: { contents: string; range?: SourceRange } = { contents };
316
+ if (hover.range) {
317
+ result.range = {
318
+ start: { line: hover.range.start.line, character: hover.range.start.character },
319
+ end: { line: hover.range.end.line, character: hover.range.end.character },
320
+ };
321
+ }
322
+ return result;
323
+ }
324
+
325
+ function extractHoverText(contents: Hover["contents"]): string {
326
+ if (typeof contents === "string") return contents;
327
+ if (Array.isArray(contents)) {
328
+ return contents
329
+ .map((item) => {
330
+ if (typeof item === "string") return item;
331
+ return item.value;
332
+ })
333
+ .join("\n");
334
+ }
335
+ // MarkupContent
336
+ return (contents as MarkupContent).value ?? "";
337
+ }