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]
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
diff --git a/php/phpstan-hover-tree-fetcher.php b/php/phpstan-hover-tree-fetcher.php
new file mode 100644
index 0000000..709839d
--- /dev/null
+++ b/php/phpstan-hover-tree-fetcher.php
@@ -0,0 +1,684 @@
+ $target) {
+ return $mid;
+ }
+ $left = $mid + 1;
+ } else {
+ $right = $mid - 1;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * @param array>> $fileDatas
+ * @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);
+ if (!is_string($file)) {
+ continue;
+ }
+ $results[$filePath] = [];
+
+ $lineOffsets = [0];
+ for ($i = 0; $i < strlen($file); $i++) {
+ if ($file[$i] === "\n") {
+ $lineOffsets[] = $i + 1;
+ }
+ }
+
+ $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']);
+ $entry = [
+ 'typeDescr' => $datum['typeDescr'],
+ 'name' => $datum['name'],
+ 'kind' => $datum['kind'] ?? null,
+ 'pos' => [
+ 'start' => $findPos($datum['pos']['start']),
+ 'end' => [
+ 'line' => $endPos['line'],
+ 'char' => $endPos['char'],
+ ],
+ ],
+ ];
+ if (isset($datum['phpDocType']) && is_string($datum['phpDocType'])) {
+ $entry['phpDocType'] = $datum['phpDocType'];
+ }
+ $results[$filePath][] = $entry;
+ }
+ }
+ }
+
+ return $results;
+ }
+
+ /** @param CollectedDataNode $node */
+ public function processNode(Node $node, Scope $scope): array {
+ $collectedData = $node->get(PHPStanEmacsHoverTreeFetcherCollector::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 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 = [];
+
+ /**
+ * @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) {
+ $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 processFunctionScope(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);
+ }
+
+ /** @var list */
+ private $visitedClosures = [];
+
+ /**
+ * @param list $closures
+ * @return list
+ */
+ private function processClosureScope(Scope $scope, array $closures): array {
+ $functionKey = implode('.', [
+ $scope->getFile(),
+ $scope->getClassReflection() ? $scope->getClassReflection()->getName() : null,
+ json_encode($closures),
+ ]);
+ if (in_array($functionKey, $this->visitedClosures, true)) {
+ return [];
+ }
+ $this->visitedClosures[] = $functionKey;
+
+ $lastClosure = end($closures);
+ /** @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 $data */
+ $data = [];
+ $this->processClosures($node, $scope);
+
+ if ($scope->getFunctionName()) {
+ $data = array_merge($data, $this->processFunctionScope($scope));
+ }
+
+ $closures = $this->getClosuresFromScope($scope);
+ if ($closures) {
+ $data = array_merge($data, $this->processClosureScope($scope, $closures));
+ }
+
+ return $data;
+ }
+
+ 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;
+ if (!is_string($varName)) {
+ return null;
+ }
+
+ if ($node->getStartFilePos() === -1 || $node->getEndFilePos() === -1) {
+ return null;
+ }
+
+ 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 */
+ 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 array_merge($this->describeTypes($type), [
+ '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 array_merge($this->describeTypes($type), [
+ '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 array_merge($this->describeTypes($type), [
+ '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 array_merge($this->describeTypes($type), [
+ '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 ?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 */
+ $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;
+ }
+
+ $typeData = $this->describeTypes($parameter->getType());
+ if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) {
+ continue;
+ }
+
+ $data[] = array_merge($typeData, [
+ 'name' => $parameter->getName(),
+ 'pos' => [
+ 'start' => $paramNode->getStartFilePos(),
+ 'end' => $paramNode->getEndFilePos() + 1,
+ ],
+ ]);
+ }
+
+ return $data;
+ }
+
+ /** @return 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;
+ }
+
+ $typeData = $this->describeTypes($parameter->getType());
+ if ($paramNode->getStartFilePos() === -1 || $paramNode->getEndFilePos() === -1) {
+ continue;
+ }
+
+ $data[] = array_merge($typeData, [
+ 'name' => $parameter->getName(),
+ 'pos' => [
+ 'start' => $paramNode->getStartFilePos(),
+ 'end' => $paramNode->getEndFilePos() + 1,
+ ],
+ ]);
+ }
+ }
+
+ return $data;
+ }
+
+ /** @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) {
+ $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;
+ }
+ }
+ if ($valueVar && $valueVar instanceof Variable) {
+ $nodeWithType = $this->processNodeWithType($valueVar, $exprType->getItemType());
+ if ($nodeWithType) {
+ $data[] = $nodeWithType;
+ }
+ }
+ }
+ }
+
+ if ($node instanceof Assign) {
+ $assignNodeData = $this->processAssignNode($node, $scope);
+ if ($assignNodeData) {
+ $data[] = $assignNodeData;
+ }
+ }
+
+ 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) {
+ $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');
+ if ($parent && $parent instanceof Assign) {
+ $type = $scope->getType($parent->expr);
+ }
+ if (!($type instanceof ErrorType)) {
+ $nodeWithType = $this->processNodeWithType($node, $type);
+ if ($nodeWithType) {
+ $data[] = $nodeWithType;
+ }
+ }
+ }
+
+ 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;
+ }
+ return $data;
+ }
+}
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
diff --git a/phpstan.el b/phpstan.el
index 680561d..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
@@ -486,6 +488,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))
@@ -651,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)