diff --git a/composer.json b/composer.json index 97c1cf1a8cf..8ac370f963f 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "openlss/lib-array2xml": "^1.0", "ocramius/package-versions": "^1.2", "composer/xdebug-handler": "^1.1", - "felixfbecker/language-server-protocol": "^1.3", + "felixfbecker/language-server-protocol": "^1.4", "felixfbecker/advanced-json-rpc": "^3.0.3", "netresearch/jsonmapper": "^1.0", "webmozart/glob": "^4.1", diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index fba7ca605d9..92dce487404 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -1141,6 +1141,60 @@ public function getReferenceAtPosition(string $file_path, Position $position) return [$reference, $range]; } + /** + * @return array{0: string, 1: int, 2: Range}|null + */ + public function getFunctionArgumentAtPosition(string $file_path, Position $position) + { + $is_open = $this->file_provider->isOpen($file_path); + + if (!$is_open) { + throw new \Psalm\Exception\UnanalyzedFileException($file_path . ' is not open'); + } + + $file_contents = $this->getFileContents($file_path); + + $offset = $position->toOffset($file_contents); + + list(,,, $argument_map) = $this->analyzer->getMapsForFile($file_path); + + $reference = null; + $argument_number = null; + + if (!$argument_map) { + return null; + } + + $start_pos = null; + $end_pos = null; + + ksort($argument_map); + + foreach ($argument_map as $start_pos => list($end_pos, $possible_reference, $possible_argument_number)) { + if ($offset < $start_pos) { + break; + } + + if ($offset > $end_pos) { + continue; + } + + $reference = $possible_reference; + $argument_number = $possible_argument_number; + } + + if ($reference === null || $start_pos === null || $end_pos === null || $argument_number === null) { + return null; + } + + $range = new Range( + self::getPositionFromOffset($start_pos, $file_contents), + self::getPositionFromOffset($end_pos, $file_contents) + ); + + return [$reference, $argument_number, $range]; + } + /** * @return array{0: string, 1: '->'|'::'|'symbol', 2: array}|null */ diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php index 2c52ffd2893..ae678c9f7fa 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php @@ -46,6 +46,11 @@ use function array_search; use function array_keys; use function in_array; +use function substr; +use function token_get_all; +use function array_reverse; +use function strlen; +use function reset; /** * @internal @@ -967,6 +972,13 @@ function (PhpParser\Node\Arg $arg) { } } + self::recordArgumentPositions( + $statements_analyzer, + $stmt, + $codebase, + $method_id + ); + if (self::checkMethodArgs( $method_id, $args, @@ -1656,4 +1668,107 @@ private static function checkMagicGetterOrSetterProperty( return true; } + + private static function recordArgumentPositions( + StatementsAnalyzer $statements_analyzer, + PhpParser\Node\Expr\MethodCall $stmt, + Codebase $codebase, + string $method_id + ): void { + $file_content = $codebase->file_provider->getContents($statements_analyzer->getFilePath()); + + // Find opening paren + $first_argument = $stmt->args[0] ?? null; + $first_argument_character = $first_argument !== null + ? $first_argument->getStartFilePos() + : ($stmt->getEndFilePos() - 1); + $method_name_and_first_parent_source_code = substr( + $file_content, + $stmt->getStartFilePos(), + $first_argument_character - $stmt->getStartFilePos() + ); + $method_name_and_first_parent_tokens = token_get_all('args[0] ?? null; + $first_argument_starting_position = $first_argument !== null + ? $first_argument->getStartFilePos() + : $stmt->getEndFilePos() - 1; + $first_range_starting_position = $opening_paren_position + 1; + if ($first_range_starting_position !== $first_argument_starting_position) { + $ranges[] = [$first_range_starting_position, $first_argument_starting_position]; + } + + // Add range between arguments + foreach ($stmt->args as $i => $argument) { + $range_start = $argument->getEndFilePos() + 1; + $next_argument = $stmt->args[$i + 1] ?? null; + $range_end = $next_argument !== null + ? $next_argument->getStartFilePos() + : $stmt->getEndFilePos(); + + if ($range_start !== $range_end) { + $ranges[] = [$range_start, $range_end]; + } + } + + $commas = []; + foreach ($ranges as $range) { + $position = $range[0]; + $length = $range[1] - $position; + $range_source_code = substr($file_content, $position, $length); + $range_tokens = token_get_all('analyzer->addNodeArgument( + $statements_analyzer->getFilePath(), + $argument_start_position, + $comma, + $method_id, + $argument_number + ); + + ++$argument_number; + $argument_start_position = $comma + 1; + } + + $codebase->analyzer->addNodeArgument( + $statements_analyzer->getFilePath(), + $argument_start_position, + $stmt->getEndFilePos(), + $method_id, + $argument_number + ); + } } diff --git a/src/Psalm/Internal/Codebase/Analyzer.php b/src/Psalm/Internal/Codebase/Analyzer.php index d0ae14819b3..03835f63823 100644 --- a/src/Psalm/Internal/Codebase/Analyzer.php +++ b/src/Psalm/Internal/Codebase/Analyzer.php @@ -62,7 +62,8 @@ * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * >, * class_locations: array>, @@ -157,6 +158,11 @@ class Analyzer */ private $alias_map = []; + /** + * @var array> + */ + private $argument_map = []; + /** * @var array> */ @@ -437,10 +443,12 @@ function () { FileManipulationBuffer::add($file_path, $manipulations); } - foreach ($pool_data['file_maps'] as $file_path => list($reference_map, $type_map, $alias_map)) { + foreach ($pool_data['file_maps'] as $file_path => $file_maps) { + list($reference_map, $type_map, $alias_map, $argument_map) = $file_maps; $this->reference_map[$file_path] = $reference_map; $this->type_map[$file_path] = $type_map; $this->alias_map[$file_path] = $alias_map; + $this->argument_map[$file_path] = $argument_map; } } @@ -524,10 +532,11 @@ public function loadCachedResults(ProjectAnalyzer $project_analyzer) $this->existing_issues = $codebase->file_reference_provider->getExistingIssues(); $file_maps = $codebase->file_reference_provider->getFileMaps(); - foreach ($file_maps as $file_path => list($reference_map, $type_map, $alias_map)) { + foreach ($file_maps as $file_path => list($reference_map, $type_map, $alias_map, $argument_map)) { $this->reference_map[$file_path] = $reference_map; $this->type_map[$file_path] = $type_map; $this->alias_map[$file_path] = $alias_map; + $this->argument_map[$file_path] = $argument_map; } } @@ -917,6 +926,40 @@ public function shiftFileOffsets(array $diff_map) } } } + + foreach ($this->argument_map as $file_path => &$argument_map) { + if (!isset($this->analyzed_methods[$file_path])) { + unset($this->argument_map[$file_path]); + continue; + } + + $file_diff_map = $diff_map[$file_path] ?? []; + + if (!$file_diff_map) { + continue; + } + + $first_diff_offset = $file_diff_map[0][0]; + $last_diff_offset = $file_diff_map[count($file_diff_map) - 1][1]; + + foreach ($argument_map as $argument_from => list($argument_to, $method_id, $argument_number)) { + if ($argument_to < $first_diff_offset || $argument_from > $last_diff_offset) { + continue; + } + + + foreach ($file_diff_map as list($from, $to, $file_offset)) { + if ($argument_from >= $from && $argument_from <= $to) { + unset($argument_map[$argument_from]); + $argument_map[$argument_from += $file_offset] = [ + $argument_to += $file_offset, + $method_id, + $argument_number, + ]; + } + } + } + } } /** @@ -1056,6 +1099,20 @@ public function addNodeAliases( ]; } + public function addNodeArgument( + string $file_path, + int $start_position, + int $end_position, + string $reference, + int $argument_number + ): void { + $this->argument_map[$file_path][$start_position] = [ + $end_position, + $reference, + $argument_number + ]; + } + /** * @return void */ @@ -1309,6 +1366,14 @@ public function removeExistingDataForFile($file_path, $start, $end) } } } + + if (isset($this->argument_map[$file_path])) { + foreach ($this->argument_map[$file_path] as $map_start => $_) { + if ($map_start >= $start && $map_start <= $end) { + unset($this->argument_map[$file_path][$map_start]); + } + } + } } /** @@ -1325,7 +1390,8 @@ public function getAnalyzedMethods() * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ @@ -1334,14 +1400,14 @@ public function getFileMaps() $file_maps = []; foreach ($this->reference_map as $file_path => $reference_map) { - $file_maps[$file_path] = [$reference_map, [], []]; + $file_maps[$file_path] = [$reference_map, [], [], []]; } foreach ($this->type_map as $file_path => $type_map) { if (isset($file_maps[$file_path])) { $file_maps[$file_path][1] = $type_map; } else { - $file_maps[$file_path] = [[], $type_map, []]; + $file_maps[$file_path] = [[], $type_map, [], []]; } } @@ -1349,7 +1415,15 @@ public function getFileMaps() if (isset($file_maps[$file_path])) { $file_maps[$file_path][2] = $alias_map; } else { - $file_maps[$file_path] = [[], [], $alias_map]; + $file_maps[$file_path] = [[], [], $alias_map, []]; + } + } + + foreach ($this->argument_map as $file_path => $argument_map) { + if (isset($file_maps[$file_path])) { + $file_maps[$file_path][3] = $argument_map; + } else { + $file_maps[$file_path] = [[], [], [], $argument_map]; } } @@ -1357,7 +1431,12 @@ public function getFileMaps() } /** - * @return array{0: TaggedCodeType, 1: TaggedCodeType, 2: array}>} + * @return array{ + * 0: TaggedCodeType, + * 1: TaggedCodeType, + * 2: array}>, + * 3: array + * } */ public function getMapsForFile(string $file_path) { @@ -1365,6 +1444,7 @@ public function getMapsForFile(string $file_path) $this->reference_map[$file_path] ?? [], $this->type_map[$file_path] ?? [], $this->alias_map[$file_path] ?? [], + $this->argument_map[$file_path] ?? [], ]; } diff --git a/src/Psalm/Internal/LanguageServer/LanguageServer.php b/src/Psalm/Internal/LanguageServer/LanguageServer.php index 76a2a94ef5d..de580a48536 100644 --- a/src/Psalm/Internal/LanguageServer/LanguageServer.php +++ b/src/Psalm/Internal/LanguageServer/LanguageServer.php @@ -241,10 +241,7 @@ function () use ($capabilities, $rootPath, $processId) { $serverCapabilities->completionProvider->triggerCharacters = ['$', '>', ':']; } - /* - $serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(); - $serverCapabilities->signatureHelpProvider->triggerCharacters = ['(', ',']; - */ + $serverCapabilities->signatureHelpProvider = new SignatureHelpOptions(['(', ',']); // Support global references $serverCapabilities->xworkspaceReferencesProvider = false; diff --git a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php index ba90137a97c..85ef2cf00d2 100644 --- a/src/Psalm/Internal/LanguageServer/Server/TextDocument.php +++ b/src/Psalm/Internal/LanguageServer/Server/TextDocument.php @@ -37,6 +37,7 @@ use function error_log; use function count; use function substr_count; +use function strlen; /** * Provides method handlers for all textDocument/* methods @@ -273,4 +274,50 @@ public function completion(TextDocumentIdentifier $textDocument, Position $posit return new Success(new CompletionList($completion_items, false)); } + + public function signatureHelp(TextDocumentIdentifier $textDocument, Position $position): Promise + { + $file_path = LanguageServer::uriToPath($textDocument->uri); + + $argument_location = $this->codebase->getFunctionArgumentAtPosition($file_path, $position); + if ($argument_location === null) { + error_log('No argument location'); + return new Success(new \LanguageServerProtocol\SignatureHelp()); + } + + list($method_symbol, $argument_number) = $argument_location; + + $declaring_method_id = $this->codebase->methods->getDeclaringMethodId($method_symbol); + if ($declaring_method_id === null) { + error_log('No declaring method id'); + return new Success(new \LanguageServerProtocol\SignatureHelp()); + } + + $method_storage = $this->codebase->methods->getStorage($declaring_method_id); + + $signature_label = '('; + $parameters = []; + foreach ($method_storage->params as $i => $param) { + $parameter_label = ($param->type ?: 'mixed') . ' $' . $param->name; + $parameters[] = new \LanguageServerProtocol\ParameterInformation([ + strlen($signature_label), + strlen($signature_label) + strlen($parameter_label), + ]) ; + $signature_label .= $parameter_label; + + if ($i < (count($method_storage->params) - 1)) { + $signature_label .= ', '; + } + } + $signature_label .= ')'; + + error_log('Argument ' . $argument_number . ' of ' . $method_storage->cased_name); + + return new Success(new \LanguageServerProtocol\SignatureHelp([ + new \LanguageServerProtocol\SignatureInformation( + $signature_label, + $parameters + ), + ], null, $argument_number)); + } } diff --git a/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php b/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php index 6d68ec8f1ff..6007b4c90ab 100644 --- a/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php +++ b/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php @@ -520,7 +520,8 @@ public function setAnalyzedMethodCache(array $analyzed_methods) * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * >|false */ @@ -540,7 +541,8 @@ public function getFileMapCache() * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ @@ -557,7 +559,8 @@ public function getFileMapCache() * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > $file_maps * @return void diff --git a/src/Psalm/Internal/Provider/FileReferenceProvider.php b/src/Psalm/Internal/Provider/FileReferenceProvider.php index b7a48c67575..3eabde15de4 100644 --- a/src/Psalm/Internal/Provider/FileReferenceProvider.php +++ b/src/Psalm/Internal/Provider/FileReferenceProvider.php @@ -135,7 +135,8 @@ class FileReferenceProvider * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ @@ -941,7 +942,8 @@ public function setAnalyzedMethods(array $analyzed_methods) * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > $file_maps */ @@ -981,7 +983,8 @@ public function getAnalyzedMethods() * array{ * 0: TaggedCodeType, * 1: TaggedCodeType, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ diff --git a/tests/Internal/Provider/FakeFileReferenceCacheProvider.php b/tests/Internal/Provider/FakeFileReferenceCacheProvider.php index db73b17648b..e0aca3a9301 100644 --- a/tests/Internal/Provider/FakeFileReferenceCacheProvider.php +++ b/tests/Internal/Provider/FakeFileReferenceCacheProvider.php @@ -43,7 +43,8 @@ class FakeFileReferenceCacheProvider extends \Psalm\Internal\Provider\FileRefere * array{ * 0: array, * 1: array, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ @@ -249,7 +250,8 @@ public function setAnalyzedMethodCache(array $correct_methods) * array{ * 0: array, * 1: array, - * 2: array}> + * 2: array}>, + * 3: array * } * > */ @@ -264,7 +266,8 @@ public function getFileMapCache() * array{ * 0: array, * 1: array, - * 2: array}> + * 2: array}>, + * 3: array * } * > $file_maps * diff --git a/tests/LanguageServer/SymbolLookupTest.php b/tests/LanguageServer/SymbolLookupTest.php index 3c853bd4774..7815e66067a 100644 --- a/tests/LanguageServer/SymbolLookupTest.php +++ b/tests/LanguageServer/SymbolLookupTest.php @@ -295,4 +295,36 @@ class A { $this->assertSame('Exception', $symbol_at_position[0]); } + + /** + * @return void + */ + public function testGetSignatureHelp() + { + $codebase = $this->project_analyzer->getCodebase(); + $config = $codebase->config; + $config->throw_exception = false; + + $this->addFile( + 'somefile.php', + 'foo("test", "Hello world!", "foo",); + } + }' + ); + + $codebase->file_provider->openFile('somefile.php'); + $codebase->scanFiles(); + $this->analyzeFile('somefile.php', new Context()); + + $reference_location = $codebase->getFunctionArgumentAtPosition('somefile.php', new Position(5, 35)); + + list($symbol, $argument_number) = $reference_location; + $this->assertSame('B\A::foo', $symbol); + $this->assertSame(0, $argument_number); + } }