summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/tool.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/lsp/src/tool.ts')
-rw-r--r--packages/lsp/src/tool.ts466
1 files changed, 233 insertions, 233 deletions
diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts
index be0d269..bd10a82 100644
--- a/packages/lsp/src/tool.ts
+++ b/packages/lsp/src/tool.ts
@@ -14,250 +14,250 @@ type Operation = "diagnostics" | "hover" | "definition" | "references" | "docume
const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]);
interface ValidatedArgs {
- readonly operation: Operation;
- readonly path: string;
- readonly line?: number | undefined;
- readonly character?: number | undefined;
+ readonly operation: Operation;
+ readonly path: string;
+ readonly line?: number | undefined;
+ readonly character?: number | undefined;
}
function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs {
- if (args === null || args === undefined || typeof args !== "object") {
- return { error: "Error: Arguments must be an object." };
- }
- const obj = args as Record<string, unknown>;
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
- const rawOp = obj.operation;
- if (typeof rawOp !== "string") {
- return { error: 'Error: Missing "operation" parameter (must be a string).' };
- }
- const validOps: ReadonlySet<string> = new Set([
- "diagnostics",
- "hover",
- "definition",
- "references",
- "documentSymbol",
- ]);
- if (!validOps.has(rawOp)) {
- return {
- error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`,
- };
- }
- const operation = rawOp as Operation;
+ const rawOp = obj.operation;
+ if (typeof rawOp !== "string") {
+ return { error: 'Error: Missing "operation" parameter (must be a string).' };
+ }
+ const validOps: ReadonlySet<string> = new Set([
+ "diagnostics",
+ "hover",
+ "definition",
+ "references",
+ "documentSymbol",
+ ]);
+ if (!validOps.has(rawOp)) {
+ return {
+ error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`,
+ };
+ }
+ const operation = rawOp as Operation;
- const rawPath = obj.path;
- if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
- return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' };
- }
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
+ return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' };
+ }
- let line: number | undefined;
- let character: number | undefined;
+ let line: number | undefined;
+ let character: number | undefined;
- if (obj.line !== undefined) {
- const n = Number(obj.line);
- if (!Number.isFinite(n) || n < 1) {
- return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' };
- }
- line = Math.floor(n);
- }
+ if (obj.line !== undefined) {
+ const n = Number(obj.line);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' };
+ }
+ line = Math.floor(n);
+ }
- if (obj.character !== undefined) {
- const n = Number(obj.character);
- if (!Number.isFinite(n) || n < 1) {
- return {
- error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).',
- };
- }
- character = Math.floor(n);
- }
+ if (obj.character !== undefined) {
+ const n = Number(obj.character);
+ if (!Number.isFinite(n) || n < 1) {
+ return {
+ error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).',
+ };
+ }
+ character = Math.floor(n);
+ }
- const result: ValidatedArgs = { operation, path: rawPath };
- if (line !== undefined) {
- (result as { line?: number }).line = line;
- }
- if (character !== undefined) {
- (result as { character?: number }).character = character;
- }
- return result;
+ const result: ValidatedArgs = { operation, path: rawPath };
+ if (line !== undefined) {
+ (result as { line?: number }).line = line;
+ }
+ if (character !== undefined) {
+ (result as { character?: number }).character = character;
+ }
+ return result;
}
function resolveFilePath(filePath: string, cwd: string): string {
- const resolved = resolve(cwd, filePath);
- const normalizedCwd = resolve(cwd);
- if (!resolved.startsWith(normalizedCwd)) {
- return normalizedCwd;
- }
- return resolved;
+ const resolved = resolve(cwd, filePath);
+ const normalizedCwd = resolve(cwd);
+ if (!resolved.startsWith(normalizedCwd)) {
+ return normalizedCwd;
+ }
+ return resolved;
}
/** Convert validated 1-based line/character to an LSP 0-based position. */
function toPosition(
- line: number | undefined,
- character: number | undefined,
+ line: number | undefined,
+ character: number | undefined,
): { readonly line: number; readonly character: number } {
- if (line === undefined || character === undefined) {
- throw new Error("Position operations require both line and character.");
- }
- return { line: line - 1, character: character - 1 };
+ if (line === undefined || character === undefined) {
+ throw new Error("Position operations require both line and character.");
+ }
+ return { line: line - 1, character: character - 1 };
}
export function createLspTool(manager: LspManager): ToolContract {
- return {
- name: "lsp",
- description:
- "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.",
- parameters: {
- type: "object",
- properties: {
- operation: {
- type: "string",
- enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"],
- description: "The LSP operation to perform.",
- },
- path: {
- type: "string",
- description: "File path relative to the workspace.",
- },
- line: {
- type: "number",
- description: "Line number (1-based). Required for hover, definition, references.",
- },
- character: {
- type: "number",
- description: "Character position (1-based). Required for hover, definition, references.",
- },
- },
- required: ["operation", "path"],
- },
- concurrencySafe: true,
- async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
- const validated = validateArgs(args);
- if ("error" in validated) {
- return { content: validated.error, isError: true };
- }
+ return {
+ name: "lsp",
+ description:
+ "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.",
+ parameters: {
+ type: "object",
+ properties: {
+ operation: {
+ type: "string",
+ enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"],
+ description: "The LSP operation to perform.",
+ },
+ path: {
+ type: "string",
+ description: "File path relative to the workspace.",
+ },
+ line: {
+ type: "number",
+ description: "Line number (1-based). Required for hover, definition, references.",
+ },
+ character: {
+ type: "number",
+ description: "Character position (1-based). Required for hover, definition, references.",
+ },
+ },
+ required: ["operation", "path"],
+ },
+ concurrencySafe: true,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
- const { operation, path: filePath, line, character } = validated;
- const cwd = ctx.cwd ?? process.cwd();
- const absolutePath = resolveFilePath(filePath, cwd);
+ const { operation, path: filePath, line, character } = validated;
+ const cwd = ctx.cwd ?? process.cwd();
+ const absolutePath = resolveFilePath(filePath, cwd);
- if (POSITION_OPS.has(operation)) {
- if (line === undefined || character === undefined) {
- return {
- content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`,
- isError: true,
- };
- }
- }
+ if (POSITION_OPS.has(operation)) {
+ if (line === undefined || character === undefined) {
+ return {
+ content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`,
+ isError: true,
+ };
+ }
+ }
- try {
- const statuses = await manager.status(cwd);
- if (statuses.length === 0) {
- return { content: "No language server configured for this workspace.", isError: true };
- }
+ try {
+ const statuses = await manager.status(cwd);
+ if (statuses.length === 0) {
+ return { content: "No language server configured for this workspace.", isError: true };
+ }
- const fileExt = extname(absolutePath).toLowerCase();
+ const fileExt = extname(absolutePath).toLowerCase();
- switch (operation) {
- case "diagnostics": {
- // 10s hard ceiling per server (same policy as the edit path).
- const DIAGNOSTICS_TIMEOUT_MS = 10_000;
- // Query ALL connected servers whose extensions match this file.
- const matching = statuses.filter(
- (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt),
- );
+ switch (operation) {
+ case "diagnostics": {
+ // 10s hard ceiling per server (same policy as the edit path).
+ const DIAGNOSTICS_TIMEOUT_MS = 10_000;
+ // Query ALL connected servers whose extensions match this file.
+ const matching = statuses.filter(
+ (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt),
+ );
- if (matching.length === 0) {
- // No matching server — fall back to any connected server.
- const connected = statuses.find((s) => s.state === "connected");
- if (!connected) {
- const first = statuses[0];
- const detail = first
- ? `"${first.name}" is not connected (state: ${first.state})`
- : "is not connected";
- return {
- content: `Language server ${detail}.`,
- isError: true,
- };
- }
- const client = manager.getClient(connected.id, connected.root);
- if (!client) {
- return { content: "Language server client not available.", isError: true };
- }
- const result = await client.waitForDiagnostics(absolutePath, {
- timeoutMs: DIAGNOSTICS_TIMEOUT_MS,
- });
- if (result.timedOut) {
- return {
- content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`,
- };
- }
- return { content: result.formatted || "No diagnostics found." };
- }
+ if (matching.length === 0) {
+ // No matching server — fall back to any connected server.
+ const connected = statuses.find((s) => s.state === "connected");
+ if (!connected) {
+ const first = statuses[0];
+ const detail = first
+ ? `"${first.name}" is not connected (state: ${first.state})`
+ : "is not connected";
+ return {
+ content: `Language server ${detail}.`,
+ isError: true,
+ };
+ }
+ const client = manager.getClient(connected.id, connected.root);
+ if (!client) {
+ return { content: "Language server client not available.", isError: true };
+ }
+ const result = await client.waitForDiagnostics(absolutePath, {
+ timeoutMs: DIAGNOSTICS_TIMEOUT_MS,
+ });
+ if (result.timedOut) {
+ return {
+ content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`,
+ };
+ }
+ return { content: result.formatted || "No diagnostics found." };
+ }
- // Query matching servers concurrently, each capped at 10s;
- // a non-responding server is skipped with a notice.
- const agg = await aggregateDiagnostics(
- (id, root) => manager.getClient(id, root),
- matching,
- absolutePath,
- DIAGNOSTICS_TIMEOUT_MS,
- {},
- );
- return { content: agg.formatted || "No diagnostics found." };
- }
- case "hover": {
- const client = await getFirstMatchingClient(manager, statuses, fileExt);
- if (!client) return { content: "No language server available.", isError: true };
- const result = await client.request("textDocument/hover", {
- textDocument: { uri: `file://${absolutePath}` },
- position: toPosition(line, character),
- });
- if (!result) return { content: "No hover information available." };
- const hover = result as { readonly contents?: { readonly value?: string } | string };
- const content =
- typeof hover.contents === "string"
- ? hover.contents
- : (hover.contents?.value ?? "No hover information available.");
- return { content };
- }
- case "definition": {
- const client = await getFirstMatchingClient(manager, statuses, fileExt);
- if (!client) return { content: "No language server available.", isError: true };
- const result = await client.request("textDocument/definition", {
- textDocument: { uri: `file://${absolutePath}` },
- position: toPosition(line, character),
- });
- if (!result) return { content: "No definition found." };
- return { content: JSON.stringify(result) };
- }
- case "references": {
- const client = await getFirstMatchingClient(manager, statuses, fileExt);
- if (!client) return { content: "No language server available.", isError: true };
- const result = await client.request("textDocument/references", {
- textDocument: { uri: `file://${absolutePath}` },
- position: toPosition(line, character),
- context: { includeDeclaration: true },
- });
- if (!result) return { content: "No references found." };
- return { content: JSON.stringify(result) };
- }
- case "documentSymbol": {
- const client = await getFirstMatchingClient(manager, statuses, fileExt);
- if (!client) return { content: "No language server available.", isError: true };
- const result = await client.request("textDocument/documentSymbol", {
- textDocument: { uri: `file://${absolutePath}` },
- });
- if (!result) return { content: "No symbols found." };
- return { content: JSON.stringify(result) };
- }
- }
- } catch (err: unknown) {
- return {
- content: `Error: ${err instanceof Error ? err.message : String(err)}`,
- isError: true,
- };
- }
- },
- };
+ // Query matching servers concurrently, each capped at 10s;
+ // a non-responding server is skipped with a notice.
+ const agg = await aggregateDiagnostics(
+ (id, root) => manager.getClient(id, root),
+ matching,
+ absolutePath,
+ DIAGNOSTICS_TIMEOUT_MS,
+ {},
+ );
+ return { content: agg.formatted || "No diagnostics found." };
+ }
+ case "hover": {
+ const client = await getFirstMatchingClient(manager, statuses, fileExt);
+ if (!client) return { content: "No language server available.", isError: true };
+ const result = await client.request("textDocument/hover", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No hover information available." };
+ const hover = result as { readonly contents?: { readonly value?: string } | string };
+ const content =
+ typeof hover.contents === "string"
+ ? hover.contents
+ : (hover.contents?.value ?? "No hover information available.");
+ return { content };
+ }
+ case "definition": {
+ const client = await getFirstMatchingClient(manager, statuses, fileExt);
+ if (!client) return { content: "No language server available.", isError: true };
+ const result = await client.request("textDocument/definition", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No definition found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "references": {
+ const client = await getFirstMatchingClient(manager, statuses, fileExt);
+ if (!client) return { content: "No language server available.", isError: true };
+ const result = await client.request("textDocument/references", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ context: { includeDeclaration: true },
+ });
+ if (!result) return { content: "No references found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "documentSymbol": {
+ const client = await getFirstMatchingClient(manager, statuses, fileExt);
+ if (!client) return { content: "No language server available.", isError: true };
+ const result = await client.request("textDocument/documentSymbol", {
+ textDocument: { uri: `file://${absolutePath}` },
+ });
+ if (!result) return { content: "No symbols found." };
+ return { content: JSON.stringify(result) };
+ }
+ }
+ } catch (err: unknown) {
+ return {
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ },
+ };
}
/**
@@ -266,22 +266,22 @@ export function createLspTool(manager: LspManager): ToolContract {
* Used by hover/definition/references/documentSymbol (single-server ops).
*/
async function getFirstMatchingClient(
- manager: LspManager,
- statuses: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: string;
- }[],
- fileExt: string,
+ manager: LspManager,
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: string;
+ }[],
+ fileExt: string,
): Promise<
- { readonly request: (method: string, params?: unknown) => Promise<unknown> } | undefined
+ { readonly request: (method: string, params?: unknown) => Promise<unknown> } | undefined
> {
- const matching = statuses.filter(
- (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt),
- );
- const target = matching[0] ?? statuses.find((s) => s.state === "connected");
- if (!target) return undefined;
- return manager.getClient(target.id, target.root);
+ const matching = statuses.filter(
+ (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt),
+ );
+ const target = matching[0] ?? statuses.find((s) => s.state === "connected");
+ if (!target) return undefined;
+ return manager.getClient(target.id, target.root);
}