@elyracode/lsp-php 0.9.17 → 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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.18] - 2026-07-18
4
+
5
+ ### Added
6
+ - Proactive symbol-aware auto-context: after an `edit` tool call, referenced class/interface identifiers in the edited code are resolved via `textDocument/hover` and appended to the tool result, so the agent sees relevant type information without a separate lookup round trip
7
+
3
8
  ## [0.9.17] - 2026-07-18
4
9
 
5
10
  ## [0.9.16] - 2026-07-15
@@ -1,4 +1,9 @@
1
- import type { ExtensionAPI } from "@elyracode/coding-agent";
1
+ import {
2
+ type EditToolDetails,
3
+ type EditToolInput,
4
+ type ExtensionAPI,
5
+ isEditToolResult,
6
+ } from "@elyracode/coding-agent";
2
7
  import { type ChildProcess, execSync, spawn } from "node:child_process";
3
8
  import { existsSync, readFileSync } from "node:fs";
4
9
  import { join, resolve } from "node:path";
@@ -67,6 +72,82 @@ function positionFromLineCol(line: number, col: number): LspPosition {
67
72
  return { line: line - 1, character: col - 1 };
68
73
  }
69
74
 
75
+ // ── Symbol-aware auto-context ────────────────────────────────────────────
76
+ // After an edit, proactively resolve unfamiliar-looking type/class names
77
+ // referenced in the new code via hover, and append a short summary to the
78
+ // edit's own tool result. Saves a definitions/hover round trip for the
79
+ // common case of "I just referenced a class, is this the shape I think it is".
80
+
81
+ /** Common built-ins that rarely need an explanation. */
82
+ const PHP_SYMBOL_STOPLIST = new Set([
83
+ "Exception",
84
+ "Error",
85
+ "Throwable",
86
+ "ArrayObject",
87
+ "ArrayAccess",
88
+ "Closure",
89
+ "Generator",
90
+ "Iterator",
91
+ "IteratorAggregate",
92
+ "Countable",
93
+ "Traversable",
94
+ "JsonSerializable",
95
+ "Stringable",
96
+ "DateTime",
97
+ "DateTimeImmutable",
98
+ "DateInterval",
99
+ ]);
100
+
101
+ /** Extract candidate PascalCase class/interface identifiers from a snippet of code. */
102
+ function extractCandidateSymbols(text: string, stoplist: Set<string>, max: number): string[] {
103
+ const found = new Set<string>();
104
+ const re = /\b[A-Z][A-Za-z0-9_]*\b/g;
105
+ let m: RegExpExecArray | null = re.exec(text);
106
+ while (m && found.size < max * 3) {
107
+ if (!stoplist.has(m[0])) found.add(m[0]);
108
+ m = re.exec(text);
109
+ }
110
+ return [...found].slice(0, max);
111
+ }
112
+
113
+ /** Find the (1-based) line/column of the first whole-word match of `symbol`, searching from `fromLine` first. */
114
+ function findSymbolPosition(
115
+ fileText: string,
116
+ symbol: string,
117
+ fromLine: number,
118
+ ): { line: number; column: number } | undefined {
119
+ const lines = fileText.split("\n");
120
+ const re = new RegExp(`\\b${symbol}\\b`);
121
+ const start = Math.max(0, fromLine - 1);
122
+ for (let i = start; i < lines.length; i++) {
123
+ const idx = lines[i].search(re);
124
+ if (idx >= 0) return { line: i + 1, column: idx + 1 };
125
+ }
126
+ for (let i = 0; i < start; i++) {
127
+ const idx = lines[i].search(re);
128
+ if (idx >= 0) return { line: i + 1, column: idx + 1 };
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /** Reduce a hover result to a short, single-line summary. */
134
+ function summarizeHover(result: unknown): string | undefined {
135
+ if (!result) return undefined;
136
+ const hover = result as LspHoverResult;
137
+ let text: string;
138
+ if (typeof hover.contents === "string") text = hover.contents;
139
+ else if (Array.isArray(hover.contents)) {
140
+ text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n");
141
+ } else text = hover.contents.value;
142
+
143
+ const firstLine = text
144
+ .split("\n")
145
+ .map((l) => l.trim())
146
+ .find((l) => l && !l.startsWith("```"));
147
+ if (!firstLine) return undefined;
148
+ return firstLine.length > 160 ? `${firstLine.slice(0, 160)}\u2026` : firstLine;
149
+ }
150
+
70
151
  function whichBinary(name: string): string | undefined {
71
152
  try {
72
153
  const found = execSync(`which ${name}`, {
@@ -232,6 +313,17 @@ export default function (elyra: ExtensionAPI): void {
232
313
  openedFiles.add(uri);
233
314
  }
234
315
 
316
+ /** Force the server to see the current on-disk content (e.g. right after our own edit). */
317
+ function syncFile(filePath: string): void {
318
+ const absPath = resolve(cwd, filePath);
319
+ const uri = fileUri(absPath);
320
+ if (openedFiles.has(uri)) {
321
+ sendNotification("textDocument/didClose", { textDocument: { uri } });
322
+ openedFiles.delete(uri);
323
+ }
324
+ openFile(filePath);
325
+ }
326
+
235
327
  function formatLocations(result: unknown, workingDir: string): string {
236
328
  if (!result) return "No results found.";
237
329
 
@@ -452,13 +544,60 @@ export default function (elyra: ExtensionAPI): void {
452
544
  const message = err instanceof Error ? err.message : String(err);
453
545
  return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
454
546
  }
455
- },
547
+ },
548
+ });
549
+
550
+ // ── Hook: auto-context on edit (see "Symbol-aware auto-context" above) ──
551
+ elyra.on("tool_result", async (event) => {
552
+ if (!initialized || !isEditToolResult(event) || event.isError) return undefined;
553
+
554
+ const input = event.input as unknown as EditToolInput;
555
+ if (typeof input?.path !== "string" || !Array.isArray(input.edits)) return undefined;
556
+ if (!input.path.endsWith(".php")) return undefined;
557
+
558
+ try {
559
+ syncFile(input.path);
560
+ const absPath = resolve(cwd, input.path);
561
+ const fileText = readFileSync(absPath, "utf-8");
562
+ const details = event.details as EditToolDetails | undefined;
563
+ const fromLine = details?.firstChangedLine ?? 1;
564
+
565
+ const candidates = new Set<string>();
566
+ for (const edit of input.edits) {
567
+ for (const s of extractCandidateSymbols(edit.newText, PHP_SYMBOL_STOPLIST, 4)) candidates.add(s);
568
+ }
569
+ if (candidates.size === 0) return undefined;
570
+
571
+ const uri = fileUri(absPath);
572
+ const lookups = [...candidates].slice(0, 5).map(async (symbol) => {
573
+ const pos = findSymbolPosition(fileText, symbol, fromLine);
574
+ if (!pos) return undefined;
575
+ try {
576
+ const result = await sendRequest("textDocument/hover", {
577
+ textDocument: { uri },
578
+ position: positionFromLineCol(pos.line, pos.column),
579
+ });
580
+ const summary = summarizeHover(result);
581
+ return summary ? `- ${symbol}: ${summary}` : undefined;
582
+ } catch {
583
+ return undefined;
584
+ }
585
+ });
586
+
587
+ const lines = (await Promise.all(lookups)).filter((l): l is string => !!l);
588
+ if (lines.length === 0) return undefined;
589
+
590
+ const contextBlock = `\n\n[Related symbols \u2014 auto-resolved via LSP, no extra tool call needed]\n${lines.join("\n")}`;
591
+ return { content: [...event.content, { type: "text" as const, text: contextBlock }] };
592
+ } catch {
593
+ return undefined;
594
+ }
595
+ });
456
596
  });
457
- });
458
597
 
459
- // ── Shutdown ────────────────────────────────────────────────────────
598
+ // ── Shutdown ────────────────────────────────────────────
460
599
 
461
- elyra.on("session_shutdown", async () => {
600
+ elyra.on("session_shutdown", async () => {
462
601
  if (lspProcess && initialized) {
463
602
  try {
464
603
  await sendRequest("shutdown", null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyracode/lsp-php",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
4
4
  "description": "PHP LSP integration for Elyra — semantic code navigation and diagnostics for Laravel and PHP projects",
5
5
  "type": "module",
6
6
  "keywords": [