Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 104 additions & 205 deletions tests/Handler/TextDocumentSyncHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,266 +5,165 @@
namespace Firehed\PhpLsp\Tests\Handler;

use Firehed\PhpLsp\Cache\InvalidatableInterface;
use Firehed\PhpLsp\Document\DocumentManager;
use Firehed\PhpLsp\Domain\ClasslikeName;
use Firehed\PhpLsp\Domain\ComposerAutoloadMap;
use Firehed\PhpLsp\Document\DocumentManagerInterface;
use Firehed\PhpLsp\Document\TextDocument;
use Firehed\PhpLsp\Handler\TextDocumentSyncHandler;
use Firehed\PhpLsp\Knowledge\SymbolSourceInterface;
use Firehed\PhpLsp\Parser\ParseMetrics;
use Firehed\PhpLsp\Knowledge\SymbolSinkInterface;
use Firehed\PhpLsp\Protocol\NotificationMessage;
use Firehed\PhpLsp\Tests\BuildsKnowledgeStackTrait;
use Firehed\PhpLsp\Tests\LoadsFixturesTrait;
use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(TextDocumentSyncHandler::class)]
class TextDocumentSyncHandlerTest extends TestCase
{
use BuildsKnowledgeStackTrait;
use LoadsFixturesTrait;

private DocumentManager $manager;
private ParseMetrics $metrics;
private SymbolSourceInterface $source;
private TextDocumentSyncHandler $handler;

protected function setUp(): void
{
$this->manager = new DocumentManager();
$production = ProductionSyntaxSource::create();
$this->metrics = $production->metrics;
$knowledge = $this->knowledgeStackForMap(new ComposerAutoloadMap(), $production);
$this->source = $knowledge->source;
$this->handler = new TextDocumentSyncHandler($this->manager, $knowledge->sink, $knowledge->invalidator);
}
private const URI = 'file:///test.php';

public function testSupports(): void
{
self::assertTrue($this->handler->supports('textDocument/didOpen'));
self::assertTrue($this->handler->supports('textDocument/didChange'));
self::assertTrue($this->handler->supports('textDocument/didClose'));
self::assertFalse($this->handler->supports('textDocument/hover'));
$handler = new TextDocumentSyncHandler(
self::createStub(DocumentManagerInterface::class),
self::createStub(SymbolSinkInterface::class),
self::createStub(InvalidatableInterface::class),
);

self::assertTrue($handler->supports('textDocument/didOpen'), 'didOpen is handled');
self::assertTrue($handler->supports('textDocument/didChange'), 'didChange is handled');
self::assertTrue($handler->supports('textDocument/didClose'), 'didClose is handled');
self::assertFalse(
$handler->supports('textDocument/hover'),
'the handler does not claim any other method',
);
}

public function testDidOpen(): void
public function testHandleDidOpenRegistersWithTheManagerThenForwardsTheDocumentToTheSink(): void
{
$notification = NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didOpen',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'languageId' => 'php',
'version' => 1,
'text' => '<?php echo "hello";',
],
],
]);
$document = new TextDocument(self::URI, 'php', 1, '<?php echo "hello";');

$documents = $this->createMock(DocumentManagerInterface::class);
$documents
->expects($this->once())
->method('open')
->with(self::URI, 'php', 1, '<?php echo "hello";');
$documents
->expects($this->once())
->method('get')
->with(self::URI)
->willReturn($document);
$documents->expects($this->never())->method('update');
$documents->expects($this->never())->method('close');

$symbols = $this->createMock(SymbolSinkInterface::class);
$symbols->expects($this->once())->method('openDocument')->with($document);
$symbols->expects($this->never())->method('updateDocument');
$symbols->expects($this->never())->method('closeDocument');

$this->handler->handle($notification);

$doc = $this->manager->get('file:///test.php');
self::assertNotNull($doc);
self::assertSame('<?php echo "hello";', $doc->getContent());
}
$invalidator = $this->createMock(InvalidatableInterface::class);
$invalidator->expects($this->never())->method('invalidate');

