feat(coder): add LSP code intelligence tools
- lsp/ module: types, config, JSON-RPC client, server-manager, operations - lsp_diagnostics: TypeScript/JavaScript diagnostics for a file - lsp_goto_definition: find symbol definition at position - lsp_find_references: find all references to a symbol - Registered as READ_TOOLS in tool index
This commit is contained in:
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
interface RpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface RpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
export class LspClient {
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
|
||||
private buffer = '';
|
||||
|
||||
constructor(
|
||||
private stdin: Writable,
|
||||
private stdout: Readable,
|
||||
) {
|
||||
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
|
||||
rl.on('line', (line) => this.handleLine(line));
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
this.buffer += line + '\n';
|
||||
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
|
||||
if (!match || !match[1]) return;
|
||||
const len = parseInt(match[1], 10);
|
||||
const headerEnd = match.index! + match[0].length;
|
||||
const body = this.buffer.slice(headerEnd, headerEnd + len);
|
||||
if (body.length < len) return;
|
||||
this.buffer = this.buffer.slice(headerEnd + len);
|
||||
try {
|
||||
const msg: RpcResponse = JSON.parse(body);
|
||||
const cb = this.pending.get(msg.id);
|
||||
if (cb) {
|
||||
this.pending.delete(msg.id);
|
||||
cb.resolve(msg);
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params?: unknown): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||
const body = JSON.stringify(req);
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: (resp) => {
|
||||
if (resp.error) reject(new Error(resp.error.message));
|
||||
else resolve(resp.result);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
this.stdin.write(header + body);
|
||||
});
|
||||
}
|
||||
|
||||
async notify(method: string, params?: unknown): Promise<void> {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
this.stdin.write(header + body);
|
||||
}
|
||||
}
|
||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface LspServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
rootPatterns: string[];
|
||||
}
|
||||
|
||||
const TS_CONFIG: LspServerConfig = {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
rootPatterns: ['package.json', 'tsconfig.json'],
|
||||
};
|
||||
|
||||
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
|
||||
|
||||
export function getServerConfig(filePath: string): LspServerConfig | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
|
||||
return null;
|
||||
}
|
||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { LspClient } from './client.js';
|
||||
import type { Diagnostic, Location } from './types.js';
|
||||
|
||||
function fileUri(filePath: string): string {
|
||||
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
||||
}
|
||||
|
||||
export async function openDocument(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
version: number = 1,
|
||||
): Promise<void> {
|
||||
const uri = fileUri(filePath);
|
||||
await client.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: 'typescript', version, text: content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
|
||||
await client.notify('textDocument/didClose', {
|
||||
textDocument: { uri: fileUri(filePath) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDiagnostics(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<Diagnostic[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/diagnostic', {
|
||||
textDocument: { uri },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (result?.diagnostics) {
|
||||
for (const d of result.diagnostics) {
|
||||
diagnostics.push({
|
||||
range: d.range,
|
||||
severity: d.severity ?? 1,
|
||||
message: d.message,
|
||||
source: d.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export async function gotoDefinition(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location | null> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/definition', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
if (!result) return null;
|
||||
const loc = Array.isArray(result) ? result[0] : result;
|
||||
return loc ? { uri: loc.uri, range: loc.range } : null;
|
||||
}
|
||||
|
||||
export async function findReferences(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/references', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
context: { includeDeclaration: true },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
|
||||
}
|
||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { LspClient } from './client.js';
|
||||
import { getServerConfig } from './config.js';
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
|
||||
interface LspInstance {
|
||||
client: LspClient;
|
||||
proc: ChildProcess;
|
||||
lastUsed: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class LspServerManager {
|
||||
private instances = new Map<string, LspInstance>();
|
||||
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startSweeper();
|
||||
}
|
||||
|
||||
private startSweeper(): void {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||
this.sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
private findProjectRoot(filePath: string): string | null {
|
||||
let dir = filePath;
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
while (true) {
|
||||
for (const pattern of config.rootPatterns) {
|
||||
if (existsSync(join(dir, pattern))) return dir;
|
||||
}
|
||||
const parent = join(dir, '..');
|
||||
if (parent === dir) return dir;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(filePath: string): Promise<LspClient | null> {
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
const projectRoot = this.findProjectRoot(filePath);
|
||||
if (!projectRoot) return null;
|
||||
|
||||
const existing = this.instances.get(projectRoot);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
clearTimeout(existing.timer);
|
||||
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
existing.timer.unref?.();
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
return this.spawn(projectRoot, config.command, config.args);
|
||||
}
|
||||
|
||||
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
||||
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
||||
const client = new LspClient(proc.stdin!, proc.stdout!);
|
||||
|
||||
await client.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: `file://${projectRoot}`,
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
diagnostic: { dynamicRegistration: false },
|
||||
definition: { dynamicRegistration: false },
|
||||
references: { dynamicRegistration: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify('initialized', {});
|
||||
|
||||
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
||||
proc.on('exit', () => this.instances.delete(projectRoot));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private kill(projectRoot: string): void {
|
||||
const inst = this.instances.get(projectRoot);
|
||||
if (!inst) return;
|
||||
this.instances.delete(projectRoot);
|
||||
inst.proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [root, inst] of this.instances) {
|
||||
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||
for (const root of [...this.instances.keys()]) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.instances.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const lspManager = new LspServerManager();
|
||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity: number;
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TextDocumentItem {
|
||||
uri: string;
|
||||
languageId: string;
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
Reference in New Issue
Block a user