diff --git a/.gitignore b/.gitignore index 569d6cd1..3e1480b1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /tests/*/Workspace /composer.lock /.php_cs.cache +/.phpbench diff --git a/composer.json b/composer.json index c3880128..998c3786 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "friendsofphp/php-cs-fixer": "^2.17", "jangregor/phpstan-prophecy": "^0.8.0", "phpactor/test-utils": "^1.1", - "phpbench/phpbench": "^1.0.0-alpha3", + "phpbench/phpbench": "^1.0.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "~0.12.0", "phpunit/phpunit": "^9.0", diff --git a/lib/LanguageServerWorseReflection/LanguageServerWorseReflectionExtension.php b/lib/LanguageServerWorseReflection/LanguageServerWorseReflectionExtension.php index 79b45e5d..975ee888 100644 --- a/lib/LanguageServerWorseReflection/LanguageServerWorseReflectionExtension.php +++ b/lib/LanguageServerWorseReflection/LanguageServerWorseReflectionExtension.php @@ -15,6 +15,8 @@ class LanguageServerWorseReflectionExtension implements Extension { + const PARAM_UPDATE_INTERVAL = 'language_server_worse_reflection.workspace_index.update_interval'; + /** * {@inheritDoc} */ @@ -25,6 +27,12 @@ public function load(ContainerBuilder $container) public function configure(Resolver $schema) { + $schema->setDefaults([ + self::PARAM_UPDATE_INTERVAL => 100, + ]); + $schema->setDescriptions([ + self::PARAM_UPDATE_INTERVAL => 'Minimum interval to update the workspace index as documents are updated (in milliseconds)' + ]); } private function registerSourceLocator(ContainerBuilder $container): void @@ -45,7 +53,8 @@ private function registerSourceLocator(ContainerBuilder $container): void $container->register(WorkspaceIndex::class, function (Container $container) { return new WorkspaceIndex( - ReflectorBuilder::create()->build() + ReflectorBuilder::create()->build(), + $container->getParameter(self::PARAM_UPDATE_INTERVAL) ); }); } diff --git a/lib/LanguageServerWorseReflection/Workspace/WorkspaceIndex.php b/lib/LanguageServerWorseReflection/Workspace/WorkspaceIndex.php index 491ff600..42f4120a 100644 --- a/lib/LanguageServerWorseReflection/Workspace/WorkspaceIndex.php +++ b/lib/LanguageServerWorseReflection/Workspace/WorkspaceIndex.php @@ -8,6 +8,8 @@ use Phpactor\WorseReflection\Core\Name; use Phpactor\WorseReflection\Core\Reflector\SourceCodeReflector; use RuntimeException; +use function Amp\asyncCall; +use function Amp\delay; class WorkspaceIndex { @@ -29,9 +31,25 @@ class WorkspaceIndex */ private $documentToNameMap = []; - public function __construct(SourceCodeReflector $reflector) + /** + * @var TextDocument|null + */ + private $documentToUpdate; + + /** + * @var bool + */ + private $waiting = false; + + /** + * @var int + */ + private $updateInterval; + + public function __construct(SourceCodeReflector $reflector, int $updateInterval = 1000) { $this->reflector = $reflector; + $this->updateInterval = $updateInterval; } public function documentForName(Name $name): ?TextDocument @@ -49,24 +67,58 @@ public function index(TextDocument $textDocument): void $this->updateDocument($textDocument); } + /** + * Refresh the document. + * + * In order to prevent continuous reparsing the document will + * only be refreshed at the sepecified interval. + */ private function updateDocument(TextDocument $textDocument): void { + if ($this->waiting) { + $this->documentToUpdate = $textDocument; + return; + } + + $this->documentToUpdate = null; + $newNames = []; foreach ($this->reflector->reflectClassesIn($textDocument) as $reflectionClass) { $newNames[] = $reflectionClass->name()->full(); } - + foreach ($this->reflector->reflectFunctionsIn($textDocument) as $reflectionFunction) { $newNames[] = $reflectionFunction->name()->full(); } - $this->updateNames($textDocument, $newNames, $this->documentToNameMap[(string)$textDocument->uri()] ?? []); + $this->updateNames( + $textDocument, + $newNames, + $this->documentToNameMap[(string)$textDocument->uri()] ?? [] + ); + + if ($this->updateInterval === 0) { + return; + } + + $this->waiting = true; + + asyncCall(function () { + yield delay($this->updateInterval); + $this->waiting = false; + + if (null === $this->documentToUpdate) { + return; + } + + $this->updateDocument($this->documentToUpdate); + }); } private function updateNames(TextDocument $textDocument, array $newNames, array $currentNames): void { $namesToRemove = array_diff($currentNames, $newNames); - + foreach ($newNames as $name) { $this->byName[$name] = $textDocument; } diff --git a/phpbench.json b/phpbench.json index c39d806c..724b4bdd 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,4 +1,7 @@ { "bootstrap": "vendor/autoload.php", - "path": "tests/LanguageServerCodeTransform/Benchmark" + "path": [ + "tests/LanguageServerCodeTransform/Benchmark", + "tests/LanguageServerWorseReflection/Benchmark" + ] } diff --git a/tests/LanguageServerCompletion/IntegrationTestCase.php b/tests/LanguageServerCompletion/IntegrationTestCase.php index 903d3fcd..a6cdb79e 100644 --- a/tests/LanguageServerCompletion/IntegrationTestCase.php +++ b/tests/LanguageServerCompletion/IntegrationTestCase.php @@ -52,7 +52,7 @@ protected function createTester(): LanguageServerTester LanguageServerBridgeExtension::class, ], [ - FilePathResolverExtension::PARAM_APPLICATION_ROOT => __DIR__ .'/../../' + FilePathResolverExtension::PARAM_APPLICATION_ROOT => __DIR__ .'/../../', ]); $builder = $container->get(LanguageServerBuilder::class); diff --git a/tests/LanguageServerWorseReflection/Benchmark/WorkspaceIndexBench.php b/tests/LanguageServerWorseReflection/Benchmark/WorkspaceIndexBench.php new file mode 100644 index 00000000..91455570 --- /dev/null +++ b/tests/LanguageServerWorseReflection/Benchmark/WorkspaceIndexBench.php @@ -0,0 +1,59 @@ +tester = $this->createTester(); + $this->tester->initialize(); + $this->tester->textDocument()->open('file:///foobar', ''); + } + + /** + * @ParamProviders({"provideUpdate"}) + * @Revs(10) + * @Iterations(10) + */ + public function benchUpdate(array $params): void + { + $this->tester->textDocument()->update('file:///foobar', $params['text']); + } + + public function provideUpdate(): Generator + { + $source = mb_str_split((string)file_get_contents( + __DIR__ . '/source/source.php.example' + ), 1); + + $buffer = ''; + + foreach ($source as $index => $char) { + $buffer .= $char; + if (0 === $index % 1000) { + yield 'length: ' . strlen($buffer) => [ + 'text' => $buffer + ]; + } + } + } +} diff --git a/tests/LanguageServerWorseReflection/Benchmark/source/source.php.example b/tests/LanguageServerWorseReflection/Benchmark/source/source.php.example new file mode 100644 index 00000000..3c55239b --- /dev/null +++ b/tests/LanguageServerWorseReflection/Benchmark/source/source.php.example @@ -0,0 +1,193 @@ +reflector = $reflector; + $this->renderer = $renderer; + $this->workspace = $workspace; + } + + public function methods(): array + { + return [ + 'textDocument/hover' => 'hover', + ]; + } + + public function hover( + TextDocumentIdentifier $textDocument, + Position $position + ): Promise { + return \Amp\call(function () use ($textDocument, $position) { + $document = $this->workspace->get($textDocument->uri); + $offset = PositionConverter::positionToByteOffset($position, $document->text); + $document = TextDocumentBuilder::create($document->text) + ->uri($document->uri) + ->language('php') + ->build(); + + $offsetReflection = $this->reflector->reflectOffset($document, $offset); + $symbolContext = $offsetReflection->symbolContext(); + $info = $this->infoFromReflecionOffset($offsetReflection); + $string = new MarkupContent('markdown', $info); + + return new Hover($string, new Range( + PositionConverter::byteOffsetToPosition( + ByteOffset::fromInt($symbolContext->symbol()->position()->start()), + $document->__toString() + ), + PositionConverter::byteOffsetToPosition( + ByteOffset::fromInt($symbolContext->symbol()->position()->end()), + $document->__toString() + ) + )); + }); + } + + public function registerCapabiltiies(ServerCapabilities $capabilities): void + { + $capabilities->hoverProvider = true; + } + + private function infoFromReflecionOffset(ReflectionOffset $offset): string + { + $symbolContext = $offset->symbolContext(); + + if ($info = $this->infoFromSymbolContext($symbolContext)) { + return $info; + } + + return $this->renderer->render($offset); + } + + private function infoFromSymbolContext(SymbolContext $symbolContext): ?string + { + try { + return $this->renderSymbolContext($symbolContext); + } catch (CouldNotFormat $e) { + } + + return null; + } + + private function renderSymbolContext(SymbolContext $symbolContext): ?string + { + switch ($symbolContext->symbol()->symbolType()) { + case Symbol::METHOD: + case Symbol::PROPERTY: + case Symbol::CONSTANT: + return $this->renderMember($symbolContext); + case Symbol::CLASS_: + return $this->renderClass($symbolContext->type()); + case Symbol::FUNCTION: + return $this->renderFunction($symbolContext); + } + + return null; + } + + private function renderMember(SymbolContext $symbolContext): string + { + $name = $symbolContext->symbol()->name(); + $container = $symbolContext->containerType(); + + try { + $class = $this->reflector->reflectClassLike((string) $container); + $member = null; + $sep = '#'; + + // note that all class-likes (classes, traits and interfaces) have + // methods but not all have constants or properties, so we play safe + // with members() which is first-come-first-serve, rather than risk + // a fatal error because of a non-existing method. + $symbolType = $symbolContext->symbol()->symbolType(); + switch ($symbolType) { + case Symbol::METHOD: + $member = $class->methods()->get($name); + $sep = '#'; + break; + case Symbol::CONSTANT: + $sep = '::'; + $member = $class->members()->get($name); + break; + case Symbol::PROPERTY: + $sep = '$'; + $member = $class->members()->get($name); + break; + default: + return sprintf('Unknown symbol type "%s"', $symbolType); + } + + return $this->renderer->render(new HoverInformation( + (string)$container.' '.$sep.' '.(string)$member->name(), + $this->renderer->render( + new MemberDocblock($member) + ), + $member + )); + } catch (NotFound $e) { + return $e->getMessage(); + } + } + + private function renderFunction(SymbolContext $symbolContext): string + { + $name = $symbolContext->symbol()->name(); + $function = $this->reflector->reflectFunction($name); + + return $this->renderer->render(new HoverInformation($name, $function->docblock()->formatted(), $function)); + } + + private function renderClass(Type $type): string + { + try { + $class = $this->reflector->reflectClassLike((string) $type); + return $this->renderer->render(new HoverInformation((string)$type, $class->docblock()->formatted(), $class)); + } catch (NotFound $e) { + return $e->getMessage(); + } + } +} diff --git a/tests/LanguageServerWorseReflection/Unit/Workspace/WorkspaceIndexTest.php b/tests/LanguageServerWorseReflection/Unit/Workspace/WorkspaceIndexTest.php index 7f134242..fca48ae8 100644 --- a/tests/LanguageServerWorseReflection/Unit/Workspace/WorkspaceIndexTest.php +++ b/tests/LanguageServerWorseReflection/Unit/Workspace/WorkspaceIndexTest.php @@ -18,7 +18,7 @@ class WorkspaceIndexTest extends TestCase protected function setUp(): void { $reflector = ReflectorBuilder::create()->build(); - $this->index = new WorkspaceIndex($reflector); + $this->index = new WorkspaceIndex($reflector, 0); } public function testIndexesClassesAndReturnsTextDocuments(): void