/**
* Step 0 acceptance: one parse per handled message. The sync path parsed
* twice — once here to register the document's classes, and again to index
* its symbols — for every keystroke the client sent.
*/
public function testSyncParsesTheDocumentOnce(): void
{
$notification = NotificationMessage::fromArray([
$handler = new TextDocumentSyncHandler($documents, $symbols, $invalidator);
$handler->handle(NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didOpen',
'params' => [
'textDocument' => [
'uri' => 'file:///fixtures/src/Domain/User.php',
'uri' => self::URI,
'languageId' => 'php',
'version' => 1,
'text' => $this->loadFixture('src/Domain/User.php'),
'text' => '<?php echo "hello";',
],
],
]);

$this->handler->handle($notification);

self::assertSame(
1,
$this->metrics->getParseCount(),
'registering classes and indexing symbols share one parse',
);
]));
}

public function testDidChange(): void
public function testHandleDidChangeAppliesTheLastContentChangeThroughTheManagerAndSink(): void
{
$this->manager->open('file:///test.php', 'php', 1, '<?php echo "v1";');
$document = new TextDocument(self::URI, 'php', 2, '<?php echo "v2";');

$documents = $this->createMock(DocumentManagerInterface::class);
$documents
->expects($this->once())
->method('update')
->with(self::URI, '<?php echo "v2";', 2);
$documents
->expects($this->once())
->method('get')
->with(self::URI)
->willReturn($document);
$documents->expects($this->never())->method('open');
$documents->expects($this->never())->method('close');

$symbols = $this->createMock(SymbolSinkInterface::class);
$symbols->expects($this->once())->method('updateDocument')->with($document);
$symbols->expects($this->never())->method('openDocument');
$symbols->expects($this->never())->method('closeDocument');

$invalidator = $this->createMock(InvalidatableInterface::class);
$invalidator->expects($this->never())->method('invalidate');

$notification = NotificationMessage::fromArray([
$handler = new TextDocumentSyncHandler($documents, $symbols, $invalidator);
$handler->handle(NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didChange',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'version' => 2,
],
'textDocument' => ['uri' => self::URI, 'version' => 2],
// Only the last change's text is applied (full-sync; LSP §Document Synchronization).
'contentChanges' => [
['text' => '<?php echo "v1-skipped";'],
['text' => '<?php echo "v2";'],
],
],
]);

$this->handler->handle($notification);

$doc = $this->manager->get('file:///test.php');
self::assertNotNull($doc);
self::assertSame('<?php echo "v2";', $doc->getContent());
self::assertSame(2, $doc->version);
]));
}

public function testDidClose(): void
public function testHandleDidChangeIsANoOpWhenContentChangesIsEmpty(): void
{
$this->manager->open('file:///test.php', 'php', 1, '<?php');

$notification = NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didClose',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
],
],
]);

$this->handler->handle($notification);
$documents = $this->createMock(DocumentManagerInterface::class);
$documents->expects($this->never())->method('update');
$documents->expects($this->never())->method('get');

self::assertNull($this->manager->get('file:///test.php'));
}
$symbols = $this->createMock(SymbolSinkInterface::class);
$symbols->expects($this->never())->method('updateDocument');

public function testDidOpenRegistersClasses(): void
{
$notification = NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didOpen',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'languageId' => 'php',
'version' => 1,
'text' => '<?php class MyTestClass {}',
],
],
]);

$this->handler->handle($notification);

/** @var class-string $className */
$className = 'MyTestClass'; // @phpstan-ignore varTag.nativeType
$classInfo = $this->source->lookupClassLike(ClasslikeName::fromFullyQualified($className));
self::assertNotNull($classInfo);
self::assertSame('MyTestClass', $classInfo->name->qualifiedName->shortName);
}

