@elyracode/lsp-php 0.9.19 → 0.9.21
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 +7 -0
- package/extensions/index.ts +168 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.21] - 2026-07-19
|
|
4
|
+
|
|
5
|
+
## [0.9.20] - 2026-07-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Auto-diagnostics and blast radius on edit: after an `edit`, the extension now also reports fresh PHP compiler errors for the edited file and lists call sites of the edited function/class in other files (via `textDocument/documentSymbol` + `references`), alongside the existing related-symbol summaries
|
|
9
|
+
|
|
3
10
|
## [0.9.19] - 2026-07-18
|
|
4
11
|
|
|
5
12
|
## [0.9.18] - 2026-07-18
|
package/extensions/index.ts
CHANGED
|
@@ -148,6 +148,82 @@ function summarizeHover(result: unknown): string | undefined {
|
|
|
148
148
|
return firstLine.length > 160 ? `${firstLine.slice(0, 160)}\u2026` : firstLine;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// LSP SymbolKind values that can meaningfully own a blast radius:
|
|
152
|
+
// Class, Method, Constructor, Enum, Interface, Function, Variable, Constant, Struct.
|
|
153
|
+
const BLAST_SYMBOL_KINDS = new Set([5, 6, 9, 10, 11, 12, 13, 14, 23]);
|
|
154
|
+
|
|
155
|
+
interface FlatSymbol {
|
|
156
|
+
name: string;
|
|
157
|
+
kind: number;
|
|
158
|
+
startLine: number;
|
|
159
|
+
endLine: number;
|
|
160
|
+
depth: number;
|
|
161
|
+
/** 0-based position of the symbol's name (for references lookups). */
|
|
162
|
+
selLine: number;
|
|
163
|
+
selChar: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Flatten a documentSymbol response (hierarchical DocumentSymbol[] or flat SymbolInformation[]). */
|
|
167
|
+
function flattenSymbols(result: unknown): FlatSymbol[] {
|
|
168
|
+
if (!Array.isArray(result)) return [];
|
|
169
|
+
const out: FlatSymbol[] = [];
|
|
170
|
+
const walk = (nodes: unknown[], depth: number): void => {
|
|
171
|
+
for (const n of nodes) {
|
|
172
|
+
if (!n || typeof n !== "object") continue;
|
|
173
|
+
const sym = n as {
|
|
174
|
+
name?: unknown;
|
|
175
|
+
kind?: unknown;
|
|
176
|
+
range?: LspRange;
|
|
177
|
+
selectionRange?: LspRange;
|
|
178
|
+
location?: { range?: LspRange };
|
|
179
|
+
children?: unknown[];
|
|
180
|
+
};
|
|
181
|
+
const range = sym.range ?? sym.location?.range;
|
|
182
|
+
if (typeof sym.name === "string" && typeof sym.kind === "number" && range) {
|
|
183
|
+
const sel = sym.selectionRange ?? range;
|
|
184
|
+
out.push({
|
|
185
|
+
name: sym.name,
|
|
186
|
+
kind: sym.kind,
|
|
187
|
+
startLine: range.start.line,
|
|
188
|
+
endLine: range.end.line,
|
|
189
|
+
depth,
|
|
190
|
+
selLine: sel.start.line,
|
|
191
|
+
selChar: sel.start.character,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(sym.children)) walk(sym.children, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
walk(result, 0);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Find the innermost symbol (by line span, then nesting depth) containing a 0-based line. */
|
|
202
|
+
function findEnclosingSymbol(symbols: FlatSymbol[], line0: number): FlatSymbol | undefined {
|
|
203
|
+
let best: FlatSymbol | undefined;
|
|
204
|
+
for (const s of symbols) {
|
|
205
|
+
if (!BLAST_SYMBOL_KINDS.has(s.kind)) continue;
|
|
206
|
+
if (line0 < s.startLine || line0 > s.endLine) continue;
|
|
207
|
+
if (!best) {
|
|
208
|
+
best = s;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const bestSpan = best.endLine - best.startLine;
|
|
212
|
+
const span = s.endLine - s.startLine;
|
|
213
|
+
if (span < bestSpan || (span === bestSpan && s.depth > best.depth)) best = s;
|
|
214
|
+
}
|
|
215
|
+
return best;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** True when the edit tool's own result already contains a diagnostics section. */
|
|
219
|
+
function hasExistingDiagnostics(content: ReadonlyArray<unknown>): boolean {
|
|
220
|
+
return content.some((c) => {
|
|
221
|
+
if (!c || typeof c !== "object") return false;
|
|
222
|
+
const item = c as { type?: unknown; text?: unknown };
|
|
223
|
+
return item.type === "text" && typeof item.text === "string" && item.text.includes("\nDiagnostics (");
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
151
227
|
function whichBinary(name: string): string | undefined {
|
|
152
228
|
try {
|
|
153
229
|
const found = execSync(`which ${name}`, {
|
|
@@ -195,6 +271,7 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
195
271
|
let cwd = "";
|
|
196
272
|
const openedFiles = new Set<string>();
|
|
197
273
|
const diagnosticsByUri = new Map<string, LspDiagnostic[]>();
|
|
274
|
+
const diagnosticsWaiters = new Map<string, Array<() => void>>();
|
|
198
275
|
|
|
199
276
|
// ── JSON-RPC Client ─────────────────────────────────────────────────
|
|
200
277
|
|
|
@@ -283,6 +360,11 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
283
360
|
if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
|
|
284
361
|
const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] };
|
|
285
362
|
diagnosticsByUri.set(p.uri, p.diagnostics);
|
|
363
|
+
const waiters = diagnosticsWaiters.get(p.uri);
|
|
364
|
+
if (waiters) {
|
|
365
|
+
diagnosticsWaiters.delete(p.uri);
|
|
366
|
+
for (const w of waiters) w();
|
|
367
|
+
}
|
|
286
368
|
}
|
|
287
369
|
}
|
|
288
370
|
}
|
|
@@ -324,6 +406,28 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
324
406
|
openFile(filePath);
|
|
325
407
|
}
|
|
326
408
|
|
|
409
|
+
/** Resolve when the server publishes diagnostics for `uri`, or after `timeoutMs`. */
|
|
410
|
+
function waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
|
|
411
|
+
return new Promise<void>((resolveWait) => {
|
|
412
|
+
const waiter = () => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
resolveWait();
|
|
415
|
+
};
|
|
416
|
+
const timer = setTimeout(() => {
|
|
417
|
+
const arr = diagnosticsWaiters.get(uri);
|
|
418
|
+
if (arr) {
|
|
419
|
+
const remaining = arr.filter((w) => w !== waiter);
|
|
420
|
+
if (remaining.length > 0) diagnosticsWaiters.set(uri, remaining);
|
|
421
|
+
else diagnosticsWaiters.delete(uri);
|
|
422
|
+
}
|
|
423
|
+
resolveWait();
|
|
424
|
+
}, timeoutMs);
|
|
425
|
+
const list = diagnosticsWaiters.get(uri) ?? [];
|
|
426
|
+
list.push(waiter);
|
|
427
|
+
diagnosticsWaiters.set(uri, list);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
327
431
|
function formatLocations(result: unknown, workingDir: string): string {
|
|
328
432
|
if (!result) return "No results found.";
|
|
329
433
|
|
|
@@ -547,7 +651,7 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
547
651
|
},
|
|
548
652
|
});
|
|
549
653
|
|
|
550
|
-
// ── Hook: auto-context
|
|
654
|
+
// ── Hook: auto-context, auto-diagnostics, and blast radius on edit ──
|
|
551
655
|
elyra.on("tool_result", async (event) => {
|
|
552
656
|
if (!initialized || !isEditToolResult(event) || event.isError) return undefined;
|
|
553
657
|
|
|
@@ -561,14 +665,14 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
561
665
|
const fileText = readFileSync(absPath, "utf-8");
|
|
562
666
|
const details = event.details as EditToolDetails | undefined;
|
|
563
667
|
const fromLine = details?.firstChangedLine ?? 1;
|
|
668
|
+
const uri = fileUri(absPath);
|
|
669
|
+
const sections: string[] = [];
|
|
564
670
|
|
|
671
|
+
// 1. Related symbols: hover summaries for referenced types.
|
|
565
672
|
const candidates = new Set<string>();
|
|
566
673
|
for (const edit of input.edits) {
|
|
567
674
|
for (const s of extractCandidateSymbols(edit.newText, PHP_SYMBOL_STOPLIST, 4)) candidates.add(s);
|
|
568
675
|
}
|
|
569
|
-
if (candidates.size === 0) return undefined;
|
|
570
|
-
|
|
571
|
-
const uri = fileUri(absPath);
|
|
572
676
|
const lookups = [...candidates].slice(0, 5).map(async (symbol) => {
|
|
573
677
|
const pos = findSymbolPosition(fileText, symbol, fromLine);
|
|
574
678
|
if (!pos) return undefined;
|
|
@@ -584,10 +688,67 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
584
688
|
}
|
|
585
689
|
});
|
|
586
690
|
|
|
587
|
-
|
|
588
|
-
|
|
691
|
+
// 2. Blast radius: references to the edited symbol outside this file.
|
|
692
|
+
const blastRadius = (async () => {
|
|
693
|
+
try {
|
|
694
|
+
const symbolResult = await sendRequest("textDocument/documentSymbol", {
|
|
695
|
+
textDocument: { uri },
|
|
696
|
+
});
|
|
697
|
+
const enclosing = findEnclosingSymbol(flattenSymbols(symbolResult), fromLine - 1);
|
|
698
|
+
if (!enclosing) return undefined;
|
|
699
|
+
|
|
700
|
+
const refsResult = await sendRequest("textDocument/references", {
|
|
701
|
+
textDocument: { uri },
|
|
702
|
+
position: { line: enclosing.selLine, character: enclosing.selChar },
|
|
703
|
+
context: { includeDeclaration: false },
|
|
704
|
+
});
|
|
705
|
+
if (!Array.isArray(refsResult)) return undefined;
|
|
706
|
+
|
|
707
|
+
const external = (refsResult as LspLocation[]).filter((loc) => uriToPath(loc.uri) !== absPath);
|
|
708
|
+
if (external.length === 0) return undefined;
|
|
709
|
+
|
|
710
|
+
const prefix = `${cwd}/`;
|
|
711
|
+
const shown = external.slice(0, 8).map((loc) => {
|
|
712
|
+
const p = uriToPath(loc.uri);
|
|
713
|
+
const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
714
|
+
return `${rel}:${loc.range.start.line + 1}`;
|
|
715
|
+
});
|
|
716
|
+
const more = external.length > shown.length ? ` (+${external.length - shown.length} more)` : "";
|
|
717
|
+
return `${enclosing.name} is referenced in ${external.length} place${external.length === 1 ? "" : "s"} outside this file:\n${shown.map((s) => `- ${s}`).join("\n")}${more}`;
|
|
718
|
+
} catch {
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
})();
|
|
722
|
+
|
|
723
|
+
// 3. Fresh diagnostics for the edited file (skip if the edit tool already reported diagnostics).
|
|
724
|
+
const freshDiagnostics = (async () => {
|
|
725
|
+
if (hasExistingDiagnostics(event.content)) return undefined;
|
|
726
|
+
try {
|
|
727
|
+
await waitForDiagnostics(uri, 1500);
|
|
728
|
+
const errors = (diagnosticsByUri.get(uri) ?? []).filter((d) => (d.severity ?? 1) === 1);
|
|
729
|
+
if (errors.length === 0) return undefined;
|
|
730
|
+
const shown = errors.slice(0, 8).map((d) => {
|
|
731
|
+
const code = d.code ? ` [${d.code}]` : "";
|
|
732
|
+
return `- ${input.path}:${d.range.start.line + 1}:${d.range.start.character + 1}${code}: ${d.message.split("\n")[0]}`;
|
|
733
|
+
});
|
|
734
|
+
const more = errors.length > shown.length ? ` (+${errors.length - shown.length} more)` : "";
|
|
735
|
+
return `${errors.length} error${errors.length === 1 ? "" : "s"} in this file after the edit:\n${shown.join("\n")}${more}`;
|
|
736
|
+
} catch {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
})();
|
|
740
|
+
|
|
741
|
+
const [hoverLines, blast, diag] = await Promise.all([Promise.all(lookups), blastRadius, freshDiagnostics]);
|
|
742
|
+
|
|
743
|
+
const related = hoverLines.filter((l): l is string => !!l);
|
|
744
|
+
if (diag) sections.push(`[Diagnostics \u2014 auto-checked via LSP]\n${diag}`);
|
|
745
|
+
if (blast) sections.push(`[Blast radius \u2014 auto-resolved via LSP]\n${blast}`);
|
|
746
|
+
if (related.length > 0) {
|
|
747
|
+
sections.push(`[Related symbols \u2014 auto-resolved via LSP, no extra tool call needed]\n${related.join("\n")}`);
|
|
748
|
+
}
|
|
749
|
+
if (sections.length === 0) return undefined;
|
|
589
750
|
|
|
590
|
-
const contextBlock = `\n\n
|
|
751
|
+
const contextBlock = `\n\n${sections.join("\n\n")}`;
|
|
591
752
|
return { content: [...event.content, { type: "text" as const, text: contextBlock }] };
|
|
592
753
|
} catch {
|
|
593
754
|
return undefined;
|