diff --git a/.gitignore b/.gitignore index 0d602a01f44..064fa341407 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ /vendor-bin/*/composer.lock /vendor-bin/*/vendor/ /tests/fixtures/symlinktest/* - +.vscode .idea/ .vscode/ .php-version diff --git a/composer.json b/composer.json index 48731769287..31ae6807fea 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ }, "require-dev": { "ext-curl": "*", + "amphp/phpunit-util": "^2.0", "bamarni/composer-bin-plugin": "^1.4", "brianium/paratest": "^6.9", "mockery/mockery": "^1.5", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2aa83e33ad9..e53cd8c1c17 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -16,8 +16,6 @@ $const_name $const_name - $matches[0] - $property_name $symbol_name $symbol_parts[1] @@ -227,11 +225,6 @@ expr->getArgs()[0]]]> - - - - - $identifier_name @@ -292,35 +285,6 @@ props[0]]]> - - - - 4]]> - 4]]> - - - - - $capabilities - $processId - $rootPath - - - - - $pair[1] - - - - - $parts[1] - - - - - $contentChanges[0] - - $method_id_parts[1] diff --git a/src/Psalm/CodeLocation.php b/src/Psalm/CodeLocation.php index 04790dfcc73..0bdfd64a683 100644 --- a/src/Psalm/CodeLocation.php +++ b/src/Psalm/CodeLocation.php @@ -169,6 +169,7 @@ private function calculateRealLocation(): void $codebase = $project_analyzer->getCodebase(); + /** @psalm-suppress ImpureMethodCall */ $file_contents = $codebase->getFileContents($this->file_path); $file_length = strlen($file_contents); diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index 2e51cd4975a..d6caa46d1da 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -37,6 +37,8 @@ use Psalm\Internal\Codebase\TaintFlowGraph; use Psalm\Internal\DataFlow\TaintSink; use Psalm\Internal\DataFlow\TaintSource; +use Psalm\Internal\LanguageServer\PHPMarkdownContent; +use Psalm\Internal\LanguageServer\Reference; use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Provider\ClassLikeStorageProvider; use Psalm\Internal\Provider\FileProvider; @@ -67,8 +69,10 @@ use UnexpectedValueException; use function array_combine; +use function array_merge; use function array_pop; use function array_reverse; +use function array_values; use function count; use function dirname; use function error_log; @@ -82,6 +86,7 @@ use function ksort; use function preg_match; use function preg_replace; +use function str_replace; use function strlen; use function strpos; use function strrpos; @@ -390,15 +395,19 @@ private function loadAnalyzer(): void /** * @param array $candidate_files */ - public function reloadFiles(ProjectAnalyzer $project_analyzer, array $candidate_files): void + public function reloadFiles(ProjectAnalyzer $project_analyzer, array $candidate_files, bool $force = false): void { $this->loadAnalyzer(); + if ($force) { + FileReferenceProvider::clearCache(); + } + $this->file_reference_provider->loadReferenceCache(false); FunctionLikeAnalyzer::clearCache(); - if (!$this->statements_provider->parser_cache_provider) { + if ($force || !$this->statements_provider->parser_cache_provider) { $diff_files = $candidate_files; } else { $diff_files = []; @@ -500,7 +509,6 @@ public function scanFiles(int $threads = 1): void } } - /** @psalm-mutation-free */ public function getFileContents(string $file_path): string { return $this->file_provider->getContents($file_path); @@ -593,6 +601,7 @@ public function findReferencesToMethod(string $method_id): array */ public function findReferencesToProperty(string $property_id): array { + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ [$fq_class_name, $property_name] = explode('::', $property_id); return $this->file_reference_provider->getClassPropertyLocations( @@ -970,7 +979,225 @@ public function getFunctionStorageForSymbol(string $file_path, string $symbol): } /** - * @return array{ type: string, description?: string|null}|null + * Get Markup content from Reference + */ + public function getMarkupContentForSymbolByReference( + Reference $reference + ): ?PHPMarkdownContent { + //Direct Assignment + if (is_numeric($reference->symbol[0])) { + return new PHPMarkdownContent( + preg_replace( + '/^[^:]*:/', + '', + $reference->symbol, + ), + ); + } + + //Class + if (strpos($reference->symbol, '::')) { + //Class Method + if (strpos($reference->symbol, '()')) { + $symbol = substr($reference->symbol, 0, -2); + + /** @psalm-suppress ArgumentTypeCoercion */ + $method_id = new MethodIdentifier(...explode('::', $symbol)); + + $declaring_method_id = $this->methods->getDeclaringMethodId( + $method_id, + ); + + if (!$declaring_method_id) { + return null; + } + + $storage = $this->methods->getStorage($declaring_method_id); + + return new PHPMarkdownContent( + $storage->getHoverMarkdown(), + "{$storage->defining_fqcln}::{$storage->cased_name}", + $storage->description, + ); + } + + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + [, $symbol_name] = explode('::', $reference->symbol); + + //Class Property + if (strpos($reference->symbol, '$') !== false) { + $property_id = preg_replace('/^\\\\/', '', $reference->symbol); + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + [$fq_class_name, $property_name] = explode('::$', $property_id); + $class_storage = $this->classlikes->getStorageFor($fq_class_name); + + //Get Real Properties + if (isset($class_storage->declaring_property_ids[$property_name])) { + $declaring_property_class = $class_storage->declaring_property_ids[$property_name]; + $declaring_class_storage = $this->classlike_storage_provider->get($declaring_property_class); + + if (isset($declaring_class_storage->properties[$property_name])) { + $storage = $declaring_class_storage->properties[$property_name]; + return new PHPMarkdownContent( + "{$storage->getInfo()} {$symbol_name}", + $reference->symbol, + $storage->description, + ); + } + } + + //Get Docblock properties + if (isset($class_storage->pseudo_property_set_types['$'.$property_name])) { + return new PHPMarkdownContent( + 'public '. + (string) $class_storage->pseudo_property_set_types['$'.$property_name].' $'.$property_name, + $reference->symbol, + ); + } + + //Get Docblock properties + if (isset($class_storage->pseudo_property_get_types['$'.$property_name])) { + return new PHPMarkdownContent( + 'public '. + (string) $class_storage->pseudo_property_get_types['$'.$property_name].' $'.$property_name, + $reference->symbol, + ); + } + + return null; + } + + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + [$fq_classlike_name, $const_name] = explode( + '::', + $reference->symbol, + ); + + $class_constants = $this->classlikes->getConstantsForClass( + $fq_classlike_name, + ReflectionProperty::IS_PRIVATE, + ); + + if (!isset($class_constants[$const_name])) { + return null; + } + + //Class Constant + return new PHPMarkdownContent( + $class_constants[$const_name]->getHoverMarkdown($const_name), + $fq_classlike_name . '::' . $const_name, + $class_constants[$const_name]->description, + ); + } + + //Procedural Function + if (strpos($reference->symbol, '()')) { + $function_id = strtolower(substr($reference->symbol, 0, -2)); + $file_storage = $this->file_storage_provider->get( + $reference->file_path, + ); + + if (isset($file_storage->functions[$function_id])) { + $function_storage = $file_storage->functions[$function_id]; + + return new PHPMarkdownContent( + $function_storage->getHoverMarkdown(), + $function_id, + $function_storage->description, + ); + } + + if (!$function_id) { + return null; + } + + $function = $this->functions->getStorage(null, $function_id); + + return new PHPMarkdownContent( + $function->getHoverMarkdown(), + $function_id, + $function->description, + ); + } + + //Procedural Variable + if (strpos($reference->symbol, '$') === 0) { + $type = VariableFetchAnalyzer::getGlobalType($reference->symbol, $this->analysis_php_version_id); + if (!$type->isMixed()) { + return new PHPMarkdownContent( + (string) $type, + $reference->symbol, + ); + } + } + + try { + $storage = $this->classlike_storage_provider->get( + $reference->symbol, + ); + return new PHPMarkdownContent( + ($storage->abstract ? 'abstract ' : '') . + 'class ' . + $storage->name, + $storage->name, + $storage->description, + ); + } catch (InvalidArgumentException $e) { + //continue on as normal + } + + if (strpos($reference->symbol, '\\')) { + $const_name_parts = explode('\\', $reference->symbol); + $const_name = array_pop($const_name_parts); + $namespace_name = implode('\\', $const_name_parts); + + $namespace_constants = NamespaceAnalyzer::getConstantsForNamespace( + $namespace_name, + ReflectionProperty::IS_PUBLIC, + ); + //Namespace Constant + if (isset($namespace_constants[$const_name])) { + $type = $namespace_constants[$const_name]; + return new PHPMarkdownContent( + $reference->symbol . ' ' . $type, + $reference->symbol, + ); + } + } else { + $file_storage = $this->file_storage_provider->get( + $reference->file_path, + ); + // ? + if (isset($file_storage->constants[$reference->symbol])) { + return new PHPMarkdownContent( + 'const ' . + $reference->symbol . + ' ' . + $file_storage->constants[$reference->symbol], + $reference->symbol, + ); + } + $type = ConstFetchAnalyzer::getGlobalConstType( + $this, + $reference->symbol, + $reference->symbol, + ); + + //Global Constant + if ($type) { + return new PHPMarkdownContent( + 'const ' . $reference->symbol . ' ' . $type, + $reference->symbol, + ); + } + } + + return new PHPMarkdownContent($reference->symbol); + } + + /** + * @psalm-suppress PossiblyUnusedMethod + * @deprecated will be removed in Psalm 6. use {@see Codebase::getSymbolLocationByReference()} instead */ public function getSymbolInformation(string $file_path, string $symbol): ?array { @@ -995,7 +1222,7 @@ public function getSymbolInformation(string $file_path, string $symbol): ?array $storage = $this->methods->getStorage($declaring_method_id); return [ - 'type' => 'getSignature(true), + 'type' => 'getCompletionSignature(), 'description' => $storage->description, ]; } @@ -1036,7 +1263,7 @@ public function getSymbolInformation(string $file_path, string $symbol): ?array $function_storage = $file_storage->functions[$function_id]; return [ - 'type' => 'getSignature(true), + 'type' => 'getCompletionSignature(), 'description' => $function_storage->description, ]; } @@ -1047,7 +1274,7 @@ public function getSymbolInformation(string $file_path, string $symbol): ?array $function = $this->functions->getStorage(null, $function_id); return [ - 'type' => 'getSignature(true), + 'type' => 'getCompletionSignature(), 'description' => $function->description, ]; } @@ -1100,6 +1327,10 @@ public function getSymbolInformation(string $file_path, string $symbol): ?array } } + /** + * @psalm-suppress PossiblyUnusedMethod + * @deprecated will be removed in Psalm 6. use {@see Codebase::getSymbolLocationByReference()} instead + */ public function getSymbolLocation(string $file_path, string $symbol): ?CodeLocation { if (is_numeric($symbol[0])) { @@ -1182,11 +1413,127 @@ public function getSymbolLocation(string $file_path, string $symbol): ?CodeLocat } } + public function getSymbolLocationByReference(Reference $reference): ?CodeLocation + { + if (is_numeric($reference->symbol[0])) { + $symbol = preg_replace('/:.*/', '', $reference->symbol); + $symbol_parts = explode('-', $symbol); + + if (!isset($symbol_parts[0]) || !isset($symbol_parts[1])) { + return null; + } + + $file_contents = $this->getFileContents($reference->file_path); + + return new Raw( + $file_contents, + $reference->file_path, + $this->config->shortenFileName($reference->file_path), + (int) $symbol_parts[0], + (int) $symbol_parts[1], + ); + } + + try { + if (strpos($reference->symbol, '::')) { + if (strpos($reference->symbol, '()')) { + $symbol = substr($reference->symbol, 0, -2); + + /** @psalm-suppress ArgumentTypeCoercion */ + $method_id = new MethodIdentifier( + ...explode('::', $symbol), + ); + + $declaring_method_id = $this->methods->getDeclaringMethodId( + $method_id, + ); + + if (!$declaring_method_id) { + return null; + } + + $storage = $this->methods->getStorage($declaring_method_id); + + return $storage->location; + } + + if (strpos($reference->symbol, '$') !== false) { + $storage = $this->properties->getStorage( + $reference->symbol, + ); + + return $storage->location; + } + + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ + [$fq_classlike_name, $const_name] = explode( + '::', + $reference->symbol, + ); + + $class_constants = $this->classlikes->getConstantsForClass( + $fq_classlike_name, + ReflectionProperty::IS_PRIVATE, + ); + + if (!isset($class_constants[$const_name])) { + return null; + } + + return $class_constants[$const_name]->location; + } + + if (strpos($reference->symbol, '()')) { + $file_storage = $this->file_storage_provider->get( + $reference->file_path, + ); + + $function_id = strtolower(substr($reference->symbol, 0, -2)); + + if (isset($file_storage->functions[$function_id])) { + return $file_storage->functions[$function_id]->location; + } + + if (!$function_id) { + return null; + } + + return $this->functions->getStorage(null, $function_id) + ->location; + } + + return $this->classlike_storage_provider->get( + $reference->symbol, + )->location; + } catch (UnexpectedValueException $e) { + error_log($e->getMessage()); + + return null; + } catch (InvalidArgumentException $e) { + return null; + } + } + /** + * @psalm-suppress PossiblyUnusedMethod * @return array{0: string, 1: Range}|null */ public function getReferenceAtPosition(string $file_path, Position $position): ?array { + $ref = $this->getReferenceAtPositionAsReference($file_path, $position); + if ($ref === null) { + return null; + } + return [$ref->symbol, $ref->range]; + } + + /** + * Get Reference from Position + */ + public function getReferenceAtPositionAsReference( + string $file_path, + Position $position + ): ?Reference { $is_open = $this->file_provider->isOpen($file_path); if (!$is_open) { @@ -1197,33 +1544,37 @@ public function getReferenceAtPosition(string $file_path, Position $position): ? $offset = $position->toOffset($file_contents); - [$reference_map, $type_map] = $this->analyzer->getMapsForFile($file_path); - - $reference = null; - - if (!$reference_map && !$type_map) { - return null; - } + $reference_maps = $this->analyzer->getMapsForFile($file_path); $reference_start_pos = null; $reference_end_pos = null; + $symbol = null; - ksort($reference_map); + foreach ($reference_maps as $reference_map) { + ksort($reference_map); - foreach ($reference_map as $start_pos => [$end_pos, $possible_reference]) { - if ($offset < $start_pos) { - break; + foreach ($reference_map as $start_pos => [$end_pos, $possible_reference]) { + if ($offset < $start_pos) { + break; + } + + if ($offset > $end_pos) { + continue; + } + $reference_start_pos = $start_pos; + $reference_end_pos = $end_pos; + $symbol = $possible_reference; } - if ($offset > $end_pos) { - continue; + if ($symbol !== null && + $reference_start_pos !== null && + $reference_end_pos !== null + ) { + break; } - $reference_start_pos = $start_pos; - $reference_end_pos = $end_pos; - $reference = $possible_reference; } - if ($reference === null || $reference_start_pos === null || $reference_end_pos === null) { + if ($symbol === null || $reference_start_pos === null || $reference_end_pos === null) { return null; } @@ -1232,7 +1583,7 @@ public function getReferenceAtPosition(string $file_path, Position $position): ? self::getPositionFromOffset($reference_end_pos, $file_contents), ); - return [$reference, $range]; + return new Reference($file_path, $symbol, $range); } /** @@ -1399,6 +1750,7 @@ public function getCompletionDataAtPosition(string $file_path, Position $positio continue; } + /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ $num_whitespace_bytes = preg_match('/\G\s+/', $file_contents, $matches, 0, $end_pos_excluding_whitespace) ? strlen($matches[0]) : 0; @@ -1487,8 +1839,11 @@ public function getTypeContextAtPosition(string $file_path, Position $position): /** * @return list */ - public function getCompletionItemsForClassishThing(string $type_string, string $gap): array - { + public function getCompletionItemsForClassishThing( + string $type_string, + string $gap, + bool $snippets_supported = false + ): array { $completion_items = []; $type = Type::parseString($type_string); @@ -1505,11 +1860,11 @@ public function getCompletionItemsForClassishThing(string $type_string, string $ $completion_item = new CompletionItem( $method_storage->cased_name, CompletionItemKind::METHOD, - (string)$method_storage, + $method_storage->getCompletionSignature(), $method_storage->description, (string)$method_storage->visibility, $method_storage->cased_name, - $method_storage->cased_name . (count($method_storage->params) !== 0 ? '($0)' : '()'), + $method_storage->cased_name, null, null, new Command('Trigger parameter hints', 'editor.action.triggerParameterHints'), @@ -1517,12 +1872,47 @@ public function getCompletionItemsForClassishThing(string $type_string, string $ 2, ); - $completion_item->insertTextFormat = InsertTextFormat::SNIPPET; + if ($snippets_supported && count($method_storage->params) > 0) { + $completion_item->insertText .= '($0)'; + $completion_item->insertTextFormat = + InsertTextFormat::SNIPPET; + } else { + $completion_item->insertText .= '()'; + } $completion_items[] = $completion_item; } } + $pseudo_property_types = []; + foreach ($class_storage->pseudo_property_get_types as $property_name => $type) { + $pseudo_property_types[$property_name] = new CompletionItem( + str_replace('$', '', $property_name), + CompletionItemKind::PROPERTY, + $type->__toString(), + null, + '1', //sort text + str_replace('$', '', $property_name), + ($gap === '::' ? '$' : '') . + str_replace('$', '', $property_name), + ); + } + + foreach ($class_storage->pseudo_property_set_types as $property_name => $type) { + $pseudo_property_types[$property_name] = new CompletionItem( + str_replace('$', '', $property_name), + CompletionItemKind::PROPERTY, + $type->__toString(), + null, + '1', + str_replace('$', '', $property_name), + ($gap === '::' ? '$' : '') . + str_replace('$', '', $property_name), + ); + } + + $completion_items = array_merge($completion_items, array_values($pseudo_property_types)); + foreach ($class_storage->declaring_property_ids as $property_name => $declaring_class) { $property_storage = $this->properties->getStorage( $declaring_class . '::$' . $property_name, @@ -1715,7 +2105,7 @@ public function getCompletionItemsForPartialSymbol( $completion_items[] = new CompletionItem( $function_name, CompletionItemKind::FUNCTION, - $function->getSignature(false), + $function->getCompletionSignature(), $function->description, null, $function_name, @@ -1832,9 +2222,9 @@ private static function getPositionFromOffset(int $offset, string $file_contents ); } - public function addTemporaryFileChanges(string $file_path, string $new_content): void + public function addTemporaryFileChanges(string $file_path, string $new_content, ?int $version = null): void { - $this->file_provider->addTemporaryFileChanges($file_path, $new_content); + $this->file_provider->addTemporaryFileChanges($file_path, $new_content, $version); } public function removeTemporaryFileChanges(string $file_path): void diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index b874537f0c3..6c3d39f2ff4 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -2,7 +2,6 @@ namespace Psalm\Internal\Analyzer; -use Amp\Loop; use Fidry\CpuCoreCounter\CpuCoreCounter; use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound; use InvalidArgumentException; @@ -15,8 +14,6 @@ use Psalm\Internal\Codebase\TaintFlowGraph; use Psalm\Internal\FileManipulation\FileManipulationBuffer; use Psalm\Internal\LanguageServer\LanguageServer; -use Psalm\Internal\LanguageServer\ProtocolStreamReader; -use Psalm\Internal\LanguageServer\ProtocolStreamWriter; use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Provider\ClassLikeStorageProvider; use Psalm\Internal\Provider\FileProvider; @@ -65,7 +62,6 @@ use function array_merge; use function array_shift; use function clearstatcache; -use function cli_set_process_title; use function count; use function defined; use function dirname; @@ -82,14 +78,9 @@ use function microtime; use function mkdir; use function number_format; -use function pcntl_fork; use function preg_match; use function rename; use function sprintf; -use function stream_set_blocking; -use function stream_socket_accept; -use function stream_socket_client; -use function stream_socket_server; use function strlen; use function strpos; use function strtolower; @@ -102,8 +93,6 @@ use const PHP_VERSION; use const PSALM_VERSION; use const STDERR; -use const STDIN; -use const STDOUT; /** * @internal @@ -211,16 +200,6 @@ class ProjectAnalyzer UnnecessaryVarAnnotation::class, ]; - /** - * When this is true, the language server will send the diagnostic code with a help link. - */ - public bool $language_server_use_extended_diagnostic_codes = false; - - /** - * If this is true then the language server will send log messages to the client with additional information. - */ - public bool $language_server_verbose = false; - /** * @param array $generated_report_options */ @@ -230,12 +209,21 @@ public function __construct( ?ReportOptions $stdout_report_options = null, array $generated_report_options = [], int $threads = 1, - ?Progress $progress = null + ?Progress $progress = null, + ?Codebase $codebase = null ) { if ($progress === null) { $progress = new VoidProgress(); } + if ($codebase === null) { + $codebase = new Codebase( + $config, + $providers, + $progress, + ); + } + $this->parser_cache_provider = $providers->parser_cache_provider; $this->project_cache_provider = $providers->project_cache_provider; $this->file_provider = $providers->file_provider; @@ -248,11 +236,7 @@ public function __construct( $this->clearCacheDirectoryIfConfigOrComposerLockfileChanged(); - $this->codebase = new Codebase( - $config, - $providers, - $progress, - ); + $this->codebase = $codebase; $this->stdout_report_options = $stdout_report_options; $this->generated_report_options = $generated_report_options; @@ -394,10 +378,12 @@ private function visitAutoloadFiles(): void ); } - public function server(?string $address = '127.0.0.1:12345', bool $socket_server_mode = false): void + public function serverMode(LanguageServer $server): void { + $server->logInfo("Initializing: Visiting Autoload Files..."); $this->visitAutoloadFiles(); $this->codebase->diff_methods = true; + $server->logInfo("Initializing: Loading Reference Cache..."); $this->file_reference_provider->loadReferenceCache(); $this->codebase->enterServerMode(); @@ -418,103 +404,12 @@ public function server(?string $address = '127.0.0.1:12345', bool $socket_server } } + $server->logInfo("Initializing: Initialize Plugins..."); $this->config->initializePlugins($this); foreach ($this->config->getProjectDirectories() as $dir_name) { $this->checkDirWithConfig($dir_name, $this->config); } - - @cli_set_process_title('Psalm ' . PSALM_VERSION . ' - PHP Language Server'); - - if (!$socket_server_mode && $address) { - // Connect to a TCP server - $socket = stream_socket_client('tcp://' . $address, $errno, $errstr); - if ($socket === false) { - fwrite(STDERR, "Could not connect to language client. Error $errno\n$errstr"); - exit(1); - } - stream_set_blocking($socket, false); - new LanguageServer( - new ProtocolStreamReader($socket), - new ProtocolStreamWriter($socket), - $this, - ); - Loop::run(); - } elseif ($socket_server_mode && $address) { - // Run a 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"); - - $fork_available = true; - if (!extension_loaded('pcntl')) { - fwrite(STDERR, "PCNTL is not available. Only a single connection will be accepted\n"); - $fork_available = false; - } - - $disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); - if (in_array('pcntl_fork', $disabled_functions)) { - fwrite( - STDERR, - "pcntl_fork() is disabled by php configuration (disable_functions directive)." - . " Only a single connection will be accepted\n", - ); - $fork_available = false; - } - - while ($socket = stream_socket_accept($tcpServer, -1)) { - fwrite(STDOUT, "Connection accepted\n"); - stream_set_blocking($socket, false); - if ($fork_available) { - // 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); - } - - if ($pid === 0) { - // Child process - $reader = new ProtocolStreamReader($socket); - $reader->on( - 'close', - static function (): void { - fwrite(STDOUT, "Connection closed\n"); - }, - ); - new LanguageServer( - $reader, - new ProtocolStreamWriter($socket), - $this, - ); - // Just for safety - exit(0); - } - } else { - // If PCNTL is not available, we only accept one connection. - // An exit notification will terminate the server - new LanguageServer( - new ProtocolStreamReader($socket), - new ProtocolStreamWriter($socket), - $this, - ); - Loop::run(); - } - } - } else { - // Use STDIO - stream_set_blocking(STDIN, false); - new LanguageServer( - new ProtocolStreamReader(STDIN), - new ProtocolStreamWriter(STDOUT), - $this, - ); - Loop::run(); - } } /** @psalm-mutation-free */ diff --git a/src/Psalm/Internal/Cli/LanguageServer.php b/src/Psalm/Internal/Cli/LanguageServer.php index d9df6548301..429144b6808 100644 --- a/src/Psalm/Internal/Cli/LanguageServer.php +++ b/src/Psalm/Internal/Cli/LanguageServer.php @@ -2,20 +2,14 @@ namespace Psalm\Internal\Cli; +use LanguageServerProtocol\MessageType; use Psalm\Config; -use Psalm\Internal\Analyzer\ProjectAnalyzer; use Psalm\Internal\CliUtils; -use Psalm\Internal\Composer; use Psalm\Internal\ErrorHandler; use Psalm\Internal\Fork\PsalmRestarter; use Psalm\Internal\IncludeCollector; -use Psalm\Internal\Provider\ClassLikeStorageCacheProvider; -use Psalm\Internal\Provider\FileProvider; -use Psalm\Internal\Provider\FileReferenceCacheProvider; -use Psalm\Internal\Provider\FileStorageCacheProvider; -use Psalm\Internal\Provider\ParserCacheProvider; -use Psalm\Internal\Provider\ProjectCacheProvider; -use Psalm\Internal\Provider\Providers; +use Psalm\Internal\LanguageServer\ClientConfiguration; +use Psalm\Internal\LanguageServer\LanguageServer as LanguageServerLanguageServer; use Psalm\Report; use function array_key_exists; @@ -52,16 +46,21 @@ require_once __DIR__ . '/../CliUtils.php'; require_once __DIR__ . '/../Composer.php'; require_once __DIR__ . '/../IncludeCollector.php'; +require_once __DIR__ . '/../LanguageServer/ClientConfiguration.php'; /** * @internal */ final class LanguageServer { - /** @param array $argv */ + /** + * @param array $argv + * @psalm-suppress ComplexMethod + */ public static function run(array $argv): void { CliUtils::checkRuntimeRequirements(); + $clientConfiguration = new ClientConfiguration(); gc_disable(); ErrorHandler::install($argv); $valid_short_options = [ @@ -72,7 +71,6 @@ public static function run(array $argv): void ]; $valid_long_options = [ - 'clear-cache', 'config:', 'find-dead-code', 'help', @@ -82,7 +80,17 @@ public static function run(array $argv): void 'tcp:', 'tcp-server', 'disable-on-change::', + 'use-baseline:', 'enable-autocomplete::', + 'enable-code-actions::', + 'enable-provide-diagnostics::', + 'enable-provide-hover::', + 'enable-provide-signature-help::', + 'enable-provide-definition::', + 'show-diagnostic-warnings::', + 'in-memory::', + 'disable-xdebug::', + 'on-change-debounce-ms::', 'use-extended-diagnostic-codes', 'verbose', ]; @@ -164,12 +172,12 @@ static function (string $arg) use ($valid_long_options): void { --find-dead-code Look for dead code - --clear-cache - Clears all cache files that the language server uses for this specific project - --use-ini-defaults Use PHP-provided ini defaults for memory and error display + --use-baseline=PATH + Allows you to use a baseline other than the default baseline provided in your config + --tcp=url Use TCP mode (by default Psalm uses STDIO) @@ -180,12 +188,39 @@ static function (string $arg) use ($valid_long_options): void { If added, the language server will not respond to onChange events. You can also specify a line count over which Psalm will not run on-change events. + --enable-code-actions[=BOOL] + Enables or disables code actions. Default is true. + + --enable-provide-diagnostics[=BOOL] + Enables or disables providing diagnostics. Default is true. + --enable-autocomplete[=BOOL] Enables or disables autocomplete on methods and properties. Default is true. - --use-extended-diagnostic-codes + --enable-provide-hover[=BOOL] + Enables or disables providing hover. Default is true. + + --enable-provide-signature-help[=BOOL] + Enables or disables providing signature help. Default is true. + + --enable-provide-definition[=BOOL] + Enables or disables providing definition. Default is true. + + --show-diagnostic-warnings[=BOOL] + Enables or disables showing diagnostic warnings. Default is true. + + --use-extended-diagnostic-codes (DEPRECATED) Enables sending help uri links with the code in diagnostic messages. + --on-change-debounce-ms=[INT] + The number of milliseconds to debounce onChange events. + + --disable-xdebug[=BOOL] + Disable xdebug for performance reasons. Enable for debugging + + --in-memory[=BOOL] + Use in-memory mode. Default is false. Experimental. + --verbose Will send log messages to the client with information. @@ -245,8 +280,14 @@ static function (string $arg) use ($valid_long_options): void { 'blackfire', ]); - // If Xdebug is enabled, restart without it - $ini_handler->check(); + $disableXdebug = !isset($options['disable-xdebug']) + || !is_string($options['disable-xdebug']) + || strtolower($options['disable-xdebug']) !== 'false'; + + // If Xdebug is enabled, restart without it based on cli + if ($disableXdebug) { + $ini_handler->check(); + } setlocale(LC_CTYPE, 'C'); @@ -259,8 +300,6 @@ static function (string $arg) use ($valid_long_options): void { } } - $find_unused_code = isset($options['find-dead-code']) ? 'auto' : null; - $config = CliUtils::initializeConfig( $path_to_config, $current_dir, @@ -276,58 +315,85 @@ static function (string $arg) use ($valid_long_options): void { $config->setServerMode(); - if (isset($options['clear-cache'])) { + $inMemory = isset($options['in-memory']) && + is_string($options['in-memory']) && + strtolower($options['in-memory']) === 'true'; + + if ($inMemory) { + $config->cache_directory = null; + } else { $cache_directory = $config->getCacheDirectory(); if ($cache_directory !== null) { Config::removeCacheDirectory($cache_directory); } - echo 'Cache directory deleted' . PHP_EOL; - exit; } - $providers = new Providers( - new FileProvider, - new ParserCacheProvider($config), - new FileStorageCacheProvider($config), - new ClassLikeStorageCacheProvider($config), - new FileReferenceCacheProvider($config), - new ProjectCacheProvider(Composer::getLockFilePath($current_dir)), - ); - - $project_analyzer = new ProjectAnalyzer( - $config, - $providers, - ); - - if ($config->find_unused_variables) { - $project_analyzer->getCodebase()->reportUnusedVariables(); + if (isset($options['use-baseline']) && is_string($options['use-baseline'])) { + $clientConfiguration->baseline = $options['use-baseline']; } - if ($config->find_unused_code) { - $find_unused_code = 'auto'; + if (isset($options['disable-on-change']) && is_numeric($options['disable-on-change'])) { + $clientConfiguration->onchangeLineLimit = (int) $options['disable-on-change']; } - if (isset($options['disable-on-change']) && is_numeric($options['disable-on-change'])) { - $project_analyzer->onchange_line_limit = (int) $options['disable-on-change']; + if (isset($options['on-change-debounce-ms']) && is_numeric($options['on-change-debounce-ms'])) { + $clientConfiguration->onChangeDebounceMs = (int) $options['on-change-debounce-ms']; } - $project_analyzer->provide_completion = !isset($options['enable-autocomplete']) + $clientConfiguration->provideDefinition = !isset($options['enable-provide-definition']) + || !is_string($options['enable-provide-definition']) + || strtolower($options['enable-provide-definition']) !== 'false'; + + $clientConfiguration->provideSignatureHelp = !isset($options['enable-provide-signature-help']) + || !is_string($options['enable-provide-signature-help']) + || strtolower($options['enable-provide-signature-help']) !== 'false'; + + $clientConfiguration->provideHover = !isset($options['enable-provide-hover']) + || !is_string($options['enable-provide-hover']) + || strtolower($options['enable-provide-hover']) !== 'false'; + + $clientConfiguration->provideDiagnostics = !isset($options['enable-provide-diagnostics']) + || !is_string($options['enable-provide-diagnostics']) + || strtolower($options['enable-provide-diagnostics']) !== 'false'; + + $clientConfiguration->provideCodeActions = !isset($options['enable-code-actions']) + || !is_string($options['enable-code-actions']) + || strtolower($options['enable-code-actions']) !== 'false'; + + $clientConfiguration->provideCompletion = !isset($options['enable-autocomplete']) || !is_string($options['enable-autocomplete']) || strtolower($options['enable-autocomplete']) !== 'false'; - if ($find_unused_code) { - $project_analyzer->getCodebase()->reportUnusedCode($find_unused_code); - } + $clientConfiguration->hideWarnings = !( + !isset($options['show-diagnostic-warnings']) + || !is_string($options['show-diagnostic-warnings']) + || strtolower($options['show-diagnostic-warnings']) !== 'false' + ); - if (isset($options['use-extended-diagnostic-codes'])) { - $project_analyzer->language_server_use_extended_diagnostic_codes = true; + /** + * if ($config->find_unused_variables) { + * $project_analyzer->getCodebase()->reportUnusedVariables(); + * } + */ + + $find_unused_code = isset($options['find-dead-code']) ? 'auto' : null; + if ($config->find_unused_code) { + $find_unused_code = 'auto'; + } + if ($find_unused_code) { + $clientConfiguration->findUnusedCode = $find_unused_code; } if (isset($options['verbose'])) { - $project_analyzer->language_server_verbose = true; + $clientConfiguration->logLevel = $options['verbose'] ? MessageType::LOG : MessageType::INFO; + } else { + $clientConfiguration->logLevel = MessageType::INFO; } - $project_analyzer->server($options['tcp'] ?? null, isset($options['tcp-server'])); + $clientConfiguration->TCPServerAddress = $options['tcp'] ?? null; + $clientConfiguration->TCPServerMode = isset($options['tcp-server']); + + LanguageServerLanguageServer::run($config, $clientConfiguration, $current_dir, $inMemory); } } diff --git a/src/Psalm/Internal/LanguageServer/Client/TextDocument.php b/src/Psalm/Internal/LanguageServer/Client/TextDocument.php index 2338465aa59..c7f4e378656 100644 --- a/src/Psalm/Internal/LanguageServer/Client/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Client/TextDocument.php @@ -4,15 +4,9 @@ namespace Psalm\Internal\LanguageServer\Client; -use Amp\Promise; -use Generator; -use JsonMapper; use LanguageServerProtocol\Diagnostic; -use LanguageServerProtocol\TextDocumentIdentifier; -use LanguageServerProtocol\TextDocumentItem; use Psalm\Internal\LanguageServer\ClientHandler; - -use function Amp\call; +use Psalm\Internal\LanguageServer\LanguageServer; /** * Provides method handlers for all textDocument/* methods @@ -23,12 +17,12 @@ class TextDocument { private ClientHandler $handler; - private JsonMapper $mapper; + private LanguageServer $server; - public function __construct(ClientHandler $handler, JsonMapper $mapper) + public function __construct(ClientHandler $handler, LanguageServer $server) { $this->handler = $handler; - $this->mapper = $mapper; + $this->server = $server; } /** @@ -36,40 +30,18 @@ public function __construct(ClientHandler $handler, JsonMapper $mapper) * * @param Diagnostic[] $diagnostics */ - public function publishDiagnostics(string $uri, array $diagnostics): void + public function publishDiagnostics(string $uri, array $diagnostics, ?int $version = null): void { + if (!$this->server->client->clientConfiguration->provideDiagnostics) { + return; + } + + $this->server->logDebug("textDocument/publishDiagnostics"); + $this->handler->notify('textDocument/publishDiagnostics', [ 'uri' => $uri, 'diagnostics' => $diagnostics, + 'version' => $version, ]); } - - /** - * The content request is sent from a server to a client - * to request the current content of a text document identified by the URI - * - * @param TextDocumentIdentifier $textDocument The document to get the content for - * @return Promise The document's current content - * @psalm-suppress MixedReturnTypeCoercion due to Psalm bug - */ - public function xcontent(TextDocumentIdentifier $textDocument): Promise - { - return call( - /** - * @return Generator, object, TextDocumentItem> - */ - function () use ($textDocument) { - /** @var Promise */ - $promise = $this->handler->request( - 'textDocument/xcontent', - ['textDocument' => $textDocument], - ); - - $result = yield $promise; - - /** @var TextDocumentItem */ - return $this->mapper->map($result, new TextDocumentItem); - }, - ); - } } diff --git a/src/Psalm/Internal/LanguageServer/Client/Workspace.php b/src/Psalm/Internal/LanguageServer/Client/Workspace.php new file mode 100644 index 00000000000..f9d9cf39e90 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Client/Workspace.php @@ -0,0 +1,59 @@ +handler = $handler; + $this->mapper = $mapper; + $this->server = $server; + } + + /** + * The workspace/configuration request is sent from the server to the client to + * fetch configuration settings from the client. The request can fetch several + * configuration settings in one roundtrip. The order of the returned configuration + * settings correspond to the order of the passed ConfigurationItems (e.g. the first + * item in the response is the result for the first configuration item in the params). + * + * @param string $section The configuration section asked for. + * @param string|null $scopeUri The scope to get the configuration section for. + */ + public function requestConfiguration(string $section, ?string $scopeUri = null): Promise + { + $this->server->logDebug("workspace/configuration"); + + /** @var Promise */ + return $this->handler->request('workspace/configuration', [ + 'items' => [ + [ + 'section' => $section, + 'scopeUri' => $scopeUri, + ], + ], + ]); + } +} diff --git a/src/Psalm/Internal/LanguageServer/ClientConfiguration.php b/src/Psalm/Internal/LanguageServer/ClientConfiguration.php new file mode 100644 index 00000000000..115a38f6567 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/ClientConfiguration.php @@ -0,0 +1,129 @@ +hideWarnings = $hideWarnings; + $this->provideCompletion = $provideCompletion; + $this->provideDefinition = $provideDefinition; + $this->provideHover = $provideHover; + $this->provideSignatureHelp = $provideSignatureHelp; + $this->provideCodeActions = $provideCodeActions; + $this->provideDiagnostics = $provideDiagnostics; + $this->findUnusedVariables = $findUnusedVariables; + $this->findUnusedCode = $findUnusedCode; + $this->logLevel = $logLevel; + $this->onchangeLineLimit = $onchangeLineLimit; + $this->baseline = $baseline; + } +} diff --git a/src/Psalm/Internal/LanguageServer/ClientHandler.php b/src/Psalm/Internal/LanguageServer/ClientHandler.php index 444e916bdf4..06e48e3d143 100644 --- a/src/Psalm/Internal/LanguageServer/ClientHandler.php +++ b/src/Psalm/Internal/LanguageServer/ClientHandler.php @@ -13,7 +13,6 @@ use Generator; use function Amp\call; -use function error_log; /** * @internal @@ -59,7 +58,6 @@ function () use ($id, $method, $params): Generator { $listener = function (Message $msg) use ($id, $deferred, &$listener): void { - error_log('request handler'); /** * @psalm-suppress UndefinedPropertyFetch * @psalm-suppress MixedArgument diff --git a/src/Psalm/Internal/LanguageServer/LanguageClient.php b/src/Psalm/Internal/LanguageServer/LanguageClient.php index 9a766dce050..096177d9d5c 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageClient.php +++ b/src/Psalm/Internal/LanguageServer/LanguageClient.php @@ -5,7 +5,14 @@ namespace Psalm\Internal\LanguageServer; use JsonMapper; +use LanguageServerProtocol\LogMessage; +use LanguageServerProtocol\LogTrace; use Psalm\Internal\LanguageServer\Client\TextDocument as ClientTextDocument; +use Psalm\Internal\LanguageServer\Client\Workspace as ClientWorkspace; + +use function is_null; +use function json_decode; +use function json_encode; /** * @internal @@ -17,44 +24,159 @@ class LanguageClient */ public ClientTextDocument $textDocument; + /** + * Handles workspace/* methods + */ + public ClientWorkspace $workspace; + /** * The client handler */ private ClientHandler $handler; - public function __construct(ProtocolReader $reader, ProtocolWriter $writer) - { + /** + * The Language Server + */ + private LanguageServer $server; + + /** + * The Client Configuration + */ + public ClientConfiguration $clientConfiguration; + + public function __construct( + ProtocolReader $reader, + ProtocolWriter $writer, + LanguageServer $server, + ClientConfiguration $clientConfiguration + ) { $this->handler = new ClientHandler($reader, $writer); - $mapper = new JsonMapper; + $this->server = $server; - $this->textDocument = new ClientTextDocument($this->handler, $mapper); + $this->textDocument = new ClientTextDocument($this->handler, $this->server); + $this->workspace = new ClientWorkspace($this->handler, new JsonMapper, $this->server); + $this->clientConfiguration = $clientConfiguration; } /** - * Send a log message to the client. + * Request Configuration from Client and save it + */ + public function refreshConfiguration(): void + { + $capabilities = $this->server->clientCapabilities; + if ($capabilities && $capabilities->workspace && $capabilities->workspace->configuration) { + $this->workspace->requestConfiguration('psalm')->onResolve(function ($error, $value): void { + if ($error) { + $this->server->logError('There was an error getting configuration'); + } else { + /** @var array $value */ + [$config] = $value; + $this->configurationRefreshed((array) $config); + } + }); + } + } + + /** + * A notification to log the trace of the server’s execution. + * The amount and content of these notifications depends on the current trace configuration. * - * @param string $message The message to send to the client. - * @psalm-param 1|2|3|4 $type - * @param int $type The log type: - * - 1 = Error - * - 2 = Warning - * - 3 = Info - * - 4 = Log - */ - public function logMessage(string $message, int $type = 4, string $method = 'window/logMessage'): void + * @psalm-suppress PossiblyUnusedMethod + */ + public function logTrace(LogTrace $logTrace): void { - // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_logMessage + //If trace is 'off', the server should not send any logTrace notification. + if (is_null($this->server->trace) || $this->server->trace === 'off') { + return; + } - if ($type < 1 || $type > 4) { - $type = 4; + //If trace is 'messages', the server should not add the 'verbose' field in the LogTraceParams. + if ($this->server->trace === 'messages') { + $logTrace->verbose = null; } $this->handler->notify( - $method, - [ - 'type' => $type, - 'message' => $message, - ], + '$/logTrace', + $logTrace, ); } + + /** + * Send a log message to the client. + */ + public function logMessage(LogMessage $logMessage): void + { + $this->handler->notify( + 'window/logMessage', + $logMessage, + ); + } + + /** + * The telemetry notification is sent from the + * server to the client to ask the client to log + * a telemetry event. + * + * The protocol doesn’t specify the payload since no + * interpretation of the data happens in the protocol. + * Most clients even don’t handle the event directly + * but forward them to the extensions owing the corresponding + * server issuing the event. + */ + public function event(LogMessage $logMessage): void + { + $this->handler->notify( + 'telemetry/event', + $logMessage, + ); + } + + /** + * Configuration Refreshed from Client + * + * @param array $config + */ + private function configurationRefreshed(array $config): void + { + //do things when the config is refreshed + + if (empty($config)) { + return; + } + + /** @var array */ + $array = json_decode(json_encode($config), true); + + if (isset($array['hideWarnings'])) { + $this->clientConfiguration->hideWarnings = (bool) $array['hideWarnings']; + } + + if (isset($array['provideCompletion'])) { + $this->clientConfiguration->provideCompletion = (bool) $array['provideCompletion']; + } + + if (isset($array['provideDefinition'])) { + $this->clientConfiguration->provideDefinition = (bool) $array['provideDefinition']; + } + + if (isset($array['provideHover'])) { + $this->clientConfiguration->provideHover = (bool) $array['provideHover']; + } + + if (isset($array['provideSignatureHelp'])) { + $this->clientConfiguration->provideSignatureHelp = (bool) $array['provideSignatureHelp']; + } + + if (isset($array['provideCodeActions'])) { + $this->clientConfiguration->provideCodeActions = (bool) $array['provideCodeActions']; + } + + if (isset($array['provideDiagnostics'])) { + $this->clientConfiguration->provideDiagnostics = (bool) $array['provideDiagnostics']; + } + + if (isset($array['findUnusedVariables'])) { + $this->clientConfiguration->findUnusedVariables = (bool) $array['findUnusedVariables']; + } + } } diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 9784cd40bd0..2f99b784cb6 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -11,16 +11,23 @@ use AdvancedJsonRpc\Request; use AdvancedJsonRpc\Response; use AdvancedJsonRpc\SuccessResponse; +use Amp\Loop; use Amp\Promise; use Amp\Success; use Generator; use InvalidArgumentException; use JsonMapper; use LanguageServerProtocol\ClientCapabilities; +use LanguageServerProtocol\ClientInfo; +use LanguageServerProtocol\CodeDescription; use LanguageServerProtocol\CompletionOptions; use LanguageServerProtocol\Diagnostic; use LanguageServerProtocol\DiagnosticSeverity; +use LanguageServerProtocol\ExecuteCommandOptions; use LanguageServerProtocol\InitializeResult; +use LanguageServerProtocol\InitializeResultServerInfo; +use LanguageServerProtocol\LogMessage; +use LanguageServerProtocol\MessageType; use LanguageServerProtocol\Position; use LanguageServerProtocol\Range; use LanguageServerProtocol\SaveOptions; @@ -28,34 +35,68 @@ use LanguageServerProtocol\SignatureHelpOptions; use LanguageServerProtocol\TextDocumentSyncKind; use LanguageServerProtocol\TextDocumentSyncOptions; +use Psalm\Codebase; use Psalm\Config; +use Psalm\ErrorBaseline; use Psalm\Internal\Analyzer\IssueData; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\Internal\Composer; +use Psalm\Internal\LanguageServer\Provider\ClassLikeStorageCacheProvider as InMemoryClassLikeStorageCacheProvider; +use Psalm\Internal\LanguageServer\Provider\FileReferenceCacheProvider as InMemoryFileReferenceCacheProvider; +use Psalm\Internal\LanguageServer\Provider\FileStorageCacheProvider as InMemoryFileStorageCacheProvider; +use Psalm\Internal\LanguageServer\Provider\ParserCacheProvider as InMemoryParserCacheProvider; +use Psalm\Internal\LanguageServer\Provider\ProjectCacheProvider as InMemoryProjectCacheProvider; use Psalm\Internal\LanguageServer\Server\TextDocument as ServerTextDocument; use Psalm\Internal\LanguageServer\Server\Workspace as ServerWorkspace; +use Psalm\Internal\Provider\ClassLikeStorageCacheProvider; +use Psalm\Internal\Provider\FileProvider; +use Psalm\Internal\Provider\FileReferenceCacheProvider; +use Psalm\Internal\Provider\FileStorageCacheProvider; +use Psalm\Internal\Provider\ParserCacheProvider; +use Psalm\Internal\Provider\ProjectCacheProvider; +use Psalm\Internal\Provider\Providers; use Psalm\IssueBuffer; use Throwable; use function Amp\asyncCoroutine; use function Amp\call; use function array_combine; +use function array_filter; use function array_keys; use function array_map; +use function array_reduce; +use function array_search; use function array_shift; +use function array_splice; use function array_unshift; +use function array_values; +use function cli_set_process_title; +use function count; use function explode; +use function fwrite; use function implode; +use function json_encode; use function max; use function parse_url; use function rawurlencode; use function realpath; use function str_replace; +use function stream_set_blocking; +use function stream_socket_accept; +use function stream_socket_client; +use function stream_socket_server; use function strpos; use function substr; use function trim; use function urldecode; +use const JSON_PRETTY_PRINT; +use const STDERR; +use const STDIN; +use const STDOUT; + /** + * @psalm-api * @internal */ class LanguageServer extends Dispatcher @@ -70,28 +111,29 @@ class LanguageServer extends Dispatcher */ public ?ServerWorkspace $workspace = null; + public ?ClientInfo $clientInfo = null; + protected ProtocolReader $protocolReader; protected ProtocolWriter $protocolWriter; public LanguageClient $client; + public ?ClientCapabilities $clientCapabilities = null; + + public ?string $trace = null; + protected ProjectAnalyzer $project_analyzer; - /** - * @var array - */ - protected array $onsave_paths_to_analyze = []; + protected Codebase $codebase; /** - * @var array + * The AMP Delay token */ - protected array $onchange_paths_to_analyze = []; + protected string $versionedAnalysisDelayToken = ''; - /** - * @var array> - */ - protected array $current_issues = []; + /** @var array}>> */ + protected array $issue_baseline = []; /** * This should actually be a private property on `parent` @@ -103,11 +145,19 @@ class LanguageServer extends Dispatcher public function __construct( ProtocolReader $reader, ProtocolWriter $writer, - ProjectAnalyzer $project_analyzer + ProjectAnalyzer $project_analyzer, + Codebase $codebase, + ClientConfiguration $clientConfiguration, + Progress $progress ) { parent::__construct($this, '/'); + + $progress->setServer($this); + $this->project_analyzer = $project_analyzer; + $this->codebase = $codebase; + $this->protocolWriter = $writer; $this->protocolReader = $reader; @@ -139,10 +189,14 @@ function (Message $msg): Generator { try { // Invoke the method handler to get a result /** - * @var Promise + * @var Promise|null */ $dispatched = $this->dispatch($msg->body); - $result = yield $dispatched; + if ($dispatched !== null) { + $result = yield $dispatched; + } else { + $result = null; + } } catch (Error $e) { // If a ResponseError is thrown, send it back in the Response $error = $e; @@ -155,6 +209,9 @@ function (Message $msg): Generator { $e, ); } + if ($error !== null) { + $this->logError($error->message); + } // Only send a Response for a Request // Notifications do not send Responses /** @@ -176,35 +233,171 @@ function (Message $msg): Generator { $this->protocolReader->on( 'readMessageGroup', function (): void { - $this->doAnalysis(); + //$this->verboseLog('Received message group'); + //$this->doAnalysis(); }, ); - $this->client = new LanguageClient($reader, $writer); + $this->client = new LanguageClient($reader, $writer, $this, $clientConfiguration); - $this->verboseLog("Language server has started."); + $this->logInfo("Psalm Language Server ".PSALM_VERSION." has started."); + } + + /** + * Start the Server + */ + public static function run( + Config $config, + ClientConfiguration $clientConfiguration, + string $base_dir, + bool $inMemory = false + ): void { + $progress = new Progress(); + + if ($inMemory) { + $providers = new Providers( + new FileProvider, + new InMemoryParserCacheProvider, + new InMemoryFileStorageCacheProvider, + new InMemoryClassLikeStorageCacheProvider, + new InMemoryFileReferenceCacheProvider($config), + new InMemoryProjectCacheProvider, + ); + } else { + $providers = new Providers( + new FileProvider, + new ParserCacheProvider($config), + new FileStorageCacheProvider($config), + new ClassLikeStorageCacheProvider($config), + new FileReferenceCacheProvider($config), + new ProjectCacheProvider(Composer::getLockFilePath($base_dir)), + ); + } + + $codebase = new Codebase( + $config, + $providers, + $progress, + ); + + if ($config->find_unused_variables) { + $codebase->reportUnusedVariables(); + } + + if ($clientConfiguration->findUnusedCode) { + $codebase->reportUnusedCode($clientConfiguration->findUnusedCode); + } + + $project_analyzer = new ProjectAnalyzer( + $config, + $providers, + null, + [], + 1, + $progress, + $codebase, + ); + + if ($clientConfiguration->onchangeLineLimit) { + $project_analyzer->onchange_line_limit = $clientConfiguration->onchangeLineLimit; + } + + //Setup Project Analyzer + $project_analyzer->provide_completion = (bool) $clientConfiguration->provideCompletion; + + @cli_set_process_title('Psalm ' . PSALM_VERSION . ' - PHP Language Server'); + + if (!$clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) { + // Connect to a TCP server + $socket = stream_socket_client('tcp://' . $clientConfiguration->TCPServerAddress, $errno, $errstr); + if ($socket === false) { + fwrite(STDERR, "Could not connect to language client. Error $errno\n$errstr"); + exit(1); + } + stream_set_blocking($socket, false); + new self( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket), + $project_analyzer, + $codebase, + $clientConfiguration, + $progress, + ); + Loop::run(); + } elseif ($clientConfiguration->TCPServerMode && $clientConfiguration->TCPServerAddress) { + // Run a TCP Server + $tcpServer = stream_socket_server('tcp://' . $clientConfiguration->TCPServerAddress, $errno, $errstr); + if ($tcpServer === false) { + fwrite(STDERR, "Could not listen on {$clientConfiguration->TCPServerAddress}. Error $errno\n$errstr"); + exit(1); + } + fwrite(STDOUT, "Server listening on {$clientConfiguration->TCPServerAddress}\n"); + + while ($socket = stream_socket_accept($tcpServer, -1)) { + fwrite(STDOUT, "Connection accepted\n"); + stream_set_blocking($socket, false); + //we only accept one connection. + //An exit notification will terminate the server + new LanguageServer( + new ProtocolStreamReader($socket), + new ProtocolStreamWriter($socket), + $project_analyzer, + $codebase, + $clientConfiguration, + $progress, + ); + Loop::run(); + } + } else { + // Use STDIO + stream_set_blocking(STDIN, false); + new LanguageServer( + new ProtocolStreamReader(STDIN), + new ProtocolStreamWriter(STDOUT), + $project_analyzer, + $codebase, + $clientConfiguration, + $progress, + ); + Loop::run(); + } } /** * The initialize request is sent as the first request from the client to 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. + * @param ClientInfo|null $clientInfo Information about the client + * @param string|null $locale The locale the client is currently showing the user interface + * in. This must not necessarily be the locale of the operating + * system. + * @param string|null $rootPath The rootPath of the workspace. Is null if no folder is open. + * @param mixed $initializationOptions + * @param string|null $trace The initial trace setting. If omitted trace is disabled ('off'). * @psalm-return Promise - * @psalm-suppress PossiblyUnusedMethod + * @psalm-suppress PossiblyUnusedParam */ public function initialize( ClientCapabilities $capabilities, + ?int $processId = null, + ?ClientInfo $clientInfo = null, + ?string $locale = null, ?string $rootPath = null, - ?int $processId = null + ?string $rootUri = null, + $initializationOptions = null, + ?string $trace = null + //?array $workspaceFolders = null //error in json-dispatcher ): Promise { + $this->clientInfo = $clientInfo; + $this->clientCapabilities = $capabilities; + $this->trace = $trace; return call( /** @return Generator */ - function (): Generator { - $this->verboseLog("Initializing..."); + function () { + $this->logInfo("Initializing..."); $this->clientStatus('initializing'); // Eventually, this might block on something. Leave it as a generator. @@ -213,22 +406,23 @@ function (): Generator { yield true; } - $this->verboseLog("Initializing: Getting code base..."); + $this->project_analyzer->serverMode($this); + + $this->logInfo("Initializing: Getting code base..."); $this->clientStatus('initializing', 'getting code base'); - $codebase = $this->project_analyzer->getCodebase(); - $this->verboseLog("Initializing: Scanning files..."); + $this->logInfo("Initializing: Scanning files ({$this->project_analyzer->threads} Threads)..."); $this->clientStatus('initializing', 'scanning files'); - $codebase->scanFiles($this->project_analyzer->threads); + $this->codebase->scanFiles($this->project_analyzer->threads); - $this->verboseLog("Initializing: Registering stub files..."); + $this->logInfo("Initializing: Registering stub files..."); $this->clientStatus('initializing', 'registering stub files'); - $codebase->config->visitStubFiles($codebase); + $this->codebase->config->visitStubFiles($this->codebase, $this->project_analyzer->progress); if ($this->textDocument === null) { $this->textDocument = new ServerTextDocument( $this, - $codebase, + $this->codebase, $this->project_analyzer, ); } @@ -236,199 +430,408 @@ function (): Generator { if ($this->workspace === null) { $this->workspace = new ServerWorkspace( $this, - $codebase, + $this->codebase, $this->project_analyzer, ); } $serverCapabilities = new ServerCapabilities(); + //The server provides execute command support. + $serverCapabilities->executeCommandProvider = new ExecuteCommandOptions(['test']); + $textDocumentSyncOptions = new TextDocumentSyncOptions(); + //Open and close notifications are sent to the server. $textDocumentSyncOptions->openClose = true; $saveOptions = new SaveOptions(); + //The client is supposed to include the content on save. $saveOptions->includeText = true; $textDocumentSyncOptions->save = $saveOptions; + /** + * Change notifications are sent to the server. See + * TextDocumentSyncKind.None, TextDocumentSyncKind.Full and + * TextDocumentSyncKind.Incremental. If omitted it defaults to + * TextDocumentSyncKind.None. + */ if ($this->project_analyzer->onchange_line_limit === 0) { + /** + * Documents should not be synced at all. + */ $textDocumentSyncOptions->change = TextDocumentSyncKind::NONE; } else { + /** + * Documents are synced by always sending the full content + * of the document. + */ $textDocumentSyncOptions->change = TextDocumentSyncKind::FULL; } + /** + * Defines how text documents are synced. Is either a detailed structure + * defining each notification or for backwards compatibility the + * TextDocumentSyncKind number. If omitted it defaults to + * `TextDocumentSyncKind.None`. + */ $serverCapabilities->textDocumentSync = $textDocumentSyncOptions; - // Support "Find all symbols" + /** + * The server provides document symbol support. + * Support "Find all symbols" + */ $serverCapabilities->documentSymbolProvider = false; - // Support "Find all symbols in workspace" + /** + * The server provides workspace symbol support. + * Support "Find all symbols in workspace" + */ $serverCapabilities->workspaceSymbolProvider = false; - // Support "Go to definition" + /** + * The server provides goto definition support. + * Support "Go to definition" + */ $serverCapabilities->definitionProvider = true; - // Support "Find all references" + /** + * The server provides find references support. + * Support "Find all references" + */ $serverCapabilities->referencesProvider = false; - // Support "Hover" + /** + * The server provides hover support. + * Support "Hover" + */ $serverCapabilities->hoverProvider = true; - // Support "Completion" - $serverCapabilities->codeActionProvider = true; - // Support "Code Actions" + /** + * The server provides completion support. + * Support "Completion" + */ if ($this->project_analyzer->provide_completion) { $serverCapabilities->completionProvider = new CompletionOptions(); + /** + * The server provides support to resolve additional + * information for a completion item. + */ $serverCapabilities->completionProvider->resolveProvider = false; + /** + * Most tools trigger completion request automatically without explicitly + * requesting it using a keyboard shortcut (e.g. Ctrl+Space). Typically they + * do so when the user starts to type an identifier. For example if the user + * types `c` in a JavaScript file code complete will automatically pop up + * present `console` besides others as a completion item. Characters that + * make up identifiers don't need to be listed here. + * + * If code complete should automatically be trigger on characters not being + * valid inside an identifier (for example `.` in JavaScript) list them in + * `triggerCharacters`. + */ $serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':',"[", "(", ",", " "]; } + /** + * Whether code action supports the `data` property which is + * preserved between a `textDocument/codeAction` and a + * `codeAction/resolve` request. + * + * Support "Code Actions" if we support data + * + * @since LSP 3.16.0 + */ + if ($this->clientCapabilities && + $this->clientCapabilities->textDocument && + $this->clientCapabilities->textDocument->publishDiagnostics && + $this->clientCapabilities->textDocument->publishDiagnostics->dataSupport + ) { + $serverCapabilities->codeActionProvider = true; + } + + /** + * The server provides signature help support. + */ $serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(['(', ',']); - // Support global references - $serverCapabilities->xworkspaceReferencesProvider = false; - $serverCapabilities->xdefinitionProvider = false; - $serverCapabilities->dependenciesProvider = false; + if ($this->client->clientConfiguration->baseline !== null) { + $this->logInfo('Utilizing Baseline: '.$this->client->clientConfiguration->baseline); + $this->issue_baseline= ErrorBaseline::read( + new FileProvider, + $this->client->clientConfiguration->baseline, + ); + } - $this->verboseLog("Initializing: Complete."); + $this->logInfo("Initializing: Complete."); $this->clientStatus('initialized'); - return new InitializeResult($serverCapabilities); + + /** + * Information about the server. + * + * @since LSP 3.15.0 + */ + $initializeResultServerInfo = new InitializeResultServerInfo('Psalm Language Server', PSALM_VERSION); + + return new InitializeResult($serverCapabilities, $initializeResultServerInfo); }, ); } /** - * @psalm-suppress PossiblyUnusedMethod + * The initialized notification is sent from the client to the server after the client received the result of the + * initialize request but before the client is sending any other request or notification to the server. + * The server can use the initialized notification for example to dynamically register capabilities. + * The initialized notification may only be sent once. */ public function initialized(): void { + try { + $this->client->refreshConfiguration(); + } catch (Throwable $e) { + $this->logError((string) $e); + } $this->clientStatus('running'); } - public function queueTemporaryFileAnalysis(string $file_path, string $uri): void + /** + * Queue Change File Analysis + */ + public function queueChangeFileAnalysis(string $file_path, string $uri, ?int $version = null): void { - $this->onchange_paths_to_analyze[$file_path] = $uri; + $this->doVersionedAnalysisDebounce([$file_path => $uri], $version); } - public function queueFileAnalysis(string $file_path, string $uri): void + /** + * Queue Open File Analysis + */ + public function queueOpenFileAnalysis(string $file_path, string $uri, ?int $version = null): void { - $this->onsave_paths_to_analyze[$file_path] = $uri; + $this->doVersionedAnalysis([$file_path => $uri], $version); } - public function doAnalysis(): void + /** + * Queue Closed File Analysis + */ + public function queueClosedFileAnalysis(string $file_path, string $uri): void { - $this->clientStatus('analyzing'); + $this->doVersionedAnalysis([$file_path => $uri]); + } - try { - $codebase = $this->project_analyzer->getCodebase(); + /** + * Queue Saved File Analysis + */ + public function queueSaveFileAnalysis(string $file_path, string $uri): void + { + $this->queueFileAnalysisWithOpenedFiles([$file_path => $uri]); + } - $all_files_to_analyze = $this->onchange_paths_to_analyze + $this->onsave_paths_to_analyze; + /** + * Queue File Analysis appending any opened files + * + * This allows for reanalysis of files that have been opened + * + * @param array $files + */ + public function queueFileAnalysisWithOpenedFiles(array $files = []): void + { + /** @var array $opened */ + $opened = array_reduce( + $this->project_analyzer->getCodebase()->file_provider->getOpenFilesPath(), + function (array $opened, string $file_path) { + $opened[$file_path] = $this->pathToUri($file_path); + return $opened; + }, + $files, + ); - if (!$all_files_to_analyze) { - return; - } + $this->doVersionedAnalysis($opened); + } - if ($this->onsave_paths_to_analyze) { - $codebase->reloadFiles($this->project_analyzer, array_keys($this->onsave_paths_to_analyze)); - } + /** + * Debounced Queue File Analysis with optional version + * + * @param array $files + */ + public function doVersionedAnalysisDebounce(array $files, ?int $version = null): void + { + Loop::cancel($this->versionedAnalysisDelayToken); + if ($this->client->clientConfiguration->onChangeDebounceMs === null) { + $this->doVersionedAnalysis($files, $version); + } else { + /** @psalm-suppress MixedAssignment,UnusedPsalmSuppress */ + $this->versionedAnalysisDelayToken = Loop::delay( + $this->client->clientConfiguration->onChangeDebounceMs, + fn() => $this->doVersionedAnalysis($files, $version), + ); + } + } - if ($this->onchange_paths_to_analyze) { - $codebase->reloadFiles($this->project_analyzer, array_keys($this->onchange_paths_to_analyze)); - } + /** + * Queue File Analysis with optional version + * + * @param array $files + */ + public function doVersionedAnalysis(array $files, ?int $version = null): void + { + Loop::cancel($this->versionedAnalysisDelayToken); + try { + $this->logDebug("Doing Analysis from version: $version"); + $this->codebase->reloadFiles( + $this->project_analyzer, + array_keys($files), + ); - $all_file_paths_to_analyze = array_keys($all_files_to_analyze); - $codebase->analyzer->addFilesToAnalyze( - array_combine($all_file_paths_to_analyze, $all_file_paths_to_analyze), + $this->codebase->analyzer->addFilesToAnalyze( + array_combine(array_keys($files), array_keys($files)), ); - $codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); - $this->emitIssues($all_files_to_analyze); + $this->logDebug("Reloading Files"); + $this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); - $this->onchange_paths_to_analyze = []; - $this->onsave_paths_to_analyze = []; - } finally { - // we are done, so set the status back to running - $this->clientStatus('running'); + $this->emitVersionedIssues($files, $version); + } catch (Throwable $e) { + $this->logError((string) $e); } } /** - * @param array $uris + * Emit Publish Diagnostics + * + * @param array $files */ - public function emitIssues(array $uris): void + public function emitVersionedIssues(array $files, ?int $version = null): void { - $data = IssueBuffer::clear(); - $this->current_issues = $data; - - foreach ($uris as $file_path => $uri) { - $diagnostics = []; - - foreach (($data[$file_path] ?? []) as $issue_data) { - //$check_name = $issue->check_name; - $description = $issue_data->message; - $severity = $issue_data->severity; - - $start_line = max($issue_data->line_from, 1); - $end_line = $issue_data->line_to; - $start_column = $issue_data->column_from; - $end_column = $issue_data->column_to; - // Language server has 0 based lines and columns, phan has 1-based lines and columns. - $range = new Range( - new Position($start_line - 1, $start_column - 1), - new Position($end_line - 1, $end_column - 1), - ); - switch ($severity) { - case Config::REPORT_INFO: - $diagnostic_severity = DiagnosticSeverity::WARNING; - break; - case Config::REPORT_ERROR: - default: - $diagnostic_severity = DiagnosticSeverity::ERROR; - break; - } - $diagnostic = new Diagnostic( - $description, - $range, - null, - $diagnostic_severity, - 'Psalm', - ); + $this->logDebug("Perform Analysis", [ + 'files' => array_keys($files), + 'version' => $version, + ]); - //$code = 'PS' . \str_pad((string) $issue_data->shortcode, 3, "0", \STR_PAD_LEFT); - $code = $issue_data->link; + //Copy variable here to be able to process it + $issue_baseline = $this->issue_baseline; - if ($this->project_analyzer->language_server_use_extended_diagnostic_codes) { - // Added in VSCode 1.43.0 and will be part of the LSP 3.16.0 standard. - // Since this new functionality is not backwards compatible, we use a - // configuration option so the end user must opt in to it using the cli argument. - // https://github.com/microsoft/vscode/blob/1.43.0/src/vs/vscode.d.ts#L4688-L4699 + $data = IssueBuffer::clear(); + foreach ($files as $file_path => $uri) { + //Dont report errors in files we are not watching + if (!$this->project_analyzer->getCodebase()->config->isInProjectDirs($file_path)) { + continue; + } + $diagnostics = array_map( + function (IssueData $issue_data): Diagnostic { + //$check_name = $issue->check_name; + $description = $issue_data->message; + $severity = $issue_data->severity; + + $start_line = max($issue_data->line_from, 1); + $end_line = $issue_data->line_to; + $start_column = $issue_data->column_from; + $end_column = $issue_data->column_to; + // Language server has 0 based lines and columns, phan has 1-based lines and columns. + $range = new Range( + new Position($start_line - 1, $start_column - 1), + new Position($end_line - 1, $end_column - 1), + ); + switch ($severity) { + case Config::REPORT_INFO: + $diagnostic_severity = DiagnosticSeverity::WARNING; + break; + case Config::REPORT_ERROR: + default: + $diagnostic_severity = DiagnosticSeverity::ERROR; + break; + } + $diagnostic = new Diagnostic( + $description, + $range, + null, + $diagnostic_severity, + 'psalm', + ); - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $diagnostic->code = [ - "value" => $code, - "target" => $issue_data->link, + $diagnostic->data = [ + 'type' => $issue_data->type, + 'snippet' => $issue_data->snippet, + 'line_from' => $issue_data->line_from, + 'line_to' => $issue_data->line_to, ]; - } else { - // the Diagnostic constructor only takes `int` for the code, but the property can be - // `int` or `string`, so we set the property directly because we want to use a `string` - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $diagnostic->code = $code; - } - $diagnostics[] = $diagnostic; - } + $diagnostic->code = $issue_data->shortcode; + + /** + * Client supports a codeDescription property + * + * @since LSP 3.16.0 + */ + if ($this->clientCapabilities !== null && + $this->clientCapabilities->textDocument && + $this->clientCapabilities->textDocument->publishDiagnostics && + $this->clientCapabilities->textDocument->publishDiagnostics->codeDescriptionSupport + ) { + $diagnostic->codeDescription = new CodeDescription($issue_data->link); + } + + return $diagnostic; + }, + array_filter( + array_map(function (IssueData $issue_data) use (&$issue_baseline) { + if (empty($issue_baseline)) { + return $issue_data; + } + //Process Baseline + $file = $issue_data->file_name; + $type = $issue_data->type; + /** @psalm-suppress MixedArrayAccess */ + if (isset($issue_baseline[$file][$type]) && $issue_baseline[$file][$type]['o'] > 0) { + /** @psalm-suppress MixedArrayAccess, MixedArgument */ + if ($issue_baseline[$file][$type]['o'] === count($issue_baseline[$file][$type]['s'])) { + /** @psalm-suppress MixedArrayAccess, MixedAssignment */ + $position = array_search( + trim($issue_data->selected_text), + $issue_baseline[$file][$type]['s'], + true, + ); + + if ($position !== false) { + $issue_data->severity = Config::REPORT_INFO; + /** @psalm-suppress MixedArgument */ + array_splice($issue_baseline[$file][$type]['s'], $position, 1); + /** @psalm-suppress MixedArrayAssignment, MixedOperand, MixedAssignment */ + $issue_baseline[$file][$type]['o']--; + } + } else { + /** @psalm-suppress MixedArrayAssignment */ + $issue_baseline[$file][$type]['s'] = []; + $issue_data->severity = Config::REPORT_INFO; + /** @psalm-suppress MixedArrayAssignment, MixedOperand, MixedAssignment */ + $issue_baseline[$file][$type]['o']--; + } + } + return $issue_data; + }, $data[$file_path] ?? []), + function (IssueData $issue_data) { + //Hide Warnings + if ($issue_data->severity === Config::REPORT_INFO && + $this->client->clientConfiguration->hideWarnings + ) { + return false; + } + + return true; + }, + ), + ); - $this->client->textDocument->publishDiagnostics($uri, $diagnostics); + $this->client->textDocument->publishDiagnostics($uri, array_values($diagnostics), $version); } } /** - * The shutdown request is sent from the client to the server. It asks the server to shut down, - * but to not exit (otherwise the response might not be delivered correctly to the client). - * There is a separate exit notification that asks the server to exit. - * - * @psalm-suppress PossiblyUnusedReturnValue + * The shutdown request is sent from the client to the server. It asks the server to shut down, but to not exit + * (otherwise the response might not be delivered correctly to the client). There is a separate exit notification + * that asks the server to exit. Clients must not send any notifications other than exit or requests to a server to + * which they have sent a shutdown request. Clients should also wait with sending the exit notification until they + * have received a response from the shutdown request. */ public function shutdown(): Promise { $this->clientStatus('closing'); - $this->verboseLog("Shutting down..."); + $this->logInfo("Shutting down..."); $codebase = $this->project_analyzer->getCodebase(); $scanned_files = $codebase->scanner->getScannedFiles(); $codebase->file_reference_provider->updateReferenceCache( @@ -441,6 +844,8 @@ public function shutdown(): Promise /** * A notification to ask the server to exit its process. + * The server should exit with success code 0 if the shutdown request has been received before; + * otherwise with error code 1. */ public function exit(): void { @@ -451,31 +856,85 @@ public function exit(): void /** * Send log message to the client * - * @param string $message The log message to send to the client. * @psalm-param 1|2|3|4 $type * @param int $type The log type: * - 1 = Error * - 2 = Warning * - 3 = Info * - 4 = Log + * @see MessageType + * @param string $message The log message to send to the client. + * @param mixed[] $context The log context */ - public function verboseLog(string $message, int $type = 4): void + public function log(int $type, string $message, array $context = []): void { - if ($this->project_analyzer->language_server_verbose) { - try { - $this->client->logMessage( - '[Psalm ' .PSALM_VERSION. ' - PHP Language Server] ' . $message, + $logLevel = $this->client->clientConfiguration->logLevel; + if ($logLevel === null) { + return; + } + + if ($type > $logLevel) { + return; + } + + if (!empty($context)) { + $message .= "\n" . json_encode($context, JSON_PRETTY_PRINT); + } + try { + $this->client->logMessage( + new LogMessage( $type, - ); - } catch (Throwable $err) { - // do nothing - } + $message, + ), + ); + } catch (Throwable $err) { + // do nothing as we could potentially go into a loop here is not careful + //TODO: Investigate if we can use error_log instead } - new Success(null); } /** - * Send status message to client. This is the same as sending a log message, + * Log Throwable Error + */ + public function logThrowable(Throwable $throwable): void + { + $this->log(MessageType::ERROR, (string) $throwable); + } + + /** + * Log Error message to the client + */ + public function logError(string $message, array $context = []): void + { + $this->log(MessageType::ERROR, $message, $context); + } + + /** + * Log Warning message to the client + */ + public function logWarning(string $message, array $context = []): void + { + $this->log(MessageType::WARNING, $message, $context); + } + + /** + * Log Info message to the client + */ + public function logInfo(string $message, array $context = []): void + { + $this->log(MessageType::INFO, $message, $context); + } + + /** + * Log Debug message to the client + */ + public function logDebug(string $message, array $context = []): void + { + $this->log(MessageType::LOG, $message, $context); + } + + /** + * Send status message to client. This is the same as sending a log message, * except this is meant for parsing by the client to present status updates in a UI. * * @param string $status The log message to send to the client. Should not contain colons `:`. @@ -485,16 +944,15 @@ public function verboseLog(string $message, int $type = 4): void private function clientStatus(string $status, ?string $additional_info = null): void { try { - // here we send a notification to the client using the telemetry notification method - $this->client->logMessage( - $status . (!empty($additional_info) ? ': ' . $additional_info : ''), - 3, - 'telemetry/event', + $this->client->event( + new LogMessage( + MessageType::INFO, + $status . (!empty($additional_info) ? ': ' . $additional_info : ''), + ), ); } catch (Throwable $err) { // do nothing } - new Success(null); } /** @@ -548,14 +1006,4 @@ public static function uriToPath(string $uri): string return $filepath; } - - /** - * Get the value of current_issues - * - * @return array> - */ - public function getCurrentIssues(): array - { - return $this->current_issues; - } } diff --git a/src/Psalm/Internal/LanguageServer/Message.php b/src/Psalm/Internal/LanguageServer/Message.php index ba1e73f9622..56b46c23350 100644 --- a/src/Psalm/Internal/LanguageServer/Message.php +++ b/src/Psalm/Internal/LanguageServer/Message.php @@ -24,8 +24,6 @@ class Message /** * Parses a message - * - * @psalm-suppress UnusedMethod */ public static function parse(string $msg): Message { @@ -35,7 +33,9 @@ public static function parse(string $msg): Message foreach ($parts as $line) { if ($line) { $pair = explode(': ', $line); - $obj->headers[$pair[0]] = $pair[1]; + if (isset($pair[1])) { + $obj->headers[$pair[0]] = $pair[1]; + } } } @@ -56,6 +56,7 @@ public function __construct(?MessageBody $body = null, array $headers = []) public function __toString(): string { + $body = (string)$this->body; $contentLength = strlen($body); $this->headers['Content-Length'] = (string) $contentLength; diff --git a/src/Psalm/Internal/LanguageServer/PHPMarkdownContent.php b/src/Psalm/Internal/LanguageServer/PHPMarkdownContent.php new file mode 100644 index 00000000000..3290ea5cd4c --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/PHPMarkdownContent.php @@ -0,0 +1,58 @@ +code = $code; + $this->title = $title; + $this->description = $description; + + $markdown = ''; + if ($title !== null) { + $markdown = "**$title**\n\n"; + } + if ($description !== null) { + $markdown = "$markdown$description\n\n"; + } + parent::__construct( + MarkupKind::MARKDOWN, + "$markdown```php\nserver = $server; + } + + public function debug(string $message): void + { + if ($this->server) { + $this->server->logDebug(str_replace("\n", "", $message)); + } + } + + public function write(string $message): void + { + if ($this->server) { + $this->server->logInfo(str_replace("\n", "", $message)); + } + } +} diff --git a/src/Psalm/Internal/LanguageServer/ProtocolStreamReader.php b/src/Psalm/Internal/LanguageServer/ProtocolStreamReader.php index 66ef9af9d1b..35031d8541d 100644 --- a/src/Psalm/Internal/LanguageServer/ProtocolStreamReader.php +++ b/src/Psalm/Internal/LanguageServer/ProtocolStreamReader.php @@ -91,7 +91,9 @@ private function readMessages(string $buffer): int $this->buffer = ''; } elseif (substr($this->buffer, -2) === "\r\n") { $parts = explode(':', $this->buffer); - $this->headers[$parts[0]] = trim($parts[1]); + if (isset($parts[1])) { + $this->headers[$parts[0]] = trim($parts[1]); + } $this->buffer = ''; } break; diff --git a/src/Psalm/Internal/LanguageServer/Provider/ClassLikeStorageCacheProvider.php b/src/Psalm/Internal/LanguageServer/Provider/ClassLikeStorageCacheProvider.php new file mode 100644 index 00000000000..f1925fe7904 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Provider/ClassLikeStorageCacheProvider.php @@ -0,0 +1,47 @@ + */ + private array $cache = []; + + public function __construct() + { + } + + public function writeToCache(ClassLikeStorage $storage, ?string $file_path, ?string $file_contents): void + { + $fq_classlike_name_lc = strtolower($storage->name); + $this->cache[$fq_classlike_name_lc] = $storage; + } + + public function getLatestFromCache( + string $fq_classlike_name_lc, + ?string $file_path, + ?string $file_contents + ): ClassLikeStorage { + $cached_value = $this->loadFromCache($fq_classlike_name_lc); + + if (!$cached_value) { + throw new UnexpectedValueException('Should be in cache'); + } + + return $cached_value; + } + + private function loadFromCache(string $fq_classlike_name_lc): ?ClassLikeStorage + { + return $this->cache[$fq_classlike_name_lc] ?? null; + } +} diff --git a/src/Psalm/Internal/LanguageServer/Provider/FileReferenceCacheProvider.php b/src/Psalm/Internal/LanguageServer/Provider/FileReferenceCacheProvider.php new file mode 100644 index 00000000000..79d54534448 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Provider/FileReferenceCacheProvider.php @@ -0,0 +1,280 @@ +> */ + private array $cached_correct_methods = []; + + /** + * @var array< + * string, + * array{ + * 0: array, + * 1: array, + * 2: array + * } + * > + */ + private array $cached_file_maps = []; + + public function __construct(Config $config) + { + $this->config = $config; + } + + public function getCachedFileReferences(): ?array + { + return $this->cached_file_references; + } + + public function getCachedClassLikeFiles(): ?array + { + return $this->cached_classlike_files; + } + + public function getCachedMethodClassReferences(): ?array + { + return $this->cached_method_class_references; + } + + public function getCachedNonMethodClassReferences(): ?array + { + return $this->cached_nonmethod_class_references; + } + + public function getCachedFileMemberReferences(): ?array + { + return $this->cached_file_member_references; + } + + public function getCachedFilePropertyReferences(): ?array + { + return $this->cached_file_property_references; + } + + public function getCachedFileMethodReturnReferences(): ?array + { + return $this->cached_file_method_return_references; + } + + public function getCachedMethodMemberReferences(): ?array + { + return $this->cached_method_member_references; + } + + public function getCachedMethodDependencies(): ?array + { + return $this->cached_method_dependencies; + } + + public function getCachedMethodPropertyReferences(): ?array + { + return $this->cached_method_property_references; + } + + public function getCachedMethodMethodReturnReferences(): ?array + { + return $this->cached_method_method_return_references; + } + + public function getCachedFileMissingMemberReferences(): ?array + { + return $this->cached_file_missing_member_references; + } + + public function getCachedMixedMemberNameReferences(): ?array + { + return $this->cached_unknown_member_references; + } + + public function getCachedMethodMissingMemberReferences(): ?array + { + return $this->cached_method_missing_member_references; + } + + public function getCachedMethodParamUses(): ?array + { + return $this->cached_method_param_uses; + } + + public function getCachedIssues(): ?array + { + return $this->cached_issues; + } + + public function setCachedFileReferences(array $file_references): void + { + $this->cached_file_references = $file_references; + } + + public function setCachedClassLikeFiles(array $file_references): void + { + $this->cached_classlike_files = $file_references; + } + + public function setCachedMethodClassReferences(array $method_class_references): void + { + $this->cached_method_class_references = $method_class_references; + } + + public function setCachedNonMethodClassReferences(array $file_class_references): void + { + $this->cached_nonmethod_class_references = $file_class_references; + } + + public function setCachedMethodMemberReferences(array $member_references): void + { + $this->cached_method_member_references = $member_references; + } + + public function setCachedMethodDependencies(array $member_references): void + { + $this->cached_method_dependencies = $member_references; + } + + public function setCachedMethodPropertyReferences(array $property_references): void + { + $this->cached_method_property_references = $property_references; + } + + public function setCachedMethodMethodReturnReferences(array $method_return_references): void + { + $this->cached_method_method_return_references = $method_return_references; + } + + public function setCachedMethodMissingMemberReferences(array $member_references): void + { + $this->cached_method_missing_member_references = $member_references; + } + + public function setCachedFileMemberReferences(array $member_references): void + { + $this->cached_file_member_references = $member_references; + } + + public function setCachedFilePropertyReferences(array $property_references): void + { + $this->cached_file_property_references = $property_references; + } + + public function setCachedFileMethodReturnReferences(array $method_return_references): void + { + $this->cached_file_method_return_references = $method_return_references; + } + + public function setCachedFileMissingMemberReferences(array $member_references): void + { + $this->cached_file_missing_member_references = $member_references; + } + + public function setCachedMixedMemberNameReferences(array $references): void + { + $this->cached_unknown_member_references = $references; + } + + public function setCachedMethodParamUses(array $uses): void + { + $this->cached_method_param_uses = $uses; + } + + public function setCachedIssues(array $issues): void + { + $this->cached_issues = $issues; + } + + /** + * @return array> + */ + public function getAnalyzedMethodCache(): array + { + return $this->cached_correct_methods; + } + + /** + * @param array> $analyzed_methods + */ + public function setAnalyzedMethodCache(array $analyzed_methods): void + { + $this->cached_correct_methods = $analyzed_methods; + } + + /** + * @return array< + * string, + * array{ + * 0: array, + * 1: array, + * 2: array + * } + * > + */ + public function getFileMapCache(): array + { + return $this->cached_file_maps; + } + + /** + * @param array< + * string, + * array{ + * 0: array, + * 1: array, + * 2: array + * } + * > $file_maps + */ + public function setFileMapCache(array $file_maps): void + { + $this->cached_file_maps = $file_maps; + } + + /** + * @param array $mixed_counts + */ + public function setTypeCoverage(array $mixed_counts): void + { + } +} diff --git a/src/Psalm/Internal/LanguageServer/Provider/FileStorageCacheProvider.php b/src/Psalm/Internal/LanguageServer/Provider/FileStorageCacheProvider.php new file mode 100644 index 00000000000..c91ffcc14ab --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Provider/FileStorageCacheProvider.php @@ -0,0 +1,48 @@ + */ + private array $cache = []; + + public function __construct() + { + } + + public function writeToCache(FileStorage $storage, string $file_contents): void + { + $file_path = strtolower($storage->file_path); + $this->cache[$file_path] = $storage; + } + + public function getLatestFromCache(string $file_path, string $file_contents): ?FileStorage + { + $cached_value = $this->loadFromCache(strtolower($file_path)); + + if (!$cached_value) { + return null; + } + + return $cached_value; + } + + public function removeCacheForFile(string $file_path): void + { + unset($this->cache[strtolower($file_path)]); + } + + private function loadFromCache(string $file_path): ?FileStorage + { + return $this->cache[strtolower($file_path)] ?? null; + } +} diff --git a/src/Psalm/Internal/LanguageServer/Provider/ParserCacheProvider.php b/src/Psalm/Internal/LanguageServer/Provider/ParserCacheProvider.php new file mode 100644 index 00000000000..0b30a2dda57 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Provider/ParserCacheProvider.php @@ -0,0 +1,109 @@ + + */ + private array $file_contents_cache = []; + + /** + * @var array + */ + private array $file_content_hash = []; + + /** + * @var array> + */ + private array $statements_cache = []; + + /** + * @var array + */ + private array $statements_cache_time = []; + + public function __construct() + { + } + + public function loadStatementsFromCache( + string $file_path, + int $file_modified_time, + string $file_content_hash + ): ?array { + if (isset($this->statements_cache[$file_path]) + && $this->statements_cache_time[$file_path] >= $file_modified_time + && $this->file_content_hash[$file_path] === $file_content_hash + ) { + return $this->statements_cache[$file_path]; + } + + return null; + } + + /** + * @return list|null + */ + public function loadExistingStatementsFromCache(string $file_path): ?array + { + if (isset($this->statements_cache[$file_path])) { + return $this->statements_cache[$file_path]; + } + + return null; + } + + /** + * @param list $stmts + */ + public function saveStatementsToCache( + string $file_path, + string $file_content_hash, + array $stmts, + bool $touch_only + ): void { + $this->statements_cache[$file_path] = $stmts; + $this->statements_cache_time[$file_path] = microtime(true); + $this->file_content_hash[$file_path] = $file_content_hash; + } + + public function loadExistingFileContentsFromCache(string $file_path): ?string + { + if (isset($this->file_contents_cache[$file_path])) { + return $this->file_contents_cache[$file_path]; + } + + return null; + } + + public function cacheFileContents(string $file_path, string $file_contents): void + { + $this->file_contents_cache[$file_path] = $file_contents; + } + + public function deleteOldParserCaches(float $time_before): int + { + $this->existing_file_content_hashes = null; + $this->new_file_content_hashes = []; + + $this->file_contents_cache = []; + $this->file_content_hash = []; + $this->statements_cache = []; + $this->statements_cache_time = []; + return 0; + } + + public function saveFileContentHashes(): void + { + } +} diff --git a/src/Psalm/Internal/LanguageServer/Provider/ProjectCacheProvider.php b/src/Psalm/Internal/LanguageServer/Provider/ProjectCacheProvider.php new file mode 100644 index 00000000000..ed210fa0ae2 --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Provider/ProjectCacheProvider.php @@ -0,0 +1,41 @@ +last_run; + } + + public function processSuccessfulRun(float $start_time, string $psalm_version): void + { + $this->last_run = (int) $start_time; + } + + public function canDiffFiles(): bool + { + return $this->last_run > 0; + } + + public function hasLockfileChanged(): bool + { + return false; + } + + public function updateComposerLockHash(): void + { + } +} diff --git a/src/Psalm/Internal/LanguageServer/Reference.php b/src/Psalm/Internal/LanguageServer/Reference.php new file mode 100644 index 00000000000..32694c7c8df --- /dev/null +++ b/src/Psalm/Internal/LanguageServer/Reference.php @@ -0,0 +1,22 @@ +file_path = $file_path; + $this->symbol = $symbol; + $this->range = $range; + } +} diff --git a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php index 87d4c650ee8..b9c4bcca8f4 100644 --- a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php @@ -6,11 +6,12 @@ use Amp\Promise; use Amp\Success; +use LanguageServerProtocol\CodeAction; +use LanguageServerProtocol\CodeActionContext; +use LanguageServerProtocol\CodeActionKind; use LanguageServerProtocol\CompletionList; use LanguageServerProtocol\Hover; use LanguageServerProtocol\Location; -use LanguageServerProtocol\MarkupContent; -use LanguageServerProtocol\MarkupKind; use LanguageServerProtocol\Position; use LanguageServerProtocol\Range; use LanguageServerProtocol\SignatureHelp; @@ -21,6 +22,7 @@ use LanguageServerProtocol\VersionedTextDocumentIdentifier; use LanguageServerProtocol\WorkspaceEdit; use Psalm\Codebase; +use Psalm\Exception\TypeParseTreeException; use Psalm\Exception\UnanalyzedFileException; use Psalm\Internal\Analyzer\ProjectAnalyzer; use Psalm\Internal\LanguageServer\LanguageServer; @@ -28,7 +30,6 @@ use function array_values; use function count; -use function error_log; use function preg_match; use function substr_count; @@ -68,36 +69,41 @@ public function __construct( */ public function didOpen(TextDocumentItem $textDocument): void { - $file_path = LanguageServer::uriToPath($textDocument->uri); + $this->server->logDebug( + 'textDocument/didOpen', + ['version' => $textDocument->version, 'uri' => $textDocument->uri], + ); - if (!$this->codebase->config->isInProjectDirs($file_path)) { - return; - } + $file_path = LanguageServer::uriToPath($textDocument->uri); + $this->codebase->removeTemporaryFileChanges($file_path); $this->codebase->file_provider->openFile($file_path); + $this->codebase->file_provider->setOpenContents($file_path, $textDocument->text); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); + $this->server->queueOpenFileAnalysis($file_path, $textDocument->uri, $textDocument->version); } /** * The document save notification is sent from the client to the server when the document was saved in the client * - * @param TextDocumentItem $textDocument the document that was opened - * @param ?string $text the content when saved + * @param TextDocumentIdentifier $textDocument the document that was opened + * @param string|null $text Optional the content when saved. Depends on the includeText value + * when the save notification was requested. */ - public function didSave(TextDocumentItem $textDocument, ?string $text): void + public function didSave(TextDocumentIdentifier $textDocument, ?string $text = null): void { - $file_path = LanguageServer::uriToPath($textDocument->uri); + $this->server->logDebug( + 'textDocument/didSave', + ['uri' => (array) $textDocument], + ); - if (!$this->codebase->config->isInProjectDirs($file_path)) { - return; - } + $file_path = LanguageServer::uriToPath($textDocument->uri); // reopen file $this->codebase->removeTemporaryFileChanges($file_path); - $this->codebase->file_provider->setOpenContents($file_path, (string) $text); + $this->codebase->file_provider->setOpenContents($file_path, $text); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); + $this->server->queueSaveFileAnalysis($file_path, $textDocument->uri); } /** @@ -108,13 +114,14 @@ public function didSave(TextDocumentItem $textDocument, ?string $text): void */ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $contentChanges): void { - $file_path = LanguageServer::uriToPath($textDocument->uri); + $this->server->logDebug( + 'textDocument/didChange', + ['version' => $textDocument->version, 'uri' => $textDocument->uri], + ); - if (!$this->codebase->config->isInProjectDirs($file_path)) { - return; - } + $file_path = LanguageServer::uriToPath($textDocument->uri); - if (count($contentChanges) === 1 && $contentChanges[0]->range === null) { + if (count($contentChanges) === 1 && isset($contentChanges[0]) && $contentChanges[0]->range === null) { $new_content = $contentChanges[0]->text; } else { throw new UnexpectedValueException('Not expecting partial diff'); @@ -126,8 +133,8 @@ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $ } } - $this->codebase->addTemporaryFileChanges($file_path, $new_content); - $this->server->queueTemporaryFileAnalysis($file_path, $textDocument->uri); + $this->codebase->addTemporaryFileChanges($file_path, $new_content, $textDocument->version); + $this->server->queueChangeFileAnalysis($file_path, $textDocument->uri, $textDocument->version); } /** @@ -142,6 +149,11 @@ public function didChange(VersionedTextDocumentIdentifier $textDocument, array $ */ public function didClose(TextDocumentIdentifier $textDocument): void { + $this->server->logDebug( + 'textDocument/didClose', + ['uri' => $textDocument->uri], + ); + $file_path = LanguageServer::uriToPath($textDocument->uri); $this->codebase->file_provider->closeFile($file_path); @@ -158,24 +170,34 @@ public function didClose(TextDocumentIdentifier $textDocument): void */ public function definition(TextDocumentIdentifier $textDocument, Position $position): Promise { + if (!$this->server->client->clientConfiguration->provideDefinition) { + return new Success(null); + } + + $this->server->logDebug( + 'textDocument/definition', + ); + $file_path = LanguageServer::uriToPath($textDocument->uri); + //This currently doesnt work right with out of project files + if (!$this->codebase->config->isInProjectDirs($file_path)) { + return new Success(null); + } + try { - $reference_location = $this->codebase->getReferenceAtPosition($file_path, $position); + $reference = $this->codebase->getReferenceAtPositionAsReference($file_path, $position); } catch (UnanalyzedFileException $e) { - $this->codebase->file_provider->openFile($file_path); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); - + $this->server->logThrowable($e); return new Success(null); } - if ($reference_location === null) { + if ($reference === null) { return new Success(null); } - [$reference] = $reference_location; - $code_location = $this->codebase->getSymbolLocation($file_path, $reference); + $code_location = $this->codebase->getSymbolLocationByReference($reference); if (!$code_location) { return new Success(null); @@ -202,39 +224,44 @@ public function definition(TextDocumentIdentifier $textDocument, Position $posit */ public function hover(TextDocumentIdentifier $textDocument, Position $position): Promise { + if (!$this->server->client->clientConfiguration->provideHover) { + return new Success(null); + } + + $this->server->logDebug( + 'textDocument/hover', + ); + $file_path = LanguageServer::uriToPath($textDocument->uri); + //This currently doesnt work right with out of project files + if (!$this->codebase->config->isInProjectDirs($file_path)) { + return new Success(null); + } + try { - $reference_location = $this->codebase->getReferenceAtPosition($file_path, $position); + $reference = $this->codebase->getReferenceAtPositionAsReference($file_path, $position); } catch (UnanalyzedFileException $e) { - $this->codebase->file_provider->openFile($file_path); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); - + $this->server->logThrowable($e); return new Success(null); } - if ($reference_location === null) { + if ($reference === null) { return new Success(null); } - [$reference, $range] = $reference_location; - - $symbol_information = $this->codebase->getSymbolInformation($file_path, $reference); - - if ($symbol_information === null) { + try { + $markup = $this->codebase->getMarkupContentForSymbolByReference($reference); + } catch (UnexpectedValueException $e) { + $this->server->logThrowable($e); return new Success(null); } - $content = "```php\n" . $symbol_information['type'] . "\n```"; - if (isset($symbol_information['description'])) { - $content .= "\n---\n" . $symbol_information['description']; + if ($markup === null) { + return new Success(null); } - $contents = new MarkupContent( - MarkupKind::MARKDOWN, - $content, - ); - return new Success(new Hover($contents, $range)); + return new Success(new Hover($markup, $reference->range)); } /** @@ -249,56 +276,74 @@ public function hover(TextDocumentIdentifier $textDocument, Position $position): * * @param TextDocumentIdentifier $textDocument The text document * @param Position $position The position - * @psalm-return Promise>|Promise + * @psalm-return Promise>|Promise|Promise */ public function completion(TextDocumentIdentifier $textDocument, Position $position): Promise { + if (!$this->server->client->clientConfiguration->provideCompletion) { + return new Success(null); + } + + $this->server->logDebug( + 'textDocument/completion', + ); + $file_path = LanguageServer::uriToPath($textDocument->uri); + + //This currently doesnt work right with out of project files if (!$this->codebase->config->isInProjectDirs($file_path)) { - return new Success([]); + return new Success(null); } try { $completion_data = $this->codebase->getCompletionDataAtPosition($file_path, $position); + if ($completion_data) { + [$recent_type, $gap, $offset] = $completion_data; + + if ($gap === '->' || $gap === '::') { + $snippetSupport = ($this->server->clientCapabilities && + $this->server->clientCapabilities->textDocument && + $this->server->clientCapabilities->textDocument->completion && + $this->server->clientCapabilities->textDocument->completion->completionItem && + $this->server->clientCapabilities->textDocument->completion->completionItem->snippetSupport) + ? true : false; + $completion_items = + $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap, $snippetSupport); + } elseif ($gap === '[') { + $completion_items = $this->codebase->getCompletionItemsForArrayKeys($recent_type); + } else { + $completion_items = $this->codebase->getCompletionItemsForPartialSymbol( + $recent_type, + $offset, + $file_path, + ); + } + return new Success(new CompletionList($completion_items, false)); + } } catch (UnanalyzedFileException $e) { - $this->codebase->file_provider->openFile($file_path); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); - - return new Success([]); + $this->server->logThrowable($e); + return new Success(null); + } catch (TypeParseTreeException $e) { + $this->server->logThrowable($e); + return new Success(null); } try { $type_context = $this->codebase->getTypeContextAtPosition($file_path, $position); - } catch (UnexpectedValueException $e) { - error_log('completion errored at ' . $position->line . ':' . $position->character. - ', Reason: '.$e->getMessage()); - return new Success([]); - } - - if (!$completion_data && !$type_context) { - error_log('completion not found at ' . $position->line . ':' . $position->character); - return new Success([]); - } - - if ($completion_data) { - [$recent_type, $gap, $offset] = $completion_data; - - if ($gap === '->' || $gap === '::') { - $completion_items = $this->codebase->getCompletionItemsForClassishThing($recent_type, $gap); - } elseif ($gap === '[') { - $completion_items = $this->codebase->getCompletionItemsForArrayKeys($recent_type); - } else { - $completion_items = $this->codebase->getCompletionItemsForPartialSymbol( - $recent_type, - $offset, - $file_path, - ); + if ($type_context) { + $completion_items = $this->codebase->getCompletionItemsForType($type_context); + return new Success(new CompletionList($completion_items, false)); } - } else { - $completion_items = $this->codebase->getCompletionItemsForType($type_context); + } catch (UnexpectedValueException $e) { + $this->server->logThrowable($e); + return new Success(null); + } catch (TypeParseTreeException $e) { + $this->server->logThrowable($e); + return new Success(null); } - return new Success(new CompletionList($completion_items, false)); + $this->server->logError('completion not found at ' . $position->line . ':' . $position->character); + return new Success(null); } /** @@ -307,96 +352,134 @@ public function completion(TextDocumentIdentifier $textDocument, Position $posit */ public function signatureHelp(TextDocumentIdentifier $textDocument, Position $position): Promise { + if (!$this->server->client->clientConfiguration->provideSignatureHelp) { + return new Success(null); + } + + $this->server->logDebug( + 'textDocument/signatureHelp', + ); + $file_path = LanguageServer::uriToPath($textDocument->uri); + //This currently doesnt work right with out of project files + if (!$this->codebase->config->isInProjectDirs($file_path)) { + return new Success(null); + } + try { $argument_location = $this->codebase->getFunctionArgumentAtPosition($file_path, $position); } catch (UnanalyzedFileException $e) { - $this->codebase->file_provider->openFile($file_path); - $this->server->queueFileAnalysis($file_path, $textDocument->uri); - - return new Success(new SignatureHelp()); + $this->server->logThrowable($e); + return new Success(null); } if ($argument_location === null) { - return new Success(new SignatureHelp()); + return new Success(null); } - $signature_information = $this->codebase->getSignatureInformation($argument_location[0], $file_path); + try { + $signature_information = $this->codebase->getSignatureInformation($argument_location[0], $file_path); + } catch (UnexpectedValueException $e) { + $this->server->logThrowable($e); + return new Success(null); + } if (!$signature_information) { - return new Success(new SignatureHelp()); + return new Success(null); } - return new Success(new SignatureHelp([ - $signature_information, - ], 0, $argument_location[1])); + return new Success( + new SignatureHelp( + [$signature_information], + 0, + $argument_location[1], + ), + ); } /** * The code action request is sent from the client to the server to compute commands * for a given text document and range. These commands are typically code fixes to * either fix problems or to beautify/refactor code. + * + * @psalm-suppress PossiblyUnusedParam */ - public function codeAction(TextDocumentIdentifier $textDocument, Range $range): Promise + public function codeAction(TextDocumentIdentifier $textDocument, Range $range, CodeActionContext $context): Promise { - $file_path = LanguageServer::uriToPath($textDocument->uri); - if (!$this->codebase->file_provider->isOpen($file_path)) { + if (!$this->server->client->clientConfiguration->provideCodeActions) { return new Success(null); } - $issues = $this->server->getCurrentIssues(); + $this->server->logDebug( + 'textDocument/codeAction', + ); + + $file_path = LanguageServer::uriToPath($textDocument->uri); - if (empty($issues[$file_path])) { + //Don't report code actions for files we arent watching + if (!$this->codebase->config->isInProjectDirs($file_path)) { return new Success(null); } - $file_contents = $this->codebase->getFileContents($file_path); + $fixers = []; + foreach ($context->diagnostics as $diagnostic) { + if ($diagnostic->source !== 'psalm') { + continue; + } + + /** @var array{type: string, snippet: string, line_from: int, line_to: int} */ + $data = (array)$diagnostic->data; - $offsetStart = $range->start->toOffset($file_contents); - $offsetEnd = $range->end->toOffset($file_contents); + //$file_path = LanguageServer::uriToPath($textDocument->uri); + //$contents = $this->codebase->file_provider->getContents($file_path); - $fixers = []; - foreach ($issues[$file_path] as $issue) { - if ($offsetStart === $issue->from && $offsetEnd === $issue->to) { - $snippetRange = new Range( - new Position($issue->line_from-1), - new Position($issue->line_to), - ); - - $indentation = ''; - if (preg_match('/^(\s*)/', $issue->snippet, $matches)) { - $indentation = $matches[1] ?? ''; - } + $snippetRange = new Range( + new Position($data['line_from']-1), + new Position($data['line_to']), + ); - /** - * Suppress Psalm because ther are bugs in how - * LanguageServer's signature of WorkspaceEdit is declared: - * - * See: - * https://github.com/felixfbecker/php-language-server-protocol - * See: - * https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workspaceEdit - */ - $edit = new WorkspaceEdit([ + $indentation = ''; + if (preg_match('/^(\s*)/', $data['snippet'], $matches)) { + $indentation = $matches[1] ?? ''; + } + + //Suppress Ability + $fixers["suppress.{$data['type']}"] = new CodeAction( + "Suppress {$data['type']} for this line", + CodeActionKind::QUICK_FIX, + null, + null, + null, + new WorkspaceEdit([ $textDocument->uri => [ new TextEdit( $snippetRange, - "{$indentation}/**\n". - "{$indentation} * @psalm-suppress {$issue->type}\n". - "{$indentation} */\n". - "{$issue->snippet}\n", + "{$indentation}/** @psalm-suppress {$data['type']} */\n". + "{$data['snippet']}\n", ), ], - ]); - - //Suppress Ability - $fixers["suppress.{$issue->type}"] = [ - 'title' => "Suppress {$issue->type} for this line", - 'kind' => 'quickfix', - 'edit' => $edit, - ]; - } + ]), + ); + + /* + $fixers["fixAll.{$diagnostic->data->type}"] = new CodeAction( + "FixAll {$diagnostic->data->type} for this file", + CodeActionKind::QUICK_FIX, + null, + null, + null, + null, + new Command( + "Fix All", + "psalm.fixall", + [ + 'uri' => $textDocument->uri, + 'type' => $diagnostic->data->type + ] + ) + ); + */ } if (empty($fixers)) { diff --git a/src/Psalm/Internal/LanguageServer/Server/Workspace.php b/src/Psalm/Internal/LanguageServer/Server/Workspace.php index 333c1bb647d..af49619c356 100644 --- a/src/Psalm/Internal/LanguageServer/Server/Workspace.php +++ b/src/Psalm/Internal/LanguageServer/Server/Workspace.php @@ -4,11 +4,21 @@ namespace Psalm\Internal\LanguageServer\Server; +use Amp\Promise; +use Amp\Success; +use InvalidArgumentException; use LanguageServerProtocol\FileChangeType; use LanguageServerProtocol\FileEvent; use Psalm\Codebase; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\Internal\Composer; use Psalm\Internal\LanguageServer\LanguageServer; +use Psalm\Internal\Provider\FileReferenceProvider; + +use function array_filter; +use function array_map; +use function in_array; +use function realpath; /** * Provides method handlers for all workspace/* methods @@ -46,9 +56,35 @@ public function __construct( */ public function didChangeWatchedFiles(array $changes): void { + $this->server->logDebug( + 'workspace/didChangeWatchedFiles', + ); + + $realFiles = array_filter( + array_map(function (FileEvent $change) { + try { + return LanguageServer::uriToPath($change->uri); + } catch (InvalidArgumentException $e) { + return null; + } + }, $changes), + ); + + $composerLockFile = realpath(Composer::getLockFilePath($this->codebase->config->base_dir)); + if (in_array($composerLockFile, $realFiles)) { + $this->server->logInfo('Composer.lock file changed. Reloading codebase'); + FileReferenceProvider::clearCache(); + $this->server->queueFileAnalysisWithOpenedFiles(); + return; + } + foreach ($changes as $change) { $file_path = LanguageServer::uriToPath($change->uri); + if ($composerLockFile === $file_path) { + continue; + } + if ($change->type === FileChangeType::DELETED) { $this->codebase->invalidateInformationForFile($file_path); continue; @@ -62,10 +98,64 @@ public function didChangeWatchedFiles(array $changes): void continue; } - //If the file is currently open then dont analyse it because its tracked by the client + //If the file is currently open then dont analize it because its tracked in didChange if (!$this->codebase->file_provider->isOpen($file_path)) { - $this->server->queueFileAnalysis($file_path, $change->uri); + $this->server->queueClosedFileAnalysis($file_path, $change->uri); } } } + + /** + * A notification sent from the client to the server to signal the change of configuration settings. + * + * @param mixed $settings + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedParam + */ + public function didChangeConfiguration($settings): void + { + $this->server->logDebug( + 'workspace/didChangeConfiguration', + ); + $this->server->client->refreshConfiguration(); + } + + /** + * The workspace/executeCommand request is sent from the client to the server to + * trigger command execution on the server. + * + * @param mixed $arguments + * @psalm-suppress PossiblyUnusedMethod + */ + public function executeCommand(string $command, $arguments): Promise + { + $this->server->logDebug( + 'workspace/executeCommand', + [ + 'command' => $command, + 'arguments' => $arguments, + ], + ); + + switch ($command) { + case 'psalm.analyze.uri': + /** @var array{uri: string} */ + $arguments = (array) $arguments; + $file = LanguageServer::uriToPath($arguments['uri']); + $this->codebase->reloadFiles( + $this->project_analyzer, + [$file], + true, + ); + + $this->codebase->analyzer->addFilesToAnalyze( + [$file => $file], + ); + $this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); + + $this->server->emitVersionedIssues([$file => $arguments['uri']]); + break; + } + + return new Success(null); + } } diff --git a/src/Psalm/Internal/Provider/FakeFileProvider.php b/src/Psalm/Internal/Provider/FakeFileProvider.php index 47dcb7c552d..ea6b53caf87 100644 --- a/src/Psalm/Internal/Provider/FakeFileProvider.php +++ b/src/Psalm/Internal/Provider/FakeFileProvider.php @@ -29,7 +29,7 @@ public function fileExists(string $file_path): bool public function getContents(string $file_path, bool $go_to_source = false): string { if (!$go_to_source && isset($this->temp_files[$file_path])) { - return $this->temp_files[$file_path]; + return $this->temp_files[$file_path]['content']; } return $this->fake_files[$file_path] ?? parent::getContents($file_path); @@ -40,10 +40,10 @@ public function setContents(string $file_path, string $file_contents): void $this->fake_files[$file_path] = $file_contents; } - public function setOpenContents(string $file_path, string $file_contents): void + public function setOpenContents(string $file_path, ?string $file_contents = null): void { if (isset($this->fake_files[$file_path])) { - $this->fake_files[$file_path] = $file_contents; + $this->fake_files[$file_path] = $file_contents ?? $this->getContents($file_path, true); } } diff --git a/src/Psalm/Internal/Provider/FileProvider.php b/src/Psalm/Internal/Provider/FileProvider.php index 13fcf572092..c18960de19b 100644 --- a/src/Psalm/Internal/Provider/FileProvider.php +++ b/src/Psalm/Internal/Provider/FileProvider.php @@ -24,7 +24,7 @@ class FileProvider { /** - * @var array + * @var array */ protected array $temp_files = []; @@ -33,32 +33,31 @@ class FileProvider */ protected static array $open_files = []; - /** @psalm-mutation-free */ + /** + * @var array + */ + protected array $open_files_paths = []; + public function getContents(string $file_path, bool $go_to_source = false): string { if (!$go_to_source && isset($this->temp_files[$file_path])) { - return $this->temp_files[$file_path]; + return $this->temp_files[$file_path]['content']; } - /** @psalm-suppress ImpureStaticProperty Used only for caching */ if (isset(self::$open_files[$file_path])) { return self::$open_files[$file_path]; } - /** @psalm-suppress ImpureFunctionCall For our purposes, this should not mutate external state */ if (!file_exists($file_path)) { throw new UnexpectedValueException('File ' . $file_path . ' should exist to get contents'); } - /** @psalm-suppress ImpureFunctionCall For our purposes, this should not mutate external state */ if (is_dir($file_path)) { throw new UnexpectedValueException('File ' . $file_path . ' is a directory'); } - /** @psalm-suppress ImpureFunctionCall For our purposes, this should not mutate external state */ $file_contents = (string) file_get_contents($file_path); - /** @psalm-suppress ImpureStaticProperty Used only for caching */ self::$open_files[$file_path] = $file_contents; return $file_contents; @@ -71,16 +70,19 @@ public function setContents(string $file_path, string $file_contents): void } if (isset($this->temp_files[$file_path])) { - $this->temp_files[$file_path] = $file_contents; + $this->temp_files[$file_path] = [ + 'version'=> null, + 'content' => $file_contents, + ]; } file_put_contents($file_path, $file_contents); } - public function setOpenContents(string $file_path, string $file_contents): void + public function setOpenContents(string $file_path, ?string $file_contents = null): void { if (isset(self::$open_files[$file_path])) { - self::$open_files[$file_path] = $file_contents; + self::$open_files[$file_path] = $file_contents ?? $this->getContents($file_path, true); } } @@ -93,9 +95,19 @@ public function getModifiedTime(string $file_path): int return (int) filemtime($file_path); } - public function addTemporaryFileChanges(string $file_path, string $new_content): void + public function addTemporaryFileChanges(string $file_path, string $new_content, ?int $version = null): void { - $this->temp_files[$file_path] = $new_content; + if (isset($this->temp_files[$file_path]) && + $version !== null && + $this->temp_files[$file_path]['version'] !== null && + $version < $this->temp_files[$file_path]['version'] + ) { + return; + } + $this->temp_files[$file_path] = [ + 'version' => $version, + 'content' => $new_content, + ]; } public function removeTemporaryFileChanges(string $file_path): void @@ -103,9 +115,15 @@ public function removeTemporaryFileChanges(string $file_path): void unset($this->temp_files[$file_path]); } + public function getOpenFilesPath(): array + { + return $this->open_files_paths; + } + public function openFile(string $file_path): void { self::$open_files[$file_path] = $this->getContents($file_path, true); + $this->open_files_paths[$file_path] = $file_path; } public function isOpen(string $file_path): bool @@ -115,7 +133,11 @@ public function isOpen(string $file_path): bool public function closeFile(string $file_path): void { - unset($this->temp_files[$file_path], self::$open_files[$file_path]); + unset( + $this->temp_files[$file_path], + self::$open_files[$file_path], + $this->open_files_paths[$file_path], + ); } public function fileExists(string $file_path): bool diff --git a/src/Psalm/Storage/ClassConstantStorage.php b/src/Psalm/Storage/ClassConstantStorage.php index 66917f816cc..9c6d7cfcde5 100644 --- a/src/Psalm/Storage/ClassConstantStorage.php +++ b/src/Psalm/Storage/ClassConstantStorage.php @@ -7,6 +7,9 @@ use Psalm\Internal\Scanner\UnresolvedConstantComponent; use Psalm\Type\Union; +use function array_values; +use function property_exists; + /** * @psalm-suppress PossiblyUnusedProperty * @psalm-immutable @@ -88,4 +91,36 @@ public function __construct( $this->suppressed_issues = $suppressed_issues; $this->description = $description; } + + /** + * Used in the Language Server + */ + public function getHoverMarkdown(string $const): string + { + switch ($this->visibility) { + case ClassLikeAnalyzer::VISIBILITY_PRIVATE: + $visibility_text = 'private'; + break; + + case ClassLikeAnalyzer::VISIBILITY_PROTECTED: + $visibility_text = 'protected'; + break; + + default: + $visibility_text = 'public'; + } + + $value = ''; + if ($this->type) { + $types = $this->type->getAtomicTypes(); + $type = array_values($types)[0]; + if (property_exists($type, 'value')) { + /** @psalm-suppress UndefinedPropertyFetch */ + $value = " = {$type->value};"; + } + } + + + return "$visibility_text const $const$value"; + } } diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php index 63f3c8952a1..929e2b84d7a 100644 --- a/src/Psalm/Storage/FunctionLikeStorage.php +++ b/src/Psalm/Storage/FunctionLikeStorage.php @@ -10,6 +10,7 @@ use function array_column; use function array_fill_keys; use function array_map; +use function count; use function implode; abstract class FunctionLikeStorage implements HasAttributesInterface @@ -245,30 +246,53 @@ abstract class FunctionLikeStorage implements HasAttributesInterface public bool $public_api = false; - public function __toString(): string + /** + * Used in the Language Server + */ + public function getHoverMarkdown(): string { - return $this->getSignature(false); + $params = count($this->params) > 0 ? "\n" . implode( + ",\n", + array_map( + function (FunctionLikeParameter $param): string { + $realType = $param->type ?: 'mixed'; + return " {$realType} \${$param->name}"; + }, + $this->params, + ), + ) . "\n" : ''; + $return_type = $this->return_type ?: 'mixed'; + $symbol_text = "function {$this->cased_name}({$params}): {$return_type}"; + + if (!$this instanceof MethodStorage) { + return $symbol_text; + } + + switch ($this->visibility) { + case ClassLikeAnalyzer::VISIBILITY_PRIVATE: + $visibility_text = 'private'; + break; + + case ClassLikeAnalyzer::VISIBILITY_PROTECTED: + $visibility_text = 'protected'; + break; + + default: + $visibility_text = 'public'; + } + + return $visibility_text . ' ' . $symbol_text; } - public function getSignature(bool $allow_newlines): string + public function getCompletionSignature(): string { - $newlines = $allow_newlines && !empty($this->params); - - $symbol_text = 'function ' . $this->cased_name . '(' - . ($newlines ? "\n" : '') - . implode( - ',' . ($newlines ? "\n" : ' '), - array_map( - static fn(FunctionLikeParameter $param): string => - ($newlines ? ' ' : '') - . ($param->type ? $param->type->getId(false) : 'mixed') - . ' $' . $param->name, - $this->params, - ), - ) - . ($newlines ? "\n" : '') - . ') : ' - . ($this->return_type ?: 'mixed'); + $symbol_text = 'function ' . $this->cased_name . '(' . implode( + ',', + array_map( + fn(FunctionLikeParameter $param): string => ($param->type ?: 'mixed') . ' $' . $param->name, + $this->params, + ), + ) . ') : ' . ($this->return_type ?: 'mixed'); if (!$this instanceof MethodStorage) { return $symbol_text; @@ -317,4 +341,18 @@ public function getAttributeStorages(): array { return $this->attributes; } + + public function __toString(): string + { + return $this->getCompletionSignature(); + } + + /** + * @deprecated will be removed in Psalm 6. use {@see FunctionLikeStorage::getCompletionSignature()} instead + * @psalm-suppress PossiblyUnusedParam, PossiblyUnusedMethod + */ + public function getSignature(bool $allow_newlines): string + { + return $this->getCompletionSignature(); + } } diff --git a/tests/AsyncTestCase.php b/tests/AsyncTestCase.php new file mode 100644 index 00000000000..e9834d7af89 --- /dev/null +++ b/tests/AsyncTestCase.php @@ -0,0 +1,196 @@ +file_provider = new FakeFileProvider(); + + $this->testConfig = $this->makeConfig(); + + $providers = new Providers( + $this->file_provider, + new FakeParserCacheProvider(), + ); + + $this->project_analyzer = new ProjectAnalyzer( + $this->testConfig, + $providers, + ); + + $this->project_analyzer->setPhpVersion('7.4', 'tests'); + } + + public function tearDown(): void + { + unset($this->project_analyzer, $this->file_provider, $this->testConfig); + RuntimeCaches::clearAll(); + } + + public function addFile(string $file_path, string $contents): void + { + $this->file_provider->registerFile($file_path, $contents); + $this->project_analyzer->getCodebase()->scanner->addFileToShallowScan($file_path); + } + + /** + * @psalm-suppress UnusedMethod + */ + public function analyzeFile(string $file_path, Context $context, bool $track_unused_suppressions = true, bool $taint_flow_tracking = false): void + { + $codebase = $this->project_analyzer->getCodebase(); + + if ($taint_flow_tracking) { + $this->project_analyzer->trackTaintedInputs(); + } + + $codebase->addFilesToAnalyze([$file_path => $file_path]); + + $codebase->scanFiles(); + + $codebase->config->visitStubFiles($codebase); + + if ($codebase->alter_code) { + $this->project_analyzer->interpretRefactors(); + } + + $this->project_analyzer->trackUnusedSuppressions(); + + $file_analyzer = new FileAnalyzer( + $this->project_analyzer, + $file_path, + $codebase->config->shortenFileName($file_path), + ); + $file_analyzer->analyze($context); + + if ($codebase->taint_flow_graph) { + $codebase->taint_flow_graph->connectSinksAndSources(); + } + + if ($track_unused_suppressions) { + IssueBuffer::processUnusedSuppressions($codebase->file_provider); + } + } + + /** + * @psalm-suppress UnusedMethod + */ + protected function getTestName(bool $withDataSet = true): string + { + return $this->getName($withDataSet); + } + + /** + * @psalm-suppress UnusedMethod + */ + public static function assertArrayKeysAreStrings(array $array, string $message = ''): void + { + $validKeys = array_filter($array, 'is_string', ARRAY_FILTER_USE_KEY); + self::assertTrue(count($array) === count($validKeys), $message); + } + + /** + * @psalm-suppress UnusedMethod + */ + public static function assertArrayKeysAreZeroOrString(array $array, string $message = ''): void + { + $isZeroOrString = /** @param mixed $key */ fn($key): bool => $key === 0 || is_string($key); + $validKeys = array_filter($array, $isZeroOrString, ARRAY_FILTER_USE_KEY); + self::assertTrue(count($array) === count($validKeys), $message); + } + + /** + * @psalm-suppress UnusedMethod + */ + public static function assertArrayValuesAreArrays(array $array, string $message = ''): void + { + $validValues = array_filter($array, 'is_array'); + self::assertTrue(count($array) === count($validValues), $message); + } + + /** + * @psalm-suppress UnusedMethod + */ + public static function assertArrayValuesAreStrings(array $array, string $message = ''): void + { + $validValues = array_filter($array, 'is_string'); + self::assertTrue(count($array) === count($validValues), $message); + } + + /** + * @psalm-suppress UnusedMethod + */ + public static function assertStringIsParsableType(string $type, string $message = ''): void + { + if ($type === '') { + // Ignore empty types for now, as these are quite common for pecl libraries + self::assertTrue(true); + } else { + $union = null; + try { + $tokens = TypeTokenizer::tokenize($type); + $union = TypeParser::parseTokens($tokens); + } catch (Throwable $_e) { + } + self::assertInstanceOf(Union::class, $union, $message); + } + } +} diff --git a/tests/FileUpdates/TemporaryUpdateTest.php b/tests/FileUpdates/TemporaryUpdateTest.php index 732955deba9..5acae2112f9 100644 --- a/tests/FileUpdates/TemporaryUpdateTest.php +++ b/tests/FileUpdates/TemporaryUpdateTest.php @@ -2,6 +2,7 @@ namespace Psalm\Tests\FileUpdates; +use Psalm\Codebase; use Psalm\Config; use Psalm\Internal\Analyzer\ProjectAnalyzer; use Psalm\Internal\Provider\FakeFileProvider; @@ -24,6 +25,8 @@ class TemporaryUpdateTest extends TestCase { + protected Codebase $codebase; + public function setUp(): void { parent::setUp(); @@ -42,10 +45,18 @@ public function setUp(): void new ProjectCacheProvider(), ); + $this->codebase = new Codebase($config, $providers); + $this->project_analyzer = new ProjectAnalyzer( $config, $providers, + null, + [], + 1, + null, + $this->codebase, ); + $this->project_analyzer->setPhpVersion('7.3', 'tests'); } @@ -62,7 +73,7 @@ public function testErrorFix( bool $test_save = true, bool $check_unused_code = false ): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->diff_methods = true; if ($check_unused_code) { diff --git a/tests/LanguageServer/CompletionTest.php b/tests/LanguageServer/CompletionTest.php index fd5f95fccc9..290562509bb 100644 --- a/tests/LanguageServer/CompletionTest.php +++ b/tests/LanguageServer/CompletionTest.php @@ -3,6 +3,7 @@ namespace Psalm\Tests\LanguageServer; use LanguageServerProtocol\Position; +use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\ProjectAnalyzer; use Psalm\Internal\Provider\FakeFileProvider; @@ -18,6 +19,8 @@ class CompletionTest extends TestCase { + protected Codebase $codebase; + public function setUp(): void { parent::setUp(); @@ -35,17 +38,25 @@ public function setUp(): void new ProjectCacheProvider(), ); + $this->codebase = new Codebase($config, $providers); + $this->project_analyzer = new ProjectAnalyzer( $config, $providers, + null, + [], + 1, + null, + $this->codebase, ); + $this->project_analyzer->setPhpVersion('7.3', 'tests'); $this->project_analyzer->getCodebase()->store_node_types = true; } public function testCompletionOnThisWithNoAssignment(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -73,7 +84,7 @@ public function foo() { public function testCompletionOnThisWithAssignmentBelow(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -103,7 +114,7 @@ public function foo() : self { public function testCompletionOnThisWithIfBelow(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -152,7 +163,7 @@ public function foo() : self { public function testCompletionOnSelfWithIfBelow(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -201,7 +212,7 @@ public function foo() : self { public function testCompletionOnSelfWithListBelow(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -250,7 +261,7 @@ public function foo() : self { public function testCompletionOnThisProperty(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -277,7 +288,7 @@ public function foo() : void { }', ); - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->file_provider->openFile('somefile.php'); $codebase->scanFiles(); @@ -288,7 +299,7 @@ public function foo() : void { public function testCompletionOnThisPropertyWithCharacter(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -315,7 +326,7 @@ public function foo() : void { }', ); - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->file_provider->openFile('somefile.php'); $codebase->scanFiles(); @@ -326,7 +337,7 @@ public function foo() : void { public function testCompletionOnThisPropertyWithAnotherCharacter(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -353,7 +364,7 @@ public function foo() : void { }', ); - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->file_provider->openFile('somefile.php'); $codebase->scanFiles(); @@ -364,7 +375,7 @@ public function foo() : void { public function testCompletionOnTemplatedThisProperty(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -400,7 +411,7 @@ public function foo() : void { }', ); - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->file_provider->openFile('somefile.php'); $codebase->scanFiles(); @@ -410,14 +421,14 @@ public function foo() : void { $this->assertSame(['B\C', '->', 726], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(3, $completion_items); } public function testCompletionOnMethodReturnValue(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -446,7 +457,7 @@ function foo(A $a) { public function testCompletionOnMethodArgument(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -477,7 +488,7 @@ function bar(A $a, C $c) { public function testCompletionOnMethodReturnValueWithArgument(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -508,7 +519,7 @@ function bar(A $a, C $c) { public function testCompletionOnVariableWithWhitespace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -533,7 +544,7 @@ function bar(A $a) { public function testCompletionOnVariableWithWhitespaceAndReturn(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -560,7 +571,7 @@ function baz(A $a) { public function testCompletionOnMethodReturnValueWithWhitespace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -590,7 +601,7 @@ function bar(A $a) { public function testCompletionOnMethodReturnValueWithWhitespaceAndReturn(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -621,7 +632,7 @@ function baz(A $a) { public function testCompletionOnMethodReturnValueWhereParamIsClosure(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -650,7 +661,7 @@ function bar(Collection $a) { public function testCompletionOnMethodReturnValueWhereParamIsClosureWithStmt(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -679,7 +690,7 @@ function baz(Collection $a) { public function testCursorPositionOnMethodCompletion(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -706,7 +717,7 @@ public function baz() {} $this->assertSame(['B\A&static', '->', 146], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(2, $completion_items); @@ -716,7 +727,7 @@ public function baz() {} public function testCompletionOnNewExceptionWithoutNamespace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -737,7 +748,7 @@ function foo() : void { public function testCompletionOnNewExceptionWithNamespaceNoUse(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -784,7 +795,7 @@ function foo() : void { public function testCompletionOnNewExceptionWithNamespaceAndUse(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -833,7 +844,7 @@ function foo() : void { public function testCompletionOnNamespaceWithFullyQualified(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -876,7 +887,7 @@ public function foo() : void { public function testCompletionOnExceptionWithNamespaceAndUseInClass(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -920,7 +931,7 @@ public function foo() : void { public function testCompletionForFunctionNames(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -956,7 +967,7 @@ function my_function_in_bar() : void { public function testCompletionForNamespacedOverriddenFunctionNames(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -986,7 +997,7 @@ function strlen() : void { public function testCompletionForFunctionNamesRespectUsedNamespaces(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1013,7 +1024,7 @@ public function testCompletionForFunctionNamesRespectUsedNamespaces(): void public function testCompletionForFunctionNamesRespectCase(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1039,7 +1050,7 @@ public function testCompletionForFunctionNamesRespectCase(): void public function testGetMatchingFunctionNames(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1064,7 +1075,7 @@ function my_function() { public function testGetMatchingFunctionNamesFromPredefinedFunctions(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1083,7 +1094,7 @@ public function testGetMatchingFunctionNamesFromPredefinedFunctions(): void public function testGetMatchingFunctionNamesFromUsedFunction(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1106,7 +1117,7 @@ public function testGetMatchingFunctionNamesFromUsedFunction(): void public function testGetMatchingFunctionNamesFromUsedNamespace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1129,7 +1140,7 @@ public function testGetMatchingFunctionNamesFromUsedNamespace(): void public function testGetMatchingFunctionNamesFromUsedNamespaceRespectFirstCharCase(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1152,7 +1163,7 @@ public function testGetMatchingFunctionNamesFromUsedNamespaceRespectFirstCharCas public function testGetMatchingFunctionNamesWithNamespace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1177,7 +1188,7 @@ function my_function() { public function testCompletionOnInstanceofWithNamespaceAndUse(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1220,7 +1231,7 @@ function foo($a) : void { public function testCompletionOnClassReference(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1245,14 +1256,14 @@ static function add() : void { $this->assertSame(['Bar\Alpha', '::', 221], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(2, $completion_items); } public function testCompletionOnClassInstanceReferenceWithAssignmentAfter(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1280,14 +1291,14 @@ public function add() : void {} $this->assertSame(['Bar\Alpha', '->', 200], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(1, $completion_items); } public function testCompletionOnClassStaticReferenceWithAssignmentAfter(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1314,13 +1325,13 @@ static function add() : void {} $this->assertSame(['Bar\Alpha', '::', 201], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(2, $completion_items); } public function testNoCrashOnLoopId(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1338,7 +1349,7 @@ public function testNoCrashOnLoopId(): void public function testCompletionOnArrayKey(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1372,7 +1383,7 @@ public function testCompletionOnArrayKey(): void public function testTypeContextForFunctionArgument(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1396,7 +1407,7 @@ function my_func(string $arg_a, bool $arg_b) : string { public function testTypeContextForFunctionArgumentWithWhiteSpace(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1420,7 +1431,7 @@ function my_func(string $arg_a, bool $arg_b) : string { public function testCallStaticInInstance(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -1439,7 +1450,7 @@ public function baz() : void {} }', ); - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->file_provider->openFile('somefile.php'); $codebase->scanFiles(); @@ -1449,14 +1460,14 @@ public function baz() : void {} $this->assertSame(['Foo&static', '->', 129], $completion_data); - $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1]); + $completion_items = $codebase->getCompletionItemsForClassishThing($completion_data[0], $completion_data[1], true); $this->assertCount(3, $completion_items); } public function testCompletionsForType(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; diff --git a/tests/LanguageServer/DiagnosticTest.php b/tests/LanguageServer/DiagnosticTest.php new file mode 100644 index 00000000000..690c008ea95 --- /dev/null +++ b/tests/LanguageServer/DiagnosticTest.php @@ -0,0 +1,663 @@ +file_provider = new FakeFileProvider(); + + $config = new TestConfig(); + + $providers = new Providers( + $this->file_provider, + new ParserInstanceCacheProvider(), + null, + null, + new FakeFileReferenceCacheProvider(), + new ProjectCacheProvider(), + ); + + $this->codebase = new Codebase($config, $providers); + + $this->project_analyzer = new ProjectAnalyzer( + $config, + $providers, + null, + [], + 1, + null, + $this->codebase, + ); + + $this->project_analyzer->setPhpVersion('7.4', 'tests'); + $this->project_analyzer->getCodebase()->store_node_types = true; + } + + public function testSnippetSupportDisabled(): void + { + // Create a new promisor + $deferred = new Deferred; + + $this->setTimeout(5000); + $clientConfiguration = new ClientConfiguration(); + + $read = new MockProtocolStream(); + $write = new MockProtocolStream(); + + $array = $this->generateInitializeRequest(); + /** @psalm-suppress MixedArrayAssignment */ + $array['params']['capabilities']['textDocument']['completion']['completionItem']['snippetSupport'] = false; + $read->write(new Message(MessageBody::parseArray($array))); + + $server = new LanguageServer( + $read, + $write, + $this->project_analyzer, + $this->codebase, + $clientConfiguration, + new Progress, + ); + + $write->on('message', function (Message $message) use ($deferred, $server): void { + /** @psalm-suppress PossiblyNullPropertyFetch,UndefinedPropertyFetch,MixedPropertyFetch */ + if ($message->body->method === 'telemetry/event' && $message->body->params->message === 'initialized') { + $this->assertFalse($server->clientCapabilities->textDocument->completion->completionItem->snippetSupport); + $deferred->resolve(null); + } + }); + + wait($deferred->promise()); + } + + /** + * @psalm-suppress UnusedMethod + */ + public function jestRun(): void + { + $config = $this->codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + '', + ); + + $issues = $this->changeFile( + 'somefile.php', + 'assertEmpty($issues); + + $issues = $this->changeFile( + 'somefile.php', + 'assertArrayHasKey('somefile.php', $issues); + $this->assertSame('Argument 1 of strpos expects a non-literal value, "" provided', $issues['somefile.php'][0]->message); + + $issues = $this->changeFile( + 'somefile.php', + 'assertArrayHasKey('somefile.php', $issues); + $this->assertSame('Argument 1 of strpos expects a non-literal value, "" provided', $issues['somefile.php'][0]->message); + $this->assertSame('Argument 1 of strpos expects a non-literal value, "" provided', $issues['somefile.php'][1]->message); + + $issues = $this->changeFile( + 'somefile.php', + 'assertArrayHasKey('somefile.php', $issues); + + $issues = $this->changeFile( + 'somefile.php', + 'assertArrayHasKey('somefile.php', $issues); + + $issues = $this->changeFile( + 'somefile.php', + 'assertArrayHasKey('somefile.php', $issues); + + $issues = $this->changeFile( + 'somefile.php', + 'assertEmpty($issues); + } + + /** + * @return array> + */ + private function changeFile(string $file_path, string $contents): array + { + $this->codebase->addTemporaryFileChanges( + $file_path, + $contents, + $this->increment, + ); + + $this->codebase->reloadFiles( + $this->project_analyzer, + [$file_path], + ); + $this->codebase->analyzer->addFilesToAnalyze( + [$file_path => $file_path], + ); + $this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); + + $this->increment++; + + return IssueBuffer::clear(); + } + + private function generateInitializeRequest(): array + { + return [ + 'method' => 'initialize', + 'params' => [ + 'processId' => rand(), + 'locale' => 'en-us', + 'capabilities' => [ + 'workspace' => [ + 'applyEdit' => true, + 'workspaceEdit' => [ + 'documentChanges' => true, + 'resourceOperations' => [ + 0 => 'create', + 1 => 'rename', + 2 => 'delete', + ], + 'failureHandling' => 'textOnlyTransactional', + 'normalizesLineEndings' => true, + 'changeAnnotationSupport' => [ + 'groupsOnLabel' => true, + ], + ], + 'didChangeConfiguration' => [ + 'dynamicRegistration' => true, + ], + 'didChangeWatchedFiles' => [ + 'dynamicRegistration' => true, + ], + 'symbol' => [ + 'dynamicRegistration' => true, + 'symbolKind' => [ + 'valueSet' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + 6 => 7, + 7 => 8, + 8 => 9, + 9 => 10, + 10 => 11, + 11 => 12, + 12 => 13, + 13 => 14, + 14 => 15, + 15 => 16, + 16 => 17, + 17 => 18, + 18 => 19, + 19 => 20, + 20 => 21, + 21 => 22, + 22 => 23, + 23 => 24, + 24 => 25, + 25 => 26, + ], + ], + 'tagSupport' => [ + 'valueSet' => [ + 0 => 1, + ], + ], + ], + 'codeLens' => [ + 'refreshSupport' => true, + ], + 'executeCommand' => [ + 'dynamicRegistration' => true, + ], + 'configuration' => true, + 'workspaceFolders' => true, + 'semanticTokens' => [ + 'refreshSupport' => true, + ], + 'fileOperations' => [ + 'dynamicRegistration' => true, + 'didCreate' => true, + 'didRename' => true, + 'didDelete' => true, + 'willCreate' => true, + 'willRename' => true, + 'willDelete' => true, + ], + ], + 'textDocument' => [ + 'publishDiagnostics' => [ + 'relatedInformation' => true, + 'versionSupport' => false, + 'tagSupport' => [ + 'valueSet' => [ + 0 => 1, + 1 => 2, + ], + ], + 'codeDescriptionSupport' => true, + 'dataSupport' => true, + ], + 'synchronization' => [ + 'dynamicRegistration' => true, + 'willSave' => true, + 'willSaveWaitUntil' => true, + 'didSave' => true, + ], + 'completion' => [ + 'dynamicRegistration' => true, + 'contextSupport' => true, + 'completionItem' => [ + 'snippetSupport' => true, + 'commitCharactersSupport' => true, + 'documentationFormat' => [ + 0 => 'markdown', + 1 => 'plaintext', + ], + 'deprecatedSupport' => true, + 'preselectSupport' => true, + 'tagSupport' => [ + 'valueSet' => [ + 0 => 1, + ], + ], + 'insertReplaceSupport' => true, + 'resolveSupport' => [ + 'properties' => [ + 0 => 'documentation', + 1 => 'detail', + 2 => 'additionalTextEdits', + ], + ], + 'insertTextModeSupport' => [ + 'valueSet' => [ + 0 => 1, + 1 => 2, + ], + ], + ], + 'completionItemKind' => [ + 'valueSet' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + 6 => 7, + 7 => 8, + 8 => 9, + 9 => 10, + 10 => 11, + 11 => 12, + 12 => 13, + 13 => 14, + 14 => 15, + 15 => 16, + 16 => 17, + 17 => 18, + 18 => 19, + 19 => 20, + 20 => 21, + 21 => 22, + 22 => 23, + 23 => 24, + 24 => 25, + ], + ], + ], + 'hover' => [ + 'dynamicRegistration' => true, + 'contentFormat' => [ + 0 => 'markdown', + 1 => 'plaintext', + ], + ], + 'signatureHelp' => [ + 'dynamicRegistration' => true, + 'signatureInformation' => [ + 'documentationFormat' => [ + 0 => 'markdown', + 1 => 'plaintext', + ], + 'parameterInformation' => [ + 'labelOffsetSupport' => true, + ], + 'activeParameterSupport' => true, + ], + 'contextSupport' => true, + ], + 'definition' => [ + 'dynamicRegistration' => true, + 'linkSupport' => true, + ], + 'references' => [ + 'dynamicRegistration' => true, + ], + 'documentHighlight' => [ + 'dynamicRegistration' => true, + ], + 'documentSymbol' => [ + 'dynamicRegistration' => true, + 'symbolKind' => [ + 'valueSet' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + 6 => 7, + 7 => 8, + 8 => 9, + 9 => 10, + 10 => 11, + 11 => 12, + 12 => 13, + 13 => 14, + 14 => 15, + 15 => 16, + 16 => 17, + 17 => 18, + 18 => 19, + 19 => 20, + 20 => 21, + 21 => 22, + 22 => 23, + 23 => 24, + 24 => 25, + 25 => 26, + ], + ], + 'hierarchicalDocumentSymbolSupport' => true, + 'tagSupport' => [ + 'valueSet' => [ + 0 => 1, + ], + ], + 'labelSupport' => true, + ], + 'codeAction' => [ + 'dynamicRegistration' => true, + 'isPreferredSupport' => true, + 'disabledSupport' => true, + 'dataSupport' => true, + 'resolveSupport' => [ + 'properties' => [ + 0 => 'edit', + ], + ], + 'codeActionLiteralSupport' => [ + 'codeActionKind' => [ + 'valueSet' => [ + 0 => '', + 1 => 'quickfix', + 2 => 'refactor', + 3 => 'refactor.extract', + 4 => 'refactor.inline', + 5 => 'refactor.rewrite', + 6 => 'source', + 7 => 'source.organizeImports', + ], + ], + ], + 'honorsChangeAnnotations' => false, + ], + 'codeLens' => [ + 'dynamicRegistration' => true, + ], + 'formatting' => [ + 'dynamicRegistration' => true, + ], + 'rangeFormatting' => [ + 'dynamicRegistration' => true, + ], + 'onTypeFormatting' => [ + 'dynamicRegistration' => true, + ], + 'rename' => [ + 'dynamicRegistration' => true, + 'prepareSupport' => true, + 'prepareSupportDefaultBehavior' => 1, + 'honorsChangeAnnotations' => true, + ], + 'documentLink' => [ + 'dynamicRegistration' => true, + 'tooltipSupport' => true, + ], + 'typeDefinition' => [ + 'dynamicRegistration' => true, + 'linkSupport' => true, + ], + 'implementation' => [ + 'dynamicRegistration' => true, + 'linkSupport' => true, + ], + 'colorProvider' => [ + 'dynamicRegistration' => true, + ], + 'foldingRange' => [ + 'dynamicRegistration' => true, + 'rangeLimit' => 5000, + 'lineFoldingOnly' => true, + ], + 'declaration' => [ + 'dynamicRegistration' => true, + 'linkSupport' => true, + ], + 'selectionRange' => [ + 'dynamicRegistration' => true, + ], + 'callHierarchy' => [ + 'dynamicRegistration' => true, + ], + 'semanticTokens' => [ + 'dynamicRegistration' => true, + 'tokenTypes' => [ + 0 => 'namespace', + 1 => 'type', + 2 => 'class', + 3 => 'enum', + 4 => 'interface', + 5 => 'struct', + 6 => 'typeParameter', + 7 => 'parameter', + 8 => 'variable', + 9 => 'property', + 10 => 'enumMember', + 11 => 'event', + 12 => 'function', + 13 => 'method', + 14 => 'macro', + 15 => 'keyword', + 16 => 'modifier', + 17 => 'comment', + 18 => 'string', + 19 => 'number', + 20 => 'regexp', + 21 => 'operator', + ], + 'tokenModifiers' => [ + 0 => 'declaration', + 1 => 'definition', + 2 => 'readonly', + 3 => 'static', + 4 => 'deprecated', + 5 => 'abstract', + 6 => 'async', + 7 => 'modification', + 8 => 'documentation', + 9 => 'defaultLibrary', + ], + 'formats' => [ + 0 => 'relative', + ], + 'requests' => [ + 'range' => true, + 'full' => [ + 'delta' => true, + ], + ], + 'multilineTokenSupport' => false, + 'overlappingTokenSupport' => false, + ], + 'linkedEditingRange' => [ + 'dynamicRegistration' => true, + ], + ], + 'window' => [ + 'showMessage' => [ + 'messageActionItem' => [ + 'additionalPropertiesSupport' => true, + ], + ], + 'showDocument' => [ + 'support' => true, + ], + 'workDoneProgress' => true, + ], + 'general' => [ + 'regularExpressions' => [ + 'engine' => 'ECMAScript', + 'version' => 'ES2020', + ], + 'markdown' => [ + 'parser' => 'marked', + 'version' => '1.1.0', + ], + ], + ], + 'trace' => 'off', + ], + ]; + } +} diff --git a/tests/LanguageServer/FileMapTest.php b/tests/LanguageServer/FileMapTest.php index 3eff330fd10..e17b19be5d1 100644 --- a/tests/LanguageServer/FileMapTest.php +++ b/tests/LanguageServer/FileMapTest.php @@ -2,6 +2,7 @@ namespace Psalm\Tests\LanguageServer; +use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\ProjectAnalyzer; use Psalm\Internal\Provider\FakeFileProvider; @@ -16,6 +17,8 @@ class FileMapTest extends TestCase { + protected Codebase $codebase; + public function setUp(): void { parent::setUp(); @@ -33,17 +36,25 @@ public function setUp(): void new ProjectCacheProvider(), ); + $this->codebase = new Codebase($config, $providers); + $this->project_analyzer = new ProjectAnalyzer( $config, $providers, + null, + [], + 1, + null, + $this->codebase, ); + $this->project_analyzer->setPhpVersion('7.3', 'tests'); $this->project_analyzer->getCodebase()->store_node_types = true; } public function testMapIsUpdatedOnReloadFiles(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -73,7 +84,7 @@ function __construct( string $var ) { public function testGetTypeMap(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $config = $codebase->config; $config->throw_exception = false; @@ -109,7 +120,7 @@ function __construct( string $var ) { public function testMapIsUpdatedAfterEditingMethod(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->diff_methods = true; $config = $codebase->config; $config->throw_exception = false; @@ -163,7 +174,7 @@ public function second(\DateTimeImmutable $d) : void { public function testMapIsUpdatedAfterDeletingFirstMethod(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->diff_methods = true; $config = $codebase->config; $config->throw_exception = false; @@ -211,7 +222,7 @@ public function second_method(\DateTimeImmutable $d) : void { public function testMapIsUpdatedAfterDeletingSecondMethod(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->diff_methods = true; $config = $codebase->config; $config->throw_exception = false; @@ -257,7 +268,7 @@ public function second(\DateTimeImmutable $d) : void { public function testMapIsUpdatedAfterAddingMethod(): void { - $codebase = $this->project_analyzer->getCodebase(); + $codebase = $this->codebase; $codebase->diff_methods = true; $config = $codebase->config; $config->throw_exception = false; diff --git a/tests/LanguageServer/Message.php b/tests/LanguageServer/Message.php new file mode 100644 index 00000000000..075a50a8134 --- /dev/null +++ b/tests/LanguageServer/Message.php @@ -0,0 +1,43 @@ +method, $decoded->params ?? null); + } elseif (Request::isRequest($decoded)) { + /** @psalm-suppress MixedArgument */ + $obj = new Request($decoded->id, $decoded->method, $decoded->params ?? null); + } elseif (SuccessResponse::isSuccessResponse($decoded)) { + /** @psalm-suppress MixedArgument */ + $obj = new SuccessResponse($decoded->id, $decoded->result); + } elseif (ErrorResponse::isErrorResponse($decoded)) { + /** @psalm-suppress MixedArgument, MixedPropertyFetch */ + $obj = new ErrorResponse($decoded->id, new Error($decoded->error->message, $decoded->error->code, $decoded->error->data ?? null)); + } else { + throw new Error('Invalid message', ErrorCode::INVALID_REQUEST); + } + return $obj; + } +} diff --git a/tests/LanguageServer/MockProtocolStream.php b/tests/LanguageServer/MockProtocolStream.php new file mode 100644 index 00000000000..34f8072c9cc --- /dev/null +++ b/tests/LanguageServer/MockProtocolStream.php @@ -0,0 +1,40 @@ +emit('message', [Message::parse((string)$msg)]); + }); + + // Create a new promisor + $deferred = new Deferred; + + $deferred->resolve(null); + + return $deferred->promise(); + } +} diff --git a/tests/LanguageServer/SymbolLookupTest.php b/tests/LanguageServer/SymbolLookupTest.php index 4223885110f..d235bc33442 100644 --- a/tests/LanguageServer/SymbolLookupTest.php +++ b/tests/LanguageServer/SymbolLookupTest.php @@ -3,9 +3,12 @@ namespace Psalm\Tests\LanguageServer; use LanguageServerProtocol\Position; +use LanguageServerProtocol\Range; +use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\FileAnalyzer; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\Internal\LanguageServer\Reference; use Psalm\Internal\Provider\FakeFileProvider; use Psalm\Internal\Provider\Providers; use Psalm\Tests\Internal\Provider\FakeFileReferenceCacheProvider; @@ -16,6 +19,8 @@ class SymbolLookupTest extends TestCase { + protected Codebase $codebase; + public function setUp(): void { parent::setUp(); @@ -33,9 +38,16 @@ public function setUp(): void new ProjectCacheProvider(), ); + $this->codebase = new Codebase($config, $providers); + $this->project_analyzer = new ProjectAnalyzer( $config, $providers, + null, + [], + 1, + null, + $this->codebase, ); $this->project_analyzer->setPhpVersion('7.3', 'tests'); @@ -44,8 +56,7 @@ public function setUp(): void public function testSimpleSymbolLookup(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->globals['$my_global'] = 'string'; $this->addFile( 'somefile.php', @@ -83,41 +94,105 @@ function qux(int $a, int $b) : int { new FileAnalyzer($this->project_analyzer, 'somefile.php', 'somefile.php'); - $codebase = $this->project_analyzer->getCodebase(); - $this->analyzeFile('somefile.php', new Context()); - $information = $codebase->getSymbolInformation('somefile.php', 'B\A::foo()'); - $this->assertNotNull($information); - $this->assertSame('getSymbolInformation('somefile.php', 'B\A::$a'); + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\A::foo()', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame('getSymbolInformation('somefile.php', 'B\bar()'); + $this->assertSame("public function foo(): void", $information->code); + $this->assertSame("B\A::foo", $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\A::$a', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame('getSymbolInformation('somefile.php', 'B\A::BANANA'); + $this->assertSame('protected int|null $a', $information->code); + $this->assertSame('B\A::$a', $information->title); + $this->assertSame('', $information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\bar()', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame('getSymbolInformation('somefile.php', 'B\baz()'); + $this->assertSame('function B\bar(): int', $information->code); + $this->assertSame('b\bar', $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\A::BANANA', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame("getSymbolInformation('somefile.php', 'B\qux()'); + $this->assertSame('public const BANANA = 🍌;', $information->code); + $this->assertSame('B\A::BANANA', $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\baz()', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame("getSymbolInformation('somefile.php', '$_SESSION'); + $this->assertSame("function B\baz(\n int \$a\n): int", $information->code); + $this->assertSame('b\baz', $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'B\qux()', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame("", $information['type']); - - $information = $codebase->getSymbolInformation('somefile.php', '$my_global'); + $this->assertSame("function B\qux(\n int \$a,\n int \$b\n): int", $information->code); + $this->assertSame('b\qux', $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + '$_SESSION', + $range, + ), + ); + $this->assertNotNull($information); + $this->assertSame("array", $information->code); + $this->assertSame('$_SESSION', $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + '$my_global', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame("assertSame("string", $information->code); + $this->assertSame('$my_global', $information->title); + $this->assertNull($information->description); } public function testSimpleSymbolLookupGlobalConst(): void @@ -131,16 +206,33 @@ public function testSimpleSymbolLookupGlobalConst(): void new FileAnalyzer($this->project_analyzer, 'somefile.php', 'somefile.php'); - $codebase = $this->project_analyzer->getCodebase(); + $range = new Range(new Position(1, 1), new Position(1, 1)); $this->analyzeFile('somefile.php', new Context()); - $information = $codebase->getSymbolInformation('somefile.php', 'APPLE'); - $this->assertNotNull($information); - $this->assertSame("getSymbolInformation('somefile.php', 'BANANA'); + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'APPLE', + $range, + ), + ); $this->assertNotNull($information); - $this->assertSame("assertSame("const APPLE string", $information->code); + $this->assertSame("APPLE", $information->title); + $this->assertNull($information->description); + + $information = $this->codebase->getMarkupContentForSymbolByReference( + new Reference( + 'somefile.php', + 'BANANA', + $range, + ), + ); + $this->assertNotNull($information); + $this->assertSame("const BANANA string", $information->code); + $this->assertSame("BANANA", $information->title); + $this->assertNull($information->description); } public function testSimpleSymbolLocation(): void @@ -169,35 +261,56 @@ function bar() : int { new FileAnalyzer($this->project_analyzer, 'somefile.php', 'somefile.php'); - $codebase = $this->project_analyzer->getCodebase(); $this->analyzeFile('somefile.php', new Context()); - $method_symbol_location = $codebase->getSymbolLocation('somefile.php', 'B\A::foo()'); + $range = new Range(new Position(1, 1), new Position(1, 1)); + + $method_symbol_location = $this->codebase->getSymbolLocationByReference(new Reference( + 'somefile.php', + 'B\A::foo()', + $range, + )); $this->assertNotNull($method_symbol_location); $this->assertSame(10, $method_symbol_location->getLineNumber()); $this->assertSame(37, $method_symbol_location->getColumn()); - $property_symbol_location = $codebase->getSymbolLocation('somefile.php', 'B\A::$a'); + $property_symbol_location = $this->codebase->getSymbolLocationByReference(new Reference( + 'somefile.php', + 'B\A::$a', + $range, + )); $this->assertNotNull($property_symbol_location); $this->assertSame(6, $property_symbol_location->getLineNumber()); $this->assertSame(31, $property_symbol_location->getColumn()); - $constant_symbol_location = $codebase->getSymbolLocation('somefile.php', 'B\A::BANANA'); + $constant_symbol_location = $this->codebase->getSymbolLocationByReference(new Reference( + 'somefile.php', + 'B\A::BANANA', + $range, + )); $this->assertNotNull($constant_symbol_location); $this->assertSame(8, $constant_symbol_location->getLineNumber()); $this->assertSame(27, $constant_symbol_location->getColumn()); - $function_symbol_location = $codebase->getSymbolLocation('somefile.php', 'B\bar()'); + $function_symbol_location = $this->codebase->getSymbolLocationByReference(new Reference( + 'somefile.php', + 'B\bar()', + $range, + )); $this->assertNotNull($function_symbol_location); $this->assertSame(16, $function_symbol_location->getLineNumber()); $this->assertSame(26, $function_symbol_location->getColumn()); - $function_symbol_location = $codebase->getSymbolLocation('somefile.php', '257-259'); + $function_symbol_location = $this->codebase->getSymbolLocationByReference(new Reference( + 'somefile.php', + '257-259', + $range, + )); $this->assertNotNull($function_symbol_location); $this->assertSame(11, $function_symbol_location->getLineNumber()); @@ -206,8 +319,7 @@ function bar() : int { public function testSymbolLookupAfterAlteration(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -234,11 +346,11 @@ public function bar() : void { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $codebase->addTemporaryFileChanges( + $this->codebase->addTemporaryFileChanges( 'somefile.php', 'reloadFiles($this->project_analyzer, ['somefile.php']); + $this->codebase->reloadFiles($this->project_analyzer, ['somefile.php']); - $codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); + $this->codebase->analyzer->analyzeFiles($this->project_analyzer, 1, false); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(10, 30)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(10, 30)); - $this->assertNotNull($symbol_at_position); + $this->assertNotNull($reference); - $this->assertSame('245-246:int|null', $symbol_at_position[0]); + $this->assertSame('245-246:int|null', $reference->symbol); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(12, 30)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(12, 30)); - $this->assertNotNull($symbol_at_position); + $this->assertNotNull($reference); - $this->assertSame('213-214:1', $symbol_at_position[0]); + $this->assertSame('213-214:1', $reference->symbol); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(17, 30)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(17, 30)); - $this->assertNotNull($symbol_at_position); + $this->assertNotNull($reference); - $this->assertSame('425-426:2', $symbol_at_position[0]); + $this->assertSame('425-426:2', $reference->symbol); } public function testGetSymbolPositionMissingArg(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -307,22 +418,21 @@ public function bar() : void { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(9, 33)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(9, 33)); - $this->assertNotNull($symbol_at_position); + $this->assertNotNull($reference); - $this->assertSame('B\A::foo()', $symbol_at_position[0]); + $this->assertSame('B\A::foo()', $reference->symbol); } public function testGetSymbolPositionGlobalVariable(): void { - $codebase = $this->project_analyzer->getCodebase(); - $codebase->reportUnusedVariables(); - $config = $codebase->config; + $this->codebase->reportUnusedVariables(); + $config = $this->codebase->config; $config->throw_exception = false; $config->globals['$my_global'] = 'string'; @@ -335,23 +445,22 @@ function foo() : void { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(2, 31)); - $this->assertNotNull($symbol_at_position); - $this->assertSame('$my_global', $symbol_at_position[0]); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(2, 31)); + $this->assertNotNull($reference); + $this->assertSame('$my_global', $reference->symbol); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(3, 28)); - $this->assertNotNull($symbol_at_position); - $this->assertSame('73-82:string', $symbol_at_position[0]); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(3, 28)); + $this->assertNotNull($reference); + $this->assertSame('73-82:string', $reference->symbol); } public function testGetSymbolPositionNullableArg(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -364,20 +473,19 @@ function B( ?AClass $class ) { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(4, 33)); - $this->assertNotNull($symbol_at_position); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(4, 33)); + $this->assertNotNull($reference); - $this->assertSame('B\AClass', $symbol_at_position[0]); + $this->assertSame('B\AClass', $reference->symbol); } public function testGetSymbolPositionMethodWrongReturnType(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -394,20 +502,19 @@ protected function get_command() : AClass { ', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(6, 60)); - $this->assertNotNull($symbol_at_position); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(6, 60)); + $this->assertNotNull($reference); - $this->assertSame('B\AClass', $symbol_at_position[0]); + $this->assertSame('B\AClass', $reference->symbol); } public function testGetSymbolPositionUseStatement(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -418,20 +525,19 @@ public function testGetSymbolPositionUseStatement(): void ', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(2, 25)); - $this->assertNotNull($symbol_at_position); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(2, 25)); + $this->assertNotNull($reference); - $this->assertSame('StreamWrapper', $symbol_at_position[0]); + $this->assertSame('StreamWrapper', $reference->symbol); } public function testGetSymbolPositionRange(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -445,23 +551,22 @@ function foo() : string { $active_symbol = foo();', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); // This is focusing the $active_symbol variable, the LSP Range that is // returned should also point to the same variable (that's where hover popovers will show) - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(6, 26)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(6, 26)); - $this->assertNotNull($symbol_at_position); - $this->assertSame(16, $symbol_at_position[1]->start->character); - $this->assertSame(30, $symbol_at_position[1]->end->character); + $this->assertNotNull($reference); + $this->assertSame(16, $reference->range->start->character); + $this->assertSame(30, $reference->range->end->character); } public function testGetTypeInDocblock(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -475,15 +580,15 @@ class A { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $symbol_at_position = $codebase->getReferenceAtPosition('somefile.php', new Position(4, 35)); + $reference = $this->codebase->getReferenceAtPositionAsReference('somefile.php', new Position(4, 35)); - $this->assertNotNull($symbol_at_position); + $this->assertNotNull($reference); - $this->assertSame('Exception', $symbol_at_position[0]); + $this->assertSame('Exception', $reference->symbol); } /** @@ -523,8 +628,7 @@ public function testGetSignatureHelp( ?int $expected_argument_number, ?int $expected_param_count ): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -559,11 +663,11 @@ function foo(string $a) { }', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $reference_location = $codebase->getFunctionArgumentAtPosition('somefile.php', $position); + $reference_location = $this->codebase->getFunctionArgumentAtPosition('somefile.php', $position); if ($expected_symbol !== null) { $this->assertNotNull($reference_location); @@ -571,7 +675,7 @@ function foo(string $a) { $this->assertSame($expected_symbol, $symbol); $this->assertSame($expected_argument_number, $argument_number); - $symbol_information = $codebase->getSignatureInformation($reference_location[0]); + $symbol_information = $this->codebase->getSignatureInformation($reference_location[0]); if ($expected_param_count === null) { $this->assertNull($symbol_information); @@ -587,8 +691,7 @@ function foo(string $a) { public function testGetSignatureHelpIncludesParamDescription(): void { - $codebase = $this->project_analyzer->getCodebase(); - $config = $codebase->config; + $config = $this->codebase->config; $config->throw_exception = false; $this->addFile( @@ -603,13 +706,13 @@ function foo(string $a) { foo();', ); - $codebase->file_provider->openFile('somefile.php'); - $codebase->scanFiles(); + $this->codebase->file_provider->openFile('somefile.php'); + $this->codebase->scanFiles(); $this->analyzeFile('somefile.php', new Context()); - $reference_location = $codebase->getFunctionArgumentAtPosition('somefile.php', new Position(7, 20)); + $reference_location = $this->codebase->getFunctionArgumentAtPosition('somefile.php', new Position(7, 20)); $this->assertNotNull($reference_location); - $symbol_information = $codebase->getSignatureInformation($reference_location[0], 'somefile.php'); + $symbol_information = $this->codebase->getSignatureInformation($reference_location[0], 'somefile.php'); $this->assertNotNull($symbol_information); $this->assertNotNull($symbol_information->parameters); $this->assertEquals('The first param, a.', $symbol_information->parameters[0]->documentation);