public function testDidChangeUpdatesClasses(): void
{
// Open with the initial class through the handler, registering it.
$this->handler->handle(NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didOpen',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'languageId' => 'php',
'version' => 1,
'text' => '<?php class OldClass {}',
],
],
]));
$invalidator = $this->createMock(InvalidatableInterface::class);
$invalidator->expects($this->never())->method('invalidate');

$notification = NotificationMessage::fromArray([
$handler = new TextDocumentSyncHandler($documents, $symbols, $invalidator);
$handler->handle(NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didChange',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'version' => 2,
],
'contentChanges' => [
['text' => '<?php class NewClass {}'],
],
'textDocument' => ['uri' => self::URI, 'version' => 2],
'contentChanges' => [],
],
]);

$this->handler->handle($notification);

/** @var class-string $oldClasslikeName */
$oldClasslikeName = 'OldClass'; // @phpstan-ignore varTag.nativeType
/** @var class-string $newClasslikeName */
$newClasslikeName = 'NewClass'; // @phpstan-ignore varTag.nativeType
self::assertNull($this->source->lookupClassLike(ClasslikeName::fromFullyQualified($oldClasslikeName)));
$newClass = $this->source->lookupClassLike(ClasslikeName::fromFullyQualified($newClasslikeName));
self::assertNotNull($newClass);
self::assertSame('NewClass', $newClass->name->qualifiedName->shortName);
]));
}

public function testDidCloseInvalidatesTheOnDiskCacheSoTheNextQueryReReadsDisk(): void
public function testHandleDidCloseInvalidatesAndClosesAcrossAllThreeCollaborators(): void
{
// Closing an edited file must drop the on-disk cache so the next query
// reflects disk rather than the pre-edit cached value (RFC 1 §5.3).
$uri = 'file:///workspace/src/Widget.php';
$this->manager->open($uri, 'php', 1, '<?php');

$documents = $this->createMock(DocumentManagerInterface::class);
$documents->expects($this->once())->method('close')->with(self::URI);
$documents->expects($this->never())->method('open');
$documents->expects($this->never())->method('update');

$symbols = $this->createMock(SymbolSinkInterface::class);
$symbols->expects($this->once())->method('closeDocument')->with(self::URI);
$symbols->expects($this->never())->method('openDocument');
$symbols->expects($this->never())->method('updateDocument');

// The invalidator drop is what makes the next on-disk query re-read disk
// rather than the pre-edit cached value (RFC 1 §5.3). Covered here at the
// handler boundary; the fan-out through composite invalidators lives in
// the integration test.
$invalidator = $this->createMock(InvalidatableInterface::class);
$invalidator->expects($this->once())
->method('invalidate')
->with($uri);

$production = ProductionSyntaxSource::create();
$knowledge = $this->knowledgeStackForMap(new ComposerAutoloadMap(), $production);
$handler = new TextDocumentSyncHandler($this->manager, $knowledge->sink, $invalidator);
$invalidator->expects($this->once())->method('invalidate')->with(self::URI);

$handler = new TextDocumentSyncHandler($documents, $symbols, $invalidator);
$handler->handle(NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didClose',
'params' => [
'textDocument' => ['uri' => $uri],
'textDocument' => ['uri' => self::URI],
],
]));
}

public function testDidCloseRemovesClasses(): void
{
/** @var class-string $className */
$className = 'ToBeRemoved'; // @phpstan-ignore varTag.nativeType

$openNotification = NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didOpen',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
'languageId' => 'php',
'version' => 1,
'text' => '<?php class ToBeRemoved {}',
],
],
]);
$this->handler->handle($openNotification);
self::assertNotNull($this->source->lookupClassLike(ClasslikeName::fromFullyQualified($className)));

$closeNotification = NotificationMessage::fromArray([
'jsonrpc' => '2.0',
'method' => 'textDocument/didClose',
'params' => [
'textDocument' => [
'uri' => 'file:///test.php',
],
],
]);
$this->handler->handle($closeNotification);

self::assertNull($this->source->lookupClassLike(ClasslikeName::fromFullyQualified($className)));
}
}
Loading
Loading