From ca33027408a444adc55f52536bf72824db992169 Mon Sep 17 00:00:00 2001 From: Roy de Vos Burchart Date: Fri, 12 Jun 2026 14:29:47 +0200 Subject: [PATCH 1/4] Add PHPStan extension for Macroable --- extension.neon | 5 + phpstan.dist.neon | 3 + src/PHPStan/Macro.php | 106 +++++++++++++ .../MacroMethodsClassReflectionExtension.php | 142 ++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 extension.neon create mode 100644 src/PHPStan/Macro.php create mode 100644 src/PHPStan/MacroMethodsClassReflectionExtension.php diff --git a/extension.neon b/extension.neon new file mode 100644 index 00000000..d6957fe6 --- /dev/null +++ b/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Saloon\PHPStan\MacroMethodsClassReflectionExtension + tags: + - phpstan.broker.methodsClassReflectionExtension diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2d8200a1..aabeb1fc 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -19,6 +19,9 @@ parameters: ignoreErrors: - "#^Unsafe usage of new static\\(\\)\\.$#" + excludePaths: + - '*/src/PHPStan/*' + # Rules treatPhpDocTypesAsCertain: false diff --git a/src/PHPStan/Macro.php b/src/PHPStan/Macro.php new file mode 100644 index 00000000..684a1f63 --- /dev/null +++ b/src/PHPStan/Macro.php @@ -0,0 +1,106 @@ +classReflection; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isStatic(): bool + { + return $this->isStatic; + } + + /** + * Set the is static value. + */ + public function setIsStatic(bool $isStatic): void + { + $this->isStatic = $isStatic; + } + + public function getDocComment(): string|null + { + return null; + } + + public function getName(): string + { + return $this->methodName; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** @inheritDoc */ + public function getVariants(): array + { + return [ + new FunctionVariant(TemplateTypeMap::createEmpty(), null, $this->closureType->getParameters(), $this->closureType->isVariadic(), $this->closureType->getReturnType()), + ]; + } + + public function getDeprecatedDescription(): string|null + { + return null; + } + + public function getThrowType(): null + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } +} diff --git a/src/PHPStan/MacroMethodsClassReflectionExtension.php b/src/PHPStan/MacroMethodsClassReflectionExtension.php new file mode 100644 index 00000000..a5fb41c0 --- /dev/null +++ b/src/PHPStan/MacroMethodsClassReflectionExtension.php @@ -0,0 +1,142 @@ + */ + private array $methods = []; + + /** @var array> */ + private array $traitCache = []; + + public function __construct(private ReflectionProvider $reflectionProvider, private ClosureTypeFactory $closureTypeFactory) + { + } + + /** + * @throws ReflectionException + * @throws ShouldNotHappenException + * @throws MissingMethodFromReflectionException + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + /** @var class-string[] $classNames */ + $classNames = []; + $found = false; + $macroTraitProperty = null; + + if ($this->hasIndirectTraitUse($classReflection, Macroable::class)) { + $classNames = [$classReflection->getName()]; + $macroTraitProperty = 'macros'; + } + + if ($classNames !== [] && $macroTraitProperty) { + foreach ($classNames as $className) { + $macroClassReflection = $this->reflectionProvider->getClass($className); + + if (! $macroClassReflection->getNativeReflection()->hasProperty($macroTraitProperty)) { + continue; + } + + $refProperty = $macroClassReflection->getNativeReflection()->getProperty($macroTraitProperty); + + $found = array_key_exists($methodName, $refProperty->getValue()); + + if (! $found) { + continue; + } + + $macroDefinition = $refProperty->getValue()[$methodName]; + + if (is_string($macroDefinition)) { + if (str_contains($macroDefinition, '::')) { + $macroDefinition = explode('::', $macroDefinition, 2); + $macroClassName = $macroDefinition[0]; + if (! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]); + } elseif (is_callable($macroDefinition)) { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject(Closure::fromCallable($macroDefinition)), + ); + } else { + throw new ShouldNotHappenException('Function ' . $macroDefinition . ' does not exist'); + } + } elseif (is_array($macroDefinition)) { + if (is_string($macroDefinition[0])) { + $macroClassName = $macroDefinition[0]; + } else { + $macroClassName = get_class($macroDefinition[0]); + } + + if ($macroClassName === false || ! $this->reflectionProvider->hasClass($macroClassName) || ! $this->reflectionProvider->getClass($macroClassName)->hasNativeMethod($macroDefinition[1])) { + throw new ShouldNotHappenException('Class ' . $macroClassName . ' does not exist'); + } + + $methodReflection = $this->reflectionProvider->getClass($macroClassName)->getNativeMethod($macroDefinition[1]); + } else { + $methodReflection = new Macro( + $macroClassReflection, + $methodName, + $this->closureTypeFactory->fromClosureObject($macroDefinition), + ); + + $methodReflection->setIsStatic(true); + } + + $this->methods[$classReflection->getName() . '-' . $methodName] = $methodReflection; + + break; + } + } + + return $found; + } + + public function getMethod( + ClassReflection $classReflection, + string $methodName, + ): MethodReflection { + return $this->methods[$classReflection->getName() . '-' . $methodName]; + } + + private function hasIndirectTraitUse(ClassReflection $class, string $traitName): bool + { + $className = $class->getName(); + + if (array_key_exists($className, $this->traitCache) && array_key_exists($traitName, $this->traitCache[$className])) { + return $this->traitCache[$className][$traitName]; + } + + $this->traitCache[$className][$traitName] = in_array($traitName, array_keys($class->getTraits(true)), true); + + return $this->traitCache[$className][$traitName]; + } +} From 49c5506c11ed3c4182f5a079443c8226809dc5df Mon Sep 17 00:00:00 2001 From: Roy de Vos Burchart Date: Fri, 12 Jun 2026 14:15:39 +0200 Subject: [PATCH 2/4] Typehint $this in macro closures --- src/Traits/Macroable.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Traits/Macroable.php b/src/Traits/Macroable.php index bb49bb3d..55f10498 100644 --- a/src/Traits/Macroable.php +++ b/src/Traits/Macroable.php @@ -25,6 +25,8 @@ trait Macroable /** * Create a macro + * + * @param-closure-this static $macro */ public static function macro(string $name, object|callable $macro): void { From 1a6652eb87556ec4a163248fad01fb0427db4787 Mon Sep 17 00:00:00 2001 From: Roy de Vos Burchart Date: Fri, 12 Jun 2026 14:23:00 +0200 Subject: [PATCH 3/4] Add extension to composer.json --- composer.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/composer.json b/composer.json index 4626f630..5f77e5a7 100644 --- a/composer.json +++ b/composer.json @@ -75,5 +75,12 @@ "pstan": [ "./vendor/bin/phpstan analyse --memory-limit=1G" ] + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } } } From c3eba0a66756e5273167a078d8f3137b0181d625 Mon Sep 17 00:00:00 2001 From: Roy de Vos Burchart Date: Fri, 12 Jun 2026 14:32:31 +0200 Subject: [PATCH 4/4] Add docblock --- src/PHPStan/MacroMethodsClassReflectionExtension.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/PHPStan/MacroMethodsClassReflectionExtension.php b/src/PHPStan/MacroMethodsClassReflectionExtension.php index a5fb41c0..0fea6b59 100644 --- a/src/PHPStan/MacroMethodsClassReflectionExtension.php +++ b/src/PHPStan/MacroMethodsClassReflectionExtension.php @@ -24,6 +24,11 @@ use PHPStan\Reflection\MethodsClassReflectionExtension; use PHPStan\Reflection\MissingMethodFromReflectionException; +/** + * Many thanks to Larastan for building this excellent extension. + * + * @see https://github.com/larastan/larastan/blob/3.x/src/Methods/MacroMethodsClassReflectionExtension.php + */ class MacroMethodsClassReflectionExtension implements MethodsClassReflectionExtension { /** @var array */