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') zwraca ViteMapper zamiast Mapper.
  • Registry::getAsset('default:logo.png') zwraca ImageAsset. tryGetAsset() zwraca ImageAsset|null.
  • FilesystemMapper::getAsset('button.js') i ViteMapper::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.