Reguły PHPStan dla Nette
Reguły PHPStan uczą PHPStan rozumieć kod Nette, dzięki czemu analiza statyczna wywodzi precyzyjne typy i zgłasza mniej fałszywych alarmów.
Wystarczy zainstalować rozszerzenie, a PHPStan rozpozna na przykład typ komponentu tam, gdzie wcześniej widział tylko błąd:
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this['menu']; // PHPStan wywodzi teraz MenuControl
$menu->setActive('home'); // żadnego ostrzeżenia o nieznanej metodzie
}
}
Instalacja
Rozszerzenie to bazuje na analizatorze statycznym PHPStan, który wykrywa błędy logiczne w Twoim kodzie, zanim go w ogóle uruchomisz. Jeśli jeszcze go nie używasz, zainstaluj go przez Composera:
composer require --dev phpstan/phpstan
Utwórz plik konfiguracyjny phpstan.neon podający katalogi do analizy i poziom reguł:
parameters:
paths:
- app
level: 8
PHPStan uruchamia się potem poleceniem:
vendor/bin/phpstan analyse
Wyczerpującą dokumentację znajdziesz na stronie PHPStan.
Następnie zainstaluj samo rozszerzenie:
composer require --dev nette/phpstan-rules
Wymagania: PHP 8.1 albo wyższe i PHPStan 2.2+.
Żeby PHPStan używał rozszerzenia, trzeba je aktywować. Albo zainstaluj phpstan/extension-installer, który zrobi to za Ciebie, albo dodaj
rozszerzenie ręcznie do swojego phpstan.neon:
includes:
- vendor/nette/phpstan-rules/extension.neon
Większość kontroli działa bez dalszego ustawiania. Tylko sekcja Assets potrzebuje w
phpstan.neon małego bloku konfiguracyjnego (opisanego niżej). Zwróć uwagę, że cała konfiguracja pokazana na
tej stronie należy do phpstan.neon, a nie do common.neon czy innych plików konfiguracyjnych Nette DI
Twojej aplikacji.
Natywne funkcje PHP
Wiele natywnych funkcji PHP deklaruje typ zwracany w rodzaju string|false albo array|null, choć
wartość błędu występuje tylko w warunkach, które w nowoczesnym kodzie praktycznie nie mogą nastąpić:
getcwd() zawodzące na rozsądnym systemie plików, json_encode() zawodzące bez
JSON_THROW_ON_ERROR, preg_split() zawodzące na wzorcu będącym stałą kompilacji i tak dalej.
Rozszerzenie usuwa z tych typów zwracanych niemożliwe części, więc PHPStan przestaje prosić Cię o obsługę błędów,
które nie mogą wystąpić.
Pełna lista jest w extension-php.neon.
Domknięcia walidujące typy w czasie działania
Powszechny idiom PHP do sprawdzania w czasie działania, że tablica zawiera elementy zadeklarowanego typu, używa otypowanego domknięcia wariadycznego wywoływanego operatorem spread:
/** @param string[] $items */
public function setItems(array $items): void
{
(function (string ...$items) {})(...$items);
}
PHP wymusza typ string na każdym argumencie spread i rzuca TypeError, jeśli któryś element nie
jest ciągiem. Ciało domknięcia jest puste, wyrażenie istnieje tylko dla swojego efektu ubocznego. PHPStan normalnie
zgłosiłby expr.resultUnused; ta reguła rozpoznaje wzorzec i milczy.
Application
W presenterach metody takie jak redirect(), forward() czy sendJson() kończą
działanie, rzucając Nette\Application\AbortException. Jeśli opakujesz takie wywołanie w try
i przechwycisz je szerokim catch (\Throwable) albo catch (\Exception), przypadkiem połkniesz
przekierowanie. Rozszerzenie Cię o tym ostrzeże:
try {
$this->redirect('Homepage:');
} catch (\Throwable $e) { // błąd: połyka AbortException
Debugger::log($e);
}
Rozwiązaniem jest ponowne rzucenie wyjątku albo wydzielenie go do osobnej gałęzi przed szerokim catchem:
try {
$this->redirect('Homepage:');
} catch (Nette\Application\AbortException $e) {
throw $e;
} catch (\Throwable $e) {
Debugger::log($e);
}
Assets
W phpstan.neon (nie w konfiguracji Nette DI) skonfiguruj mapowanie ID mapperów na klasy mapperów, żeby PHPStan
mógł zawęzić generyczny typ Asset do konkretnej klasy zasobu:
parameters:
nette:
assets:
mapping:
default: file # Nette\Assets\FilesystemMapper
images: file
vite: vite # Nette\Assets\ViteMapper
custom: App\MyMapper # dowolne FQCN
Wartości file i vite to skróty dla wbudowanych FilesystemMapper i
ViteMapper. Każda inna wartość traktowana jest jako w pełni kwalifikowana nazwa klasy własnego mappera.
Po skonfigurowaniu:
Registry::getMapper('vite')zwracaViteMapperzamiastMapper.Registry::getAsset('default:logo.png')zwracaImageAsset.tryGetAsset()zwracaImageAsset|null.FilesystemMapper::getAsset('button.js')iViteMapper::getAsset()są zawężane w ten sam sposób.
Component Model
Zawęża typ zwracany Container::getComponent() i Container::offsetGet() (czyli
$this['name']) na podstawie metod fabrykujących createComponent<Name>() zadeklarowanych w tej
samej klasie.
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this->getComponent('menu'); // MenuControl
$menu = $this['menu']; // MenuControl
}
}
Gdy nie istnieje pasująca fabryka albo nazwa komponentu nie jest ciągiem znanym w czasie kompilacji, typ zwracany
getComponent() i $this['name'] pozostaje bez zmian, czyli generyczny IComponent.
Dependency Injection
Właściwości oznaczone atrybutem #[Nette\DI\Attributes\Inject] wypełniane są przez dependency injection po
utworzeniu obiektu. PHPStan zgłosiłby je więc jako niezainicjalizowane; rozszerzenie traktuje je zamiast tego jako zapisane
i zainicjalizowane:
class HomePresenter extends Presenter
{
#[Inject]
public CartFacade $cart; // żadnego błędu o niezainicjalizowanej właściwości
}
Forms
Gdy $form->addText('name', …), $form->addSelect(…) i podobne wywoływane są w tej samej
funkcji albo metodzie co dostęp do $form['name'] (albo $form->getComponent('name')), rozszerzenie
wywodzi typ dostępu z odpowiadającego wywołania addXxx():
public function createComponentSignInForm(): Form
{
$form = new Form;
$form->addText('username', 'Username');
$form->addPassword('password', 'Password');
$form['username']; // TextInput
$form['password']; // TextInput (Password to podklasa)
return $form;
}
Dostęp działa też z metody innej niż ta, w której formularz został utworzony. Gdy budujesz go w fabryce
createComponentSignInForm() i sięgasz po jego elementy gdzie indziej, rozszerzenie śledzi przypisanie wstecz do
fabryki i znajduje pasujące wywołanie addXxx():
public function renderDefault(): void
{
$form = $this['signInForm']; // rozwiązuje createComponentSignInForm()
$form['username']; // TextInput
// bezpośredni dostęp w łańcuchu też działa
$this['signInForm']['username']; // TextInput
$this['signInForm-username']; // TextInput
}
Jeśli nie znajdzie pasującego wywołania addXxx(), rozszerzenie wraca do szukania fabryki
createComponent<Name>(), tak samo jak rozszerzenie Component Model.
Właściwości handlerów zdarzeń
Formularze konwertują dane na typ zadeklarowany w parametrze callbacku, czy to stdClass, array, czy
własne DTO. Callback, którego parametr danych jest węższy niż zadeklarowana unia array|object, jest więc w
czasie działania poprawny:
$form->onSuccess[] = function (Form $form, MyDto $data): void {
// …
};
PHPStan normalnie zgłosiłby assign.propertyType, bo MyDto jest węższe niż
array|object. Reguła tłumi ten błąd na Form::$onSuccess, $onError,
$onSubmit, $onRender, Container::$onValidate, SubmitButton::$onClick i
$onInvalidClick.
Schema
Zawęża typ zwracany Expect::array() z zadeklarowanej unii Structure|Type na podstawie
argumentu:
Expect::array(); // Type
Expect::array(['name' => Expect::string()]); // Structure (wszystkie wartości to Schema)
Expect::array(['name' => Expect::string(), 'x']); // Structure|Type (mieszanka Schema i nie-Schema)
Gdy argument miesza wartości Schema i nie-Schema, zadeklarowana unia zostaje zachowana.
Tester
PHPStan rozumie zawężanie typów po wywołaniach Tester\Assert. Wspierane metody: null(),
notNull(), true(), false(), truthy(), falsey(),
same(), notSame(), type().
function process(?User $user): void
{
Assert::notNull($user);
$user->getName(); // żadnego ostrzeżenia "called on null"
}
Funkcje strzałkowe jako callbacki void
test() i Assert::exception() Testera przyjmują callbacki otypowane jako
Closure(): void, ale często przekazuje się funkcje strzałkowe w rodzaju
fn () => throw new MyException. Funkcja strzałkowa zawsze ma wartość zwracaną, co PHPStan normalnie
oznaczyłby jako niezgodność typów. Reguła tłumi ten błąd dla następujących funkcji i metod: test(),
testException(), testNoError(), Tester\Assert::exception(),
Tester\Assert::throws(), Tester\Assert::error(), Tester\Assert::noError().
Utils
Strings::match() i matchAll(): dla stałego wzorca typ zwracany wywodzony jest bezpośrednio
z wyrażenia regularnego, czyli z jego grup przechwytujących (wraz z nazwanymi i opcjonalnymi). Flagi
captureOffset, unmatchedAsNull, a dla matchAll() także patternOrder i
lazy, odzwierciedlane są w wynikowym kształcie:
Strings::match($s, '#(\d+)-(\w+)#'); // array{non-falsy-string, decimal-int-string, non-empty-string}|null
Strings::match($s, '#(?<id>\d+)#'); // array{0: non-empty-string, id: decimal-int-string, 1: decimal-int-string}|null
Strings::matchAll($s, '#(\w+)#'); // list<array{string, non-empty-string}>
Dla wzorca niebędącego stałą (i dla metody split()) kształt wywodzony jest wyłącznie z flag.
Strings::replace(): gdy zamiennikiem jest callback, typ jego parametru $matches wywodzony jest
z tego samego wyrażenia regularnego:
Strings::replace($s, '#(\d+)#', function (array $m) {
return $m[1]; // $m jest typu array{non-empty-string, decimal-int-string}
});
Zawężanie badanego ciągu po match(): wewnątrz if (Strings::match($s, …)) badany ciąg
$s również zawężany jest na podstawie wzorca, na przykład do non-empty-string.
Walidacja wzorca: nieprawidłowe wyrażenie regularne przekazane do match(), matchAll(),
split() albo replace() zgłaszane jest podczas analizy zamiast w czasie działania.
Arrays::invoke() i Arrays::invokeMethod() zwracają tablicę typu zwracanego callable /
metody zamiast zadeklarowanego array.
Helpers::falseToNull() zawęża typ zwracany, usuwając false i dodając null.
string|false staje się więc string|null.
Magiczne metody Html: $el->setClass(…), $el->addData(…),
$el->getHref() i podobne rozwiązywane są bez adnotacji @method. setXxx() i
addXxx() zwracają static (fluent API), getXxx() zwraca mixed.