@mrclrchtr/supi-lsp 1.4.0 → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -0,0 +1,40 @@
1
+ import * as path from "node:path";
2
+
3
+ /** Strip pi's optional leading `@` file-path prefix from a tool input. */
4
+ export function stripToolPathPrefix(target: string): string {
5
+ return target.startsWith("@") ? target.slice(1) : target;
6
+ }
7
+
8
+ /**
9
+ * Resolve a tool-style file path from a session cwd.
10
+ *
11
+ * Built-in pi file tools accept a leading `@` prefix in path arguments, so
12
+ * shared SuPi path helpers normalize that prefix before resolving relative
13
+ * paths.
14
+ */
15
+ export function resolveToolPath(cwd: string, target: string): string {
16
+ return path.resolve(cwd, stripToolPathPrefix(target));
17
+ }
18
+
19
+ /** Convert a file path to a file:// URI. */
20
+ export function fileToUri(filePath: string): string {
21
+ const resolved = path.resolve(filePath);
22
+ if (process.platform === "win32") {
23
+ return `file:///${resolved.replace(/\\/g, "/")}`;
24
+ }
25
+ return `file://${resolved}`;
26
+ }
27
+
28
+ /** Convert a file:// URI to a file path. */
29
+ export function uriToFile(uri: string): string {
30
+ if (!uri.startsWith("file://")) return uri;
31
+ let filePath = decodeURIComponent(uri.slice(7));
32
+ if (
33
+ process.platform === "win32" &&
34
+ filePath.startsWith("/") &&
35
+ /^[A-Za-z]:/.test(filePath.slice(1))
36
+ ) {
37
+ filePath = filePath.slice(1);
38
+ }
39
+ return filePath;
40
+ }
@@ -5,8 +5,20 @@
5
5
  // Without this, each symlink path gets its own module copy and its own Map,
6
6
  // so registrations from one instance are invisible to consumers in another.
7
7
 
8
+ import * as path from "node:path";
9
+
8
10
  const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
9
11
 
12
+ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
13
+ const key = Symbol.for(SYMBOL_PREFIX + name);
14
+ let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
15
+ if (!map) {
16
+ map = new Map<string, T>();
17
+ (globalThis as Record<symbol, unknown>)[key] = map;
18
+ }
19
+ return map;
20
+ }
21
+
10
22
  /**
11
23
  * Create a named registry backed by `globalThis` + `Symbol.for`.
12
24
  *
@@ -18,16 +30,7 @@ const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
18
30
  * @returns An object with `register`, `getAll`, and `clear` functions.
19
31
  */
