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