diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a839c69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.DS_Store +.vscode/ +.idea/ +.git/ +tests/ +fixtures/ +coverage/ +coverage.xml +images/ diff --git a/.travis.yml b/.travis.yml index caefbec..0199d52 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,9 @@ language: php php: - '7.0' +services: + - docker + cache: directories: - vendor @@ -16,3 +19,9 @@ script: after_success: - bash <(curl -s https://codecov.io/bash) + - | + if [[ $TRAVIS_TAG == v* ]]; then + docker build -t felixfbecker/php-language-server:${TRAVIS_TAG:1} . + docker login -e="$DOCKER_EMAIL" -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" + docker push felixfbecker/php-language-server:${TRAVIS_TAG:1} + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f4291db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ + +# Running this container will start a language server that listens for TCP connections on port 2088 +# Every connection will be run in a forked child process + +# Please note that before building the image, you have to install dependencies with `composer install` + +FROM php:7-cli +MAINTAINER Felix Becker + +RUN apt-get update \ + # Needed for CodeSniffer + && apt-get install -y libxml2 libxml2-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN docker-php-ext-configure pcntl --enable-pcntl +RUN docker-php-ext-install pcntl +COPY ./php.ini /usr/local/etc/php/conf.d/ + +COPY ./ /srv/phpls + +WORKDIR /srv/phpls + +EXPOSE 2088 + +CMD ["--tcp-server=0:2088"] + +ENTRYPOINT ["php", "bin/php-language-server.php"] diff --git a/README.md b/README.md index dae264d..de9167b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,16 @@ Example: php bin/php-language-server.php --tcp=127.0.0.1:12345 +#### `--tcp-server=host:port` (optional) +Causes the server to use a tcp connection for communicating with the language client instead of using STDIN/STDOUT. +The server will listen on the given address for a connection. +If PCNTL is available, will fork a child process for every connection. +If not, will only accept one connection and the connection cannot be reestablished once closed, spawn a new process instead. + +Example: + + php bin/php-language-server.php --tcp-server=127.0.0.1:12345 + #### `--memory-limit=integer` (optional) Sets memory limit for language server. Equivalent to [memory-limit](http://php.net/manual/en/ini.core.php#ini.memory-limit) php.ini directive. diff --git a/bin/php-language-server.php b/bin/php-language-server.php index cad4215..7cb3178 100644 --- a/bin/php-language-server.php +++ b/bin/php-language-server.php @@ -3,7 +3,7 @@ use LanguageServer\{LanguageServer, ProtocolStreamReader, ProtocolStreamWriter}; use Sabre\Event\Loop; -$options = getopt('', ['tcp::', 'memory-limit::']); +$options = getopt('', ['tcp::', 'tcp-server::', 'memory-limit::']); ini_set('memory_limit', $options['memory-limit'] ?? -1); @@ -31,21 +31,67 @@ set_exception_handler(function (\Throwable $e) { @cli_set_process_title('PHP Language Server'); if (!empty($options['tcp'])) { + // Connect to a TCP server $address = $options['tcp']; $socket = stream_socket_client('tcp://' . $address, $errno, $errstr); if ($socket === false) { - fwrite(STDERR, "Could not connect to language client. Error $errno\n"); - fwrite(STDERR, "$errstr\n"); + fwrite(STDERR, "Could not connect to language client. Error $errno\n$errstr"); exit(1); } - $inputStream = $outputStream = $socket; + stream_set_blocking($socket, false); + $ls = new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket) + ); + Loop\run(); +} else if (!empty($options['tcp-server'])) { + // Run a TCP Server + $address = $options['tcp-server']; + $tcpServer = stream_socket_server('tcp://' . $address, $errno, $errstr); + if ($tcpServer === false) { + fwrite(STDERR, "Could not listen on $address. Error $errno\n$errstr"); + exit(1); + } + fwrite(STDOUT, "Server listening on $address\n"); + if (!extension_loaded('pcntl')) { + fwrite(STDERR, "PCNTL is not available. Only a single connection will be accepted\n"); + } + while ($socket = stream_socket_accept($tcpServer, -1)) { + fwrite(STDOUT, "Connection accepted\n"); + stream_set_blocking($socket, false); + if (extension_loaded('pcntl')) { + // If PCNTL is available, fork a child process for the connection + // An exit notification will only terminate the child process + $pid = pcntl_fork(); + if ($pid === -1) { + fwrite(STDERR, "Could not fork\n"); + exit(1); + } else if ($pid === 0) { + // Child process + $ls = new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket) + ); + Loop\run(); + // Just for safety + exit(0); + } + } else { + // If PCNTL is not available, we only accept one connection. + // An exit notification will terminate the server + $ls = new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket) + ); + Loop\run(); + } + } } else { - $inputStream = STDIN; - $outputStream = STDOUT; + // Use STDIO + stream_set_blocking(STDIN, false); + $ls = new LanguageServer( + new ProtocolStreamReader(STDIN), + new ProtocolStreamWriter(STDOUT) + ); + Loop\run(); } - -stream_set_blocking($inputStream, false); - -$server = new LanguageServer(new ProtocolStreamReader($inputStream), new ProtocolStreamWriter($outputStream)); - -Loop\run(); diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..334e2c5 --- /dev/null +++ b/php.ini @@ -0,0 +1,5 @@ + +# php.ini for Docker + +error_reporting = E_ALL +display_errors = stderr diff --git a/src/LanguageServer.php b/src/LanguageServer.php index 7f88494..4670fa8 100644 --- a/src/LanguageServer.php +++ b/src/LanguageServer.php @@ -108,12 +108,12 @@ class LanguageServer extends AdvancedJsonRpc\Dispatcher /** * The initialize request is sent as the first request from the client to the server. * - * @param int $processId The process Id of the parent process that started the server. * @param ClientCapabilities $capabilities The capabilities provided by the client (editor) * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param int|null $processId The process Id of the parent process that started the server. Is null if the process has not been started by another process. If the parent process is not alive then the server should exit (see exit notification) its process. * @return InitializeResult */ - public function initialize(int $processId, ClientCapabilities $capabilities, string $rootPath = null): InitializeResult + public function initialize(ClientCapabilities $capabilities, string $rootPath = null, int $processId = null): InitializeResult { $this->rootPath = $rootPath; $this->clientCapabilities = $capabilities; diff --git a/src/PhpDocument.php b/src/PhpDocument.php index cf16495..1e1a726 100644 --- a/src/PhpDocument.php +++ b/src/PhpDocument.php @@ -344,7 +344,7 @@ class PhpDocument */ public function getDefinitions() { - return $this->definitions; + return $this->definitions ?? []; } /** diff --git a/tests/LanguageServerTest.php b/tests/LanguageServerTest.php index 4a35fe6..8bd7b4f 100644 --- a/tests/LanguageServerTest.php +++ b/tests/LanguageServerTest.php @@ -71,7 +71,7 @@ class LanguageServerTest extends TestCase }); $server = new LanguageServer($input, $output); $capabilities = new ClientCapabilities; - $server->initialize(getmypid(), $capabilities, realpath(__DIR__ . '/../fixtures')); + $server->initialize($capabilities, realpath(__DIR__ . '/../fixtures'), getmypid()); $promise->wait(); } @@ -119,7 +119,7 @@ class LanguageServerTest extends TestCase $capabilities = new ClientCapabilities; $capabilities->xfilesProvider = true; $capabilities->xcontentProvider = true; - $server->initialize(getmypid(), $capabilities, $rootPath); + $server->initialize($capabilities, $rootPath, getmypid()); $promise->wait(); $this->assertTrue($filesCalled); $this->assertTrue($contentCalled);