20
32
  export function createRegistry<T>(name: string) {
21
- const key = Symbol.for(SYMBOL_PREFIX + name);
22
-
23
- const getMap = (): Map<string, T> => {
24
- let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
25
- if (!map) {
26
- map = new Map<string, T>();
27
- (globalThis as Record<symbol, unknown>)[key] = map;
28
- }
29
- return map;
30
- };
33
+ const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
31
34
 
32
35
  return {
33
36
  /**
@@ -52,3 +55,32 @@ export function createRegistry<T>(name: string) {
52
55
  },
53
56
  };
54
57
  }
58
+
59
+ /**
60
+ * Create a named session-state registry keyed by normalized cwd.
61
+ *
62
+ * This helper is intended for session-scoped runtime services that should be
63
+ * shared across duplicate jiti module instances while keeping package-specific
64
+ * state unions and convenience wrappers local to the calling package.
65
+ */
66
+ export function createSessionStateRegistry<TState>(name: string) {
67
+ const getMap = (): Map<string, TState> => getGlobalRegistryMap<TState>(name);
68
+ const normalizeCwd = (cwd: string): string => path.resolve(cwd);
69
+
70
+ return {
71
+ /** Get the current state for one session cwd. */
72
+ get: (cwd: string): TState | undefined => {
73
+ return getMap().get(normalizeCwd(cwd));
74
+ },
75
+
76
+ /** Store the current state for one session cwd. */
77
+ set: (cwd: string, state: TState): void => {
78
+ getMap().set(normalizeCwd(cwd), state);
79
+ },
80
+
81
+ /** Clear the current state for one session cwd. */
82
+ clear: (cwd: string): void => {
83
+ getMap().delete(normalizeCwd(cwd));
84
+ },
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi LSP extension — Language Server Protocol integration for pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,8 +21,9 @@
21
21
  "!__tests__"
22
22
  ],
23
23
  "dependencies": {
24
+ "ignore": "^7.0.5",
24
25
  "typescript": "6.0.3",
25
- "@mrclrchtr/supi-core": "1.4.0"
26
+ "@mrclrchtr/supi-core": "1.6.0"
26
27
  },
27
28
  "bundledDependencies": [
28
29
  "@mrclrchtr/supi-core"
@@ -1,6 +1,7 @@
1
1
  import type { LspClient } from "../client/client.ts";
2
2
  import type { ProjectServerInfo } from "../config/types.ts";
3
3
  import { displayRelativeFilePath } from "../summary.ts";
4
+ import { getSupportedLspServerActions } from "../tool/tool-specs.ts";
4
5
 
5
6
  interface ProjectServerInfoInput {
6
7
  serverName: string;
@@ -28,22 +29,7 @@ export function buildProjectServerInfo(
28
29
  root: input.root,
29
30
  fileTypes: input.fileTypes,
30
31
  status,
31
- supportedActions: getSupportedActions(input.client?.serverCapabilities),
32
+ supportedActions: getSupportedLspServerActions(input.client?.serverCapabilities),
32
33
  openFiles: input.client?.openFiles.map((file) => displayRelativeFilePath(file, cwd)) ?? [],
33
34
  };
34
35
  }
35
-
36
- function getSupportedActions(capabilities: LspClient["serverCapabilities"] | undefined): string[] {
37
- if (!capabilities) return [];
38
-
39
- const actions: string[] = ["diagnostics [optional file]"];
40
- if (capabilities.hoverProvider) actions.push("hover(file,line,char)");
41
- if (capabilities.definitionProvider) actions.push("definition(file,line,char)");
42
- if (capabilities.referencesProvider) actions.push("references(file,line,char)");
43
- if (capabilities.implementationProvider) actions.push("implementation(file,line,char)");
44
- if (capabilities.documentSymbolProvider) actions.push("symbols(file)");
45
- if (capabilities.workspaceSymbolProvider) actions.push("workspace_symbols(query)");
46
- if (capabilities.renameProvider) actions.push("rename(file,line,char,newName)");
47
- if (capabilities.codeActionProvider) actions.push("code_actions(file,line,char)");
48
- return actions;
49
- }
@@ -1,20 +1,15 @@
1
+ import ignore from "ignore";
2
+
1
3
  /** Gitignore-style glob pattern matching for path exclusion.
2
4
  *
3
- * Supports:
4
- * - Literal names at any depth: __tests__, build
5
- * - Trailing slash directory-only: __tests__ + "/"
6
- * - Leading slash root-anchored: "/" + build
7
- * - Stars-star-slash recursive: e.g. `**` + "/" + fixtures
8
- * - Asterisk single-segment wildcard: *.generated.ts
9
- * - Literal paths: packages + "/" + legacy
10
- */
11
-
12
- /**
13
- * Normalize path separators to forward slashes and trim.
5
+ * Delegates to the {@link https://github.com/kaelzhang/node-ignore | ignore} package,
6
+ * which provides battle-tested .gitignore semantics used by ESLint, Prettier, and others.
7
+ *
8
+ * Supports full gitignore syntax: literal names, `*` / `?` wildcards, `**` recursive globs,
9
+ * leading `/` anchored patterns, trailing `/` directory-only patterns, and `!` negation.
10
+ *
11
+ * **Note:** Patterns that start with `#` are treated as comments unless escaped with `\#`.
14
12
  */
15
- function normalize(p: string): string {
16
- return p.replaceAll("\\", "/").trim();
17
- }
18
13
 
19
14
  /**
20
15
  * Check whether a project-relative file path matches a gitignore-style glob pattern.
@@ -24,174 +19,6 @@ function normalize(p: string): string {
24
19
  * @returns `true` if the file path matches the pattern
25
20
  */
26
21
  export function isGlobMatch(filePath: string, pattern: string): boolean {
27
- const fp = normalize(filePath);
28
- const pat = normalize(pattern);
29
- if (!fp || !pat) return false;
30
-
31
- // Leading / → anchored to root
32
- const anchored = pat.startsWith("/");
33
- const noLeadingSlash = anchored ? pat.slice(1) : pat;
34
-
35
- // Trailing / → directory-only
36
- const dirOnly = noLeadingSlash.endsWith("/");
37
- const cleanPat = dirOnly ? noLeadingSlash.slice(0, -1) : noLeadingSlash;
38
-
39
- if (!cleanPat) return false;
40
-
41
- return matchGlob(fp, cleanPat, { anchored, dirOnly });
42
- }
43
-
44
- interface MatchOptions {
45
- anchored: boolean;
46
- dirOnly: boolean;
47
- }
48
-
49
- /**
50
- * Core recursive pattern matching against a multi-segment path.
51
- */
52
- function matchGlob(filePath: string, pattern: string, opts: MatchOptions): boolean {
53
- // Direct match
54
- if (!opts.anchored && !opts.dirOnly && filePath === pattern) return true;
55
-
56
- // Split into segments
57
- const pathSegments = filePath.split("/");
58
- const patternSegments = pattern.split("/");
59
-
60
- // ** recursive glob
61
- if (pattern.startsWith("**/")) {
62
- const suffix = pattern.slice(3);
63
- return (
64
- matchGlob(filePath, suffix, { ...opts, anchored: false }) ||
65
- starStarMatch(pathSegments, suffix)
66
- );
67
- }
68
-
69
- // prefix/**/suffix bounded recursive glob
70
- const dstarIdx = pattern.indexOf("/**/");
71
- if (dstarIdx !== -1) {
72
- const prefix = pattern.slice(0, dstarIdx);
73
- const suffix = pattern.slice(dstarIdx + 4);
74
- return matchBoundedStar(pathSegments, prefix, suffix);
75
- }
76
-
77
- // Single-segment patterns
78
- if (patternSegments.length === 1 && !opts.anchored) {
79
- return matchSingleSegment(pathSegments, patternSegments[0], opts.dirOnly);
80
- }
81
-
82
- // Multi-segment: anchored or unanchored
83
- if (opts.anchored) {
84
- return matchSegments(pathSegments, patternSegments, opts.dirOnly);
85
- }
86
-
87
- // Unanchored multi-segment: try at each starting position
88
- return matchUnanchoredSegments(pathSegments, patternSegments, opts.dirOnly);
89
- }
90
-
91
- /**
92
- * Match a ** recursive suffix across directory levels.
93
- */
94
- function starStarMatch(segments: string[], suffix: string): boolean {
95
- for (let i = 0; i < segments.length; i++) {
96
- const remaining = segments.slice(i).join("/");
97
- if (matchGlob(remaining, suffix, { anchored: false, dirOnly: false })) return true;
98
- }
99
- return false;
100
- }
101
-
102
- /** Match prefix plus star-star-slash plus suffix bounded recursive pattern. */
103
- function matchBoundedStar(segments: string[], prefix: string, suffix: string): boolean {
104
- // Try to find a split point where left matches prefix and right matches suffix
105
- for (let i = 1; i < segments.length; i++) {
106
- const left = segments.slice(0, i).join("/");
107
- const right = segments.slice(i).join("/");
108
- if (
109
- matchGlob(left, prefix, { anchored: false, dirOnly: false }) &&
110
- matchGlob(right, suffix, { anchored: false, dirOnly: false })
111
- ) {
112
- return true;
113
- }
114
- }
115
- return false;
116
- }
117
-
118
- /**
119
- * Match a single-segment pattern against path segments.
120
- */
121
- function matchSingleSegment(segments: string[], patternSeg: string, dirOnly: boolean): boolean {
122
- const hasGlob = patternSeg.includes("*") || patternSeg.includes("?");
123
-
124
- if (hasGlob) {
125
- // Glob pattern matches any segment
126
- return segments.some((seg) => simpleMatch(seg, patternSeg));
127
- }
128
-
129
- // Literal segment name
130
- if (dirOnly) {
131
- // Match as directory: any segment except the last (file) one
132
- return segments.slice(0, -1).some((seg) => seg === patternSeg);
133
- }
134
-
135
- // Match any segment (file or directory)
136
- return segments.some((seg) => seg === patternSeg);
137
- }
138
-
139
- /**
140
- * Try to match multi-segment pattern at each start position.
141
- */
142
- function matchUnanchoredSegments(segments: string[], pattern: string[], dirOnly: boolean): boolean {
143
- for (let i = 0; i < segments.length; i++) {
144
- if (matchSegments(segments.slice(i), pattern, dirOnly)) return true;
145
- }
146
- return false;
147
- }
148
-
149
- /** Match segments from start. Returns true if all pattern segments match contiguously. */
150
- function matchSegments(segments: string[], pattern: string[], dirOnly: boolean): boolean {
151
- if (pattern.length > segments.length) return false;
152
-
153
- for (let i = 0; i < pattern.length; i++) {
154
- if (!matchSegmentAtIndex(segments, pattern, i)) return false;
155
- }
156
-
157
- // dirOnly: last matched segment must not be the last path segment
158
- if (dirOnly && pattern.length === segments.length) return false;
159
-
160
- return true;
161
- }
162
-
163
- /** Match a single pattern segment against the corresponding path segment. */
164
- function matchSegmentAtIndex(segments: string[], pattern: string[], index: number): boolean {
165
- const patSeg = pattern[index];
166
- const pathSeg = segments[index];
167
-
168
- if (patSeg === "**") {
169
- // ** at end matches all remaining segments
170
- if (index === pattern.length - 1) return true;
171
- // Try rest of pattern from various positions
172
- for (let j = index; j < segments.length; j++) {
173
- if (matchSegments(segments.slice(j), pattern.slice(index + 1), false)) return true;
174
- }
175
- return false;
176
- }
177
-
178
- return simpleMatch(pathSeg, patSeg);
179
- }
180
-
181
- /**
182
- * Match a single path segment against a single pattern segment.
183
- * Supports `*` (any chars except `/`) and `?` (single char).
184
- */
185
- function simpleMatch(segment: string, pattern: string): boolean {
186
- if (pattern === "*") return true;
187
- if (pattern === segment) return true;
188
- if (!pattern.includes("*") && !pattern.includes("?")) return false;
189
-
190
- // Convert pattern to simple regex
191
- const regexStr = pattern
192
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
193
- .replace(/\*/g, "[^/]*")
194
- .replace(/\?/g, "[^/]");
195
-
196
- return new RegExp(`^${regexStr}$`).test(segment);
22
+ if (!filePath || !pattern) return false;
23
+ return ignore().add(pattern).ignores(filePath);
197
24
  }
@@ -2,7 +2,7 @@
2
2
  // Peer extensions can import `getSessionLspService` from the package root
3
3
  // to reuse the active LSP runtime without starting duplicate servers.
4
4
 
5
- import * as path from "node:path";
5
+ import { createSessionStateRegistry } from "@mrclrchtr/supi-core/api";
6
6
  import type {
7
7
  CodeAction,
8
8
  Diagnostic,
@@ -189,34 +189,18 @@ export class SessionLspService {
189
189
 
190
190
  // ── Registry ──────────────────────────────────────────────────────────
191
191
 
192
- const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-lsp/session-registry");
193
192
  const WAIT_INTERVAL_MS = 25;
194
-
195
- function getRegistry(): Map<string, SessionLspServiceState> {
196
- const globalScope = globalThis as typeof globalThis & Record<symbol, unknown>;
197
- const existing = globalScope[REGISTRY_KEY];
198
- if (existing instanceof Map) return existing as Map<string, SessionLspServiceState>;
199
-
200
- const registry = new Map<string, SessionLspServiceState>();
201
- globalScope[REGISTRY_KEY] = registry;
202
- return registry;
203
- }
204
-
205
- function normalizeCwd(cwd: string): string {
206
- return path.resolve(cwd);
207
- }
208
-
209
- const registry = getRegistry();
193
+ const registry = createSessionStateRegistry<SessionLspServiceState>("supi-lsp/session-registry");
210
194
 
211
195
  /** Publish the LSP service state for a session cwd. */
212
196
  export function setSessionLspServiceState(cwd: string, state: SessionLspServiceState): void {
213
- registry.set(normalizeCwd(cwd), state);
197
+ registry.set(cwd, state);
214
198
  }
215
199
 
216
200
  /** Acquire the LSP service state for a session cwd. */
217
201
  export function getSessionLspService(cwd: string): SessionLspServiceState {
218
202
  return (
219
- registry.get(normalizeCwd(cwd)) ?? {
203
+ registry.get(cwd) ?? {
220
204
  kind: "unavailable",
221
205
  reason: "No LSP session initialized for this workspace",
222
206
  }
@@ -241,5 +225,5 @@ export async function waitForSessionLspService(
241
225
 
242
226
  /** Remove the LSP service state for a session cwd. */
243
227
  export function clearSessionLspService(cwd: string): void {
244
- registry.delete(normalizeCwd(cwd));
228
+ registry.clear(cwd);
245
229
  }
@@ -2,15 +2,8 @@
2
2
 
3
3
  import * as path from "node:path";
4
4
  import type { ProjectServerInfo } from "../config/types.ts";
5
- import {
6
- LSP_DIAGNOSTICS_TOOL,
7
- LSP_DOCUMENT_SYMBOLS_TOOL,
8
- LSP_LOOKUP_TOOL,
9
- LSP_RECOVER_TOOL,
10
- LSP_REFACTOR_TOOL,
11
- LSP_WORKSPACE_SYMBOLS_TOOL,
12
- type LspToolName,
13
- } from "./names.ts";
5
+ import { LSP_LOOKUP_TOOL, type LspToolName } from "./names.ts";
6
+ import { LSP_TOOL_DEFINITION_SPECS } from "./tool-specs.ts";
14
7
 
15
8
  export interface LspToolPromptSurface {
16
9
  description: string;
@@ -20,33 +13,6 @@ export interface LspToolPromptSurface {
20
13
 
21
14
  export type LspToolPromptSurfaceMap = Record<LspToolName, LspToolPromptSurface>;
22
15
 
23
- const LOOKUP_GUIDELINES = [
24
- 'Use lsp_lookup with `kind: "hover"` for semantic type or symbol information at a known `file`, `line`, and `character`.',
25
- 'Use lsp_lookup with `kind: "definition"`, `"references"`, or `"implementation"` for semantic navigation at a known position.',
26
- "Use lsp_lookup after code_intel or tree_sitter has already narrowed the target file and position.",
27
- ];
28
-
29
- const DOCUMENT_SYMBOL_GUIDELINES = [
30
- "Use lsp_document_symbols(file) for semantic declarations in one supported file.",
31
- ];
32
-
33
- const WORKSPACE_SYMBOL_GUIDELINES = [
34
- "Use lsp_workspace_symbols(query) for semantic symbol-name lookup across the current project.",
35
- ];
36
-
37
- const DIAGNOSTICS_GUIDELINES = [
38
- "Use lsp_diagnostics(file?) when you need current diagnostics for one file or a workspace-level summary.",
39
- ];
40
-
41
- const REFACTOR_GUIDELINES = [
42
- 'Use lsp_refactor with `kind: "rename"` for semantic rename planning at a known `file`, `line`, and `character`.',
43
- 'Use lsp_refactor with `kind: "code_actions"` for semantic fixes or refactors at a known position.',
44
- ];
45
-
46
- const RECOVER_GUIDELINES = [
47
- "Use lsp_recover() when diagnostics look stale after workspace-level changes or generated-file updates.",
48
- ];
49
-
50
16
  export const defaultLspToolPromptSurfaces = buildLspToolPromptSurfaces([], ".");
51
17
 
52
18
  export function buildLspToolPromptSurfaces(
@@ -55,45 +21,19 @@ export function buildLspToolPromptSurfaces(
55
21
  ): LspToolPromptSurfaceMap {
56
22
  const coverageGuidelines = buildCoverageGuidelines(servers, cwd);
57
23
 
58
- return {
59
- [LSP_LOOKUP_TOOL]: {
60
- description:
61
- "Language Server Protocol lookup tool — semantic hover, definition, references, and implementation for supported files. Use lsp_lookup when you know the file and 1-based line/character position and need semantic drill-down rather than text search.",
62
- promptSnippet:
63
- "lsp_lookup — semantic hover/definition/references/implementation at a known file position",
64
- promptGuidelines: [...LOOKUP_GUIDELINES, ...coverageGuidelines],
65
- },
66
- [LSP_DOCUMENT_SYMBOLS_TOOL]: {
67
- description:
68
- "Language Server Protocol document symbols tool — list semantic declarations in one supported file. Use lsp_document_symbols when you need a symbol-aware outline rather than raw text structure.",
69
- promptSnippet: "lsp_document_symbols — semantic declarations for one supported file",
70
- promptGuidelines: DOCUMENT_SYMBOL_GUIDELINES,
71
- },
72
- [LSP_WORKSPACE_SYMBOLS_TOOL]: {
73
- description:
74
- "Language Server Protocol workspace symbols tool — semantic symbol-name lookup across the current project. Use lsp_workspace_symbols to find declarations by name before opening a specific file.",
75
- promptSnippet: "lsp_workspace_symbols — semantic symbol-name lookup across the project",
76
- promptGuidelines: WORKSPACE_SYMBOL_GUIDELINES,
77
- },
78
- [LSP_DIAGNOSTICS_TOOL]: {
79
- description:
80
- "Language Server Protocol diagnostics tool — current diagnostics for one file or a workspace summary. Use lsp_diagnostics for semantic compiler or language-server issues instead of guessing from text alone.",
81
- promptSnippet: "lsp_diagnostics — current diagnostics for one file or the workspace",
82
- promptGuidelines: DIAGNOSTICS_GUIDELINES,
83
- },
84
- [LSP_REFACTOR_TOOL]: {
85
- description:
86
- "Language Server Protocol refactor tool — semantic rename planning and code actions at a known file position. Use lsp_refactor when you need language-server-backed edits or quick-fix suggestions.",
87
- promptSnippet: "lsp_refactor — semantic rename planning and code actions at a known position",
88
- promptGuidelines: REFACTOR_GUIDELINES,
89
- },
90
- [LSP_RECOVER_TOOL]: {
91
- description:
92
- "Language Server Protocol recover tool — refresh diagnostics after workspace changes and stale language-server state. Use lsp_recover when new files, generated types, or config updates leave diagnostics out of sync.",
93
- promptSnippet: "lsp_recover — refresh stale diagnostics after workspace changes",
94
- promptGuidelines: RECOVER_GUIDELINES,
95
- },
96
- };
24
+ return Object.fromEntries(
25
+ LSP_TOOL_DEFINITION_SPECS.map((spec) => [
26
+ spec.name,
27
+ {
28
+ description: spec.description,
29
+ promptSnippet: spec.promptSnippet,
30
+ promptGuidelines:
31
+ "includeCoverageGuidelines" in spec && spec.includeCoverageGuidelines
32
+ ? [...spec.basePromptGuidelines, ...coverageGuidelines]
33
+ : [...spec.basePromptGuidelines],
34
+ } satisfies LspToolPromptSurface,
35
+ ]),
36
+ ) as LspToolPromptSurfaceMap;
97
37
  }
98
38
 
99
39
  function buildCoverageGuidelines(servers: ProjectServerInfo[], cwd: string): string[] {
@@ -1,160 +1,22 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Type } from "typebox";
4
2
  import { getSessionLspService } from "../session/service-registry.ts";
5
3
  import type { LspToolPromptSurfaceMap } from "./guidance.ts";
6
- import {
7
- LSP_DIAGNOSTICS_TOOL,
8
- LSP_DOCUMENT_SYMBOLS_TOOL,
9
- LSP_LOOKUP_TOOL,
10
- LSP_RECOVER_TOOL,
11
- LSP_REFACTOR_TOOL,
12
- LSP_WORKSPACE_SYMBOLS_TOOL,
13
- } from "./names.ts";
14
- import {
15
- executeDiagnostics,
16
- executeDocumentSymbols,
17
- executeLookup,
18
- executeRecover,
19
- executeRefactor,
20
- executeWorkspaceSymbols,
21
- } from "./service-actions.ts";
22
-
23
- const FileParam = Type.String({ description: "File path (relative or absolute)" });
24
- const LineParam = Type.Number({ description: "1-based line number", minimum: 1 });
25
- const CharacterParam = Type.Number({ description: "1-based column number", minimum: 1 });
26
- const QueryParam = Type.String({ description: "Symbol query string" });
27
- const NewNameParam = Type.String({ description: "New name for rename" });
28
-
29
- const LookupKindEnum = StringEnum(["hover", "definition", "references", "implementation"] as const);
30
-
31
- const RefactorKindEnum = StringEnum(["rename", "code_actions"] as const);
32
-
33
- const LookupParameters = Type.Object(
34
- {
35
- kind: LookupKindEnum,
36
- file: FileParam,
37
- line: LineParam,
38
- character: CharacterParam,
39
- },
40
- { additionalProperties: false },
41
- );
42
-
43
- const DocumentSymbolsParameters = Type.Object(
44
- {
45
- file: FileParam,
46
- },
47
- { additionalProperties: false },
48
- );
49
-
50
- const WorkspaceSymbolsParameters = Type.Object(
51
- {
52
- query: QueryParam,
53
- },
54
- { additionalProperties: false },
55
- );
56
-
57
- const DiagnosticsParameters = Type.Object(
58
- {
59
- file: Type.Optional(FileParam),
60
- },
61
- { additionalProperties: false },
62
- );
63
-
64
- const RefactorParameters = Type.Object(
65
- {
66
- kind: RefactorKindEnum,
67
- file: FileParam,
68
- line: LineParam,
69
- character: CharacterParam,
70
- newName: Type.Optional(NewNameParam),
71
- },
72
- { additionalProperties: false },
73
- );
74
-
75
- const RecoverParameters = Type.Object({}, { additionalProperties: false });
4
+ import { LSP_TOOL_DEFINITION_SPECS } from "./tool-specs.ts";
76
5
 
77
6
  /** Register the expert LSP toolset. Tools are re-registered on session_start to refresh guidance. */
78
7
  export function registerLspTools(pi: ExtensionAPI, promptSurfaces: LspToolPromptSurfaceMap): void {
79
- const lookupSurface = promptSurfaces[LSP_LOOKUP_TOOL];
80
- pi.registerTool({
81
- name: LSP_LOOKUP_TOOL,
82
- label: "LSP Lookup",
83
- description: lookupSurface.description,
84
- promptSnippet: lookupSurface.promptSnippet,
85
- promptGuidelines: lookupSurface.promptGuidelines,
86
- parameters: LookupParameters,
87
- execute: createToolExecutor((service, cwd, params) =>
88
- executeLookup(service, cwd, params as Parameters<typeof executeLookup>[2]),
89
- ),
90
- });
91
-
92
- const documentSymbolsSurface = promptSurfaces[LSP_DOCUMENT_SYMBOLS_TOOL];
93
- pi.registerTool({
94
- name: LSP_DOCUMENT_SYMBOLS_TOOL,
95
- label: "LSP Document Symbols",
96
- description: documentSymbolsSurface.description,
97
- promptSnippet: documentSymbolsSurface.promptSnippet,
98
- promptGuidelines: documentSymbolsSurface.promptGuidelines,
99
- parameters: DocumentSymbolsParameters,
100
- execute: createToolExecutor((service, cwd, params) =>
101
- executeDocumentSymbols(service, cwd, params as Parameters<typeof executeDocumentSymbols>[2]),
102
- ),
103
- });
104
-
105
- const workspaceSymbolsSurface = promptSurfaces[LSP_WORKSPACE_SYMBOLS_TOOL];
106
- pi.registerTool({
107
- name: LSP_WORKSPACE_SYMBOLS_TOOL,
108
- label: "LSP Workspace Symbols",
109
- description: workspaceSymbolsSurface.description,
110
- promptSnippet: workspaceSymbolsSurface.promptSnippet,
111
- promptGuidelines: workspaceSymbolsSurface.promptGuidelines,
112
- parameters: WorkspaceSymbolsParameters,
113
- execute: createToolExecutor((service, cwd, params) =>
114
- executeWorkspaceSymbols(
115
- service,
116
- cwd,
117
- params as Parameters<typeof executeWorkspaceSymbols>[2],
118
- ),
119
- ),
120
- });
121
-
122
- const diagnosticsSurface = promptSurfaces[LSP_DIAGNOSTICS_TOOL];
123
- pi.registerTool({
124
- name: LSP_DIAGNOSTICS_TOOL,
125
- label: "LSP Diagnostics",
126
- description: diagnosticsSurface.description,
127
- promptSnippet: diagnosticsSurface.promptSnippet,
128
- promptGuidelines: diagnosticsSurface.promptGuidelines,
129
- parameters: DiagnosticsParameters,
130
- execute: createToolExecutor((service, cwd, params) =>
131
- executeDiagnostics(service, cwd, params as Parameters<typeof executeDiagnostics>[2]),
132
- ),
133
- });
134
-
135
- const refactorSurface = promptSurfaces[LSP_REFACTOR_TOOL];
136
- pi.registerTool({
137
- name: LSP_REFACTOR_TOOL,
138
- label: "LSP Refactor",
139
- description: refactorSurface.description,
140
- promptSnippet: refactorSurface.promptSnippet,
141
- promptGuidelines: refactorSurface.promptGuidelines,
142
- parameters: RefactorParameters,
143
- execute: createToolExecutor((service, cwd, params) =>
144
- executeRefactor(service, cwd, params as Parameters<typeof executeRefactor>[2]),
145
- ),
146
- });
147
-
148
- const recoverSurface = promptSurfaces[LSP_RECOVER_TOOL];
149
- pi.registerTool({
150
- name: LSP_RECOVER_TOOL,
151
- label: "LSP Recover",
152
- description: recoverSurface.description,
153
- promptSnippet: recoverSurface.promptSnippet,
154
- promptGuidelines: recoverSurface.promptGuidelines,
155
- parameters: RecoverParameters,
156
- execute: createRecoverToolExecutor(),
157
- });
8
+ for (const spec of LSP_TOOL_DEFINITION_SPECS) {
9
+ const surface = promptSurfaces[spec.name];
10
+ pi.registerTool({
11
+ name: spec.name,
12
+ label: spec.label,
13
+ description: surface.description,
14
+ promptSnippet: surface.promptSnippet,
15
+ promptGuidelines: surface.promptGuidelines,
16
+ parameters: spec.parameters,
17
+ execute: createToolExecutor(spec.run),
18
+ });
19
+ }
158
20
  }
159
21
 
160
22
  function getReadyService(cwd: string) {
@@ -201,21 +63,6 @@ function createToolExecutor(
201
63
  };
202
64
  }
203
65
 
204
- function createRecoverToolExecutor() {
205
- // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
206
- return async (
207
- _toolCallId: string,
208
- _params: unknown,
209
- _signal: AbortSignal | undefined,
210
- _onUpdate: unknown,
211
- ctx: ExtensionContext,
212
- ) => {
213
- const service = getReadyService(ctx.cwd);
214
- const text = service ? await executeRecover(service) : describeUnavailableService(ctx.cwd);
215
- return makeTextResult(text);
216
- };
217
- }
218
-
219
66
  function makeTextResult(text: string) {
220
67
  return {
221
68
  content: [{ type: "text" as const, text }],
@@ -0,0 +1,248 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type TSchema, Type } from "typebox";
3
+ import type { ServerCapabilities } from "../config/types.ts";
4
+ import type { SessionLspService } from "../session/service-registry.ts";
5
+ import {
6
+ LSP_DIAGNOSTICS_TOOL,
7
+ LSP_DOCUMENT_SYMBOLS_TOOL,
8
+ LSP_LOOKUP_TOOL,
9
+ LSP_RECOVER_TOOL,
10
+ LSP_REFACTOR_TOOL,
11
+ LSP_WORKSPACE_SYMBOLS_TOOL,
12
+ type LspToolName,
13
+ } from "./names.ts";
14
+ import {
15
+ executeDiagnostics,
16
+ executeDocumentSymbols,
17
+ executeLookup,
18
+ executeRecover,
19
+ executeRefactor,
20
+ executeWorkspaceSymbols,
21
+ } from "./service-actions.ts";
22
+
23
+ const FileParam = Type.String({ description: "File path (relative or absolute)" });
24
+ const LineParam = Type.Number({ description: "1-based line number", minimum: 1 });
25
+ const CharacterParam = Type.Number({ description: "1-based column number", minimum: 1 });
26
+ const QueryParam = Type.String({ description: "Symbol query string" });
27
+ const NewNameParam = Type.String({ description: "New name for rename" });
28
+
29
+ export const LSP_LOOKUP_KIND_NAMES = [
30
+ "hover",
31
+ "definition",
32
+ "references",
33
+ "implementation",
34
+ ] as const;
35
+ export const LSP_REFACTOR_KIND_NAMES = ["rename", "code_actions"] as const;
36
+
37
+ const LookupKindEnum = StringEnum(LSP_LOOKUP_KIND_NAMES);
38
+ const RefactorKindEnum = StringEnum(LSP_REFACTOR_KIND_NAMES);
39
+
40
+ const LookupParameters = Type.Object(
41
+ {
42
+ kind: LookupKindEnum,
43
+ file: FileParam,
44
+ line: LineParam,
45
+ character: CharacterParam,
46
+ },
47
+ { additionalProperties: false },
48
+ );
49
+
50
+ const DocumentSymbolsParameters = Type.Object(
51
+ {
52
+ file: FileParam,
53
+ },
54
+ { additionalProperties: false },
55
+ );
56
+
57
+ const WorkspaceSymbolsParameters = Type.Object(
58
+ {
59
+ query: QueryParam,
60
+ },
61
+ { additionalProperties: false },
62
+ );
63
+
64
+ const DiagnosticsParameters = Type.Object(
65
+ {
66
+ file: Type.Optional(FileParam),
67
+ },
68
+ { additionalProperties: false },
69
+ );
70
+
71
+ const RefactorParameters = Type.Object(
72
+ {
73
+ kind: RefactorKindEnum,
74
+ file: FileParam,
75
+ line: LineParam,
76
+ character: CharacterParam,
77
+ newName: Type.Optional(NewNameParam),
78
+ },
79
+ { additionalProperties: false },
80
+ );
81
+
82
+ const RecoverParameters = Type.Object({}, { additionalProperties: false });
83
+
84
+ export interface LspToolDefinitionSpec {
85
+ name: LspToolName;
86
+ label: string;
87
+ description: string;
88
+ promptSnippet: string;
89
+ basePromptGuidelines: string[];
90
+ parameters: TSchema;
91
+ run: (service: SessionLspService, cwd: string, params: unknown) => Promise<string>;
92
+ includeCoverageGuidelines?: boolean;
93
+ }
94
+
95
+ export const LSP_TOOL_DEFINITION_SPECS = [
96
+ {
97
+ name: LSP_LOOKUP_TOOL,
98
+ label: "LSP Lookup",
99
+ description:
100
+ "Language Server Protocol lookup tool — semantic hover, definition, references, and implementation for supported files. Use lsp_lookup when you know the file and 1-based line/character position and need semantic drill-down rather than text search.",
101
+ promptSnippet:
102
+ "lsp_lookup — semantic hover/definition/references/implementation at a known file position",
103
+ basePromptGuidelines: [
104
+ 'Use lsp_lookup with `kind: "hover"` for semantic type or symbol information at a known `file`, `line`, and `character`.',
105
+ 'Use lsp_lookup with `kind: "definition"`, `"references"`, or `"implementation"` for semantic navigation at a known position.',
106
+ "Use lsp_lookup after code_intel or tree_sitter has already narrowed the target file and position.",
107
+ ],
108
+ parameters: LookupParameters,
109
+ run: (service, cwd, params) =>
110
+ executeLookup(service, cwd, params as Parameters<typeof executeLookup>[2]),
111
+ includeCoverageGuidelines: true,
112
+ },
113
+ {
114
+ name: LSP_DOCUMENT_SYMBOLS_TOOL,
115
+ label: "LSP Document Symbols",
116
+ description:
117
+ "Language Server Protocol document symbols tool — list semantic declarations in one supported file. Use lsp_document_symbols when you need a symbol-aware outline rather than raw text structure.",
118
+ promptSnippet: "lsp_document_symbols — semantic declarations for one supported file",
119
+ basePromptGuidelines: [
120
+ "Use lsp_document_symbols(file) for semantic declarations in one supported file.",
121
+ ],
122
+ parameters: DocumentSymbolsParameters,
123
+ run: (service, cwd, params) =>
124
+ executeDocumentSymbols(service, cwd, params as Parameters<typeof executeDocumentSymbols>[2]),
125
+ },
126
+ {
127
+ name: LSP_WORKSPACE_SYMBOLS_TOOL,
128
+ label: "LSP Workspace Symbols",
129
+ description:
130
+ "Language Server Protocol workspace symbols tool — semantic symbol-name lookup across the current project. Use lsp_workspace_symbols to find declarations by name before opening a specific file.",
131
+ promptSnippet: "lsp_workspace_symbols — semantic symbol-name lookup across the project",
132
+ basePromptGuidelines: [
133
+ "Use lsp_workspace_symbols(query) for semantic symbol-name lookup across the current project.",
134
+ ],
135
+ parameters: WorkspaceSymbolsParameters,
136
+ run: (service, cwd, params) =>
137
+ executeWorkspaceSymbols(
138
+ service,
139
+ cwd,
140
+ params as Parameters<typeof executeWorkspaceSymbols>[2],
141
+ ),
142
+ },
143
+ {
144
+ name: LSP_DIAGNOSTICS_TOOL,
145
+ label: "LSP Diagnostics",
146
+ description:
147
+ "Language Server Protocol diagnostics tool — current diagnostics for one file or a workspace summary. Use lsp_diagnostics for semantic compiler or language-server issues instead of guessing from text alone.",
148
+ promptSnippet: "lsp_diagnostics — current diagnostics for one file or the workspace",
149
+ basePromptGuidelines: [
150
+ "Use lsp_diagnostics(file?) when you need current diagnostics for one file or a workspace-level summary.",
151
+ ],
152
+ parameters: DiagnosticsParameters,
153
+ run: (service, cwd, params) =>
154
+ executeDiagnostics(service, cwd, params as Parameters<typeof executeDiagnostics>[2]),
155
+ },
156
+ {
157
+ name: LSP_REFACTOR_TOOL,
158
+ label: "LSP Refactor",
159
+ description:
160
+ "Language Server Protocol refactor tool — semantic rename planning and code actions at a known file position. Use lsp_refactor when you need language-server-backed edits or quick-fix suggestions.",
161
+ promptSnippet: "lsp_refactor — semantic rename planning and code actions at a known position",
162
+ basePromptGuidelines: [
163
+ 'Use lsp_refactor with `kind: "rename"` for semantic rename planning at a known `file`, `line`, and `character`.',
164
+ 'Use lsp_refactor with `kind: "code_actions"` for semantic fixes or refactors at a known position.',
165
+ ],
166
+ parameters: RefactorParameters,
167
+ run: (service, cwd, params) =>
168
+ executeRefactor(service, cwd, params as Parameters<typeof executeRefactor>[2]),
169
+ },
170
+ {
171
+ name: LSP_RECOVER_TOOL,
172
+ label: "LSP Recover",
173
+ description:
174
+ "Language Server Protocol recover tool — refresh diagnostics after workspace changes and stale language-server state. Use lsp_recover when new files, generated types, or config updates leave diagnostics out of sync.",
175
+ promptSnippet: "lsp_recover — refresh stale diagnostics after workspace changes",
176
+ basePromptGuidelines: [
177
+ "Use lsp_recover() when diagnostics look stale after workspace-level changes or generated-file updates.",
178
+ ],
179
+ parameters: RecoverParameters,
180
+ run: (service) => executeRecover(service),
181
+ },
182
+ ] as const satisfies readonly LspToolDefinitionSpec[];
183
+
184
+ const LSP_TOOL_SPEC_MAP = new Map<LspToolName, LspToolDefinitionSpec>(
185
+ LSP_TOOL_DEFINITION_SPECS.map((spec) => [spec.name, spec]),
186
+ );
187
+
188
+ export function getLspToolDefinitionSpec(toolName: LspToolName): LspToolDefinitionSpec {
189
+ const spec = LSP_TOOL_SPEC_MAP.get(toolName);
190
+ if (!spec) {
191
+ throw new Error(`Unknown LSP tool: ${toolName}`);
192
+ }
193
+ return spec;
194
+ }
195
+
196
+ interface LspServerSupportedActionSpec {
197
+ label: string;
198
+ isSupported: (capabilities: ServerCapabilities | null | undefined) => boolean;
199
+ }
200
+
201
+ const LSP_SERVER_SUPPORTED_ACTION_SPECS: readonly LspServerSupportedActionSpec[] = [
202
+ {
203
+ label: "diagnostics [optional file]",
204
+ isSupported: () => true,
205
+ },
206
+ {
207
+ label: "hover(file,line,char)",
208
+ isSupported: (capabilities) => Boolean(capabilities?.hoverProvider),
209
+ },
210
+ {
211
+ label: "definition(file,line,char)",
212
+ isSupported: (capabilities) => Boolean(capabilities?.definitionProvider),
213
+ },
214
+ {
215
+ label: "references(file,line,char)",
216
+ isSupported: (capabilities) => Boolean(capabilities?.referencesProvider),
217
+ },
218
+ {
219
+ label: "implementation(file,line,char)",
220
+ isSupported: (capabilities) => Boolean(capabilities?.implementationProvider),
221
+ },
222
+ {
223
+ label: "symbols(file)",
224
+ isSupported: (capabilities) => Boolean(capabilities?.documentSymbolProvider),
225
+ },
226
+ {
227
+ label: "workspace_symbols(query)",
228
+ isSupported: (capabilities) => Boolean(capabilities?.workspaceSymbolProvider),
229
+ },
230
+ {
231
+ label: "rename(file,line,char,newName)",
232
+ isSupported: (capabilities) => Boolean(capabilities?.renameProvider),
233
+ },
234
+ {
235
+ label: "code_actions(file,line,char)",
236
+ isSupported: (capabilities) => Boolean(capabilities?.codeActionProvider),
237
+ },
238
+ ] as const;
239
+
240
+ export function getSupportedLspServerActions(
241
+ capabilities: ServerCapabilities | null | undefined,
242
+ ): string[] {
243
+ if (!capabilities) return [];
244
+
245
+ return LSP_SERVER_SUPPORTED_ACTION_SPECS.filter((spec) => spec.isSupported(capabilities)).map(
246
+ (spec) => spec.label,
247
+ );
248
+ }
package/src/utils.ts CHANGED
@@ -2,41 +2,12 @@
2
2
 
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- // ── URI Handling ──────────────────────────────────────────────────────
6
5
 
7
- /** Convert a file path to a file:// URI. */
8
- export function fileToUri(filePath: string): string {
9
- const resolved = path.resolve(filePath);
10
- if (process.platform === "win32") {
11
- return `file:///${resolved.replace(/\\/g, "/")}`;
12
- }
13
- return `file://${resolved}`;
14
- }
15
-
16
- /** Convert a file:// URI to a file path. */
17
- export function uriToFile(uri: string): string {
18
- if (!uri.startsWith("file://")) return uri;
19
- let filePath = decodeURIComponent(uri.slice(7));
20
- if (
21
- process.platform === "win32" &&
22
- filePath.startsWith("/") &&
23
- /^[A-Za-z]:/.test(filePath.slice(1))
24
- ) {
25
- filePath = filePath.slice(1);
26
- }
27
- return filePath;
28
- }
29
-
30
- /**
31
- * Resolve a tool-style file path against the active session cwd.
32
- *
33
- * Built-in pi file tools accept a leading `@` prefix in path arguments, so LSP
34
- * helpers strip that prefix as well before resolving relative paths.
35
- */
36
- export function resolveSessionPath(cwd: string, filePath: string): string {
37
- const normalizedPath = filePath.startsWith("@") ? filePath.slice(1) : filePath;
38
- return path.resolve(cwd, normalizedPath);
39
- }
6
+ export {
7
+ fileToUri,
8
+ resolveToolPath as resolveSessionPath,
9
+ uriToFile,
10
+ } from "@mrclrchtr/supi-core/api";
40
11
 
41
12
  // ── Language ID Detection ─────────────────────────────────────────────
42
13