From 773c8de41aa031e6731631116727327538497d2b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 16 Feb 2026 22:13:11 +0900 Subject: [PATCH 1/9] Import SanderRonde/phpstan-vscode's TreeFetcher.php https://github.com/SanderRonde/phpstan-vscode/blob/v4.0.12/php/TreeFetcher.php --- php/TreeFetcher.php | 436 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 php/TreeFetcher.php diff --git a/php/TreeFetcher.php b/php/TreeFetcher.php new file mode 100644 index 0000000..b3d78c1 --- /dev/null +++ b/php/TreeFetcher.php @@ -0,0 +1,436 @@ + $target) { + return $mid; // Closest index that is less than or equal to the target + } else { + $left = $mid + 1; + } + } else { + $right = $mid - 1; + } + } + + return -1; // Target not found +} + + /** + * @param array>> $nodeDatas + * @return array> + */ + public static function convertCharIndicesToPositions(array $fileDatas): array { + $results = []; + foreach ($fileDatas as $filePath => $fileData) { + if (count($fileData) === 0) { + continue; + } + $file = file_get_contents($filePath); + $results[$filePath] = []; + + $lineOffsets = [0]; // Initialize with the first line starting at index 0 + for ($i = 0; $i < strlen($file); $i++) { + if ($file[$i] === "\n") { + $lineOffsets[] = $i + 1; // Add 1 to include the newline character + } + } + + // Use binary search to find the line number efficiently + $findPos = static function (int $filePos) use ($lineOffsets) { + $line = self::binarySearch($lineOffsets, $filePos); + $lineStart = $lineOffsets[$line]; + $char = $filePos - $lineStart; + return [ + 'line' => $line, + 'char' => $char + ]; + }; + + foreach ($fileData as $nodeData) { + foreach ($nodeData as $datum) { + $endPos = $findPos($datum['pos']['end']); + $results[$filePath][] = [ + 'typeDescr' => $datum['typeDescr'], + 'name' => $datum['name'], + 'pos' => [ + 'start' => $findPos($datum['pos']['start']), + 'end' => [ + 'line' => $endPos['line'], + 'char' => $endPos['char'] + ] + ] + ]; + } + } + } + + return $results; + } + + /** @param CollectedDataNode $node */ + public function processNode(Node $node, Scope $scope): array { + $collectedData = $node->get(PHPStanVSCodeTreeFetcherCollector::class); + file_put_contents(self::REPORTER_FILE, json_encode(self::convertCharIndicesToPositions($collectedData))); + return []; + } +} + +/** + * @phpstan-type CollectedData array{ + * typeDescr: string, + * name: string, + * pos: array{ + * start: int, + * end: int + * } + * } + * @implements Collector> + */ +class PHPStanVSCodeTreeFetcherCollector implements Collector { + /** @var list> */ + private $closureTypeToNode = []; + + /** + * @return ?list + */ + protected function getClosuresFromScope(Scope $scope): ?array + { + $anonymousFunctionReflection = $scope->getAnonymousFunctionReflection(); + if ($anonymousFunctionReflection) { + foreach ($this->closureTypeToNode as $closureTypeToNode) { + list($closureType, $closureClosures) = $closureTypeToNode; + if ($anonymousFunctionReflection !== $closureType) { + continue; + } + + return $closureClosures; + } + } + return null; + } + + protected function processClosures(Node $node, Scope $scope): void + { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + // We grab the type as well as the node and connect the two so that later + // callers inside this closure can resolve to the node from the type. + $closureType = $scope->getType($node); + $existingClosures = $this->getClosuresFromScope($scope) ?? []; + $existingClosures[] = [ + 'startPos' => $node->getStartFilePos(), + 'endPos' => $node->getEndFilePos() + 1, + 'isUsed' => false, + 'closureNode' => $node + ]; + $this->closureTypeToNode[] = [$closureType, $existingClosures]; + } + } + + /** @var list */ + private $visitedFunctions = []; + + /** + * @return list + */ + private function _processFunction(Scope $scope): array { + $functionKey = implode('.', [ + $scope->getFile(), + $scope->getClassReflection() ? $scope->getClassReflection()->getName() : null, + $scope->getFunctionName() + ]); + if (in_array($functionKey, $this->visitedFunctions, true)) { + return []; + } + $this->visitedFunctions[] = $functionKey; + + $function = $scope->getFunction(); + assert($function !== null); + if (!($function instanceof PhpMethodFromParserNodeReflection)) { + return []; + } + + $reflectionClass = new ReflectionClass(PhpMethodFromParserNodeReflection ::class); + $reflectionMethod = $reflectionClass->getMethod('getFunctionLike'); + $reflectionMethod->setAccessible(true); + $fnLike = $reflectionMethod->invoke($function); + return $this->onFunction($fnLike, $function); + } + + /** + * @param list $closures + * @return list + */ + private function _processClosure(Scope $scope, array $closures): array { + $functionKey = implode('.', [ + $scope->getFile(), + $scope->getClassReflection() ? $scope->getClassReflection()->getName() : null, + json_encode($closures) + ]); + if (in_array($functionKey, $this->visitedMethods, true)) { + return []; + } + $this->visitedMethods[] = $functionKey; + + $lastClosure = end($closures); + /** @var Closure|ArrowFunction */ + $lastClosureNode = $lastClosure['closureNode']; + $fnReflection = $scope->getAnonymousFunctionReflection(); + assert($fnReflection !== null); + return $this->onClosure($lastClosureNode, $fnReflection); + } + + /** + * @return list + */ + public function processFunctionTrackings(Node $node, Scope $scope): array + { + /** @var list */ + $data = []; + $this->processClosures($node, $scope); + if ($scope->getFunctionName()) { + $data = array_merge($data, $this->_processFunction($scope)); + } + + $closures = $this->getClosuresFromScope($scope); + if ($closures) { + $data = array_merge($data, $this->_processClosure($scope, $closures)); + } + return $data; + } + + /** @var list */ + private $visitedMethods = []; + + public function getNodeType(): string + { + return Node::class; + } + + /** + * @return ?CollectedData + */ + private function processNodeWithType($node, Type $type): ?array + { + $varName = $node instanceof Variable ? $node->name : $node->name->name; + $typeDescr = $type->describe(VerbosityLevel::precise()); + if (!is_string($varName)) { + // Not a plain string, can't handle this + return null; + } + + if ($node->getStartFilePos() === -1 || $node->getEndFilePos() === -1) { + return null; + } + + return [ + 'typeDescr' => $typeDescr, + 'name' => $varName, + 'pos' => [ + // Include `$` for variables + 'start' => $node->getStartFilePos() - ($node instanceof Variable ? 1 : 0), + 'end' => $node->getEndFilePos() + 1 + ] + ]; + } + + /** + * @param list $closures + */ + protected function onClosure($node, ParametersAcceptor $type): array { + /** @var array */ + $paramNodesByName = []; + foreach ($node->getParams() as $param) { + $paramNodesByName[$param->var->name] = $param; + } + + /** @var list */ + $data = []; + foreach ($type->getParameters() as $parameter) { + $paramNode = $paramNodesByName[$parameter->getName()] ?? null; + if (!$paramNode) { + continue; + } + + $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); + if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { + // Implicit parameter + continue; + } + + $data[] = [ + 'typeDescr' => $typeDescr, + 'name' => $parameter->getName(), + 'pos' => [ + 'start' => $paramNode->getStartFilePos(), + 'end' => $paramNode->getEndFilePos() + 1 + ] + ]; + } + + return $data; + } + + /** @var list */ + protected function onFunction(FunctionLike $node, PhpMethodFromParserNodeReflection $type): array { + /** @var list $data */ + $data = []; + + /** @var array */ + $paramNodesByName = []; + foreach ($node->getParams() as $param) { + $paramNodesByName[$param->var->name] = $param; + } + + foreach ($type->getVariants() as $variant) { + foreach ($variant->getParameters() as $parameter) { + $paramNode = $paramNodesByName[$parameter->getName()] ?? null; + if (!$paramNode) { + continue; + } + + $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); + + if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { + // Implicit parameter + continue; + } + $data[] = [ + 'typeDescr' => $typeDescr, + 'name' => $parameter->getName(), + 'pos' => [ + 'start' => $paramNode->getStartFilePos(), + 'end' => $paramNode->getEndFilePos() + 1 + ] + ]; + } + } + + return $data; + } + + /** @var list */ + public function processNode(Node $node, Scope $scope): ?array + { + if ($scope->getTraitReflection()) { + return null; + } + /** @var list $data */ + $data = []; + + $data = array_merge($data, $this->processFunctionTrackings($node, $scope)); + + if ($node instanceof InForeachNode) { + $keyVar = $node->getOriginalNode()->keyVar; + $valueVar = $node->getOriginalNode()->valueVar; + $exprType = $scope->getType($node->getOriginalNode()->expr); + if ($exprType instanceof ArrayType) { + if ($keyVar && $keyVar instanceof Variable) { + $nodeWithType = $this->processNodeWithType($keyVar, $exprType->getKeyType()); + if ($nodeWithType) { + $data[] = $nodeWithType; + } + } else if ($valueVar && $valueVar instanceof Variable) { + $nodeWithType = $this->processNodeWithType($valueVar, $exprType->getItemType()); + if ($nodeWithType) { + $data[] = $nodeWithType; + } + } + } + } + + if ($node instanceof Variable || $node instanceof PropertyFetch) { + $type = $scope->getType($node); + $parent = $node->getAttribute('parent'); + if ($parent && $parent instanceof Assign) { + $type = $scope->getType($parent->expr); + } + if (!($type instanceof ErrorType)) { + $nodeWithType = $this->processNodeWithType($node, $type); + if ($nodeWithType) { + $data[] = $nodeWithType; + } + } + } + + if ($data === []) { + return null; + } + return $data; + } +} From 6d77f9a69cbaf42cfb3b671ed133e4b878745738 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 16 Feb 2026 23:34:19 +0900 Subject: [PATCH 2/9] Improve phpstan-hover-tree-fetcher.php --- ...her.php => phpstan-hover-tree-fetcher.php} | 370 +++++++++++++----- 1 file changed, 263 insertions(+), 107 deletions(-) rename php/{TreeFetcher.php => phpstan-hover-tree-fetcher.php} (58%) diff --git a/php/TreeFetcher.php b/php/phpstan-hover-tree-fetcher.php similarity index 58% rename from php/TreeFetcher.php rename to php/phpstan-hover-tree-fetcher.php index b3d78c1..aaac448 100644 --- a/php/TreeFetcher.php +++ b/php/phpstan-hover-tree-fetcher.php @@ -16,10 +16,19 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Expr\YieldFrom; +use PhpParser\Node\Expr\Yield_; use PhpParser\Node\FunctionLike; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Param; +use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\Scope; use PHPStan\Collectors\Collector; use PHPStan\Node\CollectedDataNode; @@ -32,67 +41,51 @@ use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; -class PHPStanVSCodeLogger { - public static function log(...$args) { - foreach ($args as $arg) { - print_r($arg); - print(" "); - } - print("\n"); - } -} - -class PHPStanVSCodeTreeFetcher implements Rule { - // Replaced at runtime with a tmp file - public const REPORTER_FILE = 'reported.json'; +class PHPStanEmacsHoverTreeFetcher implements Rule { + public const REPORTER_FILE = '__PHPSTAN_EMACS_HOVER_REPORT_FILE__'; public function getNodeType(): string { return CollectedDataNode::class; } public static function binarySearch(&$array, $target) { - $left = 0; - $right = count($array) - 1; + $left = 0; + $right = count($array) - 1; - while ($left <= $right) { + while ($left <= $right) { $mid = floor(($left + $right) / 2); if ($array[$mid] <= $target) { if ($mid == count($array) - 1 || $array[$mid + 1] > $target) { - return $mid; // Closest index that is less than or equal to the target - } else { - $left = $mid + 1; + return $mid; } + $left = $mid + 1; } else { $right = $mid - 1; } - } + } - return -1; // Target not found -} + return -1; + } /** * @param array>> $nodeDatas + * }>>> $fileDatas * @return array> */ public static function convertCharIndicesToPositions(array $fileDatas): array { @@ -102,23 +95,25 @@ public static function convertCharIndicesToPositions(array $fileDatas): array { continue; } $file = file_get_contents($filePath); + if (!is_string($file)) { + continue; + } $results[$filePath] = []; - $lineOffsets = [0]; // Initialize with the first line starting at index 0 + $lineOffsets = [0]; for ($i = 0; $i < strlen($file); $i++) { if ($file[$i] === "\n") { - $lineOffsets[] = $i + 1; // Add 1 to include the newline character + $lineOffsets[] = $i + 1; } } - // Use binary search to find the line number efficiently $findPos = static function (int $filePos) use ($lineOffsets) { $line = self::binarySearch($lineOffsets, $filePos); $lineStart = $lineOffsets[$line]; $char = $filePos - $lineStart; return [ 'line' => $line, - 'char' => $char + 'char' => $char, ]; }; @@ -128,13 +123,14 @@ public static function convertCharIndicesToPositions(array $fileDatas): array { $results[$filePath][] = [ 'typeDescr' => $datum['typeDescr'], 'name' => $datum['name'], + 'kind' => $datum['kind'] ?? null, 'pos' => [ 'start' => $findPos($datum['pos']['start']), 'end' => [ 'line' => $endPos['line'], - 'char' => $endPos['char'] - ] - ] + 'char' => $endPos['char'], + ], + ], ]; } } @@ -145,7 +141,7 @@ public static function convertCharIndicesToPositions(array $fileDatas): array { /** @param CollectedDataNode $node */ public function processNode(Node $node, Scope $scope): array { - $collectedData = $node->get(PHPStanVSCodeTreeFetcherCollector::class); + $collectedData = $node->get(PHPStanEmacsHoverTreeFetcherCollector::class); file_put_contents(self::REPORTER_FILE, json_encode(self::convertCharIndicesToPositions($collectedData))); return []; } @@ -155,22 +151,18 @@ public function processNode(Node $node, Scope $scope): array { * @phpstan-type CollectedData array{ * typeDescr: string, * name: string, - * pos: array{ - * start: int, - * end: int - * } + * pos: array{start: int, end: int} * } * @implements Collector> */ -class PHPStanVSCodeTreeFetcherCollector implements Collector { - /** @var list> */ +class PHPStanEmacsHoverTreeFetcherCollector implements Collector { + /** @var list}> */ private $closureTypeToNode = []; /** - * @return ?list + * @return ?list */ - protected function getClosuresFromScope(Scope $scope): ?array - { + protected function getClosuresFromScope(Scope $scope): ?array { $anonymousFunctionReflection = $scope->getAnonymousFunctionReflection(); if ($anonymousFunctionReflection) { foreach ($this->closureTypeToNode as $closureTypeToNode) { @@ -178,25 +170,21 @@ protected function getClosuresFromScope(Scope $scope): ?array if ($anonymousFunctionReflection !== $closureType) { continue; } - return $closureClosures; } } return null; } - protected function processClosures(Node $node, Scope $scope): void - { + protected function processClosures(Node $node, Scope $scope): void { if ($node instanceof Closure || $node instanceof ArrowFunction) { - // We grab the type as well as the node and connect the two so that later - // callers inside this closure can resolve to the node from the type. $closureType = $scope->getType($node); $existingClosures = $this->getClosuresFromScope($scope) ?? []; $existingClosures[] = [ 'startPos' => $node->getStartFilePos(), 'endPos' => $node->getEndFilePos() + 1, 'isUsed' => false, - 'closureNode' => $node + 'closureNode' => $node, ]; $this->closureTypeToNode[] = [$closureType, $existingClosures]; } @@ -205,14 +193,12 @@ protected function processClosures(Node $node, Scope $scope): void /** @var list */ private $visitedFunctions = []; - /** - * @return list - */ - private function _processFunction(Scope $scope): array { + /** @return list */ + private function processFunctionScope(Scope $scope): array { $functionKey = implode('.', [ $scope->getFile(), $scope->getClassReflection() ? $scope->getClassReflection()->getName() : null, - $scope->getFunctionName() + $scope->getFunctionName(), ]); if (in_array($functionKey, $this->visitedFunctions, true)) { return []; @@ -225,72 +211,66 @@ private function _processFunction(Scope $scope): array { return []; } - $reflectionClass = new ReflectionClass(PhpMethodFromParserNodeReflection ::class); + $reflectionClass = new ReflectionClass(PhpMethodFromParserNodeReflection::class); $reflectionMethod = $reflectionClass->getMethod('getFunctionLike'); $reflectionMethod->setAccessible(true); $fnLike = $reflectionMethod->invoke($function); return $this->onFunction($fnLike, $function); } + /** @var list */ + private $visitedClosures = []; + /** - * @param list $closures + * @param list $closures * @return list */ - private function _processClosure(Scope $scope, array $closures): array { + private function processClosureScope(Scope $scope, array $closures): array { $functionKey = implode('.', [ $scope->getFile(), $scope->getClassReflection() ? $scope->getClassReflection()->getName() : null, - json_encode($closures) + json_encode($closures), ]); - if (in_array($functionKey, $this->visitedMethods, true)) { + if (in_array($functionKey, $this->visitedClosures, true)) { return []; } - $this->visitedMethods[] = $functionKey; + $this->visitedClosures[] = $functionKey; $lastClosure = end($closures); - /** @var Closure|ArrowFunction */ + /** @var Closure|ArrowFunction $lastClosureNode */ $lastClosureNode = $lastClosure['closureNode']; $fnReflection = $scope->getAnonymousFunctionReflection(); assert($fnReflection !== null); return $this->onClosure($lastClosureNode, $fnReflection); } - /** - * @return list - */ - public function processFunctionTrackings(Node $node, Scope $scope): array - { - /** @var list */ + /** @return list */ + public function processFunctionTrackings(Node $node, Scope $scope): array { + /** @var list $data */ $data = []; $this->processClosures($node, $scope); + if ($scope->getFunctionName()) { - $data = array_merge($data, $this->_processFunction($scope)); + $data = array_merge($data, $this->processFunctionScope($scope)); } $closures = $this->getClosuresFromScope($scope); if ($closures) { - $data = array_merge($data, $this->_processClosure($scope, $closures)); + $data = array_merge($data, $this->processClosureScope($scope, $closures)); } + return $data; } - /** @var list */ - private $visitedMethods = []; - - public function getNodeType(): string - { + public function getNodeType(): string { return Node::class; } - /** - * @return ?CollectedData - */ - private function processNodeWithType($node, Type $type): ?array - { + /** @return ?CollectedData */ + private function processNodeWithType($node, Type $type): ?array { $varName = $node instanceof Variable ? $node->name : $node->name->name; $typeDescr = $type->describe(VerbosityLevel::precise()); if (!is_string($varName)) { - // Not a plain string, can't handle this return null; } @@ -301,17 +281,146 @@ private function processNodeWithType($node, Type $type): ?array return [ 'typeDescr' => $typeDescr, 'name' => $varName, + 'kind' => 'variable', 'pos' => [ - // Include `$` for variables 'start' => $node->getStartFilePos() - ($node instanceof Variable ? 1 : 0), - 'end' => $node->getEndFilePos() + 1 - ] + 'end' => $node->getEndFilePos() + 1, + ], ]; } - /** - * @param list $closures - */ + /** @return ?CollectedData */ + private function processReturnNode(Return_ $node, Scope $scope): ?array { + $type = null; + if ($node->expr !== null) { + $type = $scope->getType($node->expr); + } else { + $function = $scope->getFunction(); + if ($function) { + $type = $function->getReturnType(); + } + } + + if (!$type || $type instanceof ErrorType) { + return null; + } + if ($node->getStartFilePos() === -1) { + return null; + } + + $start = $node->getStartFilePos(); + $end = $start + 6; // "return" + if ($node->getEndFilePos() !== -1) { + $end = min($end, $node->getEndFilePos() + 1); + } + + return [ + 'typeDescr' => $type->describe(VerbosityLevel::precise()), + 'name' => 'return', + 'kind' => 'return', + 'pos' => [ + 'start' => $start, + 'end' => $end, + ], + ]; + } + + /** @return ?CollectedData */ + private function processYieldNode(Yield_ $node, Scope $scope): ?array { + $type = null; + if ($node->value !== null) { + $type = $scope->getType($node->value); + } + if (!$type) { + $type = $scope->getType($node); + } + if (!$type || $type instanceof ErrorType) { + return null; + } + if ($node->getStartFilePos() === -1) { + return null; + } + + $start = $node->getStartFilePos(); + $end = $start + 5; // "yield" + if ($node->getEndFilePos() !== -1) { + $end = min($end, $node->getEndFilePos() + 1); + } + + return [ + 'typeDescr' => $type->describe(VerbosityLevel::precise()), + 'name' => 'yield', + 'kind' => 'yield', + 'pos' => [ + 'start' => $start, + 'end' => $end, + ], + ]; + } + + /** @return ?CollectedData */ + private function processYieldFromNode(YieldFrom $node, Scope $scope): ?array { + $type = $scope->getType($node->expr); + if (!$type || $type instanceof ErrorType) { + return null; + } + if ($node->getStartFilePos() === -1) { + return null; + } + + $start = $node->getStartFilePos(); + $end = $start + 10; // "yield from" + if ($node->getEndFilePos() !== -1) { + $end = min($end, $node->getEndFilePos() + 1); + } + + return [ + 'typeDescr' => $type->describe(VerbosityLevel::precise()), + 'name' => 'yield from', + 'kind' => 'yield-from', + 'pos' => [ + 'start' => $start, + 'end' => $end, + ], + ]; + } + + /** @return ?CollectedData */ + private function processCallNode(Node $nameNode, Type $type, string $name): ?array { + if ($type instanceof ErrorType) { + return null; + } + if ($nameNode->getStartFilePos() === -1 || $nameNode->getEndFilePos() === -1) { + return null; + } + + return [ + 'typeDescr' => $type->describe(VerbosityLevel::precise()), + 'name' => $name, + 'kind' => 'call', + 'pos' => [ + 'start' => $nameNode->getStartFilePos(), + 'end' => $nameNode->getEndFilePos() + 1, + ], + ]; + } + + /** @return ?CollectedData */ + private function processAssignNode(Assign $node, Scope $scope): ?array { + $assigned = $node->var; + if (!($assigned instanceof Variable || $assigned instanceof PropertyFetch)) { + return null; + } + + $type = $scope->getType($node->expr); + if ($type instanceof ErrorType) { + return null; + } + + return $this->processNodeWithType($assigned, $type); + } + + /** @return list */ protected function onClosure($node, ParametersAcceptor $type): array { /** @var array */ $paramNodesByName = []; @@ -329,7 +438,6 @@ protected function onClosure($node, ParametersAcceptor $type): array { $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { - // Implicit parameter continue; } @@ -338,15 +446,15 @@ protected function onClosure($node, ParametersAcceptor $type): array { 'name' => $parameter->getName(), 'pos' => [ 'start' => $paramNode->getStartFilePos(), - 'end' => $paramNode->getEndFilePos() + 1 - ] + 'end' => $paramNode->getEndFilePos() + 1, + ], ]; } return $data; } - /** @var list */ + /** @return list */ protected function onFunction(FunctionLike $node, PhpMethodFromParserNodeReflection $type): array { /** @var list $data */ $data = []; @@ -365,18 +473,17 @@ protected function onFunction(FunctionLike $node, PhpMethodFromParserNodeReflect } $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); - if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { - // Implicit parameter continue; } + $data[] = [ 'typeDescr' => $typeDescr, 'name' => $parameter->getName(), 'pos' => [ 'start' => $paramNode->getStartFilePos(), - 'end' => $paramNode->getEndFilePos() + 1 - ] + 'end' => $paramNode->getEndFilePos() + 1, + ], ]; } } @@ -384,15 +491,14 @@ protected function onFunction(FunctionLike $node, PhpMethodFromParserNodeReflect return $data; } - /** @var list */ - public function processNode(Node $node, Scope $scope): ?array - { + /** @return ?list */ + public function processNode(Node $node, Scope $scope): ?array { if ($scope->getTraitReflection()) { return null; } + /** @var list $data */ $data = []; - $data = array_merge($data, $this->processFunctionTrackings($node, $scope)); if ($node instanceof InForeachNode) { @@ -405,7 +511,8 @@ public function processNode(Node $node, Scope $scope): ?array if ($nodeWithType) { $data[] = $nodeWithType; } - } else if ($valueVar && $valueVar instanceof Variable) { + } + if ($valueVar && $valueVar instanceof Variable) { $nodeWithType = $this->processNodeWithType($valueVar, $exprType->getItemType()); if ($nodeWithType) { $data[] = $nodeWithType; @@ -414,6 +521,34 @@ public function processNode(Node $node, Scope $scope): ?array } } + if ($node instanceof Assign) { + $assignNodeData = $this->processAssignNode($node, $scope); + if ($assignNodeData) { + $data[] = $assignNodeData; + } + } + + if ($node instanceof FuncCall && $node->name instanceof Name) { + $callNodeData = $this->processCallNode($node->name, $scope->getType($node), $node->name->toString()); + if ($callNodeData) { + $data[] = $callNodeData; + } + } + + if ($node instanceof MethodCall && $node->name instanceof Identifier) { + $callNodeData = $this->processCallNode($node->name, $scope->getType($node), $node->name->toString()); + if ($callNodeData) { + $data[] = $callNodeData; + } + } + + if ($node instanceof StaticCall && $node->name instanceof Identifier) { + $callNodeData = $this->processCallNode($node->name, $scope->getType($node), $node->name->toString()); + if ($callNodeData) { + $data[] = $callNodeData; + } + } + if ($node instanceof Variable || $node instanceof PropertyFetch) { $type = $scope->getType($node); $parent = $node->getAttribute('parent'); @@ -428,6 +563,27 @@ public function processNode(Node $node, Scope $scope): ?array } } + if ($node instanceof Return_) { + $returnNodeData = $this->processReturnNode($node, $scope); + if ($returnNodeData) { + $data[] = $returnNodeData; + } + } + + if ($node instanceof Yield_) { + $yieldNodeData = $this->processYieldNode($node, $scope); + if ($yieldNodeData) { + $data[] = $yieldNodeData; + } + } + + if ($node instanceof YieldFrom) { + $yieldFromNodeData = $this->processYieldFromNode($node, $scope); + if ($yieldFromNodeData) { + $data[] = $yieldFromNodeData; + } + } + if ($data === []) { return null; } From e8db3186709e088ea12d08fa2a2c04631e47e949 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 17 Feb 2026 20:12:27 +0900 Subject: [PATCH 3/9] phpstan-hover-tree-fetcher supports ConstFetch --- php/phpstan-hover-tree-fetcher.php | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/php/phpstan-hover-tree-fetcher.php b/php/phpstan-hover-tree-fetcher.php index aaac448..8d8175c 100644 --- a/php/phpstan-hover-tree-fetcher.php +++ b/php/phpstan-hover-tree-fetcher.php @@ -16,7 +16,9 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; @@ -420,6 +422,54 @@ private function processAssignNode(Assign $node, Scope $scope): ?array { return $this->processNodeWithType($assigned, $type); } + /** @return ?CollectedData */ + private function processConstFetchNode(ConstFetch $node, Scope $scope): ?array { + $type = $scope->getType($node); + if ($type instanceof ErrorType) { + return null; + } + if ($node->name->getStartFilePos() === -1 || $node->name->getEndFilePos() === -1) { + return null; + } + + return array_merge($this->describeTypes($type), [ + 'name' => $node->name->toString(), + 'kind' => 'const', + 'pos' => [ + 'start' => $node->name->getStartFilePos(), + 'end' => $node->name->getEndFilePos() + 1, + ], + ]); + } + + /** @return ?CollectedData */ + private function processClassConstFetchNode(ClassConstFetch $node, Scope $scope): ?array { + if (!($node->name instanceof Identifier)) { + return null; + } + + $type = $scope->getType($node); + if ($type instanceof ErrorType) { + return null; + } + if ($node->name->getStartFilePos() === -1 || $node->name->getEndFilePos() === -1) { + return null; + } + + $className = ($node->class instanceof Name) ? $node->class->toString() : null; + $constName = $node->name->toString(); + $displayName = $className ? ($className . '::' . $constName) : $constName; + + return array_merge($this->describeTypes($type), [ + 'name' => $displayName, + 'kind' => 'class-const', + 'pos' => [ + 'start' => $node->name->getStartFilePos(), + 'end' => $node->name->getEndFilePos() + 1, + ], + ]); + } + /** @return list */ protected function onClosure($node, ParametersAcceptor $type): array { /** @var array */ @@ -528,6 +578,20 @@ public function processNode(Node $node, Scope $scope): ?array { } } + if ($node instanceof ConstFetch) { + $constNodeData = $this->processConstFetchNode($node, $scope); + if ($constNodeData) { + $data[] = $constNodeData; + } + } + + if ($node instanceof ClassConstFetch) { + $classConstNodeData = $this->processClassConstFetchNode($node, $scope); + if ($classConstNodeData) { + $data[] = $classConstNodeData; + } + } + if ($node instanceof FuncCall && $node->name instanceof Name) { $callNodeData = $this->processCallNode($node->name, $scope->getType($node), $node->name->toString()); if ($callNodeData) { From b84ca8ab47d1910b7c5c7a0f95dd7d195aa23f7b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 17 Feb 2026 20:18:41 +0900 Subject: [PATCH 4/9] phpstan-hover-tree-fetcher supports PHPDocType --- php/phpstan-hover-tree-fetcher.php | 78 ++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/php/phpstan-hover-tree-fetcher.php b/php/phpstan-hover-tree-fetcher.php index 8d8175c..709839d 100644 --- a/php/phpstan-hover-tree-fetcher.php +++ b/php/phpstan-hover-tree-fetcher.php @@ -35,6 +35,7 @@ use PHPStan\Collectors\Collector; use PHPStan\Node\CollectedDataNode; use PHPStan\Node\InForeachNode; +use PHPStan\PhpDocParser\Printer\Printer; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Rules\Rule; @@ -73,6 +74,7 @@ public static function binarySearch(&$array, $target) { /** * @param array>> $fileDatas * @return array $datum['typeDescr'], 'name' => $datum['name'], 'kind' => $datum['kind'] ?? null, @@ -134,6 +137,10 @@ public static function convertCharIndicesToPositions(array $fileDatas): array { ], ], ]; + if (isset($datum['phpDocType']) && is_string($datum['phpDocType'])) { + $entry['phpDocType'] = $datum['phpDocType']; + } + $results[$filePath][] = $entry; } } } @@ -158,6 +165,35 @@ public function processNode(Node $node, Scope $scope): array { * @implements Collector> */ class PHPStanEmacsHoverTreeFetcherCollector implements Collector { + /** @var ?Printer */ + private $phpDocPrinter = null; + + /** @return array{typeDescr: string, phpDocType?: string} */ + private function describeTypes(Type $type): array { + $typeDescr = $type->describe(VerbosityLevel::precise()); + $result = ['typeDescr' => $typeDescr]; + + $phpDocType = null; + try { + $phpDocType = $this->getPhpDocPrinter()->print($type->toPhpDocNode()); + } catch (Throwable $e) { + $phpDocType = null; + } + + if ($phpDocType !== null && $phpDocType !== '' && $phpDocType !== $typeDescr) { + $result['phpDocType'] = $phpDocType; + } + + return $result; + } + + private function getPhpDocPrinter(): Printer { + if ($this->phpDocPrinter === null) { + $this->phpDocPrinter = new Printer(); + } + return $this->phpDocPrinter; + } + /** @var list}> */ private $closureTypeToNode = []; @@ -271,7 +307,6 @@ public function getNodeType(): string { /** @return ?CollectedData */ private function processNodeWithType($node, Type $type): ?array { $varName = $node instanceof Variable ? $node->name : $node->name->name; - $typeDescr = $type->describe(VerbosityLevel::precise()); if (!is_string($varName)) { return null; } @@ -280,15 +315,14 @@ private function processNodeWithType($node, Type $type): ?array { return null; } - return [ - 'typeDescr' => $typeDescr, + return array_merge($this->describeTypes($type), [ 'name' => $varName, 'kind' => 'variable', 'pos' => [ 'start' => $node->getStartFilePos() - ($node instanceof Variable ? 1 : 0), 'end' => $node->getEndFilePos() + 1, ], - ]; + ]); } /** @return ?CollectedData */ @@ -316,15 +350,14 @@ private function processReturnNode(Return_ $node, Scope $scope): ?array { $end = min($end, $node->getEndFilePos() + 1); } - return [ - 'typeDescr' => $type->describe(VerbosityLevel::precise()), + return array_merge($this->describeTypes($type), [ 'name' => 'return', 'kind' => 'return', 'pos' => [ 'start' => $start, 'end' => $end, ], - ]; + ]); } /** @return ?CollectedData */ @@ -349,15 +382,14 @@ private function processYieldNode(Yield_ $node, Scope $scope): ?array { $end = min($end, $node->getEndFilePos() + 1); } - return [ - 'typeDescr' => $type->describe(VerbosityLevel::precise()), + return array_merge($this->describeTypes($type), [ 'name' => 'yield', 'kind' => 'yield', 'pos' => [ 'start' => $start, 'end' => $end, ], - ]; + ]); } /** @return ?CollectedData */ @@ -376,15 +408,14 @@ private function processYieldFromNode(YieldFrom $node, Scope $scope): ?array { $end = min($end, $node->getEndFilePos() + 1); } - return [ - 'typeDescr' => $type->describe(VerbosityLevel::precise()), + return array_merge($this->describeTypes($type), [ 'name' => 'yield from', 'kind' => 'yield-from', 'pos' => [ 'start' => $start, 'end' => $end, ], - ]; + ]); } /** @return ?CollectedData */ @@ -396,15 +427,14 @@ private function processCallNode(Node $nameNode, Type $type, string $name): ?arr return null; } - return [ - 'typeDescr' => $type->describe(VerbosityLevel::precise()), + return array_merge($this->describeTypes($type), [ 'name' => $name, 'kind' => 'call', 'pos' => [ 'start' => $nameNode->getStartFilePos(), 'end' => $nameNode->getEndFilePos() + 1, ], - ]; + ]); } /** @return ?CollectedData */ @@ -486,19 +516,18 @@ protected function onClosure($node, ParametersAcceptor $type): array { continue; } - $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); + $typeData = $this->describeTypes($parameter->getType()); if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { continue; } - $data[] = [ - 'typeDescr' => $typeDescr, + $data[] = array_merge($typeData, [ 'name' => $parameter->getName(), 'pos' => [ 'start' => $paramNode->getStartFilePos(), 'end' => $paramNode->getEndFilePos() + 1, ], - ]; + ]); } return $data; @@ -522,19 +551,18 @@ protected function onFunction(FunctionLike $node, PhpMethodFromParserNodeReflect continue; } - $typeDescr = $parameter->getType()->describe(VerbosityLevel::precise()); + $typeData = $this->describeTypes($parameter->getType()); if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) { continue; } - $data[] = [ - 'typeDescr' => $typeDescr, + $data[] = array_merge($typeData, [ 'name' => $parameter->getName(), 'pos' => [ 'start' => $paramNode->getStartFilePos(), 'end' => $paramNode->getEndFilePos() + 1, ], - ]; + ]); } } From 884790b00a8edbfd1183417e584059a21d5fca73 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 17 Feb 2026 20:52:53 +0900 Subject: [PATCH 5/9] Add phpstan-buffer-not-modified-p function --- phpstan.el | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/phpstan.el b/phpstan.el index 680561d..573dd83 100644 --- a/phpstan.el +++ b/phpstan.el @@ -486,6 +486,10 @@ it returns the value of `SOURCE' as it is." ((executable-find "phpstan") (list (executable-find "phpstan"))) (t (error "PHPStan executable not found"))))))) +(defun phpstan-buffer-not-modified-p (original) + "Return non-NIL if ORIGINAL is non-NIL and buffer is not modified." + (and original (not (buffer-modified-p)))) + (cl-defun phpstan-get-command-args (&key include-executable use-pro args format options config verbose editor) "Return command line argument for PHPStan." (let ((executable-and-args (phpstan-get-executable-and-args)) From 4573c6142eabb7d2a1b1f1ecd01ce10bb56c331a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 17 Feb 2026 22:21:15 +0900 Subject: [PATCH 6/9] Add phpstan-hover.el --- phpstan-hover.el | 460 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 phpstan-hover.el diff --git a/phpstan-hover.el b/phpstan-hover.el new file mode 100644 index 0000000..09f1ebd --- /dev/null +++ b/phpstan-hover.el @@ -0,0 +1,460 @@ +;;; phpstan-hover.el --- Hover type display for PHPStan -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Friends of Emacs-PHP development + +;; Author: USAMI Kenta +;; Created: 16 Feb 2026 +;; Keywords: tools, php +;; Homepage: https://github.com/emacs-php/phpstan.el +;; Package-Requires: ((emacs "26.1") (phpstan "0.9.0")) +;; License: GPL-3.0-or-later + +;;; Commentary: + +;; Show PHPStan inferred type at point without using Flycheck/Flymake diagnostics. + +;;; Code: + +(require 'cl-lib) +(require 'json) +(require 'php-project) +(require 'phpstan) +(eval-when-compile + (require 'compat nil t) + (require 'subr-x) + (require 'pcase)) + +(declare-function posframe-hide "ext:posframe" (buffer-or-name)) +(declare-function posframe-show "ext:posframe") +(declare-function popup-tip "ext:popup") + +(defgroup phpstan-hover nil + "Hover type display for PHPStan." + :group 'phpstan) + +(defcustom phpstan-hover-idle-delay 0.8 + "Seconds to wait before requesting PHPStan hover data." + :type 'number) + +(defcustom phpstan-hover-display-backend 'auto + "How hover messages are displayed. + +`auto' tries `posframe-show' first (GUI only), then `popup-tip', and finally +`message'." + :type '(choice (const :tag "Auto" auto) + (const :tag "Posframe" posframe) + (const :tag "Popup" popup) + (const :tag "Message" message))) + +(defcustom phpstan-hover-message-prefix "PHPStan: " + "Prefix for hover messages." + :type '(choice + (string :tag "Custom Label") + (const :tag "Bookmark Emoji" "🔖 ") + (const :tag "PHPStan prefix" "PHPStan: "))) + +(defcustom phpstan-hover-show-kind-label t + "When non-nil, show kind labels like return/yield in hover text." + :type 'boolean) + +(defcustom phpstan-hover-debug nil + "When non-nil, re-signal internal errors to show full backtraces." + :type 'boolean) + +(defcustom phpstan-hover-state-directory + (expand-file-name "phpstan-hover" temporary-file-directory) + "Directory for generated helper files and reports." + :type 'directory) + +(defvar-local phpstan-hover--idle-timer nil) +(defvar-local phpstan-hover--process nil) +(defvar-local phpstan-hover--process-buffer nil) +(defvar-local phpstan-hover--cleanup-files nil) +(defvar-local phpstan-hover--report nil) +(defvar-local phpstan-hover--report-tick -1) +(defvar-local phpstan-hover--last-shown nil) +(defvar-local phpstan-hover--last-point nil) +(defvar-local phpstan-hover--last-command-point nil) +(defvar-local phpstan-hover--last-command-window nil) +(defvar-local phpstan-hover--last-display-state nil) +(defvar-local phpstan-hover--config nil) +(defvar-local phpstan-hover--last-command nil) +(defvar phpstan-hover-mode nil) + +(defun phpstan-hover--buffer-file () + "Return current local buffer file path or nil." + (when buffer-file-name + (phpstan--expand-file-name buffer-file-name))) + +(defun phpstan-hover--state-dir () + "Return state directory for current project." + (let ((root (or (php-project-get-root-dir) default-directory))) + (expand-file-name (md5 (expand-file-name root)) + (file-name-as-directory phpstan-hover-state-directory)))) + +(defun phpstan-hover--tree-fetcher-template-file () + "Return template path of TreeFetcher script." + (let* ((base-file (or load-file-name + (symbol-file 'phpstan-hover-mode 'defun) + buffer-file-name)) + (library-dir (and base-file (file-name-directory base-file))) + (template-file (expand-file-name "php/phpstan-hover-tree-fetcher.php" library-dir))) + (unless library-dir + (error "PHPStan hover: cannot resolve library dir (load-file-name=%S buffer-file-name=%S)" + load-file-name buffer-file-name)) + (unless (file-readable-p template-file) + (user-error "Template file not found: %s" template-file)) + template-file)) + +(defun phpstan-hover--write-file (path content) + "Write CONTENT to PATH in UTF-8." + (make-directory (file-name-directory path) t) + (let ((coding-system-for-write 'utf-8-unix)) + (with-temp-file path + (insert content)))) + +(defsubst phpstan-hover--quote-single (s) + "Return S with escaped single quotes for PHP/NEON literals." + (replace-regexp-in-string "'" "\\\\'" s t t)) + +(defun phpstan-hover--build-config (config-file cache-dir) + "Build PHPStan config text using CONFIG-FILE and CACHE-DIR." + (concat + (if config-file + (concat "includes:\n" + (format " - '%s'\n\n" (phpstan-hover--quote-single config-file))) + "") + "\n" + "rules:\n" + " - PHPStanEmacsHoverTreeFetcher\n" + "\n" + "parameters:\n" + (format " tmpDir: '%s'\n" (phpstan-hover--quote-single cache-dir)) + "\n" + "services:\n" + " -\n" + " class: PHPStanEmacsHoverTreeFetcherCollector\n" + " tags:\n" + " - phpstan.collector\n")) + +(defun phpstan-hover--ensure-runtime-files () + "Prepare helper files and return runtime plist." + (let* ((dir (phpstan-hover--state-dir)) + (cache-dir (expand-file-name "cache" dir)) + (report-file (expand-file-name "reported.json" dir)) + (tree-fetcher-file (expand-file-name "TreeFetcher.php" dir)) + (autoload-file (expand-file-name "autoload.php" dir)) + (config-file (expand-file-name "config.neon" dir)) + (user-config (phpstan-get-config-file)) + (user-autoload (phpstan-get-autoload-file)) + (template (with-temp-buffer + (insert-file-contents (phpstan-hover--tree-fetcher-template-file)) + (buffer-string))) + (tree-fetcher-content + (replace-regexp-in-string + "__PHPSTAN_EMACS_HOVER_REPORT_FILE__" + (replace-regexp-in-string "\\\\" "\\\\\\\\" report-file t t) + template t t)) + (autoload-content + (concat + " line start-line) + (and (= line start-line) (>= col start-char))) + (or (< line end-line) + (and (= line end-line) (< col end-char)))))) + file-data))) + +;;;###autoload +(defun phpstan-hover-type-at-point (&optional prefer-phpdoc) + "Return hover type string at point. + +If PREFER-PHPDOC is non-nil, return PHPDoc type when available." + (when-let ((datum (phpstan-hover--datum-at-point))) + (let ((type (plist-get datum :typeDescr)) + (phpdoc-type (plist-get datum :phpDocType))) + (if (and prefer-phpdoc + (stringp phpdoc-type) + (> (length phpdoc-type) 0)) + phpdoc-type + type)))) + +(defun phpstan-hover--format-message (datum) + "Return display string from DATUM." + (let* ((type (plist-get datum :typeDescr)) + (phpdoc-type (plist-get datum :phpDocType)) + (name (plist-get datum :name)) + (kind (plist-get datum :kind)) + (body (if (not phpstan-hover-show-kind-label) + type + (pcase kind + ((or "return" (guard (equal name "return"))) + (format "return: %s" type)) + ("yield" + (format "yield: %s" type)) + ("yield-from" + (format "yield from: %s" type)) + ((or "const" "class-const") + (format "%s: %s" name type)) + ("call" + (format "%s(): %s" name type)) + (_ + (format "$%s: %s" name type)))))) + (if (and (stringp phpdoc-type) (> (length phpdoc-type) 0)) + (format "%s%s [PHPDoc: %s]" phpstan-hover-message-prefix body phpdoc-type) + (concat phpstan-hover-message-prefix body)))) + +(defun phpstan-hover--resolve-backend () + "Resolve display backend. + +This honors `phpstan-hover-display-backend'." + (pcase phpstan-hover-display-backend + ('auto (cond + ((and (display-graphic-p) (fboundp 'posframe-show)) 'posframe) + ((fboundp 'popup-tip) 'popup) + (t 'message))) + (_ phpstan-hover-display-backend))) + +(defsubst phpstan-hover--posframe-buffer-name () + "Return buffer name used by phpstan-hover posframe." + (format " *phpstan-hover-%s*" (buffer-name))) + +(defun phpstan-hover--display-state () + "Return current state used to determine if posframe should stay visible." + (list (current-buffer) (buffer-modified-tick) (point) (selected-window))) + +(defun phpstan-hover--check-display-state () + "Update display state and return non-nil when unchanged." + (let* ((current-state (phpstan-hover--display-state))) + (prog1 (equal phpstan-hover--last-display-state current-state) + (setq phpstan-hover--last-display-state current-state)))) + +(defun phpstan-hover--posframe-hidehandler (_info) + "Hide posframe when point/window/buffer state has changed." + (not (phpstan-hover--check-display-state))) + +(defun phpstan-hover--hide () + "Hide existing popup if possible." + (when (and (fboundp 'posframe-hide) (buffer-live-p (current-buffer))) + (posframe-hide (phpstan-hover--posframe-buffer-name)))) + +(defun phpstan-hover--show (text) + "Show hover TEXT." + (unless (equal text phpstan-hover--last-shown) + (setq phpstan-hover--last-shown text) + (pcase (phpstan-hover--resolve-backend) + ('posframe + (phpstan-hover--check-display-state) + (posframe-show (phpstan-hover--posframe-buffer-name) + :string text + :position (point) + :accept-focus nil + :internal-border-width 2 + :internal-border-color "gray50" + :hidehandler #'phpstan-hover--posframe-hidehandler)) + ('popup + (popup-tip text)) + (_ + (message "%s" text))))) + +(defun phpstan-hover--show-at-point () + "Show hover text for current point if available." + (if-let ((datum (phpstan-hover--datum-at-point))) + (phpstan-hover--show (phpstan-hover--format-message datum)) + (setq phpstan-hover--last-shown nil) + (phpstan-hover--hide))) + +(defun phpstan-hover--process-live-p () + "Return non-nil if hover process is alive." + (and phpstan-hover--process + (process-live-p phpstan-hover--process))) + +(defun phpstan-hover--kill-process () + "Stop running hover process and cleanup process buffer." + (when (phpstan-hover--process-live-p) + (kill-process phpstan-hover--process)) + (when (buffer-live-p phpstan-hover--process-buffer) + (kill-buffer phpstan-hover--process-buffer)) + (setq phpstan-hover--process nil) + (setq phpstan-hover--process-buffer nil) + (phpstan-hover--cleanup-temp-files)) + +(defun phpstan-hover--start-analysis () + "Start PHPStan process for hover report." + (when (and phpstan-hover-mode + (phpstan-enabled) + (phpstan-hover--buffer-file) + (not (phpstan-hover--process-live-p))) + (let* ((runtime (phpstan-hover--ensure-runtime-files)) + (tick (buffer-chars-modified-tick)) + (original-file (phpstan-hover--buffer-file)) + (command (thread-last + (phpstan-get-command-args + :include-executable t + :format "json" + :config (plist-get runtime :config-file) + :options (list "-a" (plist-get runtime :autoload-file)) + :editor (list + :analyze-original #'phpstan-buffer-not-modified-p + :original-file original-file + :temp-file #'phpstan-hover--create-temp-file + :inplace #'phpstan-hover--create-temp-file)) + (delq nil)))) + (setq phpstan-hover--process-buffer (generate-new-buffer " *phpstan-hover-process*")) + (setq phpstan-hover--last-command command) + (let ((source (current-buffer)) + (report-file (plist-get runtime :report-file))) + (setq phpstan-hover--process + (make-process + :name "phpstan-hover" + :noquery t + :command command + :buffer phpstan-hover--process-buffer + :sentinel + (lambda (proc _event) + (when (memq (process-status proc) '(exit signal)) + (when (buffer-live-p source) + (with-current-buffer source + (setq phpstan-hover--process nil) + (setq phpstan-hover--report + (or (phpstan-hover--parse-report report-file) + phpstan-hover--report)) + (setq phpstan-hover--report-tick tick) + (phpstan-hover--cleanup-temp-files) + (when (equal phpstan-hover--last-point (point)) + (phpstan-hover--show-at-point)))) + (when (buffer-live-p (process-buffer proc)) + (kill-buffer (process-buffer proc))))))))))) + +(defun phpstan-hover--idle-run (buffer) + "Idle callback for BUFFER." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq phpstan-hover--idle-timer nil) + (when (and phpstan-hover-mode + (phpstan-enabled) + (phpstan-hover--buffer-file)) + (setq phpstan-hover--last-point (point)) + (phpstan-hover--show-at-point) + (when (< phpstan-hover--report-tick (buffer-chars-modified-tick)) + (phpstan-hover--start-analysis)))))) + +(defun phpstan-hover--schedule () + "Schedule hover lookup by idle timer." + (when phpstan-hover-mode + (let ((point-changed (not (equal phpstan-hover--last-command-point (point)))) + (window-changed (not (eq phpstan-hover--last-command-window (selected-window))))) + (when (or point-changed window-changed) + (setq phpstan-hover--last-shown nil) + (phpstan-hover--hide)) + (setq phpstan-hover--last-command-point (point)) + (setq phpstan-hover--last-command-window (selected-window))) + (when (timerp phpstan-hover--idle-timer) + (cancel-timer phpstan-hover--idle-timer)) + (setq phpstan-hover--idle-timer + (run-with-idle-timer phpstan-hover-idle-delay nil + #'phpstan-hover--idle-run + (current-buffer))))) + +(defun phpstan-hover--teardown () + "Cleanup local resources for `phpstan-hover-mode'." + (when (timerp phpstan-hover--idle-timer) + (cancel-timer phpstan-hover--idle-timer)) + (setq phpstan-hover--idle-timer nil) + (setq phpstan-hover--last-display-state nil) + (setq phpstan-hover--last-command-point nil) + (setq phpstan-hover--last-command-window nil) + (phpstan-hover--hide) + (phpstan-hover--kill-process)) + +;;;###autoload +(define-minor-mode phpstan-hover-mode + "Toggle hover type display using PHPStan editor mode reports." + :init-value nil + :lighter " PHover" + :group 'phpstan-hover + (if phpstan-hover-mode + (progn + (add-hook 'post-command-hook #'phpstan-hover--schedule nil t) + (add-hook 'kill-buffer-hook #'phpstan-hover--teardown nil t) + (add-hook 'after-save-hook #'phpstan-hover--schedule nil t)) + (remove-hook 'post-command-hook #'phpstan-hover--schedule t) + (remove-hook 'kill-buffer-hook #'phpstan-hover--teardown t) + (remove-hook 'after-save-hook #'phpstan-hover--schedule t) + (phpstan-hover--teardown))) + +(provide 'phpstan-hover) +;;; phpstan-hover.el ends here From 83f9cbd63ed686543fd2f8dd944e766108ce432f Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 17 Feb 2026 22:36:05 +0900 Subject: [PATCH 7/9] Make phpstan-copy-dumped-type supports hover type data --- phpstan.el | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/phpstan.el b/phpstan.el index 573dd83..7481b71 100644 --- a/phpstan.el +++ b/phpstan.el @@ -178,6 +178,8 @@ have unexpected behaviors or performance implications." (defvar phpstan-executable-versions-alist '()) +(declare-function phpstan-hover-type-at-point "phpstan-hover" (&optional prefer-phpdoc)) + ;;;###autoload (progn (defvar-local phpstan-working-dir nil @@ -655,17 +657,27 @@ POSITION determines where to insert the comment and can be either `this-line' or (string-join identifiers ", "))))))) ;;;###autoload -(defun phpstan-copy-dumped-type () - "Copy a dumped PHPStan type." - (interactive) - (if phpstan--dumped-types - (let ((type (if (eq 1 (length phpstan--dumped-types)) - (cdar phpstan--dumped-types) - (let ((linum (line-number-at-pos))) - (cdar (seq-sort-by (lambda (elm) (abs (- linum (car elm)))) #'< phpstan--dumped-types)))))) - (kill-new type) - (message "Copied %s" type)) - (user-error "No dumped PHPStan types"))) +(defun phpstan-copy-dumped-type (&optional raw-prefix) + "Copy a dumped PHPStan type. + +When called without RAW-PREFIX, prefer PHPDoc type from phpstan-hover. +When called with RAW-PREFIX (for example, `C-u`), copy non-PHPDoc type." + (interactive "P") + (let ((prefer-phpdoc (or (null raw-prefix) (equal raw-prefix 0)))) + (if-let* ((hover-type + (and (bound-and-true-p phpstan-hover-mode) + (phpstan-hover-type-at-point prefer-phpdoc)))) + (progn + (kill-new hover-type) + (message "Copied %s" hover-type)) + (if phpstan--dumped-types + (let ((type (if (eq 1 (length phpstan--dumped-types)) + (cdar phpstan--dumped-types) + (let ((linum (line-number-at-pos))) + (cdar (seq-sort-by (lambda (elm) (abs (- linum (car elm)))) #'< phpstan--dumped-types)))))) + (kill-new type) + (message "Copied %s" type)) + (user-error "No dumped PHPStan types"))))) ;;;###autoload (defun phpstan-insert-dumptype (&optional expression prefix-num) From 31a973505d32510a02a342929cf89e475d1f2805 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 18 Feb 2026 13:17:32 +0900 Subject: [PATCH 8/9] Update README about phpstan-hover-mode --- README.org | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/README.org b/README.org index bbaf1e8..7b673e1 100644 --- a/README.org +++ b/README.org @@ -35,6 +35,29 @@ This package provides support for the [[https://phpstan.org/user-guide/editor-mo (add-hook 'php-mode-hook #'flymake-phpstan-turn-on) #+END_SRC +*** Hover type display without diagnostics (experimental) +If you want type-on-hover without adding diagnostics to Flycheck/Flymake, enable ~phpstan-hover-mode~. + +#+BEGIN_SRC emacs-lisp +(defun my-php-hover-setup () + (require 'phpstan-hover) + (phpstan-hover-mode 1)) + +(add-hook 'php-mode-hook #'my-php-hover-setup) +#+END_SRC + +By default, it tries ~posframe-show~ (GUI), then ~popup-tip~, and falls back to ~message~. + +You can customize hover behavior, for example: + +#+BEGIN_SRC emacs-lisp +(with-eval-after-load 'phpstan-hover + (setopt phpstan-hover-idle-delay 0.5) ;; Show popups more quickly than the default. + (setopt phpstan-hover-display-backend 'auto) ;; Auto-select from available popup backends. + (setopt phpstan-hover-message-prefix "🔖 ") ;; Use a shorter emoji prefix instead of "PHPStan: ". + (setopt phpstan-hover-show-kind-label t)) ;; Set nil to hide syntax labels in popup messages. +#+END_SRC + *** Using Docker (phpstan/docker-image) Install [[https://www.docker.com/get-started][Docker]] and [[https://github.com/phpstan/phpstan/pkgs/container/phpstan][phpstan/phpstan image]]. @@ -113,13 +136,34 @@ By default it inserts the tag on the previous line, but if there is already a ta If there is no existing tag and ~C-u~ is pressed before the command, it will be inserted at the end of the line. *** Command ~phpstan-copy-dumped-type~ -Copy the nearest dumped type message from PHPStan's output. +Copy a PHPStan type to the kill ring. + +When ~phpstan-hover-mode~ is enabled and hover data is available at point, this command prioritizes that type. + +- default :: Prefer PHPDoc type if available. +- with ~C-u~ :: Prefer non-PHPDoc type. + +If hover data is not available, it falls back to dumped messages from ~PHPStan\dumpType()~ / ~PHPStan\dumpPhpDocType()~ output. This command looks for messages like ~Dumped type: int|string|null~ reported by ~PHPStan\dumpType()~ or ~PHPStan\dumpPhpDocType()~, and copies the type string to the kill ring. If there are multiple dumped types in the buffer, it selects the one closest to the current line. If no dumped type messages are found, the command signals an error. +*** Minor mode ~phpstan-hover-mode~ +This feature is made possible by [[https://github.com/SanderRonde/phpstan-vscode][SanderRonde/phpstan-vscode]], and is extended in this package with additional hover integrations and behavior tuning. + +Enable this mode to show PHPStan type information at point without publishing diagnostics to Flycheck/Flymake. + +Behavior summary: +- Runs PHPStan on idle and caches hover type data per buffer/project. +- Uses Editor Mode for modified buffers when available. +- Displays with ~posframe~ / ~popup~ / ~message~ depending on + ~phpstan-hover-display-backend~. +- Hides popup on cursor move, window change, or buffer state change. + +This mode is independent of checker backends, so it can be used with Flycheck, Flymake, or without either. + ** API Most variables defined in this package are buffer local. If you want to set it for multiple projects, use [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Default-Value.html][setq-default]]. @@ -172,3 +216,64 @@ Determines whether PHPStan Editor Mode is available. *** Custom variable ~phpstan-docker-image~ Docker image URL or Docker Hub image name or NIL. Default as ~"ghcr.io/phpstan/phpstan"~. See [[https://phpstan.org/user-guide/docker][Docker - PHPStan Documentation]] and [[https://github.com/orgs/phpstan/packages/container/package/phpstan][GitHub Container Registory - Package phpstan]]. + +*** Custom variable ~phpstan-hover-idle-delay~ +Seconds to wait after idle before requesting hover data. + +*** Custom variable ~phpstan-hover-display-backend~ +Display backend for hover text. + +- ~'auto~ :: Try ~posframe-show~ (GUI), then ~popup-tip~, then ~message~. +- ~'posframe~ :: Use [[https://github.com/tumashu/posframe][posframe]] directly. +- ~'popup~ :: Use [[https://github.com/auto-complete/popup-el][popup.el]] directly. +- ~'message~ :: Use minibuffer message. + +*** Custom variable ~phpstan-hover-message-prefix~ +Prefix string shown before hover text. + +Built-in choices include: +- ~"PHPStan: "~ (default) +- ~"🔖 "~ + +*** Custom variable ~phpstan-hover-show-kind-label~ +Toggle verbose labels like ~return:~, ~yield:~, ~foo():~, and ~$var:~ in hover text. + +When nil, hover text shows only the type body. + +*** Custom variable ~phpstan-hover-debug~ +When non-nil, re-raise internal hover errors to show full backtraces. +** Copyright +This package is released under [[https://www.gnu.org/licenses/gpl-3.0.html][GPLv3]]. See [[LICENSE][~LICENSE~]] file. + +#+BEGIN_SRC +Copyright (C) 2026 Friends of Emacs-PHP development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +#+END_SRC +*** ~phpstan-hover-tree-fetcher.php~ +[[php/phpstan-hover-tree-fetcher.php][~phpstan-hover-tree-fetcher.php~]] is derived from [[https://github.com/SanderRonde/phpstan-vscode][SanderRonde/phpstan-vscode]]'s [[https://github.com/SanderRonde/phpstan-vscode/blob/v4.0.12/php/TreeFetcher.php][~TreeFetcher.php~]]. + +We are deeply grateful to Sander Ronde for publishing and maintaining the original implementation. + +This file is not relicensed under GPL by this project. The original MIT License continues to apply to ~php/phpstan-hover-tree-fetcher.php~, and we do not claim additional copyright over the upstream-derived portions. + +#+BEGIN_SRC +Copyright 2022 Sander Ronde (awsdfgvhbjn@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +#+END_SRC From e33fed5e7c352a6e334121b052d7c255ca7760b7 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 18 Feb 2026 13:18:01 +0900 Subject: [PATCH 9/9] Update CHANGELOG --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ffd34..450ad09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,25 @@ All notable changes of the `phpstan.el` are documented in this file using the [Keep a Changelog](https://keepachangelog.com/) principles. - +## Unreleased + +### Added + +* **[experimental]** Add new `phpstan-hover.el`. + * Add `phpstan-hover-mode` minor mode to show PHPStan type info at point without publishing diagnostics to Flycheck/Flymake. + * Hover support for variables, assignments, function/method/static calls, constants, class constants, `return`, `yield`, and `yield from`. + * PHPDoc type collection in hover data and display integration when available. + * Add `phpstan-hover-show-kind-label` custom variable to toggle verbose labels like `return:` / `yield:` in hover text. + * Add `phpstan-hover-message-prefix` custom variable preset choices, including emoji labels. +* `phpstan-copy-dumped-type` now prefers PHPDoc type from hover data by default, and can copy non-PHPDoc type with a prefix argument (C-u). + +### Changed + +* `phpstan-copy-dumped-type` command now prioritizes `phpstan-hover-mode` data at point before falling back to dumped-type messages. + +### Fixed + +* Fix `phpstan-get-command-args` to keep `:options` in the correct position and pass target arguments correctly when editor mode options are used. ## [0.9.0]