Language Server Platform¶
The Phpactor Language Server Platform is a framework for creating language servers implementing the Language Server Protocol.
Tutorial¶
Getting Started¶
Below is an example which will run a language server which will respond to any request with a response “Hello world!”:
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 | #!/usr/bin/env php <?php use Amp\Success; use Phpactor\LanguageServer\Core\Middleware\RequestHandler; use Phpactor\LanguageServer\Core\Rpc\Message; use Phpactor\LanguageServer\Core\Rpc\RequestMessage; use Phpactor\LanguageServer\Core\Rpc\ResponseMessage; use Phpactor\LanguageServer\Middleware\ClosureMiddleware; use Phpactor\LanguageServer\Core\Dispatcher\Dispatcher\MiddlewareDispatcher; use Phpactor\LanguageServerProtocol\InitializeParams; use Phpactor\LanguageServer\Core\Server\Transmitter\MessageTransmitter; use Phpactor\LanguageServer\Core\Dispatcher\Factory\ClosureDispatcherFactory; use Phpactor\LanguageServer\LanguageServerBuilder; require __DIR__ . '/../../vendor/autoload.php'; $builder = LanguageServerBuilder::create(new ClosureDispatcherFactory( function (MessageTransmitter $transmitter, InitializeParams $params) { return new MiddlewareDispatcher( new ClosureMiddleware(function (Message $message, RequestHandler $handler) { if (!$message instanceof RequestMessage) { return $handler->handle($message); } return new Success(new ResponseMessage($message->id, 'Hello World!')); }) ); } )); $builder ->build() ->run(); |
LanguageServerBuilder
abstracts the creation of streams and builds the Language Server. It accepts an instance ofDispatcherFactory
-ClosureDispatcherFactory
is aDispatcherFactory
. This class has the responsibility initializing the session. It is invoked when the Language Server client sendsinitialize
method, providing its capabilities.MessageTransmitter
is how your session can communicate with the client - you wouldn’t normally use this directly, but more on this later. TheInitializeParams
is a class containing the initialization information from the client, including theClientCapabilities
.MiddlewareDispatcher
Is aDispatcher
which uses the Middleware concept - this is the pipeline for incoming requests. Requests go in, andResponseMessage
classes come out (ornull
if no response is necessary).ClosureMiddleware
is aMiddleware
which allows you to specific a\Closure
instead of implementing a new class (which is what you’d normally do). TheMessage
is the incoming message (Request
,Notification
orResponse
) from the client, theRequestHandler
is used to delegate to the nextMiddleware
.- We return a
ResponseMessage
wrapped in aPromise
. We only return aResponse
forRequest
messages, and theResponse
must reference the request’s ID. - The
Success
class is aPromise
which resolves immediately. Returning aPromise
allows us to run non-blocking co-routines. - Then finally build and run the server. It will listen on STDIO by default.
If you run this example, you should be able to connect to the language server and it should respond (incorrectly) to all requests with “Hello World!”.
Let’s try it out.
$ echo '{"id":1,"method":"foobar","params":[]}' | ./bin/proxy request | php example/server/minimal.php
The proxy
binary file is used only for this demonstration, it adds the
necessary formatting to the message before passing it to our new language
server (running on STDIO by default).
It should show something like:
At this point you could connect an IDE to your new Language Server, but it wouldn’t do very much.
In the next chapter we’ll try and introduce some more concepts and add some language server functionality.
Creating a Language Server¶
In the previous tutorial we used the ClosureDispatcherFactory
. This is
fine, but let’s now implement our own application - AcmeLS
and give it a
dedicated dispatcher factory AcmeLsDispatcherFactory
. This will be the
ingress for a new session:
1 2 3 4 5 6 7 8 9 10 11 12 13 | #!/usr/bin/env php <?php require __DIR__ . '/../../vendor/autoload.php'; use AcmeLs\AcmeLsDispatcherFactory; use Phpactor\LanguageServer\LanguageServerBuilder; use Psr\Log\NullLogger; $logger = new NullLogger(); LanguageServerBuilder::create(new AcmeLsDispatcherFactory($logger)) ->build() ->run(); |
The dispatcher is responsible for bootstrapping your language server session and creating all the necessary classes that you will need. You might, for example, instantiate a container here using some initialization options form the client.
The Language Server invokes the factory method of this class with two
necessary dependencies: MessageTransmitter
and the
InitializeParams
.
Let’s just jump in at the deep end:
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 | <?php namespace AcmeLs; use Phpactor\LanguageServer\Adapter\Psr\AggregateEventDispatcher; use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\PassThroughArgumentResolver; use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\LanguageSeverProtocolParamsResolver; use Phpactor\LanguageServer\Core\Dispatcher\ArgumentResolver\ChainArgumentResolver; use Phpactor\LanguageServer\Core\Workspace\Workspace; use Phpactor\LanguageServer\Listener\WorkspaceListener; use Phpactor\LanguageServer\Middleware\CancellationMiddleware; use Phpactor\LanguageServer\Middleware\ErrorHandlingMiddleware; use Phpactor\LanguageServer\Middleware\InitializeMiddleware; use Phpactor\LanguageServerProtocol\InitializeParams; use Phpactor\LanguageServer\Core\Dispatcher\Dispatcher; use Phpactor\LanguageServer\Core\Handler\HandlerMethodRunner; use Phpactor\LanguageServer\Core\Dispatcher\DispatcherFactory; use Phpactor\LanguageServer\Handler\System\ExitHandler; use Phpactor\LanguageServer\Handler\Workspace\CommandHandler; use Phpactor\LanguageServer\Middleware\ResponseHandlingMiddleware; use Phpactor\LanguageServer\Core\Command\CommandDispatcher; use Phpactor\LanguageServer\Handler\System\ServiceHandler; use Phpactor\LanguageServer\Core\Handler\Handlers; use Phpactor\LanguageServer\Handler\TextDocument\TextDocumentHandler; use Phpactor\LanguageServer\Core\Dispatcher\Dispatcher\MiddlewareDispatcher; use Phpactor\LanguageServer\Listener\ServiceListener; use Phpactor\LanguageServer\Core\Server\RpcClient\JsonRpcClient; use Phpactor\LanguageServer\Core\Service\ServiceManager; use Phpactor\LanguageServer\Core\Service\ServiceProviders; use Phpactor\LanguageServer\Example\Service\PingProvider; use Phpactor\LanguageServer\Core\Server\ClientApi; use Phpactor\LanguageServer\Core\Server\ResponseWatcher\DeferredResponseWatcher; use Phpactor\LanguageServer\Core\Server\Transmitter\MessageTransmitter; use Phpactor\LanguageServer\Middleware\HandlerMiddleware; use Phpactor\LanguageServer\Middleware\ShutdownMiddleware; use Psr\Log\LoggerInterface; class AcmeLsDispatcherFactory implements DispatcherFactory { /** * @var LoggerInterface */ private $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } public function create(MessageTransmitter $transmitter, InitializeParams $initializeParams): Dispatcher { $responseWatcher = new DeferredResponseWatcher(); $clientApi = new ClientApi(new JsonRpcClient($transmitter, $responseWatcher)); $serviceProviders = new ServiceProviders( new PingProvider($clientApi) ); $serviceManager = new ServiceManager($serviceProviders, $this->logger); $workspace = new Workspace(); $eventDispatcher = new AggregateEventDispatcher( new ServiceListener($serviceManager), new WorkspaceListener($workspace) ); $handlers = new Handlers( new TextDocumentHandler($eventDispatcher), new ServiceHandler($serviceManager, $clientApi), new CommandHandler(new CommandDispatcher([])), ); $runner = new HandlerMethodRunner( $handlers, new ChainArgumentResolver( new LanguageSeverProtocolParamsResolver(), new PassThroughArgumentResolver() ), ); return new MiddlewareDispatcher( new ErrorHandlingMiddleware($this->logger), new InitializeMiddleware($handlers, $eventDispatcher, [ 'name' => 'acme', 'version' => '1', ]), new ShutdownMiddleware($eventDispatcher), new ResponseHandlingMiddleware($responseWatcher), new CancellationMiddleware($runner), new HandlerMiddleware($runner) ); } } |
MessageTransmitter
: This class is provided by the Language Server and allows you to send messages to the connected client. This is quite low-level, instead you should use theClientApi
.InitializeParams
: The initialization parameters provided by the client.ResponseWatcher
: Class which tracks requests made by the server to the client and can resolve responses, used as a dependency for…ClientApi
: This class allows you to send (and receive) messages to the client. It provides a convenient API$clientApi->window()->showMessage()->error('Foobar')
. In cases where the API doesn’t provide what you need you can use the …RpcClient
which allows you to send requests and notifications to the client.ServiceProviders
,PingProvider
,ServiceManager
: Ping provider is an annoying service which pings your client for no reason at all, it is an example background process. See Service Providers for more information on services.Workspace
: This class can keeps track of LSP text documents.EventDispatcher
: Required by some middlewares to transmit events which can be handled byPsr\EventDispatcher\ListenerProviderInterface
classes. We use:ServiceListener
: responsible to start all the services when the server isinitialized
.WorkspaceListener
: will update the above mentionedWorkspace
based on events emitted by theTextDocumentHandler
.
Handlers
: Method handlers are responsible for handling incoming method requests, this is the main extension point, see Method HandlersHandlerMethodRunner
: This class is responsible for calling methods on your class and converting the array of parameters from the request to match the parameters on a handler’s method. Find out more in Method Handlers.
The RPC method handlers:
TextDocumentHandler
: Handles all text document notifications from the client (i.e. text document synchronization). It emmits events.ServiceHandler
: Non-protocol handler for starting/stopping monitoring services.CommandHandler
: Clients can execute commands (e.g. refactor something) on the server, this class handlers that. See Commands.ExitHandler
: Handlesshutdown
notifications from the client.
Finally we build the middleware dispatcher with the middlewares which will handle the request:
ErrorHandlingMiddleware
: Will catch any errors thrown by succeeding middlewares and log them. As a long running process we don’t want to exit each time something goes wrong.InitializeMiddleware
: This middleware responds to the initialize request. It also allows your handlers to inject capabiltities into the response, more in Method Handlers.ResponseHandlingMiddleware
: Catch responses to requests made by the server, and resolves them using ourResponseWatcher
.CancellationMiddleware
: Often the client knows that a request is no longer required, and it request that that request be cancelled (imagine a long-running search). This middleware intercepts the$/cancelRequest
notifications and tells the runner to cancel them.HandlerMiddleware
: The final destination - will forward the request to the handler runner which will dispatch our handlers
In your application you might choose to connect all of this magic in a dependency injection container.
Reference¶
Language Server Builder¶
The language server builder takes care of:
- Creating the necessary streams.
- Creating the tester.
It is optional, you can also have a look inside and instantiate the server yourself.
It accepts:
Phpactor\LanguageServer\Core\Dispatcher\DispatcherFactory
.- An optional PSR
Psr\Log\LoggerInterface
.
<?php
use Phpactor\LanguageServer\LanguageServerBuilder;
$server = Phpactor\LanguageServer\LanguageServerBuilder::create(
new MyDispatcher(),
new NullLogger()
)->build();
$server->run();
// or
$promise = $server->start();
Run or Start¶
The run method on the built language server will start the server and listen for connections. It will also register an error and signal handler.
The start method will simply return a promise, without doing anything extra.
Method Handlers¶
Method handlers handle the RPC calls from the client.
They look like this:
<?php
use Phpactor\LanguageServer\Core\Handler\Handler;
use Amp\Promise;
class MyHandler implements Handler
{
public function methods(): array
{
return [
'method/name' => 'doSomething',
];
}
public function doSomething($args, CancellationToken $canellation): Promise
{
return new Success('hello!');
}
}
Once registered this command will respond to an RPC request to method/name
with hello!
.
Argument Resolvers¶
The first arguments passed to the parameter will depend on the argument resolvers
used by the HandlerRunner
, the last argument is always a cancellation
token more on this later.
<?php
$runner = new HandlerMethodRunner(
new Handlers(new MyHandler()),
new ChainArgumentResolver(
new LanguageSeverProtocolParamsResolver(),
new PassThroughArgumentResolver()
),
);
Here we use the ChainArgumentResolver
to try two different stragies.
LanguageServerProtocolParamsResolver¶
This strategy will see if your method implements an LSP *Params
class and
automatically instantaite it for you:
<?php
class MyHandler implements Handler
{
public function methods(): array
{
return [
'textDocument/completion' => 'complete',
];
}
public function doSomething(CompletionParams $completionParams, CancellationToken $canellation): Promise
{
$uriToTextDocument = $completionParams->textDocument->uri;
// ...
}
}
You should be able to do this with any method documented in the language server specification.
DTLArgumentResolver¶
This argument resolver will try and match the parameters from the request to the parameters of your method.
PassThroughArgumentResolver¶
This is a fallback resolver which will simply pass the raw array of arguments.
Co-routines¶
Your method MUST return an Amp\Promise
. If you return immediately you can
use the new Success($value)
promise, if you do any interruptable* work
which takes a significant amount of time you should use a co-routing. For
example:
<?php
class MyHandler implements Handler
{
//...
public function doSomething(CompletionParams $params, CancellationToken $canellation): Promise
{
return \Amp\call(function () {
// ...
$completionItems = [];
foreach($this->magicCompletionProvider->provideCompletions($params) as $completion) {
$completionItems[] = $completion;
yield Amp\delay(1);
}
return $completionItems;
});
}
}
The above will process a single completion item but then yield control back to the server for 1 millisecond before continuing. This allows the server to do other things (like for example cancel this request).
Cancellation¶
The CancellationToken
passed to the method handler can throw an exception
if the request is cancelled as follows:
<?php
class MyHandler implements Handler
{
//...
public function doSomething(CompletionParams $params, CancellationToken $canellation): Promise
{
return \Amp\call(function () {
// ...
$completionItems = [];
foreach($this->magicCompletionProvider->provideCompletions($params) as $completion) {
$completionItems[] = $completion;
yield Amp\delay(1);
try {
$cancellation->throwIfRequested();
} catch (Amp\CancelledException $cancelled) {
break;
}
}
return $completionItems;
});
}
}
In the above example, when the server cancels this request, the exception will be thrown and we will return early.
Service Providers¶
Service providers are background services which can should be started on
on the initialized
notification from the client.
A good example of a service is a code indexing service which watches the file system and indexes code when files change.
Example¶
A full example of a service provider:
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 | <?php namespace Phpactor\LanguageServer\Example\Service; use Amp\CancellationToken; use Amp\CancelledException; use Amp\Delayed; use Amp\Promise; use Phpactor\LanguageServer\Core\Server\ClientApi; use Phpactor\LanguageServer\Core\Service\ServiceProvider; /** * Example service which shows a "ping" message every second. */ class PingProvider implements ServiceProvider { /** * @var ClientApi */ private $client; public function __construct(ClientApi $client) { $this->client = $client; } /** * {@inheritDoc} */ public function services(): array { return [ 'ping' ]; } /** * @return Promise<null> */ public function ping(CancellationToken $cancel): Promise { return \Amp\call(function () use ($cancel) { while (true) { try { $cancel->throwIfRequested(); } catch (CancelledException $cancelled) { break; } yield new Delayed(1000); $this->client->window()->showMessage()->info('ping'); } }); } } |
This is similar to method handlers with the exception that:
- The
services
method provides only an array of method names. The name doubles as both the method and service name. - The method is called when the Language Server is initialized (or when it is started via. the service manager).
- Services are passed only a
CancellationToken
.
Usage¶
<?php
$serviceProviders = new ServiceProviders(
new PingProvider($clientApi)
);
$serviceManager = new ServiceManager($serviceProviders, $logger);
$eventDispatcher = new EventDispatcher(
new ServiceListener($serviceManager)
);
$handlers = new Handlers(
// ...
new ServiceHandler($serviceManager, $clientApi),
// ...
);
return new MiddlewareDispatcher(
// ...
new InitializeMiddleware($handlers, $eventDispatcher)
// ...
);
In the above code the ServiceManager
is responsible for starting and
stopping services, the ServiceHandler
handles RPC methods to start/stop
services, and we use the ServiceListener
to start the services when the
server is initialized (based on the Initialized
event issued by the
InitializeMiddleware
.
Diagnostic Providers¶
Diagnostic providers are invoked when text documents are updated and are responsible to send diagnostics (e.g. actual or potential problems with the code) to the client.
Example¶
Example of a diagnostic provider:
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 | <?php namespace Phpactor\LanguageServer\Example\Diagnostics; use Amp\CancellationToken; use Amp\Promise; use Phpactor\LanguageServerProtocol\Diagnostic; use Phpactor\LanguageServerProtocol\DiagnosticSeverity; use Phpactor\LanguageServerProtocol\Position; use Phpactor\LanguageServerProtocol\Range; use Phpactor\LanguageServerProtocol\TextDocumentItem; use Phpactor\LanguageServer\Core\Diagnostics\DiagnosticsProvider; use function Amp\call; class SayHelloDiagnosticsProvider implements DiagnosticsProvider { /** * {@inheritDoc} */ public function provideDiagnostics(TextDocumentItem $textDocument, CancellationToken $cancel): Promise { /** @phpstan-ignore-next-line */ return call(function () { return [ new Diagnostic( new Range( new Position(0, 0), new Position(1, 0) ), 'This is the first line, hello!', DiagnosticSeverity::INFORMATION ) ]; }); } public function name(): string { return 'say-hello'; } } |
$diagnosticsService = new DiagnosticsService(
new DiagnosticsEngine($clientApi, new AggregateDiagnosticsProvider(
$logger,
new SayHelloDiagnosticsProvider()
))
);
Integration¶
Diagnostics are facilitated through the “Diagnostics Service” which in turn
requires the DiagnosticsEngine
which accepts a DiagnosticProvider
-
below we use the AggregateDiagnosticsProvider
which allows you to provide
many diagnostic providers:
<?php
$diagnosticsService = new DiagnosticsService(
new DiagnosticsEngine($clientApi, new AggregateDiagnosticsProvider(
$logger,
new SayHelloDiagnosticsProvider()
))
);
Code Action Provider¶
Code action providers can be implemented to enable you to suggest commands which can be executed on a given text document and range.
Example¶
Example of a command:
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 | <?php namespace Phpactor\LanguageServer\Example\CodeAction; use Amp\CancellationToken; use Amp\Promise; use Phpactor\LanguageServerProtocol\CodeAction; use Phpactor\LanguageServerProtocol\CodeActionKind; use Phpactor\LanguageServerProtocol\Command; use Phpactor\LanguageServerProtocol\Range; use Phpactor\LanguageServerProtocol\TextDocumentItem; use Phpactor\LanguageServer\Core\CodeAction\CodeActionProvider; use function Amp\call; class SayHelloCodeActionProvider implements CodeActionProvider { public function provideActionsFor(TextDocumentItem $textDocument, Range $range, CancellationToken $cancel): Promise { /** @phpstan-ignore-next-line */ return call(function (): array { return [ CodeAction::fromArray([ 'title' => 'Alice', 'command' => new Command('Hello Alice', 'phpactor.say_hello', [ 'Alice', ]) ]), CodeAction::fromArray([ 'title' => 'Bob', 'command' => new Command('Hello Bob', 'phpactor.say_hello', [ 'Bob', ]) ]) ]; }); } /** * {@inheritDoc} */ public function kinds(): array { return [CodeActionKind::QUICK_FIX]; } public function describe(): string { return 'says hello!'; } } |
It unconditionally provides two code actions: Alice
and Bob
. It
references a previously registered commands such as:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <?php namespace Phpactor\LanguageServer\Example\Command; use Phpactor\LanguageServer\Core\Command\Command; use Phpactor\LanguageServer\Core\Server\ClientApi; class SayHelloCommand implements Command { /** * @var ClientApi */ private $api; public function __construct(ClientApi $api) { $this->api = $api; } public function __invoke(string $name): void { $this->api->window()->showMessage()->info(sprintf('Hello %s!', $name)); } } |
Commands¶
Commands are issued from the client to the server, they are similar in concept to RPC calls with the exception that they are expcitly registered with the server and executed via. an RPC method.
Usage¶
The command handler accepts a CommandDispatcher
which in turn accepts a
map of command names to invokable objects:
<?php
use Phpactor\LanguageServer\Handler\Workspace\CommandHandler;
use Phpactor\LanguageServer\Workspace\CommandDispatcher;
// ...
$handler = new CommandHandler(
new CommandDispatcher([
'my_command' => function (array $args) {
// do something
}
])
));
Now, when the client connects, the server will signify (via.
ServerCapabilties
) that this command is available.
Guide¶
Testing¶
This package includes some tools to make testing easier.
Protocol Factory¶
The ProtocolFactory
is a utility class for creating LSP protocol objects:
<?php
use Phpactor\LanguageServer\Test\ProtocolFactory;
$item = ProtocolFactory::textDocumentItem('uri', 'content');
$initializeParams = ProtocolFactory::initializeParams('/path/to/rootUri');
This is useful as the LSP objects can be complicated and we can assume some defaults using the factory.
Unit Testing Handlers, Services etc.¶
You can use the Language Server Tester to test your handlers, services, commands etc as follows:
<?php
$tester = LanguageServerTesterBuilder::create()
->addHanlder($myHandler)
->addServiceProvider($myServiceProvider)
->addCommand($myCommand)
->build();
$result = $tester->requestAndWait('soMyThing', []);
Lean more about the LanguageServerTester
Integration Testing¶
If you are using the LanguageServerBuilder
to manage the instantiation of
your LanguageServer
then, assuming you are using some kind of dependency
injection container, you can use the tester
method to get the
Language Server Tester.
<?php
$builder = $container->get(LanguageServerBuilder::class);
assert($builder instanceof LanguageServerBuilder);
$tester = $builder->tester();
$response = $tester->requestAndWait('foobar', ['bar' => 'foo']);
$response = $tester->notifyAndWait('foobar', ['bar' => 'foo']);
This will provide the Language Server Tester with the “real” dispatcher.
Language Server Tester¶
The tester provides access to a test transmitter from which you can access any message sent by the server:
<?php
// ...
$messageOrNull = $tester->transmitter()->shift();
You can also use some convenience methods to control the server:
<?php
// ...
$messageOrNull = $tester->textDocument()->open('/uri/to/text.php', 'content');
$tester->services()->start('myservice');
The tester will automatically initialize the server, but you can also pass your own initialization parameters:
<?php
// ...
$tester = $builder->tester(ProtocolFactory::initializeParams('/uri/foobar.php'));