diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index e1c2f0170..7ad8572dc 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -69,6 +69,7 @@ use ReflectionType; use UnexpectedValueException; use function array_combine; +use function array_merge; use function array_pop; use function array_reverse; use function array_values; @@ -1742,6 +1743,9 @@ final class Codebase $offset = $position->toOffset($file_contents); + $literal_part = $this->getBeginedLiteralPart($file_path, $position); + $begin_literal_offset = $offset - strlen($literal_part); + [$reference_map, $type_map] = $this->analyzer->getMapsForFile($file_path); if (!$reference_map && !$type_map) { @@ -1776,7 +1780,7 @@ final class Codebase } } - if ($offset - $end_pos === 2 || $offset - $end_pos === 3) { + if ($begin_literal_offset - $end_pos === 2) { $candidate_gap = substr($file_contents, $end_pos, 2); if ($candidate_gap === '->' || $candidate_gap === '::') { @@ -1801,6 +1805,11 @@ final class Codebase return [$possible_reference, '::', $offset]; } + if ($offset <= $end_pos && substr($file_contents, $begin_literal_offset - 2, 2) === '::') { + $class_name = explode('::', $possible_reference)[0]; + return [$class_name, '::', $offset]; + } + // Only continue for references that are partial / don't exist. if ($possible_reference[0] !== '*') { continue; @@ -1816,6 +1825,23 @@ final class Codebase return null; } + public function getBeginedLiteralPart(string $file_path, Position $position): string + { + $is_open = $this->file_provider->isOpen($file_path); + + if (!$is_open) { + throw new UnanalyzedFileException($file_path . ' is not open'); + } + + $file_contents = $this->getFileContents($file_path); + + $offset = $position->toOffset($file_contents); + + preg_match('/\$?\w+$/', substr($file_contents, 0, $offset), $matches); + + return $matches[0] ?? ''; + } + public function getTypeContextAtPosition(string $file_path, Position $position): ?Union { $file_contents = $this->getFileContents($file_path); @@ -1858,9 +1884,12 @@ final class Codebase try { $class_storage = $this->classlike_storage_provider->get($atomic_type->value); - foreach ($class_storage->appearing_method_ids as $declaring_method_id) { - $method_storage = $this->methods->getStorage($declaring_method_id); - + $methods = array_merge( + $class_storage->methods, + $class_storage->pseudo_methods, + $class_storage->pseudo_static_methods, + ); + foreach ($methods as $method_storage) { if ($method_storage->is_static || $gap === '->') { $completion_item = new CompletionItem( $method_storage->cased_name, @@ -1957,6 +1986,26 @@ final class Codebase return $completion_items; } + /** + * @param list $items + * @return list + */ + public function filterCompletionItemsByBeginLiteralPart(array $items, string $literal_part): array + { + if (!$literal_part) { + return $items; + } + + $res = []; + foreach ($items as $item) { + if ($item->insertText && strpos($item->insertText, $literal_part) === 0) { + $res[] = $item; + } + } + + return $res; + } + /** * @return list */ diff --git a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php index cb359d5b9..c8b0d36e7 100644 --- a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php @@ -297,6 +297,7 @@ class TextDocument try { $completion_data = $this->codebase->getCompletionDataAtPosition($file_path, $position); + $literal_part = $this->codebase->getBeginedLiteralPart($file_path, $position); if ($completion_data) { [$recent_type, $gap, $offset] = $completion_data; @@ -305,6 +306,8 @@ class TextDocument ->textDocument->completion->completionItem->snippetSupport ?? false; $completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap, $snippetSupport); + $completion_items = + $this->codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); } elseif ($gap === '[') { $completion_items = $this->codebase->getCompletionItemsForArrayKeys($recent_type); } else { diff --git a/tests/LanguageServer/CompletionTest.php b/tests/LanguageServer/CompletionTest.php index 942556f56..268f39982 100644 --- a/tests/LanguageServer/CompletionTest.php +++ b/tests/LanguageServer/CompletionTest.php @@ -15,6 +15,7 @@ use Psalm\Tests\TestCase; use Psalm\Tests\TestConfig; use Psalm\Type; +use function array_map; use function count; class CompletionTest extends TestCase @@ -370,7 +371,7 @@ class CompletionTest extends TestCase $codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $this->assertNull($codebase->getCompletionDataAtPosition('somefile.php', new Position(16, 41))); + $this->assertSame(['B\C', '->', 456], $codebase->getCompletionDataAtPosition('somefile.php', new Position(16, 41))); } public function testCompletionOnTemplatedThisProperty(): void @@ -725,6 +726,201 @@ class CompletionTest extends TestCase $this->assertSame('baz()', $completion_items[1]->insertText); } + public function testObjectPropertyOnAppendToEnd(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'aPr + } + }', + ); + + $codebase->file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(8, 34); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + $literal_part = $codebase->getBeginedLiteralPart('somefile.php', $position); + + $this->assertSame(['B\A&static', '->', 223], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $completion_items = $codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); + $completion_item_texts = array_map(fn($item) => $item->insertText, $completion_items); + + $this->assertSame(['aProp'], $completion_item_texts); + } + + public function testObjectPropertyOnReplaceEndPart(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'aProp2; + } + }', + ); + + $codebase->file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(8, 34); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + $literal_part = $codebase->getBeginedLiteralPart('somefile.php', $position); + + $this->assertSame(['B\A&static', '->', 225], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $completion_items = $codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); + $completion_item_texts = array_map(fn($item) => $item->insertText, $completion_items); + + $this->assertSame(['aProp1', 'aProp2'], $completion_item_texts); + } + + public function testSelfPropertyOnAppendToEnd(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(8, 34); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + $literal_part = $codebase->getBeginedLiteralPart('somefile.php', $position); + + $this->assertSame(['B\A', '::', 237], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $completion_items = $codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); + $completion_item_texts = array_map(fn($item) => $item->insertText, $completion_items); + + $this->assertSame(['$aProp'], $completion_item_texts); + } + + public function testStaticPropertyOnAppendToEnd(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(8, 36); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + $literal_part = $codebase->getBeginedLiteralPart('somefile.php', $position); + + $this->assertSame(['B\A', '::', 239], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $completion_items = $codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); + $completion_item_texts = array_map(fn($item) => $item->insertText, $completion_items); + + $this->assertSame(['$aProp'], $completion_item_texts); + } + + public function testStaticPropertyOnReplaceEndPart(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(8, 34); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + $literal_part = $codebase->getBeginedLiteralPart('somefile.php', $position); + + $this->assertSame(['B\A', '::', 239], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $completion_items = $codebase->filterCompletionItemsByBeginLiteralPart($completion_items, $literal_part); + $completion_item_texts = array_map(fn($item) => $item->insertText, $completion_items); + + $this->assertSame(['$aProp1', '$aProp2'], $completion_item_texts); + } + public function testCompletionOnNewExceptionWithoutNamespace(): void { $codebase = $this->codebase; @@ -1260,6 +1456,38 @@ class CompletionTest extends TestCase $this->assertCount(2, $completion_items); } + public function testCompletionStaticMethodOnDocBlock(): void + { + $codebase = $this->codebase; + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + $this->analyzeFile('somefile.php', new Context()); + + $position = new Position(7, 23); + $completion_data = $codebase->getCompletionDataAtPosition('somefile.php', $position); + + $this->assertSame(['Bar\Alpha', '::', 177], $completion_data); + + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); + $this->assertCount(1, $completion_items); + $this->assertSame('foo()', $completion_items[0]->insertText); + } + public function testCompletionOnClassInstanceReferenceWithAssignmentAfter(): void {