@elyracode/lsp-rust 0.9.18
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 +6 -0
- package/README.md +38 -0
- package/extensions/index.ts +616 -0
- package/package.json +34 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.9.18] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release: Rust LSP integration for Elyra. Starts a Rust language server (rust-analyzer) per session in projects containing a `Cargo.toml`, and exposes `rust_definitions`, `rust_references`, `rust_diagnostics`, and `rust_hover` as agent tools. Also proactively resolves referenced type/struct/trait names via hover after an edit, appending a short summary to the edit's own tool result.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @elyracode/lsp-rust
|
|
2
|
+
|
|
3
|
+
Rust LSP integration for Elyra. Starts a [rust-analyzer](https://rust-analyzer.github.io/) process per session and exposes semantic code navigation and diagnostics as agent tools — go-to-definition, find references, hover, and diagnostics for Rust and Cargo projects.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
`rust-analyzer` must be on your `PATH` (or installed via rustup, which places it in `~/.cargo/bin/`).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
rustup component add rust-analyzer
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
…or install a standalone binary from the [rust-analyzer releases](https://github.com/rust-lang/rust-analyzer/releases).
|
|
14
|
+
|
|
15
|
+
A `Cargo.toml` must exist in the project root. The extension does nothing if one is not found.
|
|
16
|
+
|
|
17
|
+
> Note: rust-analyzer indexes the workspace on first use, which can take longer on large crates. The first `rust_diagnostics`/`rust_hover` call after a session starts may need a moment.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
elyra install npm:@elyracode/lsp-rust
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Available Tools
|
|
26
|
+
|
|
27
|
+
| Tool | Description |
|
|
28
|
+
|------|-------------|
|
|
29
|
+
| `rust_definitions` | Go to the definition of a Rust symbol at a given file position |
|
|
30
|
+
| `rust_references` | Find all references to a Rust symbol across the project |
|
|
31
|
+
| `rust_diagnostics` | Get Rust compiler/clippy-derived errors and warnings for a file |
|
|
32
|
+
| `rust_hover` | Get type information and documentation for a symbol |
|
|
33
|
+
|
|
34
|
+
All tools accept 1-based line and column numbers and return human-readable text results.
|
|
35
|
+
|
|
36
|
+
## Symbol-aware auto-context
|
|
37
|
+
|
|
38
|
+
After a successful `edit` to a `.rs` file, the extension proactively resolves unfamiliar-looking type/struct/trait names referenced in the new code via `rust_analyzer`'s hover, and appends a short summary directly to the edit's own tool result — no extra `rust_hover` round trip needed to check "is this the shape I think it is".
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EditToolDetails,
|
|
3
|
+
type EditToolInput,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
isEditToolResult,
|
|
6
|
+
} from "@elyracode/coding-agent";
|
|
7
|
+
import { type ChildProcess, execSync, spawn } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
// ── LSP Types ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface LspPosition {
|
|
16
|
+
line: number;
|
|
17
|
+
character: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface LspRange {
|
|
21
|
+
start: LspPosition;
|
|
22
|
+
end: LspPosition;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface LspLocation {
|
|
26
|
+
uri: string;
|
|
27
|
+
range: LspRange;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LspDiagnostic {
|
|
31
|
+
range: LspRange;
|
|
32
|
+
severity?: number;
|
|
33
|
+
message: string;
|
|
34
|
+
source?: string;
|
|
35
|
+
code?: string | number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LspHoverResult {
|
|
39
|
+
contents: string | { kind: string; value: string } | Array<string | { kind: string; value: string }>;
|
|
40
|
+
range?: LspRange;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PendingRequest {
|
|
44
|
+
resolve: (value: unknown) => void;
|
|
45
|
+
reject: (reason: unknown) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const SEVERITY_LABELS: Record<number, string> = {
|
|
51
|
+
1: "Error",
|
|
52
|
+
2: "Warning",
|
|
53
|
+
3: "Information",
|
|
54
|
+
4: "Hint",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fileUri(filePath: string): string {
|
|
58
|
+
return `file://${filePath}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uriToPath(uri: string): string {
|
|
62
|
+
return uri.replace(/^file:\/\//, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function positionFromLineCol(line: number, col: number): LspPosition {
|
|
66
|
+
return { line: line - 1, character: col - 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function whichBinary(name: string): string | undefined {
|
|
70
|
+
try {
|
|
71
|
+
const found = execSync(`which ${name}`, {
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
timeout: 5000,
|
|
74
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
75
|
+
}).trim();
|
|
76
|
+
return found || undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Locate rust-analyzer: PATH first, then the default rustup install location. */
|
|
83
|
+
function findLanguageServer(): string | undefined {
|
|
84
|
+
const onPath = whichBinary("rust-analyzer");
|
|
85
|
+
if (onPath) return onPath;
|
|
86
|
+
|
|
87
|
+
const rustupDefault = join(homedir(), ".cargo", "bin", "rust-analyzer");
|
|
88
|
+
if (existsSync(rustupDefault)) return rustupDefault;
|
|
89
|
+
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Symbol-aware auto-context ────────────────────────────────────────────────
|
|
94
|
+
// After an edit, proactively resolve unfamiliar-looking type/struct/trait names
|
|
95
|
+
// referenced in the new code via hover, and append a short summary to the
|
|
96
|
+
// edit's own tool result. Saves a definitions/hover round trip for the common
|
|
97
|
+
// case of "I just referenced a type, is this the shape I think it is".
|
|
98
|
+
|
|
99
|
+
/** Common built-ins that rarely need an explanation. */
|
|
100
|
+
const RUST_SYMBOL_STOPLIST = new Set([
|
|
101
|
+
"String",
|
|
102
|
+
"Vec",
|
|
103
|
+
"Option",
|
|
104
|
+
"Result",
|
|
105
|
+
"Box",
|
|
106
|
+
"Arc",
|
|
107
|
+
"Rc",
|
|
108
|
+
"Weak",
|
|
109
|
+
"RefCell",
|
|
110
|
+
"Cell",
|
|
111
|
+
"Mutex",
|
|
112
|
+
"RwLock",
|
|
113
|
+
"HashMap",
|
|
114
|
+
"HashSet",
|
|
115
|
+
"BTreeMap",
|
|
116
|
+
"BTreeSet",
|
|
117
|
+
"Cow",
|
|
118
|
+
"Path",
|
|
119
|
+
"PathBuf",
|
|
120
|
+
"Duration",
|
|
121
|
+
"Instant",
|
|
122
|
+
"Self",
|
|
123
|
+
"Ok",
|
|
124
|
+
"Err",
|
|
125
|
+
"Some",
|
|
126
|
+
"None",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/** Extract candidate PascalCase type/struct/trait identifiers from a snippet of code. */
|
|
130
|
+
function extractCandidateSymbols(text: string, stoplist: Set<string>, max: number): string[] {
|
|
131
|
+
const found = new Set<string>();
|
|
132
|
+
const re = /\b[A-Z][A-Za-z0-9_]*\b/g;
|
|
133
|
+
let m: RegExpExecArray | null = re.exec(text);
|
|
134
|
+
while (m && found.size < max * 3) {
|
|
135
|
+
if (!stoplist.has(m[0])) found.add(m[0]);
|
|
136
|
+
m = re.exec(text);
|
|
137
|
+
}
|
|
138
|
+
return [...found].slice(0, max);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Find the (1-based) line/column of the first whole-word match of `symbol`, searching from `fromLine` first. */
|
|
142
|
+
function findSymbolPosition(
|
|
143
|
+
fileText: string,
|
|
144
|
+
symbol: string,
|
|
145
|
+
fromLine: number,
|
|
146
|
+
): { line: number; column: number } | undefined {
|
|
147
|
+
const lines = fileText.split("\n");
|
|
148
|
+
const re = new RegExp(`\\b${symbol}\\b`);
|
|
149
|
+
const start = Math.max(0, fromLine - 1);
|
|
150
|
+
for (let i = start; i < lines.length; i++) {
|
|
151
|
+
const idx = lines[i].search(re);
|
|
152
|
+
if (idx >= 0) return { line: i + 1, column: idx + 1 };
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i < start; i++) {
|
|
155
|
+
const idx = lines[i].search(re);
|
|
156
|
+
if (idx >= 0) return { line: i + 1, column: idx + 1 };
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reduce a hover result to a short, single-line summary. */
|
|
162
|
+
function summarizeHover(result: unknown): string | undefined {
|
|
163
|
+
if (!result) return undefined;
|
|
164
|
+
const hover = result as LspHoverResult;
|
|
165
|
+
let text: string;
|
|
166
|
+
if (typeof hover.contents === "string") text = hover.contents;
|
|
167
|
+
else if (Array.isArray(hover.contents)) {
|
|
168
|
+
text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n");
|
|
169
|
+
} else text = hover.contents.value;
|
|
170
|
+
|
|
171
|
+
const firstLine = text
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((l) => l.trim())
|
|
174
|
+
.find((l) => l && !l.startsWith("```"));
|
|
175
|
+
if (!firstLine) return undefined;
|
|
176
|
+
return firstLine.length > 160 ? `${firstLine.slice(0, 160)}…` : firstLine;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export default function (elyra: ExtensionAPI): void {
|
|
182
|
+
let lspProcess: ChildProcess | null = null;
|
|
183
|
+
let requestId = 0;
|
|
184
|
+
const pendingRequests = new Map<number, PendingRequest>();
|
|
185
|
+
let buffer = Buffer.alloc(0);
|
|
186
|
+
let initialized = false;
|
|
187
|
+
let cwd = "";
|
|
188
|
+
const openedFiles = new Set<string>();
|
|
189
|
+
const diagnosticsByUri = new Map<string, LspDiagnostic[]>();
|
|
190
|
+
|
|
191
|
+
// ── JSON-RPC Client ─────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function sendRequest(method: string, params: unknown): Promise<unknown> {
|
|
194
|
+
if (!lspProcess?.stdin) {
|
|
195
|
+
return Promise.reject(new Error("LSP server not running"));
|
|
196
|
+
}
|
|
197
|
+
const id = ++requestId;
|
|
198
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
199
|
+
const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
|
200
|
+
lspProcess.stdin.write(message);
|
|
201
|
+
|
|
202
|
+
return new Promise<unknown>((res, rej) => {
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
pendingRequests.delete(id);
|
|
205
|
+
rej(new Error(`LSP request "${method}" timed out after 20s`));
|
|
206
|
+
}, 20_000);
|
|
207
|
+
|
|
208
|
+
pendingRequests.set(id, {
|
|
209
|
+
resolve: (value: unknown) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
res(value);
|
|
212
|
+
},
|
|
213
|
+
reject: (reason: unknown) => {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
rej(reason);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sendNotification(method: string, params: unknown): void {
|
|
222
|
+
if (!lspProcess?.stdin) return;
|
|
223
|
+
const body = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
224
|
+
const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
|
225
|
+
lspProcess.stdin.write(message);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleData(chunk: Buffer): void {
|
|
229
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
230
|
+
|
|
231
|
+
for (;;) {
|
|
232
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
233
|
+
if (headerEnd === -1) break;
|
|
234
|
+
|
|
235
|
+
const header = buffer.subarray(0, headerEnd).toString("utf-8");
|
|
236
|
+
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
|
237
|
+
if (!match) {
|
|
238
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const contentLength = parseInt(match[1], 10);
|
|
243
|
+
const bodyStart = headerEnd + 4;
|
|
244
|
+
if (buffer.length < bodyStart + contentLength) break;
|
|
245
|
+
|
|
246
|
+
const bodyStr = buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf-8");
|
|
247
|
+
buffer = buffer.subarray(bodyStart + contentLength);
|
|
248
|
+
|
|
249
|
+
let parsed: unknown;
|
|
250
|
+
try {
|
|
251
|
+
parsed = JSON.parse(bodyStr);
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
257
|
+
const msg = parsed as Record<string, unknown>;
|
|
258
|
+
|
|
259
|
+
// Response to a request
|
|
260
|
+
if ("id" in msg && typeof msg.id === "number") {
|
|
261
|
+
const pending = pendingRequests.get(msg.id);
|
|
262
|
+
if (pending) {
|
|
263
|
+
pendingRequests.delete(msg.id);
|
|
264
|
+
if ("error" in msg && msg.error) {
|
|
265
|
+
const err = msg.error as { code: number; message: string };
|
|
266
|
+
pending.reject(new Error(`LSP error ${err.code}: ${err.message}`));
|
|
267
|
+
} else {
|
|
268
|
+
pending.resolve(msg.result);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Server notification
|
|
274
|
+
if ("method" in msg && typeof msg.method === "string") {
|
|
275
|
+
if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
|
|
276
|
+
const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] };
|
|
277
|
+
diagnosticsByUri.set(p.uri, p.diagnostics);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── File helpers ────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function openFile(filePath: string): void {
|
|
286
|
+
const absPath = resolve(cwd, filePath);
|
|
287
|
+
const uri = fileUri(absPath);
|
|
288
|
+
if (openedFiles.has(uri)) return;
|
|
289
|
+
|
|
290
|
+
let text: string;
|
|
291
|
+
try {
|
|
292
|
+
text = readFileSync(absPath, "utf-8");
|
|
293
|
+
} catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
sendNotification("textDocument/didOpen", {
|
|
298
|
+
textDocument: {
|
|
299
|
+
uri,
|
|
300
|
+
languageId: "rust",
|
|
301
|
+
version: 1,
|
|
302
|
+
text,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
openedFiles.add(uri);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Force the server to see the current on-disk content (e.g. right after our own edit). */
|
|
309
|
+
function syncFile(filePath: string): void {
|
|
310
|
+
const absPath = resolve(cwd, filePath);
|
|
311
|
+
const uri = fileUri(absPath);
|
|
312
|
+
if (openedFiles.has(uri)) {
|
|
313
|
+
sendNotification("textDocument/didClose", { textDocument: { uri } });
|
|
314
|
+
openedFiles.delete(uri);
|
|
315
|
+
}
|
|
316
|
+
openFile(filePath);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatLocations(result: unknown, workingDir: string): string {
|
|
320
|
+
if (!result) return "No results found.";
|
|
321
|
+
|
|
322
|
+
const locations: LspLocation[] = Array.isArray(result) ? result : [result as LspLocation];
|
|
323
|
+
if (locations.length === 0) return "No results found.";
|
|
324
|
+
|
|
325
|
+
const prefix = workingDir + "/";
|
|
326
|
+
return locations
|
|
327
|
+
.map((loc) => {
|
|
328
|
+
const p = uriToPath(loc.uri);
|
|
329
|
+
const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
330
|
+
return `${rel}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
|
|
331
|
+
})
|
|
332
|
+
.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
elyra.on("session_start", async (_event, ctx) => {
|
|
338
|
+
cwd = ctx.cwd;
|
|
339
|
+
|
|
340
|
+
// Rust/Cargo projects are identified by Cargo.toml.
|
|
341
|
+
if (!existsSync(join(cwd, "Cargo.toml"))) return;
|
|
342
|
+
|
|
343
|
+
const serverPath = findLanguageServer();
|
|
344
|
+
if (!serverPath) return;
|
|
345
|
+
|
|
346
|
+
lspProcess = spawn(serverPath, [], { cwd });
|
|
347
|
+
|
|
348
|
+
lspProcess.stdout?.on("data", (chunk: Buffer) => handleData(chunk));
|
|
349
|
+
lspProcess.stderr?.on("data", () => {
|
|
350
|
+
/* ignore stderr */
|
|
351
|
+
});
|
|
352
|
+
lspProcess.on("exit", () => {
|
|
353
|
+
lspProcess = null;
|
|
354
|
+
initialized = false;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await sendRequest("initialize", {
|
|
359
|
+
processId: process.pid,
|
|
360
|
+
capabilities: {},
|
|
361
|
+
rootUri: fileUri(cwd),
|
|
362
|
+
workspaceFolders: [{ uri: fileUri(cwd), name: "workspace" }],
|
|
363
|
+
});
|
|
364
|
+
sendNotification("initialized", {});
|
|
365
|
+
initialized = true;
|
|
366
|
+
} catch {
|
|
367
|
+
lspProcess?.kill();
|
|
368
|
+
lspProcess = null;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Tool: rust_definitions ──────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
elyra.registerTool({
|
|
375
|
+
name: "rust_definitions",
|
|
376
|
+
label: "Rust Go to Definition",
|
|
377
|
+
description:
|
|
378
|
+
"Go to the definition of a Rust symbol at a given position. Returns the file path and " +
|
|
379
|
+
"line where the symbol is defined. More precise than grep for finding where structs, " +
|
|
380
|
+
"enums, traits, functions, and modules are declared.",
|
|
381
|
+
promptSnippet: "Go to definition of a Rust symbol at a file position",
|
|
382
|
+
parameters: Type.Object({
|
|
383
|
+
file: Type.String({ description: "Relative file path from project root" }),
|
|
384
|
+
line: Type.Number({ description: "Line number (1-based)" }),
|
|
385
|
+
column: Type.Number({ description: "Column number (1-based)" }),
|
|
386
|
+
}),
|
|
387
|
+
execute: async (_toolCallId, params) => {
|
|
388
|
+
if (!initialized) {
|
|
389
|
+
return { content: [{ type: "text", text: "Error: Rust language server is not running." }], details: {} };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const absPath = resolve(cwd, params.file);
|
|
393
|
+
openFile(params.file);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const result = await sendRequest("textDocument/definition", {
|
|
397
|
+
textDocument: { uri: fileUri(absPath) },
|
|
398
|
+
position: positionFromLineCol(params.line, params.column),
|
|
399
|
+
});
|
|
400
|
+
return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} };
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Tool: rust_references ───────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
elyra.registerTool({
|
|
411
|
+
name: "rust_references",
|
|
412
|
+
label: "Rust Find References",
|
|
413
|
+
description:
|
|
414
|
+
"Find all references to a Rust symbol at a given position. Returns every location where " +
|
|
415
|
+
"the symbol is used across the project. More precise than grep for understanding how a " +
|
|
416
|
+
"struct, trait, or function is used in a Rust/Cargo codebase.",
|
|
417
|
+
promptSnippet: "Find all references to a Rust symbol at a file position",
|
|
418
|
+
parameters: Type.Object({
|
|
419
|
+
file: Type.String({ description: "Relative file path from project root" }),
|
|
420
|
+
line: Type.Number({ description: "Line number (1-based)" }),
|
|
421
|
+
column: Type.Number({ description: "Column number (1-based)" }),
|
|
422
|
+
}),
|
|
423
|
+
execute: async (_toolCallId, params) => {
|
|
424
|
+
if (!initialized) {
|
|
425
|
+
return { content: [{ type: "text", text: "Error: Rust language server is not running." }], details: {} };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const absPath = resolve(cwd, params.file);
|
|
429
|
+
openFile(params.file);
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const result = await sendRequest("textDocument/references", {
|
|
433
|
+
textDocument: { uri: fileUri(absPath) },
|
|
434
|
+
position: positionFromLineCol(params.line, params.column),
|
|
435
|
+
context: { includeDeclaration: true },
|
|
436
|
+
});
|
|
437
|
+
return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} };
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
440
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── Tool: rust_diagnostics ──────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
elyra.registerTool({
|
|
448
|
+
name: "rust_diagnostics",
|
|
449
|
+
label: "Rust Diagnostics",
|
|
450
|
+
description:
|
|
451
|
+
"Get Rust compiler and clippy-derived errors and warnings for a file from the language " +
|
|
452
|
+
"server (type errors, borrow-checker issues, unused imports, and more). Returns diagnostics " +
|
|
453
|
+
"with line numbers, severity, and messages.",
|
|
454
|
+
promptSnippet: "Get Rust errors and warnings for a file",
|
|
455
|
+
parameters: Type.Object({
|
|
456
|
+
file: Type.String({ description: "Relative file path from project root" }),
|
|
457
|
+
}),
|
|
458
|
+
execute: async (_toolCallId, params) => {
|
|
459
|
+
if (!initialized) {
|
|
460
|
+
return { content: [{ type: "text", text: "Error: Rust language server is not running." }], details: {} };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const absPath = resolve(cwd, params.file);
|
|
464
|
+
openFile(params.file);
|
|
465
|
+
|
|
466
|
+
const uri = fileUri(absPath);
|
|
467
|
+
|
|
468
|
+
// rust-analyzer indexes the workspace on first use, and diagnostics
|
|
469
|
+
// (especially clippy/cargo check based ones) can take a while on
|
|
470
|
+
// larger crates. Give it a moment before reading published diagnostics.
|
|
471
|
+
await new Promise<void>((r) => setTimeout(r, 2000));
|
|
472
|
+
|
|
473
|
+
const diagnostics = diagnosticsByUri.get(uri) ?? [];
|
|
474
|
+
if (diagnostics.length === 0) {
|
|
475
|
+
return { content: [{ type: "text", text: "No diagnostics found." }], details: {} };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const lines = diagnostics.map((d) => {
|
|
479
|
+
const severity = SEVERITY_LABELS[d.severity ?? 1] ?? "Unknown";
|
|
480
|
+
const loc = `${params.file}:${d.range.start.line + 1}:${d.range.start.character + 1}`;
|
|
481
|
+
const code = d.code ? ` [${d.code}]` : "";
|
|
482
|
+
return `${severity}${code} ${loc}: ${d.message}`;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ── Tool: rust_hover ─────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
elyra.registerTool({
|
|
492
|
+
name: "rust_hover",
|
|
493
|
+
label: "Rust Hover",
|
|
494
|
+
description:
|
|
495
|
+
"Get type information and documentation for a Rust symbol at a given position. Shows the " +
|
|
496
|
+
"resolved signature and doc comments for structs, enums, traits, and functions.",
|
|
497
|
+
promptSnippet: "Get type info and docs for a Rust symbol at a file position",
|
|
498
|
+
parameters: Type.Object({
|
|
499
|
+
file: Type.String({ description: "Relative file path from project root" }),
|
|
500
|
+
line: Type.Number({ description: "Line number (1-based)" }),
|
|
501
|
+
column: Type.Number({ description: "Column number (1-based)" }),
|
|
502
|
+
}),
|
|
503
|
+
execute: async (_toolCallId, params) => {
|
|
504
|
+
if (!initialized) {
|
|
505
|
+
return { content: [{ type: "text", text: "Error: Rust language server is not running." }], details: {} };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const absPath = resolve(cwd, params.file);
|
|
509
|
+
openFile(params.file);
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const result = await sendRequest("textDocument/hover", {
|
|
513
|
+
textDocument: { uri: fileUri(absPath) },
|
|
514
|
+
position: positionFromLineCol(params.line, params.column),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (!result) {
|
|
518
|
+
return { content: [{ type: "text", text: "No hover information available." }], details: {} };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const hover = result as LspHoverResult;
|
|
522
|
+
let text: string;
|
|
523
|
+
|
|
524
|
+
if (typeof hover.contents === "string") {
|
|
525
|
+
text = hover.contents;
|
|
526
|
+
} else if (Array.isArray(hover.contents)) {
|
|
527
|
+
text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n\n");
|
|
528
|
+
} else {
|
|
529
|
+
text = hover.contents.value;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: "text", text: text || "No hover information available." }],
|
|
534
|
+
details: {},
|
|
535
|
+
};
|
|
536
|
+
} catch (err) {
|
|
537
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
538
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ── Hook: auto-context on edit (see "Symbol-aware auto-context" above) ──
|
|
544
|
+
elyra.on("tool_result", async (event) => {
|
|
545
|
+
if (!initialized || !isEditToolResult(event) || event.isError) return undefined;
|
|
546
|
+
|
|
547
|
+
const input = event.input as unknown as EditToolInput;
|
|
548
|
+
if (typeof input?.path !== "string" || !Array.isArray(input.edits)) return undefined;
|
|
549
|
+
if (!input.path.endsWith(".rs")) return undefined;
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
syncFile(input.path);
|
|
553
|
+
const absPath = resolve(cwd, input.path);
|
|
554
|
+
const fileText = readFileSync(absPath, "utf-8");
|
|
555
|
+
const details = event.details as EditToolDetails | undefined;
|
|
556
|
+
const fromLine = details?.firstChangedLine ?? 1;
|
|
557
|
+
|
|
558
|
+
const candidates = new Set<string>();
|
|
559
|
+
for (const edit of input.edits) {
|
|
560
|
+
for (const s of extractCandidateSymbols(edit.newText, RUST_SYMBOL_STOPLIST, 4)) candidates.add(s);
|
|
561
|
+
}
|
|
562
|
+
if (candidates.size === 0) return undefined;
|
|
563
|
+
|
|
564
|
+
const uri = fileUri(absPath);
|
|
565
|
+
const lookups = [...candidates].slice(0, 5).map(async (symbol) => {
|
|
566
|
+
const pos = findSymbolPosition(fileText, symbol, fromLine);
|
|
567
|
+
if (!pos) return undefined;
|
|
568
|
+
try {
|
|
569
|
+
const result = await sendRequest("textDocument/hover", {
|
|
570
|
+
textDocument: { uri },
|
|
571
|
+
position: positionFromLineCol(pos.line, pos.column),
|
|
572
|
+
});
|
|
573
|
+
const summary = summarizeHover(result);
|
|
574
|
+
return summary ? `- ${symbol}: ${summary}` : undefined;
|
|
575
|
+
} catch {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const lines = (await Promise.all(lookups)).filter((l): l is string => !!l);
|
|
581
|
+
if (lines.length === 0) return undefined;
|
|
582
|
+
|
|
583
|
+
const contextBlock = `\n\n[Related symbols — auto-resolved via LSP, no extra tool call needed]\n${lines.join("\n")}`;
|
|
584
|
+
return { content: [...event.content, { type: "text" as const, text: contextBlock }] };
|
|
585
|
+
} catch {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ── Shutdown ────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
elyra.on("session_shutdown", async () => {
|
|
594
|
+
if (lspProcess && initialized) {
|
|
595
|
+
try {
|
|
596
|
+
await sendRequest("shutdown", null);
|
|
597
|
+
sendNotification("exit", null);
|
|
598
|
+
} catch {
|
|
599
|
+
// ignore errors during shutdown
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (lspProcess) {
|
|
603
|
+
lspProcess.kill();
|
|
604
|
+
lspProcess = null;
|
|
605
|
+
}
|
|
606
|
+
initialized = false;
|
|
607
|
+
openedFiles.clear();
|
|
608
|
+
diagnosticsByUri.clear();
|
|
609
|
+
buffer = Buffer.alloc(0);
|
|
610
|
+
requestId = 0;
|
|
611
|
+
for (const pending of pendingRequests.values()) {
|
|
612
|
+
pending.reject(new Error("LSP server shutting down"));
|
|
613
|
+
}
|
|
614
|
+
pendingRequests.clear();
|
|
615
|
+
});
|
|
616
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/lsp-rust",
|
|
3
|
+
"version": "0.9.18",
|
|
4
|
+
"description": "Rust LSP integration for Elyra — semantic code navigation and diagnostics via rust-analyzer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"lsp",
|
|
9
|
+
"rust",
|
|
10
|
+
"cargo",
|
|
11
|
+
"rust-analyzer"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/lsp-rust"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions/index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@elyracode/coding-agent": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"clean": "echo 'nothing to clean'",
|
|
31
|
+
"build": "echo 'nothing to build'",
|
|
32
|
+
"check": "echo 'nothing to check'"
|
|
33
|
+
}
|
|
34
|
+
}
|