-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLspDispatcherFactory.php
More file actions
442 lines (422 loc) · 22.1 KB
/
Copy pathLspDispatcherFactory.php
File metadata and controls
442 lines (422 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
<?php
declare(strict_types=1);
namespace XPHP\Lsp;
use PhpParser\ParserFactory;
use Phpactor\LanguageServer\Adapter\Psr\AggregateEventDispatcher;
use Phpactor\LanguageServer\Core\Command\ClosureCommand;
use Phpactor\LanguageServer\Core\Command\CommandDispatcher;
use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\ChainArgumentResolver;
use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\LanguageSeverProtocolParamsResolver;
use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\PassThroughArgumentResolver;
use Phpactor\LanguageServer\Core\Dispatcher\Dispatcher;
use Phpactor\LanguageServer\Core\Dispatcher\Dispatcher\MiddlewareDispatcher;
use Phpactor\LanguageServer\Core\Dispatcher\DispatcherFactory;
use Phpactor\LanguageServer\Core\Diagnostics\DiagnosticsEngine;
use Phpactor\LanguageServer\Core\Handler\HandlerMethodRunner;
use Phpactor\LanguageServer\Core\Handler\Handlers;
use Phpactor\LanguageServer\Core\Server\ClientApi;
use Phpactor\LanguageServer\Core\Server\ResponseWatcher\DeferredResponseWatcher;
use Phpactor\LanguageServer\Core\Server\RpcClient\JsonRpcClient;
use Phpactor\LanguageServer\Core\Server\Transmitter\MessageTransmitter;
use Phpactor\LanguageServer\Core\Service\ServiceManager;
use Phpactor\LanguageServer\Core\Service\ServiceProviders;
use Phpactor\LanguageServer\Core\Workspace\Workspace as PhpactorWorkspace;
use Phpactor\LanguageServer\Handler\System\ExitHandler;
use Phpactor\LanguageServer\Handler\System\ServiceHandler;
use XPHP\Lsp\Handler\XphpTextDocumentHandler;
use Phpactor\LanguageServer\Handler\Workspace\CommandHandler;
use Phpactor\LanguageServer\Listener\DidChangeWatchedFilesListener;
use Phpactor\LanguageServer\Listener\ServiceListener;
use Phpactor\LanguageServer\Listener\WorkspaceListener;
use Phpactor\LanguageServer\Middleware\CancellationMiddleware;
use Phpactor\LanguageServer\Middleware\ErrorHandlingMiddleware;
use Phpactor\LanguageServer\Middleware\HandlerMiddleware;
use Phpactor\LanguageServer\Middleware\InitializeMiddleware;
use Phpactor\LanguageServer\Middleware\ResponseHandlingMiddleware;
use Phpactor\LanguageServer\Middleware\ShutdownMiddleware;
use Phpactor\LanguageServer\Service\DiagnosticsService;
use Phpactor\LanguageServerProtocol\InitializeParams;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use XPHP\Lsp\Analyzer\Analyzer;
use XPHP\Lsp\Analyzer\ParsedDocumentCache;
use XPHP\Lsp\Analyzer\WorkspaceAnalyzer;
use XPHP\Lsp\Diagnostics\XphpDiagnosticsProvider;
use XPHP\Lsp\Handler\WorkspaceSymbols;
use XPHP\Lsp\Handler\XphpCompletionHandler;
use XPHP\Lsp\Handler\XphpDefinitionHandler;
use XPHP\Lsp\Handler\XphpDocumentSymbolHandler;
use XPHP\Lsp\Handler\XphpCodeActionHandler;
use XPHP\Lsp\Handler\XphpCodeActionResolveHandler;
use XPHP\Lsp\Handler\XphpCompletionResolveHandler;
use XPHP\Lsp\Handler\XphpDocumentHighlightHandler;
use XPHP\Lsp\Handler\XphpCallHierarchyHandler;
use XPHP\Lsp\Handler\XphpCodeLensHandler;
use XPHP\Lsp\Handler\XphpFoldingRangeHandler;
use XPHP\Lsp\Handler\XphpInlayHintHandler;
use XPHP\Lsp\Handler\XphpSignatureHelpHandler;
use XPHP\Lsp\Handler\XphpTypeDefinitionHandler;
use XPHP\Lsp\Handler\XphpFileWatcherHandler;
use XPHP\Lsp\Handler\XphpHoverHandler;
use XPHP\Lsp\Handler\XphpReferencesHandler;
use XPHP\Lsp\Handler\XphpRenameHandler;
use XPHP\Lsp\Handler\XphpWillRenameFilesHandler;
use XPHP\Lsp\Handler\XphpImplementationHandler;
use XPHP\Lsp\Handler\XphpPullDiagnosticsHandler;
use XPHP\Lsp\Handler\XphpSemanticTokensHandler;
use XPHP\Lsp\Handler\XphpTypeHierarchyHandler;
use XPHP\Lsp\Handler\XphpWorkspaceSymbolHandler;
use XPHP\Lsp\Reflection\ReflectorFactory;
use XPHP\Lsp\Reflection\FqnIndex;
use XPHP\Lsp\Resolver\CompletionIndex;
use XPHP\Lsp\Resolver\CompositeClassLikeLookup;
use XPHP\Lsp\Resolver\FilesystemClassLikeLookup;
use XPHP\Lsp\Resolver\GenericParamRegistry;
use XPHP\Lsp\Resolver\GenericResolver;
use XPHP\Lsp\Resolver\DiagnosticCodeActionProvider;
use XPHP\Lsp\Resolver\ImportCodeActionProvider;
use XPHP\Lsp\Resolver\OptimizeImportsCodeActionProvider;
use XPHP\Lsp\Resolver\PhpCompletionResolver;
use XPHP\Lsp\Resolver\ReferenceFinder;
use XPHP\Lsp\Resolver\RenameProvider;
use XPHP\Lsp\Resolver\WorkspaceClassLikeLookup;
use XPHP\Lsp\Resolver\PhpDefinitionResolver;
use XPHP\Lsp\Resolver\PhpHoverResolver;
use XPHP\Transpiler\Monomorphize\XphpSourceParser;
/**
* Builds the LSP dispatcher with the standard phpactor middleware stack and
* registers the xphp diagnostics provider. Closely mirrors the acme-ls example
* shipped with phpactor/language-server (lib/example/server/acme-ls/) — every
* piece here exists in that template; the only xphp-specific wiring is the
* DiagnosticsProvider construction.
*
* Service announcement: the diagnostics service is enabled by default at
* initialize (see InitializeMiddleware below — phpactor auto-starts services
* named in initializationOptions.initializedServices, or all services if no
* list is provided by the client).
*/
final class LspDispatcherFactory implements DispatcherFactory
{
public function __construct(
private readonly LoggerInterface $logger = new NullLogger(),
/**
* Diagnostics debounce window, in milliseconds. 300 ms is the LSP-community
* default — long enough to coalesce per-keystroke storms, short enough that
* the user perceives diagnostics as "live."
*/
private readonly int $diagnosticsDebounceMs = 300,
) {
}
public function create(MessageTransmitter $transmitter, InitializeParams $initializeParams): Dispatcher
{
$responseWatcher = new DeferredResponseWatcher();
$clientApi = new ClientApi(new JsonRpcClient($transmitter, $responseWatcher));
$workspace = new PhpactorWorkspace($this->logger);
// Shared analyzer + version-keyed AST cache, scoped to this LSP session.
// Every handler (hover, definition, completion, diagnostics) reads
// through the cache so a workspace pass costs O(unchanged docs serves
// from cache) rather than O(N parses per keystroke).
$xphpParser = new XphpSourceParser((new ParserFactory())->createForHostVersion());
$analyzer = new Analyzer($xphpParser);
$cache = new ParsedDocumentCache($analyzer);
// Worse-reflection-backed engine for PHP-semantic GTD/hover/completion
// -- everything beyond xphp generics. Built once per LSP session and
// shared across resolvers. `rootPath` is what `InitializeParams`
// hands us as the project workspace root; an empty string => no
// filesystem walking (workspace + stubs only).
$rootPath = $initializeParams->rootPath ?? '';
// FqnIndex is the single workspace-wide FQN -> declaration map.
// Replaces three parallel walks (FilesystemSourceLocator's private
// map, WorkspaceSymbols' open-doc walk, WorkspaceClassLikeLookup's
// open-doc walk). Phase-0 of the LSP follow-up roadmap.
$fqnIndex = new FqnIndex($workspace, $cache, $xphpParser, $rootPath);
$reflector = (new ReflectorFactory(
$workspace,
$cache,
$xphpParser,
$rootPath,
ReflectorFactory::defaultStubPath(),
ReflectorFactory::defaultCacheDir(),
$fqnIndex,
))->build();
// Per-session registry of (namespace, paramName) pairs harvested from
// generic ClassLike declarations in open documents. Resolvers query
// this when formatting type names so a post-strip placeholder
// reference like `App\Containers\T` renders as `T` in hover/completion
// detail, matching what the user wrote in the original `<T>` source.
// Phase 0.5: GenericParamRegistry now consumes FqnIndex (open + filesystem)
// so the prettify pass sees placeholder names from filesystem-only
// classes too -- not just open-doc declarations.
$genericParams = new GenericParamRegistry($fqnIndex);
// GenericResolver is a stronger pass that does actual type-arg
// substitution: `$user = $users->first()` where `$users = new
// Collection<User>(...)` resolves to `?App\Models\User` rather than
// the unresolved `?T` the prettify path can produce. Consulted
// first in renderVariable; falls back to prettify on misses.
// Composite chain: workspace (live) -> filesystem (on-disk). Open-doc
// declarations win; closed-file declarations fall through to the
// filesystem-backed FilesystemClassLikeLookup, which re-parses on
// demand via FqnIndex and returns the ClassLike with xphp attributes
// intact -- exactly what GenericResolver needs to substitute type-args
// when Collection.xphp isn't open in the editor.
$classLikeLookup = new CompositeClassLikeLookup(
new WorkspaceClassLikeLookup($workspace, $cache),
new FilesystemClassLikeLookup($fqnIndex),
);
$genericResolver = new GenericResolver($workspace, $cache, $classLikeLookup, $xphpParser, $fqnIndex);
// PhpDefinitionResolver takes GenericResolver too (Phase 0.7) so GTD
// on property access through a generic method's return type can
// resolve via the substituted receiver class.
$phpDefinitionResolver = new PhpDefinitionResolver($workspace, $xphpParser, $reflector, $cache, $genericResolver);
$phpHoverResolver = new PhpHoverResolver($workspace, $xphpParser, $reflector, $genericParams, $genericResolver);
$diagnosticsProvider = new XphpDiagnosticsProvider(
$cache,
new WorkspaceAnalyzer(),
$workspace,
$fqnIndex,
// Push path: lets a workspace pass re-publish diagnostics for the
// dependents of the edited file (cross-file broadcast).
$clientApi,
);
$diagnosticsEngine = new DiagnosticsEngine(
$clientApi,
$this->logger,
[$diagnosticsProvider],
$this->diagnosticsDebounceMs,
);
$diagnosticsService = new DiagnosticsService($diagnosticsEngine, workspace: $workspace);
$serviceProviders = new ServiceProviders($diagnosticsService);
$serviceManager = new ServiceManager($serviceProviders, $this->logger);
// DiagnosticsService is both a ServiceProvider AND a ListenerProviderInterface —
// registering it directly on the event dispatcher is what subscribes
// provideDiagnostics() to didOpen / didChange / didSave events.
//
// Phase 2.4: DidChangeWatchedFilesListener (phpactor-shipped) listens
// for the `initialized` event and sends `client/registerCapability`
// back to the client to subscribe to fs-watch notifications for
// **/*.xphp and **/*.php. PhpStorm + VS Code both advertise
// `dynamicRegistration: true` for this; on clients that don't, the
// listener silently no-ops and the filesystem index stays
// one-shot-at-first-query (the pre-2.4 behaviour).
$eventDispatcher = new AggregateEventDispatcher(
new ServiceListener($serviceManager),
new WorkspaceListener($workspace),
new DidChangeWatchedFilesListener(
$clientApi,
['**/*.xphp', '**/*.php'],
$initializeParams->capabilities,
),
// Fix I: warm the FQN index off the `Initialized` event so
// the first user-facing hover/definition/completion doesn't
// pay the ~500ms filesystem-walk cost in-band. Async via
// Amp\asyncCall -- doesn't block the initialize handshake.
new \XPHP\Lsp\Reflection\FqnIndexWarmer($fqnIndex),
// Perf #1: warm ParsedDocumentCache with every filesystem-
// indexed file so the cold first `textDocument/references`
// (codeLens click, Alt+F7) skips the per-file parse step --
// dominant cost in the prod 7.5s/click measurement.
// Runs on the same Initialized event, independently of the
// FQN warmer above; both are asyncCall-dispatched.
new \XPHP\Lsp\Analyzer\ParsedDocumentCacheWarmer($fqnIndex, $cache, $workspace),
$diagnosticsService,
);
// Single WorkspaceSymbols shared across the two handlers that need it
// (completion + definition). Both call the same in-memory AST cache,
// so reusing the helper avoids constructing parallel collectors.
$workspaceSymbols = new WorkspaceSymbols($workspace, $cache);
// CompletionIndex unifies workspace + stubs FQNs. Stubs are loaded
// from the same path we already extracted to in `ReflectorFactory`,
// so the index's stubs portion is a one-time JSON read on first use
// (built on demand if missing).
$completionIndex = new CompletionIndex($workspaceSymbols, ReflectorFactory::defaultStubPath());
$phpCompletionResolver = new PhpCompletionResolver(
$workspace,
$xphpParser,
$reflector,
$completionIndex,
$cache,
$genericParams,
$genericResolver,
);
// The CodeLens "Show references" command (`xphp.showReferences`) is
// handled CLIENT-side (VS Code wrapper command / PhpStorm
// XphpShowReferencesCommandsSupport), never round-tripped. But the
// two clients disagree on whether it must be advertised in
// `executeCommandProvider`:
// - PhpStorm's LSP API only renders a CodeLens as *clickable* when
// its command is advertised here -- omit it and the lens shows as
// dead text.
// - VS Code (vscode-languageclient) auto-registers a forwarding
// command for every advertised command, which SHADOWS the
// extension's own `xphp.showReferences` handler and round-trips
// the click to this server's no-op.
// So advertise by default (PhpStorm, Helix, ...) and let the VS Code
// extension opt out via the `advertiseCodeLensCommand: false`
// initialization option. The no-op handler is a safety net for any
// client that does round-trip the (advertised) command.
$executeCommands = [];
if (self::clientWantsCodeLensCommandAdvertised($initializeParams)) {
$executeCommands[XphpCodeLensHandler::COMMAND_NAME] = new ClosureCommand(
static fn (...$args): \Amp\Promise => new \Amp\Success(null),
);
}
$handlers = new Handlers(
new XphpTextDocumentHandler($eventDispatcher),
new ServiceHandler($serviceManager, $clientApi),
new CommandHandler(new CommandDispatcher($executeCommands)),
new ExitHandler(),
new XphpHoverHandler($workspace, $cache, $phpHoverResolver),
new XphpDefinitionHandler(
$workspace,
$cache,
$workspaceSymbols,
$fqnIndex,
new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver),
$phpDefinitionResolver,
$genericResolver,
),
new XphpTypeDefinitionHandler($phpDefinitionResolver),
new XphpCompletionHandler($workspace, $workspaceSymbols, $phpCompletionResolver, $fqnIndex, $reflector),
new XphpCompletionResolveHandler($reflector),
new XphpSignatureHelpHandler($workspace, $cache, $xphpParser, $reflector),
new XphpInlayHintHandler($workspace, $cache, $genericResolver),
new XphpCodeActionHandler(
$workspace,
new ImportCodeActionProvider($fqnIndex, $cache),
new DiagnosticCodeActionProvider(),
new OptimizeImportsCodeActionProvider($cache),
new \XPHP\Lsp\Resolver\BoundErrorCodeActionProvider(),
),
new XphpCodeActionResolveHandler(),
new XphpDocumentSymbolHandler($workspace, $cache),
new XphpCallHierarchyHandler($workspace, $cache, $fqnIndex, $xphpParser),
new XphpCodeLensHandler(
$workspace,
$cache,
new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver),
),
new XphpFoldingRangeHandler($workspace, $cache),
new XphpWorkspaceSymbolHandler($fqnIndex),
new XphpFileWatcherHandler($fqnIndex, $workspace, $cache),
new XphpReferencesHandler(
$workspace,
new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver),
),
new XphpDocumentHighlightHandler(
$workspace,
new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver),
$cache,
new \XPHP\Lsp\Resolver\DocumentHighlightKindResolver(),
),
new XphpRenameHandler(
$workspace,
$renameProvider = new RenameProvider(
$workspace,
new ReferenceFinder($workspace, $cache, $fqnIndex, $xphpParser, $reflector, $genericResolver),
$fqnIndex,
self::clientSupportsRenameFileOp($initializeParams),
),
),
// Cycle L Half B: workspace/willRenameFiles -- file-rename
// -> class-rename text edits. Pairs with the plugin's
// AsyncFileListener which sends the request on .xphp/.php
// file moves. Shares the rename machinery with
// textDocument/rename via the just-bound $renameProvider.
new XphpWillRenameFilesHandler(
$workspace,
$cache,
$xphpParser,
$renameProvider,
new \XPHP\Lsp\Resolver\NamespaceMoveProvider(
$workspace,
$cache,
$fqnIndex,
$xphpParser,
),
),
new XphpSemanticTokensHandler($workspace, $cache),
new XphpPullDiagnosticsHandler($workspace, $diagnosticsProvider),
new XphpTypeHierarchyHandler($workspace, $cache, $xphpParser, $fqnIndex),
new XphpImplementationHandler($workspace, $cache, $xphpParser, $fqnIndex),
);
$runner = new HandlerMethodRunner(
$handlers,
new ChainArgumentResolver(
// LspObjectArgumentResolver runs BEFORE the framework's
// `*Params$`-only resolver so handlers whose first
// parameter is a non-Params LSP object (CompletionItem,
// CodeAction) get a properly deserialised instance
// instead of `array_values($params)` splatted scalars.
// Backs textDocument/completionItem/resolve and
// codeAction/resolve.
new \XPHP\Lsp\Dispatcher\LspObjectArgumentResolver(),
new LanguageSeverProtocolParamsResolver(),
new PassThroughArgumentResolver(),
),
);
return new MiddlewareDispatcher(
new ErrorHandlingMiddleware($this->logger),
new InitializeMiddleware($handlers, $eventDispatcher, [
'name' => 'xphp-lsp',
'version' => '0.2.3',
]),
new ShutdownMiddleware($eventDispatcher),
new ResponseHandlingMiddleware($responseWatcher),
// Anchor FQN resolution to each request's textDocument so duplicate
// FQNs resolve to the declaration nearest the file being worked on.
new \XPHP\Lsp\Dispatcher\OriginTrackingMiddleware($fqnIndex),
new CancellationMiddleware($runner),
new HandlerMiddleware($runner),
);
}
/**
* Per LSP spec: when the client advertises
* `workspace.workspaceEdit.resourceOperations`, the server must
* only emit ops in that list. PhpStorm lists `["create"]` only
* (no `rename`/`delete`), so any `RenameFile` we send is silently
* dropped on the client side and the user sees a partial apply.
*
* **Cycle L attempt 1** added a plugin-side opt-in
* (`initializationOptions.xphpAcceptsRenameFile`) so the server
* could emit RenameFile ops regardless of the standard
* advertisement. Prod-test (xphp-20260530-161814 log id=50)
* proved the opt-in self-defeating: PhpStorm advertises
* `failureHandling: "abort"`, so when LSP4IJ's WorkspaceEdit
* applier sees the unsupported RenameFile op, it aborts the
* ENTIRE WorkspaceEdit including the text edits the user
* actually wanted. Reverted -- the flag is now read but no
* longer fires the override; we honour the spec-standard
* advertisement only. The xphp-side flag stays on the wire so
* the plugin can be told the server CAN emit the op (for a
* future architecture where the plugin intercepts the rename
* before LSP4IJ's abort-on-failure applier sees it).
*/
private static function clientSupportsRenameFileOp(InitializeParams $initializeParams): bool
{
$ops = $initializeParams->capabilities?->workspace?->workspaceEdit?->resourceOperations ?? null;
if (!is_array($ops)) {
return false;
}
return in_array('rename', $ops, true);
}
/**
* Whether to advertise the CodeLens "Show references" command in
* `executeCommandProvider`. Defaults to true (PhpStorm needs it for
* clickable lenses; Helix and other clients are unaffected or treat it
* as a no-op). A client that auto-registers forwarding commands for
* advertised commands -- vscode-languageclient does -- opts out by
* sending `initializationOptions: {advertiseCodeLensCommand: false}`,
* so its own client-side handler isn't shadowed.
*/
private static function clientWantsCodeLensCommandAdvertised(InitializeParams $initializeParams): bool
{
$opts = $initializeParams->initializationOptions;
if (is_object($opts)) {
$opts = get_object_vars($opts);
}
if (!is_array($opts) || !array_key_exists('advertiseCodeLensCommand', $opts)) {
return true;
}
return $opts['advertiseCodeLensCommand'] !== false;
}
}