|
| 1 | +<?php declare(strict_types=1); |
| 2 | + |
| 3 | +namespace InteractionDesignFoundation\PsalmLaravelNova; |
| 4 | + |
| 5 | +use Psalm\Codebase; |
| 6 | +use Psalm\Internal\MethodIdentifier; |
| 7 | +use Psalm\Plugin\EventHandler\AfterCodebasePopulatedInterface; |
| 8 | +use Psalm\Plugin\EventHandler\Event\AfterCodebasePopulatedEvent; |
| 9 | +use Psalm\Storage\ClassLikeStorage; |
| 10 | +use Psalm\Storage\MethodStorage; |
| 11 | +use Psalm\Type\Atomic\TClosure; |
| 12 | +use Psalm\Type\Atomic\TNamedObject; |
| 13 | +use Psalm\Type\Union; |
| 14 | + |
| 15 | +/** |
| 16 | + * Narrows `canSee()`'s callback parameter to `NovaRequest` for every `Field`-derived class, without |
| 17 | + * touching `Tool`/`Dashboard`/`Filters\Filter`/`Menu\*`, which share the same `AuthorizedToSee` trait |
| 18 | + * but can receive a plain `Illuminate\Http\Request` at runtime (`BootTools` middleware). |
| 19 | + * |
| 20 | + * A stub file cannot do this: `canSee()` is declared only on the `AuthorizedToSee` trait, and |
| 21 | + * `FieldElement`/`Field`/every concrete field class only *inherit* it (no class in that chain |
| 22 | + * redeclares it). A plugin stub can override a method the stubbed class itself declares, and can |
| 23 | + * add a genuinely new one, but — confirmed empirically against real Nova 5.10.1, redeclaring |
| 24 | + * `canSee()` on `Element.phpstub` (which actually `use`s the trait) or on `FieldElement.phpstub` |
| 25 | + * (which merely inherits it) — it cannot override a method the class only inherits: Psalm's |
| 26 | + * `Methods::getMethodParams()` resolves the call through `getDeclaringMethodId()`, which reads |
| 27 | + * `declaring_method_ids['cansee']` off the *called* class's own storage; that entry still points at |
| 28 | + * `AuthorizedToSee`/`Element` regardless of what the stub adds, so the stub's declaration is simply |
| 29 | + * never consulted. `MethodParamsProviderInterface` cannot fill the gap either: Psalm keys it by the |
| 30 | + * exact called class (`Methods::getMethodParams()`, `AtomicMethodCallAnalyzer::$fq_class_name`), with |
| 31 | + * no hierarchy walk, so it would need to enumerate every concrete Field subclass — impossible for an |
| 32 | + * open, user-extensible hierarchy (the same reason `MethodParamsProviderInterface` was already ruled |
| 33 | + * out for resolving a resource's model, see `NovaResourceQueryMethodHandler`). |
| 34 | + * |
| 35 | + * What does work, because it operates on the same storage fields `getDeclaringMethodId()` actually |
| 36 | + * reads: post-populate, for every class extending `FieldElement`, point that class's own |
| 37 | + * `declaring_method_ids['cansee']` at itself and give it its own `methods['cansee']` entry — a |
| 38 | + * narrowed clone of whatever `AuthorizedToSee::canSee()` currently declares. This is exactly what a |
| 39 | + * real `public function canSee(...)` override on that class would produce in storage, just built |
| 40 | + * programmatically instead of textually. Classes outside the `FieldElement` hierarchy are never |
| 41 | + * touched, so `Tool::canSee(fn(Request $request): bool => true)` keeps type-checking and |
| 42 | + * `Tool::canSee(fn(NovaRequest $request): bool => true)` keeps being rejected. |
| 43 | + * @internal |
| 44 | + */ |
| 45 | +final class NovaFieldAuthorizationHandler implements AfterCodebasePopulatedInterface |
| 46 | +{ |
| 47 | + private const FIELD_ELEMENT = 'laravel\nova\fields\fieldelement'; |
| 48 | + |
| 49 | + private const CAN_SEE = 'cansee'; |
| 50 | + |
| 51 | + private const NOVA_REQUEST = 'Laravel\Nova\Http\Requests\NovaRequest'; |
| 52 | + |
| 53 | + #[\Override] |
| 54 | + public static function afterCodebasePopulated(AfterCodebasePopulatedEvent $event): void |
| 55 | + { |
| 56 | + $codebase = $event->getCodebase(); |
| 57 | + |
| 58 | + foreach ($codebase->classlike_storage_provider::getAll() as $storage) { |
| 59 | + $isFieldElement = mb_strtolower($storage->name) === self::FIELD_ELEMENT |
| 60 | + || isset($storage->parent_classes[self::FIELD_ELEMENT]); |
| 61 | + if (!$isFieldElement) { |
| 62 | + continue; |
| 63 | + } |
| 64 | + |
| 65 | + self::narrowCanSee($codebase, $storage); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + private static function narrowCanSee(Codebase $codebase, ClassLikeStorage $storage): void |
| 70 | + { |
| 71 | + $declaringId = $storage->declaring_method_ids[self::CAN_SEE] ?? null; |
| 72 | + if ($declaringId === null |
| 73 | + || mb_strtolower($declaringId->fq_class_name) === mb_strtolower($storage->name) |
| 74 | + || !$codebase->classlike_storage_provider->has($declaringId->fq_class_name) |
| 75 | + ) { |
| 76 | + // No canSee() to narrow, or the class already declares its own (leave user intent alone). |
| 77 | + return; |
| 78 | + } |
| 79 | + |
| 80 | + $declaringStorage = $codebase->methods->getStorage($declaringId); |
| 81 | + $narrowedCallback = self::narrowCallbackParam($declaringStorage); |
| 82 | + if ($narrowedCallback === null) { |
| 83 | + // Nova's canSee() shape changed in a way we don't recognise: silence over false positives. |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + $narrowed = clone $declaringStorage; |
| 88 | + $narrowed->params = [$narrowedCallback]; |
| 89 | + |
| 90 | + $selfId = new MethodIdentifier($storage->name, self::CAN_SEE); |
| 91 | + $storage->methods[self::CAN_SEE] = $narrowed; |
| 92 | + $storage->declaring_method_ids[self::CAN_SEE] = $selfId; |
| 93 | + $storage->appearing_method_ids[self::CAN_SEE] = $selfId; |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * `canSee(Closure $callback)`: rewrite the closure's own param type, not `$callback`'s. |
| 98 | + * @psalm-mutation-free |
| 99 | + */ |
| 100 | + private static function narrowCallbackParam(MethodStorage $canSee): ?\Psalm\Storage\FunctionLikeParameter |
| 101 | + { |
| 102 | + $callbackParam = $canSee->params[0] ?? null; |
| 103 | + if ($callbackParam === null) { |
| 104 | + return null; |
| 105 | + } |
| 106 | + |
| 107 | + $callbackType = $callbackParam->type; |
| 108 | + if ($callbackType === null) { |
| 109 | + return null; |
| 110 | + } |
| 111 | + |
| 112 | + $closure = $callbackType->getSingleAtomic(); |
| 113 | + if (!$closure instanceof TClosure || $closure->params === null || !isset($closure->params[0])) { |
| 114 | + return null; |
| 115 | + } |
| 116 | + |
| 117 | + $narrowedRequestParam = $closure->params[0]->setType(new Union([new TNamedObject(self::NOVA_REQUEST)])); |
| 118 | + $narrowedClosure = $closure->replace([$narrowedRequestParam], $closure->return_type); |
| 119 | + |
| 120 | + return $callbackParam->setType(new Union([$narrowedClosure])); |
| 121 | + } |
| 122 | +} |
0 commit comments