@elyracode/lsp-typescript 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## [0.7.7] - 2026-05-23
4
+
5
+ ### Added
6
+ - Initial release with LSP tools: `lsp_definitions`, `lsp_references`, `lsp_diagnostics`, `lsp_hover`
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @elyracode/lsp-typescript
2
+
3
+ TypeScript LSP integration for Elyra. Starts a `typescript-language-server` process per session and exposes semantic code navigation and diagnostics as agent tools.
4
+
5
+ ## Prerequisites
6
+
7
+ The project must have both packages installed:
8
+
9
+ ```bash
10
+ npm install -D typescript typescript-language-server
11
+ ```
12
+
13
+ The extension looks for `typescript-language-server` in the project's `node_modules/.bin/` first, then falls back to a global installation.
14
+
15
+ A `tsconfig.json` must exist in the project root. The extension does nothing if one is not found.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ elyra install @elyracode/lsp-typescript
21
+ ```
22
+
23
+ ## Available Tools
24
+
25
+ | Tool | Description |
26
+ |------|-------------|
27
+ | `lsp_definitions` | Go to the definition of a symbol at a given file position |
28
+ | `lsp_references` | Find all references to a symbol across the project |
29
+ | `lsp_diagnostics` | Get TypeScript errors and warnings for a file |
30
+ | `lsp_hover` | Get type information and documentation for a symbol |
31
+
32
+ All tools accept 1-based line and column numbers and return human-readable text results.
@@ -0,0 +1,462 @@
1
+ import type { ExtensionAPI } from "@elyracode/coding-agent";
2
+ import { type ChildProcess, execSync, spawn } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { Type } from "typebox";
6
+
7
+ // ── LSP Types ───────────────────────────────────────────────────────────────
8
+
9
+ interface LspPosition {
10
+ line: number;
11
+ character: number;
12
+ }
13
+
14
+ interface LspRange {
15
+ start: LspPosition;
16
+ end: LspPosition;
17
+ }
18
+
19
+ interface LspLocation {
20
+ uri: string;
21
+ range: LspRange;
22
+ }
23
+
24
+ interface LspDiagnostic {
25
+ range: LspRange;
26
+ severity?: number;
27
+ message: string;
28
+ source?: string;
29
+ code?: string | number;
30
+ }
31
+
32
+ interface LspHoverResult {
33
+ contents: string | { kind: string; value: string } | Array<string | { kind: string; value: string }>;
34
+ range?: LspRange;
35
+ }
36
+
37
+ interface PendingRequest {
38
+ resolve: (value: unknown) => void;
39
+ reject: (reason: unknown) => void;
40
+ }
41
+
42
+ // ── Helpers ─────────────────────────────────────────────────────────────────
43
+
44
+ const SEVERITY_LABELS: Record<number, string> = {
45
+ 1: "Error",
46
+ 2: "Warning",
47
+ 3: "Information",
48
+ 4: "Hint",
49
+ };
50
+
51
+ function fileUri(filePath: string): string {
52
+ return `file://${filePath}`;
53
+ }
54
+
55
+ function uriToPath(uri: string): string {
56
+ return uri.replace(/^file:\/\//, "");
57
+ }
58
+
59
+ function positionFromLineCol(line: number, col: number): LspPosition {
60
+ return { line: line - 1, character: col - 1 };
61
+ }
62
+
63
+ function languageIdForPath(filePath: string): string {
64
+ if (filePath.endsWith(".tsx")) return "typescriptreact";
65
+ if (filePath.endsWith(".jsx")) return "javascriptreact";
66
+ if (filePath.endsWith(".js") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs")) return "javascript";
67
+ return "typescript";
68
+ }
69
+
70
+ function findBinary(workingDir: string): string | undefined {
71
+ const local = join(workingDir, "node_modules", ".bin", "typescript-language-server");
72
+ if (existsSync(local)) return local;
73
+
74
+ try {
75
+ const global = execSync("which typescript-language-server", {
76
+ encoding: "utf-8",
77
+ timeout: 5000,
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ }).trim();
80
+ if (global) return global;
81
+ } catch {
82
+ // not found globally
83
+ }
84
+
85
+ return undefined;
86
+ }
87
+
88
+ // ── Extension ───────────────────────────────────────────────────────────────
89
+
90
+ export default function (elyra: ExtensionAPI): void {
91
+ let lspProcess: ChildProcess | null = null;
92
+ let requestId = 0;
93
+ const pendingRequests = new Map<number, PendingRequest>();
94
+ let buffer = Buffer.alloc(0);
95
+ let initialized = false;
96
+ let cwd = "";
97
+ const openedFiles = new Set<string>();
98
+ const diagnosticsByUri = new Map<string, LspDiagnostic[]>();
99
+
100
+ // ── JSON-RPC Client ─────────────────────────────────────────────────
101
+
102
+ function sendRequest(method: string, params: unknown): Promise<unknown> {
103
+ if (!lspProcess?.stdin) {
104
+ return Promise.reject(new Error("LSP server not running"));
105
+ }
106
+ const id = ++requestId;
107
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
108
+ const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
109
+ lspProcess.stdin.write(message);
110
+
111
+ return new Promise<unknown>((res, rej) => {
112
+ const timer = setTimeout(() => {
113
+ pendingRequests.delete(id);
114
+ rej(new Error(`LSP request "${method}" timed out after 10s`));
115
+ }, 10_000);
116
+
117
+ pendingRequests.set(id, {
118
+ resolve: (value: unknown) => {
119
+ clearTimeout(timer);
120
+ res(value);
121
+ },
122
+ reject: (reason: unknown) => {
123
+ clearTimeout(timer);
124
+ rej(reason);
125
+ },
126
+ });
127
+ });
128
+ }
129
+
130
+ function sendNotification(method: string, params: unknown): void {
131
+ if (!lspProcess?.stdin) return;
132
+ const body = JSON.stringify({ jsonrpc: "2.0", method, params });
133
+ const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
134
+ lspProcess.stdin.write(message);
135
+ }
136
+
137
+ function handleData(chunk: Buffer): void {
138
+ buffer = Buffer.concat([buffer, chunk]);
139
+
140
+ for (;;) {
141
+ const headerEnd = buffer.indexOf("\r\n\r\n");
142
+ if (headerEnd === -1) break;
143
+
144
+ const header = buffer.subarray(0, headerEnd).toString("utf-8");
145
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
146
+ if (!match) {
147
+ buffer = buffer.subarray(headerEnd + 4);
148
+ continue;
149
+ }
150
+
151
+ const contentLength = parseInt(match[1], 10);
152
+ const bodyStart = headerEnd + 4;
153
+ if (buffer.length < bodyStart + contentLength) break;
154
+
155
+ const bodyStr = buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf-8");
156
+ buffer = buffer.subarray(bodyStart + contentLength);
157
+
158
+ let parsed: unknown;
159
+ try {
160
+ parsed = JSON.parse(bodyStr);
161
+ } catch {
162
+ continue;
163
+ }
164
+
165
+ if (!parsed || typeof parsed !== "object") continue;
166
+ const msg = parsed as Record<string, unknown>;
167
+
168
+ // Response to a request
169
+ if ("id" in msg && typeof msg.id === "number") {
170
+ const pending = pendingRequests.get(msg.id);
171
+ if (pending) {
172
+ pendingRequests.delete(msg.id);
173
+ if ("error" in msg && msg.error) {
174
+ const err = msg.error as { code: number; message: string };
175
+ pending.reject(new Error(`LSP error ${err.code}: ${err.message}`));
176
+ } else {
177
+ pending.resolve(msg.result);
178
+ }
179
+ }
180
+ }
181
+
182
+ // Server notification
183
+ if ("method" in msg && typeof msg.method === "string") {
184
+ if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
185
+ const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] };
186
+ diagnosticsByUri.set(p.uri, p.diagnostics);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ // ── File helpers ────────────────────────────────────────────────────
193
+
194
+ function openFile(filePath: string): void {
195
+ const absPath = resolve(cwd, filePath);
196
+ const uri = fileUri(absPath);
197
+ if (openedFiles.has(uri)) return;
198
+
199
+ let text: string;
200
+ try {
201
+ text = readFileSync(absPath, "utf-8");
202
+ } catch {
203
+ return;
204
+ }
205
+
206
+ sendNotification("textDocument/didOpen", {
207
+ textDocument: {
208
+ uri,
209
+ languageId: languageIdForPath(filePath),
210
+ version: 1,
211
+ text,
212
+ },
213
+ });
214
+ openedFiles.add(uri);
215
+ }
216
+
217
+ function formatLocations(result: unknown, workingDir: string): string {
218
+ if (!result) return "No results found.";
219
+
220
+ const locations: LspLocation[] = Array.isArray(result) ? result : [result as LspLocation];
221
+ if (locations.length === 0) return "No results found.";
222
+
223
+ const prefix = workingDir + "/";
224
+ return locations
225
+ .map((loc) => {
226
+ const p = uriToPath(loc.uri);
227
+ const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p;
228
+ return `${rel}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
229
+ })
230
+ .join("\n");
231
+ }
232
+
233
+ // ── Lifecycle ───────────────────────────────────────────────────────
234
+
235
+ elyra.on("session_start", async (_event, ctx) => {
236
+ cwd = ctx.cwd;
237
+
238
+ if (!existsSync(join(cwd, "tsconfig.json"))) return;
239
+
240
+ const binary = findBinary(cwd);
241
+ if (!binary) return;
242
+
243
+ lspProcess = spawn(binary, ["--stdio"], { cwd });
244
+
245
+ lspProcess.stdout?.on("data", (chunk: Buffer) => handleData(chunk));
246
+ lspProcess.stderr?.on("data", () => {
247
+ /* ignore stderr */
248
+ });
249
+ lspProcess.on("exit", () => {
250
+ lspProcess = null;
251
+ initialized = false;
252
+ });
253
+
254
+ try {
255
+ await sendRequest("initialize", {
256
+ processId: process.pid,
257
+ capabilities: {},
258
+ rootUri: fileUri(cwd),
259
+ workspaceFolders: [{ uri: fileUri(cwd), name: "workspace" }],
260
+ });
261
+ sendNotification("initialized", {});
262
+ initialized = true;
263
+ } catch {
264
+ lspProcess?.kill();
265
+ lspProcess = null;
266
+ return;
267
+ }
268
+
269
+ // ── Tool: lsp_definitions ───────────────────────────────────────
270
+
271
+ elyra.registerTool({
272
+ name: "lsp_definitions",
273
+ label: "LSP Go to Definition",
274
+ description:
275
+ "Go to the definition of a symbol at a given position. Returns the file path and " +
276
+ "line where the symbol is defined. More precise than grep for finding where functions, " +
277
+ "classes, types, and variables are declared.",
278
+ promptSnippet: "Go to definition of a symbol at a file position",
279
+ parameters: Type.Object({
280
+ file: Type.String({ description: "Relative file path from project root" }),
281
+ line: Type.Number({ description: "Line number (1-based)" }),
282
+ column: Type.Number({ description: "Column number (1-based)" }),
283
+ }),
284
+ execute: async (_toolCallId, params) => {
285
+ if (!initialized) {
286
+ return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} };
287
+ }
288
+
289
+ const absPath = resolve(cwd, params.file);
290
+ openFile(params.file);
291
+
292
+ try {
293
+ const result = await sendRequest("textDocument/definition", {
294
+ textDocument: { uri: fileUri(absPath) },
295
+ position: positionFromLineCol(params.line, params.column),
296
+ });
297
+ return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} };
298
+ } catch (err) {
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
301
+ }
302
+ },
303
+ });
304
+
305
+ // ── Tool: lsp_references ────────────────────────────────────────
306
+
307
+ elyra.registerTool({
308
+ name: "lsp_references",
309
+ label: "LSP Find References",
310
+ description:
311
+ "Find all references to a symbol at a given position. Returns every location where " +
312
+ "the symbol is used across the project. More precise than grep for understanding usage patterns.",
313
+ promptSnippet: "Find all references to a symbol at a file position",
314
+ parameters: Type.Object({
315
+ file: Type.String({ description: "Relative file path from project root" }),
316
+ line: Type.Number({ description: "Line number (1-based)" }),
317
+ column: Type.Number({ description: "Column number (1-based)" }),
318
+ }),
319
+ execute: async (_toolCallId, params) => {
320
+ if (!initialized) {
321
+ return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} };
322
+ }
323
+
324
+ const absPath = resolve(cwd, params.file);
325
+ openFile(params.file);
326
+
327
+ try {
328
+ const result = await sendRequest("textDocument/references", {
329
+ textDocument: { uri: fileUri(absPath) },
330
+ position: positionFromLineCol(params.line, params.column),
331
+ context: { includeDeclaration: true },
332
+ });
333
+ return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} };
334
+ } catch (err) {
335
+ const message = err instanceof Error ? err.message : String(err);
336
+ return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
337
+ }
338
+ },
339
+ });
340
+
341
+ // ── Tool: lsp_diagnostics ───────────────────────────────────────
342
+
343
+ elyra.registerTool({
344
+ name: "lsp_diagnostics",
345
+ label: "LSP Diagnostics",
346
+ description:
347
+ "Get TypeScript compilation errors and warnings for a file without running the full " +
348
+ "type checker. Returns diagnostics with line numbers, severity, and messages.",
349
+ promptSnippet: "Get TypeScript errors and warnings for a file",
350
+ parameters: Type.Object({
351
+ file: Type.String({ description: "Relative file path from project root" }),
352
+ }),
353
+ execute: async (_toolCallId, params) => {
354
+ if (!initialized) {
355
+ return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} };
356
+ }
357
+
358
+ const absPath = resolve(cwd, params.file);
359
+ openFile(params.file);
360
+
361
+ const uri = fileUri(absPath);
362
+
363
+ // Allow the server time to publish diagnostics after opening the file
364
+ await new Promise<void>((r) => setTimeout(r, 1000));
365
+
366
+ const diagnostics = diagnosticsByUri.get(uri) ?? [];
367
+ if (diagnostics.length === 0) {
368
+ return { content: [{ type: "text", text: "No diagnostics found." }], details: {} };
369
+ }
370
+
371
+ const lines = diagnostics.map((d) => {
372
+ const severity = SEVERITY_LABELS[d.severity ?? 1] ?? "Unknown";
373
+ const loc = `${params.file}:${d.range.start.line + 1}:${d.range.start.character + 1}`;
374
+ const code = d.code ? ` [${d.code}]` : "";
375
+ return `${severity}${code} ${loc}: ${d.message}`;
376
+ });
377
+
378
+ return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
379
+ },
380
+ });
381
+
382
+ // ── Tool: lsp_hover ─────────────────────────────────────────────
383
+
384
+ elyra.registerTool({
385
+ name: "lsp_hover",
386
+ label: "LSP Hover",
387
+ description:
388
+ "Get type information and documentation for a symbol at a given position. Shows the " +
389
+ "resolved type signature and JSDoc comments.",
390
+ promptSnippet: "Get type info and docs for a symbol at a file position",
391
+ parameters: Type.Object({
392
+ file: Type.String({ description: "Relative file path from project root" }),
393
+ line: Type.Number({ description: "Line number (1-based)" }),
394
+ column: Type.Number({ description: "Column number (1-based)" }),
395
+ }),
396
+ execute: async (_toolCallId, params) => {
397
+ if (!initialized) {
398
+ return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} };
399
+ }
400
+
401
+ const absPath = resolve(cwd, params.file);
402
+ openFile(params.file);
403
+
404
+ try {
405
+ const result = await sendRequest("textDocument/hover", {
406
+ textDocument: { uri: fileUri(absPath) },
407
+ position: positionFromLineCol(params.line, params.column),
408
+ });
409
+
410
+ if (!result) {
411
+ return { content: [{ type: "text", text: "No hover information available." }], details: {} };
412
+ }
413
+
414
+ const hover = result as LspHoverResult;
415
+ let text: string;
416
+
417
+ if (typeof hover.contents === "string") {
418
+ text = hover.contents;
419
+ } else if (Array.isArray(hover.contents)) {
420
+ text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n\n");
421
+ } else {
422
+ text = hover.contents.value;
423
+ }
424
+
425
+ return {
426
+ content: [{ type: "text", text: text || "No hover information available." }],
427
+ details: {},
428
+ };
429
+ } catch (err) {
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
432
+ }
433
+ },
434
+ });
435
+ });
436
+
437
+ // ── Shutdown ────────────────────────────────────────────────────────
438
+
439
+ elyra.on("session_shutdown", async () => {
440
+ if (lspProcess && initialized) {
441
+ try {
442
+ await sendRequest("shutdown", null);
443
+ sendNotification("exit", null);
444
+ } catch {
445
+ // ignore errors during shutdown
446
+ }
447
+ }
448
+ if (lspProcess) {
449
+ lspProcess.kill();
450
+ lspProcess = null;
451
+ }
452
+ initialized = false;
453
+ openedFiles.clear();
454
+ diagnosticsByUri.clear();
455
+ buffer = Buffer.alloc(0);
456
+ requestId = 0;
457
+ for (const pending of pendingRequests.values()) {
458
+ pending.reject(new Error("LSP server shutting down"));
459
+ }
460
+ pendingRequests.clear();
461
+ });
462
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@elyracode/lsp-typescript",
3
+ "version": "0.7.7",
4
+ "description": "TypeScript LSP integration for Elyra — semantic code navigation and diagnostics",
5
+ "type": "module",
6
+ "keywords": [
7
+ "elyra-package",
8
+ "lsp",
9
+ "typescript"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Knut W. Horne",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/kwhorne/elyra.git",
16
+ "directory": "packages/lsp-typescript"
17
+ },
18
+ "elyra": {
19
+ "extensions": [
20
+ "./extensions/index.ts"
21
+ ]
22
+ },
23
+ "peerDependencies": {
24
+ "@elyracode/coding-agent": "*",
25
+ "typebox": "*"
26
+ },
27
+ "scripts": {
28
+ "clean": "echo 'nothing to clean'",
29
+ "build": "echo 'nothing to build'",
30
+ "check": "echo 'nothing to check'"
31
+ }
32
+ }